diff --git a/.github/workflows/ci-merge.yml b/.github/workflows/ci-merge.yml index 8119dc4a3..0b8966424 100644 --- a/.github/workflows/ci-merge.yml +++ b/.github/workflows/ci-merge.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20.x] + node-version: [22.x] steps: - uses: actions/checkout@v2 - name: Enable Corepack @@ -33,9 +33,9 @@ jobs: - name: Process Test Results uses: dorny/test-reporter@v1 with: - name: Jest Tests + name: Unit Tests path: "junit.xml" - reporter: jest-junit + reporter: java-junit - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 with: diff --git a/.github/workflows/ci-pr-report.yml b/.github/workflows/ci-pr-report.yml index 13a01b7d9..d208d4242 100644 --- a/.github/workflows/ci-pr-report.yml +++ b/.github/workflows/ci-pr-report.yml @@ -16,9 +16,9 @@ jobs: uses: dorny/test-reporter@v1 with: artifact: test-results - name: Jest Tests + name: Unit Tests path: 'junit.xml' - reporter: jest-junit + reporter: java-junit coverage-report: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/ci-pr-test.yml b/.github/workflows/ci-pr-test.yml index ec05209d7..4dbb719d4 100644 --- a/.github/workflows/ci-pr-test.yml +++ b/.github/workflows/ci-pr-test.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [22.x] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 7ffe2c95d..0eef18b83 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ lib-cov coverage *.lcov +# Vitest junit reporter output (yarn test:ci) +junit.xml + # nyc test coverage .nyc_output @@ -111,4 +114,5 @@ dist /docs # .yarn meta -.yarn \ No newline at end of file +.yarn +.claude/settings.local.json diff --git a/README.md b/README.md index 01b73e895..ff6c9aaa2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ Rapid Enterprise Development Toolkit A collection of NestJS modules that were created for the rapid development of enterpise level APIs. +The v8 line is schema-first — native Zod v4 / Standard Schema validation, +serialization, and OpenAPI — on NestJS 12, ESM-only, Node >= 22. + All reasonable efforts have been made to provide loosely coupled interfaces, overridable services, and sane default implementations. @@ -33,21 +36,18 @@ once we have finalized our Contributor License Agreement. ## Modules -| Module | Summary | -| -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| [nestjs-access-control](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-access-control 'nestjs-access-control') | Advanced access control guard for NestJS with optional per-request filtering. | -| [nestjs-auth-github](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-auth-github 'nestjs-auth-github') | Authenticate requests using GitHub oAuth2 sign-on. | -| [nestjs-auth-jwt](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-auth-jwt 'nestjs-auth-jwt') | Authenticate requests using JWT tokens passed via the request (headers, cookies, body, query, etc). | -| [nestjs-auth-local](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-auth-local 'nestjs-auth-local') | Authenticate requests using username/email and password against a local or remote data source. | -| [nestjs-auth-refresh](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-auth-refresh 'nestjs-auth-refresh') | Authenticate requests using JWT refresh tokens passed via the request (headers, cookies, body, query, etc). | -| [nestjs-authentication](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-authentication 'nestjs-authentication') | Authenticate requests using one or more strategies (local, jwt, etc). | -| [nestjs-common](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-common 'nestjs-common') | The common module is a dependency of all Rockets modules. | -| [nestjs-crud](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-crud 'nestjs-crud') | Extremely powerful CRUD module that is an extension/wrapper of the popular @nestjsx/crud module. | -| [nestjs-email](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-email 'nestjs-email') | Email deliver module that supports most popular transports, as well as template based email bodies using handlebars syntax. | -| [nestjs-event](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-event 'nestjs-event') | Advanced class based event dispatch/listener module. | -| [nestjs-jwt](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-jwt 'nestjs-jwt') | A flexible JWT utilities module for signing and validating tokens. | -| [nestjs-logger](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-logger 'nestjs-logger') | Drop-in replacement for the core NestJS logger that provides additonal support for pushing log data to external log providers. | -| [nestjs-password](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-password 'nestjs-password') | A flexible Password utilities module that provides services for password strength, creation and storage. | -| [nestjs-swagger-ui](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-swagger-ui 'nestjs-swagger-ui') | Expose your OpenApi spec on your API using the powerful Swagger UI interface. | -| [nestjs-typeorm-ext](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-typeorm-ext 'nestjs-typeorm-ext') | Extension of the NestJS TypeOrm module that allows your dynamic modules to accept drop-in replacements of custom entities and repositories. | -| [nestjs-user](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-user 'nestjs-user') | A module for managing a basic User entity, including controller with full CRUD, DTOs, sample data factory and seeder. | +| Module | Summary | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| [nestjs-core](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-core 'nestjs-core') | Core framework module providing the app context system, DDD base classes, and shared utilities for Rockets modules. | +| [nestjs-repository](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-repository 'nestjs-repository') | Abstract repository adapter layer with transaction management, federation, and repository hook support. | +| [nestjs-repository-typeorm](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-repository-typeorm 'nestjs-repository-typeorm') | TypeORM driver for nestjs-repository with entity base classes and where-clause translation. | +| [nestjs-crud](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-crud 'nestjs-crud') | Powerful CRUD module with full DDD/CQRS integration, configurable operations, and optional OpenAPI documentation. | +| [nestjs-cache](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-cache 'nestjs-cache') | Cache management module using DDD/CQRS patterns with pluggable storage and HTTP CRUD gateway. | +| [nestjs-otp](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-otp 'nestjs-otp') | One-time password module supporting multiple OTP categories with rate limiting and expiry management. | +| [nestjs-role](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-role 'nestjs-role') | Role and role-assignment management module with DDD/CQRS and HTTP CRUD gateway. | +| [nestjs-password](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-password 'nestjs-password') | Password utilities module providing strength validation, hashing, and storage via configurable policy services. | +| [nestjs-user](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-user 'nestjs-user') | User entity management module with DDD/CQRS, password integration, and optional HTTP CRUD gateway. | +| [nestjs-invitation](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-invitation 'nestjs-invitation') | Invitation workflow module handling token generation, delivery, acceptance, and revocation via DDD/CQRS. | +| [nestjs-federated](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-federated 'nestjs-federated') | Federated (OAuth) identity linking module that maps external provider identities to local user accounts. | +| [nestjs-authentication](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-authentication 'nestjs-authentication') | Full-featured authentication module (JWT, local, refresh, recovery, verify, OAuth router) using DDD and CQRS. | +| [nestjs-access-control](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-access-control 'nestjs-access-control') | Advanced access control guard with role-based grants and optional per-request response attribute filtering. | diff --git a/TODOs.md b/TODOs.md new file mode 100644 index 000000000..0c810cfca --- /dev/null +++ b/TODOs.md @@ -0,0 +1,37 @@ +# Current scope + * `@concepta/nestjs-common` is deprecated — reverted to v7 line (`7.0.0-alpha.10`) and excluded from the v8 workspace. Its remaining v8-only symbols were merged into nestjs-core. Run `npm deprecate @concepta/nestjs-common@8.0.0-alpha.6` at publish time. + * Non-v8 packages are excluded from the Yarn workspace (see `workspaces` in root `package.json`) until they are migrated to the DDD pattern and NestJS 12: nestjs-email, nestjs-event, nestjs-logger, nestjs-auth-github, nestjs-org, nestjs-swagger-ui, nestjs-auth-google, nestjs-logger-coralogix, nestjs-logger-sentry, nestjs-file, nestjs-auth-apple, nestjs-report, nestjs-samples. Also removed `@concepta/nestjs-email` from devDependencies in nestjs-invitation and nestjs-authentication (only used in e2e tests — restore when nestjs-email is migrated). + +# Ranked backlog + +Single priority order across everything below (Fable review, 2026-08-30), ranked by +(impact of leaving it undone) vs (effort × blast radius) — not grouped by the old +Critical/High/Nice-To-Have labels, which were rough guesses and sometimes wrong. Effort +tags: S/M/L. Completed items are removed from this list rather than marked done — see +git history for what shipped. + + 1. **[S] Add an ESLint `import/extensions` rule** — belt-and-suspenders guard for the + `nodenext` `.js`-extension requirement on relative imports (`eslint-plugin-import` + is already a configured dependency, no conflicting rule exists). Not essential — + `tsc` itself already makes a missing extension a hard `TS2835` compile error. Do + opportunistically. + + 2. **[needs research first] Optional exports patterns are different across the + modules** — user confirmed no canonical pattern has been chosen yet; needs research + into the existing per-module variations before a target shape can even be proposed. + Not a quick win. + + 3. **Tutorial Topics** — Support of the minimum interface; Provider Overrides. Docs + work; sequence after the API stabilizes. + + 4. **When non-v8 packages are migrated to NestJS 12** — not actionable until triggered. + Full restore checklist per package: + 1. Root `package.json` `workspaces` array — add dir (or revert to glob `packages/*` + when all are migrated) + 2. Root `tsconfig.json` `references` — add `{ "path": "packages/" }` (this + alone drives both the `tsc -b` ESM build and the type-check gate — the build is + solution-file-driven) + 3. `vitest.config.ts` `test.include` — add `"packages//**/*.spec.ts"` + 4. `vitest.config-e2e.ts` `test.include` — add `"packages//**/*.e2e-spec.ts"` + 5. Restore `@concepta/nestjs-email` to nestjs-authentication and nestjs-invitation + devDependencies once nestjs-email is migrated. diff --git a/eslint.config.mjs b/eslint.config.mjs index 5a83be058..1f9b67ba3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,28 @@ import importPlugin from 'eslint-plugin-import'; import tsdocPlugin from 'eslint-plugin-tsdoc'; import jsdocPlugin from 'eslint-plugin-jsdoc'; +// The 13 packages migrated to v8 (nodenext, DDD) — same set as root +// tsconfig.json's `references` and tsconfig.eslint.json's `include`. Only +// these get type-aware linting; older packages predate the migration and +// aren't part of the tsconfig project-reference graph the parser resolves +// against, so linting them with a `project` would fail to find the file. +const v8Packages = [ + 'nestjs-core', + 'nestjs-repository', + 'nestjs-repository-typeorm', + 'nestjs-crud', + 'nestjs-cache', + 'nestjs-otp', + 'nestjs-role', + 'nestjs-password', + 'nestjs-user', + 'nestjs-invitation', + 'nestjs-federated', + 'nestjs-authentication', + 'nestjs-access-control', +]; +const v8Files = v8Packages.map((name) => `packages/${name}/src/**/*.ts`); + export default tseslint.config( // Ignore patterns { @@ -19,14 +41,13 @@ export default tseslint.config( ], }, - // Extend @concepta/eslint-config/nest (filter out undefined configs) - ...conceptaConfig.filter(config => config !== undefined), - - // JSDoc recommended config - jsdocPlugin.configs['flat/recommended-typescript'], - - // Project-specific overrides + // Type-aware rules, scoped to the migrated v8 packages { + files: v8Files, + extends: [ + ...conceptaConfig.filter((config) => config !== undefined), + jsdocPlugin.configs['flat/recommended-typescript'], + ], languageOptions: { parserOptions: { project: './tsconfig.eslint.json', @@ -82,14 +103,16 @@ export default tseslint.config( // JSDoc/TSDoc rules 'jsdoc/tag-lines': ['error', 'any', { startLines: 1 }], + // Disable nested param checking since TSDoc doesn't support dot notation + 'jsdoc/check-param-names': ['warn', { checkDestructured: false }], 'tsdoc/syntax': 'error', - }, - }, - // TypeScript files override - { - files: ['**/*.ts'], - rules: { + // ESM tree-shaking: enforce `import type` for type-only imports + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, + ], + 'jsdoc/require-jsdoc': 'off', 'jsdoc/require-param': 'off', 'jsdoc/require-returns': 'off', @@ -98,7 +121,10 @@ export default tseslint.config( // Spec and fixture files override { - files: ['**/*.spec.ts', '**/*.fixture.ts'], + files: v8Packages.flatMap((name) => [ + `packages/${name}/src/**/*.spec.ts`, + `packages/${name}/src/**/*.fixture.ts`, + ]), rules: { '@darraghor/nestjs-typed/controllers-should-supply-api-tags': 'off', '@darraghor/nestjs-typed/api-method-should-specify-api-response': 'off', @@ -106,4 +132,24 @@ export default tseslint.config( 'tsdoc/syntax': 'off', }, }, + + // `causal-context` is framework-agnostic by design — forbid framework + // imports here so the boundary can't silently erode. + { + files: ['packages/nestjs-core/src/domain/events/causal-context/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@nestjs/*', '@nestjs/**', '@concepta/*', '@concepta/**'], + message: + 'causal-context is framework-agnostic — framework imports belong in the adapter layer (domain/events/*.ts, infrastructure/context/*.ts), not here.', + }, + ], + }, + ], + }, + }, ); diff --git a/jest.config-e2e.json b/jest.config-e2e.json deleted file mode 100644 index 77a81de12..000000000 --- a/jest.config-e2e.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "globals": { - "ts-jest": { - "tsconfig": "tsconfig.jest.json" - } - }, - "testEnvironment": "node", - "testRegex": ".*\\.e2e-spec\\.ts$", - "testPathIgnorePatterns": [ - "/node_modules/", - "/dist/" - ], - "transform": { - "^.+\\.ts$": "ts-jest" - } -} \ No newline at end of file diff --git a/jest.config.json b/jest.config.json deleted file mode 100644 index 310a56199..000000000 --- a/jest.config.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "globals": { - "ts-jest": { - "tsconfig": "tsconfig.jest.json" - } - }, - "setupFilesAfterEnv": ["/jest.setup.js", "jest-extended/all"], - "coverageThreshold": { - "global": { - "branches": 0, - "functions": 0, - "lines": 0, - "statements": 0 - } - }, - "testRegex": ".*\\.spec\\.ts$", - "testPathIgnorePatterns": ["/node_modules/", "/dist/"], - "transform": { - "^.+\\.ts$": "ts-jest" - }, - "collectCoverageFrom": [ - "packages/**/*.ts", - "!packages/**/*.d.ts", - "!packages/**/*.interface.ts", - "!packages/**/*.e2e-spec.ts", - "!packages/**/*.factory.ts", - "!packages/**/*.seeder.ts", - "!packages/**/*.seeding.ts", - "!packages/nestjs-samples/src/**/main.ts", - "!**/node_modules/**", - "!**/__mocks__/**", - "!**/__stubs__/**", - "!**/__fixtures__/**" - ], - "coverageDirectory": "coverage", - "coverageReporters": [ - "text", - "text-summary", - "json", - "json-summary", - "lcovonly" - ], - "testEnvironment": "node" -} \ No newline at end of file diff --git a/jest.setup.js b/jest.setup.js deleted file mode 100644 index 27e28e73d..000000000 --- a/jest.setup.js +++ /dev/null @@ -1,3 +0,0 @@ -// Make Node.js crypto module available globally for tests -// This is needed for @nestjs/typeorm v11 which expects crypto to be global -global.crypto = require('crypto'); diff --git a/package.json b/package.json index 765d54d2e..b1a2cc0f5 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,24 @@ "license": "BSD-3-Clause", "private": true, "engines": { - "node": ">=20.0.0", + "node": ">=22.12.0", "yarn": ">=4.0.0" }, - "workspaces": { - "packages": [ - "packages/*" - ] - }, + "workspaces": [ + "packages/nestjs-core", + "packages/nestjs-repository", + "packages/nestjs-repository-typeorm", + "packages/nestjs-crud", + "packages/nestjs-cache", + "packages/nestjs-otp", + "packages/nestjs-role", + "packages/nestjs-password", + "packages/nestjs-user", + "packages/nestjs-invitation", + "packages/nestjs-federated", + "packages/nestjs-authentication", + "packages/nestjs-access-control" + ], "devDependencies": { "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", @@ -19,16 +29,16 @@ "@concepta/prettier-config": "2.0.0-alpha.4", "@darraghor/eslint-plugin-nestjs-typed": "^6.9.3", "@eslint/js": "^9.39.1", - "@nestjs/cli": "^11.0.10", - "@nestjs/schematics": "^11.0.9", - "@nestjs/testing": "^11.1.9", + "@nestjs/cli": "^12.0.0", + "@nestjs/platform-express": "^12.0.1", + "@nestjs/schematics": "^12.0.0", + "@nestjs/testing": "^12.0.1", "@types/express": "^4.17.21", "@types/jest": "^27.5.2", "@types/node": "^20.19.25", "@types/nodemailer": "^6.4.15", "@types/supertest": "^6.0.3", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -37,9 +47,6 @@ "eslint-plugin-tsdoc": "^0.5.0", "globals": "^16.5.0", "husky": "^7.0.4", - "jest": "30.2.0", - "jest-junit": "^13.2.0", - "jest-mock-extended": "^4.0.0", "jsonc-eslint-parser": "^2.4.1", "lerna": "^3.22.1", "markdownlint-cli": "^0.41.0", @@ -49,20 +56,18 @@ "rxjs": "^7.8.1", "standard-version": "^9.5.0", "supertest": "^6.3.4", - "ts-jest": "^29.4.5", - "ts-loader": "^9.5.4", - "ts-node": "^10.9.2", - "tsconfig-paths": "^3.15.0", "typedoc": "^0.25.13", "typedoc-plugin-coverage": "^3.3.0", - "typeorm": "^0.3.27", - "typescript": "^4.9.5", - "typescript-eslint": "^8.46.4" + "typeorm": "^0.3.28", + "typescript": "^5.8.0", + "typescript-eslint": "^8.46.4", + "vitest": "^4.1.9", + "vitest-mock-extended": "^4.0.0" }, "scripts": { "postinstall": "husky install", - "clean": "./node_modules/.bin/rimraf packages/*/dist packages/*/tsconfig.tsbuildinfo docs", - "build": "./node_modules/.bin/tsc --build", + "clean": "./node_modules/.bin/rimraf packages/*/dist docs", + "build": "./node_modules/.bin/tsc -b", "prepublish": "yarn clean && yarn build", "watch": "yarn build && ./node_modules/.bin/tsc --build --watch", "lint": "eslint \"packages/*/src/**/*.{ts,js,json}\"", @@ -70,13 +75,14 @@ "lint:md": "markdownlint README.md packages/**/*.md", "lint:md:fix": "yarn lint:md --fix", "lint:all": "yarn lint && yarn lint:md", - "test": "jest --testTimeout 30000", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:ci": "yarn test:cov --ci --reporters=default --reporters=jest-junit", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./jest.config-e2e.json --testTimeout 30000", + "test": "vitest run", + "test:watch": "vitest", + "test:cov": "vitest run --coverage", + "test:ci": "vitest run --coverage --reporter=default --reporter=junit --outputFile.junit=./junit.xml", + "test:debug": "vitest --inspect-brk --no-file-parallelism", + "test:e2e": "vitest run --config ./vitest.config-e2e.ts", "test:all": "yarn test && yarn test:e2e", + "smoke": "node ./scripts/smoke-test.mjs", "doc": "rimraf ./docs && typedoc", "doc:cov": "yarn doc --coverageOutputType all", "changelog": "standard-version", diff --git a/packages/nestjs-access-control/README.md b/packages/nestjs-access-control/README.md index 59c8314b8..e7e5afaae 100644 --- a/packages/nestjs-access-control/README.md +++ b/packages/nestjs-access-control/README.md @@ -5,10 +5,10 @@ Advanced access control guard for NestJS with optional per-request filtering. ## Project [![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-access-control)](https://www.npmjs.com/package/@concepta/nestjs-access-control) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-access-control)](https://www.npmjs.com/package/@concepta/nestjs-access-control) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-access-control)](https://www.npmjs.com/package/@concepta/nestjs-access-control) [![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) [![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-access-control%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) # Table of Contents @@ -28,6 +28,8 @@ Advanced access control guard for NestJS with optional per-request filtering. - [Using Dependency in Access Query Service](#using-dependency-in-access-query-service) - [Disable AccessControlGuard](#disable-accesscontrolguard) - [Create a custom AccessControlGuard](#create-a-custom-accesscontrolguard) + - [Filtering response attributes](#filtering-response-attributes) + - [Override the access-check / filter / role-resolution via the port](#override-the-access-check--filter--role-resolution-via-the-port) - [Using `@AccessControlCreateOne` Decorator](#using-accesscontrolcreateone-decorator) - [Setting Permissions](#setting-create-one-permissions) - [Using in a Controller](#using-create-one-in-a-controller) @@ -74,13 +76,22 @@ Advanced access control guard for NestJS with optional per-request filtering. Install the `@concepta/nestjs-access-control` package using yarn or npm: ```sh -yarn add @concepta/nestjs-access-control +yarn add @concepta/nestjs-access-control @nestjs/common @nestjs/config @nestjs/core ``` ```sh -npm install @concepta/nestjs-access-control +npm install @concepta/nestjs-access-control @nestjs/common @nestjs/config @nestjs/core ``` +Requirements: the package is **ESM-only** (no CommonJS build), targets +**Node.js >= 22.12**, and runs on **NestJS 12**. + +Peer dependencies: `@nestjs/common`, `@nestjs/config`, and `@nestjs/core` +(^12) must be installed by your app. `@nestjs/cqrs` (^12) is an optional +peer but is required in practice — the guard and filter dispatch +`CheckAccessQuery` / `FilterResponseAttributesQuery` / `ResolveUserRolesQuery` +through it. + ## Basic Setup To set up the `@concepta/nestjs-access-control` module, you need to @@ -95,11 +106,10 @@ These are very rough examples. We intend to improve them ASAP. ### Simple User Entity -Define a simple User entity using TypeORM and class-transformer. +Define a simple User entity using TypeORM. ```typescript import { Entity, Column, ManyToMany, Unique } from 'typeorm'; -import { Exclude } from 'class-transformer'; import { Role } from '../auth/role.entity'; @Entity() @@ -109,11 +119,9 @@ export class User { username!: string; @Column() - @Exclude() password!: string; @Column() - @Exclude() salt!: string; @ManyToMany(() => Role, (role) => role.users, { @@ -124,6 +132,12 @@ export class User { } ``` +> Entities are plain classes — in the v8 stack, response shaping is +> schema-based at the controller layer (Zod/Standard Schema response +> serialization), not entity-decorator-based. Keep sensitive fields such as +> `password` and `salt` out of your response schemas instead of decorating +> the entity. + ### Your custom ACL rules Define custom ACL rules as documented by the @@ -197,20 +211,28 @@ The `ACService` is a provider, and you can be inject any other provider you may need to get the correct user and its roles. ```typescript -import { AccessControlService } from 'nestjs-access-control'; +import { AccessControlServiceInterface } from '@concepta/nestjs-access-control'; import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { User } from '../user/user.entity'; -export class ACService implements AccessControlService { - async getUser(context: ExecutionContext): Promise { +function hasRoles(user: unknown): user is { roles: { name: string }[] } { + return ( + typeof user === 'object' && + user !== null && + 'roles' in user && + Array.isArray((user as { roles: unknown }).roles) + ); +} + +export class ACService implements AccessControlServiceInterface { + async getUser(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); // request.user should be something like this // { id: '1', username: 'john', roles: [{ id: '1', name: 'User' }] } - return request.user as T; + return request.user; } async getUserRoles(context: ExecutionContext): Promise { - const user = await this.getUser(context); - if (!user || !user.roles) throw new UnauthorizedException(); + const user = await this.getUser(context); + if (!hasRoles(user)) throw new UnauthorizedException(); return user.roles.map((role) => role.name); } } @@ -261,9 +283,8 @@ import { } from '@concepta/nestjs-access-control'; import { UserResource } from './user.types'; -import { UserCreateDto } from './dto/user-create.dto'; -import { UserCreateManyDto } from './dto/user-create-many.dto'; -import { UserUpdateDto } from './dto/user-update.dto'; +import { UserCreatableInterface } from './interfaces/user-creatable.interface'; +import { UserUpdatableInterface } from './interfaces/user-updatable.interface'; /** * User controller. @@ -291,7 +312,7 @@ export class UserController { * Create many */ @AccessControlCreateMany(AppResource.UserList) - async createMany(@Body() userCreateManyDto: UserCreateManyDto) { + async createMany(@Body() users: UserCreatableInterface[]) { // ... } @@ -299,7 +320,7 @@ export class UserController { * Create one */ @AccessControlCreateOne(AppResource.User) - async createOne(@Body() userCreateDto: UserCreateDto) { + async createOne(@Body() user: UserCreatableInterface) { // ... } @@ -309,7 +330,7 @@ export class UserController { @AccessControlUpdateOne(AppResource.User) async updateOne( @Param('id') userId: string, - @Body() userUpdateDto: UserUpdateDto, + @Body() user: UserUpdatableInterface, ) { // ... } @@ -378,22 +399,32 @@ To create a custom query service, follow these steps: to update it. ```typescript +// Action is the enum from the accesscontrol library itself +import { Action } from 'accesscontrol'; //... +function getId(value: unknown): string | undefined { + return typeof value === 'object' && + value !== null && + 'id' in value && + typeof value.id === 'string' + ? value.id + : undefined; +} + +function hasPassword(value: unknown): boolean { + return typeof value === 'object' && value !== null && 'password' in value; +} + export class MyUserAccessQueryService implements CanAccess { async canAccess(context: AccessControlContext): Promise { const { resource, action } = context.getQuery(); - if (resource === AppResource.User && action === ActionEnum.UPDATE) { - const userAuthorizedDto = plainToInstance(UserDto, context.getUser()); - - const params = context.getRequest('params'); - const userParamDto = plainToInstance(UserDto, params); + if (resource === AppResource.User && action === Action.UPDATE) { + const authorizedUserId = getId(context.getUser()); + const paramsId = getId(context.getRequest('params')); - const body = context.getRequest('body'); - const userPasswordDto = plainToInstance(UserPasswordDto, body); - - if (userParamDto.id && userPasswordDto?.password) { - return userParamDto.id === userAuthorizedDto.id; + if (paramsId && hasPassword(context.getRequest('body'))) { + return paramsId === authorizedUserId; } } @@ -427,7 +458,7 @@ export class UserController { }) async updateOne( @Param('id') userId: string, - @Body() userUpdateDto: UserUpdateDto, + @Body() user: UserUpdatableInterface, ) { // ... } @@ -531,6 +562,97 @@ AccessControlModule.forRoot({ }), ``` +## Filtering response attributes + +The module ships an `AccessControlFilter` interceptor that is registered +globally as `APP_INTERCEPTOR` by default. After a response is produced it +inspects the same `@AccessControl*` grant metadata that the guard checked, +queries the user's roles (via `ResolveUserRolesQuery`), and strips any fields +the user is not permitted to see using the `accesscontrol` library's +`permission.filter(data)` utility. + +> **Note:** Roles granted `any` access bypass the attribute filter entirely — +> `any` implies unrestricted access to all fields (see [IMPORTANT](#important)). + +The filter is enabled by default. To disable it: + +```typescript +AccessControlModule.forRoot({ + settings: { rules: acRules }, + appFilter: false, +}), +``` + +You can also supply a custom interceptor class in place of the default: + +```typescript +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; + +@Injectable() +export class MyAccessControlFilter implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + // your custom filtering logic + return next.handle(); + } +} +``` + +```typescript +AccessControlModule.forRoot({ + settings: { rules: acRules }, + appFilter: new MyAccessControlFilter(), +}), +``` + +## Override the access-check / filter / role-resolution via the port + +Internally the module dispatches three CQRS queries: + +| Query | Default handler | What it does | +|---|---|---| +| `CheckAccessQuery` | `CheckAccessHandler` | Evaluates `@AccessControlGrant` + `@AccessControlQuery` metadata against the rules | +| `FilterResponseAttributesQuery` | `FilterResponseAttributesHandler` | Masks response attributes based on grants | +| `ResolveUserRolesQuery` | `ResolveUserRolesHandler` | Resolves roles from `AccessControlService.getUserRoles` | + +Consumers can replace any subset of these by providing custom query +classes through `ports.accessControl`. The defaults shipped via +`DEFAULT_ACCESS_CONTROL_PORT_SETTINGS` fill any slot left unset. + +```typescript +import { + AccessControlModule, + CheckAccessQueryInterface, +} from '@concepta/nestjs-access-control'; +import { Query } from '@nestjs/cqrs'; + +// Define your own query — interface marker keeps it compatible with the port +export class MyCheckAccessQuery + extends Query + implements CheckAccessQueryInterface +{ + constructor(public readonly executionContext: ExecutionContext) { + super(); + } +} + +// Register a @QueryHandler(MyCheckAccessQuery) for it (not shown). + +AccessControlModule.forRoot({ + settings: { rules: acRules }, + ports: { + accessControl: { + checkAccessQuery: MyCheckAccessQuery, + // filterResponseAttributesQuery + resolveUserRolesQuery keep their defaults + }, + }, +}); +``` + +The guard and filter both inject `AccessControlPort` (resolved under +`ACCESS_CONTROL_PORT_TOKEN`), so any swap takes effect without further +wiring. + ## Using `@AccessControlCreateOne` Decorator The `@AccessControlCreateOne` decorator is used to grant create @@ -557,8 +679,8 @@ to protect the route that handles the creation of a single resource. ```typescript @Post() @AccessControlCreateOne(AppResource.User) - create(@Body() createUserDto: CreateUserDto) { - return this.userService.create(createUserDto); + create(@Body() user: UserCreatableInterface) { + return this.userService.create(user); } ``` @@ -587,8 +709,8 @@ to protect the route that handles the updating of a single resource. ```typescript @Put(':id') @AccessControlUpdateOne(AppResource.User) - update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { - return this.userService.update(id, updateUserDto); + update(@Param('id') id: string, @Body() user: UserUpdatableInterface) { + return this.userService.update(id, user); } ``` @@ -708,8 +830,8 @@ to protect the route that handles the creation of multiple resources. //... @Post('bulk') @AccessControlCreateMany(AppResource.User) - createMany(@Body() createUsersDto: CreateUsersDto) { - return this.userService.createMany(createUsersDto); + createMany(@Body() users: UserCreatableInterface[]) { + return this.userService.createMany(users); } //... ``` @@ -747,6 +869,18 @@ protect the route that handles the reading of multiple resources. //... ``` +## Other Grant Decorators + +Two more grant shortcut decorators are exported: + +- `@AccessControlReplaceOne(resource)` — for full-replace (PUT) endpoints; + a shortcut that delegates to `@AccessControlUpdateOne`, so it checks the + `update` grant. +- `@AccessControlRecoverOne(resource)` — for soft-delete recovery endpoints + (see the `recoverOne` handler in the tutorial controller example); a + shortcut that delegates to `@AccessControlCreateOne`, so it checks the + `create` grant. + ## Reference ### NestJS AuthGuard Pattern @@ -759,6 +893,11 @@ check the [official NestJS documentation](https://docs.nestjs.com/guards#access- For more details on the `accesscontrol` module, check the [official accesscontrol documentation](https://www.npmjs.com/package/accesscontrol). +### Deprecated Exports + +- `AccessControllerException` — deprecated shim kept for v7 consumer + compatibility; it will be removed once external callers migrate off it. + ## Explanation ### IMPORTANT @@ -828,21 +967,16 @@ authorized to access the resource. 1. **canAccess Method**: -- This method is used to determine if a user can access a - particular resource. +- `canAccess(context)` is the **only** method declared by the `CanAccess` + interface — all custom authorization logic goes inside it. - You can add custom logic to check the user's role and the action - they want to perform. + they want to perform, using `context.getQuery()`, `context.getUser()`, + and `context.getRequest()`. - For example, you might allow users with a 'manager' role to read - and update data, but restrict 'employee' roles to only read data. - -1. **canUpdatePassword Method**: - -- This method is used to control whether a user can update their password. -- You can add custom logic to ensure that users can only update their own - passwords. -- For example, you might check if the user is trying to update their own - password and deny the request if they are trying to update someone - else's password. + and update data, but restrict 'employee' roles to only read data — or, + as in the [custom query service example](#creating-a-custom-access-query-service), + ensure a user can only update their own password by comparing the route + parameter id with the authenticated user's id. ### How AccessControlGuard Works @@ -907,6 +1041,9 @@ perform specific actions. #### Global vs Feature-Specific Registration - **Global Registration**: Makes the module available throughout the - entire application. + entire application (`forRoot()` / `forRootAsync()`). - **Feature-Specific Registration**: Allows the module to be registered - only for specific features or modules within the application. + only for specific features or modules within the application + (`register()` / `registerAsync()`). A `forFeature()` static method also + exists — it creates a standalone set of access control providers + (imports, providers, exports) for use in sub-modules. diff --git a/packages/nestjs-access-control/package.json b/packages/nestjs-access-control/package.json index a331475b8..e12d031ed 100644 --- a/packages/nestjs-access-control/package.json +++ b/packages/nestjs-access-control/package.json @@ -1,29 +1,50 @@ { "name": "@concepta/nestjs-access-control", - "version": "7.0.0-alpha.10", + "version": "8.0.0-alpha.10", "description": "Rockets NestJS Access Control", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "license": "BSD-3-Clause", "publishConfig": { "access": "public" }, "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" ], "dependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", + "@concepta/nestjs-core": "^8.0.0-alpha.10", "accesscontrol": "^2.2.1", "rxjs": "^7.8.1" }, "devDependencies": { - "@nestjs/swagger": "^11.2.2", - "@nestjs/testing": "^11.1.9", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/swagger": "^12.0.1", + "@nestjs/testing": "^12.0.1", "@types/supertest": "^6.0.3", - "jest-mock-extended": "^4.0.0", - "supertest": "^6.3.4" + "supertest": "^6.3.4", + "vitest-mock-extended": "^4.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/cqrs": { + "optional": true + } + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } } } diff --git a/packages/nestjs-access-control/src/__tests__/exception-fault.spec.ts b/packages/nestjs-access-control/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..fcb06284d --- /dev/null +++ b/packages/nestjs-access-control/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,43 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { AccessControllerException } from '../domain/exceptions/access-controller.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'AccessControllerException', + build: () => new AccessControllerException('failure'), + fault: 'usage', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-access-control/src/access-control.constants.spec.ts b/packages/nestjs-access-control/src/access-control.constants.spec.ts new file mode 100644 index 000000000..90bc8b100 --- /dev/null +++ b/packages/nestjs-access-control/src/access-control.constants.spec.ts @@ -0,0 +1,23 @@ +import { + ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, + ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN, + ACCESS_CONTROL_MODULE_QUERY_METADATA, + ACCESS_CONTROL_MODULE_GRANT_METADATA, +} from './access-control.constants.js'; + +describe('Constants', () => { + it('Should each match expected value', () => { + expect(ACCESS_CONTROL_MODULE_SETTINGS_TOKEN).toEqual( + 'ACCESS_CONTROL_MODULE_SETTINGS_TOKEN', + ); + expect(ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN).toEqual( + 'ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN', + ); + expect(ACCESS_CONTROL_MODULE_QUERY_METADATA).toEqual( + 'ACCESS_CONTROL_MODULE_QUERY_METADATA', + ); + expect(ACCESS_CONTROL_MODULE_GRANT_METADATA).toEqual( + 'ACCESS_CONTROL_MODULE_GRANT_METADATA', + ); + }); +}); diff --git a/packages/nestjs-access-control/src/access-control.constants.ts b/packages/nestjs-access-control/src/access-control.constants.ts new file mode 100644 index 000000000..6702221cb --- /dev/null +++ b/packages/nestjs-access-control/src/access-control.constants.ts @@ -0,0 +1,15 @@ +export const ACCESS_CONTROL_MODULE_SETTINGS_TOKEN = + 'ACCESS_CONTROL_MODULE_SETTINGS_TOKEN'; + +export const ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN = + 'ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN'; + +export const ACCESS_CONTROL_MODULE_GRANT_METADATA = + 'ACCESS_CONTROL_MODULE_GRANT_METADATA'; + +export const ACCESS_CONTROL_MODULE_QUERY_METADATA = + 'ACCESS_CONTROL_MODULE_QUERY_METADATA'; + +export const ACCESS_CONTROL_PORT_TOKEN = Symbol( + '__ACCESS_CONTROL_PORT_TOKEN__', +); diff --git a/packages/nestjs-access-control/src/access-control.context.ts b/packages/nestjs-access-control/src/access-control.context.ts deleted file mode 100644 index c60b48723..000000000 --- a/packages/nestjs-access-control/src/access-control.context.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { AccessControl, IQueryInfo } from 'accesscontrol'; - -import { ExecutionContext } from '@nestjs/common'; - -import { AccessControlContextArgsInterface } from './interfaces/access-control-context-args.interface'; -import { AccessControlContextInterface } from './interfaces/access-control-context.interface'; - -export class AccessControlContext implements AccessControlContextInterface { - constructor(private readonly ctxArgs: AccessControlContextArgsInterface) {} - - protected hasProp( - obj: unknown, - key: K, - ): obj is Record { - return ( - key !== null && obj !== null && typeof obj === 'object' && key in obj - ); - } - - protected getProp(obj: unknown, prop: string) { - return this.hasProp(obj, prop) ? obj[prop] : undefined; - } - - getRequest(property?: string): unknown { - return property?.length - ? this.getProp(this.ctxArgs.request, property) - : this.ctxArgs.request; - } - - getUser(): unknown { - return this.ctxArgs.user; - } - - getQuery(): IQueryInfo { - return this.ctxArgs.query; - } - - getAccessControl(): AccessControl { - return this.ctxArgs.accessControl; - } - - getExecutionContext(): ExecutionContext { - return this.ctxArgs.executionContext; - } -} diff --git a/packages/nestjs-access-control/src/access-control.guard.spec.ts b/packages/nestjs-access-control/src/access-control.guard.spec.ts deleted file mode 100644 index 416f0c53c..000000000 --- a/packages/nestjs-access-control/src/access-control.guard.spec.ts +++ /dev/null @@ -1,407 +0,0 @@ -import { AccessControl } from 'accesscontrol'; -import { mock } from 'jest-mock-extended'; - -import { Controller, ExecutionContext, Injectable } from '@nestjs/common'; -import { HttpArgumentsHost } from '@nestjs/common/interfaces'; -import { Reflector } from '@nestjs/core'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { AccessControlContext } from './access-control.context'; -import { AccessControlGuard } from './access-control.guard'; -import { - ACCESS_CONTROL_MODULE_QUERY_METADATA, - ACCESS_CONTROL_MODULE_GRANT_METADATA, - ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, -} from './constants'; -import { AccessControlCreateOne } from './decorators/access-control-create-one.decorator'; -import { AccessControlQuery } from './decorators/access-control-query.decorator'; -import { AccessControlReadMany } from './decorators/access-control-read-many.decorator'; -import { AccessControlReadOne } from './decorators/access-control-read-one.decorator'; -import { ActionEnum } from './enums/action.enum'; -import { PossessionEnum } from './enums/possession.enum'; -import { AccessControlContextInterface } from './interfaces/access-control-context.interface'; -import { AccessControlGrantOptionInterface } from './interfaces/access-control-grant-option.interface'; -import { AccessControlOptionsInterface } from './interfaces/access-control-options.interface'; -import { AccessControlQueryOptionInterface } from './interfaces/access-control-query-option.interface'; -import { AccessControlServiceInterface } from './interfaces/access-control-service.interface'; -import { CanAccess } from './interfaces/can-access.interface'; -import { AccessControlService } from './services/access-control.service'; - -describe('AccessControlModule', () => { - const resourceNoAccess = 'protected_resource_no_access'; - const resourceGetAny = 'resource_get_any'; - const resourceGetOwn = 'resource_get_own'; - const resourceGetOneOwn = 'resource_get_one_own'; - const resourceCreateOwn = 'resource_create_own'; - - class TestUser { - constructor(public id: number) {} - } - - @Injectable() - class TestQueryServicePass implements CanAccess { - async canAccess(_context: AccessControlContextInterface) { - return true; - } - } - - @Injectable() - class TestQueryServiceFail implements CanAccess { - async canAccess(_context: AccessControlContextInterface) { - return false; - } - } - - class TestAccessService implements AccessControlServiceInterface { - async getUser(_context: ExecutionContext): Promise { - return new TestUser(1234); - } - async getUserRoles(_context: ExecutionContext): Promise { - return ['role1']; - } - } - - @Controller() - class TestController { - getOpen() { - return undefined; - } - @AccessControlReadOne(resourceNoAccess) - getNoAccess() { - return undefined; - } - @AccessControlReadOne(resourceGetAny) - getAny() { - return undefined; - } - @AccessControlReadOne(resourceGetOwn) - getOwn() { - return undefined; - } - @AccessControlReadMany(resourceGetOwn) - @AccessControlQuery({ service: TestQueryServiceFail }) - getOwnQueryFail() { - return undefined; - } - @AccessControlReadMany(resourceGetOwn) - @AccessControlQuery({ service: TestQueryServicePass }) - getOwnQueryPass() { - return undefined; - } - @AccessControlCreateOne(resourceCreateOwn) - @AccessControlQuery({ service: TestQueryServicePass }) - createOwnQueryPass() { - return undefined; - } - @AccessControlReadOne(resourceGetOneOwn) - @AccessControlQuery({ service: TestQueryServicePass }) - getOneOwnQueryPass() { - return undefined; - } - } - - let controller: TestController; - - const rules = new AccessControl(); - rules.grant('role1').readAny(resourceGetAny); - rules.grant('role1').readOwn(resourceGetOwn); - rules.grant('role1').readOwn(resourceGetOneOwn); - rules.grant('role1').createOwn(resourceCreateOwn); - rules.lock(); - - let moduleRef: TestingModule; - let guard: AccessControlGuard; - let testQueryServicePass: TestQueryServicePass; - let testQueryServiceFail: TestQueryServiceFail; - let reflector: Reflector; - - beforeEach(async () => { - const moduleConfig: AccessControlOptionsInterface = { - settings: { rules: rules }, - service: new TestAccessService(), - }; - - reflector = new Reflector(); - - moduleRef = await Test.createTestingModule({ - providers: [ - AccessControlGuard, - TestAccessService, - TestQueryServicePass, - TestQueryServiceFail, - { - provide: AccessControlService, - useClass: TestAccessService, - }, - { - provide: ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, - useValue: moduleConfig.settings, - }, - { provide: Reflector, useValue: reflector }, - ], - }).compile(); - - controller = new TestController(); - guard = moduleRef.get(AccessControlGuard); - testQueryServicePass = - moduleRef.get(TestQueryServicePass); - testQueryServiceFail = - moduleRef.get(TestQueryServiceFail); - }); - - afterEach(async () => { - jest.clearAllMocks(); - }); - - describe('guard provider', () => { - it('should be of correct type', async () => { - expect(guard).toBeInstanceOf(AccessControlGuard); - }); - }); - - describe('access grants', () => { - it('should not have any grants set for getOpen', async () => { - const grants = reflector.get( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.getOpen, - ); - - expect(grants).toBeUndefined(); - }); - - it('should have grants set for getNoAccess', async () => { - const grants = reflector.get( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.getNoAccess, - ); - - expect(grants).toEqual([ - { - action: ActionEnum.READ, - resource: resourceNoAccess, - }, - ]); - }); - - it('should have grants set for getAny', async () => { - const grants = reflector.get( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.getAny, - ); - - expect(grants).toEqual([ - { - action: ActionEnum.READ, - resource: resourceGetAny, - }, - ]); - }); - - it('should have grants set for getOwn', async () => { - const grants = reflector.get( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.getOwn, - ); - - expect(grants).toEqual([ - { - action: ActionEnum.READ, - resource: resourceGetOwn, - }, - ]); - }); - }); - - describe('access queries', () => { - it('should have queries set for getOwnQueryFail', async () => { - const queries = reflector.get( - ACCESS_CONTROL_MODULE_QUERY_METADATA, - controller.getOwnQueryFail, - ); - - expect(queries).toEqual([ - { - service: TestQueryServiceFail, - }, - ]); - }); - - it('should have query set for getOwnQueryPass', async () => { - const queries = reflector.get( - ACCESS_CONTROL_MODULE_QUERY_METADATA, - controller.getOwnQueryPass, - ); - - expect(queries).toEqual([ - { - service: TestQueryServicePass, - }, - ]); - }); - }); - - describe('canActivate', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should allow activation (no acl applied)', async () => { - const context = mock(); - context.getHandler.mockReturnValue(controller.getOpen); - const canActivate: boolean = await guard.canActivate(context); - expect(canActivate).toEqual(true); - }); - - it('should NOT allow activation', async () => { - const context = mock(); - context.getHandler.mockReturnValue(controller.getNoAccess); - const canActivate: boolean = await guard.canActivate(context); - expect(canActivate).toEqual(false); - }); - - it('should allow activation for read any of resource', async () => { - const context = mock(); - context.getHandler.mockReturnValue(controller.getAny); - const canActivate: boolean = await guard.canActivate(context); - expect(canActivate).toEqual(true); - }); - - it('should allow activation for read own of resource', async () => { - const context = mock(); - context.getHandler.mockReturnValue(controller.getOwn); - const canActivate: boolean = await guard.canActivate(context); - expect(canActivate).toEqual(true); - }); - - it('should NOT allow activation, request not found on args host', async () => { - const argsHost = mock(); - argsHost.getRequest.mockReturnValue(null); - - const context = mock(); - context.getClass.mockReturnValue(TestController); - context.getHandler.mockReturnValue(controller.getOwnQueryPass); - context.switchToHttp.mockReturnValue(argsHost); - - const querySpy = jest.spyOn(testQueryServicePass, 'canAccess'); - - const canActivate: boolean = await guard.canActivate(context); - expect(querySpy).not.toHaveBeenCalled(); - expect(canActivate).toEqual(false); - }); - - it('should NOT allow activation, query string data', async () => { - const argsHost = mock(); - argsHost.getRequest.mockReturnValue({ query: { foo: 'bar' } }); - - const context = mock(); - context.getClass.mockReturnValue(TestController); - context.getHandler.mockReturnValue(controller.getOwnQueryFail); - context.switchToHttp.mockReturnValue(argsHost); - - const querySpy = jest.spyOn(testQueryServiceFail, 'canAccess'); - - const canActivate: boolean = await guard.canActivate(context); - expect(querySpy).toHaveBeenCalledTimes(1); - expect(canActivate).toEqual(false); - }); - - it('should allow activation, query string data', async () => { - const argsHost = mock(); - - argsHost.getRequest.mockReturnValue({ query: { q1: 'abc' } }); - - const context = mock(); - context.getClass.mockReturnValue(TestController); - context.getHandler.mockReturnValue(controller.getOwnQueryPass); - context.switchToHttp.mockReturnValue(argsHost); - - const expectedAccessControlContext = new AccessControlContext({ - request: { - query: { - q1: 'abc', - }, - }, - user: { id: 1234 }, - query: { - possession: PossessionEnum.OWN, - resource: 'resource_get_own', - action: ActionEnum.READ, - role: ['role1'], - }, - accessControl: rules, - executionContext: context, - }); - - const querySpy = jest.spyOn(testQueryServicePass, 'canAccess'); - - const canActivate: boolean = await guard.canActivate(context); - expect(querySpy).toHaveBeenCalledTimes(1); - expect(querySpy).toHaveBeenCalledWith(expectedAccessControlContext); - expect(canActivate).toEqual(true); - }); - - it('should allow activation, body data', async () => { - const argsHost = mock(); - argsHost.getRequest.mockReturnValue({ body: { b1: 'xyz' } }); - - const context = mock(); - context.getClass.mockReturnValue(TestController); - context.getHandler.mockReturnValue(controller.createOwnQueryPass); - context.switchToHttp.mockReturnValue(argsHost); - - const expectedAccessControlContext = new AccessControlContext({ - request: { - body: { b1: 'xyz' }, - }, - user: { id: 1234 }, - query: { - possession: PossessionEnum.OWN, - resource: 'resource_create_own', - action: ActionEnum.CREATE, - role: ['role1'], - }, - accessControl: rules, - executionContext: context, - }); - - const querySpy = jest.spyOn(testQueryServicePass, 'canAccess'); - - const canActivate: boolean = await guard.canActivate(context); - expect(querySpy).toHaveBeenCalledTimes(1); - expect(querySpy).toHaveBeenCalledWith(expectedAccessControlContext); - expect(canActivate).toEqual(true); - }); - - it('should allow activation, path data', async () => { - const argsHost = mock(); - argsHost.getRequest.mockReturnValue({ params: { id: 7890 } }); - - const context = mock(); - context.getClass.mockReturnValue(TestController); - context.getHandler.mockReturnValue(controller.getOneOwnQueryPass); - context.switchToHttp.mockReturnValue(argsHost); - - const querySpy = jest.spyOn(testQueryServicePass, 'canAccess'); - - const expectedAccessControlContext = new AccessControlContext({ - request: { - params: { id: 7890 }, - }, - user: { id: 1234 }, - query: { - possession: PossessionEnum.OWN, - resource: 'resource_get_one_own', - action: ActionEnum.READ, - role: ['role1'], - }, - accessControl: rules, - executionContext: context, - }); - - const canActivate: boolean = await guard.canActivate(context); - expect(querySpy).toHaveBeenCalledTimes(1); - expect(querySpy).toHaveBeenCalledWith(expectedAccessControlContext); - expect(canActivate).toEqual(true); - }); - }); -}); diff --git a/packages/nestjs-access-control/src/access-control.guard.ts b/packages/nestjs-access-control/src/access-control.guard.ts deleted file mode 100644 index d73bebe46..000000000 --- a/packages/nestjs-access-control/src/access-control.guard.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { IQueryInfo } from 'accesscontrol'; - -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; -import { ModuleRef, Reflector } from '@nestjs/core'; - -import { AccessControlContext } from './access-control.context'; -import { - ACCESS_CONTROL_MODULE_QUERY_METADATA, - ACCESS_CONTROL_MODULE_GRANT_METADATA, - ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, -} from './constants'; -import { PossessionEnum } from './enums/possession.enum'; -import { AccessControllerException } from './exceptions/access-controller.exception'; -import { AccessControlGrantOptionInterface } from './interfaces/access-control-grant-option.interface'; -import { AccessControlQueryOptionInterface } from './interfaces/access-control-query-option.interface'; -import { AccessControlServiceInterface } from './interfaces/access-control-service.interface'; -import { AccessControlSettingsInterface } from './interfaces/access-control-settings.interface'; -import { CanAccess } from './interfaces/can-access.interface'; -import { AccessControlService } from './services/access-control.service'; - -@Injectable() -export class AccessControlGuard implements CanActivate { - constructor( - @Inject(ACCESS_CONTROL_MODULE_SETTINGS_TOKEN) - private readonly settings: AccessControlSettingsInterface, - @Inject(AccessControlService) - private readonly service: AccessControlServiceInterface, - private readonly reflector: Reflector, - private moduleRef: ModuleRef, - ) {} - - public async canActivate(context: ExecutionContext): Promise { - // check permissions - return this.checkAccessGrants(context); - } - - protected async checkAccessGrants( - context: ExecutionContext, - ): Promise { - const rules = this.settings.rules; - - const acGrants = this.reflector.get( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - context.getHandler(), - ); - - // get anything? - if (!acGrants || !Array.isArray(acGrants)) { - // no, nothing to check - return true; - } - - const userRoles = await this.service.getUserRoles(context); - const possessions = [PossessionEnum.ANY, PossessionEnum.OWN]; - const queriesPermitted: IQueryInfo[] = []; - - // loop each grant - loopGrants: for (const acGrant of acGrants) { - // loop each possession - for (const possession of possessions) { - // build up the query - const query: IQueryInfo = { - role: userRoles, - possession, - ...acGrant, - }; - // get permission object - const permission = rules.permission(query); - // has permission? - if (permission.granted) { - queriesPermitted.push(query); - break loopGrants; - } - } - } - - // any permitted queries? - if (queriesPermitted.length) { - // yes, check access queries - return this.checkAccessQueries(context, queriesPermitted); - } - - // no permissions via grants - return false; - } - - protected async checkAccessQueries( - context: ExecutionContext, - queriesPermitted: IQueryInfo[], - ): Promise { - const targets = [context.getClass(), context.getHandler()]; - - const acQueries = this.reflector.getAllAndMerge< - AccessControlQueryOptionInterface[] - >( - ACCESS_CONTROL_MODULE_QUERY_METADATA, - targets.filter((t) => t), - ); - - // get anything? - if (!acQueries || !Array.isArray(acQueries) || !acQueries.length) { - // no, nothing to check - return true; - } - - const request: unknown = context.switchToHttp().getRequest(); - - // did we get a request? - if (!request || typeof request !== 'object') { - // no, impossible to query - return false; - } - - const user = await this.service.getUser(context); - - // authorized by default - let authorized = true; - - // loop all ac queries - loopQueries: for await (const acQuery of acQueries) { - // get the query service - const service = await this.getQueryService(acQuery); - - // loop all queries permitted - for await (const query of queriesPermitted) { - // yes, new access control context instance - const accessControlContext = new AccessControlContext({ - request, - user, - query, - accessControl: this.settings.rules, - executionContext: context, - }); - - // call query service - authorized = await service.canAccess(accessControlContext); - - // lost access? - if (authorized) { - // yes, don't bother checking anything else - break loopQueries; - } - } - } - - return authorized; - } - - private async getQueryService( - queryOption: AccessControlQueryOptionInterface, - ): Promise { - // get the query class instance - const queryService = this.moduleRef.resolve(queryOption.service); - - if (queryService) { - return queryService; - } else { - throw new AccessControllerException( - `Access control guard was unable to resolve service ${queryOption.service.name}`, - ); - } - } -} diff --git a/packages/nestjs-access-control/src/access-control.module-definition.ts b/packages/nestjs-access-control/src/access-control.module-definition.ts index f5485f490..79c6c859d 100644 --- a/packages/nestjs-access-control/src/access-control.module-definition.ts +++ b/packages/nestjs-access-control/src/access-control.module-definition.ts @@ -1,24 +1,45 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { CqrsModule, QueryBus } from '@nestjs/cqrs'; -import { createSettingsProvider } from '@concepta/nestjs-common'; +import { createSettingsProvider } from '@concepta/nestjs-core'; -import { AccessControlGuard } from './access-control.guard'; -import { accessControlDefaultConfig } from './config/acess-control-default.config'; -import { ACCESS_CONTROL_MODULE_SETTINGS_TOKEN } from './constants'; -import { AccessControlFilter } from './filter/access-control.filter'; -import { AccessControlOptionsExtrasInterface } from './interfaces/access-control-options-extras.interface'; -import { AccessControlOptionsInterface } from './interfaces/access-control-options.interface'; -import { AccessControlSettingsInterface } from './interfaces/access-control-settings.interface'; -import { AccessControlService } from './services/access-control.service'; +import { + ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, + ACCESS_CONTROL_PORT_TOKEN, +} from './access-control.constants.js'; +import { + AccessControlPort, + type AccessControlPortSettings, +} from './application/ports/access-control.port.js'; +import { CheckAccessHandler } from './application/queries/handlers/check-access.handler.js'; +import { FilterResponseAttributesHandler } from './application/queries/handlers/filter-response-attributes.handler.js'; +import { ResolveUserRolesHandler } from './application/queries/handlers/resolve-user-roles.handler.js'; +import { CheckAccessQuery } from './application/queries/impl/check-access.query.js'; +import { FilterResponseAttributesQuery } from './application/queries/impl/filter-response-attributes.query.js'; +import { ResolveUserRolesQuery } from './application/queries/impl/resolve-user-roles.query.js'; +import { AccessControlFilter } from './gateways/http/access-control.filter.js'; +import { AccessControlGuard } from './gateways/http/access-control.guard.js'; +import { accessControlDefaultConfig } from './infrastructure/config/access-control-default.config.js'; +import { type AccessControlOptionsExtrasInterface } from './infrastructure/config/interfaces/access-control-options-extras.interface.js'; +import { type AccessControlOptionsInterface } from './infrastructure/config/interfaces/access-control-options.interface.js'; +import { type AccessControlSettingsInterface } from './infrastructure/config/interfaces/access-control-settings.interface.js'; +import { AccessControlService } from './infrastructure/services/access-control.service.js'; const RAW_OPTIONS_TOKEN = Symbol('__ACCESS_CONTROL_MODULE_RAW_OPTIONS_TOKEN__'); +export const DEFAULT_ACCESS_CONTROL_PORT_SETTINGS: Required = + { + checkAccessQuery: CheckAccessQuery, + filterResponseAttributesQuery: FilterResponseAttributesQuery, + resolveUserRolesQuery: ResolveUserRolesQuery, + }; + export const { ConfigurableModuleClass: AccessControlModuleClass, OPTIONS_TYPE: ACCESS_CONTROL_OPTIONS_TYPE, @@ -43,6 +64,12 @@ export type AccessControlAsyncOptions = Omit< 'global' >; +const ACCESS_CONTROL_QUERY_HANDLERS = [ + CheckAccessHandler, + FilterResponseAttributesHandler, + ResolveUserRolesHandler, +]; + function definitionTransform( definition: DynamicModule, extras: AccessControlOptionsExtrasInterface, @@ -64,7 +91,10 @@ function definitionTransform( export function createAccessControlImports( overrides?: Pick, ): DynamicModule['imports'] { - const imports = [ConfigModule.forFeature(accessControlDefaultConfig)]; + const imports = [ + CqrsModule, + ConfigModule.forFeature(accessControlDefaultConfig), + ]; if (overrides?.imports?.length) { return [...imports, ...overrides.imports]; @@ -76,6 +106,7 @@ export function createAccessControlImports( export function createAccessControlExports() { return [ ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, + ACCESS_CONTROL_PORT_TOKEN, AccessControlService, AccessControlFilter, AccessControlGuard, @@ -90,10 +121,12 @@ export function createAccessControlProviders(options: { ...(options.providers ?? []), createAccessControlSettingsProvider(options.overrides), createAccessControlServiceProvider(options.overrides), + createAccessControlPortProvider(options.overrides), createAccessControlAppGuardProvider(options.overrides), createAccessControlAppFilterProvider(options.overrides), AccessControlFilter, AccessControlGuard, + ...ACCESS_CONTROL_QUERY_HANDLERS, ]; } @@ -117,32 +150,48 @@ export function createAccessControlServiceProvider( return { provide: AccessControlService, inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: AccessControlOptionsInterface) => + useFactory: (options: AccessControlOptionsInterface) => optionsOverrides?.service ?? options.service ?? new AccessControlService(), }; } +export function createAccessControlPortProvider( + optionsOverrides?: AccessControlOptions, +): Provider { + return { + provide: ACCESS_CONTROL_PORT_TOKEN, + inject: [RAW_OPTIONS_TOKEN, QueryBus], + useFactory: ( + options: AccessControlOptionsInterface, + queryBus: QueryBus, + ) => { + const portSettings: Required = { + ...DEFAULT_ACCESS_CONTROL_PORT_SETTINGS, + ...options?.ports?.accessControl, + ...optionsOverrides?.ports?.accessControl, + }; + return new AccessControlPort(portSettings, queryBus); + }, + }; +} + export function createAccessControlAppGuardProvider( optionsOverrides?: AccessControlOptions, ): Provider { return { provide: APP_GUARD, inject: [RAW_OPTIONS_TOKEN, AccessControlGuard], - useFactory: async ( + useFactory: ( options: AccessControlOptionsInterface, defaultGuard: AccessControlGuard, ) => { - // get app guard from the options const appGuard = optionsOverrides?.appGuard ?? options?.appGuard; - // is app guard explicitly false? if (appGuard === false) { - // yes, don't set a guard return null; } else { - // return app guard if set, or fall back to default return appGuard ?? defaultGuard; } }, @@ -155,19 +204,15 @@ export function createAccessControlAppFilterProvider( return { provide: APP_INTERCEPTOR, inject: [RAW_OPTIONS_TOKEN, AccessControlFilter], - useFactory: async ( + useFactory: ( options: AccessControlOptionsInterface, defaultFilter: AccessControlFilter, ) => { - // get app filter from the options const appFilter = optionsOverrides?.appFilter ?? options?.appFilter; - // is app filter explicitly false? if (appFilter === false) { - // yes, don't set a filter return null; } else { - // return app filter if set, or fall back to default return appFilter ?? defaultFilter; } }, diff --git a/packages/nestjs-access-control/src/access-control.module.spec.ts b/packages/nestjs-access-control/src/access-control.module.spec.ts index 094b07acb..284828e75 100644 --- a/packages/nestjs-access-control/src/access-control.module.spec.ts +++ b/packages/nestjs-access-control/src/access-control.module.spec.ts @@ -3,16 +3,27 @@ import { AccessControl } from 'accesscontrol'; import { Module } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AccessControlModule } from './access-control.module'; -import { ACCESS_CONTROL_MODULE_SETTINGS_TOKEN } from './constants'; -import { AccessControlServiceInterface } from './interfaces/access-control-service.interface'; -import { AccessControlSettingsInterface } from './interfaces/access-control-settings.interface'; -import { AccessControlService } from './services/access-control.service'; +import { + ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, + ACCESS_CONTROL_PORT_TOKEN, +} from './access-control.constants.js'; +import { AccessControlModule } from './access-control.module.js'; +import { AccessControlPort } from './application/ports/access-control.port.js'; +import { CheckAccessHandler } from './application/queries/handlers/check-access.handler.js'; +import { FilterResponseAttributesHandler } from './application/queries/handlers/filter-response-attributes.handler.js'; +import { ResolveUserRolesHandler } from './application/queries/handlers/resolve-user-roles.handler.js'; +import { AccessControlServiceInterface } from './domain/ports/access-control-service.interface.js'; +import { AccessControlSettingsInterface } from './infrastructure/config/interfaces/access-control-settings.interface.js'; +import { AccessControlService } from './infrastructure/services/access-control.service.js'; describe('AccessControlModule', () => { let accessControlModule: AccessControlModule; let accessControlSettings: AccessControlSettingsInterface; let accessControlService: AccessControlServiceInterface; + let accessControlPort: AccessControlPort; + let checkAccessHandler: CheckAccessHandler; + let filterResponseAttributesHandler: FilterResponseAttributesHandler; + let resolveUserRolesHandler: ResolveUserRolesHandler; const rules = new AccessControl(); @@ -150,13 +161,35 @@ describe('AccessControlModule', () => { ); accessControlService = testModule.get(AccessControlService); + accessControlPort = testModule.get( + ACCESS_CONTROL_PORT_TOKEN, + ); + checkAccessHandler = testModule.get(CheckAccessHandler); + filterResponseAttributesHandler = + testModule.get( + FilterResponseAttributesHandler, + ); + resolveUserRolesHandler = testModule.get( + ResolveUserRolesHandler, + ); } function commonTests() { it('providers should be loaded', async () => { expect(accessControlModule).toBeInstanceOf(AccessControlModule); - expect(accessControlSettings).toBeInstanceOf(Object); expect(accessControlService).toBeInstanceOf(AccessControlService); }); + + it('port should be loaded under ACCESS_CONTROL_PORT_TOKEN', async () => { + expect(accessControlPort).toBeInstanceOf(AccessControlPort); + }); + + it('query handlers should be registered', async () => { + expect(checkAccessHandler).toBeInstanceOf(CheckAccessHandler); + expect(filterResponseAttributesHandler).toBeInstanceOf( + FilterResponseAttributesHandler, + ); + expect(resolveUserRolesHandler).toBeInstanceOf(ResolveUserRolesHandler); + }); } }); diff --git a/packages/nestjs-access-control/src/access-control.module.ts b/packages/nestjs-access-control/src/access-control.module.ts index 50075998c..cf83857ec 100644 --- a/packages/nestjs-access-control/src/access-control.module.ts +++ b/packages/nestjs-access-control/src/access-control.module.ts @@ -7,7 +7,7 @@ import { createAccessControlExports, createAccessControlImports, createAccessControlProviders, -} from './access-control.module-definition'; +} from './access-control.module-definition.js'; @Module({}) export class AccessControlModule extends AccessControlModuleClass { diff --git a/packages/nestjs-access-control/src/application/ports/access-control.port.ts b/packages/nestjs-access-control/src/application/ports/access-control.port.ts new file mode 100644 index 000000000..e80f804a3 --- /dev/null +++ b/packages/nestjs-access-control/src/application/ports/access-control.port.ts @@ -0,0 +1,57 @@ +import { ExecutionContext, Injectable, Type } from '@nestjs/common'; +import { Query, QueryBus } from '@nestjs/cqrs'; + +export interface CheckAccessQueryInterface extends Query { + executionContext: ExecutionContext; +} + +export interface FilterResponseAttributesQueryInterface extends Query { + executionContext: ExecutionContext; + data: unknown; +} + +export interface ResolveUserRolesQueryInterface extends Query< + string | string[] +> { + executionContext: ExecutionContext; +} + +export interface AccessControlPortSettings { + checkAccessQuery?: Type; + filterResponseAttributesQuery?: Type; + resolveUserRolesQuery?: Type; +} + +@Injectable() +export class AccessControlPort { + constructor( + private readonly portSettings: Required, + private readonly queryBus: QueryBus, + ) {} + + async checkAccess(executionContext: ExecutionContext): Promise { + return this.queryBus.execute( + new this.portSettings.checkAccessQuery(executionContext), + ); + } + + async filterResponseAttributes( + executionContext: ExecutionContext, + data: unknown, + ): Promise { + return this.queryBus.execute( + new this.portSettings.filterResponseAttributesQuery( + executionContext, + data, + ), + ); + } + + async resolveUserRoles( + executionContext: ExecutionContext, + ): Promise { + return this.queryBus.execute( + new this.portSettings.resolveUserRolesQuery(executionContext), + ); + } +} diff --git a/packages/nestjs-access-control/src/application/queries/handlers/check-access.handler.spec.ts b/packages/nestjs-access-control/src/application/queries/handlers/check-access.handler.spec.ts new file mode 100644 index 000000000..16d2f2843 --- /dev/null +++ b/packages/nestjs-access-control/src/application/queries/handlers/check-access.handler.spec.ts @@ -0,0 +1,439 @@ +import { AccessControl } from 'accesscontrol'; +import { mock } from 'vitest-mock-extended'; + +import { + type ArgumentsHost, + Controller, + ExecutionContext, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { QueryBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { ActionEnum } from '@concepta/nestjs-core'; + +import { + ACCESS_CONTROL_MODULE_QUERY_METADATA, + ACCESS_CONTROL_MODULE_GRANT_METADATA, + ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, +} from '../../../access-control.constants.js'; +import { AccessControlContext } from '../../../domain/access-control.context.js'; +import { PossessionEnum } from '../../../domain/enums/possession.enum.js'; +import { AccessControlContextInterface } from '../../../domain/interfaces/access-control-context.interface.js'; +import { AccessControlGrantOptionInterface } from '../../../domain/interfaces/access-control-grant-option.interface.js'; +import { AccessControlQueryOptionInterface } from '../../../domain/interfaces/access-control-query-option.interface.js'; +import { CanAccess } from '../../../domain/policies/can-access.policy.js'; +import { AccessControlServiceInterface } from '../../../domain/ports/access-control-service.interface.js'; +import { AccessControlCreateOne } from '../../../gateways/decorators/access-control-create-one.decorator.js'; +import { AccessControlQuery } from '../../../gateways/decorators/access-control-query.decorator.js'; +import { AccessControlReadMany } from '../../../gateways/decorators/access-control-read-many.decorator.js'; +import { AccessControlReadOne } from '../../../gateways/decorators/access-control-read-one.decorator.js'; +import { AccessControlOptionsInterface } from '../../../infrastructure/config/interfaces/access-control-options.interface.js'; +import { AccessControlService } from '../../../infrastructure/services/access-control.service.js'; +import { CheckAccessQuery } from '../impl/check-access.query.js'; + +import { CheckAccessHandler } from './check-access.handler.js'; + +type HttpArgumentsHost = ReturnType; + +describe(CheckAccessHandler.name, () => { + const resourceNoAccess = 'protected_resource_no_access'; + const resourceGetAny = 'resource_get_any'; + const resourceGetOwn = 'resource_get_own'; + const resourceGetOneOwn = 'resource_get_one_own'; + const resourceCreateOwn = 'resource_create_own'; + + class TestUser { + constructor(public id: number) {} + } + + @Injectable() + class TestQueryServicePass implements CanAccess { + async canAccess(_context: AccessControlContextInterface) { + return true; + } + } + + @Injectable() + class TestQueryServiceFail implements CanAccess { + async canAccess(_context: AccessControlContextInterface) { + return false; + } + } + + class TestAccessService implements AccessControlServiceInterface { + async getUser(_context: ExecutionContext): Promise { + return new TestUser(1234); + } + async getUserRoles(_context: ExecutionContext): Promise { + return ['role1']; + } + } + + @Controller() + class TestController { + getOpen() { + return undefined; + } + @AccessControlReadOne(resourceNoAccess) + getNoAccess() { + return undefined; + } + @AccessControlReadOne(resourceGetAny) + getAny() { + return undefined; + } + @AccessControlReadOne(resourceGetOwn) + getOwn() { + return undefined; + } + @AccessControlReadMany(resourceGetOwn) + @AccessControlQuery({ service: TestQueryServiceFail }) + getOwnQueryFail() { + return undefined; + } + @AccessControlReadMany(resourceGetOwn) + @AccessControlQuery({ service: TestQueryServicePass }) + getOwnQueryPass() { + return undefined; + } + @AccessControlCreateOne(resourceCreateOwn) + @AccessControlQuery({ service: TestQueryServicePass }) + createOwnQueryPass() { + return undefined; + } + @AccessControlReadOne(resourceGetOneOwn) + @AccessControlQuery({ service: TestQueryServicePass }) + getOneOwnQueryPass() { + return undefined; + } + } + + let controller: TestController; + + const rules = new AccessControl(); + rules.grant('role1').readAny(resourceGetAny); + rules.grant('role1').readOwn(resourceGetOwn); + rules.grant('role1').readOwn(resourceGetOneOwn); + rules.grant('role1').createOwn(resourceCreateOwn); + rules.lock(); + + let moduleRef: TestingModule; + let handler: CheckAccessHandler; + let testQueryServicePass: TestQueryServicePass; + let testQueryServiceFail: TestQueryServiceFail; + let reflector: Reflector; + + beforeEach(async () => { + const moduleConfig: AccessControlOptionsInterface = { + settings: { rules: rules }, + service: new TestAccessService(), + }; + + reflector = new Reflector(); + + moduleRef = await Test.createTestingModule({ + providers: [ + CheckAccessHandler, + TestAccessService, + TestQueryServicePass, + TestQueryServiceFail, + { + provide: AccessControlService, + useClass: TestAccessService, + }, + { + provide: ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, + useValue: moduleConfig.settings, + }, + { provide: Reflector, useValue: reflector }, + { + provide: QueryBus, + useValue: { execute: vi.fn().mockResolvedValue(['role1']) }, + }, + ], + }).compile(); + + controller = new TestController(); + handler = moduleRef.get(CheckAccessHandler); + testQueryServicePass = + moduleRef.get(TestQueryServicePass); + testQueryServiceFail = + moduleRef.get(TestQueryServiceFail); + }); + + afterEach(async () => { + vi.clearAllMocks(); + }); + + describe('handler provider', () => { + it('should be of correct type', async () => { + expect(handler).toBeInstanceOf(CheckAccessHandler); + }); + }); + + describe('access grants', () => { + it('should not have any grants set for getOpen', async () => { + const grants = reflector.get( + ACCESS_CONTROL_MODULE_GRANT_METADATA, + controller.getOpen, + ); + + expect(grants).toBeUndefined(); + }); + + it('should have grants set for getNoAccess', async () => { + const grants = reflector.get( + ACCESS_CONTROL_MODULE_GRANT_METADATA, + controller.getNoAccess, + ); + + expect(grants).toEqual([ + { + action: ActionEnum.READ, + resource: resourceNoAccess, + }, + ]); + }); + + it('should have grants set for getAny', async () => { + const grants = reflector.get( + ACCESS_CONTROL_MODULE_GRANT_METADATA, + controller.getAny, + ); + + expect(grants).toEqual([ + { + action: ActionEnum.READ, + resource: resourceGetAny, + }, + ]); + }); + + it('should have grants set for getOwn', async () => { + const grants = reflector.get( + ACCESS_CONTROL_MODULE_GRANT_METADATA, + controller.getOwn, + ); + + expect(grants).toEqual([ + { + action: ActionEnum.READ, + resource: resourceGetOwn, + }, + ]); + }); + }); + + describe('access queries', () => { + it('should have queries set for getOwnQueryFail', async () => { + const queries = reflector.get( + ACCESS_CONTROL_MODULE_QUERY_METADATA, + controller.getOwnQueryFail, + ); + + expect(queries).toEqual([ + { + service: TestQueryServiceFail, + }, + ]); + }); + + it('should have query set for getOwnQueryPass', async () => { + const queries = reflector.get( + ACCESS_CONTROL_MODULE_QUERY_METADATA, + controller.getOwnQueryPass, + ); + + expect(queries).toEqual([ + { + service: TestQueryServicePass, + }, + ]); + }); + }); + + describe('canActivate', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should allow activation (no acl applied)', async () => { + const context = mock(); + context.getHandler.mockReturnValue(controller.getOpen); + const canActivate: boolean = await handler.execute( + new CheckAccessQuery(context), + ); + expect(canActivate).toEqual(true); + }); + + it('should NOT allow activation', async () => { + const context = mock(); + context.getHandler.mockReturnValue(controller.getNoAccess); + const canActivate: boolean = await handler.execute( + new CheckAccessQuery(context), + ); + expect(canActivate).toEqual(false); + }); + + it('should allow activation for read any of resource', async () => { + const context = mock(); + context.getHandler.mockReturnValue(controller.getAny); + const canActivate: boolean = await handler.execute( + new CheckAccessQuery(context), + ); + expect(canActivate).toEqual(true); + }); + + it('should allow activation for read own of resource', async () => { + const context = mock(); + context.getHandler.mockReturnValue(controller.getOwn); + const canActivate: boolean = await handler.execute( + new CheckAccessQuery(context), + ); + expect(canActivate).toEqual(true); + }); + + it('should NOT allow activation, request not found on args host', async () => { + const argsHost = mock(); + argsHost.getRequest.mockReturnValue(null); + + const context = mock(); + context.getClass.mockReturnValue(TestController); + context.getHandler.mockReturnValue(controller.getOwnQueryPass); + context.switchToHttp.mockReturnValue(argsHost); + + const querySpy = vi.spyOn(testQueryServicePass, 'canAccess'); + + const canActivate: boolean = await handler.execute( + new CheckAccessQuery(context), + ); + expect(querySpy).not.toHaveBeenCalled(); + expect(canActivate).toEqual(false); + }); + + it('should NOT allow activation, query string data', async () => { + const argsHost = mock(); + argsHost.getRequest.mockReturnValue({ query: { foo: 'bar' } }); + + const context = mock(); + context.getClass.mockReturnValue(TestController); + context.getHandler.mockReturnValue(controller.getOwnQueryFail); + context.switchToHttp.mockReturnValue(argsHost); + + const querySpy = vi.spyOn(testQueryServiceFail, 'canAccess'); + + const canActivate: boolean = await handler.execute( + new CheckAccessQuery(context), + ); + expect(querySpy).toHaveBeenCalledTimes(1); + expect(canActivate).toEqual(false); + }); + + it('should allow activation, query string data', async () => { + const argsHost = mock(); + + argsHost.getRequest.mockReturnValue({ query: { q1: 'abc' } }); + + const context = mock(); + context.getClass.mockReturnValue(TestController); + context.getHandler.mockReturnValue(controller.getOwnQueryPass); + context.switchToHttp.mockReturnValue(argsHost); + + const expectedAccessControlContext = new AccessControlContext({ + request: { + query: { + q1: 'abc', + }, + }, + user: { id: 1234 }, + query: { + possession: PossessionEnum.OWN, + resource: 'resource_get_own', + action: ActionEnum.READ, + role: ['role1'], + }, + accessControl: rules, + executionContext: context, + }); + + const querySpy = vi.spyOn(testQueryServicePass, 'canAccess'); + + const canActivate: boolean = await handler.execute( + new CheckAccessQuery(context), + ); + expect(querySpy).toHaveBeenCalledTimes(1); + expect(querySpy).toHaveBeenCalledWith(expectedAccessControlContext); + expect(canActivate).toEqual(true); + }); + + it('should allow activation, body data', async () => { + const argsHost = mock(); + argsHost.getRequest.mockReturnValue({ body: { b1: 'xyz' } }); + + const context = mock(); + context.getClass.mockReturnValue(TestController); + context.getHandler.mockReturnValue(controller.createOwnQueryPass); + context.switchToHttp.mockReturnValue(argsHost); + + const expectedAccessControlContext = new AccessControlContext({ + request: { + body: { b1: 'xyz' }, + }, + user: { id: 1234 }, + query: { + possession: PossessionEnum.OWN, + resource: 'resource_create_own', + action: ActionEnum.CREATE, + role: ['role1'], + }, + accessControl: rules, + executionContext: context, + }); + + const querySpy = vi.spyOn(testQueryServicePass, 'canAccess'); + + const canActivate: boolean = await handler.execute( + new CheckAccessQuery(context), + ); + expect(querySpy).toHaveBeenCalledTimes(1); + expect(querySpy).toHaveBeenCalledWith(expectedAccessControlContext); + expect(canActivate).toEqual(true); + }); + + it('should allow activation, path data', async () => { + const argsHost = mock(); + argsHost.getRequest.mockReturnValue({ params: { id: 7890 } }); + + const context = mock(); + context.getClass.mockReturnValue(TestController); + context.getHandler.mockReturnValue(controller.getOneOwnQueryPass); + context.switchToHttp.mockReturnValue(argsHost); + + const querySpy = vi.spyOn(testQueryServicePass, 'canAccess'); + + const expectedAccessControlContext = new AccessControlContext({ + request: { + params: { id: 7890 }, + }, + user: { id: 1234 }, + query: { + possession: PossessionEnum.OWN, + resource: 'resource_get_one_own', + action: ActionEnum.READ, + role: ['role1'], + }, + accessControl: rules, + executionContext: context, + }); + + const canActivate: boolean = await handler.execute( + new CheckAccessQuery(context), + ); + expect(querySpy).toHaveBeenCalledTimes(1); + expect(querySpy).toHaveBeenCalledWith(expectedAccessControlContext); + expect(canActivate).toEqual(true); + }); + }); +}); diff --git a/packages/nestjs-access-control/src/application/queries/handlers/check-access.handler.ts b/packages/nestjs-access-control/src/application/queries/handlers/check-access.handler.ts new file mode 100644 index 000000000..e51d365f5 --- /dev/null +++ b/packages/nestjs-access-control/src/application/queries/handlers/check-access.handler.ts @@ -0,0 +1,149 @@ +import { IQueryInfo } from 'accesscontrol'; + +import { ExecutionContext, Inject } from '@nestjs/common'; +import { ModuleRef, Reflector } from '@nestjs/core'; +import { IQueryHandler, QueryBus, QueryHandler } from '@nestjs/cqrs'; + +import { + ACCESS_CONTROL_MODULE_GRANT_METADATA, + ACCESS_CONTROL_MODULE_QUERY_METADATA, + ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, +} from '../../../access-control.constants.js'; +import { AccessControlContext } from '../../../domain/access-control.context.js'; +import { PossessionEnum } from '../../../domain/enums/possession.enum.js'; +import { AccessControllerException } from '../../../domain/exceptions/access-controller.exception.js'; +import { AccessControlGrantOptionInterface } from '../../../domain/interfaces/access-control-grant-option.interface.js'; +import { AccessControlQueryOptionInterface } from '../../../domain/interfaces/access-control-query-option.interface.js'; +import { CanAccess } from '../../../domain/policies/can-access.policy.js'; +import { AccessControlServiceInterface } from '../../../domain/ports/access-control-service.interface.js'; +import { AccessControlSettingsInterface } from '../../../infrastructure/config/interfaces/access-control-settings.interface.js'; +import { AccessControlService } from '../../../infrastructure/services/access-control.service.js'; +import { CheckAccessQuery } from '../impl/check-access.query.js'; +import { ResolveUserRolesQuery } from '../impl/resolve-user-roles.query.js'; + +@QueryHandler(CheckAccessQuery) +export class CheckAccessHandler implements IQueryHandler< + CheckAccessQuery, + boolean +> { + constructor( + @Inject(ACCESS_CONTROL_MODULE_SETTINGS_TOKEN) + private readonly settings: AccessControlSettingsInterface, + @Inject(AccessControlService) + private readonly service: AccessControlServiceInterface, + private readonly reflector: Reflector, + private readonly moduleRef: ModuleRef, + private readonly queryBus: QueryBus, + ) {} + + async execute(query: CheckAccessQuery): Promise { + return this.checkAccessGrants(query.executionContext); + } + + protected async checkAccessGrants( + context: ExecutionContext, + ): Promise { + const rules = this.settings.rules; + + const acGrants = this.reflector.get( + ACCESS_CONTROL_MODULE_GRANT_METADATA, + context.getHandler(), + ); + + if (!acGrants || !Array.isArray(acGrants)) { + return true; + } + + const userRoles = await this.queryBus.execute< + ResolveUserRolesQuery, + string | string[] + >(new ResolveUserRolesQuery(context)); + const possessions = [PossessionEnum.ANY, PossessionEnum.OWN]; + const queriesPermitted: IQueryInfo[] = []; + + loopGrants: for (const acGrant of acGrants) { + for (const possession of possessions) { + const query: IQueryInfo = { + role: userRoles, + possession, + ...acGrant, + }; + const permission = rules.permission(query); + if (permission.granted) { + queriesPermitted.push(query); + break loopGrants; + } + } + } + + if (queriesPermitted.length) { + return this.checkAccessQueries(context, queriesPermitted); + } + + return false; + } + + protected async checkAccessQueries( + context: ExecutionContext, + queriesPermitted: IQueryInfo[], + ): Promise { + const targets = [context.getClass(), context.getHandler()]; + + const acQueries = this.reflector.getAllAndMerge< + AccessControlQueryOptionInterface[] + >( + ACCESS_CONTROL_MODULE_QUERY_METADATA, + targets.filter((t) => t), + ); + + if (!acQueries || !Array.isArray(acQueries) || !acQueries.length) { + return true; + } + + const request: unknown = context.switchToHttp().getRequest(); + + if (!request || typeof request !== 'object') { + return false; + } + + const user = await this.service.getUser(context); + + let authorized = true; + + loopQueries: for await (const acQuery of acQueries) { + const service = await this.getQueryService(acQuery); + + for await (const query of queriesPermitted) { + const accessControlContext = new AccessControlContext({ + request, + user, + query, + accessControl: this.settings.rules, + executionContext: context, + }); + + authorized = await service.canAccess(accessControlContext); + + if (authorized) { + break loopQueries; + } + } + } + + return authorized; + } + + private async getQueryService( + queryOption: AccessControlQueryOptionInterface, + ): Promise { + const queryService = this.moduleRef.resolve(queryOption.service); + + if (queryService) { + return queryService; + } else { + throw new AccessControllerException( + `Access control guard was unable to resolve service ${queryOption.service.name}`, + ); + } + } +} diff --git a/packages/nestjs-access-control/src/application/queries/handlers/filter-response-attributes.handler.spec.ts b/packages/nestjs-access-control/src/application/queries/handlers/filter-response-attributes.handler.spec.ts new file mode 100644 index 000000000..8b594e233 --- /dev/null +++ b/packages/nestjs-access-control/src/application/queries/handlers/filter-response-attributes.handler.spec.ts @@ -0,0 +1,101 @@ +import { AccessControl } from 'accesscontrol'; +import { mock } from 'vitest-mock-extended'; + +import { type ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { type QueryBus } from '@nestjs/cqrs'; + +import { ActionEnum } from '@concepta/nestjs-core'; + +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../../../access-control.constants.js'; +import { type AccessControlSettingsInterface } from '../../../infrastructure/config/interfaces/access-control-settings.interface.js'; +import { FilterResponseAttributesQuery } from '../impl/filter-response-attributes.query.js'; + +import { FilterResponseAttributesHandler } from './filter-response-attributes.handler.js'; + +describe(FilterResponseAttributesHandler.name, () => { + const resource = 'user_resource'; + + function buildRules() { + const rules = new AccessControl(); + rules.grant('viewer').readAny(resource, ['firstName', 'lastName']); + rules.lock(); + return rules; + } + + function buildHandler(rules: AccessControl, roles: string[]) { + const settings: AccessControlSettingsInterface = { rules }; + const reflector = new Reflector(); + const queryBus = mock(); + queryBus.execute.mockResolvedValue(roles); + return { + handler: new FilterResponseAttributesHandler( + settings, + reflector, + queryBus, + ), + reflector, + queryBus, + }; + } + + it('returns data unchanged when no grants are set on the handler', async () => { + const { handler } = buildHandler(buildRules(), ['viewer']); + const context = mock(); + context.getHandler.mockReturnValue(() => undefined); + + const data = { firstName: 'A', lastName: 'B', phone: 'C' }; + const result = await handler.execute( + new FilterResponseAttributesQuery(context, data), + ); + + expect(result).toEqual(data); + }); + + it('filters data to allowed attributes when permission is granted', async () => { + const rules = buildRules(); + const { handler } = buildHandler(rules, ['viewer']); + const handlerFn = () => undefined; + Reflect.defineMetadata( + ACCESS_CONTROL_MODULE_GRANT_METADATA, + [{ resource, action: ActionEnum.READ }], + handlerFn, + ); + + const context = mock(); + context.getHandler.mockReturnValue(handlerFn); + + const result = await handler.execute( + new FilterResponseAttributesQuery(context, { + firstName: 'A', + lastName: 'B', + phone: 'C', + }), + ); + + expect(result).toEqual({ firstName: 'A', lastName: 'B' }); + }); + + it('returns data unchanged when the role has no grant for the resource', async () => { + const rules = new AccessControl(); + rules.grant('viewer').readAny('other_resource', ['firstName']); + rules.lock(); + const { handler } = buildHandler(rules, ['viewer']); + const handlerFn = () => undefined; + Reflect.defineMetadata( + ACCESS_CONTROL_MODULE_GRANT_METADATA, + [{ resource, action: ActionEnum.READ }], + handlerFn, + ); + + const context = mock(); + context.getHandler.mockReturnValue(handlerFn); + + const data = { firstName: 'A', lastName: 'B', phone: 'C' }; + const result = await handler.execute( + new FilterResponseAttributesQuery(context, data), + ); + + expect(result).toEqual(data); + }); +}); diff --git a/packages/nestjs-access-control/src/application/queries/handlers/filter-response-attributes.handler.ts b/packages/nestjs-access-control/src/application/queries/handlers/filter-response-attributes.handler.ts new file mode 100644 index 000000000..2419f786a --- /dev/null +++ b/packages/nestjs-access-control/src/application/queries/handlers/filter-response-attributes.handler.ts @@ -0,0 +1,75 @@ +import { IQueryInfo } from 'accesscontrol'; + +import { Inject } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IQueryHandler, QueryBus, QueryHandler } from '@nestjs/cqrs'; + +import { + ACCESS_CONTROL_MODULE_GRANT_METADATA, + ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, +} from '../../../access-control.constants.js'; +import { PossessionEnum } from '../../../domain/enums/possession.enum.js'; +import { AccessControlGrantOptionInterface } from '../../../domain/interfaces/access-control-grant-option.interface.js'; +import { AccessControlSettingsInterface } from '../../../infrastructure/config/interfaces/access-control-settings.interface.js'; +import { FilterResponseAttributesQuery } from '../impl/filter-response-attributes.query.js'; +import { ResolveUserRolesQuery } from '../impl/resolve-user-roles.query.js'; + +@QueryHandler(FilterResponseAttributesQuery) +export class FilterResponseAttributesHandler implements IQueryHandler< + FilterResponseAttributesQuery, + unknown +> { + constructor( + @Inject(ACCESS_CONTROL_MODULE_SETTINGS_TOKEN) + private readonly settings: AccessControlSettingsInterface, + private readonly reflector: Reflector, + private readonly queryBus: QueryBus, + ) {} + + async execute(query: FilterResponseAttributesQuery): Promise { + const { executionContext, data } = query; + + const acGrants = this.reflector.get( + ACCESS_CONTROL_MODULE_GRANT_METADATA, + executionContext.getHandler(), + ); + + if (!acGrants || !Array.isArray(acGrants)) { + return data; + } + + const userRoles = await this.queryBus.execute< + ResolveUserRolesQuery, + string | string[] + >(new ResolveUserRolesQuery(executionContext)); + + for (const grant of acGrants) { + let permission = this.getPermission(userRoles, grant, PossessionEnum.ANY); + if (permission.granted && permission.attributes) + return permission.filter(data); + + permission = this.getPermission(userRoles, grant, PossessionEnum.OWN); + if (permission.granted && permission.attributes) + return permission.filter(data); + + return data; + } + + return data; + } + + private getPermission( + userRoles: string | string[], + grant: AccessControlGrantOptionInterface, + possession: PossessionEnum, + ) { + const rules = this.settings.rules; + const query: IQueryInfo = { + role: userRoles, + action: grant.action, + resource: grant.resource, + possession, + }; + return rules.permission(query); + } +} diff --git a/packages/nestjs-access-control/src/application/queries/handlers/resolve-user-roles.handler.spec.ts b/packages/nestjs-access-control/src/application/queries/handlers/resolve-user-roles.handler.spec.ts new file mode 100644 index 000000000..b39b75b4d --- /dev/null +++ b/packages/nestjs-access-control/src/application/queries/handlers/resolve-user-roles.handler.spec.ts @@ -0,0 +1,36 @@ +import { mock } from 'vitest-mock-extended'; + +import { type ExecutionContext } from '@nestjs/common'; + +import { type AccessControlServiceInterface } from '../../../domain/ports/access-control-service.interface.js'; +import { ResolveUserRolesQuery } from '../impl/resolve-user-roles.query.js'; + +import { ResolveUserRolesHandler } from './resolve-user-roles.handler.js'; + +describe(ResolveUserRolesHandler.name, () => { + it('delegates to AccessControlService.getUserRoles with the execution context', async () => { + const service = mock(); + service.getUserRoles.mockResolvedValue(['admin', 'user']); + + const handler = new ResolveUserRolesHandler(service); + const context = mock(); + + const result = await handler.execute(new ResolveUserRolesQuery(context)); + + expect(result).toEqual(['admin', 'user']); + expect(service.getUserRoles).toHaveBeenCalledTimes(1); + expect(service.getUserRoles).toHaveBeenCalledWith(context); + }); + + it('forwards a single-role string return value', async () => { + const service = mock(); + service.getUserRoles.mockResolvedValue('admin'); + + const handler = new ResolveUserRolesHandler(service); + const context = mock(); + + const result = await handler.execute(new ResolveUserRolesQuery(context)); + + expect(result).toEqual('admin'); + }); +}); diff --git a/packages/nestjs-access-control/src/application/queries/handlers/resolve-user-roles.handler.ts b/packages/nestjs-access-control/src/application/queries/handlers/resolve-user-roles.handler.ts new file mode 100644 index 000000000..a9a2fa333 --- /dev/null +++ b/packages/nestjs-access-control/src/application/queries/handlers/resolve-user-roles.handler.ts @@ -0,0 +1,21 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { AccessControlServiceInterface } from '../../../domain/ports/access-control-service.interface.js'; +import { AccessControlService } from '../../../infrastructure/services/access-control.service.js'; +import { ResolveUserRolesQuery } from '../impl/resolve-user-roles.query.js'; + +@QueryHandler(ResolveUserRolesQuery) +export class ResolveUserRolesHandler implements IQueryHandler< + ResolveUserRolesQuery, + string | string[] +> { + constructor( + @Inject(AccessControlService) + private readonly service: AccessControlServiceInterface, + ) {} + + async execute(query: ResolveUserRolesQuery): Promise { + return this.service.getUserRoles(query.executionContext); + } +} diff --git a/packages/nestjs-access-control/src/application/queries/impl/check-access.query.ts b/packages/nestjs-access-control/src/application/queries/impl/check-access.query.ts new file mode 100644 index 000000000..e4875d24b --- /dev/null +++ b/packages/nestjs-access-control/src/application/queries/impl/check-access.query.ts @@ -0,0 +1,13 @@ +import { type ExecutionContext } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type CheckAccessQueryInterface } from '../../ports/access-control.port.js'; + +export class CheckAccessQuery + extends Query + implements CheckAccessQueryInterface +{ + constructor(public readonly executionContext: ExecutionContext) { + super(); + } +} diff --git a/packages/nestjs-access-control/src/application/queries/impl/filter-response-attributes.query.ts b/packages/nestjs-access-control/src/application/queries/impl/filter-response-attributes.query.ts new file mode 100644 index 000000000..f36e4567a --- /dev/null +++ b/packages/nestjs-access-control/src/application/queries/impl/filter-response-attributes.query.ts @@ -0,0 +1,16 @@ +import { type ExecutionContext } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type FilterResponseAttributesQueryInterface } from '../../ports/access-control.port.js'; + +export class FilterResponseAttributesQuery + extends Query + implements FilterResponseAttributesQueryInterface +{ + constructor( + public readonly executionContext: ExecutionContext, + public readonly data: unknown, + ) { + super(); + } +} diff --git a/packages/nestjs-access-control/src/application/queries/impl/resolve-user-roles.query.ts b/packages/nestjs-access-control/src/application/queries/impl/resolve-user-roles.query.ts new file mode 100644 index 000000000..c17a7f027 --- /dev/null +++ b/packages/nestjs-access-control/src/application/queries/impl/resolve-user-roles.query.ts @@ -0,0 +1,13 @@ +import { type ExecutionContext } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type ResolveUserRolesQueryInterface } from '../../ports/access-control.port.js'; + +export class ResolveUserRolesQuery + extends Query + implements ResolveUserRolesQueryInterface +{ + constructor(public readonly executionContext: ExecutionContext) { + super(); + } +} diff --git a/packages/nestjs-access-control/src/config/acess-control-default.config.ts b/packages/nestjs-access-control/src/config/acess-control-default.config.ts deleted file mode 100644 index 9c6d7f715..000000000 --- a/packages/nestjs-access-control/src/config/acess-control-default.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN } from '../constants'; -import { AccessControlSettingsInterface } from '../interfaces/access-control-settings.interface'; - -/** - * Default configuration for access control. - */ -export const accessControlDefaultConfig = registerAs( - ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN, - (): Partial => ({}), -); diff --git a/packages/nestjs-access-control/src/constants.spec.ts b/packages/nestjs-access-control/src/constants.spec.ts deleted file mode 100644 index 596c8168f..000000000 --- a/packages/nestjs-access-control/src/constants.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, - ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN, - ACCESS_CONTROL_MODULE_QUERY_METADATA, - ACCESS_CONTROL_MODULE_GRANT_METADATA, -} from './constants'; - -describe('Constants', () => { - it('Should each match expected value', () => { - expect(ACCESS_CONTROL_MODULE_SETTINGS_TOKEN).toEqual( - 'ACCESS_CONTROL_MODULE_SETTINGS_TOKEN', - ); - expect(ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN).toEqual( - 'ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN', - ); - expect(ACCESS_CONTROL_MODULE_QUERY_METADATA).toEqual( - 'ACCESS_CONTROL_MODULE_QUERY_METADATA', - ); - expect(ACCESS_CONTROL_MODULE_GRANT_METADATA).toEqual( - 'ACCESS_CONTROL_MODULE_GRANT_METADATA', - ); - }); -}); diff --git a/packages/nestjs-access-control/src/constants.ts b/packages/nestjs-access-control/src/constants.ts deleted file mode 100644 index 5841b3e03..000000000 --- a/packages/nestjs-access-control/src/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const ACCESS_CONTROL_MODULE_SETTINGS_TOKEN = - 'ACCESS_CONTROL_MODULE_SETTINGS_TOKEN'; - -export const ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN = - 'ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN'; - -export const ACCESS_CONTROL_MODULE_GRANT_METADATA = - 'ACCESS_CONTROL_MODULE_GRANT_METADATA'; - -export const ACCESS_CONTROL_MODULE_QUERY_METADATA = - 'ACCESS_CONTROL_MODULE_QUERY_METADATA'; diff --git a/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.ts deleted file mode 100644 index 8b41b513f..000000000 --- a/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; -import { AccessControlGrantOptionInterface } from '../interfaces/access-control-grant-option.interface'; - -/** - * Define access control grants required for this route. - * - * @param acGrants - Array of access control grants. - * @returns Decorator function. - */ -export const AccessControlGrant = ( - ...acGrants: AccessControlGrantOptionInterface[] -): ReturnType => { - return SetMetadata(ACCESS_CONTROL_MODULE_GRANT_METADATA, acGrants); -}; diff --git a/packages/nestjs-access-control/src/decorators/access-control-query.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-query.decorator.ts deleted file mode 100644 index 5eb08c0dd..000000000 --- a/packages/nestjs-access-control/src/decorators/access-control-query.decorator.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -import { ACCESS_CONTROL_MODULE_QUERY_METADATA } from '../constants'; -import { AccessControlQueryOptionInterface } from '../interfaces/access-control-query-option.interface'; - -/** - * Define access query options for this route. - * - * @param queryOptions - Array of access control query options. - * @returns Decorator function. - */ -export const AccessControlQuery = ( - ...queryOptions: AccessControlQueryOptionInterface[] -): ReturnType => { - return SetMetadata(ACCESS_CONTROL_MODULE_QUERY_METADATA, queryOptions); -}; diff --git a/packages/nestjs-access-control/src/access-control.context.spec.ts b/packages/nestjs-access-control/src/domain/access-control.context.spec.ts similarity index 77% rename from packages/nestjs-access-control/src/access-control.context.spec.ts rename to packages/nestjs-access-control/src/domain/access-control.context.spec.ts index a4bdfff39..58af54452 100644 --- a/packages/nestjs-access-control/src/access-control.context.spec.ts +++ b/packages/nestjs-access-control/src/domain/access-control.context.spec.ts @@ -1,13 +1,20 @@ import { AccessControl } from 'accesscontrol'; -import { mock } from 'jest-mock-extended'; +import { mock } from 'vitest-mock-extended'; -import { Controller } from '@nestjs/common'; -import { ExecutionContext, HttpArgumentsHost } from '@nestjs/common/interfaces'; +import { + type ArgumentsHost, + Controller, + type ExecutionContext, +} from '@nestjs/common'; -import { AccessControlContext } from './access-control.context'; -import { AccessControlReadOne } from './decorators/access-control-read-one.decorator'; -import { ActionEnum } from './enums/action.enum'; -import { PossessionEnum } from './enums/possession.enum'; +type HttpArgumentsHost = ReturnType; + +import { ActionEnum } from '@concepta/nestjs-core'; + +import { AccessControlReadOne } from '../gateways/decorators/access-control-read-one.decorator.js'; + +import { AccessControlContext } from './access-control.context.js'; +import { PossessionEnum } from './enums/possession.enum.js'; describe(AccessControlContext.name, () => { it('should return expected values', () => { diff --git a/packages/nestjs-access-control/src/domain/access-control.context.ts b/packages/nestjs-access-control/src/domain/access-control.context.ts new file mode 100644 index 000000000..7ee4d0841 --- /dev/null +++ b/packages/nestjs-access-control/src/domain/access-control.context.ts @@ -0,0 +1,45 @@ +import { type AccessControl, type IQueryInfo } from 'accesscontrol'; + +import { type ExecutionContext } from '@nestjs/common'; + +import { type AccessControlContextArgsInterface } from './interfaces/access-control-context-args.interface.js'; +import { type AccessControlContextInterface } from './interfaces/access-control-context.interface.js'; + +export class AccessControlContext implements AccessControlContextInterface { + constructor(private readonly ctxArgs: AccessControlContextArgsInterface) {} + + protected hasProp( + obj: unknown, + key: K, + ): obj is Record { + return ( + key !== null && obj !== null && typeof obj === 'object' && key in obj + ); + } + + protected getProp(obj: unknown, prop: string) { + return this.hasProp(obj, prop) ? obj[prop] : undefined; + } + + getRequest(property?: string): unknown { + return property?.length + ? this.getProp(this.ctxArgs.request, property) + : this.ctxArgs.request; + } + + getUser(): unknown { + return this.ctxArgs.user; + } + + getQuery(): IQueryInfo { + return this.ctxArgs.query; + } + + getAccessControl(): AccessControl { + return this.ctxArgs.accessControl; + } + + getExecutionContext(): ExecutionContext { + return this.ctxArgs.executionContext; + } +} diff --git a/packages/nestjs-access-control/src/domain/enums/possession.enum.spec.ts b/packages/nestjs-access-control/src/domain/enums/possession.enum.spec.ts new file mode 100644 index 000000000..d5ca39886 --- /dev/null +++ b/packages/nestjs-access-control/src/domain/enums/possession.enum.spec.ts @@ -0,0 +1,12 @@ +import { Possession } from 'accesscontrol/lib/enums/index.js'; + +import { PossessionEnum } from './possession.enum.js'; + +describe('Access control possession enumeration', () => { + it('should match specification', () => { + expect(PossessionEnum).toEqual({ + ANY: Possession.ANY, + OWN: Possession.OWN, + }); + }); +}); diff --git a/packages/nestjs-access-control/src/enums/possession.enum.ts b/packages/nestjs-access-control/src/domain/enums/possession.enum.ts similarity index 100% rename from packages/nestjs-access-control/src/enums/possession.enum.ts rename to packages/nestjs-access-control/src/domain/enums/possession.enum.ts diff --git a/packages/nestjs-access-control/src/exceptions/access-controller.exception.ts b/packages/nestjs-access-control/src/domain/exceptions/access-controller.exception.ts similarity index 75% rename from packages/nestjs-access-control/src/exceptions/access-controller.exception.ts rename to packages/nestjs-access-control/src/domain/exceptions/access-controller.exception.ts index 2247612b4..fba9cc695 100644 --- a/packages/nestjs-access-control/src/exceptions/access-controller.exception.ts +++ b/packages/nestjs-access-control/src/domain/exceptions/access-controller.exception.ts @@ -1,12 +1,13 @@ import { RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; export class AccessControllerException extends RuntimeException { constructor(message: string, options?: RuntimeExceptionOptions) { super({ message, + fault: 'usage', ...options, }); this.errorCode = 'ACCESS_CONTROLLER_ERROR'; diff --git a/packages/nestjs-access-control/src/domain/interfaces/access-control-context-args.interface.ts b/packages/nestjs-access-control/src/domain/interfaces/access-control-context-args.interface.ts new file mode 100644 index 000000000..8eac13801 --- /dev/null +++ b/packages/nestjs-access-control/src/domain/interfaces/access-control-context-args.interface.ts @@ -0,0 +1,11 @@ +import { type AccessControl, type IQueryInfo } from 'accesscontrol'; + +import { type ExecutionContext } from '@nestjs/common'; + +export interface AccessControlContextArgsInterface { + user: unknown; + request: unknown; + query: IQueryInfo; + accessControl: AccessControl; + executionContext: ExecutionContext; +} diff --git a/packages/nestjs-access-control/src/domain/interfaces/access-control-context.interface.ts b/packages/nestjs-access-control/src/domain/interfaces/access-control-context.interface.ts new file mode 100644 index 000000000..6375945a9 --- /dev/null +++ b/packages/nestjs-access-control/src/domain/interfaces/access-control-context.interface.ts @@ -0,0 +1,11 @@ +import { type AccessControl, type IQueryInfo } from 'accesscontrol'; + +import { type ExecutionContext } from '@nestjs/common'; + +export interface AccessControlContextInterface { + getRequest(property?: string): unknown; + getUser(): unknown; + getQuery(): IQueryInfo; + getAccessControl(): AccessControl; + getExecutionContext(): ExecutionContext; +} diff --git a/packages/nestjs-access-control/src/domain/interfaces/access-control-grant-option.interface.ts b/packages/nestjs-access-control/src/domain/interfaces/access-control-grant-option.interface.ts new file mode 100644 index 000000000..7a9cb1186 --- /dev/null +++ b/packages/nestjs-access-control/src/domain/interfaces/access-control-grant-option.interface.ts @@ -0,0 +1,6 @@ +import { type ActionEnum } from '@concepta/nestjs-core'; + +export interface AccessControlGrantOptionInterface { + resource: string; + action: ActionEnum; +} diff --git a/packages/nestjs-access-control/src/domain/interfaces/access-control-metadata.interface.ts b/packages/nestjs-access-control/src/domain/interfaces/access-control-metadata.interface.ts new file mode 100644 index 000000000..bff9c10b0 --- /dev/null +++ b/packages/nestjs-access-control/src/domain/interfaces/access-control-metadata.interface.ts @@ -0,0 +1,7 @@ +import { type Type } from '@nestjs/common'; + +import { type AccessControlServiceInterface } from '../ports/access-control-service.interface.js'; + +export interface AccessControlMetadataInterface { + service?: Type; +} diff --git a/packages/nestjs-access-control/src/domain/interfaces/access-control-query-option.interface.ts b/packages/nestjs-access-control/src/domain/interfaces/access-control-query-option.interface.ts new file mode 100644 index 000000000..793f27b32 --- /dev/null +++ b/packages/nestjs-access-control/src/domain/interfaces/access-control-query-option.interface.ts @@ -0,0 +1,10 @@ +import { type Type } from '@nestjs/common'; + +import { type CanAccess } from '../policies/can-access.policy.js'; + +export interface AccessControlQueryOptionInterface { + /** + * Service used for advanced validation + */ + service: Type; +} diff --git a/packages/nestjs-access-control/src/domain/policies/can-access.policy.ts b/packages/nestjs-access-control/src/domain/policies/can-access.policy.ts new file mode 100644 index 000000000..ca7eb0b35 --- /dev/null +++ b/packages/nestjs-access-control/src/domain/policies/can-access.policy.ts @@ -0,0 +1,5 @@ +import { type AccessControlContextInterface } from '../interfaces/access-control-context.interface.js'; + +export interface CanAccess { + canAccess(context: AccessControlContextInterface): Promise; +} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-service.interface.ts b/packages/nestjs-access-control/src/domain/ports/access-control-service.interface.ts similarity index 76% rename from packages/nestjs-access-control/src/interfaces/access-control-service.interface.ts rename to packages/nestjs-access-control/src/domain/ports/access-control-service.interface.ts index 239e475e6..8dbe42bd9 100644 --- a/packages/nestjs-access-control/src/interfaces/access-control-service.interface.ts +++ b/packages/nestjs-access-control/src/domain/ports/access-control-service.interface.ts @@ -1,4 +1,4 @@ -import { ExecutionContext } from '@nestjs/common'; +import { type ExecutionContext } from '@nestjs/common'; export interface AccessControlServiceInterface { getUser(context: ExecutionContext): Promise; diff --git a/packages/nestjs-access-control/src/enums/action.enum.spec.ts b/packages/nestjs-access-control/src/enums/action.enum.spec.ts deleted file mode 100644 index 010c7cf1d..000000000 --- a/packages/nestjs-access-control/src/enums/action.enum.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Action } from 'accesscontrol/lib/enums'; - -import { ActionEnum } from './action.enum'; - -describe('Access control action enumeration', () => { - it('should match specification', () => { - expect(ActionEnum).toEqual({ - CREATE: Action.CREATE, - READ: Action.READ, - UPDATE: Action.UPDATE, - DELETE: Action.DELETE, - }); - }); -}); diff --git a/packages/nestjs-access-control/src/enums/possession.enum.spec.ts b/packages/nestjs-access-control/src/enums/possession.enum.spec.ts deleted file mode 100644 index a2b87c720..000000000 --- a/packages/nestjs-access-control/src/enums/possession.enum.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Possession } from 'accesscontrol/lib/enums'; - -import { PossessionEnum } from './possession.enum'; - -describe('Access control possession enumeration', () => { - it('should match specification', () => { - expect(PossessionEnum).toEqual({ - ANY: Possession.ANY, - OWN: Possession.OWN, - }); - }); -}); diff --git a/packages/nestjs-access-control/src/filter/access-control.filter.ts b/packages/nestjs-access-control/src/filter/access-control.filter.ts deleted file mode 100644 index 1cb7649d4..000000000 --- a/packages/nestjs-access-control/src/filter/access-control.filter.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { IQueryInfo } from 'accesscontrol'; -import { map } from 'rxjs/operators'; - -import { - CallHandler, - ExecutionContext, - Inject, - Injectable, - NestInterceptor, -} from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -import { - ACCESS_CONTROL_MODULE_GRANT_METADATA, - ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, -} from '../constants'; -import { PossessionEnum } from '../enums/possession.enum'; -import { AccessControlGrantOptionInterface } from '../interfaces/access-control-grant-option.interface'; -import { AccessControlServiceInterface } from '../interfaces/access-control-service.interface'; -import { AccessControlSettingsInterface } from '../interfaces/access-control-settings.interface'; -import { AccessControlService } from '../services/access-control.service'; - -@Injectable() -export class AccessControlFilter implements NestInterceptor { - constructor( - @Inject(ACCESS_CONTROL_MODULE_SETTINGS_TOKEN) - private readonly settings: AccessControlSettingsInterface, - @Inject(AccessControlService) - private readonly service: AccessControlServiceInterface, - private readonly reflector: Reflector, - ) {} - - async intercept(context: ExecutionContext, next: CallHandler) { - // get my method and its metadata - const acGrants = this.reflector.get( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - context.getHandler(), - ); - - if (!acGrants || !Array.isArray(acGrants)) { - return next.handle(); - } - // get roles of my user, they will be used to see if they have access - // and how attribute filter should be applied - const userRoles = await this.service.getUserRoles(context); - - // today, we can define what permission, so lets check for both - return next.handle().pipe( - map((data) => { - for (const grant of acGrants) { - // filter based on any - let permission = this.getPermission( - userRoles, - grant, - PossessionEnum.ANY, - ); - if (permission.granted && permission.attributes) - return permission.filter(data); - - // filter based on own - permission = this.getPermission(userRoles, grant, PossessionEnum.OWN); - if (permission.granted && permission.attributes) - return permission.filter(data); - - // if none just return data - return data; - } - }), - ); - } - - private getPermission( - userRoles: string | string[], - grant: AccessControlGrantOptionInterface, - possession: PossessionEnum, - ) { - const rules = this.settings.rules; - const query: IQueryInfo = { - role: userRoles, - action: grant.action, - resource: grant.resource, - possession, - }; - // get permission object - return rules.permission(query); - } -} diff --git a/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.spec.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-create-many.decorator.spec.ts similarity index 82% rename from packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.spec.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-create-many.decorator.spec.ts index 1f24781c8..0993454eb 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.spec.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-create-many.decorator.spec.ts @@ -1,9 +1,10 @@ import { Controller } from '@nestjs/common'; -import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlCreateMany } from './access-control-create-many.decorator'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../../access-control.constants.js'; + +import { AccessControlCreateMany } from './access-control-create-many.decorator.js'; describe('@AccessControlCreateOne', () => { const resource = 'a_protected_resource'; diff --git a/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-create-many.decorator.ts similarity index 93% rename from packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-create-many.decorator.ts index 98b35d2ac..0a7227de1 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-create-many.decorator.ts @@ -1,4 +1,4 @@ -import { AccessControlCreateOne } from './access-control-create-one.decorator'; +import { AccessControlCreateOne } from './access-control-create-one.decorator.js'; /** * Create many resource grant shortcut. diff --git a/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.spec.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-create-one.decorator.spec.ts similarity index 82% rename from packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.spec.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-create-one.decorator.spec.ts index 11eab9005..9d7ef29ad 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-create-one.decorator.spec.ts @@ -1,9 +1,10 @@ import { Controller } from '@nestjs/common'; -import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlCreateOne } from './access-control-create-one.decorator'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../../access-control.constants.js'; + +import { AccessControlCreateOne } from './access-control-create-one.decorator.js'; describe('@AccessControlCreateOne', () => { const resource = 'a_protected_resource'; diff --git a/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-create-one.decorator.ts similarity index 76% rename from packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-create-one.decorator.ts index 896ae7d90..7ef8253dd 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-create-one.decorator.ts @@ -1,8 +1,8 @@ -import { applyDecorators } from '@nestjs/common'; +import { type applyDecorators } from '@nestjs/common'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlGrant } from './access-control-grant.decorator'; +import { AccessControlGrant } from './access-control-grant.decorator.js'; /** * Create one resource grant shortcut. diff --git a/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.spec.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-delete-one.decorator.spec.ts similarity index 82% rename from packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.spec.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-delete-one.decorator.spec.ts index 84995cd25..129cdbae2 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-delete-one.decorator.spec.ts @@ -1,9 +1,10 @@ import { Controller } from '@nestjs/common'; -import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlDeleteOne } from './access-control-delete-one.decorator'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../../access-control.constants.js'; + +import { AccessControlDeleteOne } from './access-control-delete-one.decorator.js'; describe('@AccessControlDeleteOne', () => { const resource = 'a_protected_resource'; diff --git a/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-delete-one.decorator.ts similarity index 76% rename from packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-delete-one.decorator.ts index 76f908f17..907284720 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-delete-one.decorator.ts @@ -1,8 +1,8 @@ -import { applyDecorators } from '@nestjs/common'; +import { type applyDecorators } from '@nestjs/common'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlGrant } from './access-control-grant.decorator'; +import { AccessControlGrant } from './access-control-grant.decorator.js'; /** * Delete one resource grant shortcut. diff --git a/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.spec.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-grant.decorator.spec.ts similarity index 93% rename from packages/nestjs-access-control/src/decorators/access-control-grant.decorator.spec.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-grant.decorator.spec.ts index a08fafd36..442bed1f0 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.spec.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-grant.decorator.spec.ts @@ -1,9 +1,10 @@ import { Controller } from '@nestjs/common'; -import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlGrant } from './access-control-grant.decorator'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../../access-control.constants.js'; + +import { AccessControlGrant } from './access-control-grant.decorator.js'; describe('@AccessControlGrant', () => { const resource = 'a_protected_resource'; diff --git a/packages/nestjs-access-control/src/gateways/decorators/access-control-grant.decorator.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-grant.decorator.ts new file mode 100644 index 000000000..2ce7f105a --- /dev/null +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-grant.decorator.ts @@ -0,0 +1,16 @@ +import { SetMetadata } from '@nestjs/common'; + +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../../access-control.constants.js'; +import { type AccessControlGrantOptionInterface } from '../../domain/interfaces/access-control-grant-option.interface.js'; + +/** + * Define access control grants required for this route. + * + * @param acGrants - Array of access control grants. + * @returns Decorator function. + */ +export const AccessControlGrant = ( + ...acGrants: AccessControlGrantOptionInterface[] +): ReturnType => { + return SetMetadata(ACCESS_CONTROL_MODULE_GRANT_METADATA, acGrants); +}; diff --git a/packages/nestjs-access-control/src/decorators/access-control-query.decorator.spec.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-query.decorator.spec.ts similarity index 75% rename from packages/nestjs-access-control/src/decorators/access-control-query.decorator.spec.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-query.decorator.spec.ts index cfb95bf91..3acc6a06b 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-query.decorator.spec.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-query.decorator.spec.ts @@ -1,10 +1,10 @@ import { Controller } from '@nestjs/common'; -import { ACCESS_CONTROL_MODULE_QUERY_METADATA } from '../constants'; -import { AccessControlContextInterface } from '../interfaces/access-control-context.interface'; -import { CanAccess } from '../interfaces/can-access.interface'; +import { ACCESS_CONTROL_MODULE_QUERY_METADATA } from '../../access-control.constants.js'; +import { AccessControlContextInterface } from '../../domain/interfaces/access-control-context.interface.js'; +import { CanAccess } from '../../domain/policies/can-access.policy.js'; -import { AccessControlQuery } from './access-control-query.decorator'; +import { AccessControlQuery } from './access-control-query.decorator.js'; describe('@AccessControlQuery', () => { class TestQueryService implements CanAccess { diff --git a/packages/nestjs-access-control/src/gateways/decorators/access-control-query.decorator.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-query.decorator.ts new file mode 100644 index 000000000..e78ff0f2c --- /dev/null +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-query.decorator.ts @@ -0,0 +1,16 @@ +import { SetMetadata } from '@nestjs/common'; + +import { ACCESS_CONTROL_MODULE_QUERY_METADATA } from '../../access-control.constants.js'; +import { type AccessControlQueryOptionInterface } from '../../domain/interfaces/access-control-query-option.interface.js'; + +/** + * Define access query options for this route. + * + * @param queryOptions - Array of access control query options. + * @returns Decorator function. + */ +export const AccessControlQuery = ( + ...queryOptions: AccessControlQueryOptionInterface[] +): ReturnType => { + return SetMetadata(ACCESS_CONTROL_MODULE_QUERY_METADATA, queryOptions); +}; diff --git a/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.spec.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-read-many.decorator.spec.ts similarity index 82% rename from packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.spec.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-read-many.decorator.spec.ts index ce5803eeb..f6111b2cf 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.spec.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-read-many.decorator.spec.ts @@ -1,9 +1,10 @@ import { Controller } from '@nestjs/common'; -import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlReadMany } from './access-control-read-many.decorator'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../../access-control.constants.js'; + +import { AccessControlReadMany } from './access-control-read-many.decorator.js'; describe('@AccessControlReadMany', () => { const resource = 'a_protected_resource'; diff --git a/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-read-many.decorator.ts similarity index 75% rename from packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-read-many.decorator.ts index 409b66bca..632ae8e65 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-read-many.decorator.ts @@ -1,8 +1,8 @@ -import { applyDecorators } from '@nestjs/common'; +import { type applyDecorators } from '@nestjs/common'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlGrant } from './access-control-grant.decorator'; +import { AccessControlGrant } from './access-control-grant.decorator.js'; /** * Read many resource grant shortcut. diff --git a/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.spec.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-read-one.decorator.spec.ts similarity index 82% rename from packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.spec.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-read-one.decorator.spec.ts index 1808d12f5..8ce2aeff1 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-read-one.decorator.spec.ts @@ -1,9 +1,10 @@ import { Controller } from '@nestjs/common'; -import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlReadOne } from './access-control-read-one.decorator'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../../access-control.constants.js'; + +import { AccessControlReadOne } from './access-control-read-one.decorator.js'; describe('@AccessControlReadOne', () => { const resource = 'a_protected_resource'; diff --git a/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-read-one.decorator.ts similarity index 75% rename from packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-read-one.decorator.ts index 8b787dc42..e1efb3a51 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-read-one.decorator.ts @@ -1,8 +1,8 @@ -import { applyDecorators } from '@nestjs/common'; +import { type applyDecorators } from '@nestjs/common'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlGrant } from './access-control-grant.decorator'; +import { AccessControlGrant } from './access-control-grant.decorator.js'; /** * Read one resource grant shortcut diff --git a/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.spec.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-recover-one.decorator.spec.ts similarity index 82% rename from packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.spec.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-recover-one.decorator.spec.ts index bf81406d6..efcb919e4 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-recover-one.decorator.spec.ts @@ -1,9 +1,10 @@ import { Controller } from '@nestjs/common'; -import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlRecoverOne } from './access-control-recover-one.decorator'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../../access-control.constants.js'; + +import { AccessControlRecoverOne } from './access-control-recover-one.decorator.js'; describe('@AccessControlCreateOne', () => { const resource = 'a_protected_resource'; diff --git a/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-recover-one.decorator.ts similarity index 93% rename from packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-recover-one.decorator.ts index c2c735eee..1fa36c1d1 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-recover-one.decorator.ts @@ -1,4 +1,4 @@ -import { AccessControlCreateOne } from './access-control-create-one.decorator'; +import { AccessControlCreateOne } from './access-control-create-one.decorator.js'; /** * Recover one resource grant shortcut. diff --git a/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.spec.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-replace-one.decorator.spec.ts similarity index 82% rename from packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.spec.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-replace-one.decorator.spec.ts index c220dcb0a..cf5d3c07a 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-replace-one.decorator.spec.ts @@ -1,9 +1,10 @@ import { Controller } from '@nestjs/common'; -import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlReplaceOne } from './access-control-replace-one.decorator'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../../access-control.constants.js'; + +import { AccessControlReplaceOne } from './access-control-replace-one.decorator.js'; describe('@AccessControlUpdateOne', () => { const resource = 'a_protected_resource'; diff --git a/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-replace-one.decorator.ts similarity index 93% rename from packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-replace-one.decorator.ts index f53c7054a..023d4ffd7 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-replace-one.decorator.ts @@ -1,4 +1,4 @@ -import { AccessControlUpdateOne } from './access-control-update-one.decorator'; +import { AccessControlUpdateOne } from './access-control-update-one.decorator.js'; /** * Update one resource grant shortcut. diff --git a/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.spec.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-update-one.decorator.spec.ts similarity index 82% rename from packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.spec.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-update-one.decorator.spec.ts index e16426e73..563397ba4 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-update-one.decorator.spec.ts @@ -1,9 +1,10 @@ import { Controller } from '@nestjs/common'; -import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlUpdateOne } from './access-control-update-one.decorator'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../../access-control.constants.js'; + +import { AccessControlUpdateOne } from './access-control-update-one.decorator.js'; describe('@AccessControlUpdateOne', () => { const resource = 'a_protected_resource'; diff --git a/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.ts b/packages/nestjs-access-control/src/gateways/decorators/access-control-update-one.decorator.ts similarity index 76% rename from packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.ts rename to packages/nestjs-access-control/src/gateways/decorators/access-control-update-one.decorator.ts index c8061d374..49c4ad48b 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.ts +++ b/packages/nestjs-access-control/src/gateways/decorators/access-control-update-one.decorator.ts @@ -1,8 +1,8 @@ -import { applyDecorators } from '@nestjs/common'; +import { type applyDecorators } from '@nestjs/common'; -import { ActionEnum } from '../enums/action.enum'; +import { ActionEnum } from '@concepta/nestjs-core'; -import { AccessControlGrant } from './access-control-grant.decorator'; +import { AccessControlGrant } from './access-control-grant.decorator.js'; /** * Update one resource grant shortcut. diff --git a/packages/nestjs-access-control/src/filter/access-control.filter.e2e-spec.ts b/packages/nestjs-access-control/src/gateways/http/access-control.filter.e2e-spec.ts similarity index 93% rename from packages/nestjs-access-control/src/filter/access-control.filter.e2e-spec.ts rename to packages/nestjs-access-control/src/gateways/http/access-control.filter.e2e-spec.ts index af24ae1dc..274e02ef8 100644 --- a/packages/nestjs-access-control/src/filter/access-control.filter.e2e-spec.ts +++ b/packages/nestjs-access-control/src/gateways/http/access-control.filter.e2e-spec.ts @@ -11,12 +11,12 @@ import { Reflector } from '@nestjs/core'; import { ApiResponse, ApiTags } from '@nestjs/swagger'; import { Test, TestingModule } from '@nestjs/testing'; -import { AccessControlModule } from '../access-control.module'; -import { ACCESS_CONTROL_MODULE_SETTINGS_TOKEN } from '../constants'; -import { AccessControlReadOne } from '../decorators/access-control-read-one.decorator'; -import { AccessControlOptionsInterface } from '../interfaces/access-control-options.interface'; -import { AccessControlServiceInterface } from '../interfaces/access-control-service.interface'; -import { AccessControlService } from '../services/access-control.service'; +import { ACCESS_CONTROL_MODULE_SETTINGS_TOKEN } from '../../access-control.constants.js'; +import { AccessControlModule } from '../../access-control.module.js'; +import { AccessControlServiceInterface } from '../../domain/ports/access-control-service.interface.js'; +import { AccessControlOptionsInterface } from '../../infrastructure/config/interfaces/access-control-options.interface.js'; +import { AccessControlService } from '../../infrastructure/services/access-control.service.js'; +import { AccessControlReadOne } from '../decorators/access-control-read-one.decorator.js'; describe('AccessControlFilter', () => { const resourceGetAll = 'resource_get_all'; diff --git a/packages/nestjs-access-control/src/gateways/http/access-control.filter.spec.ts b/packages/nestjs-access-control/src/gateways/http/access-control.filter.spec.ts new file mode 100644 index 000000000..0eceb1648 --- /dev/null +++ b/packages/nestjs-access-control/src/gateways/http/access-control.filter.spec.ts @@ -0,0 +1,44 @@ +import { firstValueFrom, of } from 'rxjs'; +import { mock } from 'vitest-mock-extended'; + +import { type CallHandler, type ExecutionContext } from '@nestjs/common'; + +import { type AccessControlPort } from '../../application/ports/access-control.port.js'; + +import { AccessControlFilter } from './access-control.filter.js'; + +describe(AccessControlFilter.name, () => { + it('delegates each emitted value to AccessControlPort.filterResponseAttributes', async () => { + const port = mock(); + port.filterResponseAttributes.mockImplementation(async (_ctx, data) => ({ + filtered: data, + })); + + const filter = new AccessControlFilter(port); + const context = mock(); + const callHandler = mock(); + callHandler.handle.mockReturnValue(of({ original: true })); + + const result = await firstValueFrom(filter.intercept(context, callHandler)); + + expect(result).toEqual({ filtered: { original: true } }); + expect(port.filterResponseAttributes).toHaveBeenCalledTimes(1); + expect(port.filterResponseAttributes).toHaveBeenCalledWith(context, { + original: true, + }); + }); + + it('passes data through when the port returns it unchanged', async () => { + const port = mock(); + port.filterResponseAttributes.mockResolvedValue('unchanged'); + + const filter = new AccessControlFilter(port); + const context = mock(); + const callHandler = mock(); + callHandler.handle.mockReturnValue(of('unchanged')); + + const result = await firstValueFrom(filter.intercept(context, callHandler)); + + expect(result).toEqual('unchanged'); + }); +}); diff --git a/packages/nestjs-access-control/src/gateways/http/access-control.filter.ts b/packages/nestjs-access-control/src/gateways/http/access-control.filter.ts new file mode 100644 index 000000000..992d0d1c3 --- /dev/null +++ b/packages/nestjs-access-control/src/gateways/http/access-control.filter.ts @@ -0,0 +1,31 @@ +import { Observable, from } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; + +import { + CallHandler, + ExecutionContext, + Inject, + Injectable, + NestInterceptor, +} from '@nestjs/common'; + +import { ACCESS_CONTROL_PORT_TOKEN } from '../../access-control.constants.js'; +import { AccessControlPort } from '../../application/ports/access-control.port.js'; + +@Injectable() +export class AccessControlFilter implements NestInterceptor { + constructor( + @Inject(ACCESS_CONTROL_PORT_TOKEN) + private readonly accessControlPort: AccessControlPort, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next + .handle() + .pipe( + mergeMap((data) => + from(this.accessControlPort.filterResponseAttributes(context, data)), + ), + ); + } +} diff --git a/packages/nestjs-access-control/src/gateways/http/access-control.guard.spec.ts b/packages/nestjs-access-control/src/gateways/http/access-control.guard.spec.ts new file mode 100644 index 000000000..418a36f88 --- /dev/null +++ b/packages/nestjs-access-control/src/gateways/http/access-control.guard.spec.ts @@ -0,0 +1,35 @@ +import { mock } from 'vitest-mock-extended'; + +import { type ExecutionContext } from '@nestjs/common'; + +import { type AccessControlPort } from '../../application/ports/access-control.port.js'; + +import { AccessControlGuard } from './access-control.guard.js'; + +describe(AccessControlGuard.name, () => { + it('delegates canActivate to AccessControlPort.checkAccess and returns its result', async () => { + const port = mock(); + port.checkAccess.mockResolvedValue(true); + + const guard = new AccessControlGuard(port); + const context = mock(); + + const result = await guard.canActivate(context); + + expect(result).toEqual(true); + expect(port.checkAccess).toHaveBeenCalledTimes(1); + expect(port.checkAccess).toHaveBeenCalledWith(context); + }); + + it('propagates a false result from the port', async () => { + const port = mock(); + port.checkAccess.mockResolvedValue(false); + + const guard = new AccessControlGuard(port); + const context = mock(); + + const result = await guard.canActivate(context); + + expect(result).toEqual(false); + }); +}); diff --git a/packages/nestjs-access-control/src/gateways/http/access-control.guard.ts b/packages/nestjs-access-control/src/gateways/http/access-control.guard.ts new file mode 100644 index 000000000..de9cb851d --- /dev/null +++ b/packages/nestjs-access-control/src/gateways/http/access-control.guard.ts @@ -0,0 +1,21 @@ +import { + CanActivate, + ExecutionContext, + Inject, + Injectable, +} from '@nestjs/common'; + +import { ACCESS_CONTROL_PORT_TOKEN } from '../../access-control.constants.js'; +import { AccessControlPort } from '../../application/ports/access-control.port.js'; + +@Injectable() +export class AccessControlGuard implements CanActivate { + constructor( + @Inject(ACCESS_CONTROL_PORT_TOKEN) + private readonly accessControlPort: AccessControlPort, + ) {} + + async canActivate(context: ExecutionContext): Promise { + return this.accessControlPort.checkAccess(context); + } +} diff --git a/packages/nestjs-access-control/src/index.spec.ts b/packages/nestjs-access-control/src/index.spec.ts index 8ecb2983c..26ad847e6 100644 --- a/packages/nestjs-access-control/src/index.spec.ts +++ b/packages/nestjs-access-control/src/index.spec.ts @@ -1,41 +1,65 @@ import { - ActionEnum, - PossessionEnum, + ACCESS_CONTROL_PORT_TOKEN, + AccessControlContext, AccessControlCreateMany, AccessControlCreateOne, AccessControlDeleteOne, - AccessControlQuery, + AccessControlFilter, AccessControlGrant, AccessControlGuard, AccessControlModule, - AccessControlContext, + AccessControlPort, + AccessControlQuery, AccessControlReadMany, AccessControlReadOne, AccessControlRecoverOne, AccessControlReplaceOne, - AccessControlUpdateOne, AccessControlService, -} from './index'; + AccessControlUpdateOne, + CheckAccessHandler, + CheckAccessQuery, + DEFAULT_ACCESS_CONTROL_PORT_SETTINGS, + FilterResponseAttributesHandler, + FilterResponseAttributesQuery, + PossessionEnum, + ResolveUserRolesHandler, + ResolveUserRolesQuery, +} from './index.js'; describe('Index', () => { - // modules - it('All exported modules should be imported', () => { - expect(AccessControlGuard).toEqual(expect.any(Function)); + it('exports module and runtime classes', () => { expect(AccessControlModule).toEqual(expect.any(Function)); + expect(AccessControlGuard).toEqual(expect.any(Function)); + expect(AccessControlFilter).toEqual(expect.any(Function)); expect(AccessControlContext).toEqual(expect.any(Function)); + expect(AccessControlService).toEqual(expect.any(Function)); }); - it('All exported services should be imported', () => { - expect(AccessControlService).toEqual(expect.any(Function)); + it('exports the access-control port surface', () => { + expect(AccessControlPort).toEqual(expect.any(Function)); + expect(typeof ACCESS_CONTROL_PORT_TOKEN).toEqual('symbol'); + expect(DEFAULT_ACCESS_CONTROL_PORT_SETTINGS).toEqual({ + checkAccessQuery: CheckAccessQuery, + filterResponseAttributesQuery: FilterResponseAttributesQuery, + resolveUserRolesQuery: ResolveUserRolesQuery, + }); + }); + + it('exports all CQRS queries and handlers', () => { + expect(CheckAccessQuery).toEqual(expect.any(Function)); + expect(CheckAccessHandler).toEqual(expect.any(Function)); + expect(FilterResponseAttributesQuery).toEqual(expect.any(Function)); + expect(FilterResponseAttributesHandler).toEqual(expect.any(Function)); + expect(ResolveUserRolesQuery).toEqual(expect.any(Function)); + expect(ResolveUserRolesHandler).toEqual(expect.any(Function)); }); - // decorators - it('All exported decorators should be imported', () => { + it('exports all grant decorators', () => { + expect(AccessControlGrant).toEqual(expect.any(Function)); + expect(AccessControlQuery).toEqual(expect.any(Function)); expect(AccessControlCreateMany).toEqual(expect.any(Function)); expect(AccessControlCreateOne).toEqual(expect.any(Function)); expect(AccessControlDeleteOne).toEqual(expect.any(Function)); - expect(AccessControlQuery).toEqual(expect.any(Function)); - expect(AccessControlGrant).toEqual(expect.any(Function)); expect(AccessControlReadMany).toEqual(expect.any(Function)); expect(AccessControlReadOne).toEqual(expect.any(Function)); expect(AccessControlUpdateOne).toEqual(expect.any(Function)); @@ -43,9 +67,7 @@ describe('Index', () => { expect(AccessControlReplaceOne).toEqual(expect.any(Function)); }); - // enums - it('All exported enums should be imported', () => { - expect(ActionEnum).toEqual(expect.any(Object)); + it('exports the possession enum', () => { expect(PossessionEnum).toEqual(expect.any(Object)); }); }); diff --git a/packages/nestjs-access-control/src/index.ts b/packages/nestjs-access-control/src/index.ts index 85fa29f28..30d23b7a8 100644 --- a/packages/nestjs-access-control/src/index.ts +++ b/packages/nestjs-access-control/src/index.ts @@ -1,30 +1,67 @@ -export * from './access-control.guard'; -export { AccessControlFilter } from './filter/access-control.filter'; -export * from './access-control.module'; -export { AccessControlContext } from './access-control.context'; -export { AccessControlService } from './services/access-control.service'; -export * from './decorators/access-control-create-many.decorator'; -export * from './decorators/access-control-create-one.decorator'; -export * from './decorators/access-control-delete-one.decorator'; -export * from './decorators/access-control-query.decorator'; -export * from './decorators/access-control-grant.decorator'; -export * from './decorators/access-control-read-many.decorator'; -export * from './decorators/access-control-read-one.decorator'; -export * from './decorators/access-control-recover-one.decorator'; -export * from './decorators/access-control-replace-one.decorator'; -export * from './decorators/access-control-update-one.decorator'; -export { ActionEnum } from './enums/action.enum'; -export { PossessionEnum } from './enums/possession.enum'; -export { CanAccess } from './interfaces/can-access.interface'; -export { AccessControlContextInterface } from './interfaces/access-control-context.interface'; -export * from './interfaces/access-control-query-option.interface'; -export * from './interfaces/access-control-grant-option.interface'; -export * from './interfaces/access-control-options.interface'; -export * from './interfaces/access-control-metadata.interface'; -export * from './interfaces/access-control-service.interface'; +// Module facade + options +export { AccessControlModule } from './access-control.module.js'; +export { + AccessControlOptions, + AccessControlAsyncOptions, +} from './access-control.module-definition.js'; + +// Constants +export { + ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, + ACCESS_CONTROL_PORT_TOKEN, +} from './access-control.constants.js'; + +// Gateways +export { AccessControlGuard } from './gateways/http/access-control.guard.js'; +export { AccessControlFilter } from './gateways/http/access-control.filter.js'; + +// Decorators +export { AccessControlGrant } from './gateways/decorators/access-control-grant.decorator.js'; +export { AccessControlQuery } from './gateways/decorators/access-control-query.decorator.js'; +export { AccessControlCreateMany } from './gateways/decorators/access-control-create-many.decorator.js'; +export { AccessControlCreateOne } from './gateways/decorators/access-control-create-one.decorator.js'; +export { AccessControlDeleteOne } from './gateways/decorators/access-control-delete-one.decorator.js'; +export { AccessControlReadMany } from './gateways/decorators/access-control-read-many.decorator.js'; +export { AccessControlReadOne } from './gateways/decorators/access-control-read-one.decorator.js'; +export { AccessControlRecoverOne } from './gateways/decorators/access-control-recover-one.decorator.js'; +export { AccessControlReplaceOne } from './gateways/decorators/access-control-replace-one.decorator.js'; +export { AccessControlUpdateOne } from './gateways/decorators/access-control-update-one.decorator.js'; + +// Domain types +export { AccessControlContext } from './domain/access-control.context.js'; +export { PossessionEnum } from './domain/enums/possession.enum.js'; +export { CanAccess } from './domain/policies/can-access.policy.js'; +export { AccessControlContextInterface } from './domain/interfaces/access-control-context.interface.js'; +export { AccessControlGrantOptionInterface } from './domain/interfaces/access-control-grant-option.interface.js'; +export { AccessControlQueryOptionInterface } from './domain/interfaces/access-control-query-option.interface.js'; +export { AccessControlMetadataInterface } from './domain/interfaces/access-control-metadata.interface.js'; +export { AccessControlServiceInterface } from './domain/ports/access-control-service.interface.js'; + +// Port +export { + AccessControlPort, + AccessControlPortSettings, + CheckAccessQueryInterface, + FilterResponseAttributesQueryInterface, + ResolveUserRolesQueryInterface, +} from './application/ports/access-control.port.js'; +export { DEFAULT_ACCESS_CONTROL_PORT_SETTINGS } from './access-control.module-definition.js'; + +// Infrastructure +export { AccessControlService } from './infrastructure/services/access-control.service.js'; +export { AccessControlOptionsInterface } from './infrastructure/config/interfaces/access-control-options.interface.js'; +export { AccessControlSettingsInterface } from './infrastructure/config/interfaces/access-control-settings.interface.js'; + +// Application — queries + handlers +export { CheckAccessQuery } from './application/queries/impl/check-access.query.js'; +export { FilterResponseAttributesQuery } from './application/queries/impl/filter-response-attributes.query.js'; +export { ResolveUserRolesQuery } from './application/queries/impl/resolve-user-roles.query.js'; +export { CheckAccessHandler } from './application/queries/handlers/check-access.handler.js'; +export { FilterResponseAttributesHandler } from './application/queries/handlers/filter-response-attributes.handler.js'; +export { ResolveUserRolesHandler } from './application/queries/handlers/resolve-user-roles.handler.js'; /** - * COMPAT + * @deprecated Kept for v7 consumer compatibility. Will be removed once + * external callers migrate off it. */ -export { ActionEnum as AccessControlAction } from './enums/action.enum'; -export { AccessControllerException } from './exceptions/access-controller.exception'; +export { AccessControllerException } from './domain/exceptions/access-controller.exception.js'; diff --git a/packages/nestjs-access-control/src/infrastructure/config/access-control-default.config.ts b/packages/nestjs-access-control/src/infrastructure/config/access-control-default.config.ts new file mode 100644 index 000000000..fbd8fb648 --- /dev/null +++ b/packages/nestjs-access-control/src/infrastructure/config/access-control-default.config.ts @@ -0,0 +1,13 @@ +import { registerAs } from '@nestjs/config'; + +import { ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN } from '../../access-control.constants.js'; + +import { type AccessControlSettingsInterface } from './interfaces/access-control-settings.interface.js'; + +/** + * Default configuration for access control. + */ +export const accessControlDefaultConfig = registerAs( + ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN, + (): Partial => ({}), +); diff --git a/packages/nestjs-access-control/src/infrastructure/config/interfaces/access-control-options-extras.interface.ts b/packages/nestjs-access-control/src/infrastructure/config/interfaces/access-control-options-extras.interface.ts new file mode 100644 index 000000000..f17cfa211 --- /dev/null +++ b/packages/nestjs-access-control/src/infrastructure/config/interfaces/access-control-options-extras.interface.ts @@ -0,0 +1,10 @@ +import { type DynamicModule, type Provider } from '@nestjs/common'; + +import { type CanAccess } from '../../../domain/policies/can-access.policy.js'; + +export interface AccessControlOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' | 'imports' +> { + queryServices?: Provider[]; +} diff --git a/packages/nestjs-access-control/src/infrastructure/config/interfaces/access-control-options.interface.ts b/packages/nestjs-access-control/src/infrastructure/config/interfaces/access-control-options.interface.ts new file mode 100644 index 000000000..76f8231cf --- /dev/null +++ b/packages/nestjs-access-control/src/infrastructure/config/interfaces/access-control-options.interface.ts @@ -0,0 +1,16 @@ +import { type CanActivate, type NestInterceptor } from '@nestjs/common'; + +import { type AccessControlPortSettings } from '../../../application/ports/access-control.port.js'; +import { type AccessControlServiceInterface } from '../../../domain/ports/access-control-service.interface.js'; + +import { type AccessControlSettingsInterface } from './access-control-settings.interface.js'; + +export interface AccessControlOptionsInterface { + settings: AccessControlSettingsInterface; + service?: AccessControlServiceInterface; + appGuard?: CanActivate | false; + appFilter?: NestInterceptor | false; + ports?: { + accessControl?: AccessControlPortSettings; + }; +} diff --git a/packages/nestjs-access-control/src/infrastructure/config/interfaces/access-control-settings.interface.ts b/packages/nestjs-access-control/src/infrastructure/config/interfaces/access-control-settings.interface.ts new file mode 100644 index 000000000..6474e9f29 --- /dev/null +++ b/packages/nestjs-access-control/src/infrastructure/config/interfaces/access-control-settings.interface.ts @@ -0,0 +1,5 @@ +import { type AccessControl } from 'accesscontrol'; + +export interface AccessControlSettingsInterface { + rules: AccessControl; +} diff --git a/packages/nestjs-access-control/src/services/access-control.service.spec.ts b/packages/nestjs-access-control/src/infrastructure/services/access-control.service.spec.ts similarity index 83% rename from packages/nestjs-access-control/src/services/access-control.service.spec.ts rename to packages/nestjs-access-control/src/infrastructure/services/access-control.service.spec.ts index eb70156b0..e132f6bcc 100644 --- a/packages/nestjs-access-control/src/services/access-control.service.spec.ts +++ b/packages/nestjs-access-control/src/infrastructure/services/access-control.service.spec.ts @@ -1,10 +1,15 @@ -import { mock } from 'jest-mock-extended'; +import { mock } from 'vitest-mock-extended'; -import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { HttpArgumentsHost } from '@nestjs/common/interfaces'; -import { Test, TestingModule } from '@nestjs/testing'; +import { + type ArgumentsHost, + type ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; -import { AccessControlService } from './access-control.service'; +import { AccessControlService } from './access-control.service.js'; + +type HttpArgumentsHost = ReturnType; describe('AccessControlDefaultService', () => { let service: AccessControlService; diff --git a/packages/nestjs-access-control/src/services/access-control.service.ts b/packages/nestjs-access-control/src/infrastructure/services/access-control.service.ts similarity index 87% rename from packages/nestjs-access-control/src/services/access-control.service.ts rename to packages/nestjs-access-control/src/infrastructure/services/access-control.service.ts index 1c62031bd..fd06816a8 100644 --- a/packages/nestjs-access-control/src/services/access-control.service.ts +++ b/packages/nestjs-access-control/src/infrastructure/services/access-control.service.ts @@ -4,7 +4,7 @@ import { UnauthorizedException, } from '@nestjs/common'; -import { AccessControlServiceInterface } from '../interfaces/access-control-service.interface'; +import { AccessControlServiceInterface } from '../../domain/ports/access-control-service.interface.js'; interface RoleDefaultInterface { name: string; diff --git a/packages/nestjs-access-control/src/interfaces/access-control-context-args.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-context-args.interface.ts deleted file mode 100644 index e8170612d..000000000 --- a/packages/nestjs-access-control/src/interfaces/access-control-context-args.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AccessControl, IQueryInfo } from 'accesscontrol'; - -import { ExecutionContext } from '@nestjs/common'; - -import { ReferenceUserInterface } from '@concepta/nestjs-common'; - -export interface AccessControlContextArgsInterface - extends ReferenceUserInterface { - request: unknown; - query: IQueryInfo; - accessControl: AccessControl; - executionContext: ExecutionContext; -} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-context.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-context.interface.ts deleted file mode 100644 index 1f1412948..000000000 --- a/packages/nestjs-access-control/src/interfaces/access-control-context.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AccessControl, IQueryInfo } from 'accesscontrol'; - -import { ExecutionContext } from '@nestjs/common'; - -export interface AccessControlContextInterface { - getRequest(property?: string): unknown; - getUser(): unknown; - getQuery(): IQueryInfo; - getAccessControl(): AccessControl; - getExecutionContext(): ExecutionContext; -} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-grant-option.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-grant-option.interface.ts deleted file mode 100644 index 5728abd73..000000000 --- a/packages/nestjs-access-control/src/interfaces/access-control-grant-option.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ActionEnum } from '../enums/action.enum'; - -export interface AccessControlGrantOptionInterface { - resource: string; - action: ActionEnum; -} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-metadata.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-metadata.interface.ts deleted file mode 100644 index 4aee140e1..000000000 --- a/packages/nestjs-access-control/src/interfaces/access-control-metadata.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Type } from '@nestjs/common'; - -import { AccessControlServiceInterface } from './access-control-service.interface'; - -export interface AccessControlMetadataInterface { - service?: Type; -} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-options-extras.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-options-extras.interface.ts deleted file mode 100644 index 2adb0a8ca..000000000 --- a/packages/nestjs-access-control/src/interfaces/access-control-options-extras.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DynamicModule, Provider } from '@nestjs/common'; - -import { CanAccess } from './can-access.interface'; - -export interface AccessControlOptionsExtrasInterface - extends Pick { - queryServices?: Provider[]; -} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-options.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-options.interface.ts deleted file mode 100644 index fe2ef5093..000000000 --- a/packages/nestjs-access-control/src/interfaces/access-control-options.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CanActivate, NestInterceptor } from '@nestjs/common'; - -import { AccessControlServiceInterface } from './access-control-service.interface'; -import { AccessControlSettingsInterface } from './access-control-settings.interface'; - -export interface AccessControlOptionsInterface { - settings: AccessControlSettingsInterface; - service?: AccessControlServiceInterface; - appGuard?: CanActivate | false; - appFilter?: NestInterceptor | false; -} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-query-option.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-query-option.interface.ts deleted file mode 100644 index cc19c48df..000000000 --- a/packages/nestjs-access-control/src/interfaces/access-control-query-option.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Type } from '@nestjs/common'; - -import { CanAccess } from './can-access.interface'; - -export interface AccessControlQueryOptionInterface { - /** - * Service used for advanced validation - */ - service: Type; -} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-settings.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-settings.interface.ts deleted file mode 100644 index 5a9b4c072..000000000 --- a/packages/nestjs-access-control/src/interfaces/access-control-settings.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { AccessControl } from 'accesscontrol'; - -export interface AccessControlSettingsInterface { - rules: AccessControl; -} diff --git a/packages/nestjs-access-control/src/interfaces/can-access.interface.ts b/packages/nestjs-access-control/src/interfaces/can-access.interface.ts deleted file mode 100644 index 0d8c40e56..000000000 --- a/packages/nestjs-access-control/src/interfaces/can-access.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { AccessControlContextInterface } from './access-control-context.interface'; - -export interface CanAccess { - canAccess(context: AccessControlContextInterface): Promise; -} diff --git a/packages/nestjs-access-control/tsconfig.json b/packages/nestjs-access-control/tsconfig.json index ef9980950..edc11225e 100644 --- a/packages/nestjs-access-control/tsconfig.json +++ b/packages/nestjs-access-control/tsconfig.json @@ -4,10 +4,13 @@ "composite": true, "rootDir": "./src", "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", "typeRoots": [ "./node_modules/@types", - "../../node_modules/@types" - ] + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] }, "include": [ "src/**/*.ts" diff --git a/packages/nestjs-auth-apple/package.json b/packages/nestjs-auth-apple/package.json index d653b43a2..16f4231f0 100644 --- a/packages/nestjs-auth-apple/package.json +++ b/packages/nestjs-auth-apple/package.json @@ -20,7 +20,7 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.9", "@nestjs/passport": "^11.0.5", - "@nestjs/swagger": "^11.2.2", + "@nestjs/swagger": "11.2.2", "jwks-rsa": "^3.1.0", "passport-apple": "^2.0.2" }, diff --git a/packages/nestjs-auth-apple/src/auth-apple.module-definition.ts b/packages/nestjs-auth-apple/src/auth-apple.module-definition.ts index 226fe5f54..2325171b1 100644 --- a/packages/nestjs-auth-apple/src/auth-apple.module-definition.ts +++ b/packages/nestjs-auth-apple/src/auth-apple.module-definition.ts @@ -1,13 +1,13 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { IssueTokenService, - IssueTokenServiceInterface, + type IssueTokenServiceInterface, } from '@concepta/nestjs-authentication'; import { createSettingsProvider } from '@concepta/nestjs-common'; import { FederatedOAuthService } from '@concepta/nestjs-federated'; @@ -22,10 +22,10 @@ import { import { AuthAppleService } from './auth-apple.service'; import { AuthAppleStrategy } from './auth-apple.strategy'; import { authAppleDefaultConfig } from './config/auth-apple-default.config'; -import { AuthAppleOptionsExtrasInterface } from './interfaces/auth-apple-options-extras.interface'; -import { AuthAppleOptionsInterface } from './interfaces/auth-apple-options.interface'; -import { AuthAppleServiceInterface } from './interfaces/auth-apple-service.interface'; -import { AuthAppleSettingsInterface } from './interfaces/auth-apple-settings.interface'; +import { type AuthAppleOptionsExtrasInterface } from './interfaces/auth-apple-options-extras.interface'; +import { type AuthAppleOptionsInterface } from './interfaces/auth-apple-options.interface'; +import { type AuthAppleServiceInterface } from './interfaces/auth-apple-service.interface'; +import { type AuthAppleSettingsInterface } from './interfaces/auth-apple-settings.interface'; const RAW_OPTIONS_TOKEN = Symbol('__AUTH_APPLE_MODULE_RAW_OPTIONS_TOKEN__'); diff --git a/packages/nestjs-auth-apple/src/auth-apple.module.spec.ts b/packages/nestjs-auth-apple/src/auth-apple.module.spec.ts index ab87f337b..48b68aa09 100644 --- a/packages/nestjs-auth-apple/src/auth-apple.module.spec.ts +++ b/packages/nestjs-auth-apple/src/auth-apple.module.spec.ts @@ -1,4 +1,4 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; import { AuthenticationModule } from '@concepta/nestjs-authentication'; diff --git a/packages/nestjs-auth-apple/src/auth-apple.service.spec.ts b/packages/nestjs-auth-apple/src/auth-apple.service.spec.ts index eddffda70..2812114e6 100644 --- a/packages/nestjs-auth-apple/src/auth-apple.service.spec.ts +++ b/packages/nestjs-auth-apple/src/auth-apple.service.spec.ts @@ -1,8 +1,8 @@ import { JwksClient } from 'jwks-rsa'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; -import { JwtVerifyServiceInterface } from '@concepta/nestjs-jwt'; +import { type JwtVerifyServiceInterface } from '@concepta/nestjs-jwt'; import { AUTH_APPLE_JWT_SERVICE_TOKEN, @@ -17,7 +17,7 @@ import { AuthAppleInvalidAudienceException } from './exceptions/auth-apple-inval import { AuthAppleInvalidIssuerException } from './exceptions/auth-apple-invalid-issuer.exception'; import { AuthApplePublicKeyException } from './exceptions/auth-apple-public-key.exception'; import { AuthAppleTokenExpiredException } from './exceptions/auth-apple-token-expired.exception'; -import { AuthAppleProfileInterface } from './interfaces/auth-apple-profile.interface'; +import { type AuthAppleProfileInterface } from './interfaces/auth-apple-profile.interface'; // Mock jwks-rsa jest.mock('jwks-rsa'); diff --git a/packages/nestjs-auth-apple/src/auth-apple.strategy.spec.ts b/packages/nestjs-auth-apple/src/auth-apple.strategy.spec.ts index 16f41c675..2c3144877 100644 --- a/packages/nestjs-auth-apple/src/auth-apple.strategy.spec.ts +++ b/packages/nestjs-auth-apple/src/auth-apple.strategy.spec.ts @@ -1,8 +1,8 @@ import { UnauthorizedException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { - FederatedCredentialsInterface, + type FederatedCredentialsInterface, FederatedOAuthService, } from '@concepta/nestjs-federated'; @@ -13,10 +13,10 @@ import { import { AuthAppleStrategy } from './auth-apple.strategy'; import { AuthAppleMissingEmailException } from './exceptions/auth-apple-missing-email.exception'; import { AuthAppleMissingIdException } from './exceptions/auth-apple-missing-id.exception'; -import { AuthAppleCredentialsInterface } from './interfaces/auth-apple-credentials.interface'; -import { AuthAppleProfileInterface } from './interfaces/auth-apple-profile.interface'; -import { AuthAppleServiceInterface } from './interfaces/auth-apple-service.interface'; -import { AuthAppleSettingsInterface } from './interfaces/auth-apple-settings.interface'; +import { type AuthAppleCredentialsInterface } from './interfaces/auth-apple-credentials.interface'; +import { type AuthAppleProfileInterface } from './interfaces/auth-apple-profile.interface'; +import { type AuthAppleServiceInterface } from './interfaces/auth-apple-service.interface'; +import { type AuthAppleSettingsInterface } from './interfaces/auth-apple-settings.interface'; import { mapProfile } from './utils/auth-apple-map-profile'; // Mock the PassportStrategy class diff --git a/packages/nestjs-auth-apple/src/auth-apple.types.ts b/packages/nestjs-auth-apple/src/auth-apple.types.ts index a32d8fb58..8c4aaa48a 100644 --- a/packages/nestjs-auth-apple/src/auth-apple.types.ts +++ b/packages/nestjs-auth-apple/src/auth-apple.types.ts @@ -1,5 +1,5 @@ -import { AuthAppleCredentialsInterface } from './interfaces/auth-apple-credentials.interface'; -import { AuthAppleProfileInterface } from './interfaces/auth-apple-profile.interface'; +import { type AuthAppleCredentialsInterface } from './interfaces/auth-apple-credentials.interface'; +import { type AuthAppleProfileInterface } from './interfaces/auth-apple-profile.interface'; export type MapProfile = ( profile: AuthAppleProfileInterface, diff --git a/packages/nestjs-auth-apple/src/config/auth-apple-default.config.ts b/packages/nestjs-auth-apple/src/config/auth-apple-default.config.ts index f44fc299f..c617794e6 100644 --- a/packages/nestjs-auth-apple/src/config/auth-apple-default.config.ts +++ b/packages/nestjs-auth-apple/src/config/auth-apple-default.config.ts @@ -2,7 +2,7 @@ import { registerAs } from '@nestjs/config'; import { AUTH_APPLE_MODULE_DEFAULT_SETTINGS_TOKEN } from '../auth-apple.constants'; import { AuthAppleLoginDto } from '../dto/auth-apple-login.dto'; -import { AuthAppleSettingsInterface } from '../interfaces/auth-apple-settings.interface'; +import { type AuthAppleSettingsInterface } from '../interfaces/auth-apple-settings.interface'; import { mapProfile } from '../utils/auth-apple-map-profile'; import { authAppleScopeParser } from '../utils/auth-apple-scope-parser.util'; diff --git a/packages/nestjs-auth-apple/src/exceptions/auth-apple-decode.exception.ts b/packages/nestjs-auth-apple/src/exceptions/auth-apple-decode.exception.ts index 346dd16c3..baca44f0f 100644 --- a/packages/nestjs-auth-apple/src/exceptions/auth-apple-decode.exception.ts +++ b/packages/nestjs-auth-apple/src/exceptions/auth-apple-decode.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthAppleException } from './auth-apple-exception'; diff --git a/packages/nestjs-auth-apple/src/exceptions/auth-apple-email-not-verified.exception.ts b/packages/nestjs-auth-apple/src/exceptions/auth-apple-email-not-verified.exception.ts index b9c251d11..b5aeadc07 100644 --- a/packages/nestjs-auth-apple/src/exceptions/auth-apple-email-not-verified.exception.ts +++ b/packages/nestjs-auth-apple/src/exceptions/auth-apple-email-not-verified.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthAppleException } from './auth-apple-exception'; diff --git a/packages/nestjs-auth-apple/src/exceptions/auth-apple-exception.ts b/packages/nestjs-auth-apple/src/exceptions/auth-apple-exception.ts index f194e821f..c80f1c37f 100644 --- a/packages/nestjs-auth-apple/src/exceptions/auth-apple-exception.ts +++ b/packages/nestjs-auth-apple/src/exceptions/auth-apple-exception.ts @@ -1,6 +1,6 @@ import { RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; /** * Generic auth local exception. diff --git a/packages/nestjs-auth-apple/src/exceptions/auth-apple-invalid-audience.exception.ts b/packages/nestjs-auth-apple/src/exceptions/auth-apple-invalid-audience.exception.ts index 59a708d4b..faf960963 100644 --- a/packages/nestjs-auth-apple/src/exceptions/auth-apple-invalid-audience.exception.ts +++ b/packages/nestjs-auth-apple/src/exceptions/auth-apple-invalid-audience.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthAppleException } from './auth-apple-exception'; diff --git a/packages/nestjs-auth-apple/src/exceptions/auth-apple-invalid-issuer.exception.ts b/packages/nestjs-auth-apple/src/exceptions/auth-apple-invalid-issuer.exception.ts index e8a10f9ec..3ccd5daf2 100644 --- a/packages/nestjs-auth-apple/src/exceptions/auth-apple-invalid-issuer.exception.ts +++ b/packages/nestjs-auth-apple/src/exceptions/auth-apple-invalid-issuer.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthAppleException } from './auth-apple-exception'; diff --git a/packages/nestjs-auth-apple/src/exceptions/auth-apple-missing-email.exception.ts b/packages/nestjs-auth-apple/src/exceptions/auth-apple-missing-email.exception.ts index 32d36faea..89ee9c2d9 100644 --- a/packages/nestjs-auth-apple/src/exceptions/auth-apple-missing-email.exception.ts +++ b/packages/nestjs-auth-apple/src/exceptions/auth-apple-missing-email.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthAppleException } from './auth-apple-exception'; diff --git a/packages/nestjs-auth-apple/src/exceptions/auth-apple-missing-id.exception.ts b/packages/nestjs-auth-apple/src/exceptions/auth-apple-missing-id.exception.ts index 332fe6af8..e6578cb84 100644 --- a/packages/nestjs-auth-apple/src/exceptions/auth-apple-missing-id.exception.ts +++ b/packages/nestjs-auth-apple/src/exceptions/auth-apple-missing-id.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthAppleException } from './auth-apple-exception'; diff --git a/packages/nestjs-auth-apple/src/exceptions/auth-apple-public-key.exception.ts b/packages/nestjs-auth-apple/src/exceptions/auth-apple-public-key.exception.ts index a48a2025a..f7c83f933 100644 --- a/packages/nestjs-auth-apple/src/exceptions/auth-apple-public-key.exception.ts +++ b/packages/nestjs-auth-apple/src/exceptions/auth-apple-public-key.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthAppleException } from './auth-apple-exception'; diff --git a/packages/nestjs-auth-apple/src/exceptions/auth-apple-token-expired.exception.ts b/packages/nestjs-auth-apple/src/exceptions/auth-apple-token-expired.exception.ts index 3c33f75da..88b1d373a 100644 --- a/packages/nestjs-auth-apple/src/exceptions/auth-apple-token-expired.exception.ts +++ b/packages/nestjs-auth-apple/src/exceptions/auth-apple-token-expired.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthAppleException } from './auth-apple-exception'; diff --git a/packages/nestjs-auth-apple/src/interfaces/auth-apple-credentials.interface.ts b/packages/nestjs-auth-apple/src/interfaces/auth-apple-credentials.interface.ts index 4c471c53c..5aac15407 100644 --- a/packages/nestjs-auth-apple/src/interfaces/auth-apple-credentials.interface.ts +++ b/packages/nestjs-auth-apple/src/interfaces/auth-apple-credentials.interface.ts @@ -1,8 +1,7 @@ import { - ReferenceEmailInterface, - ReferenceIdInterface, + type ReferenceEmailInterface, + type ReferenceIdInterface, } from '@concepta/nestjs-common'; export interface AuthAppleCredentialsInterface - extends ReferenceIdInterface, - ReferenceEmailInterface {} + extends ReferenceIdInterface, ReferenceEmailInterface {} diff --git a/packages/nestjs-auth-apple/src/interfaces/auth-apple-options-extras.interface.ts b/packages/nestjs-auth-apple/src/interfaces/auth-apple-options-extras.interface.ts index 70c56f2f5..c73377e97 100644 --- a/packages/nestjs-auth-apple/src/interfaces/auth-apple-options-extras.interface.ts +++ b/packages/nestjs-auth-apple/src/interfaces/auth-apple-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface AuthAppleOptionsExtrasInterface - extends Pick {} +export interface AuthAppleOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-auth-apple/src/interfaces/auth-apple-options.interface.ts b/packages/nestjs-auth-apple/src/interfaces/auth-apple-options.interface.ts index b5d679848..87e8ebd26 100644 --- a/packages/nestjs-auth-apple/src/interfaces/auth-apple-options.interface.ts +++ b/packages/nestjs-auth-apple/src/interfaces/auth-apple-options.interface.ts @@ -1,12 +1,11 @@ -import { IssueTokenServiceInterface } from '@concepta/nestjs-authentication'; -import { ModuleOptionsSettingsInterface } from '@concepta/nestjs-common'; -import { JwtVerifyServiceInterface } from '@concepta/nestjs-jwt'; +import { type IssueTokenServiceInterface } from '@concepta/nestjs-authentication'; +import { type ModuleOptionsSettingsInterface } from '@concepta/nestjs-common'; +import { type JwtVerifyServiceInterface } from '@concepta/nestjs-jwt'; -import { AuthAppleServiceInterface } from './auth-apple-service.interface'; -import { AuthAppleSettingsInterface } from './auth-apple-settings.interface'; +import { type AuthAppleServiceInterface } from './auth-apple-service.interface'; +import { type AuthAppleSettingsInterface } from './auth-apple-settings.interface'; -export interface AuthAppleOptionsInterface - extends ModuleOptionsSettingsInterface { +export interface AuthAppleOptionsInterface extends ModuleOptionsSettingsInterface { /** * Implementation of a class used to verify Apple tokens */ diff --git a/packages/nestjs-auth-apple/src/interfaces/auth-apple-profile.interface.ts b/packages/nestjs-auth-apple/src/interfaces/auth-apple-profile.interface.ts index 191445c43..792488381 100644 --- a/packages/nestjs-auth-apple/src/interfaces/auth-apple-profile.interface.ts +++ b/packages/nestjs-auth-apple/src/interfaces/auth-apple-profile.interface.ts @@ -1,7 +1,6 @@ -import { ReferenceEmailInterface } from '@concepta/nestjs-common'; +import { type ReferenceEmailInterface } from '@concepta/nestjs-common'; -export interface AuthAppleProfileInterface - extends Partial { +export interface AuthAppleProfileInterface extends Partial { iss: string; aud: string; exp: number; diff --git a/packages/nestjs-auth-apple/src/interfaces/auth-apple-service.interface.ts b/packages/nestjs-auth-apple/src/interfaces/auth-apple-service.interface.ts index 3cec6f21d..f02e7af0c 100644 --- a/packages/nestjs-auth-apple/src/interfaces/auth-apple-service.interface.ts +++ b/packages/nestjs-auth-apple/src/interfaces/auth-apple-service.interface.ts @@ -1,4 +1,4 @@ -import { AuthAppleProfileInterface } from './auth-apple-profile.interface'; +import { type AuthAppleProfileInterface } from './auth-apple-profile.interface'; export interface AuthAppleServiceInterface { verifyIdToken(idToken: string): Promise; diff --git a/packages/nestjs-auth-apple/src/interfaces/auth-apple-settings.interface.ts b/packages/nestjs-auth-apple/src/interfaces/auth-apple-settings.interface.ts index 7c31c1e82..531bf9ecd 100644 --- a/packages/nestjs-auth-apple/src/interfaces/auth-apple-settings.interface.ts +++ b/packages/nestjs-auth-apple/src/interfaces/auth-apple-settings.interface.ts @@ -1,10 +1,10 @@ -import { AuthenticateOptions } from 'passport-apple'; +import { type AuthenticateOptions } from 'passport-apple'; -import { Type } from '@nestjs/common'; +import { type Type } from '@nestjs/common'; -import { AuthenticationCodeInterface } from '@concepta/nestjs-common'; +import { type AuthenticationCodeInterface } from '@concepta/nestjs-common'; -import { MapProfile } from '../auth-apple.types'; +import { type MapProfile } from '../auth-apple.types'; export interface AuthAppleSettingsInterface extends AuthenticateOptions { loginDto?: Type; diff --git a/packages/nestjs-auth-apple/src/utils/auth-apple-map-profile.ts b/packages/nestjs-auth-apple/src/utils/auth-apple-map-profile.ts index d6c49870e..40e07aa65 100644 --- a/packages/nestjs-auth-apple/src/utils/auth-apple-map-profile.ts +++ b/packages/nestjs-auth-apple/src/utils/auth-apple-map-profile.ts @@ -1,5 +1,5 @@ -import { AuthAppleCredentialsInterface as AuthAppleCredentialsInterface } from '../interfaces/auth-apple-credentials.interface'; -import { AuthAppleProfileInterface } from '../interfaces/auth-apple-profile.interface'; +import { type AuthAppleCredentialsInterface as AuthAppleCredentialsInterface } from '../interfaces/auth-apple-credentials.interface'; +import { type AuthAppleProfileInterface } from '../interfaces/auth-apple-profile.interface'; export const mapProfile = ( profile: AuthAppleProfileInterface, diff --git a/packages/nestjs-auth-github/package.json b/packages/nestjs-auth-github/package.json index f5b336914..9ae2db7cc 100644 --- a/packages/nestjs-auth-github/package.json +++ b/packages/nestjs-auth-github/package.json @@ -19,7 +19,7 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.9", "@nestjs/passport": "^11.0.5", - "@nestjs/swagger": "^11.2.2", + "@nestjs/swagger": "11.2.2", "passport-github": "^1.1.0" }, "devDependencies": { diff --git a/packages/nestjs-auth-github/src/auth-github.module-definition.ts b/packages/nestjs-auth-github/src/auth-github.module-definition.ts index 73268a6d3..30898f595 100644 --- a/packages/nestjs-auth-github/src/auth-github.module-definition.ts +++ b/packages/nestjs-auth-github/src/auth-github.module-definition.ts @@ -1,13 +1,13 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { IssueTokenService, - IssueTokenServiceInterface, + type IssueTokenServiceInterface, } from '@concepta/nestjs-authentication'; import { createSettingsProvider } from '@concepta/nestjs-common'; import { FederatedOAuthService } from '@concepta/nestjs-federated'; @@ -18,9 +18,9 @@ import { } from './auth-github.constants'; import { AuthGithubStrategy } from './auth-github.strategy'; import { authGithubDefaultConfig } from './config/auth-github-default.config'; -import { AuthGithubOptionsExtrasInterface } from './interfaces/auth-github-options-extras.interface'; -import { AuthGithubOptionsInterface } from './interfaces/auth-github-options.interface'; -import { AuthGithubSettingsInterface } from './interfaces/auth-github-settings.interface'; +import { type AuthGithubOptionsExtrasInterface } from './interfaces/auth-github-options-extras.interface'; +import { type AuthGithubOptionsInterface } from './interfaces/auth-github-options.interface'; +import { type AuthGithubSettingsInterface } from './interfaces/auth-github-settings.interface'; const RAW_OPTIONS_TOKEN = Symbol('__AUTH_GITHUB_MODULE_RAW_OPTIONS_TOKEN__'); diff --git a/packages/nestjs-auth-github/src/auth-github.module.spec.ts b/packages/nestjs-auth-github/src/auth-github.module.spec.ts index 4d87819bf..2b731c63d 100644 --- a/packages/nestjs-auth-github/src/auth-github.module.spec.ts +++ b/packages/nestjs-auth-github/src/auth-github.module.spec.ts @@ -1,4 +1,4 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; import { AuthenticationModule } from '@concepta/nestjs-authentication'; diff --git a/packages/nestjs-auth-github/src/auth-github.strategy.spec.ts b/packages/nestjs-auth-github/src/auth-github.strategy.spec.ts index 26304561d..42b059bed 100644 --- a/packages/nestjs-auth-github/src/auth-github.strategy.spec.ts +++ b/packages/nestjs-auth-github/src/auth-github.strategy.spec.ts @@ -1,9 +1,9 @@ import { UnauthorizedException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { FederatedOAuthService, - FederatedCredentialsInterface, + type FederatedCredentialsInterface, } from '@concepta/nestjs-federated'; import { @@ -13,8 +13,8 @@ import { import { AuthGithubStrategy } from './auth-github.strategy'; import { AuthGithubMissingEmailException } from './exceptions/auth-github-missing-email.exception'; import { AuthGithubMissingIdException } from './exceptions/auth-github-missing-id.exception'; -import { AuthGithubProfileInterface } from './interfaces/auth-github-profile.interface'; -import { AuthGithubSettingsInterface } from './interfaces/auth-github-settings.interface'; +import { type AuthGithubProfileInterface } from './interfaces/auth-github-profile.interface'; +import { type AuthGithubSettingsInterface } from './interfaces/auth-github-settings.interface'; import { mapProfile } from './utils/auth-github-map-profile'; // Mock the PassportStrategy class diff --git a/packages/nestjs-auth-github/src/auth-github.types.ts b/packages/nestjs-auth-github/src/auth-github.types.ts index b5821b168..4e3f269c7 100644 --- a/packages/nestjs-auth-github/src/auth-github.types.ts +++ b/packages/nestjs-auth-github/src/auth-github.types.ts @@ -1,5 +1,5 @@ -import { AuthGithubCredentialsInterface } from './interfaces/auth-github-credentials.interface'; -import { AuthGithubProfileInterface } from './interfaces/auth-github-profile.interface'; +import { type AuthGithubCredentialsInterface } from './interfaces/auth-github-credentials.interface'; +import { type AuthGithubProfileInterface } from './interfaces/auth-github-profile.interface'; export type MapProfile = ( profile: AuthGithubProfileInterface, diff --git a/packages/nestjs-auth-github/src/config/auth-github-default.config.ts b/packages/nestjs-auth-github/src/config/auth-github-default.config.ts index e60268f8f..fef48088d 100644 --- a/packages/nestjs-auth-github/src/config/auth-github-default.config.ts +++ b/packages/nestjs-auth-github/src/config/auth-github-default.config.ts @@ -2,7 +2,7 @@ import { registerAs } from '@nestjs/config'; import { AUTH_GITHUB_MODULE_DEFAULT_SETTINGS_TOKEN } from '../auth-github.constants'; import { AuthGithubLoginDto } from '../dto/auth-github-login.dto'; -import { AuthGithubSettingsInterface } from '../interfaces/auth-github-settings.interface'; +import { type AuthGithubSettingsInterface } from '../interfaces/auth-github-settings.interface'; import { mapProfile } from '../utils/auth-github-map-profile'; /** diff --git a/packages/nestjs-auth-github/src/exceptions/auth-github-missing-email.exception.ts b/packages/nestjs-auth-github/src/exceptions/auth-github-missing-email.exception.ts index 6a212a9eb..41887232e 100644 --- a/packages/nestjs-auth-github/src/exceptions/auth-github-missing-email.exception.ts +++ b/packages/nestjs-auth-github/src/exceptions/auth-github-missing-email.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthGithubException } from './auth-github.exception'; diff --git a/packages/nestjs-auth-github/src/exceptions/auth-github-missing-id.exception.ts b/packages/nestjs-auth-github/src/exceptions/auth-github-missing-id.exception.ts index 11a21bec9..9a3c67478 100644 --- a/packages/nestjs-auth-github/src/exceptions/auth-github-missing-id.exception.ts +++ b/packages/nestjs-auth-github/src/exceptions/auth-github-missing-id.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthGithubException } from './auth-github.exception'; diff --git a/packages/nestjs-auth-github/src/exceptions/auth-github.exception.ts b/packages/nestjs-auth-github/src/exceptions/auth-github.exception.ts index ecddafad7..ed54c17af 100644 --- a/packages/nestjs-auth-github/src/exceptions/auth-github.exception.ts +++ b/packages/nestjs-auth-github/src/exceptions/auth-github.exception.ts @@ -1,6 +1,6 @@ import { RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; /** * Generic auth github exception. diff --git a/packages/nestjs-auth-github/src/interfaces/auth-github-credentials.interface.ts b/packages/nestjs-auth-github/src/interfaces/auth-github-credentials.interface.ts index 46cc9fa6c..7d8892dd9 100644 --- a/packages/nestjs-auth-github/src/interfaces/auth-github-credentials.interface.ts +++ b/packages/nestjs-auth-github/src/interfaces/auth-github-credentials.interface.ts @@ -1,8 +1,7 @@ import { - ReferenceEmailInterface, - ReferenceIdInterface, + type ReferenceEmailInterface, + type ReferenceIdInterface, } from '@concepta/nestjs-common'; export interface AuthGithubCredentialsInterface - extends ReferenceIdInterface, - ReferenceEmailInterface {} + extends ReferenceIdInterface, ReferenceEmailInterface {} diff --git a/packages/nestjs-auth-github/src/interfaces/auth-github-options-extras.interface.ts b/packages/nestjs-auth-github/src/interfaces/auth-github-options-extras.interface.ts index d41725119..e43bedac5 100644 --- a/packages/nestjs-auth-github/src/interfaces/auth-github-options-extras.interface.ts +++ b/packages/nestjs-auth-github/src/interfaces/auth-github-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface AuthGithubOptionsExtrasInterface - extends Pick {} +export interface AuthGithubOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-auth-github/src/interfaces/auth-github-options.interface.ts b/packages/nestjs-auth-github/src/interfaces/auth-github-options.interface.ts index 0eab1f4cf..ecd60425c 100644 --- a/packages/nestjs-auth-github/src/interfaces/auth-github-options.interface.ts +++ b/packages/nestjs-auth-github/src/interfaces/auth-github-options.interface.ts @@ -1,10 +1,9 @@ -import { IssueTokenServiceInterface } from '@concepta/nestjs-authentication'; -import { ModuleOptionsSettingsInterface } from '@concepta/nestjs-common'; +import { type IssueTokenServiceInterface } from '@concepta/nestjs-authentication'; +import { type ModuleOptionsSettingsInterface } from '@concepta/nestjs-common'; -import { AuthGithubSettingsInterface } from './auth-github-settings.interface'; +import { type AuthGithubSettingsInterface } from './auth-github-settings.interface'; -export interface AuthGithubOptionsInterface - extends ModuleOptionsSettingsInterface { +export interface AuthGithubOptionsInterface extends ModuleOptionsSettingsInterface { /** * Implementation of a class to issue tokens, which is used as injection * in the controller to generate the response payload, with access token diff --git a/packages/nestjs-auth-github/src/interfaces/auth-github-profile.interface.ts b/packages/nestjs-auth-github/src/interfaces/auth-github-profile.interface.ts index fd33d6dc8..53d4ff596 100644 --- a/packages/nestjs-auth-github/src/interfaces/auth-github-profile.interface.ts +++ b/packages/nestjs-auth-github/src/interfaces/auth-github-profile.interface.ts @@ -1,13 +1,14 @@ import { - ReferenceEmailInterface, - ReferenceIdInterface, - ReferenceUsernameInterface, + type ReferenceEmailInterface, + type ReferenceIdInterface, + type ReferenceUsernameInterface, } from '@concepta/nestjs-common'; -import { AuthGithubEmailsInterface } from './auth-github-emails.interface'; +import { type AuthGithubEmailsInterface } from './auth-github-emails.interface'; export interface AuthGithubProfileInterface - extends ReferenceIdInterface, + extends + ReferenceIdInterface, Partial, Partial { displayName?: string; diff --git a/packages/nestjs-auth-github/src/interfaces/auth-github-settings.interface.ts b/packages/nestjs-auth-github/src/interfaces/auth-github-settings.interface.ts index 8e43467bf..a12771b72 100644 --- a/packages/nestjs-auth-github/src/interfaces/auth-github-settings.interface.ts +++ b/packages/nestjs-auth-github/src/interfaces/auth-github-settings.interface.ts @@ -1,8 +1,8 @@ -import { Type } from '@nestjs/common'; +import { type Type } from '@nestjs/common'; -import { AuthenticationCodeInterface } from '@concepta/nestjs-common'; +import { type AuthenticationCodeInterface } from '@concepta/nestjs-common'; -import { MapProfile } from '../auth-github.types'; +import { type MapProfile } from '../auth-github.types'; export interface AuthGithubSettingsInterface { clientId: string; diff --git a/packages/nestjs-auth-github/src/utils/auth-github-map-profile.ts b/packages/nestjs-auth-github/src/utils/auth-github-map-profile.ts index 203c99c12..e851bb1c2 100644 --- a/packages/nestjs-auth-github/src/utils/auth-github-map-profile.ts +++ b/packages/nestjs-auth-github/src/utils/auth-github-map-profile.ts @@ -1,5 +1,5 @@ -import { AuthGithubCredentialsInterface } from '../interfaces/auth-github-credentials.interface'; -import { AuthGithubProfileInterface } from '../interfaces/auth-github-profile.interface'; +import { type AuthGithubCredentialsInterface } from '../interfaces/auth-github-credentials.interface'; +import { type AuthGithubProfileInterface } from '../interfaces/auth-github-profile.interface'; export const mapProfile = ( profile: AuthGithubProfileInterface, diff --git a/packages/nestjs-auth-google/package.json b/packages/nestjs-auth-google/package.json index c0aab118c..851bcec6c 100644 --- a/packages/nestjs-auth-google/package.json +++ b/packages/nestjs-auth-google/package.json @@ -19,7 +19,7 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.9", "@nestjs/passport": "^11.0.5", - "@nestjs/swagger": "^11.2.2", + "@nestjs/swagger": "11.2.2", "passport-google-oauth20": "^2.0.0" }, "devDependencies": { diff --git a/packages/nestjs-auth-google/src/auth-google.module-definition.ts b/packages/nestjs-auth-google/src/auth-google.module-definition.ts index 1d3d0802e..a8951fdfa 100644 --- a/packages/nestjs-auth-google/src/auth-google.module-definition.ts +++ b/packages/nestjs-auth-google/src/auth-google.module-definition.ts @@ -1,13 +1,13 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { IssueTokenService, - IssueTokenServiceInterface, + type IssueTokenServiceInterface, } from '@concepta/nestjs-authentication'; import { createSettingsProvider } from '@concepta/nestjs-common'; import { FederatedOAuthService } from '@concepta/nestjs-federated'; @@ -18,9 +18,9 @@ import { } from './auth-google.constants'; import { AuthGoogleStrategy } from './auth-google.strategy'; import { authGoogleDefaultConfig } from './config/auth-google-default.config'; -import { AuthGoogleOptionsExtrasInterface } from './interfaces/auth-google-options-extras.interface'; -import { AuthGoogleOptionsInterface } from './interfaces/auth-google-options.interface'; -import { AuthGoogleSettingsInterface } from './interfaces/auth-google-settings.interface'; +import { type AuthGoogleOptionsExtrasInterface } from './interfaces/auth-google-options-extras.interface'; +import { type AuthGoogleOptionsInterface } from './interfaces/auth-google-options.interface'; +import { type AuthGoogleSettingsInterface } from './interfaces/auth-google-settings.interface'; const RAW_OPTIONS_TOKEN = Symbol('__AUTH_GOOGLE_MODULE_RAW_OPTIONS_TOKEN__'); diff --git a/packages/nestjs-auth-google/src/auth-google.module.spec.ts b/packages/nestjs-auth-google/src/auth-google.module.spec.ts index 2d8bdce97..719e9cf61 100644 --- a/packages/nestjs-auth-google/src/auth-google.module.spec.ts +++ b/packages/nestjs-auth-google/src/auth-google.module.spec.ts @@ -1,4 +1,4 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; import { AuthenticationModule } from '@concepta/nestjs-authentication'; diff --git a/packages/nestjs-auth-google/src/auth-google.strategy.spec.ts b/packages/nestjs-auth-google/src/auth-google.strategy.spec.ts index f764b6458..fb1998cc0 100644 --- a/packages/nestjs-auth-google/src/auth-google.strategy.spec.ts +++ b/packages/nestjs-auth-google/src/auth-google.strategy.spec.ts @@ -1,9 +1,9 @@ import { UnauthorizedException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { FederatedOAuthService, - FederatedCredentialsInterface, + type FederatedCredentialsInterface, } from '@concepta/nestjs-federated'; import { @@ -13,8 +13,8 @@ import { import { AuthGoogleStrategy } from './auth-google.strategy'; import { AuthGoogleMissingEmailException } from './exceptions/auth-google-missing-email.exception'; import { AuthGoogleMissingIdException } from './exceptions/auth-google-missing-id.exception'; -import { AuthGoogleProfileInterface } from './interfaces/auth-google-profile.interface'; -import { AuthGoogleSettingsInterface } from './interfaces/auth-google-settings.interface'; +import { type AuthGoogleProfileInterface } from './interfaces/auth-google-profile.interface'; +import { type AuthGoogleSettingsInterface } from './interfaces/auth-google-settings.interface'; import { mapProfile } from './utils/auth-google-map-profile'; // Mock the PassportStrategy class diff --git a/packages/nestjs-auth-google/src/auth-google.types.ts b/packages/nestjs-auth-google/src/auth-google.types.ts index 00c2c7053..fab42b6e4 100644 --- a/packages/nestjs-auth-google/src/auth-google.types.ts +++ b/packages/nestjs-auth-google/src/auth-google.types.ts @@ -1,5 +1,5 @@ -import { AuthGoogleCredentialsInterface } from './interfaces/auth-google-credentials.interface'; -import { AuthGoogleProfileInterface } from './interfaces/auth-google-profile.interface'; +import { type AuthGoogleCredentialsInterface } from './interfaces/auth-google-credentials.interface'; +import { type AuthGoogleProfileInterface } from './interfaces/auth-google-profile.interface'; export type MapProfile = ( profile: AuthGoogleProfileInterface, diff --git a/packages/nestjs-auth-google/src/config/auth-google-default.config.ts b/packages/nestjs-auth-google/src/config/auth-google-default.config.ts index 0bc95bfa9..0e862d6bb 100644 --- a/packages/nestjs-auth-google/src/config/auth-google-default.config.ts +++ b/packages/nestjs-auth-google/src/config/auth-google-default.config.ts @@ -2,7 +2,7 @@ import { registerAs } from '@nestjs/config'; import { AUTH_GOOGLE_MODULE_DEFAULT_SETTINGS_TOKEN } from '../auth-google.constants'; import { AuthGoogleLoginDto } from '../dto/auth-google-login.dto'; -import { AuthGoogleSettingsInterface } from '../interfaces/auth-google-settings.interface'; +import { type AuthGoogleSettingsInterface } from '../interfaces/auth-google-settings.interface'; import { mapProfile } from '../utils/auth-google-map-profile'; import { authGoogleParseScope } from '../utils/auth-google-scope-parser.util'; diff --git a/packages/nestjs-auth-google/src/exceptions/auth-google-missing-email.exception.ts b/packages/nestjs-auth-google/src/exceptions/auth-google-missing-email.exception.ts index 6cad94739..917b6fd5d 100644 --- a/packages/nestjs-auth-google/src/exceptions/auth-google-missing-email.exception.ts +++ b/packages/nestjs-auth-google/src/exceptions/auth-google-missing-email.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthGoogleException } from './auth-google.exception'; diff --git a/packages/nestjs-auth-google/src/exceptions/auth-google-missing-id.exception.ts b/packages/nestjs-auth-google/src/exceptions/auth-google-missing-id.exception.ts index a1f68304f..a8a9a35b1 100644 --- a/packages/nestjs-auth-google/src/exceptions/auth-google-missing-id.exception.ts +++ b/packages/nestjs-auth-google/src/exceptions/auth-google-missing-id.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { AuthGoogleException } from './auth-google.exception'; diff --git a/packages/nestjs-auth-google/src/exceptions/auth-google.exception.ts b/packages/nestjs-auth-google/src/exceptions/auth-google.exception.ts index a34956278..20786d66e 100644 --- a/packages/nestjs-auth-google/src/exceptions/auth-google.exception.ts +++ b/packages/nestjs-auth-google/src/exceptions/auth-google.exception.ts @@ -1,6 +1,6 @@ import { RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; /** * Generic auth google exception. diff --git a/packages/nestjs-auth-google/src/interfaces/auth-google-credentials.interface.ts b/packages/nestjs-auth-google/src/interfaces/auth-google-credentials.interface.ts index 217c1cef0..4008e5eec 100644 --- a/packages/nestjs-auth-google/src/interfaces/auth-google-credentials.interface.ts +++ b/packages/nestjs-auth-google/src/interfaces/auth-google-credentials.interface.ts @@ -1,8 +1,7 @@ import { - ReferenceEmailInterface, - ReferenceIdInterface, + type ReferenceEmailInterface, + type ReferenceIdInterface, } from '@concepta/nestjs-common'; export interface AuthGoogleCredentialsInterface - extends ReferenceIdInterface, - ReferenceEmailInterface {} + extends ReferenceIdInterface, ReferenceEmailInterface {} diff --git a/packages/nestjs-auth-google/src/interfaces/auth-google-options-extras.interface.ts b/packages/nestjs-auth-google/src/interfaces/auth-google-options-extras.interface.ts index 408b09b95..92f70ec43 100644 --- a/packages/nestjs-auth-google/src/interfaces/auth-google-options-extras.interface.ts +++ b/packages/nestjs-auth-google/src/interfaces/auth-google-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface AuthGoogleOptionsExtrasInterface - extends Pick {} +export interface AuthGoogleOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-auth-google/src/interfaces/auth-google-options.interface.ts b/packages/nestjs-auth-google/src/interfaces/auth-google-options.interface.ts index dac771354..af351915e 100644 --- a/packages/nestjs-auth-google/src/interfaces/auth-google-options.interface.ts +++ b/packages/nestjs-auth-google/src/interfaces/auth-google-options.interface.ts @@ -1,10 +1,9 @@ -import { IssueTokenServiceInterface } from '@concepta/nestjs-authentication'; -import { ModuleOptionsSettingsInterface } from '@concepta/nestjs-common'; +import { type IssueTokenServiceInterface } from '@concepta/nestjs-authentication'; +import { type ModuleOptionsSettingsInterface } from '@concepta/nestjs-common'; -import { AuthGoogleSettingsInterface } from './auth-google-settings.interface'; +import { type AuthGoogleSettingsInterface } from './auth-google-settings.interface'; -export interface AuthGoogleOptionsInterface - extends ModuleOptionsSettingsInterface { +export interface AuthGoogleOptionsInterface extends ModuleOptionsSettingsInterface { /** * Implementation of a class to issue tokens */ diff --git a/packages/nestjs-auth-google/src/interfaces/auth-google-profile.interface.ts b/packages/nestjs-auth-google/src/interfaces/auth-google-profile.interface.ts index ff0d273ce..ebd593356 100644 --- a/packages/nestjs-auth-google/src/interfaces/auth-google-profile.interface.ts +++ b/packages/nestjs-auth-google/src/interfaces/auth-google-profile.interface.ts @@ -1,13 +1,12 @@ import { - ReferenceEmailInterface, - ReferenceIdInterface, + type ReferenceEmailInterface, + type ReferenceIdInterface, } from '@concepta/nestjs-common'; -import { AuthGoogleEmailsInterface } from './auth-google-emails.interface'; +import { type AuthGoogleEmailsInterface } from './auth-google-emails.interface'; export interface AuthGoogleProfileInterface - extends ReferenceIdInterface, - Partial { + extends ReferenceIdInterface, Partial { displayName: string; name: { familyName: string; diff --git a/packages/nestjs-auth-google/src/interfaces/auth-google-settings.interface.ts b/packages/nestjs-auth-google/src/interfaces/auth-google-settings.interface.ts index 8db42a1c8..6b56c9f29 100644 --- a/packages/nestjs-auth-google/src/interfaces/auth-google-settings.interface.ts +++ b/packages/nestjs-auth-google/src/interfaces/auth-google-settings.interface.ts @@ -1,10 +1,10 @@ -import { StrategyOptions } from 'passport-google-oauth20'; +import { type StrategyOptions } from 'passport-google-oauth20'; -import { Type } from '@nestjs/common'; +import { type Type } from '@nestjs/common'; -import { AuthenticationCodeInterface } from '@concepta/nestjs-common'; +import { type AuthenticationCodeInterface } from '@concepta/nestjs-common'; -import { MapProfile } from '../auth-google.types'; +import { type MapProfile } from '../auth-google.types'; export interface AuthGoogleSettingsInterface extends StrategyOptions { loginDto?: Type; diff --git a/packages/nestjs-auth-google/src/utils/auth-google-map-profile.ts b/packages/nestjs-auth-google/src/utils/auth-google-map-profile.ts index 1a7582ee1..e6e83d21d 100644 --- a/packages/nestjs-auth-google/src/utils/auth-google-map-profile.ts +++ b/packages/nestjs-auth-google/src/utils/auth-google-map-profile.ts @@ -1,5 +1,5 @@ -import { AuthGoogleCredentialsInterface as AuthGoogleCredentialsInterface } from '../interfaces/auth-google-credentials.interface'; -import { AuthGoogleProfileInterface } from '../interfaces/auth-google-profile.interface'; +import { type AuthGoogleCredentialsInterface as AuthGoogleCredentialsInterface } from '../interfaces/auth-google-credentials.interface'; +import { type AuthGoogleProfileInterface } from '../interfaces/auth-google-profile.interface'; export const mapProfile = ( profile: AuthGoogleProfileInterface, diff --git a/packages/nestjs-auth-jwt/README.md b/packages/nestjs-auth-jwt/README.md deleted file mode 100644 index 39fba18d5..000000000 --- a/packages/nestjs-auth-jwt/README.md +++ /dev/null @@ -1,897 +0,0 @@ -# Rockets NestJS JWT Authentication - -Authenticate requests using JWT tokens passed via the -request (headers, cookies, body, query, etc). - -## Project - -[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-auth-jwt)](https://www.npmjs.com/package/@concepta/nestjs-auth-jwt) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-auth-jwt)](https://www.npmjs.com/package/@concepta/nestjs-auth-jwt) -[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) -[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) - -## Table of Contents - -- [Tutorials](#tutorials) - - [1. Getting Started with AuthJwtModule](#1-getting-started-with-authjwtmodule) - - [1.1 Introduction](#11-introduction) - - [Overview of the Library](#overview-of-the-library) - - [Purpose and Key Features](#purpose-and-key-features) - - [1.2 Installation](#12-installation) - - [Install the AuthJwtModule package](#install-the-authjwtmodule-package) - - [Add the AuthJwtModule to Your NestJS Application](#add-the-authjwtmodule-to-your-nestjs-application) - - [1.3 Basic Setup in a NestJS Project](#13-basic-setup-in-a-nestjs-project) - - [Scenario: Users have a list of pets](#scenario-users-have-a-list-of-pets) - - [Step 1: Create Entities](#step-1-create-entities) - - [Step 2: Create Services](#step-2-create-services) - - [Step 3: Create Controller](#step-3-create-controller) - - [Step 4: Configure the Module](#step-4-configure-the-module) - - [1.4 First Authentication with JWT](#14-first-authentication-with-jwt) - - [Validating the Setup](#validating-the-setup) - - [Step 1: Obtain a JWT Token](#step-1-obtain-a-jwt-token) - - [Step 2: Make an Authenticated Request](#step-2-make-an-authenticated-request) - - [Example CURL Calls](#example-curl-calls) - - [Obtain a JWT token](#obtain-a-jwt-token) - - [Example JWT response](#example-jwt-response) - - [Make an authenticated request using the token](#make-an-authenticated-request-using-the-token) - - [Example authenticated response](#example-authenticated-response) -- [How-To Guides](#how-to-guides) - - [Setting Up a custom Module for Providers](#setting-up-a-custom-module-for-providers) - - [1. Registering AuthJwtModule Synchronously](#1-registering-authjwtmodule-synchronously) - - [2. Registering AuthJwtModule Asynchronously](#2-registering-authjwtmodule-asynchronously) - - [3. Global Registering AuthJwtModule Asynchronously](#3-global-registering-authjwtmodule-asynchronously) - - [4. Using Custom User Model Service](#4-using-custom-user-model-service) - - [5. Implementing and Using Custom Token Verification Service](#5-implementing-and-using-custom-token-verification-service) - - [6. Setting Up a Custom Guard](#6-setting-up-a-custom-guard) - - [Step 1: Implement the Custom Guard](#step-1-implement-the-custom-guard) - - [Step 2: Provide the Custom Guard in Module Configuration](#step-2-provide-the-custom-guard-in-module-configuration) - - [7. Disabling the Guard](#7-disabling-the-guard) - - [Disable the Guard in Module Configuration](#disable-the-guard-in-module-configuration) - - [8. Overwriting the Settings](#8-overwriting-the-settings) - - [9. Integration with Other NestJS Modules](#9-integration-with-other-nestjs-modules) -- [Reference](#reference) - - [1. AuthJwtModule API Reference](#1-authjwtmodule-api-reference) - - [2. AuthJwtOptionsInterface](#2-authjwtoptionsinterface) - - [3. AuthJwtModule Classes and Interfaces](#3-authjwtmodule-classes-and-interfaces) -- [Engineering Concepts](#engineering-concepts) - - [Conceptual Overview of JWT Authentication](#conceptual-overview-of-jwt-authentication) - - [What is JWT?](#what-is-jwt) - - [Benefits of Using JWT](#benefits-of-using-jwt) - - [Design Choices in AuthJwtModule](#design-choices-in-authjwtmodule) - - [Why Use NestJS Guards?](#why-use-nestjs-guards) - - [Synchronous vs Asynchronous Registration](#synchronous-vs-asynchronous-registration) - - [Global vs Feature-Specific Registration](#global-vs-feature-specific-registration) - - [Integrating AuthJwtModule with Other Modules](#integrating-authjwtmodule-with-other-modules) - - [How AuthJwtModule Works with AuthLocalModule](#how-authjwtmodule-works-with-authlocalmodule) - - [Integrating with AuthRefreshModule](#integrating-with-authrefreshmodule) - -## Tutorials - -### 1. Getting Started with AuthJwtModule - -### 1.1 Introduction - -#### Overview of the Library - -The `AuthJwtModule` is a powerful yet easy-to-use NestJS module designed for -implementing JWT-based authentication. With a few simple steps, you can integrate -secure authentication into your application without hassle. - -#### Purpose and Key Features - -- **Ease of Use**: The primary goal of `AuthJwtModule` is to simplify the process -of adding JWT authentication to your NestJS application. All you need to do is provide -configuration data, and the module handles the rest. - -- **Protect By Default**: Be default the `AuthJwtModule` provides a global `APP_GUARD` -to protect all routes by default. This can easily be overridden using the -`@AuthPublic` decorator. - -- **Synchronous and Asynchronous Registration**: Flexibly register the module either -synchronously or asynchronously, depending on your application's needs. - -- **Global and Feature-Specific Registration**: Use the module globally across your -application or tailor it for specific features. - -- **Seamless Integration**: Easily integrates with other NestJS modules like -`@concepta/nestjs-auth-local`, `@concepta/nestjs-auth-refresh`, and more. - -### 1.2 Installation - -#### Install the AuthJwtModule package - -To get started, install the `@concepta/nestjs-auth-jwt` packages and some other -dependencies from npm or yarn: - -```bash -npm install class-transformer -npm install class-validator -npm install @nestjs/jwt -npm install @concepta/nestjs-common -npm install @concepta/nestjs-authentication -npm install @concepta/nestjs-jwt -npm install @concepta/nestjs-auth-jwt -``` - -or - -```bash -yarn add class-transformer -yarn add class-validator -yarn add @nestjs/jwt -yarn add @concepta/nestjs-common -yarn add @concepta/nestjs-authentication -yarn add @concepta/nestjs-jwt -yarn add @concepta/nestjs-auth-jwt -``` - -#### Add the AuthJwtModule to Your NestJS Application - -Import the `AuthJwtModule` and required services in your application module. -Ensure to import `JwtModule` and provide the necessary configuration options, -including the required `userModelService`. - -### 1.3 Basic Setup in a NestJS Project - -#### Scenario: Users have a list of pets - -To demonstrate this scenario, we will set up an application where users can -have a list of pets. We will create the necessary entities, services, module -configurations to simulate the environment. - -> Note: The `@concepta/nestjs-user` module can be used in place of our -> example `User` related prerequisites. - -#### Step 1: Create Entities - -First, create the `User` and `Pet` entities. - -```ts -// user.entity.ts -import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; -import { Pet } from './pet.entity'; - -@Entity() -export class User { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column() - name: string; - - @OneToMany(() => Pet, pet => pet.user) - pets: Pet[]; -} -``` - -```ts -// pet.entity.ts -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.entity'; - -@Entity() -export class Pet { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column() - name: string; - - @ManyToOne(() => User, user => user.pets) - user: User; -} -``` - -#### Step 2: Create Services - -Next, create services for `User` and `Pet` to handle the business logic. - -```ts -// user.service.ts -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User } from './user.entity'; - -@Injectable() -export class UserService { - constructor( - @InjectRepository(User) - private userRepository: Repository, - ) {} - - findAll(): Promise { - return this.userRepository.find({ relations: ['pets'] }); - } - - findOne(id: string): Promise { - return this.userRepository.findOne({ - where: { id }, - relations: ['pets'], - }); - } -} -``` - -```ts -// user-model.service.ts -import { AuthJwtUserModelServiceInterface } from '@concepta/nestjs-auth-jwt'; -import { ReferenceIdInterface, ReferenceSubject } from '@concepta/nestjs-common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -export class UserModelService implements AuthJwtUserModelServiceInterface { - constructor( - private userService: UserService, - ) {} - async bySubject(subject: ReferenceSubject): Promise { - // return authorized user - return this.userService.findOne(subject); - } -} -``` - -```ts -// pet.service.ts -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Pet } from './pet.entity'; - -@Injectable() -export class PetService { - constructor( - @InjectRepository(Pet) - private petRepository: Repository, - ) {} - - findAll(): Promise { - return this.petRepository.find(); - } - - findByUserId(userId: number): Promise { - return this.petRepository.find({ where: { user: { id: userId } } }); - } -} -``` - -#### Step 3: Create Controller - -Create a controller to handle the HTTP requests. - -> Use `@AuthPublic` decorator from `@concepta/nestjs-authentication` -on the controller or individual routes if you want to override the -global JWT guard to make the route public. - -```ts -// user.controller.ts -import { Controller, Get, Param } from '@nestjs/common'; -import { UserService } from './user.service'; -import { PetService } from './pet.service'; -import { AuthJwtGuard } from '@concepta/nestjs-auth-jwt'; - -@Controller('user') -export class UserController { - constructor( - private userService: UserService, - private petService: PetService, - ) {} - - @Get(':id/pets') - async getPets(@Param('id') userId: number) { - return this.petService.findByUserId(userId); - } -} -``` - -#### Step 4: Configure the Module - -Configure the module to include the necessary services, controllers, and guards. - -```ts -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { UserController } from './user.controller'; -import { UserService } from './user.service'; -import { PetService } from './pet.service'; -import { User } from './user.entity'; -import { Pet } from './pet.entity'; -import { JwtModule, ExtractJwt } from '@concepta/nestjs-jwt'; -import { ConfigService } from '@nestjs/config'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([User, Pet]), - JwtModule.forRoot({}), // <- required for AuthJwtModule to work - AuthJwtModule.registerAsync({ - inject: [UserService], - useFactory: async (userModelService: UserService) => ({ - userModelService, - }), - }), - ], - controllers: [UserController], - providers: [UserService, PetService], -}); - -export class UserModule {}; -``` - -### 1.4 First Authentication with JWT - -#### Validating the Setup - -To validate the setup, you can use `curl` commands to simulate -frontend requests. - -By following these steps, you can validate that the setup is -working correctly and that authenticated requests to the `user/:id/pets` -endpoint return the expected list of pets for a given user. - -Here are the steps to test the `user/:id/pets` endpoint: - -#### Step 1: Obtain a JWT Token - -Assuming you have an authentication endpoint to obtain a JWT token, -use `curl` to get the token. Replace `[auth-url]` with your actual -authentication URL, and `[username]` and `[password]` with valid credentials. - -```bash -curl -X POST [auth-url] \ - -H "Content-Type: application/json" \ - -d '{"username": "[username]", "password": "[password]"}' -``` - -This should return a response with a JWT token, which you'll use for -authenticated requests. - -#### Step 2: Make an Authenticated Request - -Use the JWT token obtained in the previous step to make an authenticated -request to the `user/:id/pets` endpoint. Replace `[jwt-token]` with the actual -token and `[user-id]` with a valid user ID. - -```bash -curl -X GET http://localhost:3000/user/[user-id]/pets \ - -H "Authorization: Bearer [jwt-token]" -``` - -#### Example Curl Calls - -Here is an example sequence of curl commands: - -##### Obtain a JWT token - -```bash -curl -X POST http://localhost:3000/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username": "testuser", "password": "testpassword"}' -``` - -##### Example JWT response - -```json -{ - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} -``` - -##### Make an authenticated request using the token - -```bash -curl -X GET http://localhost:3000/user/1/pets \ - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -``` - -##### Example authenticated response - -```json -[ - { - "id": 1, - "name": "Fluffy", - "user": { - "id": 1, - "name": "John Doe", - "pets": [] - } - } -] -``` - -## How-To Guides - -### Setting Up a Custom Module for Providers - -Before diving into the How-To Guides, we'll set up a custom module that -includes the necessary providers and exports for `UserModelService`, -`MyVerifyTokenService`, and `MyAppGuard`. - -This will ensure that our asynchronous registration examples can -inject these services correctly. - -```ts -import { Module } from '@nestjs/common'; -import { UserModelService } from './user-model.service'; -import { MyVerifyTokenService } from './verify-token.service'; -import { MyAppGuard } from './my-app-guard'; - -@Module({ - providers: [UserModelService, MyVerifyTokenService, MyAppGuard], - exports: [UserModelService, MyVerifyTokenService, MyAppGuard], -}); - -export class MyProviderModule {}; -``` - -### 1. Registering AuthJwtModule Synchronously - -```ts -import * as jwt from 'jsonwebtoken'; -import { Module } from '@nestjs/common'; -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { JwtModule, ExtractJwt } from '@concepta/nestjs-jwt'; -import { UserModelService } from './user-model.service'; - -// define the verifyToken function -const verifyToken = async ( - token: string, - done: (error: any, payload?: any) => void -) => { - try { - const payload = await jwt.verify(token, 'your-secret-key'); - done(null, payload); - } catch (error) { - done(error); - } -}; - -@Module({ - imports: [ - JwtModule.forRoot({}), // <- required for AuthJwtModule to work - AuthJwtModule.register({ - userModelService: new UserModelService(), // <- required - settings: { - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - verifyToken, - }, - verifyTokenService: new MyVerifyTokenService(), // <- optional custom service - appGuard: new MyAppGuard(), // <- optional custom guard - }), - ], -}) -export class AppModule {} -``` - -### 2. Registering AuthJwtModule Asynchronously - -```ts -import * as jwt from 'jsonwebtoken'; -import { Module } from '@nestjs/common'; -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { JwtModule, ExtractJwt } from '@concepta/nestjs-jwt'; -import { ConfigService } from '@nestjs/config'; -import { MyProviderModule } from './my-provider.module'; - -// define the verifyToken function -const verifyToken = (configService: ConfigService) => async ( - token: string, - done: (error: any, payload?: any) => void -) => { - try { - const payload = await jwt.verify(token, configService.get('JWT_SECRET')); - done(null, payload); - } catch (error) { - done(error); - } -}; - -@Module({ - imports: [ - JwtModule.forRoot({}), // <- required for AuthJwtModule to work - MyProviderModule, // <- import the my provider module - AuthJwtModule.registerAsync({ - imports: [MyProviderModule], - useFactory: async ( - configService: ConfigService, - userModelService: UserModelService, - verifyTokenService: MyVerifyTokenService, - appGuard: MyAppGuard, - ) => ({ - userModelService, // injected via useFactory - settings: { - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - verifyToken: verifyToken(configService), - }, - verifyTokenService, // injected via useFactory - appGuard, // injected via useFactory - }), - inject: [ConfigService, UserModelService, MyVerifyTokenService, MyAppGuard], - }), - ], -}); - -export class AppModule {}; -``` - -### 3. Global Registering AuthJwtModule Asynchronously - -```ts -import * as jwt from 'jsonwebtoken'; -import { Module } from '@nestjs/common'; -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { JwtModule, ExtractJwt } from '@concepta/nestjs-jwt'; -import { ConfigService } from '@nestjs/config'; -import { MyProviderModule } from './my-provider.module'; - -// define the verifyToken function -const verifyToken = (configService: ConfigService) => async ( - token: string, - done: (error: any, payload?: any) => void -) => { - try { - const payload = await jwt.verify(token, configService.get('JWT_SECRET')); - done(null, payload); - } catch (error) { - done(error); - } -}; - -@Module({ - imports: [ - JwtModule.forRoot({}), // <- required for AuthJwtModule to work - MyProviderModule, // <- import the my provider module - AuthJwtModule.forRootAsync({ - imports: [MyProviderModule], - useFactory: async ( - configService: ConfigService, - userModelService: UserModelService, - verifyTokenService: MyVerifyTokenService, - appGuard: MyAppGuard, - ) => ({ - userModelService, // injected via useFactory - settings: { - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - verifyToken: verifyToken(configService), - }, - verifyTokenService, // injected via useFactory - appGuard, // injected via useFactory - }), - inject: [ConfigService, UserModelService, MyVerifyTokenService, MyAppGuard], - }), - ], -}); - -export class AppModule {}; -``` - -### 4. Using Custom User Model Service - -This service is responsible for looking up user information based -on the JWT payload. It implements the `AuthJwtUserModelServiceInterface` -and must be provided to the module. - -```ts -import { AuthJwtUserModelServiceInterface } from '@concepta/nestjs-auth-jwt'; -import { ReferenceIdInterface, ReferenceSubject } from '@concepta/nestjs-common'; - -export class UserModelService implements AuthJwtUserModelServiceInterface { - async bySubject(subject: ReferenceSubject): Promise { - // implement user model logic here - } -} -``` - -#### 5. Implementing and Using Custom Token Verification Service - -This service verifies JWT tokens. If not provided, the default verification -logic will be used. It extends the `VerifyTokenServiceInterface`. - -```ts -import { JwtService } from '@nestjs/jwt'; -import { Injectable } from '@nestjs/common'; -import { VerifyTokenServiceInterface } from '@concepta/nestjs-authentication'; - -@Injectable() -export class MyVerifyTokenService implements VerifyTokenServiceInterface { - accessToken(): ReturnType { - return new Promise((resolve, reject) => { - try { - // your custom logic to sign and validate the the token - resolve({ accessToken: 'access-token' }); - } catch (error) { - reject(error); - } - }); - } - - refreshToken( - ...args: Parameters - ): ReturnType { - return new Promise((resolve, reject) => { - try { - // your custom logic to sign and validate the the token - resolve({ accessToken: 'refresh-token' }); - } catch (error) { - reject(error); - } - }); - } -} -``` - -### 6. Setting Up a Custom Guard - -To use a custom guard, you need to implement the `CanActivate` interface -from NestJS and provide it in the module configuration. - -To take advantage of the ability to enable/disable guards via configuration -settings or with the `@AuthPublic` decorator, it is highly recommended that -you extend `AuthJwtGuard` class or call the `AuthGuard()` class factory from -the `@concepta/nestjs-authentication` module. - -#### Step 1: Implement the Custom Guard - -Create a custom guard by extending the `AuthJwtGuard`. - -```ts -import { Injectable, ExecutionContext } from '@nestjs/common'; -import { AuthGuard } from '@concepta/nestjs-authentication'; - -@Injectable() -export class MyAppGuard extends AuthJwtGuard { - canActivate(context: ExecutionContext) { - // call super class first - if (!super.canActivate(context)) { - return false; - } - - // implement your custom authentication logic here - return true; - } -} -``` - -#### Step 2: Provide the Custom Guard in Module Configuration - -Update the module configuration to use the custom guard. - -```ts -// ... -AuthJwtModule.registerAsync({ - useFactory: async (userModelService: UserService) => ({ - userModelService, - appGuard: MyAppGuard, // use the custom guard - }), - inject: [UserService], -}), -// ... -``` - -### 7. Disabling the Guard - -To completely disable the global guard for all routes, you can set the -`appGuard` option to false. - -#### Disable the Guard in Module Configuration - -Update the module configuration to disable the global `APP_GUARD`. - -```ts -// ... -AuthJwtModule.registerAsync({ - useFactory: async (userModelService: UserService) => ({ - userModelService, - appGuard: false, // disable the global APP_GUARD - }), - inject: [UserService], -}), -// ... -``` - -### 8. Overwriting the settings - -```ts -import { ExtractJwt, JwtStrategyOptionsInterface } from "@concepta/nestjs-jwt"; - -const settings: JwtStrategyOptionsInterface = { - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - verifyToken: async ( - token: string, - done: (error: any, payload?: any) => void, - ) => { - try { - // add custom logic ot validate token - const payload = { id: 'user-id' }; - done(null, payload); - } catch (error) { - done(error); - } - }, -}; - -// ... -AuthJwtModule.registerAsync({ - useFactory: async (userModelService: UserService) => ({ - userModelService, - settings, - }), - inject: [UserService], -}), -// ... -``` - -### 9. Integration with Other NestJS Modules - -Integrate `@concepta/nestjs-auth-jwt` with other NestJS modules like -`@concepta/nestjs-user`, `@concepta/nestjs-auth-local`, -`@concepta/nestjs-auth-refresh`, and more for a comprehensive -authentication system. - -# Reference - -Detailed Descriptions of All Classes, Methods, and Properties - -## 1. AuthJwtModule API Reference - -- ### register(options: AuthJwtOptions) - - - Registers the module with synchronous options. - -- ### registerAsync(options: AuthJwtAsyncOptions) - - - Registers the module with asynchronous options. - -- ### forRoot(options: AuthJwtOptions) - - - Registers the module globally with synchronous options. - -- ### forRootAsync(options: AuthJwtAsyncOptions) - - - Registers the module globally with asynchronous options. - -- ### forFeature(options: AuthJwtOptions) - - - Registers the module for specific features with custom options. - -## 2. AuthJwtOptionsInterface - -The `AuthJwtOptionsInterface` provides various configuration options -to customize the behavior of the `AuthJwtModule`. - -Below is a summary of the key options: - -- ### userModelService (required) - - - Service for looking up user information based on JWT payload. - -- ### verifyTokenService (optional) - - - Service for verifying JWT tokens. - -- ### appGuard (optional) - - - Custom guard to protect routes; can be set to a custom guard or `false`. - -- ### settings (optional) - - - JWT strategy settings, including token extraction and verification logic. - -## 3. AuthJwtModule Classes and Interfaces - -- AuthJwtUserModelServiceInterface -- VerifyTokenServiceInterface -- JwtStrategyOptionsInterface - -# Engineering Concepts - -## Conceptual Overview of JWT Authentication - -### What is JWT? - -JSON Web Tokens (JWT) are a compact, URL-safe means of representing claims to -be transferred between two parties. - -The token is composed of three parts: the header, payload, and signature. -The header typically consists of the token type (JWT) and the signing -algorithm (e.g., HMAC SHA256). - -The payload contains the claims, which are statements about an entity -(typically, the user) and additional data. The signature is used to verify -that the sender of the JWT is who it says it is and to ensure that the message -wasn't changed along the way. - -For more details on JWT, see the -[JWT Introduction](https://jwt.io/introduction/). - -### Benefits of Using JWT - -JWTs offer several benefits for authentication and authorization: - -- **Stateless**: JWTs do not require storing user session information on -the server, which makes them ideal for scalable applications. - -- **Compact**: Their small size allows them to be easily passed in URLs, -POST parameters, or inside HTTP headers. - -- **Self-contained**: JWTs contain all the necessary information about the -user, avoiding the need to query the database for each request once the user -is authenticated. - -- **Security**: JWTs can be signed using a secret (with HMAC algorithm) or -a public/private key pair (with RSA or ECDSA), ensuring the data integrity. - -For more benefits, see the [JWT Handbook](https://auth0.com/learn/json-web-tokens/). - -## Design Choices in AuthJwtModule - -### Why Use NestJS Guards? - -Description: NestJS guards provide a way to control the access to various -parts of the application by checking certain conditions before the route -handler is executed. - -In `AuthJwtModule`, guards are used to implement authentication and -authorization logic. By using guards, developers can apply security policies -across routes efficiently, ensuring that only authenticated and authorized -users can access protected resources. - -Read more about [NestJS Guards](https://docs.nestjs.com/guards). - -### Synchronous vs Asynchronous Registration - -The `AuthJwtModule` supports both synchronous and asynchronous registration: - -- **Synchronous Registration**: This method is used when the configuration -options are static and available at application startup. It simplifies the -setup process and is suitable for most use cases where configuration values -do not depend on external services. - -- **Asynchronous Registration**: This method is beneficial when configuration -options need to be retrieved from external sources, such as a database or an -external API, at runtime. It allows for more flexible and dynamic configuration -but requires an asynchronous factory function. - -For more on module registration, see the [NestJS Documentation](https://docs.nestjs.com/modules). - -### Global vs Feature-Specific Registration - -The `AuthJwtModule` can be registered globally or for specific features: - -- **Global Registration**: Makes the module available throughout the entire -application. This approach is useful when JWT authentication is required across -all or most routes in the application. - -- **Feature-Specific Registration**: Allows the module to be registered only -for specific features or modules within the application. This provides more -granular control, enabling different parts of the application to have distinct -authentication and authorization requirements. - -To understand more about global and feature-specific registration, refer to the -[NestJS Module Documentation](https://docs.nestjs.com/modules#global-modules). - -## Integrating AuthJwtModule with Other Modules - -### How AuthJwtModule Works with AuthLocalModule - -The `AuthJwtModule` can be seamlessly integrated with the -`AuthLocalModule` to provide a comprehensive authentication solution. -`AuthLocalModule` handles the initial authentication using local strategies -such as username and password. - -Once the user is authenticated, `AuthJwtModule` can issue a JWT that the -user can use for subsequent requests. This integration allows for secure and -efficient authentication processes combining the strengths of both modules. - -### Integrating with AuthRefreshModule - -Integrating `AuthJwtModule` with `AuthRefreshModule` enables the -application to handle token refresh logic. Refresh tokens are used to obtain -new access tokens without requiring the user to re-authenticate. - -This setup enhances the user experience by maintaining sessions securely and -seamlessly. The integration involves configuring both modules to use the same -token issuance and verification mechanisms, ensuring smooth interoperability -and security. diff --git a/packages/nestjs-auth-jwt/package.json b/packages/nestjs-auth-jwt/package.json deleted file mode 100644 index 78ea91ab7..000000000 --- a/packages/nestjs-auth-jwt/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@concepta/nestjs-auth-jwt", - "version": "7.0.0-alpha.10", - "description": "Rockets NestJS JWT Authorization", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "license": "BSD-3-Clause", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" - ], - "dependencies": { - "@concepta/nestjs-authentication": "^7.0.0-alpha.10", - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@concepta/nestjs-jwt": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9" - }, - "devDependencies": { - "@nestjs/testing": "^11.1.9", - "jest-mock-extended": "^4.0.0" - }, - "peerDependencies": { - "class-validator": "*", - "rxjs": "^7.1.0", - "typeorm": "^0.3.0" - } -} diff --git a/packages/nestjs-auth-jwt/src/__fixtures__/user/user-model.service.fixture.ts b/packages/nestjs-auth-jwt/src/__fixtures__/user/user-model.service.fixture.ts deleted file mode 100644 index 62957e437..000000000 --- a/packages/nestjs-auth-jwt/src/__fixtures__/user/user-model.service.fixture.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ReferenceIdInterface, - ReferenceSubject, -} from '@concepta/nestjs-common'; - -import { AuthJwtUserModelServiceInterface } from '../../interfaces/auth-jwt-user-model-service.interface'; - -@Injectable() -export class UserModelServiceFixture - implements AuthJwtUserModelServiceInterface -{ - async bySubject(subject: ReferenceSubject): Promise { - throw new Error(`Method not implemented, cant get ${subject}.`); - } -} diff --git a/packages/nestjs-auth-jwt/src/__fixtures__/user/user.controller.fixture.ts b/packages/nestjs-auth-jwt/src/__fixtures__/user/user.controller.fixture.ts deleted file mode 100644 index abaf83979..000000000 --- a/packages/nestjs-auth-jwt/src/__fixtures__/user/user.controller.fixture.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; - -import { AuthJwtGuard } from '../../auth-jwt.guard'; - -@Controller('user') -@UseGuards(AuthJwtGuard) -export class UserControllerFixtures { - /** - * Status - */ - @Get('status') - getStatus(): boolean { - return true; - } -} diff --git a/packages/nestjs-auth-jwt/src/__fixtures__/user/user.entity.fixture.ts b/packages/nestjs-auth-jwt/src/__fixtures__/user/user.entity.fixture.ts deleted file mode 100644 index efee8ef03..000000000 --- a/packages/nestjs-auth-jwt/src/__fixtures__/user/user.entity.fixture.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Entity } from 'typeorm'; - -import { ReferenceIdInterface } from '@concepta/nestjs-common'; - -@Entity() -export class UserFixture implements ReferenceIdInterface { - id!: string; -} diff --git a/packages/nestjs-auth-jwt/src/__fixtures__/user/user.module.fixture.ts b/packages/nestjs-auth-jwt/src/__fixtures__/user/user.module.fixture.ts deleted file mode 100644 index e963f7540..000000000 --- a/packages/nestjs-auth-jwt/src/__fixtures__/user/user.module.fixture.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { UserModelServiceFixture } from './user-model.service.fixture'; -import { UserControllerFixtures } from './user.controller.fixture'; - -@Global() -@Module({ - controllers: [UserControllerFixtures], - providers: [UserModelServiceFixture], - exports: [UserModelServiceFixture], -}) -export class UserModuleFixture {} diff --git a/packages/nestjs-auth-jwt/src/auth-jwt.constants.ts b/packages/nestjs-auth-jwt/src/auth-jwt.constants.ts deleted file mode 100644 index 7785304d0..000000000 --- a/packages/nestjs-auth-jwt/src/auth-jwt.constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const AUTH_JWT_MODULE_SETTINGS_TOKEN = 'AUTH_JWT_MODULE_SETTINGS_TOKEN'; - -export const AUTH_JWT_STRATEGY_NAME = 'jwt'; - -export const AUTH_JWT_MODULE_DEFAULT_SETTINGS_TOKEN = - 'AUTH_JWT_MODULE_DEFAULT_SETTINGS_TOKEN'; - -export const AuthJwtVerifyTokenService = Symbol( - '__AUTH_JWT_MODULE_VERIFY_TOKEN_SERVICE_TOKEN__', -); - -export const AuthJwtUserModelService = Symbol( - '__AUTH_JWT_MODULE_USER_MODEL_SERVICE_TOKEN__', -); diff --git a/packages/nestjs-auth-jwt/src/auth-jwt.guard.spec.ts b/packages/nestjs-auth-jwt/src/auth-jwt.guard.spec.ts deleted file mode 100644 index 4a1d4fdb8..000000000 --- a/packages/nestjs-auth-jwt/src/auth-jwt.guard.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { mock } from 'jest-mock-extended'; - -import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; - -import { AuthJwtGuard } from './auth-jwt.guard'; - -import { UserFixture } from './__fixtures__/user/user.entity.fixture'; -import { UserModuleFixture } from './__fixtures__/user/user.module.fixture'; - -describe(AuthJwtGuard, () => { - let context: ExecutionContext; - let authJwtGuard: AuthJwtGuard; - let spyCanActivate: jest.SpyInstance; - let user: UserFixture; - - beforeEach(async () => { - context = mock(); - - const moduleRef = await Test.createTestingModule({ - imports: [UserModuleFixture], - }).compile(); - authJwtGuard = moduleRef.get(AuthJwtGuard); - spyCanActivate = jest - .spyOn(AuthJwtGuard.prototype, 'canActivate') - .mockImplementation(() => true); - user = new UserFixture(); - user.id = randomUUID(); - }); - - describe(AuthJwtGuard.prototype.canActivate, () => { - it('should be success', async () => { - await authJwtGuard.canActivate(context); - expect(spyCanActivate).toHaveBeenCalled(); - expect(spyCanActivate).toHaveBeenCalledWith(context); - }); - }); - - describe(AuthJwtGuard.prototype.handleRequest, () => { - it('should return user', () => { - const response = authJwtGuard.handleRequest(undefined, user); - expect(response?.id).toBe(user.id); - }); - it('should throw error', () => { - const error = new Error(); - const t = () => { - authJwtGuard.handleRequest(error, user); - }; - expect(t).toThrow(); - }); - it('should throw error unauthorized', () => { - const t = () => { - authJwtGuard.handleRequest(undefined, undefined); - }; - expect(t).toThrow(UnauthorizedException); - }); - }); -}); diff --git a/packages/nestjs-auth-jwt/src/auth-jwt.guard.ts b/packages/nestjs-auth-jwt/src/auth-jwt.guard.ts deleted file mode 100644 index 770cbccb7..000000000 --- a/packages/nestjs-auth-jwt/src/auth-jwt.guard.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; - -import { AuthGuard } from '@concepta/nestjs-authentication'; -import { ReferenceIdInterface } from '@concepta/nestjs-common'; - -import { AUTH_JWT_STRATEGY_NAME } from './auth-jwt.constants'; - -@Injectable() -export class AuthJwtGuard extends AuthGuard(AUTH_JWT_STRATEGY_NAME, { - canDisable: true, -}) { - handleRequest( - err: Error | undefined, - user: T, - info?: Error, - ) { - // You can throw an exception based on either "info" or "err" arguments - if (err || !user) { - throw new UnauthorizedException(null, { cause: err ?? info }); - } - return user; - } -} diff --git a/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.spec.ts b/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.spec.ts deleted file mode 100644 index 6371c2b29..000000000 --- a/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { FactoryProvider } from '@nestjs/common'; - -import { AuthJwtGuard } from './auth-jwt.guard'; -import { - AuthJwtOptions, - createAuthJwtAppGuardProvider, -} from './auth-jwt.module-definition'; - -describe(createAuthJwtAppGuardProvider.name, () => { - const guard = mock(); - - it('should return null if appGuard is explicitly false', async () => { - const options: Pick = { appGuard: false }; - const provider = createAuthJwtAppGuardProvider(options) as FactoryProvider; - const result = await provider.useFactory(options, guard); - expect(result).toBeNull(); - }); - - it('should return appGuard if set, or fall back to default', async () => { - const options = { appGuard: guard }; - const provider = createAuthJwtAppGuardProvider(options) as FactoryProvider; - const result = await provider.useFactory(options, guard); - expect(result).toBe(options.appGuard); - }); -}); diff --git a/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.ts b/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.ts deleted file mode 100644 index 59a78f092..000000000 --- a/packages/nestjs-auth-jwt/src/auth-jwt.module-definition.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { - ConfigurableModuleBuilder, - DynamicModule, - Provider, -} from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { APP_GUARD } from '@nestjs/core'; - -import { - VerifyTokenService, - VerifyTokenServiceInterface, -} from '@concepta/nestjs-authentication'; -import { createSettingsProvider } from '@concepta/nestjs-common'; - -import { - AUTH_JWT_MODULE_SETTINGS_TOKEN, - AuthJwtUserModelService, - AuthJwtVerifyTokenService, -} from './auth-jwt.constants'; -import { AuthJwtGuard } from './auth-jwt.guard'; -import { AuthJwtStrategy } from './auth-jwt.strategy'; -import { authJwtDefaultConfig } from './config/auth-jwt-default.config'; -import { AuthJwtOptionsExtrasInterface } from './interfaces/auth-jwt-options-extras.interface'; -import { AuthJwtOptionsInterface } from './interfaces/auth-jwt-options.interface'; -import { AuthJwtSettingsInterface } from './interfaces/auth-jwt-settings.interface'; - -const RAW_OPTIONS_TOKEN = Symbol('__AUTH_JWT_MODULE_RAW_OPTIONS_TOKEN__'); - -export const { - ConfigurableModuleClass: AuthJwtModuleClass, - OPTIONS_TYPE: AUTH_JWT_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: AUTH_JWT_ASYNC_OPTIONS_TYPE, -} = new ConfigurableModuleBuilder({ - moduleName: 'AuthJwt', - optionsInjectionToken: RAW_OPTIONS_TOKEN, -}) - .setExtras( - { global: false }, - definitionTransform, - ) - .build(); - -export type AuthJwtOptions = Omit; -export type AuthJwtAsyncOptions = Omit< - typeof AUTH_JWT_ASYNC_OPTIONS_TYPE, - 'global' ->; - -function definitionTransform( - definition: DynamicModule, - extras: AuthJwtOptionsExtrasInterface, -): DynamicModule { - const { providers } = definition; - const { global } = extras; - - return { - ...definition, - global, - imports: createAuthJwtImports(), - providers: createAuthJwtProviders({ providers }), - exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createAuthJwtExports()], - }; -} - -export function createAuthJwtImports(): DynamicModule['imports'] { - return [ConfigModule.forFeature(authJwtDefaultConfig)]; -} - -export function createAuthJwtExports() { - return [ - AUTH_JWT_MODULE_SETTINGS_TOKEN, - AuthJwtUserModelService, - AuthJwtVerifyTokenService, - AuthJwtStrategy, - AuthJwtGuard, - ]; -} - -export function createAuthJwtProviders(options: { - overrides?: AuthJwtOptions; - providers?: Provider[]; -}): Provider[] { - return [ - ...(options.providers ?? []), - AuthJwtStrategy, - AuthJwtGuard, - VerifyTokenService, - createAuthJwtOptionsProvider(options.overrides), - createAuthJwtVerifyTokenServiceProvider(options.overrides), - createAuthJwtUserModelServiceProvider(options.overrides), - createAuthJwtAppGuardProvider(options.overrides), - ]; -} - -export function createAuthJwtOptionsProvider( - optionsOverrides?: AuthJwtOptions, -): Provider { - return createSettingsProvider< - AuthJwtSettingsInterface, - AuthJwtOptionsInterface - >({ - settingsToken: AUTH_JWT_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: authJwtDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createAuthJwtVerifyTokenServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthJwtVerifyTokenService, - inject: [RAW_OPTIONS_TOKEN, VerifyTokenService], - useFactory: async ( - options: Pick, - defaultService: VerifyTokenServiceInterface, - ) => - optionsOverrides?.verifyTokenService ?? - options.verifyTokenService ?? - defaultService, - }; -} - -export function createAuthJwtUserModelServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthJwtUserModelService, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: Pick) => - optionsOverrides?.userModelService ?? options.userModelService, - }; -} - -export function createAuthJwtAppGuardProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: APP_GUARD, - inject: [RAW_OPTIONS_TOKEN, AuthJwtGuard], - useFactory: async ( - options: Pick, - defaultGuard: AuthJwtGuard, - ) => { - // get app guard from the options - const appGuard = optionsOverrides?.appGuard ?? options?.appGuard; - - // is app guard explicitly false? - if (appGuard === false) { - // yes, don't set a guard - return null; - } else { - // return app guard if set, or fall back to default - return appGuard ?? defaultGuard; - } - }, - }; -} diff --git a/packages/nestjs-auth-jwt/src/auth-jwt.module.spec.ts b/packages/nestjs-auth-jwt/src/auth-jwt.module.spec.ts deleted file mode 100644 index 1aac1e29f..000000000 --- a/packages/nestjs-auth-jwt/src/auth-jwt.module.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { - AuthenticationModule, - VerifyTokenService, -} from '@concepta/nestjs-authentication'; -import { JwtModule, JwtVerifyTokenService } from '@concepta/nestjs-jwt'; - -import { AuthJwtUserModelService } from './auth-jwt.constants'; -import { AuthJwtModule } from './auth-jwt.module'; -import { AuthJwtUserModelServiceInterface } from './interfaces/auth-jwt-user-model-service.interface'; - -import { UserModelServiceFixture } from './__fixtures__/user/user-model.service.fixture'; -import { UserModuleFixture } from './__fixtures__/user/user.module.fixture'; - -describe(AuthJwtModule, () => { - const jwtVerifyTokenService = mock(); - - let testModule: TestingModule; - let authJwtModule: AuthJwtModule; - let userModelService: AuthJwtUserModelServiceInterface; - let verifyTokenService: VerifyTokenService; - - describe(AuthJwtModule.forRoot, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthJwtModule.forRoot({ - verifyTokenService: new VerifyTokenService(jwtVerifyTokenService), - userModelService: new UserModelServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - describe(AuthJwtModule.register, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthJwtModule.register({ - verifyTokenService: new VerifyTokenService(jwtVerifyTokenService), - userModelService: new UserModelServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - describe(AuthJwtModule.forRootAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthJwtModule.forRootAsync({ - inject: [VerifyTokenService, UserModelServiceFixture], - useFactory: ( - verifyTokenService: VerifyTokenService, - userModelService: AuthJwtUserModelServiceInterface, - ) => ({ verifyTokenService, userModelService }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - describe(AuthJwtModule.registerAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthJwtModule.registerAsync({ - inject: [VerifyTokenService, UserModelServiceFixture], - useFactory: ( - verifyTokenService: VerifyTokenService, - userModelService: UserModelServiceFixture, - ) => ({ verifyTokenService, userModelService }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - function commonVars(module: TestingModule) { - authJwtModule = module.get(AuthJwtModule); - userModelService = module.get(AuthJwtUserModelService); - verifyTokenService = module.get(VerifyTokenService); - } - - function commonTests() { - expect(authJwtModule).toBeInstanceOf(AuthJwtModule); - expect(userModelService).toBeInstanceOf(UserModelServiceFixture); - expect(verifyTokenService).toBeInstanceOf(VerifyTokenService); - } -}); - -function testModuleFactory( - extraImports: DynamicModule['imports'] = [], -): ModuleMetadata { - return { - imports: [ - AuthenticationModule.forRoot({}), - JwtModule.forRoot({}), - UserModuleFixture, - ...extraImports, - ], - }; -} diff --git a/packages/nestjs-auth-jwt/src/auth-jwt.module.ts b/packages/nestjs-auth-jwt/src/auth-jwt.module.ts deleted file mode 100644 index f70ea30f1..000000000 --- a/packages/nestjs-auth-jwt/src/auth-jwt.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { DynamicModule, Module } from '@nestjs/common'; - -import { - AuthJwtAsyncOptions, - AuthJwtModuleClass, - AuthJwtOptions, -} from './auth-jwt.module-definition'; - -/** - * Auth local module - */ -@Module({}) -export class AuthJwtModule extends AuthJwtModuleClass { - static register(options: AuthJwtOptions): DynamicModule { - return super.register(options); - } - - static registerAsync(options: AuthJwtAsyncOptions): DynamicModule { - return super.registerAsync(options); - } - - static forRoot(options: AuthJwtOptions): DynamicModule { - return super.register({ ...options, global: true }); - } - - static forRootAsync(options: AuthJwtAsyncOptions): DynamicModule { - return super.registerAsync({ ...options, global: true }); - } -} diff --git a/packages/nestjs-auth-jwt/src/auth-jwt.strategy.spec.ts b/packages/nestjs-auth-jwt/src/auth-jwt.strategy.spec.ts deleted file mode 100644 index 289d29d2a..000000000 --- a/packages/nestjs-auth-jwt/src/auth-jwt.strategy.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { mock } from 'jest-mock-extended'; - -import { VerifyTokenServiceInterface } from '@concepta/nestjs-authentication'; -import { AuthorizationPayloadInterface } from '@concepta/nestjs-common'; - -import { AuthJwtStrategy } from './auth-jwt.strategy'; -import { AuthJwtUnauthorizedException } from './exceptions/auth-jwt-unauthorized.exception'; -import { AuthJwtSettingsInterface } from './interfaces/auth-jwt-settings.interface'; -import { AuthJwtUserModelServiceInterface } from './interfaces/auth-jwt-user-model-service.interface'; - -import { UserFixture } from './__fixtures__/user/user.entity.fixture'; - -describe(AuthJwtStrategy, () => { - let user: UserFixture; - let settings: Partial; - let verifyToken: VerifyTokenServiceInterface; - let userModelService: AuthJwtUserModelServiceInterface; - let authJwtStrategy: AuthJwtStrategy; - let authorizationPayload: AuthorizationPayloadInterface; - - beforeEach(async () => { - settings = mock>(); - verifyToken = mock(); - userModelService = mock(); - authJwtStrategy = new AuthJwtStrategy( - settings, - verifyToken, - userModelService, - ); - authorizationPayload = mock(); - user = new UserFixture(); - user.id = randomUUID(); - }); - - describe(AuthJwtStrategy.prototype.validate, () => { - it('should return user', async () => { - jest - .spyOn(userModelService, 'bySubject') - .mockImplementationOnce(async () => { - return user; - }); - const userResponse = await authJwtStrategy.validate(authorizationPayload); - expect(userResponse.id).toBe(user.id); - }); - - it('should throw error', async () => { - jest.spyOn(userModelService, 'bySubject').mockImplementationOnce(() => { - return new Promise((resolve) => { - resolve(null); - }); - }); - const t = async () => { - await authJwtStrategy.validate(authorizationPayload); - }; - await expect(t).rejects.toThrow(AuthJwtUnauthorizedException); - }); - }); -}); diff --git a/packages/nestjs-auth-jwt/src/auth-jwt.strategy.ts b/packages/nestjs-auth-jwt/src/auth-jwt.strategy.ts deleted file mode 100644 index bfc139fa1..000000000 --- a/packages/nestjs-auth-jwt/src/auth-jwt.strategy.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { - PassportStrategyFactory, - VerifyTokenServiceInterface, -} from '@concepta/nestjs-authentication'; -import { - ReferenceIdInterface, - AuthorizationPayloadInterface, -} from '@concepta/nestjs-common'; -import { - createVerifyAccessTokenCallback, - JwtStrategy, - JwtStrategyOptionsInterface, -} from '@concepta/nestjs-jwt'; - -import { - AUTH_JWT_STRATEGY_NAME, - AUTH_JWT_MODULE_SETTINGS_TOKEN, - AuthJwtUserModelService, - AuthJwtVerifyTokenService, -} from './auth-jwt.constants'; -import { AuthJwtUnauthorizedException } from './exceptions/auth-jwt-unauthorized.exception'; -import { AuthJwtSettingsInterface } from './interfaces/auth-jwt-settings.interface'; -import { AuthJwtUserModelServiceInterface } from './interfaces/auth-jwt-user-model-service.interface'; - -@Injectable() -export class AuthJwtStrategy extends PassportStrategyFactory( - JwtStrategy, - AUTH_JWT_STRATEGY_NAME, -) { - constructor( - @Inject(AUTH_JWT_MODULE_SETTINGS_TOKEN) - settings: Partial, - @Inject(AuthJwtVerifyTokenService) - verifyTokenService: VerifyTokenServiceInterface, - @Inject(AuthJwtUserModelService) - private userModelService: AuthJwtUserModelServiceInterface, - ) { - const options: Partial = { - verifyToken: createVerifyAccessTokenCallback(verifyTokenService), - ...settings, - }; - - super(options); - } - - /** - * Validate the user based on payload sub - * - * @param payload - The payload to validate - */ - async validate( - payload: AuthorizationPayloadInterface, - ): Promise { - const user = await this.userModelService.bySubject(payload.sub); - - if (user) { - return user; - } else { - throw new AuthJwtUnauthorizedException(); - } - } -} diff --git a/packages/nestjs-auth-jwt/src/config/auth-jwt-default.config.ts b/packages/nestjs-auth-jwt/src/config/auth-jwt-default.config.ts deleted file mode 100644 index afaff5ec2..000000000 --- a/packages/nestjs-auth-jwt/src/config/auth-jwt-default.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { ExtractJwt } from '@concepta/nestjs-jwt'; - -import { AUTH_JWT_MODULE_DEFAULT_SETTINGS_TOKEN } from '../auth-jwt.constants'; -import { AuthJwtSettingsInterface } from '../interfaces/auth-jwt-settings.interface'; - -/** - * Default configuration for auth local. - */ -export const authJwtDefaultConfig = registerAs( - AUTH_JWT_MODULE_DEFAULT_SETTINGS_TOKEN, - (): Partial => ({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - }), -); diff --git a/packages/nestjs-auth-jwt/src/exceptions/auth-jwt-unauthorized.exception.ts b/packages/nestjs-auth-jwt/src/exceptions/auth-jwt-unauthorized.exception.ts deleted file mode 100644 index 6076e1b9a..000000000 --- a/packages/nestjs-auth-jwt/src/exceptions/auth-jwt-unauthorized.exception.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthJwtException } from './auth-jwt.exception'; - -export class AuthJwtUnauthorizedException extends AuthJwtException { - constructor(options?: RuntimeExceptionOptions) { - super({ - safeMessage: 'Unable to authenticate user with provided JWT token.', - ...options, - }); - - this.errorCode = 'AUTH_JWT_UNAUTHORIZED_ERROR'; - } -} diff --git a/packages/nestjs-auth-jwt/src/exceptions/auth-jwt.exception.ts b/packages/nestjs-auth-jwt/src/exceptions/auth-jwt.exception.ts deleted file mode 100644 index 4f65a9f2a..000000000 --- a/packages/nestjs-auth-jwt/src/exceptions/auth-jwt.exception.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; -/** - * Generic auth jwt exception. - */ -export class AuthJwtException extends RuntimeException { - constructor(options?: RuntimeExceptionOptions) { - super(options); - this.errorCode = 'AUTH_JWT_ERROR'; - } -} diff --git a/packages/nestjs-auth-jwt/src/index.spec.ts b/packages/nestjs-auth-jwt/src/index.spec.ts deleted file mode 100644 index bf4fa2c65..000000000 --- a/packages/nestjs-auth-jwt/src/index.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - AuthJwtModule, - AuthJwtStrategy, - AuthJwtGuard, - JwtAuthGuard, -} from './index'; - -describe('Index', () => { - it('AuthJwtModule should be imported', () => { - expect(AuthJwtModule).toBeInstanceOf(Function); - }); - - it('AuthJwtStrategy should be imported', () => { - expect(AuthJwtStrategy).toBeInstanceOf(Function); - }); - - it('AuthJwtGuard should be imported', () => { - expect(AuthJwtGuard).toBeInstanceOf(Function); - }); - - it('JwtAuthGuard should be imported', () => { - expect(JwtAuthGuard).toBeInstanceOf(Function); - }); -}); diff --git a/packages/nestjs-auth-jwt/src/index.ts b/packages/nestjs-auth-jwt/src/index.ts deleted file mode 100644 index 796b338ed..000000000 --- a/packages/nestjs-auth-jwt/src/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { AuthJwtModule } from './auth-jwt.module'; -export { AuthJwtStrategy } from './auth-jwt.strategy'; -export { AuthJwtGuard, AuthJwtGuard as JwtAuthGuard } from './auth-jwt.guard'; - -// interfaces -export { AuthJwtOptionsInterface } from './interfaces/auth-jwt-options.interface'; -export { AuthJwtOptionsExtrasInterface } from './interfaces/auth-jwt-options-extras.interface'; -export { AuthJwtSettingsInterface } from './interfaces/auth-jwt-settings.interface'; -export { AuthJwtUserModelServiceInterface } from './interfaces/auth-jwt-user-model-service.interface'; - -// tokens -export { - AuthJwtUserModelService, - AuthJwtVerifyTokenService, -} from './auth-jwt.constants'; - -// exceptions -export { AuthJwtException } from './exceptions/auth-jwt.exception'; -export { AuthJwtUnauthorizedException } from './exceptions/auth-jwt-unauthorized.exception'; diff --git a/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-options-extras.interface.ts b/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-options-extras.interface.ts deleted file mode 100644 index f630891d6..000000000 --- a/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-options-extras.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface AuthJwtOptionsExtrasInterface - extends Pick {} diff --git a/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-options.interface.ts b/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-options.interface.ts deleted file mode 100644 index 07b6f962e..000000000 --- a/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-options.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CanActivate } from '@nestjs/common'; - -import { VerifyTokenServiceInterface } from '@concepta/nestjs-authentication'; - -import { AuthJwtSettingsInterface } from './auth-jwt-settings.interface'; -import { AuthJwtUserModelServiceInterface } from './auth-jwt-user-model-service.interface'; - -export interface AuthJwtOptionsInterface { - settings?: AuthJwtSettingsInterface; - userModelService: AuthJwtUserModelServiceInterface; - verifyTokenService?: VerifyTokenServiceInterface; - appGuard?: CanActivate | false; -} diff --git a/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-settings.interface.ts b/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-settings.interface.ts deleted file mode 100644 index 1264b6df9..000000000 --- a/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-settings.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { JwtStrategyOptionsInterface } from '@concepta/nestjs-jwt'; - -export interface AuthJwtSettingsInterface extends JwtStrategyOptionsInterface {} diff --git a/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-user-model-service.interface.ts b/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-user-model-service.interface.ts deleted file mode 100644 index 30ae9170a..000000000 --- a/packages/nestjs-auth-jwt/src/interfaces/auth-jwt-user-model-service.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { - BySubjectInterface, - ReferenceIdInterface, - ReferenceSubject, -} from '@concepta/nestjs-common'; - -export interface AuthJwtUserModelServiceInterface - extends BySubjectInterface {} diff --git a/packages/nestjs-auth-jwt/tsconfig.json b/packages/nestjs-auth-jwt/tsconfig.json deleted file mode 100644 index ef9980950..000000000 --- a/packages/nestjs-auth-jwt/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": [ - "./node_modules/@types", - "../../node_modules/@types" - ] - }, - "include": [ - "src/**/*.ts" - ] -} diff --git a/packages/nestjs-auth-local/README.md b/packages/nestjs-auth-local/README.md deleted file mode 100644 index c4b3715f9..000000000 --- a/packages/nestjs-auth-local/README.md +++ /dev/null @@ -1,630 +0,0 @@ -# Rockets NestJS Local Authentication - -Authenticate requests using username/email and password against a local or -remote data source. - -## Project - -[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-auth-local)](https://www.npmjs.com/package/@concepta/nestjs-auth-local) -[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-auth-local)](https://www.npmjs.com/package/@concepta/nestjs-auth-local) -[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) -[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) - -## Table of Contents - -- [Tutorials](#tutorials) - - [1. Getting Started with AuthLocalModule](#1-getting-started-with-authlocalmodule) - - [1.1 Introduction](#11-introduction) - - [Overview of the Library](#overview-of-the-library) - - [Purpose and Key Features](#purpose-and-key-features) - - [1.2 Installation](#12-installation) - - [Install the AuthLocalModule package](#install-the-authlocalmodule-package) - - [Add the AuthLocalModule to Your NestJS Application](#add-the-authlocalmodule-to-your-nestjs-application) - - [1.3 Basic Setup in a NestJS Project](#13-basic-setup-in-a-nestjs-project) - - [Scenario: Users can log in using local authentication](#scenario-users-can-log-in-using-local-authentication) - - [Step 1: Create Entities](#step-1-create-entities) - - [Step 2: Create Services](#step-2-create-services) - - [Step 3: Configure the Module](#step-3-configure-the-module) - - [Validating the Setup](#validating-the-setup) -- [How-To Guides](#how-to-guides) - - [1. Registering AuthLocalModule Synchronously](#1-registering-authlocalmodule-synchronously) - - [2. Registering AuthLocalModule Asynchronously](#2-registering-authlocalmodule-asynchronously) - - [3. Global Registering AuthLocalModule Asynchronously](#3-global-registering-authlocalmodule-asynchronously) - - [4. Implementing User Model Service](#4-implementing-user-model-service) - - [5. Implementing custom token issuance service](#5-implementing-custom-token-issuance-service) - - [6. Implementing a custom user validation service](#6-implementing-a-custom-user-validation-service) - - [7. Implementing a custom password validation service](#7-implementing-a-custom-password-validation-service) - - [8. Overriding the Settings](#8-overriding-the-settings) - - [9. Integration with Other NestJS Modules](#9-integration-with-other-nestjs-modules) -- [Reference](#reference) -- [Explanation](#explanation) - - [Conceptual Overview of Local Authentication](#conceptual-overview-of-local-authentication) - - [What is Local Authentication?](#what-is-local-authentication) - - [Benefits of Using Local Authentication](#benefits-of-using-local-authentication) - - [Design Choices in AuthLocalModule](#design-choices-in-authlocalmodule) - - [Why Use Local Authentication?](#why-use-local-authentication) - - [Synchronous vs Asynchronous Registration](#synchronous-vs-asynchronous-registration) - - [Global vs Feature-Specific Registration](#global-vs-feature-specific-registration) - -## Tutorials - -### 1. Getting Started with AuthLocalModule - -#### 1.1 Introduction - -##### Overview of the Library - -The `AuthLocalModule` is a robust NestJS module designed for implementing -local authentication using username and password. This module leverages the -[`passport-local`](https://www.passportjs.org/packages/passport-local) strategy -to authenticate users locally within your application. - -##### Purpose and Key Features - -- **Local Authentication**: Provides a straightforward way to implement local -authentication using username and password. - -- **Synchronous and Asynchronous Registration**: Flexibly register the module -either synchronously or asynchronously, depending on your application's needs. - -- **Global and Feature-Specific Registration**: Use the module globally across -your application or tailor it for specific features. - -- **Customizable**: Easily customize various aspects such as user validation, -token issuance, and password validation. - -#### 1.2 Installation - -##### Install the AuthLocalModule package - -```sh -npm install class-transformer -npm install class-validator -npm install @concepta/nestjs-common -npm install @concepta/nestjs-authentication -npm install @concepta/nestjs-password -npm install @concepta/nestjs-jwt -npm install @concepta/nestjs-auth-local - -or - -yarn add class-transformer -yarn add class-validator -yarn add @concepta/nestjs-common -yarn add @concepta/nestjs-authentication -yarn add @concepta/nestjs-password -yarn add @concepta/nestjs-jwt -yarn add @concepta/nestjs-auth-local - -``` - -##### Add the AuthLocalModule to Your NestJS Application - -Import the `AuthLocalModule` and required services in your application module. -Ensure to provide the necessary configuration options at -`AuthLocalOptionsInterface`. - -The `AuthLocalOptionsInterface` defines the configuration options for the -local authentication strategy within a NestJS application using the -`@concepta/nestjs-auth-local` package. This interface allows for the customization -of `userModelService`, `issueTokenService`, `validateUserService`, and -`passwordValidationService`. Please see [Reference](#reference) for more -details. - -Optional fields utilize default implementations, enabling straightforward -integration and flexibility to override with custom implementations as needed. -This setup ensures that developers can tailor the authentication process to -specific requirements while maintaining a robust and secure authentication -framework. - -#### 1.3 Basic Setup in a NestJS Project - -##### Scenario: Users can log in using local authentication - -To test this scenario, we will set up an application where users can log -in using a username and password. We will create the necessary entities, -services, module configurations. - -> Note: The `@concepta/nestjs-user` module can be used in place of our -> example `User` related prerequisites. - -## Step 1: Create Entities - -First, create the `User` entity. - -```ts -// user.entity.ts -export class User { - id: number; - username: string; - password: string; -} -``` - -## Step 2: Create Services - -Next, you need to create the `UserModelService`. This -service is responsible for the business logic related to -retrieving user data. It should implement the -`AuthLocalUserModelServiceInterface`. - -Within this service, implement the `byUsername` method to -fetch user details by their username (or email). Ensure that -the method returns a `User` object containing `passwordHash` and -`passwordSalt`. - -These attributes are crucial as they are used by the -`validateUser` method in the `passwordValidationService` -to authenticate the user, which is a configurable option -in the `AuthLocalModule`. - -```ts -// user-model.service.ts -import { Injectable } from '@nestjs/common'; -import { ReferenceUsername } from '@concepta/nestjs-common'; -import { AuthLocalUserModelServiceInterface } from '@concepta/nestjs-auth-local'; -import { AuthLocalCredentialsInterface } from '@concepta/nestjs-auth-local/dist/interfaces/auth-local-credentials.interface'; - -@Injectable() -export class UserModelService implements AuthLocalUserModelServiceInterface { - async byUsername( - username: ReferenceUsername, - ): Promise { - // make sure this method will return a valid user with - // correct passwordHash and passwordSalt - // let's user this mock data for the purposes of this tutorial - return { - id: '5b3f5fd3-9426-4c4d-a06d-b4d55079034d', - username: username, - passwordHash: - '$2b$12$9rQ4qZx8gpTaTR4ic3LQ.OkebyVBa48DP42jErL1zfqF17WeG4hHC', - passwordSalt: '$2b$12$9rQ4qZx8gpTaTR4ic3LQ.O', - active: true, - }; - } -} -``` - -## Step 3: Configure the Module - -Configure the module to include the necessary services `userModelService`. - -```ts -// app.module.ts -import { Module } from '@nestjs/common'; -import { AuthLocalModule } from '@concepta/nestjs-auth-local'; -import { JwtModule } from '@concepta/nestjs-jwt'; -import { UserModelService } from './user-model.service'; - -@Module({ - imports: [ - JwtModule.forRoot({}), - AuthLocalModule.forRoot({ - userModelService: new UserModelService(), - }), - ], - controllers: [], - providers: [], -}) -export class AppModule {} -``` - -## Validating the Setup - -To validate the setup, you can use `curl` commands to simulate frontend -requests. Here are the steps to test the login endpoint: - -### Step 1: Obtain a JWT Token - -Assuming you have an endpoint to obtain a JWT token, use `curl` to get -the token. Replace `auth-url` with your actual authentication URL, and -`username` and `password` with valid credentials. - -```sh -curl -X POST http://localhost:3000/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username": "testuser", "password": "testpassword"}' -``` - -This should return a response with a login message. - -### Example - -Here is an example sequence of `curl` commands to validate the login setup: - -1. **Login Request:** - -Command: - -```sh -curl -X POST http://localhost:3000/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username": "username", "password": "Test1234"}' -``` - -Response (example): - -```json -{ - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0NjZkMTAyNS1iZGNkLTRiNWItYTYxMi0yYThiZTU2MDhlNjIiLCJpYXQiOjE3MTgwNDg1NDQsImV4cCI6MTcxODA1MjE0NH0.Zl2i59w89cgJxfI4lXn6VmOhC5GLEqMm2nWkiVKpEUs", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0NjZkMTAyNS1iZGNkLTRiNWItYTYxMi0yYThiZTU2MDhlNjIiLCJpYXQiOjE3MTgwNDg1NDQsImV4cCI6NDg0MjI1MDk0NH0.xEF7kObwkztrMF7J83S-xvDarABmjXYkqLFINPWbx6g" -} -``` - -1. **Invalid Credentials Request:** - -Command: - -```sh -curl -X POST http://localhost:3000/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username": "testuser", "password": "wrongpassword"}' -``` - -Response (example): - -```json -{ - "statusCode": 401, - "message": "Unauthorized" -} -``` - -## How-To Guides - -### 1. Registering AuthLocalModule Synchronously - -```ts -// app.module.ts - -//... - AuthLocalModule.register({ - userModelService: new MyUserModelService(), // required - }), -//... -``` - -### 2. Registering AuthLocalModule Asynchronously - -```ts -// app.module.ts -import { MyUserModelService } from './services/my-user-model.service.ts'; - -//... -AuthLocalModule.registerAsync({ - useFactory: async (userModelService: MyUserModelService) => ({ - userModelService, // required - }), - inject: [MyUserModelService], -}), -//... -``` - -### 3. Global Registering AuthLocalModule Asynchronously - -```ts -// app.module.ts - -//... -AuthLocalModule.forRootAsync({ - useFactory: async (userModelService: MyUserModelService) => ({ - userModelService, - }), - inject: [MyUserModelService], -}), -//... -``` - -### 4. Implementing User Model Service - -```ts -// my-user-model.service.ts -import { Injectable } from '@nestjs/common'; -import { - AuthLocalUserModelServiceInterface, - AuthLocalCredentialsInterface -} from '@concepta/nestjs-auth-local'; - -@Injectable() -export class MyUserModelService - implements AuthLocalUserModelServiceInterface { - async byUsername(username: string): Promise { - // implement custom logic to return the user's credentials - return null; - } -} -``` - -### 5. Implementing custom token issuance service - -There are two ways to implementing the custom token issue service. You can -take advantage of the default service, as seen here: - -```ts -// my-jwt-issue.service.ts -import { Injectable } from '@nestjs/common'; -import { - JwtIssueService, - JwtIssueServiceInterface, - JwtSignService, -} from '@concepta/nestjs-jwt'; - -@Injectable() -export class MyJwtIssueService extends JwtIssueService { - constructor(protected readonly jwtSignService: JwtSignService) { - super(jwtSignService); - } - - async accessToken( - ...args: Parameters - ) { - // your custom code - return super.accessToken(...args); - } - - async refreshToken( - ...args: Parameters - ) { - // your custom code - return super.refreshToken(...args); - } -} -``` - -Or you can completely replace the default implementation: - -```ts -// my-jwt-issue.service.ts -import { Injectable } from '@nestjs/common'; -import { JwtIssueServiceInterface } from '@concepta/nestjs-jwt'; - -@Injectable() -export class MyJwtIssueService implements JwtIssueServiceInterface { - constructor() {} - - async accessToken( - ...args: Parameters - ) { - // your custom code - } - - async refreshToken( - ...args: Parameters - ) { - // your custom code - } -} -``` - -### 6. Implementing a custom user validation service - -The same approach can be done for `AuthLocalValidateUserService` you can -either completely override the default implementation or you can take -advantage of the default implementation. - -```ts -// my-auth-local-validate-user.service.ts -import { Injectable } from '@nestjs/common'; -import { ReferenceActiveInterface, ReferenceIdInterface } from '@concepta/nestjs-common'; -import { - AuthLocalValidateUserInterface, - AuthLocalValidateUserService -} from '@concepta/nestjs-auth-local'; - -@Injectable() -export class MyAuthLocalValidateUserService - extends AuthLocalValidateUserService -{ - - async validateUser( - dto: AuthLocalValidateUserInterface, - ): Promise { - // customize as needed - return super.validateUser(dto); - } - - async isActive( - user: ReferenceIdInterface & ReferenceActiveInterface, - ): Promise { - // customize as needed - return super.isActive(user); - } -} -``` - -```ts -// my-auth-local-validate-user.service.ts -import { Injectable } from '@nestjs/common'; -import { ReferenceActiveInterface, ReferenceIdInterface } from '@concepta/nestjs-common'; -import { - AuthLocalValidateUserInterface, - AuthLocalValidateUserServiceInterface -} from '@concepta/nestjs-auth-local'; - -@Injectable() -export class MyAuthLocalValidateUserService - implements AuthLocalValidateUserServiceInterface -{ - async validateUser( - dto: AuthLocalValidateUserInterface, - ): Promise { - // your custom code - return { - id: '[userId]', - //... - } - } - - async isActive( - user: ReferenceIdInterface & ReferenceActiveInterface, - ): Promise { - // customize as needed - return true; - } -} -``` - -### 7. Implementing a custom password validation service - -The `PasswordValidationService` in the `@concepta/nestjs-password` module -provides a default implementation using bcrypt for hashing and verifying passwords. -However, depending on your application's requirements, you might need to use a -different method for password hashing or add additional validation logic. - -You can either extend the existing `PasswordValidationService` to leverage its -built-in functionalities while adding your enhancements, or completely -override it with your custom implementation. - -**Overriding the Default Implementation:** - -If your application requires a different hashing algorithm , you can replace -the default implementation with one that suits your needs. - -```ts -// my-password-validation.service.ts -import { Injectable } from '@nestjs/common'; -import { - PasswordStorageInterface, - PasswordValidationServiceInterface, -} from '@concepta/nestjs-password'; - -@Injectable() -export class MyPasswordValidationService - implements PasswordValidationServiceInterface -{ - async validate(options: { - password: string; - passwordHash: string; - passwordSalt: string; - }): Promise { - // customize as needed - return true; - } - - async validateObject( - password: string, - object: T, - ): Promise { - // customize as needed - return true; - } -} -``` - -**Extending the Default Service:** - -If you want to add additional validation logic while keeping the current -hashing and validation, you can extend the default service: - -```ts -// my-password-validation.service.ts -import { - PasswordStorageInterface, - PasswordValidationService, -} from '@concepta/nestjs-password'; -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class MyPasswordValidationService extends PasswordValidationService { - async validate(options: { - password: string; - passwordHash: string; - passwordSalt: string; - }): Promise { - // customize as neeeded - return super.validate(options); - } - - async validateObject( - password: string, - object: T, - ): Promise { - // customize as neeeded - return super.validateObject(password, object); - } -} -``` - -### 8. Overriding the Settings - -```ts -import { Type } from '@nestjs/common'; - -export class CustomLoginDto { - email: string; - password: string; -} - -export const localSettings = { - loginDto: CustomLoginDto, - usernameField: 'email', - passwordField: 'password' -}; -``` - -```ts -AuthLocalModule.forRoot({ - userModelService: new UserModelService(), - issueTokenService: new MyIssueTokenService(), // <- optional - passwordValidationService: new PasswordValidationService(), // <- optional - settings: localSettings -}), -``` - -### 9. Integration with Other NestJS Modules - -Integrate `nestjs-auth-local` with other NestJS modules like, -`@concepta/nestjs-authentication`, `@concepta/nestjs-auth-jwt`, -`@concepta/nestjs-auth-refresh` for a comprehensive authentication system. - -## Reference - -For detailed information on the properties, methods, and classes used in -the `@concepta/nestjs-auth-local`, please refer to the API documentation -available at -[AuthLocalModule API Documentation](https://www.rockets.tools/reference/rockets/nestjs-auth-local/README). -This documentation provides comprehensive details on the interfaces and -services that you can utilize to customize and extend the authentication -functionality within your NestJS application. - -## Explanation - -### Conceptual Overview of Local Authentication - -#### What is Local Authentication? - -Local Authentication is a method of verifying user identity based on credentials -(username and password) stored locally within the application or in a connected -database. - -#### Benefits of Using Local Authentication - -- **Simplicity**: Easy to implement and manage. -- **Control**: Full control over user authentication and data. -- **Security**: When properly implemented, provides a secure way to authenticate - users. - -### Design Choices in AuthLocalModule - -#### Why Use Local Authentication? - -Local Authentication is ideal for applications that need to manage user -authentication directly within the application without relying on external -identity providers. - -#### Synchronous vs Asynchronous Registration - -- **Synchronous Registration**: Used when configuration options are static and -available at startup. - -- **Asynchronous Registration**: Used when configuration options need to be -retrieved from external sources at runtime. - -#### Global vs Feature-Specific Registration - -- **Global Registration**: Makes the module available throughout the entire -application. - -- **Feature-Specific Registration**: Allows the module to be registered only -for specific features or modules within the application. diff --git a/packages/nestjs-auth-local/package.json b/packages/nestjs-auth-local/package.json deleted file mode 100644 index 44ca0d0f0..000000000 --- a/packages/nestjs-auth-local/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "@concepta/nestjs-auth-local", - "version": "7.0.0-alpha.10", - "description": "Rockets NestJS Local Authentication", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "license": "BSD-3-Clause", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" - ], - "dependencies": { - "@concepta/nestjs-authentication": "^7.0.0-alpha.10", - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@concepta/nestjs-password": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", - "@nestjs/swagger": "^11.2.2", - "passport-local": "^1.0.0" - }, - "devDependencies": { - "@concepta/nestjs-auth-jwt": "^7.0.0-alpha.10", - "@concepta/nestjs-jwt": "^7.0.0-alpha.10", - "@nestjs/testing": "^11.1.9", - "@types/passport-local": "^1.0.38", - "@types/supertest": "^6.0.3", - "jest-mock-extended": "^4.0.0", - "supertest": "^6.3.4" - }, - "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", - "rxjs": "^7.1.0" - } -} diff --git a/packages/nestjs-auth-local/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-auth-local/src/__fixtures__/app.module.fixture.ts deleted file mode 100644 index f38d9e17d..000000000 --- a/packages/nestjs-auth-local/src/__fixtures__/app.module.fixture.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { AuthenticationModule } from '@concepta/nestjs-authentication'; -import { JwtModule } from '@concepta/nestjs-jwt'; - -// import { default as ormConfig } from './ormconfig.fixture'; -import { AuthLocalModule } from '../auth-local.module'; - -import { UserModelServiceFixture } from './user/user-model.service.fixture'; -import { UserModuleFixture } from './user/user.module.fixture'; - -@Module({ - imports: [ - JwtModule.forRoot({}), - AuthenticationModule.forRoot({}), - AuthJwtModule.forRootAsync({ - inject: [UserModelServiceFixture], - useFactory: (userModelService: UserModelServiceFixture) => ({ - userModelService, - }), - }), - AuthLocalModule.forRootAsync({ - inject: [UserModelServiceFixture], - useFactory: (userModelService) => ({ - userModelService, - }), - }), - UserModuleFixture, - ], -}) -export class AppModuleDbFixture {} diff --git a/packages/nestjs-auth-local/src/__fixtures__/auth-local.controller.fixture.ts b/packages/nestjs-auth-local/src/__fixtures__/auth-local.controller.fixture.ts deleted file mode 100644 index 6f38ab3b6..000000000 --- a/packages/nestjs-auth-local/src/__fixtures__/auth-local.controller.fixture.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; -import { - ApiBody, - ApiOkResponse, - ApiTags, - ApiUnauthorizedResponse, -} from '@nestjs/swagger'; - -import { - AuthUser, - IssueTokenServiceInterface, - AuthenticationJwtResponseDto, - AuthPublic, -} from '@concepta/nestjs-authentication'; -import { - AuthenticatedUserInterface, - AuthenticationResponseInterface, -} from '@concepta/nestjs-common'; - -import { AuthLocalIssueTokenService } from '../auth-local.constants'; -import { AuthLocalGuard } from '../auth-local.guard'; -import { AuthLocalLoginDto } from '../dto/auth-local-login.dto'; - -/** - * Auth Local controller - */ -@Controller('auth/login') -@UseGuards(AuthLocalGuard) -@AuthPublic() -@ApiTags('auth') -export class AuthLocalControllerFixture { - constructor( - @Inject(AuthLocalIssueTokenService) - private issueTokenService: IssueTokenServiceInterface, - ) {} - - /** - * Login - */ - @ApiBody({ - type: AuthLocalLoginDto, - description: 'DTO containing username and password.', - }) - @ApiOkResponse({ - type: AuthenticationJwtResponseDto, - description: 'DTO containing an access token and a refresh token.', - }) - @ApiUnauthorizedResponse() - @Post() - async login( - @AuthUser() user: AuthenticatedUserInterface, - ): Promise { - return this.issueTokenService.responsePayload(user.id); - } -} diff --git a/packages/nestjs-auth-local/src/__fixtures__/user/constants.ts b/packages/nestjs-auth-local/src/__fixtures__/user/constants.ts deleted file mode 100644 index 3f98678bb..000000000 --- a/packages/nestjs-auth-local/src/__fixtures__/user/constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { AuthLocalCredentialsInterface } from '../../interfaces/auth-local-credentials.interface'; - -export const LOGIN_SUCCESS = { - username: 'random_username', - password: 'random_password', -}; - -export const USER_SUCCESS: AuthLocalCredentialsInterface = { - id: randomUUID(), - active: true, - passwordHash: LOGIN_SUCCESS.password, - passwordSalt: LOGIN_SUCCESS.password, - username: LOGIN_SUCCESS.username, -}; diff --git a/packages/nestjs-auth-local/src/__fixtures__/user/user-model.service.fixture.ts b/packages/nestjs-auth-local/src/__fixtures__/user/user-model.service.fixture.ts deleted file mode 100644 index a88c19caf..000000000 --- a/packages/nestjs-auth-local/src/__fixtures__/user/user-model.service.fixture.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { ReferenceSubject, ReferenceUsername } from '@concepta/nestjs-common'; - -import { AuthLocalCredentialsInterface } from '../../interfaces/auth-local-credentials.interface'; -import { AuthLocalUserModelServiceInterface } from '../../interfaces/auth-local-user-model-service.interface'; - -import { LOGIN_SUCCESS, USER_SUCCESS } from './constants'; - -@Injectable() -export class UserModelServiceFixture - implements AuthLocalUserModelServiceInterface -{ - async byUsername( - username: ReferenceUsername, - ): Promise { - if (LOGIN_SUCCESS.username === username) return USER_SUCCESS; - else return null; - } - - async bySubject( - subject: ReferenceSubject, - ): Promise { - throw new Error(`Method not implemented, can't get ${subject}.`); - } -} diff --git a/packages/nestjs-auth-local/src/__fixtures__/user/user.entity.fixture.ts b/packages/nestjs-auth-local/src/__fixtures__/user/user.entity.fixture.ts deleted file mode 100644 index 0084bb596..000000000 --- a/packages/nestjs-auth-local/src/__fixtures__/user/user.entity.fixture.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { IsString } from 'class-validator'; - -import { ReferenceIdInterface } from '@concepta/nestjs-common'; - -import { AuthLocalCredentialsInterface } from '../../interfaces/auth-local-credentials.interface'; - -export class UserFixture - implements ReferenceIdInterface, AuthLocalCredentialsInterface -{ - id!: string; - - @IsString() - username!: string; - - active!: boolean; - - @IsString() - password!: string; - - passwordHash!: string; - - passwordSalt!: string; -} diff --git a/packages/nestjs-auth-local/src/__fixtures__/user/user.module.fixture.ts b/packages/nestjs-auth-local/src/__fixtures__/user/user.module.fixture.ts deleted file mode 100644 index 794790675..000000000 --- a/packages/nestjs-auth-local/src/__fixtures__/user/user.module.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { UserModelServiceFixture } from './user-model.service.fixture'; - -@Global() -@Module({ - providers: [UserModelServiceFixture], - exports: [UserModelServiceFixture], -}) -export class UserModuleFixture {} diff --git a/packages/nestjs-auth-local/src/auth-local.constants.ts b/packages/nestjs-auth-local/src/auth-local.constants.ts deleted file mode 100644 index 1c6d62db1..000000000 --- a/packages/nestjs-auth-local/src/auth-local.constants.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const AUTH_LOCAL_MODULE_SETTINGS_TOKEN = - 'AUTH_LOCAL_MODULE_SETTINGS_TOKEN'; - -export const AUTH_LOCAL_MODULE_DEFAULT_SETTINGS_TOKEN = - 'AUTH_LOCAL_MODULE_DEFAULT_SETTINGS_TOKEN'; - -export const AUTH_LOCAL_STRATEGY_NAME = 'local'; - -export const AuthLocalIssueTokenService = Symbol( - '__AUTH_LOCAL_MODULE_ISSUE_TOKEN_SERVICE_TOKEN__', -); - -export const AuthLocalUserModelService = Symbol( - '__AUTH_LOCAL_MODULE_USER_MODEL_SERVICE_TOKEN__', -); - -export const AuthLocalPasswordValidationService = Symbol( - '__AUTH_LOCAL_MODULE_PASSWORD_VALIDATION_SERVICE_TOKEN__', -); diff --git a/packages/nestjs-auth-local/src/auth-local.guard.ts b/packages/nestjs-auth-local/src/auth-local.guard.ts deleted file mode 100644 index 55a23a3fa..000000000 --- a/packages/nestjs-auth-local/src/auth-local.guard.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { AuthGuard } from '@concepta/nestjs-authentication'; - -import { AUTH_LOCAL_STRATEGY_NAME } from './auth-local.constants'; - -@Injectable() -export class AuthLocalGuard extends AuthGuard(AUTH_LOCAL_STRATEGY_NAME, { - canDisable: false, -}) {} diff --git a/packages/nestjs-auth-local/src/auth-local.module-definition.spec.ts b/packages/nestjs-auth-local/src/auth-local.module-definition.spec.ts deleted file mode 100644 index 87e6828f5..000000000 --- a/packages/nestjs-auth-local/src/auth-local.module-definition.spec.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { FactoryProvider } from '@nestjs/common'; - -import { IssueTokenService } from '@concepta/nestjs-authentication'; -import { JwtIssueTokenService } from '@concepta/nestjs-jwt'; -import { PasswordValidationService } from '@concepta/nestjs-password'; - -import { - AUTH_LOCAL_MODULE_SETTINGS_TOKEN, - AuthLocalIssueTokenService, - AuthLocalPasswordValidationService, - AuthLocalUserModelService, -} from './auth-local.constants'; -import { - createAuthLocalExports, - createAuthLocalIssueTokenServiceProvider, - createAuthLocalPasswordValidationServiceProvider, - createAuthLocalUserModelServiceProvider, - createAuthLocalValidateUserServiceProvider, -} from './auth-local.module-definition'; -import { AuthLocalValidateUserService } from './services/auth-local-validate-user.service'; - -import { UserModelServiceFixture } from './__fixtures__/user/user-model.service.fixture'; - -describe('Auth-local.module-definition', () => { - describe(createAuthLocalExports.name, () => { - it('should return an array with the expected tokens', () => { - const result = createAuthLocalExports(); - expect(result).toEqual([ - AUTH_LOCAL_MODULE_SETTINGS_TOKEN, - AuthLocalUserModelService, - AuthLocalIssueTokenService, - AuthLocalPasswordValidationService, - AuthLocalValidateUserService, - ]); - }); - }); - - describe(createAuthLocalValidateUserServiceProvider.name, () => { - class TestUserModelService extends UserModelServiceFixture {} - class TestPasswordValidationService extends PasswordValidationService {} - class TestAuthLocalValidateUserService extends AuthLocalValidateUserService {} - - const testUserModelService = mock(); - const testPasswordValidationService = mock(); - const testAuthLocalValidateUserService = - new TestAuthLocalValidateUserService( - testUserModelService, - testPasswordValidationService, - ); - - it('should return a default validateUserService', async () => { - const provider = - createAuthLocalValidateUserServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBeInstanceOf(AuthLocalValidateUserService); - }); - - it('should return a validateUserService from initialization', async () => { - const provider = - createAuthLocalValidateUserServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({ - validateUserService: testAuthLocalValidateUserService, - }); - - expect(useFactoryResult).toBeInstanceOf(TestAuthLocalValidateUserService); - }); - - it('should return a override validateUserService', async () => { - const provider = createAuthLocalValidateUserServiceProvider({ - validateUserService: testAuthLocalValidateUserService, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBeInstanceOf(TestAuthLocalValidateUserService); - }); - }); - - describe(createAuthLocalIssueTokenServiceProvider.name, () => { - class TestIssueTokenService extends IssueTokenService {} - - const jwtIssueTokenService = mock(); - const testIssueTokenService = new TestIssueTokenService( - jwtIssueTokenService, - ); - - it('should return an issueTokenService', async () => { - const provider = - createAuthLocalIssueTokenServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory( - {}, - testIssueTokenService, - ); - - expect(useFactoryResult).toBeInstanceOf(TestIssueTokenService); - }); - - it('should return an issueTokenService from initialization', async () => { - const provider = - createAuthLocalIssueTokenServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({ - issueTokenService: testIssueTokenService, - }); - - expect(useFactoryResult).toBeInstanceOf(TestIssueTokenService); - }); - - it('should return an overridden issueTokenService', async () => { - const provider = createAuthLocalIssueTokenServiceProvider({ - issueTokenService: testIssueTokenService, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBeInstanceOf(TestIssueTokenService); - }); - }); - - describe(createAuthLocalUserModelServiceProvider.name, () => { - class TestModelService extends UserModelServiceFixture {} - class OverrideModelService extends UserModelServiceFixture {} - - it('should return a default userModelService', async () => { - const provider: FactoryProvider = createAuthLocalUserModelServiceProvider( - { - userModelService: new OverrideModelService(), - }, - ) as FactoryProvider; - - // useFactory is for when class was initially defined - const useFactoryResult = await provider.useFactory({ - userModelService: new TestModelService(), - }); - expect(useFactoryResult).toBeInstanceOf(OverrideModelService); - }); - - it('should return a userModelService from initialization', async () => { - const provider: FactoryProvider = - createAuthLocalUserModelServiceProvider() as FactoryProvider; - - // useFactory is for when class was initially defined - const useFactoryResult = await provider.useFactory({ - userModelService: new TestModelService(), - }); - expect(useFactoryResult).toBeInstanceOf(TestModelService); - }); - }); - - describe(createAuthLocalPasswordValidationServiceProvider.name, () => { - class TestPasswordValidationService extends PasswordValidationService {} - - const testPasswordValidationService = new TestPasswordValidationService(); - - it('should return an issueTokenService', async () => { - const provider = - createAuthLocalPasswordValidationServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory( - {}, - testPasswordValidationService, - ); - - expect(useFactoryResult).toBeInstanceOf(TestPasswordValidationService); - }); - - it('should return an issueTokenService from initialization', async () => { - const provider = - createAuthLocalPasswordValidationServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({ - passwordValidationService: testPasswordValidationService, - }); - - expect(useFactoryResult).toBeInstanceOf(TestPasswordValidationService); - }); - - it('should return an overridden issueTokenService', async () => { - const provider = createAuthLocalPasswordValidationServiceProvider({ - passwordValidationService: testPasswordValidationService, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBeInstanceOf(TestPasswordValidationService); - }); - }); -}); diff --git a/packages/nestjs-auth-local/src/auth-local.module-definition.ts b/packages/nestjs-auth-local/src/auth-local.module-definition.ts deleted file mode 100644 index 444aface4..000000000 --- a/packages/nestjs-auth-local/src/auth-local.module-definition.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { - ConfigurableModuleBuilder, - DynamicModule, - Provider, -} from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; - -import { - IssueTokenService, - IssueTokenServiceInterface, -} from '@concepta/nestjs-authentication'; -import { createSettingsProvider } from '@concepta/nestjs-common'; -import { - PasswordValidationService, - PasswordValidationServiceInterface, -} from '@concepta/nestjs-password'; - -import { - AUTH_LOCAL_MODULE_SETTINGS_TOKEN, - AuthLocalIssueTokenService, - AuthLocalUserModelService, - AuthLocalPasswordValidationService, -} from './auth-local.constants'; -import { AuthLocalStrategy } from './auth-local.strategy'; -import { authLocalDefaultConfig } from './config/auth-local-default.config'; -import { AuthLocalOptionsExtrasInterface } from './interfaces/auth-local-options-extras.interface'; -import { AuthLocalOptionsInterface } from './interfaces/auth-local-options.interface'; -import { AuthLocalSettingsInterface } from './interfaces/auth-local-settings.interface'; -import { AuthLocalUserModelServiceInterface } from './interfaces/auth-local-user-model-service.interface'; -import { AuthLocalValidateUserService } from './services/auth-local-validate-user.service'; - -const RAW_OPTIONS_TOKEN = Symbol('__AUTH_LOCAL_MODULE_RAW_OPTIONS_TOKEN__'); - -export const { - ConfigurableModuleClass: AuthLocalModuleClass, - OPTIONS_TYPE: AUTH_LOCAL_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: AUTH_LOCAL_ASYNC_OPTIONS_TYPE, -} = new ConfigurableModuleBuilder({ - moduleName: 'AuthLocal', - optionsInjectionToken: RAW_OPTIONS_TOKEN, -}) - .setExtras( - { global: false }, - definitionTransform, - ) - .build(); - -export type AuthLocalOptions = Omit; -export type AuthLocalAsyncOptions = Omit< - typeof AUTH_LOCAL_ASYNC_OPTIONS_TYPE, - 'global' ->; - -function definitionTransform( - definition: DynamicModule, - extras: AuthLocalOptionsExtrasInterface, -): DynamicModule { - const { providers } = definition; - const { global } = extras; - - return { - ...definition, - global, - imports: createAuthLocalImports(), - providers: createAuthLocalProviders({ providers }), - exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createAuthLocalExports()], - }; -} - -export function createAuthLocalImports(): DynamicModule['imports'] { - return [ConfigModule.forFeature(authLocalDefaultConfig)]; -} - -export function createAuthLocalExports() { - return [ - AUTH_LOCAL_MODULE_SETTINGS_TOKEN, - AuthLocalUserModelService, - AuthLocalIssueTokenService, - AuthLocalPasswordValidationService, - AuthLocalValidateUserService, - ]; -} - -export function createAuthLocalProviders(options: { - overrides?: AuthLocalOptions; - providers?: Provider[]; -}): Provider[] { - return [ - ...(options.providers ?? []), - IssueTokenService, - PasswordValidationService, - AuthLocalStrategy, - AuthLocalValidateUserService, - createAuthLocalOptionsProvider(options.overrides), - createAuthLocalValidateUserServiceProvider(options.overrides), - createAuthLocalIssueTokenServiceProvider(options.overrides), - createAuthLocalUserModelServiceProvider(options.overrides), - createAuthLocalPasswordValidationServiceProvider(options.overrides), - ]; -} - -export function createAuthLocalOptionsProvider( - optionsOverrides?: AuthLocalOptions, -): Provider { - return createSettingsProvider< - AuthLocalSettingsInterface, - AuthLocalOptionsInterface - >({ - settingsToken: AUTH_LOCAL_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: authLocalDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createAuthLocalValidateUserServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthLocalValidateUserService, - inject: [ - RAW_OPTIONS_TOKEN, - AuthLocalUserModelService, - AuthLocalPasswordValidationService, - ], - useFactory: async ( - options: Pick, - userModelService: AuthLocalUserModelServiceInterface, - passwordValidationService: PasswordValidationServiceInterface, - ) => - optionsOverrides?.validateUserService ?? - options.validateUserService ?? - new AuthLocalValidateUserService( - userModelService, - passwordValidationService, - ), - }; -} - -export function createAuthLocalIssueTokenServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthLocalIssueTokenService, - inject: [RAW_OPTIONS_TOKEN, IssueTokenService], - useFactory: async ( - options: Pick, - defaultService: IssueTokenServiceInterface, - ) => - optionsOverrides?.issueTokenService ?? - options.issueTokenService ?? - defaultService, - }; -} - -export function createAuthLocalPasswordValidationServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthLocalPasswordValidationService, - inject: [RAW_OPTIONS_TOKEN, PasswordValidationService], - useFactory: async ( - options: Pick, - defaultService: PasswordValidationServiceInterface, - ) => - optionsOverrides?.passwordValidationService ?? - options.passwordValidationService ?? - defaultService, - }; -} - -export function createAuthLocalUserModelServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthLocalUserModelService, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: AuthLocalOptionsInterface) => - optionsOverrides?.userModelService ?? options.userModelService, - }; -} diff --git a/packages/nestjs-auth-local/src/auth-local.module.spec.ts b/packages/nestjs-auth-local/src/auth-local.module.spec.ts deleted file mode 100644 index 45952bc86..000000000 --- a/packages/nestjs-auth-local/src/auth-local.module.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { - AuthenticationModule, - IssueTokenService, - IssueTokenServiceInterface, -} from '@concepta/nestjs-authentication'; -import { - JwtIssueTokenService, - JwtModule, - JwtService, -} from '@concepta/nestjs-jwt'; -import { - PasswordValidationService, - PasswordValidationServiceInterface, -} from '@concepta/nestjs-password'; - -import { AuthLocalModule } from './auth-local.module'; -import { AuthLocalUserModelServiceInterface } from './interfaces/auth-local-user-model-service.interface'; -import { AuthLocalValidateUserServiceInterface } from './interfaces/auth-local-validate-user-service.interface'; -import { AuthLocalValidateUserService } from './services/auth-local-validate-user.service'; - -import { UserModelServiceFixture } from './__fixtures__/user/user-model.service.fixture'; -import { UserModuleFixture } from './__fixtures__/user/user.module.fixture'; - -describe(AuthLocalModule, () => { - const jwtService = new JwtService(); - const jwtIssueTokenService = new JwtIssueTokenService(jwtService, jwtService); - - let testModule: TestingModule; - let authLocalModule: AuthLocalModule; - let userModelService: AuthLocalUserModelServiceInterface; - let validateUserService: AuthLocalValidateUserServiceInterface; - let issueTokenService: IssueTokenServiceInterface; - let passwordValidationService: PasswordValidationServiceInterface; - - describe(AuthLocalModule.forRoot, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthLocalModule.forRoot({ - issueTokenService: new IssueTokenService(jwtIssueTokenService), - userModelService: new UserModelServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - describe(AuthLocalModule.register, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthLocalModule.register({ - issueTokenService: new IssueTokenService(jwtIssueTokenService), - userModelService: new UserModelServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - describe(AuthLocalModule.forRootAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthLocalModule.forRootAsync({ - inject: [IssueTokenService, UserModelServiceFixture], - useFactory: ( - issueTokenService: IssueTokenServiceInterface, - userModelService: AuthLocalUserModelServiceInterface, - ) => ({ issueTokenService, userModelService }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - describe(AuthLocalModule.registerAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthLocalModule.registerAsync({ - inject: [IssueTokenService, UserModelServiceFixture], - useFactory: ( - issueTokenService: IssueTokenService, - userModelService: AuthLocalUserModelServiceInterface, - ) => ({ issueTokenService, userModelService }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - function commonVars(module: TestingModule) { - authLocalModule = module.get(AuthLocalModule); - userModelService = module.get(UserModelServiceFixture); - validateUserService = module.get(AuthLocalValidateUserService); - issueTokenService = module.get(IssueTokenService); - passwordValidationService = module.get(PasswordValidationService); - } - - function commonTests() { - expect(authLocalModule).toBeInstanceOf(AuthLocalModule); - expect(userModelService).toBeInstanceOf(UserModelServiceFixture); - expect(issueTokenService).toBeInstanceOf(IssueTokenService); - expect(passwordValidationService).toBeInstanceOf(PasswordValidationService); - expect(validateUserService).toBeInstanceOf(AuthLocalValidateUserService); - } -}); - -function testModuleFactory( - extraImports: DynamicModule['imports'] = [], -): ModuleMetadata { - return { - imports: [ - UserModuleFixture, - AuthenticationModule.forRoot({}), - JwtModule.forRoot({}), - ...extraImports, - ], - }; -} diff --git a/packages/nestjs-auth-local/src/auth-local.module.ts b/packages/nestjs-auth-local/src/auth-local.module.ts deleted file mode 100644 index 9c60f2267..000000000 --- a/packages/nestjs-auth-local/src/auth-local.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { DynamicModule, Module } from '@nestjs/common'; - -import { - AuthLocalAsyncOptions, - AuthLocalModuleClass, - AuthLocalOptions, -} from './auth-local.module-definition'; - -/** - * Auth local module - */ -@Module({}) -export class AuthLocalModule extends AuthLocalModuleClass { - static register(options: AuthLocalOptions): DynamicModule { - return super.register(options); - } - - static registerAsync(options: AuthLocalAsyncOptions): DynamicModule { - return super.registerAsync(options); - } - - static forRoot(options: AuthLocalOptions): DynamicModule { - return super.register({ ...options, global: true }); - } - - static forRootAsync(options: AuthLocalAsyncOptions): DynamicModule { - return super.registerAsync({ ...options, global: true }); - } -} diff --git a/packages/nestjs-auth-local/src/auth-local.strategy.spec.ts b/packages/nestjs-auth-local/src/auth-local.strategy.spec.ts deleted file mode 100644 index 23f3c64cd..000000000 --- a/packages/nestjs-auth-local/src/auth-local.strategy.spec.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { randomUUID } from 'crypto'; - -import * as classValidator from 'class-validator'; -import { mock } from 'jest-mock-extended'; - -import { BadRequestException, HttpStatus } from '@nestjs/common'; - -import { ReferenceIdInterface } from '@concepta/nestjs-common'; -import { PasswordValidationService } from '@concepta/nestjs-password'; - -import { AuthLocalStrategy } from './auth-local.strategy'; -import { AuthLocalInvalidCredentialsException } from './exceptions/auth-local-invalid-credentials.exception'; -import { AuthLocalInvalidLoginDataException } from './exceptions/auth-local-invalid-login-data.exception'; -import { AuthLocalException } from './exceptions/auth-local.exception'; -import { AuthLocalSettingsInterface } from './interfaces/auth-local-settings.interface'; -import { AuthLocalUserModelServiceInterface } from './interfaces/auth-local-user-model-service.interface'; -import { AuthLocalValidateUserServiceInterface } from './interfaces/auth-local-validate-user-service.interface'; -import { AuthLocalValidateUserInterface } from './interfaces/auth-local-validate-user.interface'; -import { AuthLocalValidateUserService } from './services/auth-local-validate-user.service'; - -import { UserFixture } from './__fixtures__/user/user.entity.fixture'; - -describe(AuthLocalStrategy.name, () => { - const USERNAME = 'username'; - const PASSWORD = 'password'; - - let user: UserFixture; - let settings: AuthLocalSettingsInterface; - let userModelService: AuthLocalUserModelServiceInterface; - let validateUserService: AuthLocalValidateUserServiceInterface; - let passwordValidationService: PasswordValidationService; - let authLocalStrategy: AuthLocalStrategy; - - beforeEach(async () => { - settings = mock>({ - loginDto: UserFixture, - usernameField: USERNAME, - passwordField: PASSWORD, - }); - - userModelService = mock(); - passwordValidationService = mock(); - validateUserService = new AuthLocalValidateUserService( - userModelService, - passwordValidationService, - ); - authLocalStrategy = new AuthLocalStrategy(settings, validateUserService); - - user = new UserFixture(); - user.id = randomUUID(); - user.active = true; - jest.resetAllMocks(); - jest.spyOn(userModelService, 'byUsername').mockResolvedValue(user); - }); - - it('constructor', async () => { - settings = mock>({ - loginDto: undefined, - }); - authLocalStrategy = new AuthLocalStrategy(settings, validateUserService); - expect(true).toBeTruthy(); - }); - - describe(AuthLocalStrategy.prototype.validate, () => { - it('should return user', async () => { - jest.spyOn(passwordValidationService, 'validate').mockResolvedValue(true); - - const result = await authLocalStrategy.validate(USERNAME, PASSWORD); - expect(result.id).toBe(user.id); - }); - - it('should fail to validate user', async () => { - jest - .spyOn(validateUserService, 'validateUser') - .mockImplementationOnce((_dto: AuthLocalValidateUserInterface) => { - return null as unknown as Promise>; - }); - - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); - await expect(t).rejects.toThrow(AuthLocalInvalidCredentialsException); - }); - - it('should fail to validate user with custom message', async () => { - jest - .spyOn(validateUserService, 'validateUser') - .mockImplementation((_dto: AuthLocalValidateUserInterface) => { - throw new AuthLocalInvalidCredentialsException({ - message: 'Custom message', - safeMessage: 'Custom safe message', - }); - }); - - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); - - try { - await t(); - } catch (error: unknown) { - if (error instanceof AuthLocalInvalidCredentialsException) { - expect(error.httpStatus).toBe(HttpStatus.UNAUTHORIZED); - expect(error.message).toBe('Custom message'); - expect(error.safeMessage).toBe('Custom safe message'); - } else { - throw new Error('Wrong error type'); - } - } - }); - - it('should fail with internal server error', async () => { - jest - .spyOn(validateUserService, 'validateUser') - .mockImplementation((_dto: AuthLocalValidateUserInterface) => { - throw new Error('This is really bad'); - }); - - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); - - try { - await t(); - } catch (error: unknown) { - if (error instanceof AuthLocalException) { - expect(error?.httpStatus).toBe(HttpStatus.INTERNAL_SERVER_ERROR); - expect(error?.context?.originalError?.message).toBe( - 'This is really bad', - ); - } else { - throw new Error('Wrong error type'); - } - } - }); - - it('should throw error on validateOrReject', async () => { - const t = () => authLocalStrategy.validate(USERNAME, ''); - await expect(t).rejects.toThrow(); - }); - - it('should throw BadRequest on validateOrReject', async () => { - jest - .spyOn(classValidator, 'validateOrReject') - .mockRejectedValueOnce(BadRequestException); - - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); - await expect(t).rejects.toThrow(AuthLocalInvalidLoginDataException); - }); - - it('should return no user on userModelService.byUsername', async () => { - jest.spyOn(userModelService, 'byUsername').mockResolvedValue(null); - - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); - await expect(t).rejects.toThrow(AuthLocalInvalidCredentialsException); - }); - - it('should be invalid on passwordService.validate', async () => { - jest - .spyOn(passwordValidationService, 'validate') - .mockResolvedValue(false); - - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); - await expect(t).rejects.toThrow(AuthLocalInvalidCredentialsException); - }); - }); - - describe(AuthLocalStrategy.prototype['assertSettings'], () => { - it('should return with success', async () => { - const result = authLocalStrategy['assertSettings'](); - expect(result.loginDto).toBe(settings.loginDto); - expect(result.usernameField).toBe(settings.usernameField); - expect(result.passwordField).toBe(settings.passwordField); - }); - - it('should throw error for no loginDto', async () => { - settings = mock>({ - loginDto: undefined, - usernameField: USERNAME, - passwordField: PASSWORD, - }); - authLocalStrategy = new AuthLocalStrategy(settings, validateUserService); - const t = () => authLocalStrategy['assertSettings'](); - expect(t).toThrow(); - }); - - it('should throw error for no usernameField', async () => { - settings = mock>({ - loginDto: UserFixture, - usernameField: undefined, - passwordField: PASSWORD, - }); - authLocalStrategy = new AuthLocalStrategy(settings, validateUserService); - const t = () => authLocalStrategy['assertSettings'](); - expect(t).toThrow(); - }); - - it('should throw error for no passwordField', async () => { - settings = mock>({ - loginDto: UserFixture, - usernameField: USERNAME, - passwordField: undefined, - }); - authLocalStrategy = new AuthLocalStrategy(settings, validateUserService); - const t = () => authLocalStrategy['assertSettings'](); - expect(t).toThrow(); - }); - }); -}); diff --git a/packages/nestjs-auth-local/src/auth-local.strategy.ts b/packages/nestjs-auth-local/src/auth-local.strategy.ts deleted file mode 100644 index 198d7e602..000000000 --- a/packages/nestjs-auth-local/src/auth-local.strategy.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { validateOrReject } from 'class-validator'; -import { Strategy } from 'passport-local'; - -import { Inject, Injectable } from '@nestjs/common'; - -import { PassportStrategyFactory } from '@concepta/nestjs-authentication'; -import { - ReferenceIdInterface, - ReferenceUsername, -} from '@concepta/nestjs-common'; - -import { - AUTH_LOCAL_MODULE_SETTINGS_TOKEN, - AUTH_LOCAL_STRATEGY_NAME, -} from './auth-local.constants'; -import { AuthLocalInvalidCredentialsException } from './exceptions/auth-local-invalid-credentials.exception'; -import { AuthLocalInvalidLoginDataException } from './exceptions/auth-local-invalid-login-data.exception'; -import { AuthLocalMissingLoginDtoException } from './exceptions/auth-local-missing-login-dto.exception'; -import { AuthLocalMissingPasswordFieldException } from './exceptions/auth-local-missing-password-field.exception'; -import { AuthLocalMissingUsernameFieldException } from './exceptions/auth-local-missing-username-field.exception'; -import { AuthLocalException } from './exceptions/auth-local.exception'; -import { AuthLocalSettingsInterface } from './interfaces/auth-local-settings.interface'; -import { AuthLocalValidateUserServiceInterface } from './interfaces/auth-local-validate-user-service.interface'; -import { AuthLocalValidateUserService } from './services/auth-local-validate-user.service'; - -/** - * Define the Local strategy using passport. - * - * Local strategy is used to authenticate a user using a username and password. - * The field username and password can be configured using the `usernameField` and `passwordField` properties. - */ -@Injectable() -export class AuthLocalStrategy extends PassportStrategyFactory( - Strategy, - AUTH_LOCAL_STRATEGY_NAME, -) { - /** - * @param settings - The settings for the local strategy - * @param validateUserService - The service used validate passwords - */ - constructor( - @Inject(AUTH_LOCAL_MODULE_SETTINGS_TOKEN) - private settings: AuthLocalSettingsInterface, - @Inject(AuthLocalValidateUserService) - private validateUserService: AuthLocalValidateUserServiceInterface, - ) { - super({ - usernameField: settings?.usernameField, - passwordField: settings?.passwordField, - }); - } - - /** - * Validate the user based on the username and password - * from the request body - * - * @param username - The username to authenticate - * @param password - The plain text password - */ - async validate(username: ReferenceUsername, password: string) { - // break out the settings - const { loginDto, usernameField, passwordField } = this.assertSettings(); - - // validate the dto - const dto = new loginDto(); - dto[usernameField] = username; - dto[passwordField] = password; - - try { - await validateOrReject(dto); - } catch (e) { - throw new AuthLocalInvalidLoginDataException({ - originalError: e, - }); - } - - let validatedUser: ReferenceIdInterface; - - try { - // try to get fully validated user - validatedUser = await this.validateUserService.validateUser({ - username, - password, - }); - } catch (e) { - // did they throw an invalid credentials exception? - if (e instanceof AuthLocalInvalidCredentialsException) { - // yes, use theirs - throw e; - } else { - // something else went wrong - throw new AuthLocalException({ originalError: e }); - } - } - - // did we get a valid user? - if (!validatedUser) { - throw new AuthLocalInvalidCredentialsException({ - message: `Unable to validate user with username: %s`, - messageParams: [username], - }); - } - - return validatedUser; - } - - /** - * Return settings asserted as definitely defined. - */ - protected assertSettings(): Required { - const { loginDto, usernameField, passwordField } = this.settings; - - // is the login dto missing? - if (!loginDto) { - throw new AuthLocalMissingLoginDtoException(); - } - - // is the username field missing? - if (!usernameField) { - throw new AuthLocalMissingUsernameFieldException(); - } - - // is the password field missing? - if (!passwordField) { - throw new AuthLocalMissingPasswordFieldException(); - } - - return { loginDto, usernameField, passwordField }; - } -} diff --git a/packages/nestjs-auth-local/src/config/auth-local-default.config.ts b/packages/nestjs-auth-local/src/config/auth-local-default.config.ts deleted file mode 100644 index 813c44c01..000000000 --- a/packages/nestjs-auth-local/src/config/auth-local-default.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { AUTH_LOCAL_MODULE_DEFAULT_SETTINGS_TOKEN } from '../auth-local.constants'; -import { AuthLocalLoginDto } from '../dto/auth-local-login.dto'; -import { AuthLocalSettingsInterface } from '../interfaces/auth-local-settings.interface'; - -/** - * Default configuration for auth local. - */ -export const authLocalDefaultConfig = registerAs( - AUTH_LOCAL_MODULE_DEFAULT_SETTINGS_TOKEN, - (): AuthLocalSettingsInterface => ({ - /** - * The login dto - */ - loginDto: AuthLocalLoginDto, - /** - * The field name to use for the username. - */ - usernameField: process.env.AUTH_LOCAL_USERNAME_FIELD ?? 'username', - /** - * The field name to use for the password. - */ - passwordField: process.env.AUTH_LOCAL_PASSWORD_FIELD ?? 'password', - }), -); diff --git a/packages/nestjs-auth-local/src/controllers/auth-local.controller.e2e-spec.ts b/packages/nestjs-auth-local/src/controllers/auth-local.controller.e2e-spec.ts deleted file mode 100644 index c54757bc1..000000000 --- a/packages/nestjs-auth-local/src/controllers/auth-local.controller.e2e-spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -import supertest from 'supertest'; - -import { INestApplication } from '@nestjs/common'; -import { HttpAdapterHost } from '@nestjs/core'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { ExceptionsFilter } from '@concepta/nestjs-common'; -import { PasswordValidationService } from '@concepta/nestjs-password'; - -import { AuthLocalInvalidCredentialsException } from '../exceptions/auth-local-invalid-credentials.exception'; -import { AuthLocalValidateUserService } from '../services/auth-local-validate-user.service'; - -import { AppModuleDbFixture } from '../__fixtures__/app.module.fixture'; -import { AuthLocalControllerFixture } from '../__fixtures__/auth-local.controller.fixture'; -import { LOGIN_SUCCESS } from '../__fixtures__/user/constants'; - -describe('AuthLocalController (e2e)', () => { - let app: INestApplication; - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleDbFixture], - controllers: [AuthLocalControllerFixture], - }) - .overrideProvider(PasswordValidationService) - .useValue({ - validate: () => { - return true; - }, - }) - .compile(); - app = moduleFixture.createNestApplication(); - - const exceptionsFilter = app.get(HttpAdapterHost); - app.useGlobalFilters(new ExceptionsFilter(exceptionsFilter)); - - await app.init(); - }); - - it('POST auth/login success', async () => { - await supertest(app.getHttpServer()) - .post('/auth/login') - .send(LOGIN_SUCCESS) - .then((response) => { - expect(response.body.accessToken).toBeDefined(); - expect(response.body.refreshToken).toBeDefined(); - expect(response.status).toBe(201); - }); - }); - - it('POST auth/login username not found ', async () => { - await supertest(app.getHttpServer()) - .post('/auth/login') - .send({ - ...LOGIN_SUCCESS, - username: 'no_user', - }) - .then((response) => { - expect(response.body.message).toBe( - 'The provided username or password is incorrect. Please try again.', - ); - expect(response.status).toBe(401); - }); - }); - - it('POST auth/login username not found with custom message', async () => { - const validateUserService = app.get(AuthLocalValidateUserService); - - jest - .spyOn(validateUserService, 'validateUser') - .mockImplementationOnce(() => { - throw new AuthLocalInvalidCredentialsException({ - safeMessage: 'Custom invalid credentials message', - }); - }); - - await supertest(app.getHttpServer()) - .post('/auth/login') - .send({ - ...LOGIN_SUCCESS, - username: 'no_user', - }) - .then((response) => { - expect(response.body.message).toBe( - 'Custom invalid credentials message', - ); - expect(response.status).toBe(401); - }); - }); - - it('POST auth/login password fail ', async () => { - await supertest(app.getHttpServer()) - .post('/auth/login') - .send({ - ...LOGIN_SUCCESS, - password: '', - }) - .then((response) => { - expect(response.body.message).toBe('Unauthorized'); - expect(response.status).toBe(401); - }); - }); - - it('POST auth/login username fail ', async () => { - await supertest(app.getHttpServer()) - .post('/auth/login') - .send({ - ...LOGIN_SUCCESS, - username: '', - }) - .then((response) => { - expect(response.body.message).toBe('Unauthorized'); - expect(response.status).toBe(401); - }); - }); - - it('POST auth/login username fail ', async () => { - await supertest(app.getHttpServer()) - .post('/auth/login') - .send({ - ...LOGIN_SUCCESS, - username: 999, - }) - .then((response) => { - expect(response.body.message).toBe( - 'The provided username or password is incorrect. Please try again.', - ); - expect(response.status).toBe(400); - }); - }); - - it('POST auth/login password fail ', async () => { - await supertest(app.getHttpServer()) - .post('/auth/login') - .send({ - ...LOGIN_SUCCESS, - password: 999, - }) - .then((response) => { - expect(response.body.message).toBe( - 'The provided username or password is incorrect. Please try again.', - ); - expect(response.status).toBe(400); - }); - }); -}); diff --git a/packages/nestjs-auth-local/src/controllers/auth-local.controller.spec.ts b/packages/nestjs-auth-local/src/controllers/auth-local.controller.spec.ts deleted file mode 100644 index 07a9aaf06..000000000 --- a/packages/nestjs-auth-local/src/controllers/auth-local.controller.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { mock } from 'jest-mock-extended'; - -import { IssueTokenServiceInterface } from '@concepta/nestjs-authentication'; -import { - AuthenticatedUserInterface, - AuthenticationResponseInterface, -} from '@concepta/nestjs-common'; - -import { AuthLocalControllerFixture } from '../__fixtures__/auth-local.controller.fixture'; - -describe(AuthLocalControllerFixture, () => { - const accessToken = 'accessToken'; - const refreshToken = 'refreshToken'; - let controller: AuthLocalControllerFixture; - const response: AuthenticationResponseInterface = { - accessToken, - refreshToken, - }; - - beforeEach(async () => { - const issueTokenService = mock({ - responsePayload: () => { - return new Promise((resolve) => { - resolve(response); - }); - }, - }); - controller = new AuthLocalControllerFixture(issueTokenService); - }); - - describe(AuthLocalControllerFixture.prototype.login, () => { - it('should return user', async () => { - const user: AuthenticatedUserInterface = { - id: randomUUID(), - }; - const result = await controller.login(user); - expect(result.accessToken).toBe(response.accessToken); - }); - }); -}); diff --git a/packages/nestjs-auth-local/src/dto/auth-local-login.dto.ts b/packages/nestjs-auth-local/src/dto/auth-local-login.dto.ts deleted file mode 100644 index 9dabe5f10..000000000 --- a/packages/nestjs-auth-local/src/dto/auth-local-login.dto.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { AuthenticationLoginInterface } from '@concepta/nestjs-common'; - -@Exclude() -export class AuthLocalLoginDto implements AuthenticationLoginInterface { - @Expose() - @ApiProperty({ - type: 'string', - description: 'Username', - }) - @IsString() - username = ''; - - @Expose() - @ApiProperty({ - type: 'string', - description: 'Password', - }) - @IsString() - password = ''; -} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local-invalid-credentials.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local-invalid-credentials.exception.ts deleted file mode 100644 index fcf90c9c2..000000000 --- a/packages/nestjs-auth-local/src/exceptions/auth-local-invalid-credentials.exception.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthLocalUnauthorizedException } from './auth-local-unauthorized.exception'; - -export class AuthLocalInvalidCredentialsException extends AuthLocalUnauthorizedException { - constructor(options?: Omit) { - super({ - safeMessage: - 'The provided username or password is incorrect. Please try again.', - ...options, - }); - - this.errorCode = 'AUTH_LOCAL_INVALID_CREDENTIALS_ERROR'; - } -} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local-invalid-login-data.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local-invalid-login-data.exception.ts deleted file mode 100644 index 54c722d89..000000000 --- a/packages/nestjs-auth-local/src/exceptions/auth-local-invalid-login-data.exception.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthLocalException } from './auth-local.exception'; - -export class AuthLocalInvalidLoginDataException extends AuthLocalException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: 'Data validation error occurred before user validation.', - safeMessage: - 'The provided username or password is incorrect. Please try again.', - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'AUTH_LOCAL_INVALID_LOGIN_DATA_ERROR'; - } -} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local-invalid-password.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local-invalid-password.exception.ts deleted file mode 100644 index 2f06d1a71..000000000 --- a/packages/nestjs-auth-local/src/exceptions/auth-local-invalid-password.exception.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthLocalInvalidCredentialsException } from './auth-local-invalid-credentials.exception'; - -export class AuthLocalInvalidPasswordException extends AuthLocalInvalidCredentialsException { - constructor(userName: string, options?: RuntimeExceptionOptions) { - super({ - message: `Invalid password for username: %s`, - messageParams: [userName], - ...options, - }); - - this.errorCode = 'AUTH_LOCAL_INVALID_PASSWORD_ERROR'; - } -} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local-missing-login-dto.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local-missing-login-dto.exception.ts deleted file mode 100644 index 37bfc9b37..000000000 --- a/packages/nestjs-auth-local/src/exceptions/auth-local-missing-login-dto.exception.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthLocalException } from './auth-local.exception'; - -export class AuthLocalMissingLoginDtoException extends AuthLocalException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: 'Login DTO is required, did someone remove the default?', - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'AUTH_LOCAL_MISSING_LOGIN_DTO_ERROR'; - } -} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local-missing-password-field.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local-missing-password-field.exception.ts deleted file mode 100644 index bfc86a723..000000000 --- a/packages/nestjs-auth-local/src/exceptions/auth-local-missing-password-field.exception.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthLocalException } from './auth-local.exception'; - -export class AuthLocalMissingPasswordFieldException extends AuthLocalException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: - 'Login password field is required, did someone remove the default?', - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'AUTH_LOCAL_MISSING_PASSWORD_FIELD_ERROR'; - } -} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local-missing-username-field.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local-missing-username-field.exception.ts deleted file mode 100644 index fa0bfa44a..000000000 --- a/packages/nestjs-auth-local/src/exceptions/auth-local-missing-username-field.exception.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthLocalException } from './auth-local.exception'; - -export class AuthLocalMissingUsernameFieldException extends AuthLocalException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: - 'Login username field is required, did someone remove the default?', - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'AUTH_LOCAL_MISSING_USERNAME_FIELD_ERROR'; - } -} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local-unauthorized.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local-unauthorized.exception.ts deleted file mode 100644 index 942134212..000000000 --- a/packages/nestjs-auth-local/src/exceptions/auth-local-unauthorized.exception.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthLocalException } from './auth-local.exception'; - -export class AuthLocalUnauthorizedException extends AuthLocalException { - constructor(options?: Omit) { - super({ - message: 'Unauthorized', - safeMessage: 'Unauthorized', - ...options, - httpStatus: HttpStatus.UNAUTHORIZED, - }); - - this.errorCode = 'AUTH_LOCAL_UNAUTHORIZED_ERROR'; - } -} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local-user-inactive.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local-user-inactive.exception.ts deleted file mode 100644 index 2a59884f9..000000000 --- a/packages/nestjs-auth-local/src/exceptions/auth-local-user-inactive.exception.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthLocalInvalidCredentialsException } from './auth-local-invalid-credentials.exception'; - -export class AuthLocalUserInactiveException extends AuthLocalInvalidCredentialsException { - constructor(userName: string, options?: RuntimeExceptionOptions) { - super({ - message: `User with username '%s' is inactive`, - messageParams: [userName], - ...options, - }); - - this.errorCode = 'AUTH_LOCAL_USER_INACTIVE_ERROR'; - } -} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local-username-not-found.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local-username-not-found.exception.ts deleted file mode 100644 index 362c8d217..000000000 --- a/packages/nestjs-auth-local/src/exceptions/auth-local-username-not-found.exception.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthLocalInvalidCredentialsException } from './auth-local-invalid-credentials.exception'; - -export class AuthLocalUsernameNotFoundException extends AuthLocalInvalidCredentialsException { - constructor(userName: string, options?: RuntimeExceptionOptions) { - super({ - message: `No user found for username: %s`, - messageParams: [userName], - ...options, - }); - - this.errorCode = 'AUTH_LOCAL_USERNAME_NOT_FOUND_ERROR'; - } -} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local.exception.ts deleted file mode 100644 index d63f34abd..000000000 --- a/packages/nestjs-auth-local/src/exceptions/auth-local.exception.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -/** - * Generic auth local exception. - */ -export class AuthLocalException extends RuntimeException { - constructor(options?: RuntimeExceptionOptions) { - super(options); - this.errorCode = 'AUTH_LOCAL_ERROR'; - } -} diff --git a/packages/nestjs-auth-local/src/index.spec.ts b/packages/nestjs-auth-local/src/index.spec.ts deleted file mode 100644 index 63a4527da..000000000 --- a/packages/nestjs-auth-local/src/index.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - AuthLocalModule, - AuthLocalLoginDto, - AuthLocalGuard, - LocalAuthGuard, -} from './index'; - -describe('Index', () => { - it('AuthLocalModule should be imported', () => { - expect(AuthLocalModule).toBeInstanceOf(Function); - }); - - it('AuthLocalLoginDto should be imported', () => { - expect(AuthLocalLoginDto).toBeInstanceOf(Function); - }); - - it('AuthLocalGuard should be imported', () => { - expect(AuthLocalGuard).toBeInstanceOf(Function); - }); - - it('LocalAuthGuard should be imported', () => { - expect(LocalAuthGuard).toBeInstanceOf(Function); - }); -}); diff --git a/packages/nestjs-auth-local/src/index.ts b/packages/nestjs-auth-local/src/index.ts deleted file mode 100644 index 81504fe01..000000000 --- a/packages/nestjs-auth-local/src/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -// interfaces -export { AuthLocalOptionsInterface } from './interfaces/auth-local-options.interface'; -export { AuthLocalOptionsExtrasInterface } from './interfaces/auth-local-options-extras.interface'; -export { AuthLocalSettingsInterface } from './interfaces/auth-local-settings.interface'; -export { AuthLocalValidateUserInterface } from './interfaces/auth-local-validate-user.interface'; -export { AuthLocalUserModelServiceInterface } from './interfaces/auth-local-user-model-service.interface'; -export { AuthLocalValidateUserServiceInterface } from './interfaces/auth-local-validate-user-service.interface'; -export { AuthLocalCredentialsInterface } from './interfaces/auth-local-credentials.interface'; - -// DTOs -export { AuthLocalLoginDto } from './dto/auth-local-login.dto'; - -// module -export { AuthLocalModule } from './auth-local.module'; -export { AuthLocalValidateUserService } from './services/auth-local-validate-user.service'; - -export { - AuthLocalGuard, - AuthLocalGuard as LocalAuthGuard, -} from './auth-local.guard'; - -export { - AuthLocalIssueTokenService, - AuthLocalUserModelService, - AuthLocalPasswordValidationService, -} from './auth-local.constants'; - -// exceptions -export { AuthLocalException } from './exceptions/auth-local.exception'; -export { AuthLocalUsernameNotFoundException } from './exceptions/auth-local-username-not-found.exception'; -export { AuthLocalUserInactiveException } from './exceptions/auth-local-user-inactive.exception'; -export { AuthLocalUnauthorizedException } from './exceptions/auth-local-unauthorized.exception'; -export { AuthLocalMissingUsernameFieldException } from './exceptions/auth-local-missing-username-field.exception'; -export { AuthLocalMissingPasswordFieldException } from './exceptions/auth-local-missing-password-field.exception'; -export { AuthLocalMissingLoginDtoException } from './exceptions/auth-local-missing-login-dto.exception'; -export { AuthLocalInvalidPasswordException } from './exceptions/auth-local-invalid-password.exception'; -export { AuthLocalInvalidLoginDataException } from './exceptions/auth-local-invalid-login-data.exception'; -export { AuthLocalInvalidCredentialsException } from './exceptions/auth-local-invalid-credentials.exception'; diff --git a/packages/nestjs-auth-local/src/interfaces/auth-local-credentials.interface.ts b/packages/nestjs-auth-local/src/interfaces/auth-local-credentials.interface.ts deleted file mode 100644 index 5bdcf787e..000000000 --- a/packages/nestjs-auth-local/src/interfaces/auth-local-credentials.interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { - ReferenceActiveInterface, - ReferenceIdInterface, - ReferenceUsernameInterface, - PasswordStorageInterface, -} from '@concepta/nestjs-common'; - -/** - * Credentials Interface - */ -export interface AuthLocalCredentialsInterface - extends ReferenceIdInterface, - ReferenceUsernameInterface, - ReferenceActiveInterface, - PasswordStorageInterface {} diff --git a/packages/nestjs-auth-local/src/interfaces/auth-local-options-extras.interface.ts b/packages/nestjs-auth-local/src/interfaces/auth-local-options-extras.interface.ts deleted file mode 100644 index 241e6d455..000000000 --- a/packages/nestjs-auth-local/src/interfaces/auth-local-options-extras.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface AuthLocalOptionsExtrasInterface - extends Pick {} diff --git a/packages/nestjs-auth-local/src/interfaces/auth-local-options.interface.ts b/packages/nestjs-auth-local/src/interfaces/auth-local-options.interface.ts deleted file mode 100644 index 0d67436a5..000000000 --- a/packages/nestjs-auth-local/src/interfaces/auth-local-options.interface.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { IssueTokenServiceInterface } from '@concepta/nestjs-authentication'; -import { PasswordValidationServiceInterface } from '@concepta/nestjs-password'; - -import { AuthLocalSettingsInterface } from './auth-local-settings.interface'; -import { AuthLocalUserModelServiceInterface } from './auth-local-user-model-service.interface'; -import { AuthLocalValidateUserServiceInterface } from './auth-local-validate-user-service.interface'; - -export interface AuthLocalOptionsInterface { - /** - * Implementation of user model service class - */ - userModelService: AuthLocalUserModelServiceInterface; - - /** - * Implementation of a class to issue tokens - */ - issueTokenService?: IssueTokenServiceInterface; - - /** - * Implementation of a class to validate user - */ - validateUserService?: AuthLocalValidateUserServiceInterface; - - /** - * Implementation of a class to handle password validation - */ - passwordValidationService?: PasswordValidationServiceInterface; - - /** - * Settings - */ - settings?: AuthLocalSettingsInterface; -} diff --git a/packages/nestjs-auth-local/src/interfaces/auth-local-settings.interface.ts b/packages/nestjs-auth-local/src/interfaces/auth-local-settings.interface.ts deleted file mode 100644 index 334fe76ba..000000000 --- a/packages/nestjs-auth-local/src/interfaces/auth-local-settings.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Type } from '@nestjs/common'; - -export interface AuthLocalSettingsInterface { - loginDto?: Type; - usernameField?: string; - passwordField?: string; -} diff --git a/packages/nestjs-auth-local/src/interfaces/auth-local-user-model-service.interface.ts b/packages/nestjs-auth-local/src/interfaces/auth-local-user-model-service.interface.ts deleted file mode 100644 index 51ee2a224..000000000 --- a/packages/nestjs-auth-local/src/interfaces/auth-local-user-model-service.interface.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { - ReferenceUsername, - ByUsernameInterface, -} from '@concepta/nestjs-common'; - -import { AuthLocalCredentialsInterface } from './auth-local-credentials.interface'; - -export interface AuthLocalUserModelServiceInterface - extends ByUsernameInterface< - ReferenceUsername, - AuthLocalCredentialsInterface - > {} diff --git a/packages/nestjs-auth-local/src/interfaces/auth-local-validate-user-service.interface.ts b/packages/nestjs-auth-local/src/interfaces/auth-local-validate-user-service.interface.ts deleted file mode 100644 index c0d0ddad9..000000000 --- a/packages/nestjs-auth-local/src/interfaces/auth-local-validate-user-service.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ValidateUserServiceInterface } from '@concepta/nestjs-authentication'; -import { ReferenceIdInterface } from '@concepta/nestjs-common'; - -import { AuthLocalValidateUserInterface } from './auth-local-validate-user.interface'; - -export interface AuthLocalValidateUserServiceInterface - extends ValidateUserServiceInterface<[AuthLocalValidateUserInterface]> { - validateUser: ( - dto: AuthLocalValidateUserInterface, - ) => Promise; -} diff --git a/packages/nestjs-auth-local/src/interfaces/auth-local-validate-user.interface.ts b/packages/nestjs-auth-local/src/interfaces/auth-local-validate-user.interface.ts deleted file mode 100644 index 12490a2fe..000000000 --- a/packages/nestjs-auth-local/src/interfaces/auth-local-validate-user.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface AuthLocalValidateUserInterface { - username: string; - password: string; -} diff --git a/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.spec.ts b/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.spec.ts deleted file mode 100644 index e60397381..000000000 --- a/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { PasswordValidationServiceInterface } from '@concepta/nestjs-password'; - -import { AuthLocalUserModelServiceInterface } from '../interfaces/auth-local-user-model-service.interface'; -import { AuthLocalValidateUserInterface } from '../interfaces/auth-local-validate-user.interface'; - -import { AuthLocalValidateUserService } from './auth-local-validate-user.service'; - -describe(AuthLocalValidateUserService.name, () => { - const USERNAME = 'test'; - const PASSWORD = 'test'; - - let service: AuthLocalValidateUserService; - let userModelService: AuthLocalUserModelServiceInterface; - let passwordValidationService: PasswordValidationServiceInterface; - - beforeEach(() => { - userModelService = { - byUsername: jest.fn(), - } as unknown as AuthLocalUserModelServiceInterface; - - passwordValidationService = { - validate: jest.fn(), - } as unknown as PasswordValidationServiceInterface; - - service = new AuthLocalValidateUserService( - userModelService, - passwordValidationService, - ); - }); - - describe('validateUser', () => { - const USER = { - active: false, - id: 'uuid', - username: 'username', - password: 'password', - passwordHash: 'hash', - passwordSalt: 'salt', - }; - it('should throw an error if no user is found for the given username', async () => { - jest.spyOn(userModelService, 'byUsername').mockResolvedValue(null); - - const t = () => - service.validateUser({ - username: USERNAME, - password: PASSWORD, - } as AuthLocalValidateUserInterface); - await expect(t).rejects.toThrow( - `No user found for username: ${USERNAME}`, - ); - }); - - it('should throw an error if the user is inactive', async () => { - jest.spyOn(userModelService, 'byUsername').mockResolvedValue(USER); - jest.spyOn(service, 'isActive').mockResolvedValue(false); - - const t = () => - service.validateUser({ - username: USERNAME, - password: PASSWORD, - } as AuthLocalValidateUserInterface); - await expect(t).rejects.toThrow( - `User with username '${USERNAME}' is inactive`, - ); - }); - - it('should throw an error if the password is invalid', async () => { - jest.spyOn(userModelService, 'byUsername').mockResolvedValue(USER); - jest.spyOn(service, 'isActive').mockResolvedValue(true); - jest - .spyOn(passwordValidationService, 'validate') - .mockResolvedValue(false); - - const t = () => - service.validateUser({ - username: USER.username, - password: USER.password, - } as AuthLocalValidateUserInterface); - await expect(t).rejects.toThrow( - `Invalid password for username: ${USER.username}`, - ); - }); - - it('should return the user if the user is found, active, and the password is valid', async () => { - jest.spyOn(userModelService, 'byUsername').mockResolvedValue(USER); - jest.spyOn(service, 'isActive').mockResolvedValue(true); - jest.spyOn(passwordValidationService, 'validate').mockResolvedValue(true); - - const result = await service.validateUser({ - username: USER.username, - password: USER.password, - } as AuthLocalValidateUserInterface); - - expect(result).toEqual(USER); - }); - }); -}); diff --git a/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.ts b/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.ts deleted file mode 100644 index b8ffebe95..000000000 --- a/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { ValidateUserService } from '@concepta/nestjs-authentication'; -import { ReferenceIdInterface } from '@concepta/nestjs-common'; -import { PasswordValidationServiceInterface } from '@concepta/nestjs-password'; - -import { - AuthLocalPasswordValidationService, - AuthLocalUserModelService, -} from '../auth-local.constants'; -import { AuthLocalInvalidPasswordException } from '../exceptions/auth-local-invalid-password.exception'; -import { AuthLocalUserInactiveException } from '../exceptions/auth-local-user-inactive.exception'; -import { AuthLocalUsernameNotFoundException } from '../exceptions/auth-local-username-not-found.exception'; -import { AuthLocalUserModelServiceInterface } from '../interfaces/auth-local-user-model-service.interface'; -import { AuthLocalValidateUserServiceInterface } from '../interfaces/auth-local-validate-user-service.interface'; -import { AuthLocalValidateUserInterface } from '../interfaces/auth-local-validate-user.interface'; - -@Injectable() -export class AuthLocalValidateUserService - extends ValidateUserService<[AuthLocalValidateUserInterface]> - implements AuthLocalValidateUserServiceInterface -{ - constructor( - @Inject(AuthLocalUserModelService) - protected readonly userModelService: AuthLocalUserModelServiceInterface, - @Inject(AuthLocalPasswordValidationService) - protected readonly passwordValidationService: PasswordValidationServiceInterface, - ) { - super(); - } - - /** - * Returns true if user is considered valid for authentication purposes. - */ - async validateUser( - dto: AuthLocalValidateUserInterface, - ): Promise { - // try to get the user by username - const user = await this.userModelService.byUsername(dto.username); - - // did we get a user? - if (!user) { - throw new AuthLocalUsernameNotFoundException(dto.username); - } - - const isUserActive = await this.isActive(user); - - // is the user active? - if (!isUserActive) { - throw new AuthLocalUserInactiveException(dto.username); - } - - // validate password - const isValid = await this.passwordValidationService.validate({ - ...user, - password: dto.password, - }); - - // password is valid? - if (!isValid) { - throw new AuthLocalInvalidPasswordException(user.username); - } - - // return the user - return user; - } -} diff --git a/packages/nestjs-auth-local/tsconfig.json b/packages/nestjs-auth-local/tsconfig.json deleted file mode 100644 index 1556fedf0..000000000 --- a/packages/nestjs-auth-local/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": [ - "./node_modules/@types", - "../../node_modules/@types" - ] - }, - "include": [ - "src/**/*.ts" - ], - "references": [ - { - "path": "../typeorm-common" - }, - { - "path": "../nestjs-auth-jwt" - } - ] -} diff --git a/packages/nestjs-auth-local/typedoc.json b/packages/nestjs-auth-local/typedoc.json deleted file mode 100644 index 944fda5ad..000000000 --- a/packages/nestjs-auth-local/typedoc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "entryPoints": ["src/index.ts"] -} \ No newline at end of file diff --git a/packages/nestjs-auth-recovery/README.md b/packages/nestjs-auth-recovery/README.md deleted file mode 100644 index 482965250..000000000 --- a/packages/nestjs-auth-recovery/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Rockets NestJS Auth recovery Authentication - -Recover user password using email - -## Project - -[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-auth-recovery)](https://www.npmjs.com/package/@concepta/nestjs-auth-recovery) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-auth-recovery)](https://www.npmjs.com/package/@concepta/nestjs-auth-recovery) -[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) -[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) - -## Installation - -`yarn add @concepta/nestjs-auth-recovery` diff --git a/packages/nestjs-auth-recovery/package.json b/packages/nestjs-auth-recovery/package.json deleted file mode 100644 index 054033826..000000000 --- a/packages/nestjs-auth-recovery/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@concepta/nestjs-auth-recovery", - "version": "7.0.0-alpha.10", - "description": "Rockets NestJS Auth Recovery", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "license": "BSD-3-Clause", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" - ], - "dependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", - "@nestjs/swagger": "^11.2.2" - }, - "devDependencies": { - "@concepta/nestjs-auth-jwt": "^7.0.0-alpha.10", - "@concepta/nestjs-authentication": "^7.0.0-alpha.10", - "@concepta/nestjs-crud": "^7.0.0-alpha.10", - "@concepta/nestjs-email": "^7.0.0-alpha.10", - "@concepta/nestjs-jwt": "^7.0.0-alpha.10", - "@concepta/nestjs-otp": "^7.0.0-alpha.10", - "@concepta/nestjs-password": "^7.0.0-alpha.10", - "@concepta/nestjs-typeorm-ext": "^7.0.0-alpha.10", - "@concepta/nestjs-user": "^7.0.0-alpha.10", - "@concepta/typeorm-seeding": "^4.0.0", - "@nestjs/testing": "^11.1.9", - "@nestjs/typeorm": "^11.0.0", - "jest-mock-extended": "^4.0.0", - "supertest": "^6.3.4" - }, - "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", - "rxjs": "^7.1.0", - "typeorm": "^0.3.0" - } -} diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/app.module.db.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/app.module.db.fixture.ts deleted file mode 100644 index 4d58c0de1..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/app.module.db.fixture.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { AuthenticationModule } from '@concepta/nestjs-authentication'; -import { CrudModule } from '@concepta/nestjs-crud'; -import { EmailModule, EmailService } from '@concepta/nestjs-email'; -import { JwtModule } from '@concepta/nestjs-jwt'; -import { OtpModule, OtpService } from '@concepta/nestjs-otp'; -import { PasswordModule } from '@concepta/nestjs-password'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { - UserModelService, - UserModelServiceInterface, - UserModule, - UserPasswordService, -} from '@concepta/nestjs-user'; - -import { AuthRecoveryModule } from '../auth-recovery.module'; - -import { AuthRecoveryController } from './auth-recovery.controller.fixture'; -import { MailerServiceFixture } from './email/mailer.service.fixture'; -import { default as ormConfig } from './ormconfig.fixture'; -import { UserEntityFixture } from './user/entities/user-entity.fixture'; -import { UserOtpEntityFixture } from './user/entities/user-otp-entity.fixture'; - -@Module({ - imports: [ - TypeOrmExtModule.forRoot(ormConfig), - CrudModule.forRoot({}), - JwtModule.forRoot({}), - AuthenticationModule.forRoot({ - settings: { - disableGuard: (context, guard) => - guard.constructor.name === 'AuthJwtGuard' && - context.getClass().name === 'UserController', - }, - }), - AuthJwtModule.forRootAsync({ - inject: [UserModelService], - useFactory: (userModelService: UserModelServiceInterface) => ({ - userModelService, - }), - }), - AuthRecoveryModule.forRootAsync({ - inject: [UserModelService, UserPasswordService, OtpService, EmailService], - useFactory: ( - userModelService, - userPasswordService, - otpService, - emailService, - ) => ({ - userModelService, - userPasswordService, - otpService, - emailService, - }), - }), - OtpModule.forRootAsync({ - useFactory: () => ({}), - entities: ['userOtp'], - imports: [ - TypeOrmExtModule.forFeature({ - userOtp: { - entity: UserOtpEntityFixture, - }, - }), - ], - }), - PasswordModule.forRoot({}), - UserModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - user: { - entity: UserEntityFixture, - }, - }), - ], - useFactory: () => ({}), - }), - EmailModule.forRoot({ - mailerService: new MailerServiceFixture(), - }), - ], - controllers: [AuthRecoveryController], -}) -export class AppModuleDbFixture {} diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/app.module.fixture.ts deleted file mode 100644 index 91e070ba6..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/app.module.fixture.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { AuthenticationModule } from '@concepta/nestjs-authentication'; -import { EmailModule, EmailService } from '@concepta/nestjs-email'; -import { JwtModule } from '@concepta/nestjs-jwt'; - -import { AuthRecoveryModule } from '../auth-recovery.module'; - -import { MailerServiceFixture } from './email/mailer.service.fixture'; -import { OtpModuleFixture } from './otp/otp.module.fixture'; -import { OtpServiceFixture } from './otp/otp.service.fixture'; -import { UserModelServiceFixture } from './user/services/user-model.service.fixture'; -import { UserPasswordServiceFixture } from './user/services/user-password.service.fixture'; -import { UserModuleFixture } from './user/user.module.fixture'; - -@Module({ - imports: [ - JwtModule.forRoot({}), - AuthenticationModule.forRoot({}), - AuthJwtModule.forRootAsync({ - inject: [UserModelServiceFixture], - useFactory: (userModelService: UserModelServiceFixture) => ({ - userModelService, - }), - }), - AuthRecoveryModule.forRootAsync({ - inject: [ - EmailService, - OtpServiceFixture, - UserModelServiceFixture, - UserPasswordServiceFixture, - ], - useFactory: ( - emailService, - otpService, - userModelService, - userPasswordService, - ) => ({ - emailService, - otpService, - userModelService, - userPasswordService, - }), - }), - EmailModule.forRoot({ mailerService: new MailerServiceFixture() }), - OtpModuleFixture, - UserModuleFixture, - ], -}) -export class AppModuleFixture {} diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/auth-recovery.controller.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/auth-recovery.controller.fixture.ts deleted file mode 100644 index c37d694ea..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/auth-recovery.controller.fixture.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - Body, - Controller, - Get, - Inject, - Param, - Patch, - Post, -} from '@nestjs/common'; -import { - ApiBadRequestResponse, - ApiBody, - ApiNotFoundResponse, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; - -import { AuthPublic } from '@concepta/nestjs-authentication'; - -import { AuthRecoveryRecoverLoginDto } from '../dto/auth-recovery-recover-login.dto'; -import { AuthRecoveryRecoverPasswordDto } from '../dto/auth-recovery-recover-password.dto'; -import { AuthRecoveryUpdatePasswordDto } from '../dto/auth-recovery-update-password.dto'; -import { AuthRecoveryOtpInvalidException } from '../exceptions/auth-recovery-otp-invalid.exception'; -import { AuthRecoveryServiceInterface } from '../interfaces/auth-recovery.service.interface'; -import { AuthRecoveryService } from '../services/auth-recovery.service'; - -@Controller('auth/recovery') -@AuthPublic() -@ApiTags('auth') -export class AuthRecoveryController { - constructor( - @Inject(AuthRecoveryService) - private readonly authRecoveryService: AuthRecoveryServiceInterface, - ) {} - - @ApiOperation({ - summary: - 'Recover account username password by providing an email that will receive an username.', - }) - @ApiBody({ - type: AuthRecoveryRecoverLoginDto, - description: 'DTO of login recover.', - }) - @ApiOkResponse() - @Post('/login') - async recoverLogin( - @Body() recoverLoginDto: AuthRecoveryRecoverLoginDto, - ): Promise { - await this.authRecoveryService.recoverLogin(recoverLoginDto.email); - } - - @ApiOperation({ - summary: - 'Recover account email password by providing an email that will receive a password reset link.', - }) - @ApiBody({ - type: AuthRecoveryRecoverPasswordDto, - description: 'DTO of email recover.', - }) - @ApiOkResponse() - @Post('/password') - async recoverPassword( - @Body() recoverPasswordDto: AuthRecoveryRecoverPasswordDto, - ): Promise { - await this.authRecoveryService.recoverPassword(recoverPasswordDto.email); - } - - @ApiOperation({ - summary: 'Check if passcode is valid.', - }) - @ApiOkResponse() - @ApiNotFoundResponse() - @Get('/passcode/:passcode') - async validatePasscode(@Param('passcode') passcode: string): Promise { - const otp = await this.authRecoveryService.validatePasscode(passcode); - - if (!otp) { - throw new AuthRecoveryOtpInvalidException(); - } - } - - @ApiOperation({ - summary: 'Update lost password by providing passcode and new password.', - }) - @ApiBody({ - type: AuthRecoveryUpdatePasswordDto, - description: 'DTO of update password.', - }) - @ApiOkResponse() - @ApiBadRequestResponse() - @Patch('/password') - async updatePassword( - @Body() updatePasswordDto: AuthRecoveryUpdatePasswordDto, - ): Promise { - const { passcode, newPassword } = updatePasswordDto; - - const user = await this.authRecoveryService.updatePassword( - passcode, - newPassword, - ); - - if (!user) { - // the client should have checked using validate passcode first - throw new AuthRecoveryOtpInvalidException(); - } - } -} diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/email/mailer.service.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/email/mailer.service.fixture.ts deleted file mode 100644 index fd842639e..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/email/mailer.service.fixture.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - EmailSendInterface, - EmailSendOptionsInterface, -} from '@concepta/nestjs-common'; - -@Injectable() -export class MailerServiceFixture implements EmailSendInterface { - sendMail(_sendMailOptions: EmailSendOptionsInterface): Promise { - throw new Error('Method not implemented.'); - } -} diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/ormconfig.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/ormconfig.fixture.ts deleted file mode 100644 index 19352ec33..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/ormconfig.fixture.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { DataSourceOptions } from 'typeorm'; - -import { UserEntityFixture } from './user/entities/user-entity.fixture'; -import { UserOtpEntityFixture } from './user/entities/user-otp-entity.fixture'; - -const config: DataSourceOptions = { - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [UserEntityFixture, UserOtpEntityFixture], -}; - -export default config; diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/otp/otp.module.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/otp/otp.module.fixture.ts deleted file mode 100644 index db0b5e982..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/otp/otp.module.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { OtpServiceFixture } from './otp.service.fixture'; - -@Global() -@Module({ - providers: [OtpServiceFixture], - exports: [OtpServiceFixture], -}) -export class OtpModuleFixture {} diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/otp/otp.service.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/otp/otp.service.fixture.ts deleted file mode 100644 index f4efe1035..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/otp/otp.service.fixture.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { Injectable } from '@nestjs/common'; - -import { - AssigneeRelationInterface, - OtpCreateParamsInterface, - OtpInterface, -} from '@concepta/nestjs-common'; - -import { AuthRecoveryOtpServiceInterface } from '../../interfaces/auth-recovery-otp.service.interface'; -import { UserFixture } from '../user/user.fixture'; - -@Injectable() -export class OtpServiceFixture implements AuthRecoveryOtpServiceInterface { - async create({ otp }: OtpCreateParamsInterface): Promise { - const { assigneeId, category, type } = otp; - return { - id: randomUUID(), - category, - type, - assigneeId, - active: true, - passcode: 'GOOD_PASSCODE', - expirationDate: new Date(), - dateCreated: new Date(), - dateUpdated: new Date(), - dateDeleted: null, - version: 1, - }; - } - - async validate( - _assignment: string, - otp: Pick, - _deleteIfValid: boolean, - ): Promise { - return otp.passcode === 'GOOD_PASSCODE' - ? { assigneeId: UserFixture.id } - : null; - } - - async clear( - _assignment: string, - _otp: Pick, - ): Promise { - return; - } -} diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/user/entities/user-entity.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/user/entities/user-entity.fixture.ts deleted file mode 100644 index 6e42bcbf1..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/user/entities/user-entity.fixture.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Entity } from 'typeorm'; - -import { UserSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * User Entity Fixture - */ -@Entity() -export class UserEntityFixture extends UserSqliteEntity {} diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/user/entities/user-otp-entity.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/user/entities/user-otp-entity.fixture.ts deleted file mode 100644 index 180101235..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/user/entities/user-otp-entity.fixture.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Column, Entity } from 'typeorm'; - -import { ReferenceId, OtpInterface } from '@concepta/nestjs-common'; -import { CommonSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * Otp Entity Fixture - */ -@Entity() -export class UserOtpEntityFixture - extends CommonSqliteEntity - implements OtpInterface -{ - @Column() - category!: string; - - @Column({ nullable: true }) - type!: string; - - @Column() - passcode!: string; - - @Column({ default: true }) - active!: boolean; - - @Column({ type: 'datetime' }) - expirationDate!: Date; - - @Column() - assigneeId!: ReferenceId; -} diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/user/services/user-model.service.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/user/services/user-model.service.fixture.ts deleted file mode 100644 index 7d96015f4..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/user/services/user-model.service.fixture.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ReferenceEmail, - ReferenceIdInterface, - ReferenceSubject, -} from '@concepta/nestjs-common'; - -import { AuthRecoveryUserModelServiceInterface } from '../../../interfaces/auth-recovery-user-model.service.interface'; -import { UserFixture } from '../user.fixture'; - -@Injectable() -export class UserModelServiceFixture - implements AuthRecoveryUserModelServiceInterface -{ - async byId( - id: string, - ): ReturnType { - if (id === UserFixture.id) { - return UserFixture; - } else { - throw new Error(); - } - } - - async byEmail( - email: ReferenceEmail, - ): ReturnType { - return email === UserFixture.email ? UserFixture : null; - } - - async bySubject(subject: ReferenceSubject): Promise { - throw new Error(`Method not implemented, can't get ${subject}.`); - } -} diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/user/services/user-password.service.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/user/services/user-password.service.fixture.ts deleted file mode 100644 index 59442a985..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/user/services/user-password.service.fixture.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - AuthenticatedUserInterface, - PasswordPlainCurrentInterface, - PasswordStorageInterface, - ReferenceIdInterface, - PasswordPlainInterface, -} from '@concepta/nestjs-common'; -import { UserPasswordServiceInterface } from '@concepta/nestjs-user'; - -@Injectable() -export class UserPasswordServiceFixture - implements UserPasswordServiceInterface -{ - getPasswordStore( - _userId: string, - ): Promise & PasswordStorageInterface> { - throw new Error('Method not implemented.'); - } - setPassword( - _passwordDto: PasswordPlainInterface & - Partial, - _userToUpdateId?: string, - _authorizedUser?: AuthenticatedUserInterface, - ): Promise { - return Promise.resolve(); - } -} diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/user/user.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/user/user.fixture.ts deleted file mode 100644 index 9eae02f84..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/user/user.fixture.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const UserFixture = { - id: 'abc', - email: 'me@dispostable.com', - username: 'me@dispostable.com', -}; diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/user/user.module.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/user/user.module.fixture.ts deleted file mode 100644 index 27867928d..000000000 --- a/packages/nestjs-auth-recovery/src/__fixtures__/user/user.module.fixture.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { UserModelServiceFixture } from './services/user-model.service.fixture'; -import { UserPasswordServiceFixture } from './services/user-password.service.fixture'; - -@Global() -@Module({ - providers: [UserModelServiceFixture, UserPasswordServiceFixture], - exports: [UserModelServiceFixture, UserPasswordServiceFixture], -}) -export class UserModuleFixture {} diff --git a/packages/nestjs-auth-recovery/src/assets/templates/email/password-updated-successfully.template.hbs b/packages/nestjs-auth-recovery/src/assets/templates/email/password-updated-successfully.template.hbs deleted file mode 100644 index cd1aaa439..000000000 --- a/packages/nestjs-auth-recovery/src/assets/templates/email/password-updated-successfully.template.hbs +++ /dev/null @@ -1,6 +0,0 @@ -

- Logo -

-

- Congratulations you were successfully joined app. -

diff --git a/packages/nestjs-auth-recovery/src/assets/templates/email/recover-login.template.hbs b/packages/nestjs-auth-recovery/src/assets/templates/email/recover-login.template.hbs deleted file mode 100644 index f6a942b2f..000000000 --- a/packages/nestjs-auth-recovery/src/assets/templates/email/recover-login.template.hbs +++ /dev/null @@ -1,10 +0,0 @@ -

- Logo -

-

-You have requested to recover your login. If you did not make this request, please ignore this message. -

- -

-This is your login {{login}} -

diff --git a/packages/nestjs-auth-recovery/src/assets/templates/email/recover-password.template.hbs b/packages/nestjs-auth-recovery/src/assets/templates/email/recover-password.template.hbs deleted file mode 100644 index b5abd43d3..000000000 --- a/packages/nestjs-auth-recovery/src/assets/templates/email/recover-password.template.hbs +++ /dev/null @@ -1,18 +0,0 @@ -

- Logo -

-

-You have requested to reset your password. If you did not make this request, please ignore this message. -

- -

-Please click on the link below to choose a new password. -

- -

-Click Here -

- -

-This link will expire at {{tokenExp}} -

diff --git a/packages/nestjs-auth-recovery/src/auth-recovery.constants.ts b/packages/nestjs-auth-recovery/src/auth-recovery.constants.ts deleted file mode 100644 index a29467630..000000000 --- a/packages/nestjs-auth-recovery/src/auth-recovery.constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const AUTH_RECOVERY_MODULE_SETTINGS_TOKEN = - 'AUTH_RECOVERY_MODULE_SETTINGS_TOKEN'; - -export const AUTH_RECOVERY_MODULE_DEFAULT_SETTINGS_TOKEN = - 'AUTH_RECOVERY_MODULE_DEFAULT_SETTINGS_TOKEN'; - -export const AuthRecoveryOtpService = Symbol( - '__AUTH_RECOVERY_MODULE_OTP_SERVICE_TOKEN__', -); - -export const AuthRecoveryEmailService = Symbol( - '__AUTH_RECOVERY_MODULE_EMAIL_SERVICE_TOKEN__', -); - -export const AuthRecoveryUserModelService = Symbol( - '__AUTH_RECOVERY_MODULE_USER_MODEL_SERVICE_TOKEN__', -); - -export const AuthRecoveryUserPasswordService = Symbol( - '__AUTH_RECOVERY_MODULE_USER_PASSWORD_SERVICE_TOKEN__', -); diff --git a/packages/nestjs-auth-recovery/src/auth-recovery.controller.e2e-spec.ts b/packages/nestjs-auth-recovery/src/auth-recovery.controller.e2e-spec.ts deleted file mode 100644 index 3e9ade011..000000000 --- a/packages/nestjs-auth-recovery/src/auth-recovery.controller.e2e-spec.ts +++ /dev/null @@ -1,170 +0,0 @@ -import supertest from 'supertest'; - -import { INestApplication } from '@nestjs/common'; -import { HttpAdapterHost } from '@nestjs/core'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { - OtpInterface, - UserInterface, - ExceptionsFilter, -} from '@concepta/nestjs-common'; -import { EmailService } from '@concepta/nestjs-email'; -import { OtpService } from '@concepta/nestjs-otp'; -import { UserModelService } from '@concepta/nestjs-user'; -import { UserFactory } from '@concepta/nestjs-user/src/seeding'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { AUTH_RECOVERY_MODULE_SETTINGS_TOKEN } from './auth-recovery.constants'; -import { AuthRecoveryRecoverLoginDto } from './dto/auth-recovery-recover-login.dto'; -import { AuthRecoveryRecoverPasswordDto } from './dto/auth-recovery-recover-password.dto'; -import { AuthRecoveryUpdatePasswordDto } from './dto/auth-recovery-update-password.dto'; -import { AuthRecoverySettingsInterface } from './interfaces/auth-recovery-settings.interface'; - -import { AppModuleDbFixture } from './__fixtures__/app.module.db.fixture'; -import { AuthRecoveryController } from './__fixtures__/auth-recovery.controller.fixture'; -import { UserEntityFixture } from './__fixtures__/user/entities/user-entity.fixture'; - -describe(AuthRecoveryController, () => { - let app: INestApplication; - let otpService: OtpService; - let userModelService: UserModelService; - let settings: AuthRecoverySettingsInterface; - let user: UserEntityFixture; - let seedingSource: SeedingSource; - let userFactory: UserFactory; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleDbFixture], - }).compile(); - app = moduleFixture.createNestApplication(); - const exceptionsFilter = app.get(HttpAdapterHost); - app.useGlobalFilters(new ExceptionsFilter(exceptionsFilter)); - - await app.init(); - - otpService = moduleFixture.get(OtpService); - userModelService = moduleFixture.get(UserModelService); - - settings = moduleFixture.get( - AUTH_RECOVERY_MODULE_SETTINGS_TOKEN, - ); - - seedingSource = new SeedingSource({ - dataSource: moduleFixture.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - userFactory = new UserFactory({ - entity: UserEntityFixture, - seedingSource, - }); - - user = await userFactory.create(); - - jest.spyOn(EmailService.prototype, 'sendMail').mockResolvedValue(undefined); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - it('POST auth/recover-login', async () => { - await supertest(app.getHttpServer()) - .post('/auth/recovery/login') - .send({ email: user.email } as AuthRecoveryRecoverLoginDto) - .expect(201); - }); - - it('GET auth/recovery/passcode/{passcode}', async () => { - const user = await getFirstUser(app); - - const otpCreateDto = await createOtp(settings, otpService, user.id); - - const { passcode } = otpCreateDto; - - await supertest(app.getHttpServer()) - .get(`/auth/recovery/passcode/${passcode}`) - .expect(200); - - await validateRecoverPassword(app, user); - }); - - it('GET auth/recovery/passcode/{passcode} fail after create', async () => { - const user = await getFirstUser(app); - - const otpCreateDto = await createOtp(settings, otpService, user.id); - // this should clear old otp - await createOtp(settings, otpService, user.id, true); - - const { passcode } = otpCreateDto; - - // should fail - await supertest(app.getHttpServer()) - .get(`/auth/recovery/passcode/${passcode}`) - .expect(400); - }); - - it('POST auth/recovery/password', async () => { - const user = await getFirstUser(app); - - await validateRecoverPassword(app, user); - }); - - it('PATCH auth/recovery/password', async () => { - const user = await getFirstUser(app); - - await validateRecoverPassword(app, user); - - const otpCreateDto = await createOtp(settings, otpService, user.id); - - await supertest(app.getHttpServer()) - .patch('/auth/recovery/password') - .send({ - passcode: otpCreateDto.passcode, - newPassword: '$!Abc123bsksl6764579', - } as AuthRecoveryUpdatePasswordDto) - .expect(200); - }); - - const getFirstUser = async ( - _app: INestApplication, - ): Promise => { - const response = await userModelService.find(); - return response[0]; - }; -}); - -const validateRecoverPassword = async ( - app: INestApplication, - user: UserInterface, -): Promise => { - await supertest(app.getHttpServer()) - .post('/auth/recovery/password') - .send({ email: user.email } as AuthRecoveryRecoverPasswordDto) - .expect(201); -}; - -const createOtp = async ( - config: AuthRecoverySettingsInterface, - otpService: OtpService, - userId: string, - clearOnCreate?: boolean, -): Promise => { - const { category, assignment, type, expiresIn } = config.otp; - - return await otpService.create({ - assignment, - otp: { - category, - type, - expiresIn, - assigneeId: userId, - }, - clearOnCreate, - }); -}; diff --git a/packages/nestjs-auth-recovery/src/auth-recovery.controller.spec.ts b/packages/nestjs-auth-recovery/src/auth-recovery.controller.spec.ts deleted file mode 100644 index 38a7126a5..000000000 --- a/packages/nestjs-auth-recovery/src/auth-recovery.controller.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { AuthRecoveryRecoverLoginDto } from './dto/auth-recovery-recover-login.dto'; -import { AuthRecoveryUpdatePasswordDto } from './dto/auth-recovery-update-password.dto'; -import { AuthRecoveryOtpInvalidException } from './exceptions/auth-recovery-otp-invalid.exception'; -import { AuthRecoveryService } from './services/auth-recovery.service'; - -import { AuthRecoveryController } from './__fixtures__/auth-recovery.controller.fixture'; - -describe(AuthRecoveryController.name, () => { - let controller: AuthRecoveryController; - let authRecoveryService: AuthRecoveryService; - const dto: AuthRecoveryRecoverLoginDto = { - email: 'test@example.com', - }; - const passwordDto: AuthRecoveryUpdatePasswordDto = { - passcode: '123456', - newPassword: 'newPassword', - }; - beforeEach(() => { - authRecoveryService = mock(); - controller = new AuthRecoveryController(authRecoveryService); - }); - - describe('recoverLogin', () => { - it('should call recoverLogin method of AuthRecoveryService', async () => { - const recoverLoginSpy = jest.spyOn(authRecoveryService, 'recoverLogin'); - - await controller.recoverLogin(dto); - - expect(recoverLoginSpy).toHaveBeenCalledWith(dto.email); - }); - }); - - describe('recoverPassword', () => { - it('should call recoverPassword method of AuthRecoveryService', async () => { - const recoverPasswordSpy = jest.spyOn( - authRecoveryService, - 'recoverPassword', - ); - - await controller.recoverPassword(dto); - - expect(recoverPasswordSpy).toHaveBeenCalledWith(dto.email); - }); - }); - - describe('validatePasscode', () => { - it('should call validatePasscode method of AuthRecoveryService', async () => { - const validatePasscodeSpy = jest - .spyOn(authRecoveryService, 'validatePasscode') - .mockResolvedValue(null); - - const t = () => controller.validatePasscode(passwordDto.passcode); - await expect(t).rejects.toThrow(AuthRecoveryOtpInvalidException); - - expect(validatePasscodeSpy).toHaveBeenCalledWith(passwordDto.passcode); - }); - - it('should call validatePasscode method of AuthRecoveryService', async () => { - const validatePasscodeSpy = jest - .spyOn(authRecoveryService, 'validatePasscode') - .mockResolvedValue({ - assigneeId: '1', - }); - - await controller.validatePasscode(passwordDto.passcode); - - expect(validatePasscodeSpy).toHaveBeenCalledWith(passwordDto.passcode); - }); - }); - - describe('updatePassword', () => { - it('should call updatePassword method of AuthRecoveryService', async () => { - const updatePasswordSpy = jest - .spyOn(authRecoveryService, 'updatePassword') - .mockResolvedValue(null); - - const t = () => controller.updatePassword(passwordDto); - await expect(t).rejects.toThrow(AuthRecoveryOtpInvalidException); - - expect(updatePasswordSpy).toHaveBeenCalledWith( - passwordDto.passcode, - passwordDto.newPassword, - ); - }); - - it('should call updatePassword method of AuthRecoveryService', async () => { - const updatePasswordSpy = jest - .spyOn(authRecoveryService, 'updatePassword') - .mockResolvedValue({ - id: '1', - }); - - await controller.updatePassword(passwordDto); - - expect(updatePasswordSpy).toHaveBeenCalledWith( - passwordDto.passcode, - passwordDto.newPassword, - ); - }); - }); -}); diff --git a/packages/nestjs-auth-recovery/src/auth-recovery.module-definition.spec.ts b/packages/nestjs-auth-recovery/src/auth-recovery.module-definition.spec.ts deleted file mode 100644 index 5a0b74a1c..000000000 --- a/packages/nestjs-auth-recovery/src/auth-recovery.module-definition.spec.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { FactoryProvider } from '@nestjs/common'; - -import { UserPasswordServiceInterface } from '@concepta/nestjs-user'; - -import { - AUTH_RECOVERY_MODULE_SETTINGS_TOKEN, - AuthRecoveryOtpService, - AuthRecoveryEmailService, - AuthRecoveryUserModelService, - AuthRecoveryUserPasswordService, -} from './auth-recovery.constants'; -import { - createAuthRecoveryEmailServiceProvider, - createAuthRecoveryExports, - createAuthRecoveryNotificationServiceProvider, - createAuthRecoveryOtpServiceProvider, - createAuthRecoveryUserModelServiceProvider, - createAuthRecoveryUserPasswordServiceProvider, -} from './auth-recovery.module-definition'; -import { AuthRecoveryEmailServiceInterface } from './interfaces/auth-recovery-email.service.interface'; -import { AuthRecoveryNotificationServiceInterface } from './interfaces/auth-recovery-notification.service.interface'; -import { AuthRecoveryUserModelServiceInterface } from './interfaces/auth-recovery-user-model.service.interface'; -import { AuthRecoveryNotificationService } from './services/auth-recovery-notification.service'; -import { AuthRecoveryService } from './services/auth-recovery.service'; - -import { OtpServiceFixture } from './__fixtures__/otp/otp.service.fixture'; -import { UserModelServiceFixture } from './__fixtures__/user/services/user-model.service.fixture'; -import { UserPasswordServiceFixture } from './__fixtures__/user/services/user-password.service.fixture'; - -describe('AuthRecoveryModuleDefinition', () => { - const mockEmailService = mock(); - const mockAuthRecoveryNotification = - mock(); - const mockAuthRecoveryOptions = { - emailService: mockEmailService, - otpService: new OtpServiceFixture(), - userModelService: new UserModelServiceFixture(), - userPasswordService: new UserPasswordServiceFixture(), - }; - describe(createAuthRecoveryExports.name, () => { - it('should return an array with the expected tokens', () => { - const result = createAuthRecoveryExports(); - expect(result).toEqual([ - AUTH_RECOVERY_MODULE_SETTINGS_TOKEN, - AuthRecoveryOtpService, - AuthRecoveryEmailService, - AuthRecoveryUserModelService, - AuthRecoveryUserPasswordService, - AuthRecoveryService, - ]); - }); - }); - - describe(createAuthRecoveryOtpServiceProvider.name, () => { - class TestOtpService extends OtpServiceFixture {} - - const testOtpService = mock(); - - it('should return a default otpService', async () => { - const provider = - createAuthRecoveryOtpServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBe(undefined); - }); - - it('should return an otpService from initialization', async () => { - const provider = - createAuthRecoveryOtpServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({ - otpService: testOtpService, - }); - - expect(useFactoryResult).toBe(testOtpService); - }); - - it('should return an overridden otpService', async () => { - const provider = createAuthRecoveryOtpServiceProvider({ - otpService: mockAuthRecoveryOptions.otpService, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBeInstanceOf(OtpServiceFixture); - }); - }); - - describe(createAuthRecoveryEmailServiceProvider.name, () => { - it('should return a have no default', async () => { - const provider = - createAuthRecoveryEmailServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBe(undefined); - }); - - it('should override an emailService', async () => { - const provider = createAuthRecoveryEmailServiceProvider({ - emailService: mockAuthRecoveryOptions.emailService, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory(); - - expect(useFactoryResult).toBe(mockAuthRecoveryOptions.emailService); - }); - - it('should return an emailService from initialization', async () => { - const provider = - createAuthRecoveryEmailServiceProvider() as FactoryProvider; - - const testMockEmailService = mock(); - const useFactoryResult = await provider.useFactory({ - emailService: testMockEmailService, - }); - - expect(useFactoryResult).toBe(testMockEmailService); - }); - }); - - describe(createAuthRecoveryUserModelServiceProvider.name, () => { - it('should return a have no default', async () => { - const provider = - createAuthRecoveryUserModelServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBe(undefined); - }); - - it('should override userModelService', async () => { - const provider = createAuthRecoveryUserModelServiceProvider({ - userModelService: mockAuthRecoveryOptions.userModelService, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory(); - - expect(useFactoryResult).toBe(mockAuthRecoveryOptions.userModelService); - }); - - it('should return an userModelService from initialization', async () => { - const provider = - createAuthRecoveryUserModelServiceProvider() as FactoryProvider; - - const mockService = mock(); - const useFactoryResult = await provider.useFactory({ - userModelService: mockService, - }); - - expect(useFactoryResult).toBe(mockService); - }); - }); - - describe(createAuthRecoveryUserPasswordServiceProvider.name, () => { - it('should return a have no default', async () => { - const provider = - createAuthRecoveryUserPasswordServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBe(undefined); - }); - - it('should override userPasswordService', async () => { - const provider = createAuthRecoveryUserPasswordServiceProvider({ - userPasswordService: mockAuthRecoveryOptions.userPasswordService, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory(); - - expect(useFactoryResult).toBe( - mockAuthRecoveryOptions.userPasswordService, - ); - }); - - it('should return an userPasswordService from initialization', async () => { - const provider = - createAuthRecoveryUserPasswordServiceProvider() as FactoryProvider; - - const mockService = mock(); - const useFactoryResult = await provider.useFactory({ - userPasswordService: mockService, - }); - - expect(useFactoryResult).toBe(mockService); - }); - }); - - describe(createAuthRecoveryNotificationServiceProvider.name, () => { - it('should return a default AuthRecoveryNotificationService', async () => { - const provider = - createAuthRecoveryNotificationServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBeInstanceOf(AuthRecoveryNotificationService); - }); - - it('should override notificationService', async () => { - const provider = createAuthRecoveryNotificationServiceProvider({ - notificationService: mockAuthRecoveryNotification, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory(); - - expect(useFactoryResult).toBe(mockAuthRecoveryNotification); - }); - - it('should return an notificationService from initialization', async () => { - const provider = - createAuthRecoveryNotificationServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({ - notificationService: mockAuthRecoveryNotification, - }); - - expect(useFactoryResult).toBe(mockAuthRecoveryNotification); - }); - }); -}); diff --git a/packages/nestjs-auth-recovery/src/auth-recovery.module-definition.ts b/packages/nestjs-auth-recovery/src/auth-recovery.module-definition.ts deleted file mode 100644 index 162cf26b5..000000000 --- a/packages/nestjs-auth-recovery/src/auth-recovery.module-definition.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { - ConfigurableModuleBuilder, - DynamicModule, - Provider, -} from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; - -import { createSettingsProvider } from '@concepta/nestjs-common'; - -import { - AUTH_RECOVERY_MODULE_SETTINGS_TOKEN, - AuthRecoveryOtpService, - AuthRecoveryEmailService, - AuthRecoveryUserModelService, - AuthRecoveryUserPasswordService, -} from './auth-recovery.constants'; -import { authRecoveryDefaultConfig } from './config/auth-recovery-default.config'; -import { AuthRecoveryEmailServiceInterface } from './interfaces/auth-recovery-email.service.interface'; -import { AuthRecoveryOptionsExtrasInterface } from './interfaces/auth-recovery-options-extras.interface'; -import { AuthRecoveryOptionsInterface } from './interfaces/auth-recovery-options.interface'; -import { AuthRecoverySettingsInterface } from './interfaces/auth-recovery-settings.interface'; -import { AuthRecoveryNotificationService } from './services/auth-recovery-notification.service'; -import { AuthRecoveryService } from './services/auth-recovery.service'; - -const RAW_OPTIONS_TOKEN = Symbol('__AUTH_RECOVERY_MODULE_RAW_OPTIONS_TOKEN__'); - -export const { - ConfigurableModuleClass: AuthRecoveryModuleClass, - OPTIONS_TYPE: AUTH_RECOVERY_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: AUTH_RECOVERY_ASYNC_OPTIONS_TYPE, -} = new ConfigurableModuleBuilder({ - moduleName: 'AuthRecovery', - optionsInjectionToken: RAW_OPTIONS_TOKEN, -}) - .setExtras( - { global: false }, - definitionTransform, - ) - .build(); - -export type AuthRecoveryOptions = Omit< - typeof AUTH_RECOVERY_OPTIONS_TYPE, - 'global' ->; -export type AuthRecoveryAsyncOptions = Omit< - typeof AUTH_RECOVERY_ASYNC_OPTIONS_TYPE, - 'global' ->; - -function definitionTransform( - definition: DynamicModule, - extras: AuthRecoveryOptionsExtrasInterface, -): DynamicModule { - const { providers } = definition; - const { global } = extras; - - return { - ...definition, - global, - imports: createAuthRecoveryImports(), - providers: createAuthRecoveryProviders({ providers }), - exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createAuthRecoveryExports()], - }; -} - -export function createAuthRecoveryImports(): DynamicModule['imports'] { - return [ConfigModule.forFeature(authRecoveryDefaultConfig)]; -} - -export function createAuthRecoveryExports() { - return [ - AUTH_RECOVERY_MODULE_SETTINGS_TOKEN, - AuthRecoveryOtpService, - AuthRecoveryEmailService, - AuthRecoveryUserModelService, - AuthRecoveryUserPasswordService, - AuthRecoveryService, - ]; -} - -export function createAuthRecoveryProviders(options: { - overrides?: AuthRecoveryOptions; - providers?: Provider[]; -}): Provider[] { - return [ - ...(options.providers ?? []), - AuthRecoveryService, - createAuthRecoverySettingsProvider(options.overrides), - createAuthRecoveryOtpServiceProvider(options.overrides), - createAuthRecoveryEmailServiceProvider(options.overrides), - createAuthRecoveryUserModelServiceProvider(options.overrides), - createAuthRecoveryUserPasswordServiceProvider(options.overrides), - createAuthRecoveryNotificationServiceProvider(options.overrides), - ]; -} - -export function createAuthRecoverySettingsProvider( - optionsOverrides?: AuthRecoveryOptions, -): Provider { - return createSettingsProvider< - AuthRecoverySettingsInterface, - AuthRecoveryOptionsInterface - >({ - settingsToken: AUTH_RECOVERY_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: authRecoveryDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createAuthRecoveryOtpServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthRecoveryOtpService, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: Pick) => - optionsOverrides?.otpService ?? options.otpService, - }; -} - -export function createAuthRecoveryEmailServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthRecoveryEmailService, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: Pick) => - optionsOverrides?.emailService ?? options.emailService, - }; -} - -export function createAuthRecoveryUserModelServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthRecoveryUserModelService, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async ( - options: Pick, - ) => optionsOverrides?.userModelService ?? options.userModelService, - }; -} - -export function createAuthRecoveryUserPasswordServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthRecoveryUserPasswordService, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async ( - options: Pick, - ) => optionsOverrides?.userPasswordService ?? options.userPasswordService, - }; -} - -export function createAuthRecoveryNotificationServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthRecoveryNotificationService, - inject: [ - RAW_OPTIONS_TOKEN, - AUTH_RECOVERY_MODULE_SETTINGS_TOKEN, - AuthRecoveryEmailService, - ], - useFactory: async ( - options: Pick, - settings: AuthRecoverySettingsInterface, - emailService: AuthRecoveryEmailServiceInterface, - ) => - optionsOverrides?.notificationService ?? - options.notificationService ?? - new AuthRecoveryNotificationService(settings, emailService), - }; -} diff --git a/packages/nestjs-auth-recovery/src/auth-recovery.module.spec.ts b/packages/nestjs-auth-recovery/src/auth-recovery.module.spec.ts deleted file mode 100644 index fe4a908e5..000000000 --- a/packages/nestjs-auth-recovery/src/auth-recovery.module.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { EmailModule, EmailService } from '@concepta/nestjs-email'; -import { UserPasswordServiceInterface } from '@concepta/nestjs-user'; - -import { AuthRecoveryModule } from './auth-recovery.module'; -import { AuthRecoveryEmailServiceInterface } from './interfaces/auth-recovery-email.service.interface'; -import { AuthRecoveryOtpServiceInterface } from './interfaces/auth-recovery-otp.service.interface'; -import { AuthRecoveryUserModelServiceInterface } from './interfaces/auth-recovery-user-model.service.interface'; -import { AuthRecoveryServiceInterface } from './interfaces/auth-recovery.service.interface'; -import { AuthRecoveryService } from './services/auth-recovery.service'; - -import { MailerServiceFixture } from './__fixtures__/email/mailer.service.fixture'; -import { OtpModuleFixture } from './__fixtures__/otp/otp.module.fixture'; -import { OtpServiceFixture } from './__fixtures__/otp/otp.service.fixture'; -import { UserModelServiceFixture } from './__fixtures__/user/services/user-model.service.fixture'; -import { UserPasswordServiceFixture } from './__fixtures__/user/services/user-password.service.fixture'; -import { UserModuleFixture } from './__fixtures__/user/user.module.fixture'; - -describe(AuthRecoveryModule, () => { - let testModule: TestingModule; - let authRecoveryModule: AuthRecoveryModule; - let otpService: AuthRecoveryOtpServiceInterface; - let userModelService: AuthRecoveryUserModelServiceInterface; - let userPasswordService: UserPasswordServiceInterface; - let authRecoveryService: AuthRecoveryServiceInterface; - let emailService: EmailService; - - const mockEmailService = mock(); - - describe(AuthRecoveryModule.forRoot, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthRecoveryModule.forRoot({ - emailService: mockEmailService, - otpService: new OtpServiceFixture(), - userModelService: new UserModelServiceFixture(), - userPasswordService: new UserPasswordServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - describe(AuthRecoveryModule.register, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthRecoveryModule.register({ - emailService: mockEmailService, - otpService: new OtpServiceFixture(), - userModelService: new UserModelServiceFixture(), - userPasswordService: new UserPasswordServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - describe(AuthRecoveryModule.forRootAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthRecoveryModule.forRootAsync({ - inject: [ - UserModelServiceFixture, - UserPasswordServiceFixture, - OtpServiceFixture, - EmailService, - ], - useFactory: ( - userModelService, - userPasswordService, - otpService, - emailService, - ) => ({ - userModelService, - userPasswordService, - otpService, - emailService, - }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - describe(AuthRecoveryModule.registerAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthRecoveryModule.registerAsync({ - inject: [ - UserModelServiceFixture, - UserPasswordServiceFixture, - OtpServiceFixture, - EmailService, - ], - useFactory: ( - userModelService, - userPasswordService, - otpService, - emailService, - ) => ({ - userModelService, - userPasswordService, - otpService, - emailService, - }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - function commonVars() { - authRecoveryModule = testModule.get(AuthRecoveryModule); - otpService = - testModule.get(OtpServiceFixture); - emailService = testModule.get(EmailService); - userModelService = testModule.get( - UserModelServiceFixture, - ); - userPasswordService = testModule.get( - UserPasswordServiceFixture, - ); - authRecoveryService = - testModule.get(AuthRecoveryService); - } - - function commonTests() { - expect(authRecoveryModule).toBeInstanceOf(AuthRecoveryModule); - expect(otpService).toBeInstanceOf(OtpServiceFixture); - expect(emailService).toBeInstanceOf(EmailService); - expect(userModelService).toBeInstanceOf(UserModelServiceFixture); - expect(userPasswordService).toBeInstanceOf(UserPasswordServiceFixture); - expect(authRecoveryService).toBeInstanceOf(AuthRecoveryService); - } -}); - -function testModuleFactory( - extraImports: DynamicModule['imports'] = [], -): ModuleMetadata { - return { - imports: [ - UserModuleFixture, - OtpModuleFixture, - EmailModule.forRoot({ mailerService: new MailerServiceFixture() }), - ...extraImports, - ], - }; -} diff --git a/packages/nestjs-auth-recovery/src/auth-recovery.module.ts b/packages/nestjs-auth-recovery/src/auth-recovery.module.ts deleted file mode 100644 index 6af4ab815..000000000 --- a/packages/nestjs-auth-recovery/src/auth-recovery.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DynamicModule, Module } from '@nestjs/common'; - -import { - AuthRecoveryAsyncOptions, - AuthRecoveryModuleClass, - AuthRecoveryOptions, -} from './auth-recovery.module-definition'; - -@Module({}) -export class AuthRecoveryModule extends AuthRecoveryModuleClass { - static register(options: AuthRecoveryOptions): DynamicModule { - return super.register(options); - } - - static registerAsync(options: AuthRecoveryAsyncOptions): DynamicModule { - return super.registerAsync(options); - } - - static forRoot(options: AuthRecoveryOptions): DynamicModule { - return super.register({ ...options, global: true }); - } - - static forRootAsync(options: AuthRecoveryAsyncOptions): DynamicModule { - return super.registerAsync({ ...options, global: true }); - } -} diff --git a/packages/nestjs-auth-recovery/src/auth-recovery.utils.spec.ts b/packages/nestjs-auth-recovery/src/auth-recovery.utils.spec.ts deleted file mode 100644 index 4de0e1c01..000000000 --- a/packages/nestjs-auth-recovery/src/auth-recovery.utils.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { formatTokenUrl } from './auth-recovery.utils'; - -describe('formatTokenUrl', () => { - it('should return the correct URL', () => { - const baseUrl = 'https://example.com'; - const passcode = '123456'; - const expectedUrl = 'https://example.com/123456'; - - const result = formatTokenUrl(baseUrl, passcode); - - expect(result).toBe(expectedUrl); - }); -}); diff --git a/packages/nestjs-auth-recovery/src/auth-recovery.utils.ts b/packages/nestjs-auth-recovery/src/auth-recovery.utils.ts deleted file mode 100644 index 1ff64db9d..000000000 --- a/packages/nestjs-auth-recovery/src/auth-recovery.utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function formatTokenUrl(baseUrl: string, passcode: string) { - return `${baseUrl}/${passcode}`; -} diff --git a/packages/nestjs-auth-recovery/src/config/auth-recovery-default.config.ts b/packages/nestjs-auth-recovery/src/config/auth-recovery-default.config.ts deleted file mode 100644 index b6e19835f..000000000 --- a/packages/nestjs-auth-recovery/src/config/auth-recovery-default.config.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { AUTH_RECOVERY_MODULE_DEFAULT_SETTINGS_TOKEN } from '../auth-recovery.constants'; -import { formatTokenUrl } from '../auth-recovery.utils'; -import { AuthRecoverySettingsInterface } from '../interfaces/auth-recovery-settings.interface'; - -/** - * Default configuration for auth recovery. - */ -export const authRecoveryDefaultConfig = registerAs( - AUTH_RECOVERY_MODULE_DEFAULT_SETTINGS_TOKEN, - (): AuthRecoverySettingsInterface => ({ - email: { - from: 'from', - baseUrl: 'baseUrl', - tokenUrlFormatter: formatTokenUrl, - templates: { - recoverLogin: { - logo: '/public/logo.svg', - fileName: __dirname + '/../assets/recover-login.template.hbs', - subject: 'Login Recovery', - }, - recoverPassword: { - logo: '/public/logo.svg', - fileName: __dirname + '/../assets/recover-password.template.hbs', - subject: 'Password Recovery', - }, - passwordUpdated: { - logo: '/public/logo.svg', - fileName: - __dirname + '/../assets/password-updated-successfully.template', - subject: 'Password Updated Successfully', - }, - }, - }, - otp: { - assignment: 'userOtp', - category: 'auth-recovery', - type: 'uuid', - expiresIn: '1h', - clearOtpOnCreate: process.env.AUTH_RECOVERY_OTP_CLEAR_ON_CREATE - ? process.env.AUTH_RECOVERY_OTP_CLEAR_ON_CREATE === 'true' - : false, - }, - }), -); diff --git a/packages/nestjs-auth-recovery/src/dto/auth-recovery-recover-login.dto.ts b/packages/nestjs-auth-recovery/src/dto/auth-recovery-recover-login.dto.ts deleted file mode 100644 index 51c7e0091..000000000 --- a/packages/nestjs-auth-recovery/src/dto/auth-recovery-recover-login.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { IsEmail } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -export class AuthRecoveryRecoverLoginDto { - @ApiProperty({ - title: 'user email', - type: 'string', - description: - 'Recover email login by providing an email that will receive an username', - }) - @IsEmail() - email = ''; -} diff --git a/packages/nestjs-auth-recovery/src/dto/auth-recovery-recover-password.dto.ts b/packages/nestjs-auth-recovery/src/dto/auth-recovery-recover-password.dto.ts deleted file mode 100644 index 3a0ed5e88..000000000 --- a/packages/nestjs-auth-recovery/src/dto/auth-recovery-recover-password.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { IsEmail } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -export class AuthRecoveryRecoverPasswordDto { - @ApiProperty({ - title: 'user email', - type: 'string', - description: - 'Recover email password by providing an email that will receive a password reset link', - }) - @IsEmail() - email = ''; -} diff --git a/packages/nestjs-auth-recovery/src/dto/auth-recovery-update-password.dto.ts b/packages/nestjs-auth-recovery/src/dto/auth-recovery-update-password.dto.ts deleted file mode 100644 index 0a8b0bccc..000000000 --- a/packages/nestjs-auth-recovery/src/dto/auth-recovery-update-password.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -export class AuthRecoveryUpdatePasswordDto { - @ApiProperty({ - title: 'account reset passcode', - type: 'string', - description: 'Passcode used to reset account password', - }) - @IsString() - passcode = ''; - - @ApiProperty({ - title: 'account new password', - type: 'string', - description: 'New password account', - }) - @IsString() - newPassword = ''; -} diff --git a/packages/nestjs-auth-recovery/src/dto/auth-recovery-validate-passcode.dto.ts b/packages/nestjs-auth-recovery/src/dto/auth-recovery-validate-passcode.dto.ts deleted file mode 100644 index c99912d5a..000000000 --- a/packages/nestjs-auth-recovery/src/dto/auth-recovery-validate-passcode.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -export class AuthRecoveryValidatePasscodeDto { - @ApiProperty({ - title: 'User passcode', - type: 'string', - description: 'User passcode used to verify if it valid or not.', - }) - @IsString() - passcode = ''; -} diff --git a/packages/nestjs-auth-recovery/src/exceptions/auth-recovery-otp-invalid.exception.ts b/packages/nestjs-auth-recovery/src/exceptions/auth-recovery-otp-invalid.exception.ts deleted file mode 100644 index abcee8b34..000000000 --- a/packages/nestjs-auth-recovery/src/exceptions/auth-recovery-otp-invalid.exception.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthRecoveryException } from './auth-recovery.exception'; - -export class AuthRecoveryOtpInvalidException extends AuthRecoveryException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: `Invalid recovery code provided`, - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'AUTH_RECOVERY_OTP_INVALID_ERROR'; - } -} diff --git a/packages/nestjs-auth-recovery/src/exceptions/auth-recovery.exception.ts b/packages/nestjs-auth-recovery/src/exceptions/auth-recovery.exception.ts deleted file mode 100644 index 594fd4d9c..000000000 --- a/packages/nestjs-auth-recovery/src/exceptions/auth-recovery.exception.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -/** - * Generic auth recovery exception. - */ -export class AuthRecoveryException extends RuntimeException { - constructor(options?: RuntimeExceptionOptions) { - super(options); - this.errorCode = 'AUTH_RECOVERY_ERROR'; - } -} diff --git a/packages/nestjs-auth-recovery/src/index.spec.ts b/packages/nestjs-auth-recovery/src/index.spec.ts deleted file mode 100644 index 942cbcc2c..000000000 --- a/packages/nestjs-auth-recovery/src/index.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - AuthRecoveryModule, - AuthRecoveryService, - AuthRecoveryNotificationService, - AuthRecoveryRecoverLoginDto, - AuthRecoveryRecoverPasswordDto, - AuthRecoveryUpdatePasswordDto, - AuthRecoveryValidatePasscodeDto, -} from './index'; - -describe('Index', () => { - it('AuthRecoveryModule should be a function', () => { - expect(AuthRecoveryModule).toBeInstanceOf(Function); - }); - - it('AuthRecoveryService should be a function', () => { - expect(AuthRecoveryService).toBeInstanceOf(Function); - }); - - it('AuthRecoveryNotificationService should be a function', () => { - expect(AuthRecoveryNotificationService).toBeInstanceOf(Function); - }); - - it('AuthRecoveryRecoverLoginDto should be a function', () => { - expect(AuthRecoveryRecoverLoginDto).toBeInstanceOf(Function); - }); - - it('AuthRecoveryRecoverPasswordDto should be a function', () => { - expect(AuthRecoveryRecoverPasswordDto).toBeInstanceOf(Function); - }); - - it('AuthRecoveryUpdatePasswordDto should be a function', () => { - expect(AuthRecoveryUpdatePasswordDto).toBeInstanceOf(Function); - }); - - it('AuthRecoveryValidatePasscodeDto should be a function', () => { - expect(AuthRecoveryValidatePasscodeDto).toBeInstanceOf(Function); - }); -}); diff --git a/packages/nestjs-auth-recovery/src/index.ts b/packages/nestjs-auth-recovery/src/index.ts deleted file mode 100644 index 9a72ff002..000000000 --- a/packages/nestjs-auth-recovery/src/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -export { AuthRecoveryModule } from './auth-recovery.module'; -export { AuthRecoveryService } from './services/auth-recovery.service'; -export { AuthRecoveryNotificationService } from './services/auth-recovery-notification.service'; - -export { - AuthRecoveryEmailService, - AuthRecoveryOtpService, - AuthRecoveryUserModelService, - AuthRecoveryUserPasswordService, -} from './auth-recovery.constants'; - -export { AuthRecoveryOptionsInterface } from './interfaces/auth-recovery-options.interface'; -export { AuthRecoveryOptionsExtrasInterface } from './interfaces/auth-recovery-options-extras.interface'; -export { AuthRecoverySettingsInterface } from './interfaces/auth-recovery-settings.interface'; -export { AuthRecoveryUserModelServiceInterface } from './interfaces/auth-recovery-user-model.service.interface'; -export { AuthRecoveryEmailServiceInterface } from './interfaces/auth-recovery-email.service.interface'; -export { AuthRecoveryOtpServiceInterface } from './interfaces/auth-recovery-otp.service.interface'; -export { AuthRecoveryServiceInterface } from './interfaces/auth-recovery.service.interface'; -export { AuthRecoveryNotificationServiceInterface } from './interfaces/auth-recovery-notification.service.interface'; - -export { AuthRecoveryRecoverLoginDto } from './dto/auth-recovery-recover-login.dto'; -export { AuthRecoveryRecoverPasswordDto } from './dto/auth-recovery-recover-password.dto'; -export { AuthRecoveryUpdatePasswordDto } from './dto/auth-recovery-update-password.dto'; -export { AuthRecoveryValidatePasscodeDto } from './dto/auth-recovery-validate-passcode.dto'; -export { AuthRecoveryException } from './exceptions/auth-recovery.exception'; -export { AuthRecoveryOtpInvalidException } from './exceptions/auth-recovery-otp-invalid.exception'; diff --git a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-email.service.interface.ts b/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-email.service.interface.ts deleted file mode 100644 index 1ef4c2d40..000000000 --- a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-email.service.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { EmailSendInterface } from '@concepta/nestjs-common'; - -export interface AuthRecoveryEmailServiceInterface extends EmailSendInterface {} diff --git a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-notification.service.interface.ts b/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-notification.service.interface.ts deleted file mode 100644 index 126ef9d09..000000000 --- a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-notification.service.interface.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { EmailSendOptionsInterface } from '@concepta/nestjs-common'; - -export interface AuthRecoveryNotificationServiceInterface { - sendEmail(sendMailOptions: EmailSendOptionsInterface): Promise; - sendRecoverLoginEmail(email: string, username: string): Promise; - sendRecoverPasswordEmail( - email: string, - passcode: string, - resetTokenExp: Date, - ): Promise; - sendPasswordUpdatedSuccessfullyEmail(email: string): Promise; -} diff --git a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-options-extras.interface.ts b/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-options-extras.interface.ts deleted file mode 100644 index 6952d87ce..000000000 --- a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-options-extras.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface AuthRecoveryOptionsExtrasInterface - extends Pick {} diff --git a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-options.interface.ts b/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-options.interface.ts deleted file mode 100644 index a470048e4..000000000 --- a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-options.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { UserPasswordServiceInterface } from '@concepta/nestjs-user'; - -import { AuthRecoveryEmailServiceInterface } from './auth-recovery-email.service.interface'; -import { AuthRecoveryNotificationServiceInterface } from './auth-recovery-notification.service.interface'; -import { AuthRecoveryOtpServiceInterface } from './auth-recovery-otp.service.interface'; -import { AuthRecoverySettingsInterface } from './auth-recovery-settings.interface'; -import { AuthRecoveryUserModelServiceInterface } from './auth-recovery-user-model.service.interface'; - -export interface AuthRecoveryOptionsInterface { - settings?: AuthRecoverySettingsInterface; - otpService: AuthRecoveryOtpServiceInterface; - emailService: AuthRecoveryEmailServiceInterface; - userModelService: AuthRecoveryUserModelServiceInterface; - userPasswordService: UserPasswordServiceInterface; - notificationService?: AuthRecoveryNotificationServiceInterface; -} diff --git a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-otp.service.interface.ts b/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-otp.service.interface.ts deleted file mode 100644 index a81570ec3..000000000 --- a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-otp.service.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - OtpClearInterface, - OtpCreateInterface, - OtpValidateInterface, -} from '@concepta/nestjs-common'; - -export interface AuthRecoveryOtpServiceInterface - extends OtpCreateInterface, - OtpValidateInterface, - OtpClearInterface {} diff --git a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-settings.interface.ts b/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-settings.interface.ts deleted file mode 100644 index 19a2c6645..000000000 --- a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-settings.interface.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - ReferenceAssignment, - OtpCreatableInterface, -} from '@concepta/nestjs-common'; - -export interface AuthRecoveryOtpSettingsInterface - extends Pick, - Partial> { - assignment: ReferenceAssignment; - clearOtpOnCreate?: boolean; -} - -export interface AuthRecoverySettingsInterface { - email: { - from: string; - baseUrl: string; - tokenUrlFormatter?: (baseUrl: string, passcode: string) => string; - templates: { - recoverLogin: { - logo: string; - fileName: string; - subject: string; - }; - recoverPassword: { - logo: string; - fileName: string; - subject: string; - }; - passwordUpdated: { - logo: string; - fileName: string; - subject: string; - }; - }; - }; - otp: AuthRecoveryOtpSettingsInterface; -} diff --git a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-user-model.service.interface.ts b/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-user-model.service.interface.ts deleted file mode 100644 index 78cbbd001..000000000 --- a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-user-model.service.interface.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - ByEmailInterface, - ByIdInterface, - ReferenceEmail, - ReferenceEmailInterface, - ReferenceId, - ReferenceIdInterface, - ReferenceUsernameInterface, -} from '@concepta/nestjs-common'; - -export interface AuthRecoveryUserModelServiceInterface - extends ByIdInterface< - ReferenceId, - ReferenceIdInterface & ReferenceEmailInterface - >, - ByEmailInterface< - ReferenceEmail, - ReferenceIdInterface & ReferenceUsernameInterface - > {} diff --git a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery.service.interface.ts b/packages/nestjs-auth-recovery/src/interfaces/auth-recovery.service.interface.ts deleted file mode 100644 index 84592ed77..000000000 --- a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery.service.interface.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - AssigneeRelationInterface, - ReferenceIdInterface, -} from '@concepta/nestjs-common'; - -export interface AuthRecoveryServiceInterface { - recoverLogin(email: string): Promise; - recoverPassword(email: string): Promise; - validatePasscode( - passcode: string, - deleteIfValid?: boolean, - ): Promise; - updatePassword( - passcode: string, - newPassword: string, - ): Promise; - revokeAllUserPasswordRecoveries(email: string): Promise; -} diff --git a/packages/nestjs-auth-recovery/src/services/auth-recovery-notification.service.spec.ts b/packages/nestjs-auth-recovery/src/services/auth-recovery-notification.service.spec.ts deleted file mode 100644 index 5a89cfed5..000000000 --- a/packages/nestjs-auth-recovery/src/services/auth-recovery-notification.service.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { EmailService } from '@concepta/nestjs-email'; - -import { AuthRecoveryEmailService } from '../auth-recovery.constants'; - -import { AuthRecoveryNotificationService } from './auth-recovery-notification.service'; - -import { AppModuleFixture } from '../__fixtures__/app.module.fixture'; - -describe('AuthRecoveryNotificationService', () => { - let app: INestApplication; - let emailService: EmailService; - let authRecoveryNotificationService: AuthRecoveryNotificationService; - - let spyEmailService: jest.SpyInstance; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - - emailService = moduleFixture.get(AuthRecoveryEmailService); - - spyEmailService = jest - .spyOn(emailService, 'sendMail') - .mockResolvedValue(undefined); - - authRecoveryNotificationService = - moduleFixture.get( - AuthRecoveryNotificationService, - ); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - it('Send email', async () => { - await authRecoveryNotificationService.sendEmail({}); - expect(spyEmailService).toHaveBeenCalledTimes(1); - }); - - it('Send recover email login', async () => { - await authRecoveryNotificationService.sendRecoverLoginEmail( - 'me@mail.com', - 'me', - ); - expect(spyEmailService).toHaveBeenCalledTimes(1); - }); - - it('Send recover email password', async () => { - await authRecoveryNotificationService.sendRecoverPasswordEmail( - 'me@mail.com', - 'me', - new Date(), - ); - expect(spyEmailService).toHaveBeenCalledTimes(1); - }); - - it('Send recover email password', async () => { - authRecoveryNotificationService['settings'].email.tokenUrlFormatter = - undefined; - - await authRecoveryNotificationService.sendRecoverPasswordEmail( - 'me@mail.com', - 'me', - new Date(), - ); - expect(spyEmailService).toHaveBeenCalledTimes(1); - }); - - it('Send recover email password', async () => { - await authRecoveryNotificationService.sendPasswordUpdatedSuccessfullyEmail( - 'me@mail.com', - ); - expect(emailService.sendMail).toHaveBeenCalled(); - }); -}); diff --git a/packages/nestjs-auth-recovery/src/services/auth-recovery-notification.service.ts b/packages/nestjs-auth-recovery/src/services/auth-recovery-notification.service.ts deleted file mode 100644 index d89451bf5..000000000 --- a/packages/nestjs-auth-recovery/src/services/auth-recovery-notification.service.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { EmailSendOptionsInterface } from '@concepta/nestjs-common'; - -import { - AUTH_RECOVERY_MODULE_SETTINGS_TOKEN, - AuthRecoveryEmailService, -} from '../auth-recovery.constants'; -import { formatTokenUrl } from '../auth-recovery.utils'; -import { AuthRecoveryEmailServiceInterface } from '../interfaces/auth-recovery-email.service.interface'; -import { AuthRecoveryNotificationServiceInterface } from '../interfaces/auth-recovery-notification.service.interface'; -import { AuthRecoverySettingsInterface } from '../interfaces/auth-recovery-settings.interface'; - -@Injectable() -export class AuthRecoveryNotificationService - implements AuthRecoveryNotificationServiceInterface -{ - constructor( - @Inject(AUTH_RECOVERY_MODULE_SETTINGS_TOKEN) - private readonly settings: AuthRecoverySettingsInterface, - @Inject(AuthRecoveryEmailService) - private readonly emailService: AuthRecoveryEmailServiceInterface, - ) {} - - async sendEmail(sendMailOptions: EmailSendOptionsInterface): Promise { - await this.emailService.sendMail(sendMailOptions); - } - - async sendRecoverPasswordEmail( - email: string, - passcode: string, - resetTokenExp: Date, - ): Promise { - const { - from, - baseUrl, - tokenUrlFormatter = formatTokenUrl, - } = this.settings.email; - const { subject, fileName, logo } = - this.settings.email.templates.recoverPassword; - await this.sendEmail({ - from, - subject, - to: email, - template: fileName, - context: { - logo: `${baseUrl}/${logo}`, - tokenUrl: tokenUrlFormatter(baseUrl, passcode), - tokenExp: resetTokenExp, - }, - }); - } - - async sendPasswordUpdatedSuccessfullyEmail(email: string): Promise { - const { from, baseUrl } = this.settings.email; - const { subject, fileName, logo } = - this.settings.email.templates.passwordUpdated; - await this.sendEmail({ - from, - subject, - to: email, - template: fileName, - context: { - logo: `${baseUrl}/${logo}`, - }, - }); - } - - async sendRecoverLoginEmail(email: string, username: string): Promise { - const { from, baseUrl } = this.settings.email; - const { subject, fileName, logo } = - this.settings.email.templates.recoverLogin; - await this.sendEmail({ - from, - subject, - to: email, - template: fileName, - context: { - logo: `${baseUrl}/${logo}`, - login: username, - }, - }); - } -} diff --git a/packages/nestjs-auth-recovery/src/services/auth-recovery.service.spec.ts b/packages/nestjs-auth-recovery/src/services/auth-recovery.service.spec.ts deleted file mode 100644 index 61a3019c6..000000000 --- a/packages/nestjs-auth-recovery/src/services/auth-recovery.service.spec.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { UserPasswordServiceInterface } from '@concepta/nestjs-user'; - -import { - AUTH_RECOVERY_MODULE_SETTINGS_TOKEN, - AuthRecoveryUserModelService, - AuthRecoveryUserPasswordService, -} from '../auth-recovery.constants'; -import { AuthRecoveryNotificationServiceInterface } from '../interfaces/auth-recovery-notification.service.interface'; -import { AuthRecoveryOtpServiceInterface } from '../interfaces/auth-recovery-otp.service.interface'; -import { AuthRecoverySettingsInterface } from '../interfaces/auth-recovery-settings.interface'; -import { AuthRecoveryUserModelServiceInterface } from '../interfaces/auth-recovery-user-model.service.interface'; - -import { AuthRecoveryNotificationService } from './auth-recovery-notification.service'; -import { AuthRecoveryService } from './auth-recovery.service'; - -import { AppModuleFixture } from '../__fixtures__/app.module.fixture'; -import { OtpServiceFixture } from '../__fixtures__/otp/otp.service.fixture'; -import { UserFixture } from '../__fixtures__/user/user.fixture'; - -describe(AuthRecoveryService, () => { - let app: INestApplication; - let authRecoveryService: AuthRecoveryService; - let notificationService: AuthRecoveryNotificationServiceInterface; - let otpService: AuthRecoveryOtpServiceInterface; - let userModelService: AuthRecoveryUserModelServiceInterface; - let userPasswordService: UserPasswordServiceInterface; - let settings: AuthRecoverySettingsInterface; - - let spySendRecoverLoginEmail: jest.SpyInstance; - let spySendRecoverPasswordEmail: jest.SpyInstance; - let spySendRecoverPasswordSuccessEmail: jest.SpyInstance; - let spyOtpServiceValidate: jest.SpyInstance; - let spyUserModelServiceByEmail: jest.SpyInstance; - let spyUserPasswordServiceSetPassword: jest.SpyInstance; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - - authRecoveryService = - moduleFixture.get(AuthRecoveryService); - - otpService = - moduleFixture.get(OtpServiceFixture); - - settings = moduleFixture.get( - AUTH_RECOVERY_MODULE_SETTINGS_TOKEN, - ); - - notificationService = - moduleFixture.get( - AuthRecoveryNotificationService, - ); - - userModelService = moduleFixture.get( - AuthRecoveryUserModelService, - ); - - userPasswordService = moduleFixture.get( - AuthRecoveryUserPasswordService, - ); - - spySendRecoverLoginEmail = jest - .spyOn(notificationService, 'sendRecoverLoginEmail') - .mockResolvedValue(undefined); - - spySendRecoverPasswordEmail = jest - .spyOn(notificationService, 'sendRecoverPasswordEmail') - .mockResolvedValue(undefined); - - spySendRecoverPasswordSuccessEmail = jest - .spyOn(notificationService, 'sendPasswordUpdatedSuccessfullyEmail') - .mockResolvedValue(undefined); - - spyOtpServiceValidate = jest.spyOn(otpService, 'validate'); - spyUserModelServiceByEmail = jest.spyOn(userModelService, 'byEmail'); - spyUserPasswordServiceSetPassword = jest.spyOn( - userPasswordService, - 'setPassword', - ); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - describe(AuthRecoveryService.prototype.recoverLogin, () => { - it('should send login recovery', async () => { - const result = await authRecoveryService.recoverLogin(UserFixture.email); - - expect(result).toBeUndefined(); - expect(spyUserModelServiceByEmail).toHaveBeenCalledTimes(1); - expect(spyUserModelServiceByEmail).toHaveBeenCalledWith( - UserFixture.email, - ); - - expect(spySendRecoverLoginEmail).toHaveBeenCalledTimes(1); - expect(spySendRecoverLoginEmail).toHaveBeenCalledWith( - UserFixture.email, - UserFixture.username, - ); - }); - }); - - describe(AuthRecoveryService.prototype.recoverPassword, () => { - it('should send password recovery', async () => { - const result = await authRecoveryService.recoverPassword( - UserFixture.email, - ); - - expect(result).toBeUndefined(); - expect(spyUserModelServiceByEmail).toHaveBeenCalledTimes(1); - expect(spyUserModelServiceByEmail).toHaveBeenCalledWith( - UserFixture.email, - ); - - expect(spySendRecoverPasswordEmail).toHaveBeenCalledTimes(1); - expect(spySendRecoverPasswordEmail).toHaveBeenCalledWith( - UserFixture.email, - 'GOOD_PASSCODE', - expect.any(Date), - ); - }); - }); - - describe(AuthRecoveryService.prototype.validatePasscode, () => { - it('should call otp validator', async () => { - await authRecoveryService.validatePasscode('GOOD_PASSCODE'); - - expect(spyOtpServiceValidate).toHaveBeenCalledWith( - settings.otp.assignment, - { category: settings.otp.category, passcode: 'GOOD_PASSCODE' }, - false, - ); - }); - - it('should validate good passcode', async () => { - const otp = await authRecoveryService.validatePasscode('GOOD_PASSCODE'); - expect(otp).toEqual({ assigneeId: UserFixture.id }); - }); - - it('should not validate bad passcode', async () => { - const otp = await authRecoveryService.validatePasscode('BAD_PASSCODE'); - expect(otp).toBeNull(); - }); - }); - - describe(AuthRecoveryService.prototype.updatePassword, () => { - it('should call user password service', async () => { - await authRecoveryService.updatePassword( - 'GOOD_PASSCODE', - '$!Abc123bsksl6764579', - ); - - expect(spyUserPasswordServiceSetPassword).toHaveBeenCalledTimes(1); - expect(spyUserPasswordServiceSetPassword).toHaveBeenCalledWith( - { password: '$!Abc123bsksl6764579' }, - UserFixture.id, - ); - }); - - it('should send success email', async () => { - await authRecoveryService.updatePassword( - 'GOOD_PASSCODE', - 'any_string_will_do', - ); - - expect(spySendRecoverPasswordSuccessEmail).toHaveBeenCalledTimes(1); - expect(spySendRecoverPasswordSuccessEmail).toHaveBeenCalledWith( - UserFixture.email, - ); - }); - - it('should update password', async () => { - const user = await authRecoveryService.updatePassword( - 'GOOD_PASSCODE', - '$!Abc123bsksl6764579', - ); - - expect(user).toEqual(UserFixture); - }); - - it('should fail to update password', async () => { - const user = await authRecoveryService.updatePassword( - 'FAKE_PASSCODE', - '$!Abc123bsksl6764579', - ); - - expect(user).toBeNull(); - }); - }); -}); diff --git a/packages/nestjs-auth-recovery/src/services/auth-recovery.service.ts b/packages/nestjs-auth-recovery/src/services/auth-recovery.service.ts deleted file mode 100644 index 3719d8322..000000000 --- a/packages/nestjs-auth-recovery/src/services/auth-recovery.service.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { - AssigneeRelationInterface, - ReferenceIdInterface, -} from '@concepta/nestjs-common'; -import { UserPasswordServiceInterface } from '@concepta/nestjs-user'; - -import { - AUTH_RECOVERY_MODULE_SETTINGS_TOKEN, - AuthRecoveryOtpService, - AuthRecoveryUserModelService, - AuthRecoveryUserPasswordService, -} from '../auth-recovery.constants'; -import { AuthRecoveryNotificationServiceInterface } from '../interfaces/auth-recovery-notification.service.interface'; -import { AuthRecoveryOtpServiceInterface } from '../interfaces/auth-recovery-otp.service.interface'; -import { AuthRecoverySettingsInterface } from '../interfaces/auth-recovery-settings.interface'; -import { AuthRecoveryUserModelServiceInterface } from '../interfaces/auth-recovery-user-model.service.interface'; -import { AuthRecoveryServiceInterface } from '../interfaces/auth-recovery.service.interface'; - -import { AuthRecoveryNotificationService } from './auth-recovery-notification.service'; - -@Injectable() -export class AuthRecoveryService implements AuthRecoveryServiceInterface { - constructor( - @Inject(AUTH_RECOVERY_MODULE_SETTINGS_TOKEN) - private readonly config: AuthRecoverySettingsInterface, - @Inject(AuthRecoveryOtpService) - private readonly otpService: AuthRecoveryOtpServiceInterface, - @Inject(AuthRecoveryUserModelService) - private readonly userModelService: AuthRecoveryUserModelServiceInterface, - @Inject(AuthRecoveryUserPasswordService) - private readonly userPasswordService: UserPasswordServiceInterface, - @Inject(AuthRecoveryNotificationService) - private readonly notificationService: AuthRecoveryNotificationServiceInterface, - ) {} - - /** - * Recover lost username providing an email and send the username by email. - * - * @param email - user email - */ - async recoverLogin(email: string): Promise { - // recover the user by providing an email - const user = await this.userModelService.byEmail(email); - - // did we find the user? - if (user) { - // yes, send an email with the recovered login - await this.notificationService.sendRecoverLoginEmail( - email, - user.username, - ); - } - - // !!! Falling through to void is intentional !!!! - // !!! Do NOT give any indication if e-mail does not exist !!!! - } - - /** - * Recover lost password providing an email and send the passcode token by email. - * - * @param email - user email - */ - async recoverPassword(email: string): Promise { - // recover the user by providing an email - const user = await this.userModelService.byEmail(email); - - // did we find a user? - if (user) { - // extract required otp properties - const { - category, - assignment, - type, - expiresIn, - clearOtpOnCreate, - rateSeconds, - rateThreshold, - } = this.config.otp; - // create an OTP save it in the database - const otp = await this.otpService.create({ - assignment, - otp: { - category, - type, - expiresIn, - assigneeId: user.id, - }, - clearOnCreate: clearOtpOnCreate, - rateSeconds, - rateThreshold, - }); - - // send en email with a recover OTP - await this.notificationService.sendRecoverPasswordEmail( - email, - otp.passcode, - otp.expirationDate, - ); - } - - // !!! Falling through to void is intentional !!!! - // !!! Do NOT give any indication if e-mail does not exist !!!! - } - - /** - * Validate passcode and return it's user. - * - * @param passcode - user's passcode - * @param deleteIfValid - flag to delete if valid or not - */ - async validatePasscode( - passcode: string, - deleteIfValid = false, - ): Promise { - // extract required properties - const { category, assignment } = this.config.otp; - - // validate passcode return passcode's user was found - return this.otpService.validate( - assignment, - { category, passcode }, - deleteIfValid, - ); - } - - /** - * Change user's password by providing it's OTP passcode and the new password. - * - * @param passcode - OTP user's passcode - * @param newPassword - new user password - */ - async updatePassword( - passcode: string, - newPassword: string, - ): Promise { - // get otp by passcode, but no delete it until all workflow pass - const otp = await this.validatePasscode(passcode, false); - - // did we get an otp? - if (otp) { - // get user by otp assigneeId - const user = await this.userModelService.byId(otp.assigneeId); - - if (user) { - // call set the password - await this.userPasswordService.setPassword( - { - password: newPassword, - }, - otp.assigneeId, - ); - - await this.notificationService.sendPasswordUpdatedSuccessfullyEmail( - user.email, - ); - - await this.revokeAllUserPasswordRecoveries(user.email); - } - - return user; - } - - // otp was not found - return null; - } - - /** - * Recover lost password providing an email and send the passcode token by email. - * - * @param email - user email - */ - async revokeAllUserPasswordRecoveries(email: string): Promise { - // recover users password by providing an email - const user = await this.userModelService.byEmail(email); - - // did we find a user? - if (user) { - // extract required otp properties - const { category, assignment } = this.config.otp; - // clear all user's otps in DB - await this.otpService.clear(assignment, { - category, - assigneeId: user.id, - }); - } - - // !!! Falling through to void is intentional !!!! - // !!! Do NOT give any indication if e-mail does not exist !!!! - } -} diff --git a/packages/nestjs-auth-recovery/tsconfig.json b/packages/nestjs-auth-recovery/tsconfig.json deleted file mode 100644 index ef9980950..000000000 --- a/packages/nestjs-auth-recovery/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": [ - "./node_modules/@types", - "../../node_modules/@types" - ] - }, - "include": [ - "src/**/*.ts" - ] -} diff --git a/packages/nestjs-auth-recovery/typedoc.json b/packages/nestjs-auth-recovery/typedoc.json deleted file mode 100644 index 944fda5ad..000000000 --- a/packages/nestjs-auth-recovery/typedoc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "entryPoints": ["src/index.ts"] -} \ No newline at end of file diff --git a/packages/nestjs-auth-refresh/README.md b/packages/nestjs-auth-refresh/README.md deleted file mode 100644 index a092590a1..000000000 --- a/packages/nestjs-auth-refresh/README.md +++ /dev/null @@ -1,417 +0,0 @@ -# Rockets NestJS Refresh Authentication - -Authenticate requests using JWT refresh tokens passed via -the request (headers, cookies, body, query, etc). - -## Project - -[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-auth-refresh)](https://www.npmjs.com/package/@concepta/nestjs-auth-refresh) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-auth-refresh)](https://www.npmjs.com/package/@concepta/nestjs-auth-refresh) -[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) -[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) - -## Table of Contents - -- [Tutorials](#tutorials) - - [1. Getting Started with AuthRefreshModule](#1-getting-started-with-authrefreshmodule) - - [1.1 Introduction](#11-introduction) - - [Overview of the Library](#overview-of-the-library) - - [Purpose and Key Features](#purpose-and-key-features) - - [1.2 Installation](#12-installation) - - [Install the AuthRefreshModule package](#install-the-authrefreshmodule-package) - - [Add the AuthRefreshModule to Your NestJS Application](#add-the-authrefreshmodule-to-your-nestjs-application) - - [1.3 Basic Setup in a NestJS Project](#13-basic-setup-in-a-nestjs-project) - - [Scenario: Refreshing JWT Tokens](#scenario-refreshing-jwt-tokens) - - [Adding AuthRefreshModule to your NestJS Application](#adding-authrefreshmodule-to-your-nestjs-application) - - [1.4 First Token Refresh](#14-first-token-refresh) - - [Validating the Setup](#validating-the-setup) - - [Step 1: Obtain a Refresh Token](#step-1-obtain-a-refresh-token) - - [Step 2: Refresh the JWT Token](#step-2-refresh-the-jwt-token) - - [Example CURL Calls](#example-curl-calls) - - [Obtain a Refresh Token](#obtain-a-refresh-token) - - [Example Refresh Token Response](#example-refresh-token-response) - - [Refresh the JWT Token](#refresh-the-jwt-token) -- [How-To Guides](#how-to-guides) - - [1. Registering AuthRefreshModule Synchronously](#1-registering-authrefreshmodule-synchronously) - - [2. Registering AuthRefreshModule Asynchronously](#2-registering-authrefreshmodule-asynchronously) - - [3. Global Registering AuthRefreshModule Asynchronously](#3-global-registering-authrefreshmodule-asynchronously) - - [4. Using Custom User Model Service](#4-using-custom-user-model-service) - - [5. Implementing and Using Custom Token Verification Service](#5-implementing-and-using-custom-token-verification-service) - - [6. Overwriting the Settings](#6-overwriting-the-settings) - - [7. Integration with Other NestJS Modules](#7-integration-with-other-nestjs-modules) -- [Engineering Concepts](#engineering-concepts) - - [Conceptual Overview of JWT Refresh Tokens](#conceptual-overview-of-jwt-refresh-tokens) - - [What is a Refresh Token?](#what-is-a-refresh-token) - - [Benefits of Using Refresh Tokens](#benefits-of-using-refresh-tokens) - - [Design Choices in AuthRefreshModule](#design-choices-in-authrefreshmodule) - - [Why Use NestJS Guards?](#why-use-nestjs-guards) - - [Synchronous vs Asynchronous Registration](#synchronous-vs-asynchronous-registration) - - [Global vs Feature-Specific Registration](#global-vs-feature-specific-registration) - - [Integrating AuthRefreshModule with Other Modules](#integrating-authrefreshmodule-with-other-modules) - - [How AuthRefreshModule Works with AuthJwtModule](#how-authrefreshmodule-works-with-authjwtmodule) - - [Integrating with AuthLocalModule](#integrating-with-authlocalmodule) - -## Tutorials - -### 1. Getting Started with AuthRefreshModule - -#### 1.1 Introduction - -##### Overview of the Library - -The `AuthRefreshModule` is a powerful yet easy-to-use NestJS -module designed for implementing JWT refresh token functionality. -With a few simple steps, you can integrate secure token refreshing -into your application without hassle. - -##### Purpose and Key Features - -- **Ease of Use**: The primary goal of `AuthRefreshModule` is to -simplify the process of adding JWT refresh token functionality to your -NestJS application. All you need to do is provide configuration data, and -the module handles the rest. -- **Synchronous and Asynchronous Registration**: Flexibly register the module -either synchronously or asynchronously, depending on your application's needs. -- **Global and Feature-Specific Registration**: Register the module globally or -for specific features within your application, allowing for more granular -control over authentication and authorization requirements. - -#### 1.2 Installation - -##### Install the AuthRefreshModule package - -To install the `AuthRefreshModule` package, run the following command in -your terminal: - -```bash -npm install @concepta/nestjs-auth-refresh -``` - -##### Add the AuthRefreshModule to Your NestJS Application - -To add the `AuthRefreshModule` to your NestJS application, import the module in -your main application module (usually `app.module.ts`) and register it using the -`forRoot` or `forRootAsync` method: - -```typescript -import { AuthRefreshModule } from '@concepta/nestjs-auth-refresh'; - -@Module({ - imports: [ - AuthRefreshModule.forRoot({ - // Configuration options - }), - ], -}) -export class AppModule {} -``` - -#### 1.3 Basic Setup in a NestJS Project - -##### Scenario: Refreshing JWT Tokens - -To demonstrate the basic setup of the `AuthRefreshModule`, let's consider -a scenario where we want to refresh JWT tokens. In this example, we will use -`@concepta/nestjs-auth-refresh` in conjunction with other essential modules -such as `@concepta/nestjs-auth-jwt`, `@concepta/nestjs-auth-local`, and -`@concepta/nestjs-authentication`. These modules work together to provide a -comprehensive and secure token refresh mechanism. - -For more detailed instructions on setting up the authentication modules, -please refer to the [Authentication Module Documentation](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-authentication). -We will continue with the tutorial in the [Authentication Module Documentation](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-authentication). - -###### Adding AuthRefreshModule to your NestJS Application - -To add the `AuthRefreshModule` to your NestJS application, import the module -in your main application module (usually `app.module.ts`) and register it -using the `forRoot` or `forRootAsync` method, let's use the -`MyJwtUserModelService` created at -[Authentication Module Documentation](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-authentication): - -```ts -//... -AuthRefreshModule.forRoot({ - userModelService: new MyJwtUserModelService() -}), -//... -``` - -> Additionally, you can take advantage of the `MyUserModelService` -> from the `@concepta/nestjs-user` module to streamline user model -> operations within your authentication flow, check -> [User Module Documentation](https://www.rockets.tools/reference/rockets/nestjs-user/README) -> for reference: - -By default, `AuthRefreshModule` uses services defined in the -[AuthenticationModule](https://www.rockets.tools/reference/rockets/nestjs-authentication/README) -to verify refresh tokens. However, you can override this behavior by -providing a custom service specifically for the -refresh token implementation during the module setup. - -#### 1.4 First Token Refresh - -##### Validating the Setup - -To validate the setup, let's test the refresh token functionality using CURL commands. - -##### Step 1: Obtain a Refresh Token - -First, obtain a refresh token by sending a request to the `/auth/login` -endpoint with valid credentials: - -```bash -curl -X POST \ - http://localhost:3000/auth/login\ - -H 'Content-Type: application/json' \ - -d '{"username":"user@example.com","password":"password"}' -``` - -This should return a response with an access token and a refresh token. - -##### Step 2: Refresh the JWT Token - -Next, use the obtained refresh token to refresh the JWT token: - -```bash -curl -X POST \ - http://localhost:3000/auth/refresh \ - -H 'Content-Type: application/json' \ - -d '{"refreshToken":"[refresh_token_value]"}' -``` - -This should return a new access token and a new refresh token. - -##### Example CURL Calls - -###### Obtain a Refresh Token - -```bash -curl -X POST \ - http://localhost:3000/auth/login \ - -H 'Content-Type: application/json' \ - -d '{"username":"user@example.com","password":"password"}' -``` - -###### Example Refresh Token Response - -```json -{ - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cC...", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cC..." -} -``` - -###### Refresh the JWT Token - -```bash -curl -X POST \ - http://localhost:3000/auth/refresh \ - -H 'Content-Type: application/json' \ - -d '{"refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cC..."}' -``` - -###### Response (example) - -```json -{ - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cC...", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cC..." -} -``` - -## How-To Guides - -### 1. Registering AuthRefreshModule Synchronously - -```ts -//... -AuthRefreshModule.register({ - userModelService: new MyUserModelService(), - issueTokenService: new MyIssueTokenService(), -}), -//... -``` - -### 2. Registering AuthRefreshModule Asynchronously - -```ts -//... -AuthRefreshModule.registerAsync({ - inject: [MyUserModelService, MyIssueTokenService], - useFactory: async ( - userModelService: MyUserModelService, - issueTokenService: MyIssueTokenService - ) => ({ - userModelService, - issueTokenService, - }), -}), -//... -``` - -### 3. Global Registering AuthRefreshModule Asynchronously - -```ts -//... -AuthRefreshModule.forRootAsync({ - inject: [MyUserModelService, MyIssueTokenService], - useFactory: async ( - userModelService: MyUserModelService, - issueTokenService: MyIssueTokenService - ) => ({ - userModelService, - issueTokenService, - }), -}), -//... -``` - -### 4. Using Custom User Model Service - -```ts -//... -@Injectable() -export class MyUserModelService extends AuthRefreshUserModelServiceInterface { - constructor(private userService: UserService) {} - - async bySubject(subject: ReferenceSubject): Promise { - // return authorized user - return this.userService.findOne(subject); - } -} -//... -``` - -### 5. Implementing and Using Custom Token Verification Service - -By default, `AuthRefreshModule` uses services defined in the -[AuthenticationModule](https://www.rockets.tools/reference/rockets/nestjs-authentication/README) -to verify refresh tokens. However, you can override this behavior by providing -a custom service specifically for the refresh token implementation during -the module setup. - -For more details on implementing a custom token verification service, refer to -section 5 of the How-To Guide in the -[@concepta/nestjs-auth-jwt](https://www.rockets.tools/reference/rockets/nestjs-auth-jwt/README) -documentation. - -### 6. Overwriting the Settings - -```ts - // app.module.ts -import { Module } from '@nestjs/common'; -import { ExtractJwt } from '@concepta/nestjs-jwt'; -import { AuthRefreshModule, AuthRefreshSettingsInterface } from '@concepta/nestjs-auth-refresh'; - -const settings: AuthRefreshSettingsInterface = { - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - verifyToken: async (token: string, done: (error: any, payload?: any) => void) => { - try { - const payload = { id: 'user-id' }; - done(null, payload); - } catch (error) { - done(error); - } - }, -}; - -@Module({ - imports: [ - AuthRefreshModule.registerAsync({ - useFactory: async () => ({ - settings, - }), - }), - ], -}) -export class AppModule {} -``` - -### 7. Integration with Other NestJS Modules - -Integrate `@concepta/nestjs-auth-refresh` with other NestJS modules -like `@concepta/nestjs-user`, `@concepta/nestjs-auth-local`, -`@concepta/nestjs-auth-jwt`, and more for a comprehensive -authentication system. - -## Engineering Concepts - -### Conceptual Overview of JWT Refresh Tokens - -#### What is a Refresh Token? - -A refresh token is a special token used to obtain a new access token -without requiring the user to re-authenticate. It is typically issued -alongside the access token and has a longer expiration time. - -#### Benefits of Using Refresh Tokens - -- **Improved Security**: By using refresh tokens, access tokens can - have shorter lifespans, reducing the risk of token theft. -- **Enhanced User Experience**: Users do not need to log in frequently, -as refresh tokens can be used to obtain new access tokens seamlessly. -- **Scalability**: Refresh tokens allow for stateless authentication, -which is ideal for scalable applications. - -### Design Choices in AuthRefreshModule - -#### Why Use NestJS Guards? - -NestJS guards provide a way to control access to various parts of the -application by checking certain conditions before the route handler is -executed. In `AuthRefreshModule`, guards are used to implement authentication -and authorization logic. By using guards, developers can apply security -policies across routes efficiently, ensuring that only authenticated -and authorized users can access protected resources. - -#### Synchronous vs Asynchronous Registration - -The `AuthRefreshModule` supports both synchronous and asynchronous -registration: - -- **Synchronous Registration**: This method is used when the configuration -options are static and available at application startup. It simplifies the -setup process and is suitable for most use cases where configuration values -do not depend on external services. - -- **Asynchronous Registration**: This method is beneficial when configuration -options need to be retrieved from external sources, such as a database or an -external API, at runtime. It allows for more flexible and dynamic -configuration but requires an asynchronous factory function. - -#### Global vs Feature-Specific Registration - -The `AuthRefreshModule` can be registered globally or for specific features: - -- **Global Registration**: Makes the module available throughout the entire -application. This approach is useful when JWT refresh functionality is -required across all or most routes in the application. - -- **Feature-Specific Registration**: Allows the module to be registered -only for specific features or modules within the application. This provides -more granular control, enabling different parts of the application to have -distinct authentication and authorization requirements. - -### Integrating AuthRefreshModule with Other Modules - -#### How AuthRefreshModule Works with AuthJwtModule - -The `AuthRefreshModule` can be seamlessly integrated with the -`AuthJwtModule` to provide a comprehensive authentication solution. -`AuthJwtModule` handles the initial authentication using JWT tokens. - -Once the user is authenticated, `AuthRefreshModule` can issue a refresh -token that the user can use to obtain new access tokens. This integration -allows for secure and efficient authentication processes combining the -strengths of both modules. - -#### Integrating with AuthLocalModule - -Integrating `AuthRefreshModule` with `AuthLocalModule` enables the -application to handle token refresh logic alongside local authentication. -This setup enhances the user experience by maintaining sessions securely and -seamlessly. The integration involves configuring both modules to use the same -token issuance and verification mechanisms, ensuring smooth interoperability -and security. diff --git a/packages/nestjs-auth-refresh/package.json b/packages/nestjs-auth-refresh/package.json deleted file mode 100644 index 145424413..000000000 --- a/packages/nestjs-auth-refresh/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@concepta/nestjs-auth-refresh", - "version": "7.0.0-alpha.10", - "description": "Rockets NestJS Refresh Authentication", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "license": "BSD-3-Clause", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" - ], - "dependencies": { - "@concepta/nestjs-authentication": "^7.0.0-alpha.10", - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@concepta/nestjs-jwt": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", - "@nestjs/swagger": "^11.2.2" - }, - "devDependencies": { - "@nestjs/testing": "^11.1.9", - "jest-mock-extended": "^4.0.0" - }, - "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", - "rxjs": "^7.1.0" - } -} diff --git a/packages/nestjs-auth-refresh/src/__fixtures__/auth-refresh.controller.fixture.ts b/packages/nestjs-auth-refresh/src/__fixtures__/auth-refresh.controller.fixture.ts deleted file mode 100644 index dad75b4fb..000000000 --- a/packages/nestjs-auth-refresh/src/__fixtures__/auth-refresh.controller.fixture.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; -import { - ApiBody, - ApiOkResponse, - ApiTags, - ApiUnauthorizedResponse, -} from '@nestjs/swagger'; - -import { - IssueTokenServiceInterface, - AuthUser, - AuthenticationJwtResponseDto, - AuthPublic, -} from '@concepta/nestjs-authentication'; -import { - AuthenticatedUserInterface, - AuthenticationResponseInterface, -} from '@concepta/nestjs-common'; - -import { AuthRefreshIssueTokenService } from '../auth-refresh.constants'; -import { AuthRefreshGuard } from '../auth-refresh.guard'; -import { AuthRefreshDto } from '../dto/auth-refresh.dto'; - -/** - * Auth Local controller - */ -@Controller('token/refresh') -@UseGuards(AuthRefreshGuard) -@AuthPublic() -@ApiTags('auth') -export class AuthRefreshControllerFixture { - constructor( - @Inject(AuthRefreshIssueTokenService) - private issueTokenService: IssueTokenServiceInterface, - ) {} - - /** - * Login - */ - @ApiBody({ - type: AuthRefreshDto, - description: 'DTO containing a refresh token.', - }) - @ApiOkResponse({ - type: AuthenticationJwtResponseDto, - description: 'DTO containing an access token and a refresh token.', - }) - @ApiUnauthorizedResponse() - @Post() - async refresh( - @AuthUser() user: AuthenticatedUserInterface, - ): Promise { - return this.issueTokenService.responsePayload(user.id); - } -} diff --git a/packages/nestjs-auth-refresh/src/__fixtures__/user/user-model.service.fixture.ts b/packages/nestjs-auth-refresh/src/__fixtures__/user/user-model.service.fixture.ts deleted file mode 100644 index 4589f5bb7..000000000 --- a/packages/nestjs-auth-refresh/src/__fixtures__/user/user-model.service.fixture.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ReferenceIdInterface, - ReferenceSubject, -} from '@concepta/nestjs-common'; - -import { AuthRefreshUserModelServiceInterface } from '../../interfaces/auth-refresh-user-model-service.interface'; - -@Injectable() -export class UserModelServiceFixture - implements AuthRefreshUserModelServiceInterface -{ - async bySubject(subject: ReferenceSubject): Promise { - throw new Error(`Method not implemented, cant get ${subject}.`); - } -} diff --git a/packages/nestjs-auth-refresh/src/__fixtures__/user/user.entity.fixture.ts b/packages/nestjs-auth-refresh/src/__fixtures__/user/user.entity.fixture.ts deleted file mode 100644 index 761316de5..000000000 --- a/packages/nestjs-auth-refresh/src/__fixtures__/user/user.entity.fixture.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ReferenceIdInterface } from '@concepta/nestjs-common'; -export class UserFixture implements ReferenceIdInterface { - id!: string; - username!: string; -} diff --git a/packages/nestjs-auth-refresh/src/__fixtures__/user/user.module.fixture.ts b/packages/nestjs-auth-refresh/src/__fixtures__/user/user.module.fixture.ts deleted file mode 100644 index 794790675..000000000 --- a/packages/nestjs-auth-refresh/src/__fixtures__/user/user.module.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { UserModelServiceFixture } from './user-model.service.fixture'; - -@Global() -@Module({ - providers: [UserModelServiceFixture], - exports: [UserModelServiceFixture], -}) -export class UserModuleFixture {} diff --git a/packages/nestjs-auth-refresh/src/auth-refresh.constants.ts b/packages/nestjs-auth-refresh/src/auth-refresh.constants.ts deleted file mode 100644 index 6b8fc6e44..000000000 --- a/packages/nestjs-auth-refresh/src/auth-refresh.constants.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const AUTH_REFRESH_MODULE_SETTINGS_TOKEN = - 'AUTH_REFRESH_MODULE_SETTINGS_TOKEN'; - -export const AUTH_REFRESH_MODULE_DEFAULT_SETTINGS_TOKEN = - 'AUTH_REFRESH_MODULE_DEFAULT_SETTINGS_TOKEN'; - -export const AUTH_REFRESH_MODULE_STRATEGY_NAME = - 'AUTH_REFRESH_MODULE_STRATEGY_NAME'; - -export const AuthRefreshVerifyService = Symbol( - '__AUTH_REFRESH_MODULE_VERIFY_SERVICE_TOKEN__', -); - -export const AuthRefreshIssueTokenService = Symbol( - '__AUTH_REFRESH_MODULE_ISSUE_TOKEN_SERVICE_TOKEN__', -); - -export const AuthRefreshUserModelService = Symbol( - '__AUTH_REFRESH_MODULE_USER_MODEL_SERVICE_TOKEN__', -); diff --git a/packages/nestjs-auth-refresh/src/auth-refresh.controller.spec.ts b/packages/nestjs-auth-refresh/src/auth-refresh.controller.spec.ts deleted file mode 100644 index 4d4de68eb..000000000 --- a/packages/nestjs-auth-refresh/src/auth-refresh.controller.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { mock } from 'jest-mock-extended'; - -import { IssueTokenServiceInterface } from '@concepta/nestjs-authentication'; -import { - AuthenticatedUserInterface, - AuthenticationResponseInterface, -} from '@concepta/nestjs-common'; - -import { AuthRefreshControllerFixture } from './__fixtures__/auth-refresh.controller.fixture'; - -describe(AuthRefreshControllerFixture, () => { - const accessToken = 'accessToken'; - const refreshToken = 'refreshToken'; - let controller: AuthRefreshControllerFixture; - const response: AuthenticationResponseInterface = { - accessToken, - refreshToken, - }; - - beforeEach(async () => { - const issueTokenService = mock({ - responsePayload: () => { - return new Promise((resolve) => { - resolve(response); - }); - }, - }); - controller = new AuthRefreshControllerFixture(issueTokenService); - }); - - describe(AuthRefreshControllerFixture.prototype.refresh, () => { - it('should return user', async () => { - const user: AuthenticatedUserInterface = { - id: randomUUID(), - }; - const result = await controller.refresh(user); - expect(result.accessToken).toBe(response.accessToken); - }); - }); -}); diff --git a/packages/nestjs-auth-refresh/src/auth-refresh.guard.ts b/packages/nestjs-auth-refresh/src/auth-refresh.guard.ts deleted file mode 100644 index 092c3ba09..000000000 --- a/packages/nestjs-auth-refresh/src/auth-refresh.guard.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { AuthGuard } from '@concepta/nestjs-authentication'; - -import { AUTH_REFRESH_MODULE_STRATEGY_NAME } from './auth-refresh.constants'; - -@Injectable() -export class AuthRefreshGuard extends AuthGuard( - AUTH_REFRESH_MODULE_STRATEGY_NAME, - { - canDisable: false, - }, -) {} diff --git a/packages/nestjs-auth-refresh/src/auth-refresh.module-definition.ts b/packages/nestjs-auth-refresh/src/auth-refresh.module-definition.ts deleted file mode 100644 index 29254f1f2..000000000 --- a/packages/nestjs-auth-refresh/src/auth-refresh.module-definition.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { - ConfigurableModuleBuilder, - DynamicModule, - Provider, -} from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; - -import { - IssueTokenService, - IssueTokenServiceInterface, - VerifyTokenService, - VerifyTokenServiceInterface, -} from '@concepta/nestjs-authentication'; -import { createSettingsProvider } from '@concepta/nestjs-common'; - -import { - AUTH_REFRESH_MODULE_SETTINGS_TOKEN, - AuthRefreshIssueTokenService, - AuthRefreshUserModelService, - AuthRefreshVerifyService, -} from './auth-refresh.constants'; -import { AuthRefreshStrategy } from './auth-refresh.strategy'; -import { authRefreshDefaultConfig } from './config/auth-refresh-default.config'; -import { AuthRefreshOptionsExtrasInterface } from './interfaces/auth-refresh-options-extras.interface'; -import { AuthRefreshOptionsInterface } from './interfaces/auth-refresh-options.interface'; -import { AuthRefreshSettingsInterface } from './interfaces/auth-refresh-settings.interface'; - -const RAW_OPTIONS_TOKEN = Symbol('__AUTH_REFRESH_MODULE_RAW_OPTIONS_TOKEN__'); - -export const { - ConfigurableModuleClass: AuthRefreshModuleClass, - OPTIONS_TYPE: AUTH_REFRESH_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: AUTH_REFRESH_ASYNC_OPTIONS_TYPE, -} = new ConfigurableModuleBuilder({ - moduleName: 'AuthRefresh', - optionsInjectionToken: RAW_OPTIONS_TOKEN, -}) - .setExtras( - { global: false }, - definitionTransform, - ) - .build(); - -export type AuthRefreshOptions = Omit< - typeof AUTH_REFRESH_OPTIONS_TYPE, - 'global' ->; -export type AuthRefreshAsyncOptions = Omit< - typeof AUTH_REFRESH_ASYNC_OPTIONS_TYPE, - 'global' ->; - -function definitionTransform( - definition: DynamicModule, - extras: AuthRefreshOptionsExtrasInterface, -): DynamicModule { - const { providers } = definition; - const { global } = extras; - - return { - ...definition, - global, - imports: createAuthRefreshImports(), - providers: createAuthRefreshProviders({ providers }), - exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createAuthRefreshExports()], - }; -} - -export function createAuthRefreshImports(): DynamicModule['imports'] { - return [ConfigModule.forFeature(authRefreshDefaultConfig)]; -} - -export function createAuthRefreshExports() { - return [ - AUTH_REFRESH_MODULE_SETTINGS_TOKEN, - AuthRefreshUserModelService, - AuthRefreshVerifyService, - AuthRefreshIssueTokenService, - AuthRefreshStrategy, - ]; -} - -export function createAuthRefreshProviders(options: { - overrides?: AuthRefreshOptions; - providers?: Provider[]; -}): Provider[] { - return [ - ...(options.providers ?? []), - AuthRefreshStrategy, - VerifyTokenService, - IssueTokenService, - createAuthRefreshOptionsProvider(options.overrides), - createAuthRefreshVerifyTokenServiceProvider(options.overrides), - createAuthRefreshIssueTokenServiceProvider(options.overrides), - createAuthRefreshUserModelServiceProvider(options.overrides), - ]; -} - -export function createAuthRefreshOptionsProvider( - optionsOverrides?: AuthRefreshOptions, -): Provider { - return createSettingsProvider< - AuthRefreshSettingsInterface, - AuthRefreshOptionsInterface - >({ - settingsToken: AUTH_REFRESH_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: authRefreshDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createAuthRefreshVerifyTokenServiceProvider( - optionsOverrides?: AuthRefreshOptions, -): Provider { - return { - provide: AuthRefreshVerifyService, - inject: [RAW_OPTIONS_TOKEN, VerifyTokenService], - useFactory: async ( - options: AuthRefreshOptions, - defaultService: VerifyTokenServiceInterface, - ) => - optionsOverrides?.verifyTokenService ?? - options.verifyTokenService ?? - defaultService, - }; -} - -export function createAuthRefreshIssueTokenServiceProvider( - optionsOverrides?: AuthRefreshOptions, -): Provider { - return { - provide: AuthRefreshIssueTokenService, - inject: [RAW_OPTIONS_TOKEN, IssueTokenService], - useFactory: async ( - options: AuthRefreshOptionsInterface, - defaultService: IssueTokenServiceInterface, - ) => - optionsOverrides?.issueTokenService ?? - options.issueTokenService ?? - defaultService, - }; -} - -export function createAuthRefreshUserModelServiceProvider( - optionsOverrides?: AuthRefreshOptions, -): Provider { - return { - provide: AuthRefreshUserModelService, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: AuthRefreshOptionsInterface) => - optionsOverrides?.userModelService ?? options.userModelService, - }; -} diff --git a/packages/nestjs-auth-refresh/src/auth-refresh.module.spec.ts b/packages/nestjs-auth-refresh/src/auth-refresh.module.spec.ts deleted file mode 100644 index 0a83d4717..000000000 --- a/packages/nestjs-auth-refresh/src/auth-refresh.module.spec.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { - AuthenticationModule, - IssueTokenService, - IssueTokenServiceInterface, - VerifyTokenService, - VerifyTokenServiceInterface, -} from '@concepta/nestjs-authentication'; -import { - JwtIssueTokenService, - JwtModule, - JwtService, - JwtVerifyTokenService, -} from '@concepta/nestjs-jwt'; - -import { AuthRefreshModule } from './auth-refresh.module'; -import { AuthRefreshUserModelServiceInterface } from './interfaces/auth-refresh-user-model-service.interface'; - -import { UserModelServiceFixture } from './__fixtures__/user/user-model.service.fixture'; -import { UserModuleFixture } from './__fixtures__/user/user.module.fixture'; - -describe(AuthRefreshModule, () => { - const jwtService = new JwtService(); - const jwtVerifyTokenService = new JwtVerifyTokenService( - jwtService, - jwtService, - ); - const jwtIssueTokenService = new JwtIssueTokenService(jwtService, jwtService); - - let testModule: TestingModule; - let authRefreshModule: AuthRefreshModule; - let userModelService: AuthRefreshUserModelServiceInterface; - let issueTokenService: IssueTokenServiceInterface; - let verifyTokenService: VerifyTokenServiceInterface; - - describe(AuthRefreshModule.forRoot, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthRefreshModule.forRoot({ - verifyTokenService: new VerifyTokenService(jwtVerifyTokenService), - issueTokenService: new IssueTokenService(jwtIssueTokenService), - userModelService: new UserModelServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - describe(AuthRefreshModule.register, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthRefreshModule.register({ - verifyTokenService: new VerifyTokenService(jwtVerifyTokenService), - issueTokenService: new IssueTokenService(jwtIssueTokenService), - userModelService: new UserModelServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - describe(AuthRefreshModule.forRootAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthRefreshModule.forRootAsync({ - inject: [ - VerifyTokenService, - IssueTokenService, - UserModelServiceFixture, - ], - useFactory: ( - verifyTokenService: VerifyTokenService, - issueTokenService: IssueTokenServiceInterface, - userModelService: AuthRefreshUserModelServiceInterface, - ) => ({ - verifyTokenService, - issueTokenService, - userModelService: userModelService, - }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - describe(AuthRefreshModule.registerAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthRefreshModule.registerAsync({ - inject: [ - VerifyTokenService, - IssueTokenService, - UserModelServiceFixture, - ], - useFactory: ( - verifyTokenService: VerifyTokenService, - issueTokenService: IssueTokenService, - userModelService: AuthRefreshUserModelServiceInterface, - ) => ({ - verifyTokenService, - issueTokenService, - userModelService: userModelService, - }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(testModule); - commonTests(); - }); - }); - - function commonVars(module: TestingModule) { - authRefreshModule = module.get(AuthRefreshModule); - userModelService = module.get(UserModelServiceFixture); - verifyTokenService = module.get(VerifyTokenService); - issueTokenService = module.get(IssueTokenService); - } - - function commonTests() { - expect(authRefreshModule).toBeInstanceOf(AuthRefreshModule); - expect(userModelService).toBeInstanceOf(UserModelServiceFixture); - expect(issueTokenService).toBeInstanceOf(IssueTokenService); - expect(verifyTokenService).toBeInstanceOf(VerifyTokenService); - } -}); - -function testModuleFactory( - extraImports: DynamicModule['imports'] = [], -): ModuleMetadata { - return { - imports: [ - UserModuleFixture, - AuthenticationModule.forRoot({}), - JwtModule.forRoot({}), - ...extraImports, - ], - }; -} diff --git a/packages/nestjs-auth-refresh/src/auth-refresh.module.ts b/packages/nestjs-auth-refresh/src/auth-refresh.module.ts deleted file mode 100644 index 85838e0c4..000000000 --- a/packages/nestjs-auth-refresh/src/auth-refresh.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { DynamicModule, Module } from '@nestjs/common'; - -import { - AuthRefreshAsyncOptions, - AuthRefreshModuleClass, - AuthRefreshOptions, -} from './auth-refresh.module-definition'; - -/** - * Auth Refresh module - */ -@Module({}) -export class AuthRefreshModule extends AuthRefreshModuleClass { - static register(options: AuthRefreshOptions): DynamicModule { - return super.register(options); - } - - static registerAsync(options: AuthRefreshAsyncOptions): DynamicModule { - return super.registerAsync(options); - } - - static forRoot(options: AuthRefreshOptions): DynamicModule { - return super.register({ ...options, global: true }); - } - - static forRootAsync(options: AuthRefreshAsyncOptions): DynamicModule { - return super.registerAsync({ ...options, global: true }); - } -} diff --git a/packages/nestjs-auth-refresh/src/auth-refresh.strategy.spec.ts b/packages/nestjs-auth-refresh/src/auth-refresh.strategy.spec.ts deleted file mode 100644 index b2cff223b..000000000 --- a/packages/nestjs-auth-refresh/src/auth-refresh.strategy.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { mock } from 'jest-mock-extended'; - -import { VerifyTokenServiceInterface } from '@concepta/nestjs-authentication'; -import { AuthorizationPayloadInterface } from '@concepta/nestjs-common'; - -import { AuthRefreshStrategy } from './auth-refresh.strategy'; -import { AuthRefreshUnauthorizedException } from './exceptions/auth-refresh-unauthorized.exception'; -import { AuthRefreshSettingsInterface } from './interfaces/auth-refresh-settings.interface'; -import { AuthRefreshUserModelServiceInterface } from './interfaces/auth-refresh-user-model-service.interface'; - -import { UserFixture } from './__fixtures__/user/user.entity.fixture'; - -describe(AuthRefreshStrategy, () => { - const USERNAME = 'username'; - - let user: UserFixture; - let settings: Partial; - let userModelService: AuthRefreshUserModelServiceInterface; - let verifyTokenService: VerifyTokenServiceInterface; - let authRefreshStrategy: AuthRefreshStrategy; - let authorizationPayloadInterface: AuthorizationPayloadInterface; - - beforeEach(async () => { - // TODO: configure JWT module to use different access and refresh secrets - - settings = mock>(); - - userModelService = mock(); - verifyTokenService = mock(); - authRefreshStrategy = new AuthRefreshStrategy( - settings, - verifyTokenService, - userModelService, - ); - - user = new UserFixture(); - user.id = randomUUID(); - - authorizationPayloadInterface = { - sub: USERNAME, - }; - - jest.spyOn(userModelService, 'bySubject').mockResolvedValue(user); - }); - - it('constructor', async () => { - settings = mock>(); - authRefreshStrategy = new AuthRefreshStrategy( - settings, - verifyTokenService, - userModelService, - ); - expect(true).toBeTruthy(); - }); - - describe(AuthRefreshStrategy.prototype.validate, () => { - it('should return user', async () => { - const result = await authRefreshStrategy.validate( - authorizationPayloadInterface, - ); - expect(result.id).toBe(user.id); - }); - - it(`should throw UnauthorizedException`, async () => { - jest.spyOn(userModelService, 'bySubject').mockResolvedValue(null); - - const t = () => - authRefreshStrategy.validate(authorizationPayloadInterface); - await expect(t).rejects.toThrow(AuthRefreshUnauthorizedException); - }); - }); -}); diff --git a/packages/nestjs-auth-refresh/src/auth-refresh.strategy.ts b/packages/nestjs-auth-refresh/src/auth-refresh.strategy.ts deleted file mode 100644 index d43c15230..000000000 --- a/packages/nestjs-auth-refresh/src/auth-refresh.strategy.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { - PassportStrategyFactory, - VerifyTokenServiceInterface, -} from '@concepta/nestjs-authentication'; -import { AuthorizationPayloadInterface } from '@concepta/nestjs-common'; -import { - createVerifyRefreshTokenCallback, - JwtStrategy, -} from '@concepta/nestjs-jwt'; - -import { - AUTH_REFRESH_MODULE_SETTINGS_TOKEN, - AUTH_REFRESH_MODULE_STRATEGY_NAME, - AuthRefreshUserModelService, - AuthRefreshVerifyService, -} from './auth-refresh.constants'; -import { AuthRefreshUnauthorizedException } from './exceptions/auth-refresh-unauthorized.exception'; -import { AuthRefreshSettingsInterface } from './interfaces/auth-refresh-settings.interface'; -import { AuthRefreshUserModelServiceInterface } from './interfaces/auth-refresh-user-model-service.interface'; - -@Injectable() -export class AuthRefreshStrategy extends PassportStrategyFactory( - JwtStrategy, - AUTH_REFRESH_MODULE_STRATEGY_NAME, -) { - constructor( - @Inject(AUTH_REFRESH_MODULE_SETTINGS_TOKEN) - settings: Partial, - @Inject(AuthRefreshVerifyService) - verifyTokenService: VerifyTokenServiceInterface, - @Inject(AuthRefreshUserModelService) - private userModelService: AuthRefreshUserModelServiceInterface, - ) { - const options: Partial = { - verifyToken: createVerifyRefreshTokenCallback(verifyTokenService), - ...settings, - }; - - super(options); - } - - /** - * Validate the user sub from the verified token - * - * @param payload - Authorization payload - */ - async validate(payload: AuthorizationPayloadInterface) { - const user = await this.userModelService.bySubject(payload.sub); - - if (!user) { - throw new AuthRefreshUnauthorizedException(); - } - - return user; - } -} diff --git a/packages/nestjs-auth-refresh/src/config/auth-refresh-default.config.ts b/packages/nestjs-auth-refresh/src/config/auth-refresh-default.config.ts deleted file mode 100644 index 1d381f0bf..000000000 --- a/packages/nestjs-auth-refresh/src/config/auth-refresh-default.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { ExtractJwt } from '@concepta/nestjs-jwt'; - -import { AUTH_REFRESH_MODULE_DEFAULT_SETTINGS_TOKEN } from '../auth-refresh.constants'; -import { AuthRefreshSettingsInterface } from '../interfaces/auth-refresh-settings.interface'; - -/** - * Default configuration for auth refresh. - */ -export const authRefreshDefaultConfig = registerAs( - AUTH_REFRESH_MODULE_DEFAULT_SETTINGS_TOKEN, - (): Partial => ({ - jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'), - }), -); diff --git a/packages/nestjs-auth-refresh/src/dto/auth-refresh.dto.ts b/packages/nestjs-auth-refresh/src/dto/auth-refresh.dto.ts deleted file mode 100644 index 89976c194..000000000 --- a/packages/nestjs-auth-refresh/src/dto/auth-refresh.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsJWT } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { AuthenticationRefreshInterface } from '@concepta/nestjs-common'; - -@Exclude() -export class AuthRefreshDto implements AuthenticationRefreshInterface { - @Expose() - @ApiProperty({ - type: 'string', - description: 'JWT access token to use for request authorization.', - }) - @IsJWT() - refreshToken = ''; -} diff --git a/packages/nestjs-auth-refresh/src/exceptions/auth-refresh-unauthorized.exception.ts b/packages/nestjs-auth-refresh/src/exceptions/auth-refresh-unauthorized.exception.ts deleted file mode 100644 index bb951efba..000000000 --- a/packages/nestjs-auth-refresh/src/exceptions/auth-refresh-unauthorized.exception.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthRefreshException } from './auth-refresh.exception'; - -export class AuthRefreshUnauthorizedException extends AuthRefreshException { - constructor(options?: Omit) { - super({ - message: `Unauthorized refresh attempt`, - ...options, - httpStatus: HttpStatus.UNAUTHORIZED, - }); - - this.errorCode = 'AUTH_REFRESH_NOT_AUTHORIZED_ERROR'; - } -} diff --git a/packages/nestjs-auth-refresh/src/exceptions/auth-refresh.exception.ts b/packages/nestjs-auth-refresh/src/exceptions/auth-refresh.exception.ts deleted file mode 100644 index 6b6e31695..000000000 --- a/packages/nestjs-auth-refresh/src/exceptions/auth-refresh.exception.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -/** - * Generic auth refresh exception. - */ -export class AuthRefreshException extends RuntimeException { - constructor(options?: RuntimeExceptionOptions) { - super(options); - this.errorCode = 'AUTH_REFRESH_ERROR'; - } -} diff --git a/packages/nestjs-auth-refresh/src/index.spec.ts b/packages/nestjs-auth-refresh/src/index.spec.ts deleted file mode 100644 index 3f410eb6f..000000000 --- a/packages/nestjs-auth-refresh/src/index.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AuthRefreshModule, RefreshAuthGuard } from './index'; - -describe('Index', () => { - it('should be defined', () => { - expect(AuthRefreshModule).toBeInstanceOf(Function); - }); - it('should be defined', () => { - expect(RefreshAuthGuard).toBeInstanceOf(Function); - }); -}); diff --git a/packages/nestjs-auth-refresh/src/index.ts b/packages/nestjs-auth-refresh/src/index.ts deleted file mode 100644 index cf7140ace..000000000 --- a/packages/nestjs-auth-refresh/src/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { AuthRefreshModule } from './auth-refresh.module'; -export { - AuthRefreshIssueTokenService, - AuthRefreshVerifyService, - AuthRefreshUserModelService, -} from './auth-refresh.constants'; -export { AuthRefreshDto } from './dto/auth-refresh.dto'; -export { - AuthRefreshGuard, - AuthRefreshGuard as RefreshAuthGuard, -} from './auth-refresh.guard'; - -export { AuthRefreshOptionsInterface } from './interfaces/auth-refresh-options.interface'; -export { AuthRefreshOptionsExtrasInterface } from './interfaces/auth-refresh-options-extras.interface'; -export { AuthRefreshSettingsInterface } from './interfaces/auth-refresh-settings.interface'; -export { AuthRefreshUserModelServiceInterface } from './interfaces/auth-refresh-user-model-service.interface'; - -export { AuthRefreshException } from './exceptions/auth-refresh.exception'; -export { AuthRefreshUnauthorizedException } from './exceptions/auth-refresh-unauthorized.exception'; diff --git a/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-options-extras.interface.ts b/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-options-extras.interface.ts deleted file mode 100644 index 855c6750e..000000000 --- a/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-options-extras.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface AuthRefreshOptionsExtrasInterface - extends Pick {} diff --git a/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-options.interface.ts b/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-options.interface.ts deleted file mode 100644 index a03113563..000000000 --- a/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-options.interface.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - IssueTokenServiceInterface, - VerifyTokenServiceInterface, -} from '@concepta/nestjs-authentication'; - -import { AuthRefreshSettingsInterface } from './auth-refresh-settings.interface'; -import { AuthRefreshUserModelServiceInterface } from './auth-refresh-user-model-service.interface'; - -export interface AuthRefreshOptionsInterface { - /** - * Implementation of a class that returns user identity - */ - userModelService: AuthRefreshUserModelServiceInterface; - - /** - * Implementation of a class to issue tokens - */ - issueTokenService?: IssueTokenServiceInterface; - - /** - * Implementation of a class to verify tokens - */ - verifyTokenService?: VerifyTokenServiceInterface; - - /** - * Settings - */ - settings?: AuthRefreshSettingsInterface; -} diff --git a/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-settings.interface.ts b/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-settings.interface.ts deleted file mode 100644 index 9fe54dbd2..000000000 --- a/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-settings.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { JwtStrategyOptionsInterface } from '@concepta/nestjs-jwt'; - -export interface AuthRefreshSettingsInterface - extends JwtStrategyOptionsInterface {} diff --git a/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-user-model-service.interface.ts b/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-user-model-service.interface.ts deleted file mode 100644 index bca7e1f97..000000000 --- a/packages/nestjs-auth-refresh/src/interfaces/auth-refresh-user-model-service.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { - BySubjectInterface, - ReferenceIdInterface, - ReferenceSubject, -} from '@concepta/nestjs-common'; - -export interface AuthRefreshUserModelServiceInterface - extends BySubjectInterface {} diff --git a/packages/nestjs-auth-refresh/tsconfig.json b/packages/nestjs-auth-refresh/tsconfig.json deleted file mode 100644 index 30d13ba37..000000000 --- a/packages/nestjs-auth-refresh/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": [ - "./node_modules/@types", - "../../node_modules/@types" - ] - }, - "include": [ - "src/**/*.ts" - ] -} diff --git a/packages/nestjs-auth-refresh/typedoc.json b/packages/nestjs-auth-refresh/typedoc.json deleted file mode 100644 index 944fda5ad..000000000 --- a/packages/nestjs-auth-refresh/typedoc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "entryPoints": ["src/index.ts"] -} \ No newline at end of file diff --git a/packages/nestjs-auth-router/README.md b/packages/nestjs-auth-router/README.md deleted file mode 100644 index 262c116aa..000000000 --- a/packages/nestjs-auth-router/README.md +++ /dev/null @@ -1,323 +0,0 @@ -# Rockets NestJS Auth Guard Router - -Route authentication requests to provider-specific guards based on query -parameters. - -## Project - -[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-auth-router)](https://www.npmjs.com/package/@concepta/nestjs-auth-router) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-auth-router)](https://www.npmjs.com/package/@concepta/nestjs-auth-router) -[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://google.com/conceptadev/rockets) -[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://google.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) - -## Table of Contents - -1. [Tutorials](#tutorials) - - [Introduction](#introduction) - - [Getting Started with Auth Guard Router](#getting-started-with-auth-guard-router) - - [Step 1: Install the Package](#step-1-install-the-package) - - [Step 2: Configure Multiple Auth Guard Router Guards](#step-2-configure-multiple-auth-guard-router-guards) - - [Step 3: Use the Auth Router Guard](#step-3-use-the-auth-router-guard) -2. [How-To Guides](#how-to-guides) - - [Configuring Provider-Specific Guards](#configuring-provider-specific-guards) - - [Creating Custom Controllers](#creating-custom-controllers) - - [Error Handling](#error-handling) -3. [Reference](#reference) -4. [Explanation](#explanation) - - [Overview of the Guard Router](#overview-of-the-guard-router) - - [Provider-Based Routing](#provider-based-routing) - - [Error Handling System](#error-handling-system) - -## Tutorials - -### Introduction - -The `@concepta/nestjs-auth-router` module provides a guard router that -delegates authentication to provider-specific guards based on the `provider` -query parameter. This allows you to support multiple authentication providers -(Google, Facebook, GitHub, etc.) through a single unified interface. - -**Important:** This module is a guard router only. It does not provide -authentication strategies or authentication logic itself. You need to implement -or use provider-specific guards (like `@concepta/nestjs-auth-google`) that -handle the actual authentication. - -### Getting Started with Auth Guard Router - -#### Step 1: Install the Package - -To get started, install the `@concepta/nestjs-auth-router` package: - -```bash -yarn add @concepta/nestjs-auth-router -``` - -#### Step 2: Configure Multiple Auth Guard Router Guards - -Configure the Auth Guard Router module with your provider-specific guards. You -need to import the actual authentication provider modules that provide the -guards: - -```ts -import { Module } from '@nestjs/common'; -import { AuthRouterModule } from '@concepta/nestjs-auth-router'; -import { AuthGoogleModule, AuthGoogleGuard } from '@concepta/nestjs-auth-google'; -import { AuthFacebookModule, AuthFacebookGuard } from '@concepta/nestjs-auth-facebook'; -import { AuthGitHubModule, AuthGitHubGuard } from '@concepta/nestjs-auth-github'; - -@Module({ - imports: [ - // Import the actual authentication provider modules - AuthGoogleModule.forRoot({ - // Google-specific configuration - }), - AuthFacebookModule.forRoot({ - // Facebook-specific configuration - }), - AuthGitHubModule.forRoot({ - // GitHub-specific configuration - }), - // Configure the Auth Router with the guards from those modules - AuthRouterModule.forRoot({ - guards: [ - { name: 'google', guard: AuthGoogleGuard }, - { name: 'facebook', guard: AuthFacebookGuard }, - { name: 'github', guard: AuthGitHubGuard }, - ], - }), - ], -}) -export class AppModule {} -``` - -#### Step 3: Use the Auth Router Guard - -Use the `AuthRouterGuard` in your controllers: - -```ts -import { Controller, Get, UseGuards, Query } from '@nestjs/common'; -import { AuthRouterGuard } from '@concepta/nestjs-auth-router'; - -@Controller('auth') -@UseGuards(AuthRouterGuard) -export class AuthController { - @Get('login') - login(@Query('provider') provider: string): void { - // The AuthRouterGuard will route to the appropriate provider guard - // based on the provider query parameter - return; - } - - @Get('callback') - callback(): string { - // Handle the authentication callback - return 'Authentication successful'; - } -} -``` - -## How-To Guides - -### Configuring Provider-Specific Guards - -You typically use existing authentication provider modules rather than creating -guards from scratch. For example, use `@concepta/nestjs-auth-google` for -Google authentication: - -```ts -import { Module } from '@nestjs/common'; -import { AuthRouterModule } from '@concepta/nestjs-auth-router'; -import { AuthGoogleModule, AuthGoogleGuard } from '@concepta/nestjs-auth-google'; - -@Module({ - imports: [ - // Import the Google authentication module with its configuration - AuthGoogleModule.forRoot({ - // Google authentication configuration (client ID, secret, etc.) - }), - // Configure the Auth Router to use the Google guard - AuthRouterModule.forRoot({ - guards: [ - { name: 'google', guard: AuthGoogleGuard }, - ], - }), - ], -}) -export class AppModule {} -``` - -If you need to create a custom authentication guard, it must implement the -`CanActivate` interface: - -```ts -import { CanActivate, Injectable, ExecutionContext } from '@nestjs/common'; - -@Injectable() -export class CustomAuthGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean | Promise | Observable { - // Implement your authentication logic here - return true; - } -} -``` - -Then register it in the module: - -```ts -AuthRouterModule.forRoot({ - guards: [ - { name: 'custom', guard: CustomAuthGuard }, - ], -}) -``` - -### Creating Custom Controllers - -You can create custom controllers that use the Auth Guard Router guard: - -```ts -import { Controller, Get, UseGuards } from '@nestjs/common'; -import { AuthRouterGuard } from '@concepta/nestjs-auth-router'; - -@Controller('auth') -@UseGuards(AuthRouterGuard) -export class AuthController { - @Get('login') - login(): void { - // Guard handles routing based on ?provider= query parameter - return; - } - - @Get('callback') - callback(): { message: string } { - return { message: 'Authentication callback handled' }; - } -} -``` - -### Error Handling - -The Auth Guard Router guard provides specific exceptions for different error -scenarios: - -```ts -import { - AuthRouterProviderMissingException, - AuthRouterProviderNotSupportedException, - AuthRouterConfigNotAvailableException, - AuthRouterGuardInvalidException, - AuthRouterAuthenticationFailedException, -} from '@concepta/nestjs-auth-router'; - -// These exceptions are thrown automatically by the guard: -// - AuthRouterProviderMissingException: No provider query parameter -// - AuthRouterProviderNotSupportedException: Provider not configured -// - AuthRouterConfigNotAvailableException: Guards not properly configured -// - AuthRouterGuardInvalidException: Guard instance is invalid -// - AuthRouterAuthenticationFailedException: Authentication failed -``` - -## Reference - -### Exported Types and Classes - -- **`AuthRouterModule`**: Main module class with `forRoot()` and - `forRootAsync()` methods -- **`AuthRouterGuard`**: Main guard that routes requests to - provider-specific guards -- **`AuthRouterGuardsRecord`**: Type for mapping provider names to guard - instances -- **`AuthRouterException`**: Base exception class for Auth Router - related errors - -### Configuration Options - -```ts -interface AuthRouterOptions { - guards: AuthRouterGuardConfigInterface[]; - settings?: AuthRouterSettingsInterface; -} - -interface AuthRouterGuardConfigInterface { - name: string; // Provider name (e.g., 'google', 'facebook') - guard: Type; // Guard class that implements CanActivate -} - -interface AuthRouterOptionsExtrasInterface { - global?: boolean; // Whether the module should be global -} -``` - -### Usage Patterns - -**URL Patterns:** - -- `/auth/login?provider=google` → Routes to Google guard -- `/auth/login?provider=facebook` → Routes to Facebook guard -- `/auth/login?provider=github` → Routes to GitHub guard - -**Callback Handling:** - -The guard also handles callback scenarios where the `code` parameter is present: - -- `/auth/callback?provider=google&code=abc123` → Routes to Google guard with - callback -- `/auth/callback?code=abc123&state={"provider":"google"}` → Extracts - provider from state - -## Explanation - -### Overview of the Guard Router - -The Auth Router module provides a routing mechanism for authentication -rather than implementing authentication strategies directly. It acts as a -dispatcher that: - -1. Extracts the `provider` query parameter from incoming requests -2. Validates the provider and configuration -3. Routes the request to the appropriate provider-specific guard -4. Handles the response from the provider guard - -### Provider-Based Routing - -The routing system works as follows: - -1. **Request Processing**: When a request hits an endpoint protected by - `AuthRouterGuard`, the guard extracts the `provider` query parameter. - -2. **Callback Detection**: If a `code` parameter is present, the guard - handles callback scenarios: - - Uses the `provider` from query parameters - - Falls back to extracting provider from `state` parameter if needed - -3. **Provider Validation**: The guard validates that: - - The provider parameter is present and not empty - - The provider is configured in the `guards` array - - The corresponding guard instance is valid - -4. **Guard Delegation**: The request is forwarded to the provider-specific - guard's `canActivate` method. - -5. **Response Handling**: The guard handles different return types: - - `boolean`: Direct return - - `Promise`: Awaited - - `Observable`: Converted to Promise and awaited - -### Error Handling System - -The module includes comprehensive error handling: - -- **`AuthRouterProviderMissingException`**: Thrown when no `provider` - query parameter is provided -- **`AuthRouterProviderNotSupportedException`**: Thrown when the provider - is not configured -- **`AuthRouterConfigNotAvailableException`**: Thrown when the guards - configuration is invalid -- **`AuthRouterGuardInvalidException`**: Thrown when a guard instance - doesn't implement `canActivate` -- **`AuthRouterAuthenticationFailedException`**: Thrown when the provider - guard throws an unexpected error - -This approach ensures that authentication errors are properly categorized and -can be handled appropriately by your application's error handling middleware. diff --git a/packages/nestjs-auth-router/package.json b/packages/nestjs-auth-router/package.json deleted file mode 100644 index ac9a55708..000000000 --- a/packages/nestjs-auth-router/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@concepta/nestjs-auth-router", - "version": "7.0.0-alpha.10", - "description": "Rockets NestJS Auth Guard Router", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "license": "BSD-3-Clause", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" - ], - "dependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", - "@nestjs/swagger": "^11.2.2" - }, - "devDependencies": { - "@concepta/nestjs-auth-google": "^7.0.0-alpha.10", - "@concepta/nestjs-authentication": "^7.0.0-alpha.10", - "@nestjs/testing": "^11.1.9", - "@types/express": "^4.17.21", - "supertest": "^6.3.4" - }, - "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", - "rxjs": "^7.8.1", - "typeorm": "^0.3.0" - } -} diff --git a/packages/nestjs-auth-router/src/__fixtures__/auth-router.controller.fixture.ts b/packages/nestjs-auth-router/src/__fixtures__/auth-router.controller.fixture.ts deleted file mode 100644 index e0d7e3d35..000000000 --- a/packages/nestjs-auth-router/src/__fixtures__/auth-router.controller.fixture.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Controller, Get, Post, UseGuards } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; - -import { - AuthenticationJwtResponseDto, - AuthPublic, - AuthUser, -} from '@concepta/nestjs-authentication'; -import { AuthenticatedUserInterface } from '@concepta/nestjs-common'; - -import { AuthRouterGuard } from '../auth-router.guard'; - -@Controller('auth-router') -@UseGuards(AuthRouterGuard) -@AuthPublic() -@ApiTags('auth') -export class AuthRouterControllerFixture { - constructor() {} - - /** - * Login - */ - @ApiOkResponse({ - description: 'Users are redirected to request their Auth Router identity.', - }) - @Get('login') - login(): void { - // TODO: no code needed, Decorator will redirect to google - return; - } - - @ApiOkResponse({ - type: AuthenticationJwtResponseDto, - description: 'DTO containing an access token and a refresh token.', - }) - @Get('callback') - async callback(@AuthUser() _user: AuthenticatedUserInterface) { - return { - ok: 'success', - }; - } - - @ApiOkResponse({ - type: AuthenticationJwtResponseDto, - description: 'DTO containing an access token and a refresh token.', - }) - @Post('callback') - async postCallback(@AuthUser() _user: AuthenticatedUserInterface) { - return { - ok: 'success', - }; - } -} diff --git a/packages/nestjs-auth-router/src/auth-router.constants.ts b/packages/nestjs-auth-router/src/auth-router.constants.ts deleted file mode 100644 index 502620368..000000000 --- a/packages/nestjs-auth-router/src/auth-router.constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const AUTH_ROUTER_ISSUE_TOKEN_SERVICE_TOKEN = - 'AUTH_ROUTER_ISSUE_TOKEN_SERVICE_TOKEN'; - -export const AUTH_ROUTER_MODULE_SETTINGS_TOKEN = - 'AUTH_ROUTER_MODULE_SETTINGS_TOKEN'; - -export const AUTH_ROUTER_MODULE_DEFAULT_SETTINGS_TOKEN = - 'AUTH_ROUTER_MODULE_DEFAULT_SETTINGS_TOKEN'; - -export const AUTH_ROUTER_CONFIG_TOKEN = 'AUTH_ROUTER_CONFIG_TOKEN'; - -export const AuthRouterModuleGuards = Symbol('AUTH_ROUTER_MODULE_GUARDS_TOKEN'); - -export const AUTH_ROUTER_STRATEGY_NAME = 'auth-router'; diff --git a/packages/nestjs-auth-router/src/auth-router.controller.e2e-spec.ts b/packages/nestjs-auth-router/src/auth-router.controller.e2e-spec.ts deleted file mode 100644 index 277c091f3..000000000 --- a/packages/nestjs-auth-router/src/auth-router.controller.e2e-spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import supertest from 'supertest'; - -import { INestApplication, CanActivate } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { AuthRouterModuleGuards } from './auth-router.constants'; -import { AuthRouterModule } from './auth-router.module'; - -import { AuthRouterFixtureGuard } from './__fixtures__/auth-router-fixture.guards'; -import { AuthRouterControllerFixture } from './__fixtures__/auth-router.controller.fixture'; - -describe('AuthRouterController (e2e)', () => { - let app: INestApplication; - let moduleFixture: TestingModule; - let guardsRecord: { google: CanActivate }; - - beforeAll(async () => { - moduleFixture = await Test.createTestingModule({ - imports: [ - AuthRouterModule.forRoot({ - guards: [ - { - name: 'google', - guard: AuthRouterFixtureGuard, - }, - ], - }), - ], - controllers: [AuthRouterControllerFixture], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - - // Get the guards record from the module - guardsRecord = moduleFixture.get(AuthRouterModuleGuards); - }); - - afterAll(async () => { - await app.close(); - }); - - describe(AuthRouterControllerFixture.prototype.login, () => { - it('should call the Auth Router guard and return successfully when provider is specified', async () => { - const googleGuard = guardsRecord.google; - const guardSpy = jest.spyOn(googleGuard, 'canActivate'); - - await supertest(app.getHttpServer()) - .get('/auth-router/login?provider=google') - .expect(200); - - // Verify the guard was called - expect(guardSpy).toHaveBeenCalled(); - - // Verify the guard received the correct execution context - const executionContext = guardSpy.mock.calls[0][0]; - const httpRequest = executionContext.switchToHttp().getRequest(); - expect(httpRequest.query.provider).toBe('google'); - }); - - it('should return 500 when provider is missing (Auth Router exception)', async () => { - await supertest(app.getHttpServer()) - .get('/auth-router/login') - .expect(500); - }); - - it('should return 500 when provider is not supported (Auth Router exception)', async () => { - await supertest(app.getHttpServer()) - .get('/auth-router/login?provider=unsupported') - .expect(500); - }); - }); - - describe(AuthRouterControllerFixture.prototype.callback, () => { - it('should call the Auth Router guard and return success response when provider is specified', async () => { - const googleGuard = guardsRecord.google; - const guardSpy = jest.spyOn(googleGuard, 'canActivate'); - - const response = await supertest(app.getHttpServer()) - .get('/auth-router/callback?provider=google') - .expect(200); - - // Verify the guard was called - expect(guardSpy).toHaveBeenCalled(); - - // Verify the response contains the expected data - expect(response.body).toEqual({ ok: 'success' }); - - // Verify the guard received the correct execution context - const executionContext = guardSpy.mock.calls[0][0]; - const httpRequest = executionContext.switchToHttp().getRequest(); - expect(httpRequest.query.provider).toBe('google'); - - // Verify the user was attached by the guard - expect(httpRequest.user).toBeDefined(); - expect(httpRequest.user.id).toBe('fixture-user-allow'); - expect(httpRequest.user.provider).toBe('google'); - }); - }); -}); diff --git a/packages/nestjs-auth-router/src/auth-router.guard.spec.ts b/packages/nestjs-auth-router/src/auth-router.guard.spec.ts deleted file mode 100644 index 84ce3e77b..000000000 --- a/packages/nestjs-auth-router/src/auth-router.guard.spec.ts +++ /dev/null @@ -1,467 +0,0 @@ -import { ExecutionContext, CanActivate } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { AuthRouterModuleGuards } from './auth-router.constants'; -import { AuthRouterGuard } from './auth-router.guard'; -import { AuthRouterAuthenticationFailedException } from './exceptions/auth-router-authentication-failed.exception'; -import { AuthRouterConfigNotAvailableException } from './exceptions/auth-router-config-not-available.exception'; -import { AuthRouterGuardInvalidException } from './exceptions/auth-router-guard-invalid.exception'; -import { AuthRouterProviderMissingException } from './exceptions/auth-router-provider-missing.exception'; -import { AuthRouterProviderNotSupportedException } from './exceptions/auth-router-provider-not-supported.exception'; - -// Mock guard classes for testing -class MockSuccessGuard implements CanActivate { - canActivate(_context: ExecutionContext): boolean { - return true; - } -} - -class MockFailureGuard implements CanActivate { - canActivate(_context: ExecutionContext): boolean { - return false; - } -} - -class MockAsyncSuccessGuard implements CanActivate { - canActivate(_context: ExecutionContext): Promise { - return Promise.resolve(true); - } -} - -class MockErrorGuard implements CanActivate { - canActivate(_context: ExecutionContext): boolean { - throw new Error('Mock guard error'); - } -} - -class MockAsyncErrorGuard implements CanActivate { - canActivate(_context: ExecutionContext): Promise { - return Promise.reject(new Error('Mock async guard error')); - } -} - -describe(AuthRouterGuard.name, () => { - let guard: AuthRouterGuard; - let mockExecutionContext: ExecutionContext; - let mockAuthRouterGuards: Record; - - const createMockExecutionContext = (provider?: string): ExecutionContext => { - const mockRequest = { - query: { provider }, - }; - - return { - switchToHttp: () => ({ - getRequest: () => mockRequest, - getResponse: () => ({}), - getNext: () => () => {}, - }), - getClass: () => class {}, - getHandler: () => () => {}, - getArgs: () => [], - getArgByIndex: () => undefined, - switchToRpc: () => ({ - getContext: () => ({}), - getData: () => ({}), - }), - switchToWs: () => ({ - getClient: () => ({}), - getData: () => ({}), - getPattern: () => undefined, - }), - getType: () => 'http', - } as unknown as ExecutionContext; - }; - - beforeEach(async () => { - mockAuthRouterGuards = { - google: new MockSuccessGuard(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AuthRouterGuard, - { - provide: AuthRouterModuleGuards, - useValue: mockAuthRouterGuards, - }, - ], - }).compile(); - - guard = module.get(AuthRouterGuard); - }); - - describe('Guard Instance', () => { - it('should be defined', () => { - expect(guard).toBeDefined(); - }); - - it('should be an instance of AuthRouter', () => { - expect(guard).toBeInstanceOf(AuthRouterGuard); - }); - }); - - describe('canActivate - Provider Validation', () => { - it('should throw AuthRouterProviderMissingException when provider is missing', async () => { - mockExecutionContext = createMockExecutionContext(); - - try { - await guard.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterProviderMissingException); - } - }); - - it('should throw AuthRouterProviderMissingException when provider is empty string', async () => { - mockExecutionContext = createMockExecutionContext(''); - - try { - await guard.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterProviderMissingException); - } - }); - - it('should throw AuthRouterProviderMissingException when provider is null', async () => { - mockExecutionContext = createMockExecutionContext( - null as unknown as string, - ); - - try { - await guard.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterProviderMissingException); - } - }); - - it('should throw AuthRouterProviderMissingException when provider is undefined', async () => { - mockExecutionContext = createMockExecutionContext(undefined); - - try { - await guard.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterProviderMissingException); - } - }); - }); - - describe('canActivate - Guards Configuration Validation', () => { - it('should throw AuthRouterConfigNotAvailableException when guards record is not found', async () => { - mockExecutionContext = createMockExecutionContext('google'); - - const guardWithoutGuards = new AuthRouterGuard( - null as unknown as Record, - ); - - try { - await guardWithoutGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterConfigNotAvailableException); - } - }); - - it('should throw AuthRouterConfigNotAvailableException when guards record is undefined', async () => { - mockExecutionContext = createMockExecutionContext('google'); - - const guardWithUndefinedGuards = new AuthRouterGuard( - undefined as unknown as Record, - ); - - try { - await guardWithUndefinedGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterConfigNotAvailableException); - } - }); - - it('should throw AuthRouterConfigNotAvailableException when guards record is not an object', async () => { - mockExecutionContext = createMockExecutionContext('google'); - - const guardWithInvalidGuards = new AuthRouterGuard( - 'not an object' as unknown as Record, - ); - - try { - await guardWithInvalidGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterConfigNotAvailableException); - } - }); - }); - - describe('canActivate - Provider Support Validation', () => { - it('should throw AuthRouterProviderNotSupportedException when provider is not in guards record', async () => { - mockExecutionContext = createMockExecutionContext('unsupported'); - mockAuthRouterGuards = { - google: new MockSuccessGuard(), - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - - try { - await guardWithGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterProviderNotSupportedException); - } - }); - - it('should throw AuthRouterProviderNotSupportedException with correct provider name', async () => { - mockExecutionContext = createMockExecutionContext('facebook'); - mockAuthRouterGuards = { - google: new MockSuccessGuard(), - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - - try { - await guardWithGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterProviderNotSupportedException); - expect( - (error as AuthRouterProviderNotSupportedException).safeMessage, - ).toContain('facebook'); - } - }); - }); - - describe('canActivate - Guard Instance Validation', () => { - it('should throw AuthRouterGuardInvalidException when guard instance canActivate is not a function', async () => { - mockExecutionContext = createMockExecutionContext('google'); - mockAuthRouterGuards = { - google: { - canActivate: 'not a function', - } as unknown as CanActivate, - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - - try { - await guardWithGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterGuardInvalidException); - } - }); - }); - - describe('canActivate - Guard Execution Success Cases', () => { - it('should return true when guard returns boolean true', async () => { - mockExecutionContext = createMockExecutionContext('google'); - mockAuthRouterGuards = { - google: new MockSuccessGuard(), - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - const result = await guardWithGuards.canActivate(mockExecutionContext); - - expect(result).toBe(true); - }); - - it('should return false when guard returns boolean false', async () => { - mockExecutionContext = createMockExecutionContext('google'); - mockAuthRouterGuards = { - google: new MockFailureGuard(), - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - const result = await guardWithGuards.canActivate(mockExecutionContext); - - expect(result).toBe(false); - }); - - it('should return true when guard returns Promise', async () => { - mockExecutionContext = createMockExecutionContext('google'); - mockAuthRouterGuards = { - google: new MockAsyncSuccessGuard(), - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - const result = await guardWithGuards.canActivate(mockExecutionContext); - - expect(result).toBe(true); - }); - }); - - describe('canActivate - Guard Execution Error Cases', () => { - it('should throw AuthRouterAuthenticationFailedException when guard throws error', async () => { - mockExecutionContext = createMockExecutionContext('google'); - mockAuthRouterGuards = { - google: new MockErrorGuard(), - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - - try { - await guardWithGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterAuthenticationFailedException); - } - }); - - it('should throw AuthRouterAuthenticationFailedException when async guard throws error', async () => { - mockExecutionContext = createMockExecutionContext('google'); - mockAuthRouterGuards = { - google: new MockAsyncErrorGuard(), - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - - try { - await guardWithGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterAuthenticationFailedException); - } - }); - - it('should include provider name in AuthRouterAuthenticationFailedException', async () => { - mockExecutionContext = createMockExecutionContext('github'); - mockAuthRouterGuards = { - github: new MockErrorGuard(), - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - - try { - await guardWithGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterAuthenticationFailedException); - expect( - (error as AuthRouterAuthenticationFailedException).safeMessage, - ).toContain('github'); - expect( - (error as AuthRouterAuthenticationFailedException).safeMessage, - ).toContain('Mock guard error'); - } - }); - - it('should handle unknown error types in AuthRouterAuthenticationFailedException', async () => { - mockExecutionContext = createMockExecutionContext('google'); - mockAuthRouterGuards = { - google: { - canActivate: () => { - throw 'String error'; // Non-Error object - }, - } as unknown as CanActivate, - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - - try { - await guardWithGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterAuthenticationFailedException); - expect( - (error as AuthRouterAuthenticationFailedException).safeMessage, - ).toContain('Unknown error'); - } - }); - }); - - describe('canActivate - Exception Re-throwing', () => { - it('should re-throw AuthRouterProviderMissingException without wrapping', async () => { - mockExecutionContext = createMockExecutionContext(); - - try { - await guard.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterProviderMissingException); - } - }); - - it('should re-throw AuthRouterConfigNotAvailableException without wrapping', async () => { - mockExecutionContext = createMockExecutionContext('google'); - - const guardWithoutGuards = new AuthRouterGuard( - null as unknown as Record, - ); - - try { - await guardWithoutGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterConfigNotAvailableException); - } - }); - - it('should re-throw AuthRouterProviderNotSupportedException without wrapping', async () => { - mockExecutionContext = createMockExecutionContext('unsupported'); - mockAuthRouterGuards = { - google: new MockSuccessGuard(), - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - - try { - await guardWithGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterProviderNotSupportedException); - } - }); - - it('should re-throw AuthRouterGuardInvalidException without wrapping', async () => { - mockExecutionContext = createMockExecutionContext('google'); - mockAuthRouterGuards = { - google: { - canActivate: 'not a function', - } as unknown as CanActivate, - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - - try { - await guardWithGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterGuardInvalidException); - } - }); - }); - - describe('canActivate - Edge Cases', () => { - it('should handle multiple providers in guards record correctly', async () => { - mockExecutionContext = createMockExecutionContext('github'); - mockAuthRouterGuards = { - google: new MockSuccessGuard(), - github: new MockFailureGuard(), - facebook: new MockSuccessGuard(), - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - const result = await guardWithGuards.canActivate(mockExecutionContext); - - expect(result).toBe(false); // github guard returns false - }); - - it('should handle provider name case sensitivity', async () => { - mockExecutionContext = createMockExecutionContext('Google'); - mockAuthRouterGuards = { - google: new MockSuccessGuard(), - }; - - const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); - - try { - await guardWithGuards.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterProviderNotSupportedException); - } - }); - - it('should handle empty provider name', async () => { - mockExecutionContext = createMockExecutionContext(' '); - - try { - await guard.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterProviderMissingException); - } - }); - - it('should handle whitespace-only provider name', async () => { - mockExecutionContext = createMockExecutionContext(' '); - - try { - await guard.canActivate(mockExecutionContext); - } catch (error: unknown) { - expect(error).toBeInstanceOf(AuthRouterProviderMissingException); - } - }); - }); -}); diff --git a/packages/nestjs-auth-router/src/auth-router.guard.ts b/packages/nestjs-auth-router/src/auth-router.guard.ts deleted file mode 100644 index 0db7ebe11..000000000 --- a/packages/nestjs-auth-router/src/auth-router.guard.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { firstValueFrom, isObservable } from 'rxjs'; - -import { - CanActivate, - Injectable, - ExecutionContext, - Inject, -} from '@nestjs/common'; - -import { AuthRouterModuleGuards } from './auth-router.constants'; -import { AuthRouterGuardsRecord } from './auth-router.types'; -import { AuthRouterAuthenticationFailedException } from './exceptions/auth-router-authentication-failed.exception'; -import { AuthRouterConfigNotAvailableException } from './exceptions/auth-router-config-not-available.exception'; -import { AuthRouterGuardInvalidException } from './exceptions/auth-router-guard-invalid.exception'; -import { AuthRouterProviderMissingException } from './exceptions/auth-router-provider-missing.exception'; -import { AuthRouterProviderNotSupportedException } from './exceptions/auth-router-provider-not-supported.exception'; -import { AuthRouterException } from './exceptions/auth-router.exception'; - -/** - * Auth Router - * - * This guard is responsible for handling Auth Router authentication by delegating - * to provider-specific guards based on the 'provider' query parameter. - */ -@Injectable() -export class AuthRouterGuard implements CanActivate { - constructor( - @Inject(AuthRouterModuleGuards) - private readonly allAuthRouterGuards: AuthRouterGuardsRecord, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const provider = request.query?.provider as string; - const code = request.query?.code as string; - const state = request.query?.state as string; - - // Handle callback case (when code is present) - if (code) { - let callbackProvider = provider; - - // If no provider in query, try to extract from state parameter - if (!callbackProvider && state) { - try { - // The state parameter might be a JSON string containing provider info - const stateData = JSON.parse(state); - callbackProvider = stateData.provider; - } catch (_error) { - // Ignore parse errors - } - } - - if (!callbackProvider) { - throw new AuthRouterProviderMissingException(); - } - - // Now proceed with the provider-specific guard - return this.executeProviderGuard(callbackProvider?.trim(), context); - } - - // Handle initial authorization request - if (!provider) { - throw new AuthRouterProviderMissingException(); - } - - const trimmedProvider = provider.trim(); - if (!trimmedProvider) { - throw new AuthRouterProviderMissingException(); - } - - return this.executeProviderGuard(trimmedProvider, context); - } - - private async executeProviderGuard( - provider: string, - context: ExecutionContext, - ): Promise { - try { - if ( - !this.allAuthRouterGuards || - typeof this.allAuthRouterGuards !== 'object' - ) { - throw new AuthRouterConfigNotAvailableException(); - } - - const guardInstance = this.getProviderGuard(provider); - const result = guardInstance.canActivate(context); - - // Handle Observable, Promise, or boolean return types - if (isObservable(result)) { - const observableResult = await firstValueFrom(result); - return Boolean(observableResult); - } else if (result instanceof Promise) { - const promiseResult = await result; - return Boolean(promiseResult); - } else { - return Boolean(result); - } - } catch (error) { - // Re-throw our custom Auth Router exceptions - if (error instanceof AuthRouterException) { - throw error; - } - - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - throw new AuthRouterAuthenticationFailedException(provider, errorMessage); - } - } - - /** - * Get the guard instance for the given provider. - * Similar to CacheService.getAssignmentRepo() - * - * @internal - * @param provider - The Auth Router provider name - */ - protected getProviderGuard(provider: string): CanActivate { - // Get the guard instance from the injected guards record - const guardInstance = this.allAuthRouterGuards[provider]; - - if (!guardInstance) { - throw new AuthRouterProviderNotSupportedException(provider); - } - - if (typeof guardInstance.canActivate !== 'function') { - throw new AuthRouterGuardInvalidException(provider); - } - - return guardInstance; - } -} diff --git a/packages/nestjs-auth-router/src/auth-router.module-definition.ts b/packages/nestjs-auth-router/src/auth-router.module-definition.ts deleted file mode 100644 index 9ee62cee9..000000000 --- a/packages/nestjs-auth-router/src/auth-router.module-definition.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - ConfigurableModuleBuilder, - DynamicModule, - Provider, - CanActivate, -} from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; - -import { createSettingsProvider } from '@concepta/nestjs-common'; - -import { - AUTH_ROUTER_MODULE_SETTINGS_TOKEN, - AuthRouterModuleGuards, -} from './auth-router.constants'; -import { AuthRouterGuardsRecord } from './auth-router.types'; -import { authRouterDefaultConfig } from './config/auth-router-default.config'; -import { AuthRouterOptionsExtrasInterface } from './interfaces/auth-router-options-extras.interface'; -import { AuthRouterOptionsInterface } from './interfaces/auth-router-options.interface'; -import { AuthRouterSettingsInterface } from './interfaces/auth-router-settings.interface'; - -const RAW_OPTIONS_TOKEN = Symbol('__AUTH_ROUTER_MODULE_RAW_OPTIONS_TOKEN__'); - -export const { - ConfigurableModuleClass: AuthRouterModuleClass, - OPTIONS_TYPE: AUTH_ROUTER_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: AUTH_ROUTER_ASYNC_OPTIONS_TYPE, -} = new ConfigurableModuleBuilder({ - moduleName: 'AuthRouter', - optionsInjectionToken: RAW_OPTIONS_TOKEN, -}) - .setExtras( - { global: false, guards: [] }, - definitionTransform, - ) - .build(); - -export type AuthRouterOptions = Omit; -export type AuthRouterAsyncOptions = Omit< - typeof AUTH_ROUTER_ASYNC_OPTIONS_TYPE, - 'global' ->; - -function definitionTransform( - definition: DynamicModule, - extras: AuthRouterOptionsExtrasInterface, -): DynamicModule { - const { providers = [] } = definition; - const { global = false } = extras; - - return { - ...definition, - global, - imports: createAuthRouterImports(), - providers: createAuthRouterProviders({ providers, extras }), - exports: [ - ConfigModule, - RAW_OPTIONS_TOKEN, - ...createAuthRouterExports(extras), - ], - }; -} - -export function createAuthRouterImports(): DynamicModule['imports'] { - return [ConfigModule.forFeature(authRouterDefaultConfig)]; -} - -export function createAuthRouterExports( - extras?: AuthRouterOptionsExtrasInterface, -) { - return [ - AUTH_ROUTER_MODULE_SETTINGS_TOKEN, - AuthRouterModuleGuards, - ...(extras?.guards?.map((config) => config.guard) ?? []), - ]; -} - -export function createAuthRouterProviders(options: { - overrides?: AuthRouterOptions; - providers?: Provider[]; - extras?: AuthRouterOptionsExtrasInterface; -}): Provider[] { - return [ - ...(options.providers ?? []), - createAuthRouterSettingsProvider(options.overrides), - ...createAuthRouterGuardsProvider(options.extras), - ]; -} - -export function createAuthRouterSettingsProvider( - optionsOverrides?: AuthRouterOptions, -): Provider { - return createSettingsProvider< - AuthRouterSettingsInterface, - AuthRouterOptionsInterface - >({ - settingsToken: AUTH_ROUTER_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: authRouterDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createAuthRouterGuardsProvider( - extras?: AuthRouterOptionsExtrasInterface, -): Provider[] { - const { guards = [] } = extras || {}; - - // Get unique guard classes to inject - const guardsToInject = []; - const providerTracker: Record = {}; - - let guardIdx = 0; - - for (const guardConfig of guards) { - guardsToInject[guardIdx] = guardConfig.guard; - providerTracker[guardConfig.name] = guardIdx++; - } - - return [ - // Register each guard as a provider - ...guards.map((config) => config.guard), - // Create the guards record provider - { - provide: AuthRouterModuleGuards, - inject: guardsToInject, - useFactory: (...args: CanActivate[]): AuthRouterGuardsRecord => { - const guardInstances: AuthRouterGuardsRecord = {}; - - for (const guardConfig of guards) { - guardInstances[guardConfig.name] = - args[providerTracker[guardConfig.name]]; - } - - return guardInstances; - }, - }, - ]; -} diff --git a/packages/nestjs-auth-router/src/auth-router.module.spec.ts b/packages/nestjs-auth-router/src/auth-router.module.spec.ts deleted file mode 100644 index ae5d90750..000000000 --- a/packages/nestjs-auth-router/src/auth-router.module.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { AuthGoogleGuard } from '@concepta/nestjs-auth-google'; -import { AuthGuard } from '@concepta/nestjs-authentication'; - -import { AuthRouterModuleGuards } from './auth-router.constants'; -import { AuthRouterModule } from './auth-router.module'; - -import { AuthRouterFixtureGuard } from './__fixtures__/auth-router-fixture.guards'; - -@Injectable() -export class AuthGoogleGuardTest extends AuthGuard('google', { - canDisable: false, -}) {} - -describe(AuthRouterModule, () => { - let authRouterModule: AuthRouterModule; - - describe(AuthRouterModule.forRoot, () => { - it('module should be loaded with google guard', async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [ - AuthRouterModule.forRoot({ - guards: [ - { - name: 'auth-google', - guard: AuthGoogleGuard, - }, - { - name: 'google', - guard: AuthRouterFixtureGuard, - }, - { - name: 'google-passport', - guard: AuthGoogleGuardTest, - }, - ], - }), - ], - }).compile(); - - authRouterModule = module.get(AuthRouterModule); - expect(authRouterModule).toBeInstanceOf(AuthRouterModule); - - const guardsToken = module.get(AuthRouterModuleGuards); - expect(guardsToken.google).toBeInstanceOf(AuthRouterFixtureGuard); - expect(guardsToken['google-passport']).toBeInstanceOf( - AuthGoogleGuardTest, - ); - }); - }); -}); diff --git a/packages/nestjs-auth-router/src/auth-router.module.ts b/packages/nestjs-auth-router/src/auth-router.module.ts deleted file mode 100644 index 2edb95d4a..000000000 --- a/packages/nestjs-auth-router/src/auth-router.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { DynamicModule, Module } from '@nestjs/common'; - -import { - AuthRouterAsyncOptions, - AuthRouterModuleClass, - AuthRouterOptions, -} from './auth-router.module-definition'; - -/** - * Auth Router module - */ -@Module({}) -export class AuthRouterModule extends AuthRouterModuleClass { - static register(options: AuthRouterOptions): DynamicModule { - return super.register(options); - } - - static registerAsync(options: AuthRouterAsyncOptions): DynamicModule { - return super.registerAsync(options); - } - - static forRoot(options: AuthRouterOptions): DynamicModule { - return super.register({ ...options, global: true }); - } - - static forRootAsync(options: AuthRouterAsyncOptions): DynamicModule { - return super.registerAsync({ ...options, global: true }); - } -} diff --git a/packages/nestjs-auth-router/src/auth-router.types.ts b/packages/nestjs-auth-router/src/auth-router.types.ts deleted file mode 100644 index 4f73b3f9a..000000000 --- a/packages/nestjs-auth-router/src/auth-router.types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { CanActivate } from '@nestjs/common'; - -export type AuthRouterGuardsRecord = Record; diff --git a/packages/nestjs-auth-router/src/config/auth-router-default.config.ts b/packages/nestjs-auth-router/src/config/auth-router-default.config.ts deleted file mode 100644 index 864870bab..000000000 --- a/packages/nestjs-auth-router/src/config/auth-router-default.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { AUTH_ROUTER_MODULE_DEFAULT_SETTINGS_TOKEN } from '../auth-router.constants'; -import { AuthRouterSettingsInterface } from '../interfaces/auth-router-settings.interface'; - -/** - * Default configuration for auth router. - */ -export const authRouterDefaultConfig = registerAs( - AUTH_ROUTER_MODULE_DEFAULT_SETTINGS_TOKEN, - (): AuthRouterSettingsInterface => ({}), -); diff --git a/packages/nestjs-auth-router/src/exceptions/auth-router-authentication-failed.exception.ts b/packages/nestjs-auth-router/src/exceptions/auth-router-authentication-failed.exception.ts deleted file mode 100644 index dddf24084..000000000 --- a/packages/nestjs-auth-router/src/exceptions/auth-router-authentication-failed.exception.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthRouterException } from './auth-router.exception'; - -export class AuthRouterAuthenticationFailedException extends AuthRouterException { - constructor( - provider: string, - errorMessage: string, - options?: RuntimeExceptionOptions, - ) { - super({ - safeMessage: `Auth Router authentication failed for provider '${provider}': ${errorMessage}`, - ...options, - }); - - this.errorCode = 'AUTH_ROUTER_AUTHENTICATION_FAILED_ERROR'; - } -} diff --git a/packages/nestjs-auth-router/src/exceptions/auth-router-config-not-available.exception.ts b/packages/nestjs-auth-router/src/exceptions/auth-router-config-not-available.exception.ts deleted file mode 100644 index 901b13399..000000000 --- a/packages/nestjs-auth-router/src/exceptions/auth-router-config-not-available.exception.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthRouterException } from './auth-router.exception'; - -export class AuthRouterConfigNotAvailableException extends AuthRouterException { - constructor(options?: RuntimeExceptionOptions) { - super({ - safeMessage: 'Auth Router configuration is not available or invalid.', - ...options, - }); - - this.errorCode = 'AUTH_ROUTER_CONFIG_NOT_AVAILABLE_ERROR'; - } -} diff --git a/packages/nestjs-auth-router/src/exceptions/auth-router-guard-invalid.exception.ts b/packages/nestjs-auth-router/src/exceptions/auth-router-guard-invalid.exception.ts deleted file mode 100644 index 9eaea09bd..000000000 --- a/packages/nestjs-auth-router/src/exceptions/auth-router-guard-invalid.exception.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthRouterException } from './auth-router.exception'; - -export class AuthRouterGuardInvalidException extends AuthRouterException { - constructor(provider: string, options?: RuntimeExceptionOptions) { - super({ - safeMessage: `Invalid guard configuration for Auth Router provider '${provider}'.`, - ...options, - }); - - this.errorCode = 'AUTH_ROUTER_GUARD_INVALID_ERROR'; - } -} diff --git a/packages/nestjs-auth-router/src/exceptions/auth-router-guard-not-configured.exception.ts b/packages/nestjs-auth-router/src/exceptions/auth-router-guard-not-configured.exception.ts deleted file mode 100644 index f7ab36a7b..000000000 --- a/packages/nestjs-auth-router/src/exceptions/auth-router-guard-not-configured.exception.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthRouterException } from './auth-router.exception'; - -export class AuthRouterGuardNotConfiguredException extends AuthRouterException { - constructor(provider: string, options?: RuntimeExceptionOptions) { - super({ - safeMessage: `No guard configured for Auth Router provider '${provider}'.`, - ...options, - }); - - this.errorCode = 'AUTH_ROUTER_GUARD_NOT_CONFIGURED_ERROR'; - } -} diff --git a/packages/nestjs-auth-router/src/exceptions/auth-router-provider-missing.exception.ts b/packages/nestjs-auth-router/src/exceptions/auth-router-provider-missing.exception.ts deleted file mode 100644 index 8d19e4383..000000000 --- a/packages/nestjs-auth-router/src/exceptions/auth-router-provider-missing.exception.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthRouterException } from './auth-router.exception'; - -export class AuthRouterProviderMissingException extends AuthRouterException { - constructor(options?: RuntimeExceptionOptions) { - super({ - safeMessage: - 'Auth Router provider is required in the request query parameters.', - ...options, - }); - - this.errorCode = 'AUTH_ROUTER_PROVIDER_MISSING_ERROR'; - } -} diff --git a/packages/nestjs-auth-router/src/exceptions/auth-router-provider-not-supported.exception.ts b/packages/nestjs-auth-router/src/exceptions/auth-router-provider-not-supported.exception.ts deleted file mode 100644 index 0eb130098..000000000 --- a/packages/nestjs-auth-router/src/exceptions/auth-router-provider-not-supported.exception.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthRouterException } from './auth-router.exception'; - -export class AuthRouterProviderNotSupportedException extends AuthRouterException { - constructor(provider: string, options?: RuntimeExceptionOptions) { - super({ - safeMessage: `Auth Router provider '${provider}' is not supported.`, - ...options, - }); - - this.errorCode = 'AUTH_ROUTER_PROVIDER_NOT_SUPPORTED_ERROR'; - } -} diff --git a/packages/nestjs-auth-router/src/index.ts b/packages/nestjs-auth-router/src/index.ts deleted file mode 100644 index 147a7dabf..000000000 --- a/packages/nestjs-auth-router/src/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { AuthRouterModule } from './auth-router.module'; - -export { AuthRouterGuard } from './auth-router.guard'; - -export { AuthRouterGuardsRecord } from './auth-router.types'; - -export { AuthRouterException } from './exceptions/auth-router.exception'; - -// Export configuration types -export { - AuthRouterOptions, - AuthRouterAsyncOptions, -} from './auth-router.module-definition'; -// Export interfaces -export { AuthRouterOptionsInterface } from './interfaces/auth-router-options.interface'; -export { AuthRouterSettingsInterface } from './interfaces/auth-router-settings.interface'; -export { AuthRouterOptionsExtrasInterface } from './interfaces/auth-router-options-extras.interface'; -export { AuthRouterGuardConfigInterface } from './interfaces/auth-router-guard-config.interface'; - -export { AuthRouterModuleGuards } from './auth-router.constants'; diff --git a/packages/nestjs-auth-router/src/interfaces/auth-router-guard-config.interface.ts b/packages/nestjs-auth-router/src/interfaces/auth-router-guard-config.interface.ts deleted file mode 100644 index db7dfec18..000000000 --- a/packages/nestjs-auth-router/src/interfaces/auth-router-guard-config.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { CanActivate, Type } from '@nestjs/common'; - -export interface AuthRouterGuardConfigInterface { - name: string; - guard: Type; -} diff --git a/packages/nestjs-auth-router/src/interfaces/auth-router-options-extras.interface.ts b/packages/nestjs-auth-router/src/interfaces/auth-router-options-extras.interface.ts deleted file mode 100644 index 91694aa2d..000000000 --- a/packages/nestjs-auth-router/src/interfaces/auth-router-options-extras.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -import { AuthRouterGuardConfigInterface } from './auth-router-guard-config.interface'; - -export interface AuthRouterOptionsExtrasInterface - extends Pick { - guards: AuthRouterGuardConfigInterface[]; -} diff --git a/packages/nestjs-auth-router/src/interfaces/auth-router-options.interface.ts b/packages/nestjs-auth-router/src/interfaces/auth-router-options.interface.ts deleted file mode 100644 index 4045a53c3..000000000 --- a/packages/nestjs-auth-router/src/interfaces/auth-router-options.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { AuthRouterSettingsInterface } from './auth-router-settings.interface'; - -export interface AuthRouterOptionsInterface { - settings?: AuthRouterSettingsInterface; -} diff --git a/packages/nestjs-auth-router/src/interfaces/auth-router-settings.interface.ts b/packages/nestjs-auth-router/src/interfaces/auth-router-settings.interface.ts deleted file mode 100644 index 92cbab34c..000000000 --- a/packages/nestjs-auth-router/src/interfaces/auth-router-settings.interface.ts +++ /dev/null @@ -1 +0,0 @@ -export interface AuthRouterSettingsInterface {} diff --git a/packages/nestjs-auth-router/tsconfig.json b/packages/nestjs-auth-router/tsconfig.json deleted file mode 100644 index 9c2c3ffd9..000000000 --- a/packages/nestjs-auth-router/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": ["./node_modules/@types", "../../node_modules/@types"] - }, - "include": [ - "src/**/*.ts", - ] -} diff --git a/packages/nestjs-auth-router/typedoc.json b/packages/nestjs-auth-router/typedoc.json deleted file mode 100644 index 944fda5ad..000000000 --- a/packages/nestjs-auth-router/typedoc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "entryPoints": ["src/index.ts"] -} \ No newline at end of file diff --git a/packages/nestjs-auth-verify/README.md b/packages/nestjs-auth-verify/README.md deleted file mode 100644 index df7ee28cf..000000000 --- a/packages/nestjs-auth-verify/README.md +++ /dev/null @@ -1,604 +0,0 @@ -# Rockets NestJS Auth verify Authentication - -Verify user password using email - -## Project - -[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-auth-verify)](https://www.npmjs.com/package/@concepta/nestjs-auth-verify) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-auth-verify)](https://www.npmjs.com/package/@concepta/nestjs-auth-verify) -[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) -[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) - -## Table of Contents - -- [Tutorials](#tutorials) - - [Introduction](#introduction) - - [Email Configuration](#email-configuration) - - [Setup Auth Verify Module](#setup-auth-verify-module) - -- [How-To Guides](#how-to-guides) - - [1. How to define the AuthVerifySettings](#1-how-to-define-the-authverifysettings) - - [2. How to define the OtpService](#2-how-to-define-the-otpservice) - - [3. How to define EmailService](#3-how-to-define-the-emailservice) - - [4. How to define the UserModelService](#4-how-to-define-the-usermodelservice) - - [5. How to define the NotificationService](#5-how-to-define-the-notificationservice) - -- [Engineering Concepts](#engineering-concepts) - - [1. Dynamic Configuration Settings](#1-dynamic-configuration-settings) - - [2. Dynamic OTP service](#2-dynamic-otp-service) - - [3. Dynamic Email Service](#3-dynamic-email-service) - - [4. User Model Service](#4-user-model-service) - - [5. Notification Service (Optional)](#5-notification-service-optional) - -## Tutorials - -### Introduction - -The Auth Verify module provides functionality to verify user accounts -via email. Before getting started, ensure you have email sending -capabilities set up in your application. - -The module relies on the following key components: - -- `@nestjs-modules/mailer` for email delivery -- `@concepta/nestjs-email` for email service integration - -The module already implements all the necessary logic for email -verification through these key classes: - -- `AuthVerifyService` - Core service for managing verification -- `AuthVerifyNotificationService` - Handles sending verification emails -- `AuthVerifyController` - Exposes verification endpoints - -Let's walk through setting up the required email configuration first. -Note that you can use any email setup that works for your needs, but for -this tutorial we'll demonstrate using `@concepta/nestjs-email`, -`@nestjs-modules/mailer` and with Mailgun. - -#### Installation - -`yarn add @concepta/nestjs-auth-verify` - -### Email Configuration - -For detailed instructions on setting up email functionality, please -follow the tutorial in the [@concepta/nestjs-email README](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-email#tutorial). - -The tutorial covers: - -- Creating email configuration with mailer options -- Setting up SMTP transport settings -- Configuring email templates -- Initializing the required modules (EmailModule and MailerModule) - -Once you have email properly configured following those instructions, -you can proceed with setting up the verification module below. - -### Setup Auth Verify Module - -```ts -import { AuthVerifySettingsInterface } from '@concepta/nestjs-auth-verify'; -import { registerAs } from '@nestjs/config'; - -/** - * Default configuration for auth verify. - */ -export const authVerifyDefaultConfig = registerAs( - "STARTER_AUTH_VERIFY_MODULE_DEFAULT_SETTINGS_TOKEN", - (): AuthVerifySettingsInterface => ({ - email: { - from: 'from', - baseUrl: 'baseUrl', - templates: { - verifyEmail: { - fileName: `${__dirname}/../${ - process.env?.NODEMAILER_TEMPLATE_PATH ?? 'assets/templates/email' - }/verify.template.hbs`, - subject: 'Password Recovery', - }, - }, - }, - otp: { - assignment: 'userOtp', - category: 'auth-verify', - type: 'uuid', - expiresIn: '24h', - }, - }), -); -``` - -Let's take advantage of the following modules to set up the verify -module: - -- [@concepta/nestjs-user](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-user) - - Provides `UserModelService` to get users and to - update them -- [@concepta/nestjs-otp](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-otp) - - Provides `OtpService` to create one-time passwords -- [@concepta/nestjs-email](https://github.com/conceptadev/rockets/tree/main/packages/nestjs-email) - - Provides `EmailService` to send emails - -Please refer to each module's documentation linked above to see how to -properly set them up in your application. - -Assuming both `@concepta/nestjs-user` and `@concepta/nestjs-otp` are -configured, we can proceed with setting up the verify module. While the -use of these modules is optional, it is recommended to use them to take -advantage of their pre-built services and functionality. - -```ts -import { MailerModule, MailerService } from '@nestjs-modules/mailer'; -import { mailerConfig } from './config/mailer.config'; -import { authVerifyDefaultConfig } from './config/auth-verify-default.config'; -import { AuthVerifyModule } from '@concepta/nestjs-auth-verify'; -import { EmailModule, EmailService } from '@concepta/nestjs-email'; -import { - UserModelService, -} from '@concepta/nestjs-user'; -import { - OtpService, - OtpModule, -} from '@concepta/nestjs-otp'; - -/// ... -@Module({ - imports: [ - /// - AuthVerifyModule.forRootAsync({ - inject: [ - UserModelService, - OtpService, - EmailService, - authVerifyDefaultConfig.KEY, - ], - useFactory: ( - userModelService, - otpService, - emailService, - settings: ConfigType, - ) => ({ - userModelService, - otpService, - emailService, - settings, - }), - }), - //... -} - -``` - -The module exposes the following endpoints: - -- `POST /auth/verify/send` - Sends a verification email to the user -- `PATCH /auth/verify/confirm` - Confirms the verification code received - in the email - -These endpoints handle the email verification flow, allowing users to -verify their email addresses and activate their accounts. - -Once the module is setup, we can send a request to the -`/auth/verify/send` endpoint to send a verify email to the user. - -```ts -curl -X 'POST' \ - 'http://localhost:3001/auth/verify/send' \ - -H 'accept: */*' \ - -H 'Content-Type: application/json' \ - -d '{ - "email": "user-email@email.com" -}' -``` - -An email will be sent to the user containing a verification code. Once -the user receives the email, they can use the verification code to -confirm their email address. To validate the code, send a request to the -`/auth/verify/confirm` endpoint with the code received in the email. - -```ts -curl -X 'PATCH' \ - 'http://localhost:3001/auth/verify/confirm' \ - -H 'accept: */*' \ - -H 'Content-Type: application/json' \ - -d '{ - "passcode": "123455" -}' -``` - -if the code is valid, the user will be verified and the user status will be -updated to active. - -## How-To Guides - -### 1. How to define the AuthVerifySettings - -The `AuthVerifySettingsInterface` allows you to configure various -settings for the authentication verification module. These settings -control the behavior of the verification process, including OTP -configuration and email templates. - -Here's an example of how to define the settings: - -```ts -import { AuthVerifySettingsInterface } from '@concepta/nestjs-auth-verify'; -import { registerAs } from '@nestjs/config'; - -/** - * Default configuration for auth verify. - */ -export const authVerifyDefaultConfig = registerAs( - "YOUR_AUTH_VERIFY_MODULE_DEFAULT_SETTINGS_TOKEN", - (): AuthVerifySettingsInterface => ({ - email: { - from: 'from', - baseUrl: 'baseUrl', - templates: { - verifyEmail: { - fileName: `${__dirname}/../${ - process.env?.NODEMAILER_TEMPLATE_PATH ?? 'assets/templates/email' - }/verify.template.hbs`, - subject: 'Password Recovery', - }, - }, - }, - otp: { - assignment: 'userOtp', - category: 'auth-verify', - type: 'uuid', - expiresIn: '24h', - }, - }), -); -``` - -Now let's add the new settings to our module. - -```ts -uthVerifyModule.forRootAsync({ - inject: [ - //... - authVerifyDefaultConfig.KEY, - ], - useFactory: ( - //... - settings: ConfigType, - ) => ({ - //... - settings, - }), - }), -``` - -### 2. How to define the OtpService - -The `OtpService` is responsible for handling the creation and validation -of one-time passwords (OTP) used in the email verification process. This -service must implement the `AuthVerifyOtpServiceInterface` interface, -which defines the required methods for OTP management. - -The service should handle: - -- Creating new OTP codes when verification emails are requested -- Validating OTP codes submitted during the confirmation step -- Managing OTP expiration and usage limits - -Here's an example of how to implement the OTP service: - -```ts -import { - OtpCreatableInterface, OtpInterface, - ReferenceAssigneeInterface, - ReferenceAssignment, - OtpCreateParamsInterface -} from '@concepta/nestjs-common'; -import { Inject, Injectable } from '@nestjs/common'; -import { Repository } from 'typeorm'; -import { OtpServiceInterface } from '../interfaces/otp-service.interface'; -import { OtpSettingsInterface } from '../interfaces/otp-settings.interface'; -import { - OTP_MODULE_REPOSITORIES_TOKEN, - OTP_MODULE_SETTINGS_TOKEN, -} from '../otp.constants'; - -@Injectable() -export class YourAuthVerifyOtpService implements AuthVerifyOtpServiceInterface { - constructor() {} - - async create( - params: OtpCreateParamsInterface - ): Promise { - // your custom logic to create OTP - } - - async validate( - assignment: ReferenceAssignment, - otp: Pick, - deleteIfValid = false, - ): Promise { - // your custom logic to validate OTP - } - - async clear( - assignment: ReferenceAssignment, - otp: Pick, - ): Promise { - // your custom logic to clear OTP - } -} -``` - -Now let's add the new OTP service to our module. - -```ts -AuthVerifyModule.forRootAsync({ - inject: [ - //... - YourAuthVerifyOtpService, - //... - ], - useFactory: ( - //... - otpService, - //... - ) => ({ - //... - otpService, - //... - }), - }), -``` - -### 3. How to define the EmailService - -```ts -import { EmailSendOptionsInterface } from '@concepta/nestjs-common'; -import { Injectable } from '@nestjs/common'; -import { AuthVerifyEmailServiceInterface } from '@nestjs/nestjs-auth-verify'; - -@Injectable() -export class YourAuthVerifyEmailService - implements AuthVerifyEmailServiceInterface { - constructor() {} - - public async sendMail(dto: EmailSendOptionsInterface): Promise { - // your custom logic to send email - } -} -``` - -Now let's add the new email service to our module. - -```ts -AuthVerifyModule.forRootAsync({ - inject: [ - //... - YourAuthVerifyEmailService, - //... - ], - useFactory: ( - //... - emailService, - //... - ) => ({ - //... - emailService, - //... - }), - }), -``` - -### 4. How to define the UserModelService - -```ts -import { - QueryEmailInterface, - QueryIdInterface, - ReferenceActiveInterface, - ReferenceEmail, - ReferenceId, - ReferenceIdInterface, - ReferenceUsernameInterface, - UpdateOneInterface, -} from '@concepta/nestjs-common'; -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class YourAuthVerifyUserModelService - implements AuthVerifyUserModelServiceInterface { - - async findById( - id: ReferenceId, - ): Promise { - // your custom logic to find user by ID - } - - async findByEmail( - email: ReferenceEmail, - ): Promise { - // your custom logic to find user by email - } - - async update( - user: ReferenceIdInterface & ReferenceActiveInterface, - ): Promise< - ReferenceIdInterface & - ReferenceEmailInterface & - ReferenceActiveInterface - > { - // your custom logic to update user details - } -} -``` - -Now let's add the new user model service to our module: - -```ts -AuthVerifyModule.forRootAsync({ - inject: [ - //... - YourAuthVerifyUserModelService, - //... - ], - useFactory: ( - //... - userModelService, - //... - ) => ({ - //... - userModelService, - //... - }), -}), -``` - -### 5. How to define the NotificationService - -Here's an example of how to implement the `NotificationService`: - -```ts -import { EmailSendOptionsInterface } from '@concepta/nestjs-common'; -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class YourAuthVerifyNotificationService - implements AuthVerifyNotificationServiceInterface { - - async sendEmail(sendMailOptions: EmailSendOptionsInterface): Promise { - // your custom logic to send a general email - } - - async sendVerifyEmail( - email: string, - passcode: string, - resetTokenExp: Date, - ): Promise { - // your custom logic to send a verification email - } -} -``` - -Now let's add the new notification service to our module: - -```ts -AuthVerifyModule.forRootAsync({ - inject: [ - //... - YourAuthVerifyNotificationService, - //... - ], - useFactory: ( - //... - notificationService, - //... - ) => ({ - //... - notificationService, - //... - }), -}), -``` - -## Engineering Concepts - -### 1. Dynamic Configuration Settings - -In designing the dynamic configuration settings for the authentication -verification module, several key decisions were made to ensure -flexibility, maintainability, and scalability: - -These choices were made to create a robust configuration system that -adapts to various operational needs while maintaining a clean and -organized codebase. This approach not only enhances the application's -flexibility but also simplifies the process of managing and updating -configurations as the application evolves. - -### 2. Dynamic OTP service - -The OTP (One-Time Password) service is a crucial component that handles -the generation, validation, and management of verification tokens. The -service must implement the `AuthVerifyOtpServiceInterface` which defines -three core methods: - -- `create()`: Generates a new OTP token associated with a specific user - assignment -- `validate()`: Verifies if a provided OTP token is valid for the given - assignment -- `clear()`: Removes existing OTP tokens for a user assignment - -While you can implement your own custom OTP service logic, the -`@concepta/nestjs-otp` module provides a ready-to-use implementation that -handles: - -- Secure token generation using configurable algorithms (UUID, numeric - codes, etc) -- Token expiration and lifecycle management -- Storage and retrieval of OTP records -- Built-in validation logic with configurable rules - -Using the `@concepta/nestjs-otp` module can significantly reduce -development time while ensuring secure and reliable OTP functionality. -The module seamlessly integrates with the auth verification flow and -follows best practices for OTP implementation. - -The OTP service implementation requires implementing the -`AuthVerifyOtpServiceInterface`. Here's an example of leveraging the -`@concepta/nestjs-otp` module in your OTP service: - -See [2. How to Define the OtpService](#2-how-to-define-the-otpservice) -under How-To Guides for more details on implementing the OTP service. - -### 3. Dynamic Email Service - -The email service is responsible for handling email delivery in the -verification process. It must implement the `AuthVerifyEmailServiceInterface` -which extends the `EmailSendInterface`. The key method to implement is: - -- `sendMail()`: Handles sending the verification email to users - -See [3. How to define the EmailService](#3-how-to-define-the-emailservice) -under How-To Guides for more details on implementing the EmailService. - -### 4. User Model Service - -The user model service is responsible for retrieving user information -during the verification process. It must implement the -`AuthVerifyUserModelServiceInterface` which extends both -`QueryIdInterface`, `QueryEmailInterface` and `UpdateOneInterface`. The key -methods to implement are: - -- `byId()`: Retrieves a user by their unique identifier -- `byEmail()`: Retrieves a user by their email address -- `update()`: Updates user data with verification status changes - -The model service provides a standardized way to query user data -regardless of your underlying user storage implementation. This -abstraction allows the auth verification module to work with any user -data source while maintaining a consistent interface. - -### 5. Notification Service (Optional) - -The notification service is an optional component that handles sending -verification-related notifications to users. It must implement the -`AuthVerifyNotificationServiceInterface`. The key methods to implement are: - -- `sendEmail()`: Sends a generic email using the provided options -- `sendVerifyEmail()`: Sends a verification email with the passcode and -expiration - -The notification service provides a higher-level abstraction over the -email service, specifically tailored for verification-related communications. -It handles formatting verification emails with the correct templates and -context data. - -When implemented, the notification service: - -- Uses configured email templates -- Formats verification URLs -- Includes token expiration information -- Manages email sending through the underlying email service - -While optional, implementing a notification service can help standardize your -verification-related communications and reduce code duplication. The module -provides a default implementation that you can use or extend. diff --git a/packages/nestjs-auth-verify/package.json b/packages/nestjs-auth-verify/package.json deleted file mode 100644 index 2ce5b00db..000000000 --- a/packages/nestjs-auth-verify/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@concepta/nestjs-auth-verify", - "version": "7.0.0-alpha.10", - "description": "Rockets NestJS Auth Verify", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "license": "BSD-3-Clause", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" - ], - "dependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", - "@nestjs/swagger": "^11.2.2" - }, - "devDependencies": { - "@concepta/nestjs-auth-jwt": "^7.0.0-alpha.10", - "@concepta/nestjs-authentication": "^7.0.0-alpha.10", - "@concepta/nestjs-crud": "^7.0.0-alpha.10", - "@concepta/nestjs-email": "^7.0.0-alpha.10", - "@concepta/nestjs-jwt": "^7.0.0-alpha.10", - "@concepta/nestjs-otp": "^7.0.0-alpha.10", - "@concepta/nestjs-password": "^7.0.0-alpha.10", - "@concepta/nestjs-typeorm-ext": "^7.0.0-alpha.10", - "@concepta/nestjs-user": "^7.0.0-alpha.10", - "@concepta/typeorm-seeding": "^4.0.0", - "@nestjs/testing": "^11.1.9", - "@nestjs/typeorm": "^11.0.0", - "jest-mock-extended": "^4.0.0", - "supertest": "^6.3.4" - }, - "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", - "rxjs": "^7.1.0", - "typeorm": "^0.3.0" - } -} diff --git a/packages/nestjs-auth-verify/src/__fixtures__/app.module.db.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/app.module.db.fixture.ts deleted file mode 100644 index b9123e166..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/app.module.db.fixture.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { AuthenticationModule } from '@concepta/nestjs-authentication'; -import { CrudModule } from '@concepta/nestjs-crud'; -import { EmailModule, EmailService } from '@concepta/nestjs-email'; -import { JwtModule } from '@concepta/nestjs-jwt'; -import { OtpModule, OtpService } from '@concepta/nestjs-otp'; -import { PasswordModule } from '@concepta/nestjs-password'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { - UserModelService, - UserModelServiceInterface, - UserModule, -} from '@concepta/nestjs-user'; - -import { AuthVerifyModule } from '../auth-verify.module'; - -import { AuthVerifyControllerFixture } from './auth-verify.controller.fixture'; -import { MailerServiceFixture } from './email/mailer.service.fixture'; -import { default as ormConfig } from './ormconfig.fixture'; -import { UserEntityFixture } from './user/entities/user-entity.fixture'; -import { UserOtpEntityFixture } from './user/entities/user-otp-entity.fixture'; - -@Module({ - imports: [ - TypeOrmExtModule.forRoot(ormConfig), - CrudModule.forRoot({}), - JwtModule.forRoot({}), - AuthenticationModule.forRoot({ - settings: { - disableGuard: (context, guard) => - guard.constructor.name === 'AuthJwtGuard' && - context.getClass().name === 'UserController', - }, - }), - AuthJwtModule.forRootAsync({ - inject: [UserModelService], - useFactory: (userModelService: UserModelServiceInterface) => ({ - userModelService, - }), - }), - AuthVerifyModule.forRootAsync({ - inject: [UserModelService, OtpService, EmailService], - useFactory: (userModelService, otpService, emailService) => ({ - userModelService, - otpService, - emailService, - }), - }), - OtpModule.forRootAsync({ - useFactory: () => ({}), - entities: ['userOtp'], - imports: [ - TypeOrmExtModule.forFeature({ - userOtp: { - entity: UserOtpEntityFixture, - }, - }), - ], - }), - PasswordModule.forRoot({}), - UserModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - user: { - entity: UserEntityFixture, - }, - }), - ], - useFactory: () => ({}), - }), - EmailModule.forRoot({ - mailerService: new MailerServiceFixture(), - }), - ], - controllers: [AuthVerifyControllerFixture], -}) -export class AppModuleDbFixture {} diff --git a/packages/nestjs-auth-verify/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/app.module.fixture.ts deleted file mode 100644 index b15081957..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/app.module.fixture.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { AuthenticationModule } from '@concepta/nestjs-authentication'; -import { EmailModule, EmailService } from '@concepta/nestjs-email'; -import { JwtModule } from '@concepta/nestjs-jwt'; - -import { AuthVerifyModule } from '../auth-verify.module'; - -import { MailerServiceFixture } from './email/mailer.service.fixture'; -import { OtpModuleFixture } from './otp/otp.module.fixture'; -import { OtpServiceFixture } from './otp/otp.service.fixture'; -import { UserModelServiceFixture } from './user/services/user-model.service.fixture'; -import { UserModuleFixture } from './user/user.module.fixture'; - -@Module({ - imports: [ - JwtModule.forRoot({}), - AuthenticationModule.forRoot({}), - AuthJwtModule.forRootAsync({ - inject: [UserModelServiceFixture], - useFactory: (userModelService: UserModelServiceFixture) => ({ - userModelService, - }), - }), - AuthVerifyModule.forRootAsync({ - inject: [EmailService, OtpServiceFixture, UserModelServiceFixture], - useFactory: (emailService, otpService, userModelService) => ({ - emailService, - otpService, - userModelService, - }), - }), - EmailModule.forRoot({ mailerService: new MailerServiceFixture() }), - OtpModuleFixture, - UserModuleFixture, - ], -}) -export class AppModuleFixture {} diff --git a/packages/nestjs-auth-verify/src/__fixtures__/auth-verify.controller.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/auth-verify.controller.fixture.ts deleted file mode 100644 index 03d800166..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/auth-verify.controller.fixture.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Body, Controller, Patch, Post } from '@nestjs/common'; -import { - ApiBadRequestResponse, - ApiBody, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; - -import { AuthPublic } from '@concepta/nestjs-authentication'; - -import { AuthVerifyUpdateDto } from '../dto/auth-verify-update.dto'; -import { AuthVerifyDto } from '../dto/auth-verify.dto'; -import { AuthVerifyService } from '../services/auth-verify.service'; - -@Controller('auth/verify') -@AuthPublic() -@ApiTags('auth') -export class AuthVerifyControllerFixture { - constructor(private readonly authVerifyService: AuthVerifyService) {} - - @ApiOperation({ - summary: - 'Send Verify account email by providing an email that will receive link to confirm account.', - }) - @ApiBody({ - type: AuthVerifyDto, - description: 'DTO of email verify.', - }) - @ApiOkResponse() - @Post('/send') - async send(@Body() authVerifyDto: AuthVerifyDto): Promise { - await this.authVerifyService.send({ email: authVerifyDto.email }); - } - - @ApiOperation({ - summary: 'confirm email providing passcode.', - }) - @ApiBody({ - type: AuthVerifyUpdateDto, - description: 'DTO of verify email.', - }) - @ApiOkResponse() - @ApiBadRequestResponse() - @Patch('/confirm') - async confirm( - @Body() authVerifyUpdateDto: AuthVerifyUpdateDto, - ): Promise { - const { passcode } = authVerifyUpdateDto; - - await this.authVerifyService.confirmUser({ passcode }); - } -} diff --git a/packages/nestjs-auth-verify/src/__fixtures__/email/mailer.service.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/email/mailer.service.fixture.ts deleted file mode 100644 index fd842639e..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/email/mailer.service.fixture.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - EmailSendInterface, - EmailSendOptionsInterface, -} from '@concepta/nestjs-common'; - -@Injectable() -export class MailerServiceFixture implements EmailSendInterface { - sendMail(_sendMailOptions: EmailSendOptionsInterface): Promise { - throw new Error('Method not implemented.'); - } -} diff --git a/packages/nestjs-auth-verify/src/__fixtures__/ormconfig.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/ormconfig.fixture.ts deleted file mode 100644 index 19352ec33..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/ormconfig.fixture.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { DataSourceOptions } from 'typeorm'; - -import { UserEntityFixture } from './user/entities/user-entity.fixture'; -import { UserOtpEntityFixture } from './user/entities/user-otp-entity.fixture'; - -const config: DataSourceOptions = { - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [UserEntityFixture, UserOtpEntityFixture], -}; - -export default config; diff --git a/packages/nestjs-auth-verify/src/__fixtures__/otp/otp.module.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/otp/otp.module.fixture.ts deleted file mode 100644 index db0b5e982..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/otp/otp.module.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { OtpServiceFixture } from './otp.service.fixture'; - -@Global() -@Module({ - providers: [OtpServiceFixture], - exports: [OtpServiceFixture], -}) -export class OtpModuleFixture {} diff --git a/packages/nestjs-auth-verify/src/__fixtures__/otp/otp.service.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/otp/otp.service.fixture.ts deleted file mode 100644 index fea322eb4..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/otp/otp.service.fixture.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { Injectable } from '@nestjs/common'; - -import { - AssigneeRelationInterface, - OtpCreateParamsInterface, - OtpInterface, -} from '@concepta/nestjs-common'; - -import { AuthVerifyOtpServiceInterface } from '../../interfaces/auth-verify-otp.service.interface'; -import { UserFixture } from '../user/user.fixture'; - -@Injectable() -export class OtpServiceFixture implements AuthVerifyOtpServiceInterface { - async create({ otp }: OtpCreateParamsInterface): Promise { - const { assigneeId, category, type } = otp; - return { - id: randomUUID(), - category, - type, - assigneeId, - active: true, - passcode: 'GOOD_PASSCODE', - expirationDate: new Date(), - dateCreated: new Date(), - dateUpdated: new Date(), - dateDeleted: null, - version: 1, - }; - } - - async validate( - _assignment: string, - otp: Pick, - _deleteIfValid: boolean, - ): Promise { - return otp.passcode === 'GOOD_PASSCODE' - ? { assigneeId: UserFixture.id } - : null; - } - - async clear( - _assignment: string, - _otp: Pick, - ): Promise { - return; - } -} diff --git a/packages/nestjs-auth-verify/src/__fixtures__/user/entities/user-entity.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/user/entities/user-entity.fixture.ts deleted file mode 100644 index 6e42bcbf1..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/user/entities/user-entity.fixture.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Entity } from 'typeorm'; - -import { UserSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * User Entity Fixture - */ -@Entity() -export class UserEntityFixture extends UserSqliteEntity {} diff --git a/packages/nestjs-auth-verify/src/__fixtures__/user/entities/user-otp-entity.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/user/entities/user-otp-entity.fixture.ts deleted file mode 100644 index 180101235..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/user/entities/user-otp-entity.fixture.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Column, Entity } from 'typeorm'; - -import { ReferenceId, OtpInterface } from '@concepta/nestjs-common'; -import { CommonSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * Otp Entity Fixture - */ -@Entity() -export class UserOtpEntityFixture - extends CommonSqliteEntity - implements OtpInterface -{ - @Column() - category!: string; - - @Column({ nullable: true }) - type!: string; - - @Column() - passcode!: string; - - @Column({ default: true }) - active!: boolean; - - @Column({ type: 'datetime' }) - expirationDate!: Date; - - @Column() - assigneeId!: ReferenceId; -} diff --git a/packages/nestjs-auth-verify/src/__fixtures__/user/services/user-model.service.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/user/services/user-model.service.fixture.ts deleted file mode 100644 index 77cd6c8b6..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/user/services/user-model.service.fixture.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ReferenceActiveInterface, - ReferenceEmail, - ReferenceIdInterface, - ReferenceSubject, -} from '@concepta/nestjs-common'; - -import { AuthVerifyUserModelServiceInterface } from '../../../interfaces/auth-verify-user-model.service.interface'; -import { UserFixture } from '../user.fixture'; - -@Injectable() -export class UserModelServiceFixture - implements AuthVerifyUserModelServiceInterface -{ - async byId( - id: string, - ): ReturnType { - if (id === UserFixture.id) { - return UserFixture; - } else { - throw new Error(); - } - } - - async byEmail( - email: ReferenceEmail, - ): ReturnType { - return email === UserFixture.email ? UserFixture : null; - } - - async bySubject(subject: ReferenceSubject): Promise { - throw new Error(`Method not implemented, can't get ${subject}.`); - } - - async update( - object: ReferenceIdInterface & ReferenceActiveInterface, - ): ReturnType { - if (object.id === UserFixture.id) { - return UserFixture; - } else { - throw new Error(); - } - } -} diff --git a/packages/nestjs-auth-verify/src/__fixtures__/user/user.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/user/user.fixture.ts deleted file mode 100644 index 989929386..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/user/user.fixture.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const UserFixture = { - id: 'abc', - email: 'me@dispostable.com', - username: 'me@dispostable.com', - active: true, -}; diff --git a/packages/nestjs-auth-verify/src/__fixtures__/user/user.module.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/user/user.module.fixture.ts deleted file mode 100644 index 980ef90c4..000000000 --- a/packages/nestjs-auth-verify/src/__fixtures__/user/user.module.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { UserModelServiceFixture } from './services/user-model.service.fixture'; - -@Global() -@Module({ - providers: [UserModelServiceFixture], - exports: [UserModelServiceFixture], -}) -export class UserModuleFixture {} diff --git a/packages/nestjs-auth-verify/src/assets/templates/email/verify.template.hbs b/packages/nestjs-auth-verify/src/assets/templates/email/verify.template.hbs deleted file mode 100644 index 5a9ca2cd8..000000000 --- a/packages/nestjs-auth-verify/src/assets/templates/email/verify.template.hbs +++ /dev/null @@ -1,15 +0,0 @@ -

-Welcome! Thank you for registering. To complete your registration and verify your email address, -

- -

-Please click on the link below to confirm your account. -

- -

-Click Here -

- -

-This link will expire at {{tokenExp}} -

diff --git a/packages/nestjs-auth-verify/src/auth-verify.constants.ts b/packages/nestjs-auth-verify/src/auth-verify.constants.ts deleted file mode 100644 index 67217b091..000000000 --- a/packages/nestjs-auth-verify/src/auth-verify.constants.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const AUTH_VERIFY_MODULE_SETTINGS_TOKEN = - 'AUTH_VERIFY_MODULE_SETTINGS_TOKEN'; - -export const AUTH_VERIFY_MODULE_DEFAULT_SETTINGS_TOKEN = - 'AUTH_VERIFY_MODULE_DEFAULT_SETTINGS_TOKEN'; - -export const AuthVerifyOtpService = Symbol( - '__AUTH_VERIFY_MODULE_OTP_SERVICE_TOKEN__', -); - -export const AuthVerifyEmailService = Symbol( - '__AUTH_VERIFY_MODULE_EMAIL_SERVICE_TOKEN__', -); - -export const AuthVerifyUserModelService = Symbol( - '__AUTH_VERIFY_MODULE_USER_MODEL_SERVICE_TOKEN__', -); diff --git a/packages/nestjs-auth-verify/src/auth-verify.module-definition.spec.ts b/packages/nestjs-auth-verify/src/auth-verify.module-definition.spec.ts deleted file mode 100644 index cb8cedf40..000000000 --- a/packages/nestjs-auth-verify/src/auth-verify.module-definition.spec.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { FactoryProvider } from '@nestjs/common'; - -import { - AUTH_VERIFY_MODULE_SETTINGS_TOKEN, - AuthVerifyEmailService, - AuthVerifyOtpService, - AuthVerifyUserModelService, -} from './auth-verify.constants'; -import { - createAuthVerifyEmailServiceProvider, - createAuthVerifyExports, - createAuthVerifyNotificationServiceProvider, - createAuthVerifyOtpServiceProvider, - createAuthVerifyUserModelServiceProvider, -} from './auth-verify.module-definition'; -import { AuthVerifyEmailServiceInterface } from './interfaces/auth-verify-email.service.interface'; -import { AuthVerifyNotificationServiceInterface } from './interfaces/auth-verify-notification.service.interface'; -import { AuthVerifyUserModelServiceInterface } from './interfaces/auth-verify-user-model.service.interface'; -import { AuthVerifyNotificationService } from './services/auth-verify-notification.service'; -import { AuthVerifyService } from './services/auth-verify.service'; - -import { OtpServiceFixture } from './__fixtures__/otp/otp.service.fixture'; -import { UserModelServiceFixture } from './__fixtures__/user/services/user-model.service.fixture'; - -describe('AuthVerifyModuleDefinition', () => { - const mockEmailService = mock(); - const mockAuthVerifyNotification = - mock(); - const mockAuthVerifyOptions = { - emailService: mockEmailService, - otpService: new OtpServiceFixture(), - userModelService: new UserModelServiceFixture(), - }; - describe(createAuthVerifyExports.name, () => { - it('should return an array with the expected tokens', () => { - const result = createAuthVerifyExports(); - expect(result).toEqual([ - AUTH_VERIFY_MODULE_SETTINGS_TOKEN, - AuthVerifyOtpService, - AuthVerifyEmailService, - AuthVerifyUserModelService, - AuthVerifyService, - ]); - }); - }); - - describe(createAuthVerifyOtpServiceProvider.name, () => { - class TestOtpService extends OtpServiceFixture {} - - const testOtpService = mock(); - - it('should return a default otpService', async () => { - const provider = createAuthVerifyOtpServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBe(undefined); - }); - - it('should return an otpService from initialization', async () => { - const provider = createAuthVerifyOtpServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({ - otpService: testOtpService, - }); - - expect(useFactoryResult).toBe(testOtpService); - }); - - it('should return an overridden otpService', async () => { - const provider = createAuthVerifyOtpServiceProvider({ - otpService: mockAuthVerifyOptions.otpService, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBeInstanceOf(OtpServiceFixture); - }); - }); - - describe(createAuthVerifyEmailServiceProvider.name, () => { - it('should return a have no default', async () => { - const provider = - createAuthVerifyEmailServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBe(undefined); - }); - - it('should override an emailService', async () => { - const provider = createAuthVerifyEmailServiceProvider({ - emailService: mockAuthVerifyOptions.emailService, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory(); - - expect(useFactoryResult).toBe(mockAuthVerifyOptions.emailService); - }); - - it('should return an emailService from initialization', async () => { - const provider = - createAuthVerifyEmailServiceProvider() as FactoryProvider; - - const testMockEmailService = mock(); - const useFactoryResult = await provider.useFactory({ - emailService: testMockEmailService, - }); - - expect(useFactoryResult).toBe(testMockEmailService); - }); - }); - - describe(createAuthVerifyUserModelServiceProvider.name, () => { - it('should return a have no default', async () => { - const provider = - createAuthVerifyUserModelServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBe(undefined); - }); - - it('should override userModelService', async () => { - const provider = createAuthVerifyUserModelServiceProvider({ - userModelService: mockAuthVerifyOptions.userModelService, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory(); - - expect(useFactoryResult).toBe(mockAuthVerifyOptions.userModelService); - }); - - it('should return an userModelService from initialization', async () => { - const provider = - createAuthVerifyUserModelServiceProvider() as FactoryProvider; - - const mockService = mock(); - const useFactoryResult = await provider.useFactory({ - userModelService: mockService, - }); - - expect(useFactoryResult).toBe(mockService); - }); - }); - - describe(createAuthVerifyNotificationServiceProvider.name, () => { - it('should return a default AuthVerifyNotificationService', async () => { - const provider = - createAuthVerifyNotificationServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({}); - - expect(useFactoryResult).toBeInstanceOf(AuthVerifyNotificationService); - }); - - it('should override notificationService', async () => { - const provider = createAuthVerifyNotificationServiceProvider({ - notificationService: mockAuthVerifyNotification, - }) as FactoryProvider; - - const useFactoryResult = await provider.useFactory(); - - expect(useFactoryResult).toBe(mockAuthVerifyNotification); - }); - - it('should return an notificationService from initialization', async () => { - const provider = - createAuthVerifyNotificationServiceProvider() as FactoryProvider; - - const useFactoryResult = await provider.useFactory({ - notificationService: mockAuthVerifyNotification, - }); - - expect(useFactoryResult).toBe(mockAuthVerifyNotification); - }); - }); -}); diff --git a/packages/nestjs-auth-verify/src/auth-verify.module-definition.ts b/packages/nestjs-auth-verify/src/auth-verify.module-definition.ts deleted file mode 100644 index 3c33e0d96..000000000 --- a/packages/nestjs-auth-verify/src/auth-verify.module-definition.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { - ConfigurableModuleBuilder, - DynamicModule, - Provider, -} from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; - -import { createSettingsProvider } from '@concepta/nestjs-common'; - -import { - AUTH_VERIFY_MODULE_SETTINGS_TOKEN, - AuthVerifyEmailService, - AuthVerifyOtpService, - AuthVerifyUserModelService, -} from './auth-verify.constants'; -import { authVerifyDefaultConfig } from './config/auth-verify-default.config'; -import { AuthVerifyEmailServiceInterface } from './interfaces/auth-verify-email.service.interface'; -import { AuthVerifyOptionsExtrasInterface } from './interfaces/auth-verify-options-extras.interface'; -import { AuthVerifyOptionsInterface } from './interfaces/auth-verify-options.interface'; -import { AuthVerifySettingsInterface } from './interfaces/auth-verify-settings.interface'; -import { AuthVerifyNotificationService } from './services/auth-verify-notification.service'; -import { AuthVerifyService } from './services/auth-verify.service'; - -const RAW_OPTIONS_TOKEN = Symbol('__AUTH_VERIFY_MODULE_RAW_OPTIONS_TOKEN__'); - -export const { - ConfigurableModuleClass: AuthVerifyModuleClass, - OPTIONS_TYPE: AUTH_VERIFY_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: AUTH_VERIFY_ASYNC_OPTIONS_TYPE, -} = new ConfigurableModuleBuilder({ - moduleName: 'AuthVerify', - optionsInjectionToken: RAW_OPTIONS_TOKEN, -}) - .setExtras( - { global: false }, - definitionTransform, - ) - .build(); - -export type AuthVerifyOptions = Omit; -export type AuthVerifyAsyncOptions = Omit< - typeof AUTH_VERIFY_ASYNC_OPTIONS_TYPE, - 'global' ->; - -function definitionTransform( - definition: DynamicModule, - extras: AuthVerifyOptionsExtrasInterface, -): DynamicModule { - const { providers } = definition; - const { global } = extras; - - return { - ...definition, - global, - imports: createAuthVerifyImports(), - providers: createAuthVerifyProviders({ providers }), - exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createAuthVerifyExports()], - }; -} - -export function createAuthVerifyImports(): DynamicModule['imports'] { - return [ConfigModule.forFeature(authVerifyDefaultConfig)]; -} - -export function createAuthVerifyExports() { - return [ - AUTH_VERIFY_MODULE_SETTINGS_TOKEN, - AuthVerifyOtpService, - AuthVerifyEmailService, - AuthVerifyUserModelService, - AuthVerifyService, - ]; -} - -export function createAuthVerifyProviders(options: { - overrides?: AuthVerifyOptions; - providers?: Provider[]; -}): Provider[] { - return [ - ...(options.providers ?? []), - AuthVerifyService, - createAuthVerifySettingsProvider(options.overrides), - createAuthVerifyOtpServiceProvider(options.overrides), - createAuthVerifyEmailServiceProvider(options.overrides), - createAuthVerifyUserModelServiceProvider(options.overrides), - createAuthVerifyNotificationServiceProvider(options.overrides), - ]; -} - -export function createAuthVerifySettingsProvider( - optionsOverrides?: AuthVerifyOptions, -): Provider { - return createSettingsProvider< - AuthVerifySettingsInterface, - AuthVerifyOptionsInterface - >({ - settingsToken: AUTH_VERIFY_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: authVerifyDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createAuthVerifyOtpServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthVerifyOtpService, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: Pick) => - optionsOverrides?.otpService ?? options.otpService, - }; -} - -export function createAuthVerifyEmailServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthVerifyEmailService, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: Pick) => - optionsOverrides?.emailService ?? options.emailService, - }; -} - -export function createAuthVerifyUserModelServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthVerifyUserModelService, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: Pick) => - optionsOverrides?.userModelService ?? options.userModelService, - }; -} - -export function createAuthVerifyNotificationServiceProvider( - optionsOverrides?: Pick, -): Provider { - return { - provide: AuthVerifyNotificationService, - inject: [ - RAW_OPTIONS_TOKEN, - AUTH_VERIFY_MODULE_SETTINGS_TOKEN, - AuthVerifyEmailService, - ], - useFactory: async ( - options: Pick, - settings: AuthVerifySettingsInterface, - emailService: AuthVerifyEmailServiceInterface, - ) => - optionsOverrides?.notificationService ?? - options.notificationService ?? - new AuthVerifyNotificationService(settings, emailService), - }; -} diff --git a/packages/nestjs-auth-verify/src/auth-verify.module.spec.ts b/packages/nestjs-auth-verify/src/auth-verify.module.spec.ts deleted file mode 100644 index 45846ef03..000000000 --- a/packages/nestjs-auth-verify/src/auth-verify.module.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { EmailModule, EmailService } from '@concepta/nestjs-email'; - -import { AuthVerifyModule } from './auth-verify.module'; -import { AuthVerifyEmailServiceInterface } from './interfaces/auth-verify-email.service.interface'; -import { AuthVerifyOtpServiceInterface } from './interfaces/auth-verify-otp.service.interface'; -import { AuthVerifyUserModelServiceInterface } from './interfaces/auth-verify-user-model.service.interface'; -import { AuthVerifyServiceInterface } from './interfaces/auth-verify.service.interface'; -import { AuthVerifyService } from './services/auth-verify.service'; - -import { MailerServiceFixture } from './__fixtures__/email/mailer.service.fixture'; -import { OtpModuleFixture } from './__fixtures__/otp/otp.module.fixture'; -import { OtpServiceFixture } from './__fixtures__/otp/otp.service.fixture'; -import { UserModelServiceFixture } from './__fixtures__/user/services/user-model.service.fixture'; -import { UserModuleFixture } from './__fixtures__/user/user.module.fixture'; - -describe(AuthVerifyModule, () => { - let testModule: TestingModule; - let authVerifyModule: AuthVerifyModule; - let otpService: AuthVerifyOtpServiceInterface; - let userModelService: AuthVerifyUserModelServiceInterface; - let authVerifyService: AuthVerifyServiceInterface; - let emailService: EmailService; - - const mockEmailService = mock(); - - describe(AuthVerifyModule.forRoot, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthVerifyModule.forRoot({ - emailService: mockEmailService, - otpService: new OtpServiceFixture(), - userModelService: new UserModelServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - describe(AuthVerifyModule.register, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthVerifyModule.register({ - emailService: mockEmailService, - otpService: new OtpServiceFixture(), - userModelService: new UserModelServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - describe(AuthVerifyModule.forRootAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthVerifyModule.forRootAsync({ - inject: [UserModelServiceFixture, OtpServiceFixture, EmailService], - useFactory: (userModelService, otpService, emailService) => ({ - userModelService, - otpService, - emailService, - }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - describe(AuthVerifyModule.registerAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - AuthVerifyModule.registerAsync({ - inject: [UserModelServiceFixture, OtpServiceFixture, EmailService], - useFactory: (userModelService, otpService, emailService) => ({ - userModelService, - otpService, - emailService, - }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - function commonVars() { - authVerifyModule = testModule.get(AuthVerifyModule); - otpService = - testModule.get(OtpServiceFixture); - emailService = testModule.get(EmailService); - userModelService = testModule.get( - UserModelServiceFixture, - ); - authVerifyService = testModule.get(AuthVerifyService); - } - - function commonTests() { - expect(authVerifyModule).toBeInstanceOf(AuthVerifyModule); - expect(otpService).toBeInstanceOf(OtpServiceFixture); - expect(emailService).toBeInstanceOf(EmailService); - expect(userModelService).toBeInstanceOf(UserModelServiceFixture); - expect(authVerifyService).toBeInstanceOf(AuthVerifyService); - } -}); - -function testModuleFactory( - extraImports: DynamicModule['imports'] = [], -): ModuleMetadata { - return { - imports: [ - UserModuleFixture, - OtpModuleFixture, - EmailModule.forRoot({ mailerService: new MailerServiceFixture() }), - ...extraImports, - ], - }; -} diff --git a/packages/nestjs-auth-verify/src/auth-verify.module.ts b/packages/nestjs-auth-verify/src/auth-verify.module.ts deleted file mode 100644 index cf0620805..000000000 --- a/packages/nestjs-auth-verify/src/auth-verify.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DynamicModule, Module } from '@nestjs/common'; - -import { - AuthVerifyAsyncOptions, - AuthVerifyModuleClass, - AuthVerifyOptions, -} from './auth-verify.module-definition'; - -@Module({}) -export class AuthVerifyModule extends AuthVerifyModuleClass { - static register(options: AuthVerifyOptions): DynamicModule { - return super.register(options); - } - - static registerAsync(options: AuthVerifyAsyncOptions): DynamicModule { - return super.registerAsync(options); - } - - static forRoot(options: AuthVerifyOptions): DynamicModule { - return super.register({ ...options, global: true }); - } - - static forRootAsync(options: AuthVerifyAsyncOptions): DynamicModule { - return super.registerAsync({ ...options, global: true }); - } -} diff --git a/packages/nestjs-auth-verify/src/auth-verify.utils.spec.ts b/packages/nestjs-auth-verify/src/auth-verify.utils.spec.ts deleted file mode 100644 index a2323c3a2..000000000 --- a/packages/nestjs-auth-verify/src/auth-verify.utils.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { formatTokenUrl } from './auth-verify.utils'; - -describe('formatTokenUrl', () => { - it('should return the correct URL', () => { - const baseUrl = 'https://example.com'; - const passcode = '123456'; - const expectedUrl = 'https://example.com/123456'; - - const result = formatTokenUrl(baseUrl, passcode); - - expect(result).toBe(expectedUrl); - }); -}); diff --git a/packages/nestjs-auth-verify/src/auth-verify.utils.ts b/packages/nestjs-auth-verify/src/auth-verify.utils.ts deleted file mode 100644 index 1ff64db9d..000000000 --- a/packages/nestjs-auth-verify/src/auth-verify.utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function formatTokenUrl(baseUrl: string, passcode: string) { - return `${baseUrl}/${passcode}`; -} diff --git a/packages/nestjs-auth-verify/src/config/auth-verify-default.config.ts b/packages/nestjs-auth-verify/src/config/auth-verify-default.config.ts deleted file mode 100644 index b3b859a05..000000000 --- a/packages/nestjs-auth-verify/src/config/auth-verify-default.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { AUTH_VERIFY_MODULE_DEFAULT_SETTINGS_TOKEN } from '../auth-verify.constants'; -import { formatTokenUrl } from '../auth-verify.utils'; -import { AuthVerifySettingsInterface } from '../interfaces/auth-verify-settings.interface'; - -/** - * Default configuration for auth verify. - */ -export const authVerifyDefaultConfig = registerAs( - AUTH_VERIFY_MODULE_DEFAULT_SETTINGS_TOKEN, - (): AuthVerifySettingsInterface => ({ - email: { - from: 'from', - baseUrl: 'baseUrl', - tokenUrlFormatter: formatTokenUrl, - templates: { - verifyEmail: { - fileName: __dirname + '/../assets/verify.template.hbs', - subject: 'Verify Email', - }, - }, - }, - otp: { - assignment: 'userOtp', - category: 'auth-verify', - type: 'uuid', - expiresIn: '24h', - }, - }), -); diff --git a/packages/nestjs-auth-verify/src/controllers/auth-verify.controller.e2e-spec.ts b/packages/nestjs-auth-verify/src/controllers/auth-verify.controller.e2e-spec.ts deleted file mode 100644 index b9e74c08c..000000000 --- a/packages/nestjs-auth-verify/src/controllers/auth-verify.controller.e2e-spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import supertest from 'supertest'; - -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { OtpInterface, UserInterface } from '@concepta/nestjs-common'; -import { EmailService } from '@concepta/nestjs-email'; -import { OtpService } from '@concepta/nestjs-otp'; -import { UserModelService } from '@concepta/nestjs-user'; -import { UserFactory } from '@concepta/nestjs-user/src/seeding'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { AUTH_VERIFY_MODULE_SETTINGS_TOKEN } from '../auth-verify.constants'; -import { AuthVerifyUpdateDto } from '../dto/auth-verify-update.dto'; -import { AuthVerifyDto } from '../dto/auth-verify.dto'; -import { AuthVerifySettingsInterface } from '../interfaces/auth-verify-settings.interface'; - -import { AppModuleDbFixture } from '../__fixtures__/app.module.db.fixture'; -import { AuthVerifyControllerFixture } from '../__fixtures__/auth-verify.controller.fixture'; -import { UserEntityFixture } from '../__fixtures__/user/entities/user-entity.fixture'; - -describe(AuthVerifyControllerFixture, () => { - let app: INestApplication; - let otpService: OtpService; - let userModelService: UserModelService; - let settings: AuthVerifySettingsInterface; - let seedingSource: SeedingSource; - let userFactory: UserFactory; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleDbFixture], - }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - - otpService = moduleFixture.get(OtpService); - userModelService = moduleFixture.get(UserModelService); - - settings = moduleFixture.get( - AUTH_VERIFY_MODULE_SETTINGS_TOKEN, - ); - - seedingSource = new SeedingSource({ - dataSource: moduleFixture.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - userFactory = new UserFactory({ - entity: UserEntityFixture, - seedingSource, - }); - - await userFactory.create({ - active: false, - }); - - jest.spyOn(EmailService.prototype, 'sendMail').mockResolvedValue(undefined); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - it('POST auth/verify/send', async () => { - const user = await getFirstUser(app); - - await send(app, user); - }); - - it('PATCH auth/verify/confirm', async () => { - const user = await getFirstUser(app); - - await send(app, user); - - const otpCreateDto = await createOtp(settings, otpService, user.id); - - await supertest(app.getHttpServer()) - .patch('/auth/verify/confirm') - .send({ - passcode: otpCreateDto.passcode, - } as AuthVerifyUpdateDto) - .expect(200); - }); - - const getFirstUser = async ( - _app: INestApplication, - ): Promise => { - const response = await userModelService.find(); - return response[0]; - }; -}); - -const send = async ( - app: INestApplication, - user: UserInterface, -): Promise => { - await supertest(app.getHttpServer()) - .post('/auth/verify/send') - .send({ email: user.email } as AuthVerifyDto) - .expect(201); -}; - -const createOtp = async ( - config: AuthVerifySettingsInterface, - otpService: OtpService, - userId: string, -): Promise => { - const { category, assignment, type, expiresIn } = config.otp; - return await otpService.create({ - assignment, - otp: { - category, - type, - expiresIn, - assigneeId: userId, - }, - }); -}; diff --git a/packages/nestjs-auth-verify/src/controllers/auth-verify.controller.spec.ts b/packages/nestjs-auth-verify/src/controllers/auth-verify.controller.spec.ts deleted file mode 100644 index 26d742ef0..000000000 --- a/packages/nestjs-auth-verify/src/controllers/auth-verify.controller.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { AuthVerifyUpdateDto } from '../dto/auth-verify-update.dto'; -import { AuthVerifyDto } from '../dto/auth-verify.dto'; -import { AuthVerifyService } from '../services/auth-verify.service'; - -import { AuthVerifyControllerFixture } from '../__fixtures__/auth-verify.controller.fixture'; - -describe(AuthVerifyControllerFixture.name, () => { - let controller: AuthVerifyControllerFixture; - let authVerifyService: AuthVerifyService; - const dto: AuthVerifyDto = { - email: 'test@example.com', - }; - const authVerifyUpdateDto: AuthVerifyUpdateDto = { - passcode: '123456', - }; - beforeEach(() => { - authVerifyService = mock(); - controller = new AuthVerifyControllerFixture(authVerifyService); - }); - - describe('send', () => { - it('should call send method of AuthVerifyService', async () => { - const verifySendSpy = jest.spyOn(authVerifyService, 'send'); - - await controller.send(dto); - - expect(verifySendSpy).toHaveBeenCalledWith({ email: dto.email }); - }); - }); - - describe('confirm', () => { - it('should call confirmUser method of AuthVerifyService', async () => { - const confirmUserSpy = jest - .spyOn(authVerifyService, 'confirmUser') - .mockResolvedValue(null); - - await controller.confirm(authVerifyUpdateDto); - - expect(confirmUserSpy).toHaveBeenCalledWith({ - passcode: authVerifyUpdateDto.passcode, - }); - }); - - it('should call confirmUser method of AuthVerifyService', async () => { - const confirmUserSpy = jest - .spyOn(authVerifyService, 'confirmUser') - .mockResolvedValue({ - id: '1', - }); - - await controller.confirm(authVerifyUpdateDto); - - expect(confirmUserSpy).toHaveBeenCalledWith({ - passcode: authVerifyUpdateDto.passcode, - }); - }); - }); -}); diff --git a/packages/nestjs-auth-verify/src/dto/auth-verify-update.dto.ts b/packages/nestjs-auth-verify/src/dto/auth-verify-update.dto.ts deleted file mode 100644 index 707e31d30..000000000 --- a/packages/nestjs-auth-verify/src/dto/auth-verify-update.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -export class AuthVerifyUpdateDto { - @ApiProperty({ - title: 'account confirm passcode', - type: 'string', - description: 'Passcode used to confirm account', - }) - @IsString() - passcode = ''; -} diff --git a/packages/nestjs-auth-verify/src/dto/auth-verify.dto.ts b/packages/nestjs-auth-verify/src/dto/auth-verify.dto.ts deleted file mode 100644 index 1a8f9a77f..000000000 --- a/packages/nestjs-auth-verify/src/dto/auth-verify.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { IsEmail } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -export class AuthVerifyDto { - @ApiProperty({ - title: 'user email', - type: 'string', - description: - 'Verify email by providing an email that will receive a confirmation link', - }) - @IsEmail() - email = ''; -} diff --git a/packages/nestjs-auth-verify/src/exceptions/auth-verify-otp-invalid.exception.ts b/packages/nestjs-auth-verify/src/exceptions/auth-verify-otp-invalid.exception.ts deleted file mode 100644 index e9a58bf18..000000000 --- a/packages/nestjs-auth-verify/src/exceptions/auth-verify-otp-invalid.exception.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthVerifyException } from './auth-verify.exception'; - -export class AuthRecoveryOtpInvalidException extends AuthVerifyException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: `Invalid confirmation code provided`, - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'AUTH_VERIFY_OTP_INVALID_ERROR'; - } -} diff --git a/packages/nestjs-auth-verify/src/exceptions/auth-verify.exception.ts b/packages/nestjs-auth-verify/src/exceptions/auth-verify.exception.ts deleted file mode 100644 index 28dab13b8..000000000 --- a/packages/nestjs-auth-verify/src/exceptions/auth-verify.exception.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -/** - * Generic auth verify exception. - */ -export class AuthVerifyException extends RuntimeException { - constructor(options?: RuntimeExceptionOptions) { - super(options); - this.errorCode = 'AUTH_VERIFY_ERROR'; - } -} diff --git a/packages/nestjs-auth-verify/src/index.spec.ts b/packages/nestjs-auth-verify/src/index.spec.ts deleted file mode 100644 index 50eae925d..000000000 --- a/packages/nestjs-auth-verify/src/index.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - AuthVerifyModule, - AuthVerifyService, - AuthVerifyNotificationService, - AuthVerifyDto, -} from './index'; - -describe('Index', () => { - it('AuthVerifyModule should be a function', () => { - expect(AuthVerifyModule).toBeInstanceOf(Function); - }); - - it('AuthVerifyService should be a function', () => { - expect(AuthVerifyService).toBeInstanceOf(Function); - }); - - it('AuthVerifyNotificationService should be a function', () => { - expect(AuthVerifyNotificationService).toBeInstanceOf(Function); - }); - - it('AuthVerifyVerifyLoginDto should be a function', () => { - expect(AuthVerifyDto).toBeInstanceOf(Function); - }); -}); diff --git a/packages/nestjs-auth-verify/src/index.ts b/packages/nestjs-auth-verify/src/index.ts deleted file mode 100644 index ae28971ef..000000000 --- a/packages/nestjs-auth-verify/src/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -export { AuthVerifyModule } from './auth-verify.module'; -export { AuthVerifyService } from './services/auth-verify.service'; -export { AuthVerifyNotificationService } from './services/auth-verify-notification.service'; -export { AuthVerifyDto } from './dto/auth-verify.dto'; -export { AuthVerifyUpdateDto } from './dto/auth-verify-update.dto'; - -// tokens -export { - AuthVerifyOtpService, - AuthVerifyEmailService, - AuthVerifyUserModelService, -} from './auth-verify.constants'; - -// interfaces -export { AuthVerifySettingsInterface } from './interfaces/auth-verify-settings.interface'; -export { AuthVerifyOptionsInterface } from './interfaces/auth-verify-options.interface'; -export { AuthVerifyOptionsExtrasInterface } from './interfaces/auth-verify-options-extras.interface'; -export { AuthVerifyEmailServiceInterface } from './interfaces/auth-verify-email.service.interface'; -export { AuthVerifyUserModelServiceInterface } from './interfaces/auth-verify-user-model.service.interface'; -export { AuthVerifyOtpServiceInterface } from './interfaces/auth-verify-otp.service.interface'; - -// exceptions -export { AuthVerifyException } from './exceptions/auth-verify.exception'; -export { AuthRecoveryOtpInvalidException } from './exceptions/auth-verify-otp-invalid.exception'; diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-confirm-params.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-confirm-params.interface.ts deleted file mode 100644 index e4661494b..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-confirm-params.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface AuthVerifyConfirmParamsInterface { - passcode: string; -} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-email-params.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-email-params.interface.ts deleted file mode 100644 index 10ae1bc50..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-email-params.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface AuthVerifyEmailParamsInterface { - email: string; - passcode: string; - resetTokenExp: Date; -} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-email.service.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-email.service.interface.ts deleted file mode 100644 index f1b62ee77..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-email.service.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { EmailSendInterface } from '@concepta/nestjs-common'; - -export interface AuthVerifyEmailServiceInterface extends EmailSendInterface {} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-notification.service.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-notification.service.interface.ts deleted file mode 100644 index e979418da..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-notification.service.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { EmailSendOptionsInterface } from '@concepta/nestjs-common'; - -import { AuthVerifyEmailParamsInterface } from './auth-verify-email-params.interface'; - -export interface AuthVerifyNotificationServiceInterface { - sendEmail(sendMailOptions: EmailSendOptionsInterface): Promise; - sendVerifyEmail(params: AuthVerifyEmailParamsInterface): Promise; -} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-options-extras.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-options-extras.interface.ts deleted file mode 100644 index 1c2921cdb..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-options-extras.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface AuthVerifyOptionsExtrasInterface - extends Pick {} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-options.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-options.interface.ts deleted file mode 100644 index 331af694f..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-options.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AuthVerifyEmailServiceInterface } from './auth-verify-email.service.interface'; -import { AuthVerifyNotificationServiceInterface } from './auth-verify-notification.service.interface'; -import { AuthVerifyOtpServiceInterface } from './auth-verify-otp.service.interface'; -import { AuthVerifySettingsInterface } from './auth-verify-settings.interface'; -import { AuthVerifyUserModelServiceInterface } from './auth-verify-user-model.service.interface'; - -export interface AuthVerifyOptionsInterface { - settings?: AuthVerifySettingsInterface; - otpService: AuthVerifyOtpServiceInterface; - emailService: AuthVerifyEmailServiceInterface; - userModelService: AuthVerifyUserModelServiceInterface; - notificationService?: AuthVerifyNotificationServiceInterface; -} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-otp.service.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-otp.service.interface.ts deleted file mode 100644 index d90683353..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-otp.service.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - OtpClearInterface, - OtpCreateInterface, - OtpValidateInterface, -} from '@concepta/nestjs-common'; - -export interface AuthVerifyOtpServiceInterface - extends OtpCreateInterface, - OtpValidateInterface, - OtpClearInterface {} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-revoke-params.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-revoke-params.interface.ts deleted file mode 100644 index 9278bd079..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-revoke-params.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { AuthVerifySendParamsInterface } from './auth-verify-send-params.interface'; - -export interface AuthVerifyRevokeParamsInterface - extends AuthVerifySendParamsInterface {} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-send-params.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-send-params.interface.ts deleted file mode 100644 index 2ae626cdb..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-send-params.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface AuthVerifySendParamsInterface { - email: string; -} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-settings.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-settings.interface.ts deleted file mode 100644 index f55db8c1f..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-settings.interface.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - ReferenceAssignment, - OtpCreatableInterface, -} from '@concepta/nestjs-common'; - -export interface AuthVerifyOtpSettingsInterface - extends Pick, - Partial> { - assignment: ReferenceAssignment; - clearOtpOnCreate?: boolean; -} - -export interface AuthVerifySettingsInterface { - email: { - from: string; - baseUrl: string; - tokenUrlFormatter?: (baseUrl: string, passcode: string) => string; - templates: { - verifyEmail: { - fileName: string; - subject: string; - }; - }; - }; - otp: AuthVerifyOtpSettingsInterface; -} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-user-model.service.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-user-model.service.interface.ts deleted file mode 100644 index 44d12719b..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-user-model.service.interface.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - ByEmailInterface, - ByIdInterface, - ReferenceActiveInterface, - ReferenceEmail, - ReferenceEmailInterface, - ReferenceId, - ReferenceIdInterface, - ReferenceUsernameInterface, - UpdateOneInterface, -} from '@concepta/nestjs-common'; - -export interface AuthVerifyUserModelServiceInterface - extends ByIdInterface, - ByEmailInterface< - ReferenceEmail, - ReferenceIdInterface & ReferenceUsernameInterface - >, - UpdateOneInterface< - ReferenceIdInterface & ReferenceActiveInterface, - ReferenceIdInterface & ReferenceEmailInterface & ReferenceActiveInterface - > {} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-validate-params.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-validate-params.interface.ts deleted file mode 100644 index ca09f6d97..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-validate-params.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { AuthVerifyConfirmParamsInterface } from './auth-verify-confirm-params.interface'; - -export interface AuthVerifyValidateParamsInterface - extends AuthVerifyConfirmParamsInterface { - deleteIfValid?: boolean; -} diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify.service.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify.service.interface.ts deleted file mode 100644 index e9f474324..000000000 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify.service.interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ReferenceIdInterface } from '@concepta/nestjs-common'; - -import { AuthVerifyConfirmParamsInterface } from './auth-verify-confirm-params.interface'; -import { AuthVerifyRevokeParamsInterface } from './auth-verify-revoke-params.interface'; -import { AuthVerifySendParamsInterface } from './auth-verify-send-params.interface'; - -export interface AuthVerifyServiceInterface { - send(params: AuthVerifySendParamsInterface): Promise; - confirmUser( - params: AuthVerifyConfirmParamsInterface, - ): Promise; - revokeAllUserVerifyToken( - params: AuthVerifyRevokeParamsInterface, - ): Promise; -} diff --git a/packages/nestjs-auth-verify/src/services/auth-verify-notification.service.spec.ts b/packages/nestjs-auth-verify/src/services/auth-verify-notification.service.spec.ts deleted file mode 100644 index 927f02c2e..000000000 --- a/packages/nestjs-auth-verify/src/services/auth-verify-notification.service.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { EmailService } from '@concepta/nestjs-email'; - -import { AuthVerifyEmailService } from '../auth-verify.constants'; - -import { AuthVerifyNotificationService } from './auth-verify-notification.service'; - -import { AppModuleFixture } from '../__fixtures__/app.module.fixture'; - -describe('AuthVerifyNotificationService', () => { - let app: INestApplication; - let emailService: EmailService; - let authVerifyNotificationService: AuthVerifyNotificationService; - - let spyEmailService: jest.SpyInstance; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - - emailService = moduleFixture.get(AuthVerifyEmailService); - - spyEmailService = jest - .spyOn(emailService, 'sendMail') - .mockResolvedValue(undefined); - - authVerifyNotificationService = - moduleFixture.get( - AuthVerifyNotificationService, - ); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - it('Send email', async () => { - await authVerifyNotificationService.sendEmail({}); - expect(spyEmailService).toHaveBeenCalledTimes(1); - }); - - it('Send verify email passcode', async () => { - await authVerifyNotificationService.sendVerifyEmail({ - email: 'me@mail.com', - passcode: 'me', - resetTokenExp: new Date(), - }); - expect(spyEmailService).toHaveBeenCalledTimes(1); - }); - - it('Send verify email passcode', async () => { - authVerifyNotificationService['settings'].email.tokenUrlFormatter = - undefined; - - await authVerifyNotificationService.sendVerifyEmail({ - email: 'me@mail.com', - passcode: 'me', - resetTokenExp: new Date(), - }); - expect(spyEmailService).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/nestjs-auth-verify/src/services/auth-verify-notification.service.ts b/packages/nestjs-auth-verify/src/services/auth-verify-notification.service.ts deleted file mode 100644 index 7be8f0a4c..000000000 --- a/packages/nestjs-auth-verify/src/services/auth-verify-notification.service.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { EmailSendOptionsInterface } from '@concepta/nestjs-common'; - -import { - AUTH_VERIFY_MODULE_SETTINGS_TOKEN, - AuthVerifyEmailService, -} from '../auth-verify.constants'; -import { formatTokenUrl } from '../auth-verify.utils'; -import { AuthVerifyEmailParamsInterface } from '../interfaces/auth-verify-email-params.interface'; -import { AuthVerifyEmailServiceInterface } from '../interfaces/auth-verify-email.service.interface'; -import { AuthVerifyNotificationServiceInterface } from '../interfaces/auth-verify-notification.service.interface'; -import { AuthVerifySettingsInterface } from '../interfaces/auth-verify-settings.interface'; - -@Injectable() -export class AuthVerifyNotificationService - implements AuthVerifyNotificationServiceInterface -{ - constructor( - @Inject(AUTH_VERIFY_MODULE_SETTINGS_TOKEN) - private readonly settings: AuthVerifySettingsInterface, - @Inject(AuthVerifyEmailService) - private readonly emailService: AuthVerifyEmailServiceInterface, - ) {} - - async sendEmail(sendMailOptions: EmailSendOptionsInterface): Promise { - await this.emailService.sendMail(sendMailOptions); - } - - async sendVerifyEmail(params: AuthVerifyEmailParamsInterface): Promise { - const { email, passcode, resetTokenExp } = params; - const { - from, - baseUrl, - tokenUrlFormatter = formatTokenUrl, - } = this.settings.email; - const { subject, fileName } = this.settings.email.templates.verifyEmail; - await this.sendEmail({ - from, - subject, - to: email, - template: fileName, - context: { - tokenUrl: tokenUrlFormatter(baseUrl, passcode), - tokenExp: resetTokenExp, - }, - }); - } -} diff --git a/packages/nestjs-auth-verify/src/services/auth-verify.service.spec.ts b/packages/nestjs-auth-verify/src/services/auth-verify.service.spec.ts deleted file mode 100644 index 8cd9a5c2b..000000000 --- a/packages/nestjs-auth-verify/src/services/auth-verify.service.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { - AUTH_VERIFY_MODULE_SETTINGS_TOKEN, - AuthVerifyUserModelService, -} from '../auth-verify.constants'; -import { AuthRecoveryOtpInvalidException } from '../exceptions/auth-verify-otp-invalid.exception'; -import { AuthVerifyNotificationServiceInterface } from '../interfaces/auth-verify-notification.service.interface'; -import { AuthVerifyOtpServiceInterface } from '../interfaces/auth-verify-otp.service.interface'; -import { AuthVerifySettingsInterface } from '../interfaces/auth-verify-settings.interface'; -import { AuthVerifyUserModelServiceInterface } from '../interfaces/auth-verify-user-model.service.interface'; - -import { AuthVerifyNotificationService } from './auth-verify-notification.service'; -import { AuthVerifyService } from './auth-verify.service'; - -import { AppModuleFixture } from '../__fixtures__/app.module.fixture'; -import { OtpServiceFixture } from '../__fixtures__/otp/otp.service.fixture'; -import { UserFixture } from '../__fixtures__/user/user.fixture'; - -describe(AuthVerifyService, () => { - let app: INestApplication; - let authVerifyService: AuthVerifyService; - let notificationService: AuthVerifyNotificationServiceInterface; - let otpService: AuthVerifyOtpServiceInterface; - let userModelService: AuthVerifyUserModelServiceInterface; - let settings: AuthVerifySettingsInterface; - - let sendVerifyEmail: jest.SpyInstance; - let spyOtpServiceValidate: jest.SpyInstance; - let spyUserModelServiceByEmail: jest.SpyInstance; - let spyUserModelServiceUpdate: jest.SpyInstance; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - - authVerifyService = moduleFixture.get(AuthVerifyService); - - otpService = - moduleFixture.get(OtpServiceFixture); - - settings = moduleFixture.get( - AUTH_VERIFY_MODULE_SETTINGS_TOKEN, - ); - - notificationService = - moduleFixture.get( - AuthVerifyNotificationService, - ); - - userModelService = moduleFixture.get( - AuthVerifyUserModelService, - ); - - sendVerifyEmail = jest - .spyOn(notificationService, 'sendVerifyEmail') - .mockResolvedValue(undefined); - - spyOtpServiceValidate = jest.spyOn(otpService, 'validate'); - spyUserModelServiceByEmail = jest.spyOn(userModelService, 'byEmail'); - spyUserModelServiceUpdate = jest.spyOn(userModelService, 'update'); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - describe(AuthVerifyService.prototype.send, () => { - it('should send passcode verify', async () => { - const result = await authVerifyService.send({ email: UserFixture.email }); - - expect(result).toBeUndefined(); - expect(spyUserModelServiceByEmail).toHaveBeenCalledTimes(1); - expect(spyUserModelServiceByEmail).toHaveBeenCalledWith( - UserFixture.email, - ); - - expect(sendVerifyEmail).toHaveBeenCalledTimes(1); - expect(sendVerifyEmail).toHaveBeenCalledWith({ - email: UserFixture.email, - passcode: 'GOOD_PASSCODE', - resetTokenExp: expect.any(Date), - }); - }); - }); - - describe(AuthVerifyService.prototype.validatePasscode, () => { - it('should call otp validator', async () => { - await authVerifyService.validatePasscode({ passcode: 'GOOD_PASSCODE' }); - - expect(spyOtpServiceValidate).toHaveBeenCalledWith( - settings.otp.assignment, - { category: settings.otp.category, passcode: 'GOOD_PASSCODE' }, - false, - ); - }); - - it('should validate good passcode', async () => { - const otp = await authVerifyService.validatePasscode({ - passcode: 'GOOD_PASSCODE', - }); - expect(otp).toEqual({ assigneeId: UserFixture.id }); - }); - - it('should not validate bad passcode', async () => { - const otp = await authVerifyService.validatePasscode({ - passcode: 'BAD_PASSCODE', - }); - expect(otp).toBeNull(); - }); - }); - - describe(AuthVerifyService.prototype.confirmUser, () => { - it('should call user model service', async () => { - await authVerifyService.confirmUser({ passcode: 'GOOD_PASSCODE' }); - - expect(spyUserModelServiceUpdate).toHaveBeenCalledTimes(1); - expect(spyUserModelServiceUpdate).toHaveBeenCalledWith({ - id: UserFixture.id, - active: true, - }); - }); - - it('should confirm user', async () => { - const user = await authVerifyService.confirmUser({ - passcode: 'GOOD_PASSCODE', - }); - - expect(user).toEqual(UserFixture); - }); - - it('should fail to confirm user', async () => { - const t = async () => { - await authVerifyService.confirmUser({ - passcode: 'FAKE_PASSCODE', - }); - }; - - expect(t).rejects.toThrow(AuthRecoveryOtpInvalidException); - }); - }); -}); diff --git a/packages/nestjs-auth-verify/src/services/auth-verify.service.ts b/packages/nestjs-auth-verify/src/services/auth-verify.service.ts deleted file mode 100644 index f7a59cb33..000000000 --- a/packages/nestjs-auth-verify/src/services/auth-verify.service.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { - AssigneeRelationInterface, - ReferenceIdInterface, -} from '@concepta/nestjs-common'; - -import { - AUTH_VERIFY_MODULE_SETTINGS_TOKEN, - AuthVerifyOtpService, - AuthVerifyUserModelService, -} from '../auth-verify.constants'; -import { AuthRecoveryOtpInvalidException } from '../exceptions/auth-verify-otp-invalid.exception'; -import { AuthVerifyConfirmParamsInterface } from '../interfaces/auth-verify-confirm-params.interface'; -import { AuthVerifyNotificationServiceInterface } from '../interfaces/auth-verify-notification.service.interface'; -import { AuthVerifyOtpServiceInterface } from '../interfaces/auth-verify-otp.service.interface'; -import { AuthVerifyRevokeParamsInterface } from '../interfaces/auth-verify-revoke-params.interface'; -import { AuthVerifySendParamsInterface } from '../interfaces/auth-verify-send-params.interface'; -import { AuthVerifySettingsInterface } from '../interfaces/auth-verify-settings.interface'; -import { AuthVerifyUserModelServiceInterface } from '../interfaces/auth-verify-user-model.service.interface'; -import { AuthVerifyValidateParamsInterface } from '../interfaces/auth-verify-validate-params.interface'; -import { AuthVerifyServiceInterface } from '../interfaces/auth-verify.service.interface'; - -import { AuthVerifyNotificationService } from './auth-verify-notification.service'; - -@Injectable() -export class AuthVerifyService implements AuthVerifyServiceInterface { - constructor( - @Inject(AUTH_VERIFY_MODULE_SETTINGS_TOKEN) - private readonly config: AuthVerifySettingsInterface, - @Inject(AuthVerifyOtpService) - private readonly otpService: AuthVerifyOtpServiceInterface, - @Inject(AuthVerifyUserModelService) - private readonly userModelService: AuthVerifyUserModelServiceInterface, - @Inject(AuthVerifyNotificationService) - private readonly notificationService: AuthVerifyNotificationServiceInterface, - ) {} - - /** - * Send an email to verify a user's email address. - * - * This method: - * 1. Looks up the user by email - * 2. If found, creates a one-time passcode (OTP) - * 3. Sends verification email with the OTP - * 4. Returns void regardless of whether user exists (for security) - * - * @param params - Parameters for sending verification email - */ - async send(params: AuthVerifySendParamsInterface): Promise { - const { email } = params; - - // verify the user by providing an email - const user = await this.userModelService.byEmail(email); - - // did we find a user? - if (user) { - // extract required otp properties - const { - category, - assignment, - type, - expiresIn, - clearOtpOnCreate, - rateSeconds, - rateThreshold, - } = this.config.otp; - - // create an OTP save it in the database - const otp = await this.otpService.create({ - assignment, - otp: { - category, - type, - expiresIn, - assigneeId: user.id, - }, - clearOnCreate: clearOtpOnCreate, - rateSeconds, - rateThreshold, - }); - - // send en email with a verify OTP - await this.notificationService.sendVerifyEmail({ - email, - passcode: otp.passcode, - resetTokenExp: otp.expirationDate, - }); - } - - // !!! Falling through to void is intentional !!!! - // !!! Do NOT give any indication if e-mail does not exist !!!! - } - - /** - * Send an email to verify a user's email address. - * - * This method: - * 1. Looks up the user by email - * 2. If found, creates a one-time passcode (OTP) - * 3. Sends verification email with the OTP - * 4. Returns void regardless of whether user exists (for security) - * - * @param params - Parameters for sending verification email - */ - async validatePasscode( - params: AuthVerifyValidateParamsInterface, - ): Promise { - const { passcode, deleteIfValid = false } = params; - // extract required properties - const { category, assignment } = this.config.otp; - - // validate passcode return passcode's user was found - return this.otpService.validate( - assignment, - { category, passcode }, - deleteIfValid, - ); - } - - /** - * Confirms a user's account by validating their OTP passcode. - * - * This method: - * 1. Validates the provided OTP passcode - * 2. If valid, marks the user's account as active - * 3. Revokes all other verification tokens for this user - * 4. Returns the updated user if successful, null if invalid passcode - * - * @param params - Parameters for confirming user - */ - async confirmUser( - params: AuthVerifyConfirmParamsInterface, - ): Promise { - const { passcode } = params; - - // get otp by passcode, but no delete it until all workflow pass - const otp = await this.validatePasscode({ - passcode, - deleteIfValid: true, - }); - - // did we get an otp? - if (otp) { - // call user model service - const user = await this.userModelService.update({ - id: otp.assigneeId, - active: true, - }); - - if (user) { - await this.revokeAllUserVerifyToken({ - email: user.email, - }); - - return user; - } - } - - // otp was not found - throw new AuthRecoveryOtpInvalidException(); - } - - /** - * Revokes all verification tokens for a given user - * - * @param params - Parameters for revoking tokens - * @returns Promise that resolves when tokens are revoked - */ - async revokeAllUserVerifyToken( - params: AuthVerifyRevokeParamsInterface, - ): Promise { - const { email } = params; - // verify user by providing an email - const user = await this.userModelService.byEmail(email); - - // did we find a user? - if (user) { - // extract required otp properties - const { category, assignment } = this.config.otp; - // clear all user's otps in DB - await this.otpService.clear(assignment, { - category, - assigneeId: user.id, - }); - } - } -} diff --git a/packages/nestjs-auth-verify/tsconfig.json b/packages/nestjs-auth-verify/tsconfig.json deleted file mode 100644 index ef9980950..000000000 --- a/packages/nestjs-auth-verify/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": [ - "./node_modules/@types", - "../../node_modules/@types" - ] - }, - "include": [ - "src/**/*.ts" - ] -} diff --git a/packages/nestjs-auth-verify/typedoc.json b/packages/nestjs-auth-verify/typedoc.json deleted file mode 100644 index 944fda5ad..000000000 --- a/packages/nestjs-auth-verify/typedoc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "entryPoints": ["src/index.ts"] -} \ No newline at end of file diff --git a/packages/nestjs-authentication/README.md b/packages/nestjs-authentication/README.md index d7e45617b..7808f6c6a 100644 --- a/packages/nestjs-authentication/README.md +++ b/packages/nestjs-authentication/README.md @@ -1,791 +1,1536 @@ -# Authentication Module Documentation - -## Project +# @concepta/nestjs-authentication [![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-authentication)](https://www.npmjs.com/package/@concepta/nestjs-authentication) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-authentication)](https://www.npmjs.com/package/@concepta/nestjs-authentication) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-authentication)](https://www.npmjs.com/package/@concepta/nestjs-authentication) [![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) [![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-authentication%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +Comprehensive NestJS authentication module built on CQRS and clean architecture. +Includes local (username/password), JWT bearer, refresh token, password recovery, +email verification, and OAuth provider routing — all in a single, unified module. + +Request validation and OpenAPI documentation are schema-first: every request +body is described by a native Zod v4 schema exposed through the +[Standard Schema](https://standardschema.dev) interface — there are no +class-validator DTO classes. ## Table of Contents -- [Tutorials](#tutorials) - - [Introduction](#introduction) - - [Overview of the Library](#overview-of-the-library) - - [Purpose and Key Features](#purpose-and-key-features) - - [Installation](#installation) - - [Getting Started](#getting-started) - - [Overview](#overview) - - [Basic Setup](#basic-setup) - - [Basic Setup in a NestJS Project](#basic-setup-in-a-nestjs-project) - - [Scenario: Users have a list of pets](#scenario-users-have-a-list-of-pets) - - [Step 1: Create Entities](#step-1-create-entities) - - [Step 2: Create Services](#step-2-create-services) - - [Step 3: Create Controller](#step-3-create-controller) - - [Step 4: Configure the Module](#step-4-configure-the-module) - - [First Authentication with JWT](#first-authentication-with-jwt) - - [Validating the Setup](#validating-the-setup) - - [Step 1: Obtain a JWT Token](#step-1-obtain-a-jwt-token) - - [Step 2: Make an Authenticated Request](#step-2-make-an-authenticated-request) - - [Example Curl Calls](#example-curl-calls) -- [How to Guides](#how-to-guides) - - [1. How to Set Up AuthenticationModule with forRoot and JwtModule from @concepta/nestjs-jwt](#1-how-to-set-up-authenticationmodule-with-forroot-and-jwtmodule-from-conceptanestjs-jwt) - - [2. How to Configure AuthenticationModule Settings](#2-how-to-configure-authenticationmodule-settings) -- [Explanation](#explanation) - - [Conceptual Overview](#conceptual-overview) - - [What is This Library?](#what-is-this-library) - - [Benefits of Using This Library](#benefits-of-using-this-library) - - [Design Choices](#design-choices) - - [Why Use NestJS Guards?](#why-use-nestjs-guards) - - [Global, Synchronous vs Asynchronous Registration](#global-synchronous-vs-asynchronous-registration) - - [Integration Details](#integration-details) - - [Integrating with Other Modules](#integrating-with-other-modules) - -# Tutorials - -## Introduction - -### Overview of the Library - -This module is designed to manage JWT authentication processes within a -NestJS application. It includes services for issuing JWTs, validating user -credentials, and verifying tokens. The services handle the generation of -access and refresh tokens, ensure users are active and meet authentication -criteria, and perform token validity checks, including additional validations -if necessary. This comprehensive approach ensures secure user authentication -and efficient token management. - -### Purpose and Key Features - -- **Secure Token Management**: Provides robust mechanisms for issuing and - managing access and refresh tokens, ensuring secure and efficient token - lifecycle management. -- **Abstract User Validation Service**: Offers an abstract service to validate - user credentials and check user activity status, ensuring that only eligible - users can authenticate. This abstract nature requires implementations to - define specific validation logic, allowing flexibility across different user - models and authentication requirements. -- **Token Verification**: Includes capabilities to verify the authenticity and - validity of tokens, with support for additional custom validations to meet - specific security requirements. -- **Customizable and Extensible**: Designed to be flexible, allowing - customization of token generation, user validation, and token verification - processes to suit different application needs. -- **Integration with NestJS Ecosystem**: Seamlessly integrates with other - NestJS modules and services, leveraging the framework's features for enhanced - functionality and performance. - -#### Installation - -To get started, install the `AuthenticationModule` package: - -`yarn add @concepta/nestjs-authentication` - -## Getting Started - -### Overview - -This section covers the basics of setting up the `AuthenticationModule` -in a NestJS application. - -### Basic Setup - -The `@concepta/nestjs-authentication` module is designed to integrate -seamlessly with other modules in the authentication suite, such as -`@concepta/nestjs-auth-jwt`, `@concepta/nestjs-auth-local`, -`@concepta/nestjs-auth-recovery`, and `@concepta/nestjs-auth-refresh`. - -For optimal functionality, it is recommended to use these modules together to -address various aspects of authentication and token management in your NestJS -application. - -To set up the `@concepta/nestjs-authentication` module, begin by installing -the necessary packages using your package manager. - -Here is a basic example using `yarn`: - -```sh -yarn add @concepta/nestjs-authentication @concepta/nestjs-auth-jwt @concepta/nestjs-auth-local @concepta/nestjs-auth-recovery @concepta/nestjs-auth-refresh -``` - -### Basic Setup in a NestJS Project - -#### Scenario: Users have a list of pets - -To demonstrate this scenario, we will set up an application -where users can have a list of pets. We will create the necessary entities, -services, module configurations to simulate the environment. +- [Overview](#overview) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [End-to-End Example](#end-to-end-example) +- [Features](#features) + - [JWT Bearer Authentication](#jwt-bearer-authentication) + - [Local (Username/Password) Login](#local-usernamepassword-login) + - [Refresh Tokens](#refresh-tokens) + - [Password Recovery](#password-recovery) + - [Email Verification](#email-verification) + - [OAuth Provider Router](#oauth-provider-router) +- [Validation Schemas](#validation-schemas) +- [Configuration Reference](#configuration-reference) + - [Module Options](#module-options) + - [JWT Settings](#jwt-settings) + - [Strategy Settings](#strategy-settings) + - [MFA Settings](#mfa-settings) + - [Port Settings](#port-settings) + - [Extras](#extras) +- [Exceptions](#exceptions) +- [Advanced](#advanced) + - [Two-Tier CQRS Architecture](#two-tier-cqrs-architecture) + - [Custom Notification Commands](#custom-notification-commands) + - [Disabling the Global Guard](#disabling-the-global-guard) + - [Context Overlay](#context-overlay) +- [Exports Reference](#exports-reference) +- [Related Packages](#related-packages) + +--- + +## Overview + +`@concepta/nestjs-authentication` consolidates six authentication features +into a single package: + +| Feature | What it provides | +|---|---| +| **JWT** | Bearer token verification, global APP_GUARD, `@AuthPublic`/`@AuthUser` | +| **Local** | Username/password login via `passport-local` | +| **Refresh** | Refresh token verification and re-issuance | +| **Recovery** | OTP-based password recovery (recover-login, recover-password, update-password) | +| **Verify** | OTP-based email/account verification | +| **Router** | `?provider=` query dispatch to named OAuth guards | + +Internally the module is structured in three layers: + +- **Domain** — aggregates, ports, policies, events, exceptions; zero framework + dependencies. +- **Application** — CQRS command/query handlers that orchestrate the domain. +- **Infrastructure** — Passport strategies, JWT service, Zod request schemas, + config, the `AuthUserContextOverlay` gateway. + +A key design point: **Passport strategies never issue tokens.** A strategy +validates credentials and places the authenticated *user* on `request.user`. +Your controller then issues the access/refresh token pair by executing +`IssueAuthenticatedResponseCommand` on the `CommandBus`. This keeps +strategies transport-agnostic and token issuance overridable via +[ports](#port-settings). + +OAuth provider strategies (Apple, GitHub, Google) live in separate packages +(`@concepta/nestjs-auth-apple`, `-github`, `-google`). This module provides +the `AuthRouterGuard` dispatcher that routes to them and the OAuth utility +types they depend on. + +--- + +## Installation -> Note: The `@concepta/nestjs-user` module can be used in place of -> our example `User` related prerequisites. +```bash +yarn add @concepta/nestjs-authentication +``` -#### Step 1: Create Entities +Peer dependencies: -First, create the `User` and `Pet` entities. +```bash +yarn add rxjs @nestjs/common @nestjs/config @nestjs/core @nestjs/swagger +``` -```typescript -// user.entity.ts -import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; -import { Pet } from './pet.entity'; +`@nestjs/cqrs` is also a peer, marked optional in `peerDependenciesMeta`, +but is required in practice — every port-backed feature (recovery, verify, +OAuth router) dispatches through it. -@Entity() -export class User { - @PrimaryGeneratedColumn('uuid') - id: string; +Requirements: - @Column() - name: string; +- **ESM-only** — the package ships native ES modules (no CommonJS build). +- **Node.js >= 22.12** +- **NestJS 12** - @OneToMany(() => Pet, pet => pet.user) - pets: Pet[]; -} -``` +Zod v4 and `@standard-schema/spec` are regular dependencies — you do not need +to install them yourself unless you author your own schemas. + +--- + +## Quick Start + +The minimal setup — JWT verification only, no local login, no MFA — requires +only `settings.jwt`: ```typescript -// pet.entity.ts -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user.entity'; +import { AuthenticationModule } from '@concepta/nestjs-authentication'; -@Entity() -export class Pet { - @PrimaryGeneratedColumn('uuid') - id: string; +@Module({ + imports: [ + AuthenticationModule.forRoot({ + settings: { + jwt: { + access: { + secret: process.env.JWT_ACCESS_SECRET, + signOptions: { expiresIn: '15m' }, + }, + refresh: { + secret: process.env.JWT_REFRESH_SECRET, + signOptions: { expiresIn: '7d' }, + }, + }, + strategies: { + jwt: {}, // activates JwtStrategy + global APP_GUARD + }, + }, + }), + ], +}) +export class AppModule {} +``` - @Column() - name: string; +All routes are protected by default. Mark public routes with `@AuthPublic()`. +When applying the decorator to an entire controller class, pass +`{ classLevel: true }` — without it, a runtime warning is emitted on every +request that hits the class-level decorator: + +```typescript +import { AuthPublic } from '@concepta/nestjs-authentication'; - @ManyToOne(() => User, user => user.pets) - user: User; +// class level — explicit opt-in required +@AuthPublic({ classLevel: true }) +@Controller('health') +export class HealthController { + @Get() + check() { return 'ok'; } +} + +// method level — no option needed +@Controller('info') +export class InfoController { + @AuthPublic() + @Get() + version() { return '1.0.0'; } } ``` -#### Step 2: Create Services +Activate additional features by adding keys to `settings.strategies` and +`settings.mfa` and supplying the corresponding `ports.*` settings. See the +[Configuration Reference](#configuration-reference) and the +[End-to-End Example](#end-to-end-example) below. -Next, create services for `User` and `Pet` to handle the business logic. +--- -```typescript -// user.service.ts -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User } from './user.entity'; +## End-to-End Example -@Injectable() -export class UserService { - constructor( - @InjectRepository(User) - private userRepository: Repository, - ) {} +This example wires up local login, refresh, and JWT bearer auth. The scenario: - async findAll(): Promise { - return this.userRepository.find({ relations: ['pets'] }); - } +- `POST /auth/login` — accepts `username`/`password`, returns + `accessToken` + `refreshToken`. +- `POST /token/refresh` — accepts `refreshToken`, returns a new token pair. +- `GET /me` — returns the authenticated user (protected by the global JWT guard). - async findOne(id: string): Promise { - return this.userRepository.findOne({ - where: { id }, - relations: ['pets'], - }); - } +### Step 1 — Implement UserPort queries and handlers - async create(userData: Partial): Promise { - const newUser = this.userRepository.create(userData); - await this.userRepository.save(newUser); - return newUser; +The module needs to look up users by id, subject (JWT sub), username, and +email. You provide CQRS Query/Command classes that implement the port interfaces. + +```typescript +// src/user/queries/get-user-by-username.query.ts +import { PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; +import { + AuthenticationUserResult, + GetUserByUsernameQueryInterface, +} from '@concepta/nestjs-authentication'; + +export class GetUserByUsernameQuery + extends Query + implements GetUserByUsernameQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly username: string, + ) { + super(); } } ``` ```typescript -// pet.service.ts -import { Injectable } from '@nestjs/common'; +// src/user/queries/get-user-by-username.handler.ts +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Pet } from './pet.entity'; +import { AuthenticationUserResult } from '@concepta/nestjs-authentication'; +import { GetUserByUsernameQuery } from './get-user-by-username.query'; +import { UserEntity } from '../user.entity'; -@Injectable() -export class PetService { +@QueryHandler(GetUserByUsernameQuery) +export class GetUserByUsernameHandler + implements IQueryHandler +{ constructor( - @InjectRepository(Pet) - private petRepository: Repository, + @InjectRepository(UserEntity) + private readonly repo: Repository, ) {} - findAll(): Promise { - return this.petRepository.find(); + async execute(query: GetUserByUsernameQuery): Promise { + return this.repo.findOne({ where: { username: query.username } }); } +} +``` + +Repeat this pattern for `GetUserByIdQuery`, `GetUserBySubjectQuery`, +`GetUserByEmailQuery`, and `UpdateUserCommand` — each implementing the +corresponding interface exported from `@concepta/nestjs-authentication` +(`GetUserByIdQueryInterface`, `GetUserBySubjectQueryInterface`, +`GetUserByEmailQueryInterface`, `UpdateUserCommandInterface`). + +### Step 2 — Implement PasswordPort commands + +Password hashing and validation come from `@concepta/nestjs-password`. Its +`PasswordValidationService.validate()` takes the plain password and the stored +hash: - findByUserId(userId: number): Promise { - return this.petRepository.find({ where: { user: { id: userId } } }); +```typescript +// src/user/commands/validate-password.command.ts +import { PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; +import { + ValidatePasswordCommandInterface, +} from '@concepta/nestjs-authentication'; +import { ReferenceIdInterface } from '@concepta/nestjs-core'; + +export class ValidatePasswordCommand + extends Command + implements ValidatePasswordCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly password: string, + public readonly target: ReferenceIdInterface, + ) { + super(); } } ``` -Create the Model Service for AuthJwtModule - ```typescript -// my-jwt-user-model.service.ts -import { AuthJwtUserModelServiceInterface } from '@concepta/nestjs-auth-jwt'; -import { ReferenceIdInterface, ReferenceSubject } from '@concepta/nestjs-common'; +// src/user/commands/validate-password.handler.ts +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { PasswordValidationService } from '@concepta/nestjs-password'; +import { ValidatePasswordCommand } from './validate-password.command'; +import { UserRepository } from '../user.repository'; + +@CommandHandler(ValidatePasswordCommand) +export class ValidatePasswordHandler + implements ICommandHandler +{ + constructor( + private readonly userRepo: UserRepository, + private readonly passwordValidationService: PasswordValidationService, + ) {} -export class MyJwtUserModelService implements AuthJwtUserModelServiceInterface { - async bySubject(subject: ReferenceSubject): Promise { - // return authorized user - return { - id: '5b3f5fd3-9426-4c4d-a06d-b4d55079034d', - }; + async execute(command: ValidatePasswordCommand): Promise { + const user = await this.userRepo.findById(command.target.id); + if (!user || !user.passwordHash) return false; + return this.passwordValidationService.validate({ + password: command.password, + passwordHash: user.passwordHash, + }); } } ``` -Create the Model Service for Auth Local +Alternatively, dispatch `ValidatePasswordCommand` from +`@concepta/nestjs-password` itself on the `CommandBus` — the package registers +its own `ValidatePasswordHandler` that performs the same check. + +Provide `SetPasswordCommand` in the same way (implementing +`SetPasswordCommandInterface`). -```ts -// my-auth-local-user-model.service.ts -import { Injectable } from '@nestjs/common'; -import { ReferenceUsername } from '@concepta/nestjs-common'; +### Step 3 — Wire up the module + +```typescript +// src/app.module.ts +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { AuthenticationModule } from '@concepta/nestjs-authentication'; + +import { GetUserByIdQuery } from './user/queries/get-user-by-id.query'; +import { GetUserBySubjectQuery } from './user/queries/get-user-by-subject.query'; +import { GetUserByUsernameQuery } from './user/queries/get-user-by-username.query'; +import { GetUserByEmailQuery } from './user/queries/get-user-by-email.query'; +import { UpdateUserCommand } from './user/commands/update-user.command'; +import { ValidatePasswordCommand } from './user/commands/validate-password.command'; +import { SetPasswordCommand } from './user/commands/set-password.command'; +import { userQueryHandlers, userCommandHandlers } from './user/user.handlers'; +import { passwordHandlers } from './user/password.handlers'; + +@Module({ + imports: [ + CqrsModule, + AuthenticationModule.forRoot({ + settings: { + jwt: { + access: { + secret: process.env.JWT_ACCESS_SECRET, + signOptions: { expiresIn: '15m' }, + }, + refresh: { + secret: process.env.JWT_REFRESH_SECRET, + signOptions: { expiresIn: '7d' }, + }, + }, + strategies: { + jwt: {}, // enable JwtStrategy + global APP_GUARD + local: {}, // enable LocalStrategy + refresh: {}, // enable RefreshStrategy + }, + }, + ports: { + user: { + getByIdQuery: GetUserByIdQuery, + getBySubjectQuery: GetUserBySubjectQuery, + getByUsernameQuery: GetUserByUsernameQuery, + getByEmailQuery: GetUserByEmailQuery, + updateCommand: UpdateUserCommand, + }, + password: { + validateCommand: ValidatePasswordCommand, + setPasswordCommand: SetPasswordCommand, + }, + // user, password, otp, recoveryNotification, verifyNotification are + // all required together once ports is provided (jwt/token are the + // only optional pair, defaulting if omitted) — supply stubs or real + // implementations depending on whether you enable settings.mfa.recovery / .verify + otp: { /* ... */ }, + recoveryNotification: { /* ... */ }, + verifyNotification: { /* ... */ }, + }, + }), + ], + providers: [ + ...userQueryHandlers, + ...userCommandHandlers, + ...passwordHandlers, + ], +}) +export class AppModule {} +``` + +### Step 4 — Add login and refresh controllers + +`LocalStrategy` validates the request body against the configured login schema +and calls `LocalService.validateUser()`, which invokes `UserPort` and +`PasswordPort`. The validated **user** is placed on `request.user` — the +strategy does not issue tokens. Your controller issues the token pair by +executing `IssueAuthenticatedResponseCommand`: + +```typescript +// src/auth/local.controller.ts +import { Controller, HttpStatus, Post, UseGuards } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { + ApiBody, + ApiResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; import { - AuthLocalUserModelServiceInterface, - AuthLocalCredentialsInterface -} from '@concepta/nestjs-auth-local'; - -@Injectable() -export class MyAuthLocalUserModelService implements AuthLocalUserModelServiceInterface { - async byUsername( - username: ReferenceUsername, - ): Promise { - // make sure this method will return a valid user with - // correct passwordHash and passwordSalt - return { - id: '5b3f5fd3-9426-4c4d-a06d-b4d55079034d', - username: username, - passwordHash: - '$2b$12$9rQ4qZx8gpTaTR4ic3LQ.OkebyVBa48DP42jErL1zfqF17WeG4hHC', - passwordSalt: '$2b$12$9rQ4qZx8gpTaTR4ic3LQ.O', - active: true, - }; + AuthPublic, + AuthUser, + AuthenticatedResponseInterface, + AuthenticatedUserInterface, + IssueAuthenticatedResponseCommand, + LocalGuard, + authenticationResponseSchema, + localLoginSchema, +} from '@concepta/nestjs-authentication'; + +// The body is consumed by the Passport strategy, not @Body(), so the +// OpenAPI body schema is provided manually via the schema's JSON Schema bridge. +const localLoginBodySchema = localLoginSchema['~standard'].jsonSchema?.input?.({ + target: 'openapi-3.0', +}); + +@Controller('auth/login') +@UseGuards(LocalGuard) +@AuthPublic({ classLevel: true }) +@ApiTags('auth') +export class LocalController { + constructor(private readonly commandBus: CommandBus) {} + + @ApiBody({ + schema: localLoginBodySchema, + description: 'Schema containing username and password.', + }) + @ApiResponse({ + status: HttpStatus.OK, + standardSchema: authenticationResponseSchema, + description: 'Schema containing an access token and a refresh token.', + }) + @ApiUnauthorizedResponse() + @Post() + async login( + @AuthUser() user: AuthenticatedUserInterface, + ): Promise { + return this.commandBus.execute( + new IssueAuthenticatedResponseCommand({}, user.id), + ); } } ``` -Let's create a password validation service and overwrite -the validate method, for demo purposes only. +The refresh controller is shape-identical — `RefreshGuard` verifies the +refresh token, loads the user via `UserPort.getBySubject()`, and the +controller issues a fresh pair: -```ts -// my-auth-local-user-password-validation.service.ts +```typescript +// src/auth/refresh.controller.ts +import { Controller, HttpStatus, Post, UseGuards } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; import { - PasswordStorageInterface, - PasswordValidationService, -} from '@concepta/nestjs-password'; -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class MyAuthLocalPasswordValidationService extends PasswordValidationService { - constructor() { - super(); + ApiBody, + ApiResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { + AuthPublic, + AuthUser, + AuthenticatedResponseInterface, + AuthenticatedUserInterface, + IssueAuthenticatedResponseCommand, + RefreshGuard, + authenticationResponseSchema, + refreshSchema, +} from '@concepta/nestjs-authentication'; + +const refreshBodySchema = refreshSchema['~standard'].jsonSchema?.input?.({ + target: 'openapi-3.0', +}); + +@Controller('token/refresh') +@UseGuards(RefreshGuard) +@AuthPublic({ classLevel: true }) +@ApiTags('auth') +export class RefreshController { + constructor(private readonly commandBus: CommandBus) {} + + @ApiBody({ + schema: refreshBodySchema, + description: 'Schema containing a refresh token.', + }) + @ApiResponse({ + status: HttpStatus.OK, + standardSchema: authenticationResponseSchema, + description: 'Schema containing an access token and a refresh token.', + }) + @ApiUnauthorizedResponse() + @Post() + async refresh( + @AuthUser() user: AuthenticatedUserInterface, + ): Promise { + return this.commandBus.execute( + new IssueAuthenticatedResponseCommand({}, user.id), + ); } +} +``` - async validate(options: { - password: string; - passwordHash: string; - passwordSalt: string; - }): Promise { - // you should call super.validate to use the default password validation - return true; - } +```typescript +// src/me/me.controller.ts +import { Controller, Get } from '@nestjs/common'; +import { AuthUser } from '@concepta/nestjs-authentication'; - async validateObject( - password: string, - object: T, - ): Promise { - return true; +@Controller('me') +export class MeController { + @Get() + profile(@AuthUser() user: unknown) { + return user; } } +``` + +### Step 5 — Test with curl + +```bash +# Obtain tokens +curl -X POST http://localhost:3000/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "alice", "password": "s3cr3t"}' +# => { "accessToken": "eyJ...", "refreshToken": "eyJ..." } +# Access a protected route +curl -X GET http://localhost:3000/me \ + -H "Authorization: Bearer eyJ..." + +# Refresh tokens +curl -X POST http://localhost:3000/token/refresh \ + -H "Content-Type: application/json" \ + -d '{"refreshToken": "eyJ..."}' +# => { "accessToken": "eyJ...", "refreshToken": "eyJ..." } ``` -Let's create a verify service to validate the token that will be -received in the request. -If we need a custom logic to validate the access token you can -overwrite this service. +The response bodies match `authenticationResponseSchema`, which is published +to OpenAPI as the named component `AuthenticationResponse`. -```ts -// jwt-verify-token.service.ts -import { JwtService } from '@nestjs/jwt'; -import { Injectable } from '@nestjs/common'; -import { VerifyTokenServiceInterface } from '@concepta/nestjs-authentication'; -@Injectable() -export class MyJwtVerifyTokenService implements VerifyTokenServiceInterface { - accessToken() { - // your custom logic to sign and validate the the token - return { accessToken: 'access-token' }; - } +--- + +## Features + +### JWT Bearer Authentication + +Activated by setting `settings.strategies.jwt`. Registers a global `APP_GUARD` +that enforces JWT verification on every route. Token extraction defaults to +`Authorization: Bearer `. + +**Decorators:** + +- `@AuthPublic()` — exempts a route handler from the global guard. At class + level, pass `@AuthPublic({ classLevel: true })` to make the intent explicit; + a class-level `@AuthPublic()` without the option triggers a runtime warning + on every request. +- `@AuthUser()` — injects the verified user object into a route parameter. - refreshToken(...args) { - // your custom logic to sign and validate the the token - return { accessToken: 'refresh-token' }; +```typescript +import { AuthPublic, AuthUser } from '@concepta/nestjs-authentication'; + +@AuthPublic({ classLevel: true }) +@Controller('public') +export class PublicController { + @Get() + open() { return 'no token needed'; } +} + +@Controller('private') +export class PrivateController { + @Get('me') + whoAmI(@AuthUser() user: unknown) { + return user; } } ``` -#### Step 3: Create Controller +**Detecting `@AuthPublic()`:** -Create a controller to handle the HTTP requests. - -> Note: Use the `@AuthPublic` decorator from `@concepta/nestjs-authentication` -on the controller or individual routes if you want to override the -global JWT guard to make the route public. +`isAuthPublic()` reads the metadata `@AuthPublic()` sets, without depending +on the underlying metadata key — useful for building tooling (route audits, +a custom guard) that needs to know whether a route was marked public. +Checks every target given, not just the first: ```typescript -// user.controller.ts -import { Controller, Get, Param } from '@nestjs/common'; -import { UserService } from './user.service'; -import { PetService } from './pet.service'; -import { AuthJwtGuard } from '@concepta/nestjs-auth-jwt'; +import { isAuthPublic } from '@concepta/nestjs-authentication'; -@Controller('user') -export class UserController { - constructor( - private userService: UserService, - private petService: PetService, - ) {} +isAuthPublic(context.getHandler(), context.getClass()); // boolean +``` +**Guards exported:** - @Get() - async findAll(): Promise { - return this.userService.findAll(); - } +- `JwtGuard` — `AuthGuard('jwt')` subclass with `canDisable` support. +- `AuthGuard` — base factory function; use to build custom guards: + `AuthGuard(strategyName, options?)`. See `AuthGuardOptions` and + `AuthGuardCtr` for the option/constructor types. - @Post() - async create(@Body() userData: Partial): Promise { - return this.userService.create(userData); - } +**Custom token extraction:** - @Get(':id/pets') - async getPets(@Param('id') userId: number) { - return this.petService.findByUserId(userId); - } +Configure `settings.strategies.jwt` with `jwtFromRequest` (a +`JwtFromRequestFunction` from `passport-jwt`) to change how tokens are +extracted. `ExtractJwt` is re-exported for convenience: + +```typescript +import { ExtractJwt } from '@concepta/nestjs-authentication'; + +settings: { + strategies: { + jwt: { + jwtFromRequest: ExtractJwt.fromUrlQueryParameter('token'), + }, + }, } ``` -#### Step 4: Configure the Module +--- -Configure the module to include the necessary services, controllers, and guards. +### Local (Username/Password) Login -```typescript -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { Module } from '@nestjs/common'; +Activated by setting `settings.strategies.local`. Registers a `passport-local` +strategy. On each login request the strategy: -import { Pet } from './entity/pet.entity'; -import { User } from './user/user.entity'; -import { JwtModule } from '@concepta/nestjs-jwt'; -import { AuthenticationModule } from '@concepta/nestjs-authentication'; -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { MyJwtUserModelService } from './services/my-jwt-user-model.service'; -import { AuthLocalModule } from '@concepta/nestjs-auth-local'; -import { MyAuthLocalUserModelService } from './services/my-auth-local-user-model.service'; -import { MyAuthLocalPasswordValidationService } from './services/my-auth-local-password-validation.service'; -import { MyJwtVerifyTokenService } from './services/jwt-verify-token.service'; +1. Validates the request body against the configured `loginSchema` by calling + its Standard Schema `~standard.validate` method (throws + `LocalInvalidLoginDataException` on schema issues). +2. Calls `LocalService.validateUser()`, which uses `UserPort.getByUsername()` + to find the user and `PasswordPort.validate()` to verify the password. +3. Returns the validated **user**, which Passport places on `request.user`. -@Module({ - imports: [ - TypeOrmExtModule.forRoot({ - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [User, Pet], - }), - AuthLocalModule.forRoot({ - // this service contains the byUsername method - userModelService: new MyAuthLocalUserModelService(), - // this service contains the validate the password logic - passwordValidationService: new MyAuthLocalPasswordValidationService(), // - }), - AuthJwtModule.forRoot({ - // this contains the bySubject method that will get user based on the token - userModelService: new MyJwtUserModelService(), - // service to validate the jwt token - verifyTokenService: new MyJwtVerifyTokenService(), - }), - AuthenticationModule.forRoot({}), - JwtModule.forRoot({}), - ], -}) -export class AppModule {} +Token issuance is your controller's responsibility — execute +`IssueAuthenticatedResponseCommand(ctx, user.id)` on the `CommandBus` as shown +in the [End-to-End Example](#end-to-end-example). + +**Exported symbols:** `LocalGuard`, `LocalService`, `localLoginSchema`, +`LocalCredentialsInterface`, `LocalServiceInterface`, +`LocalValidateUserInterface`. + +**Customize field names and validation:** + +```typescript +settings: { + strategies: { + local: { + usernameField: 'email', // default: 'username' + passwordField: 'pass', // default: 'password' + loginSchema: myLoginSchema, // optional StandardSchemaV1; default: localLoginSchema + }, + }, +} ``` -### First Authentication with JWT +`loginSchema` accepts any Standard Schema implementation — a plain Zod v4 +schema works out of the box. The default `localLoginSchema` requires +`username` (max 255 chars) and `password` (max 72 chars). When you remap +`usernameField`/`passwordField`, the strategy validates an object keyed by +your custom field names, so a custom schema should declare those keys. -#### Validating the Setup +The default field names can also be set through the environment variables +`AUTH_LOCAL_USERNAME_FIELD` and `AUTH_LOCAL_PASSWORD_FIELD` (read by the +module's default config). -To validate the setup, you can use `curl` commands to simulate frontend requests. +**Exceptions:** `LocalUnauthorizedException`, `LocalUsernameNotFoundException`, +`LocalUserInactiveException`, `LocalInvalidPasswordException`, +`LocalInvalidLoginDataException`, `LocalInvalidCredentialsException`. -By following these steps, you can validate that the setup is working correctly -and that authenticated requests to the `user` endpoint return the -expected list of pets for a given user. +--- -Here are the steps to test the `user` endpoint: +### Refresh Tokens -#### Step 1: Obtain a JWT Token +Activated by setting `settings.strategies.refresh`. Registers a Passport +strategy that reads a refresh token from the request body (`refreshToken` +field by default), verifies it via `JwtPort`, and loads the user with +`UserPort.getBySubject()`. The **user** lands on `request.user`; your +controller issues the new access + refresh pair with +`IssueAuthenticatedResponseCommand` (see the +[End-to-End Example](#end-to-end-example)). -The `AuthLocalModule` provide a controller with an authentication -endpoint to obtain a JWT token, use `curl` to get the token. +**Exported symbols:** `RefreshGuard`, `refreshSchema` (validates +`refreshToken` as a JWT via `z.jwt()`). -Replace `[auth-url]` with your actual authentication URL, and -`[username]` and `[password]` with valid credentials. For our demo, -since we overwrote the `passwordValidationService`, we can use any password. +**Custom token extraction:** -```bash -curl -X POST [auth-url] \ - -H "Content-Type: application/json" \ - -d '{"username": "[username]", "password": "[password]"}' +```typescript +import { ExtractJwt } from '@concepta/nestjs-authentication'; + +settings: { + strategies: { + refresh: { + jwtFromRequest: ExtractJwt.fromBodyField('token'), + }, + }, +} ``` -This should return a response with a JWT token, which you'll use for -authenticated requests. +**Exceptions:** `RefreshException`, `RefreshUnauthorizedException`. -#### Step 2: Make an Authenticated Request +--- -Use the JWT token obtained in the previous step to make an authenticated request -to the `user` endpoint. Replace `[jwt-token]`. +### Password Recovery -```bash -curl -X GET http://localhost:3000/user \ - -H "Authorization: Bearer [jwt-token]" -``` +Activated by setting `settings.mfa.recovery`. Provides the following flows, +all requiring `ports.otp` and `ports.recoveryNotification`: -#### Example Curl Calls +| Flow | `RecoveryService` method | Description | +|---|---|---| +| Recover login | `recoverLogin(ctx, email)` | Sends the user's username to their email | +| Recover password | `recoverPassword(ctx, email)` | Generates an OTP passcode and sends it by email | +| Validate passcode | `validatePasscode(ctx, passcode)` | Validates the OTP passcode | +| Update password | `updatePassword(ctx, passcode, newPassword)` | Sets a new password and notifies the user | +| Revoke recoveries | `revokeAllUserPasswordRecoveries(ctx, email)` | Clears all active recovery OTPs for a user | -Here is an example sequence of curl commands: +**Exported symbols:** `RecoveryService`, `recoveryRecoverLoginSchema`, +`recoveryRecoverPasswordSchema`, `recoveryUpdatePasswordSchema`, +`recoveryValidatePasscodeSchema`, `RecoveryRecoverLoginParamsInterface`, +`RecoveryRecoverPasswordParamsInterface`, +`RecoveryUpdatePasswordParamsInterface`, +`RecoveryValidatePasscodeParamsInterface`, `RecoveryException`, +`RecoveryOtpInvalidException`. -##### Obtain a JWT token +**Controller pattern:** validate bodies with the exported schemas via the +native `@Body()` schema option and `StandardSchemaValidationPipe`: -```bash -curl -X POST http://localhost:3000/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username": "testuser", "password": "testpassword"}' +```typescript +import { + Body, + Controller, + PlainLiteralObject, + Post, + StandardSchemaValidationPipe, +} from '@nestjs/common'; +import { Ctx } from '@concepta/nestjs-core'; +import { + AuthPublic, + RecoveryRecoverPasswordParamsInterface, + RecoveryService, + recoveryRecoverPasswordSchema, +} from '@concepta/nestjs-authentication'; + +@Controller('auth/recovery') +@AuthPublic({ classLevel: true }) +export class RecoveryController { + constructor(private readonly recoveryService: RecoveryService) {} + + @Post('/password') + async recoverPassword( + @Ctx() ctx: PlainLiteralObject, + @Body({ + schema: recoveryRecoverPasswordSchema, + pipes: [new StandardSchemaValidationPipe()], + }) + recoverPasswordParams: RecoveryRecoverPasswordParamsInterface, + ): Promise { + await this.recoveryService.recoverPassword( + ctx, + recoverPasswordParams.email, + ); + } +} ``` -##### Example JWT response +**Shipped OTP defaults** (override under `settings.mfa.recovery.otp`): -```json -{ - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +```typescript +settings: { + mfa: { + recovery: { + otp: { + namespace: 'userOtp', + category: 'auth-recovery', + type: 'uuid', + expiresIn: '1h', + duplicateStrategy: 'DEACTIVATE', + rateSeconds: 60, // min seconds between requests per user + rateThreshold: 5, // max requests within rateSeconds window + }, + }, + }, } ``` -##### Make an authenticated request using the token +**Notification dispatch:** Recovery events are dispatched fire-and-forget via +`RecoveryNotificationPort`, which calls `CommandBus.execute()` with the command +class you provide in `ports.recoveryNotification`. Register a `@CommandHandler` +for each command class in your application module. A rejecting handler is not +silent — see [Custom Notification Commands](#custom-notification-commands) +for the `NotificationSendFailedEvent` published on failure. -Assuming that are alerady inserted the user and its pets, let's try to retrieve it. - -```bash -curl -X GET http://localhost:3000/user/5b3f5fd3-9426-4c4d-a06d-b4d55079034d/pets \ - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +```typescript +ports: { + recoveryNotification: { + sendRecoverLoginNotificationCommand: SendRecoverLoginCommand, + sendRecoverPasswordNotificationCommand: SendRecoverPasswordCommand, + sendPasswordUpdatedNotificationCommand: SendPasswordUpdatedCommand, + }, +} ``` -##### Example authenticated response +Each command receives `(ctx, email, ...params)` — see +`SendRecoverLoginNotificationCommandInterface`, +`SendRecoverPasswordNotificationCommandInterface`, and +`SendPasswordUpdatedNotificationCommandInterface` for the exact shapes. -```json -[ - { - "id": 1, - "name": "Fluffy", - "user": { - "id": 1, - "name": "John Doe", - "pets": [] - } +--- + +### Email Verification + +Activated by setting `settings.mfa.verify`. Provides OTP-based email +verification using two endpoints — send and confirm — and toggles the user's +`active` flag on successful confirmation. + +**`VerifyService` methods:** `send(ctx, { email })`, +`validatePasscode(ctx, { passcode })`, `confirmUser(ctx, { passcode })`, +`revokeAllUserVerifyToken(ctx, { email })`. + +**Exported symbols:** `VerifyService`, `verifySchema`, `verifyUpdateSchema`, +`VerifySendParamsInterface`, `VerifyConfirmParamsInterface`, +`VerifyException`, `VerifyOtpInvalidException`. + +**Controller pattern** (same native `@Body()` schema option as recovery): + +```typescript +import { + Body, + Controller, + Patch, + PlainLiteralObject, + Post, + StandardSchemaValidationPipe, +} from '@nestjs/common'; +import { Ctx } from '@concepta/nestjs-core'; +import { + AuthPublic, + VerifyConfirmParamsInterface, + VerifySendParamsInterface, + VerifyService, + verifySchema, + verifyUpdateSchema, +} from '@concepta/nestjs-authentication'; + +@Controller('auth/verify') +@AuthPublic({ classLevel: true }) +export class VerifyController { + constructor(private readonly verifyService: VerifyService) {} + + @Post('/send') + async send( + @Ctx() ctx: PlainLiteralObject, + @Body({ + schema: verifySchema, + pipes: [new StandardSchemaValidationPipe()], + }) + verifyParams: VerifySendParamsInterface, + ): Promise { + await this.verifyService.send(ctx, { email: verifyParams.email }); + } + + @Patch('/confirm') + async confirm( + @Ctx() ctx: PlainLiteralObject, + @Body({ + schema: verifyUpdateSchema, + pipes: [new StandardSchemaValidationPipe()], + }) + verifyUpdateParams: VerifyConfirmParamsInterface, + ): Promise { + const { passcode } = verifyUpdateParams; + await this.verifyService.confirmUser(ctx, { passcode }); } -] +} ``` -To get authenticated user, we can use the decorator `@AuthUser()` -this will return whatever was defined at `bySubject` method from -`MyJwtUserModelService`. +**Shipped OTP defaults** (override under `settings.mfa.verify.otp`): -```ts -@Get(':id/pets') - async getPets( - @AuthUser() user: User, - @Param('id') userId: number - ) { - if (user.id !== userId) throw new UnauthorizedException() - return this.petService.findByUserId(userId); - } +```typescript +settings: { + mfa: { + verify: { + otp: { + namespace: 'userOtp', + category: 'auth-verify', + type: 'uuid', + expiresIn: '24h', + duplicateStrategy: 'DEACTIVATE', + rateSeconds: 60, + rateThreshold: 5, + }, + }, + }, +} ``` -## How to Guides +**Notification dispatch:** `VerifyNotificationPort` fires +`sendVerifyNotificationCommand` via the command bus. The command receives +`(ctx, email, passcode, tokenExp)` — see `SendVerifyNotificationCommandInterface`. +A rejecting handler publishes `NotificationSendFailedEvent` — see +[Custom Notification Commands](#custom-notification-commands): -### 1. How to Set Up AuthenticationModule with forRoot and JwtModule from @concepta/nestjs-jwt +```typescript +ports: { + verifyNotification: { + sendVerifyNotificationCommand: SendVerifyCommand, + }, +} +``` -The `@concepta/nestjs-authentication` module is designed to integrate -seamlessly with other modules in the authentication suite, such as -`@concepta/nestjs-auth-jwt`, `@concepta/nestjs-auth-local`, -`@concepta/nestjs-auth-recovery`, and `@concepta/nestjs-auth-refresh`. +--- -For optimal functionality, it is recommended to use these modules -together to address various aspects of authentication and token management -in your NestJS application. +### OAuth Provider Router -To set up the `nestjs-authentication` module, begin by installing the -necessary packages using your package manager. +`AuthRouterGuard` dispatches auth requests to named provider guards based on +the `?provider=` query parameter. It does not implement any OAuth strategy +itself — the strategies live in downstream provider packages. -Here is a basic example using `yarn`: +**Login flow:** -yarn add @nestjs-authentication @concepta/nestjs-jwt +```text +GET /auth/login?provider=google → AuthRouterGuard → AuthGoogleGuard +``` + +**Callback flow** (OAuth `code` + `state`): -#### Example Setup +```text +GET /auth/callback?code=xxx&state={"provider":"google"} + → AuthRouterGuard (extracts provider from state JSON) + → AuthGoogleGuard +``` -To set up the `AuthenticationModule` and `JwtModule`, follow these steps: +**Wiring provider guards:** -**Import the modules** in your application module: +```typescript +import { AuthGoogleGuard } from '@concepta/nestjs-auth-google'; -```ts -//... - AuthenticationModule.forRoot({}), - AuthJwtModule.forRoot({ - // this model service contains bySubject method - userModelService: new JwtUserModelService(), - }), - JwtModule.forRoot({ - secret: 'your-secret-key', - signOptions: { expiresIn: '60s' }, - }), -//... +AuthenticationModule.forRoot({ + // ...settings + ports... + guards: [ + { name: 'google', guard: new AuthGoogleGuard() }, + { name: 'github', guard: new AuthGithubGuard() }, + ], +}) ``` -This setup configures the `AuthenticationModule` with global -settings and integrates the `JwtModule` for JWT-based authentication. +The `AuthRouterGuard` is exported and can be applied to any controller: -### 2. How to Configure AuthenticationModule Settings +```typescript +import { AuthRouterGuard, AuthPublic } from '@concepta/nestjs-authentication'; + +@AuthPublic() +@UseGuards(AuthRouterGuard) +@Get('login') +async login() {} + +@AuthPublic() +@UseGuards(AuthRouterGuard) +@Get('callback') +async callback(@Req() req: Request) { + return req.user; +} +``` + +**OAuth utility types** (used by provider packages, re-exported here): +`OAuthAuthenticateOptionsInterface`, `OAuthParamsInterface`, +`OAuthRequestInterface`, `processOAuthParams`. + +**Router exceptions:** All router errors extend `AuthRouterException`, the +only router exception in the public exports. Client mistakes — a missing +`?provider=` parameter or a provider with no registered guard — are rejected +with **400 Bad Request**. Server-side misconfiguration (missing guard config, +invalid guard) surfaces as 500. An `HttpException` thrown by the delegated +provider guard (e.g. a Passport strategy rejecting credentials) is re-thrown +unchanged — the router does not rewrite its status or body. Any other +unexpected failure from the provider guard is wrapped as +`AuthRouterAuthenticationFailedException` with **500** and `fault: 'internal'`. + +--- + +## Validation Schemas + +All request/response bodies are described by Zod v4 schemas built with the +schema helpers from `@concepta/nestjs-core`: + +- `conformsTo()(schema)` — pins the schema's output type to a domain + interface at compile time. +- `withOpenApi(schema)` — attaches a JSON Schema bridge so the schema can be + rendered into OpenAPI (used for request bodies). +- `withNamedComponent(schema, name)` — additionally registers the schema as a + named OpenAPI component (used for `authenticationResponseSchema` → + `AuthenticationResponse`). + +| Schema | Shape | Conforms to | +|---|---|---| +| `localLoginSchema` | `{ username: string (≤255), password: string (≤72) }` | `AuthenticationLoginInterface` | +| `refreshSchema` | `{ refreshToken: jwt }` | `AuthenticationRefreshInterface` | +| `authenticationResponseSchema` | `{ accessToken: string, refreshToken: string }` | `AuthenticatedResponseInterface` | +| `verifySchema` | `{ email: email }` | `VerifySendParamsInterface` | +| `verifyUpdateSchema` | `{ passcode: string (≤36) }` | `VerifyConfirmParamsInterface` | +| `recoveryRecoverLoginSchema` | `{ email: email }` | `RecoveryRecoverLoginParamsInterface` | +| `recoveryRecoverPasswordSchema` | `{ email: email }` | `RecoveryRecoverPasswordParamsInterface` | +| `recoveryUpdatePasswordSchema` | `{ passcode: string (≤36), newPassword: string (≤72) }` | `RecoveryUpdatePasswordParamsInterface` | +| `recoveryValidatePasscodeSchema` | `{ passcode: string (≤36) }` | `RecoveryValidatePasscodeParamsInterface` | + +Two usage patterns: + +- **Body consumed by your controller** — pass the schema to the native + `@Body({ schema, pipes: [new StandardSchemaValidationPipe()] })` option; + OpenAPI documentation is derived automatically. +- **Body consumed by a Passport strategy** (local login, refresh) — the + strategy validates internally via the schema's `~standard.validate`; document + the body manually: + + ```ts + @ApiBody({ + schema: theSchema['~standard'].jsonSchema?.input?.({ + target: 'openapi-3.0', + }), + }) + ``` -The `AuthenticationModule` provides several configurable settings to -customize its behavior. Each setting can be defined in the module -configuration and will create default services to be used in other modules. +Responses are documented with +`@ApiResponse({ status, standardSchema: authenticationResponseSchema })`. -#### Settings Example +--- -Here is an example of how to configure each property of the settings: +## Configuration Reference -##### 1. enableGuards: Enables or disables guards globally +### Module Options -```ts -//... +```typescript AuthenticationModule.forRoot({ - settings: { - enableGuards: true, // Enables guards globally + settings?: AuthenticationSettingsInterface; + ports?: AuthenticationPortsInterface; + // extras (passed as the second arg to setExtras): + global?: boolean; // forRoot sets this to true automatically + appGuard?: false | CanActivate; + guards?: AuthRouterGuardConfigInterface[]; +}) +``` + +`forRoot` / `forRootAsync` set `global: true` (module available app-wide). +`register` / `registerAsync` do not — use these for feature-scoped auth. + +### JWT Settings + +Configured under `settings.jwt` (see `JwtPolicySettingsInterface`). Both +`access` and `refresh` accept `TokenOptionsInterface`, which extends +`JwtModuleOptions` from `@nestjs/jwt` minus `secretOrPrivateKey` (and narrows +`secret` to `string | Buffer`). + +```typescript +settings: { + jwt: { + access: { + secret: process.env.JWT_ACCESS_SECRET, // min 32 chars recommended + signOptions: { expiresIn: '15m' }, + }, + refresh: { + secret: process.env.JWT_REFRESH_SECRET, // must differ from access secret + signOptions: { expiresIn: '7d' }, + }, }, -}), -//... +} ``` -##### 2. issueTokenService: Custom service for issuing tokens +Defaults when `signOptions.expiresIn` is omitted: access = **1h**, refresh = +**24h** — and the module emits a `process.emitWarning` +(`ROCKETS_JWT_NO_EXPIRY`) urging you to set it explicitly. Warnings are also +emitted for secrets shorter than 32 characters (`ROCKETS_JWT_WEAK_SECRET`) and +for identical access/refresh secrets (`ROCKETS_JWT_SHARED_SECRET`). Use +separate secrets for access and refresh tokens. -```ts -//... - AuthenticationModule.forRoot({ - issueTokenService: new MyIssueTokenService(), // Custom token issuance service - }), -//... +### Strategy Settings + +Presence of a strategy key activates that Passport strategy: + +```typescript +settings: { + strategies: { + jwt?: JwtStrategyPolicySettingsInterface; // { jwtFromRequest? } + local?: LocalStrategyPolicySettingsInterface; // { usernameField?, passwordField?, loginSchema? } + refresh?: RefreshStrategyPolicySettingsInterface; // { jwtFromRequest? } + }, +} ``` - **Implementation** : +Omitting a strategy key entirely disables that strategy. +`loginSchema` is any `StandardSchemaV1` (default: `localLoginSchema`). -```ts -import { Injectable } from '@nestjs/common'; -import { JwtIssueService } from '@concepta/nestjs-jwt'; -import { AuthenticationResponseInterface } from '@concepta/nestjs-common'; -import { IssueTokenServiceInterface } from '../interfaces/issue-token-service.interface'; +### MFA Settings -@Injectable() -export class MyIssueTokenService implements IssueTokenServiceInterface { - constructor(protected readonly jwtIssueService: JwtIssueService) {} +Presence of an MFA key activates that feature: - async accessToken(...args: Parameters) { - return this.jwtIssueService.accessToken(...args); - } +```typescript +settings: { + mfa: { + recovery?: RecoveryPolicySettingsInterface; // { otp: OtpPolicySettingsInterface } + verify?: VerifyPolicySettingsInterface; // { otp: OtpPolicySettingsInterface } + }, +} +``` + +`OtpPolicySettingsInterface`: - async refreshToken(...args: Parameters) { - return this.jwtIssueService.refreshToken(...args); +```typescript +{ + otp: { + category: string; // groups OTPs (e.g. 'auth-recovery') + namespace: string; // OTP repository namespace (e.g. 'userOtp') + type: string; // OTP type (e.g. 'uuid', 'numeric') + expiresIn: string; // e.g. '1h' + duplicateStrategy?: 'ALLOW' | 'DEACTIVATE'; + rateSeconds?: number; // 0 disables rate limiting (emits warning) + rateThreshold?: number; // 0 disables rate limiting (emits warning) } +} +``` - async responsePayload( - id: string, - ): Promise { - const payload = { sub: id }; +Shipped defaults (deep-merged with your settings): both features use +`namespace: 'userOtp'`, `type: 'uuid'`, `duplicateStrategy: 'DEACTIVATE'`, +`rateSeconds: 60`, `rateThreshold: 5`; recovery uses +`category: 'auth-recovery'` with `expiresIn: '1h'`, verify uses +`category: 'auth-verify'` with `expiresIn: '24h'`. - const dto = new AuthenticationJwtResponseDto(); +### Port Settings - dto.accessToken = await this.accessToken(payload); - dto.refreshToken = await this.refreshToken(payload); +Ports connect the module's domain layer to your application's CQRS handlers. +`jwt` and `token` are optional and fall back to built-in defaults; once `ports` +is provided, `user`, `password`, `otp`, `recoveryNotification` and +`verifyNotification` are all required together: - return dto; - } +```typescript +ports: { + jwt?: JwtPortSettings; // optional; defaults are used if omitted + token?: TokenPortSettings; // optional; defaults are used if omitted + user: UserPortSettings; // required + password: PasswordPortSettings; // required + otp: OtpPortSettings; // required + recoveryNotification: RecoveryNotificationPortSettings; // required + verifyNotification: VerifyNotificationPortSettings; // required } ``` -##### 3. **verifyTokenService**: Custom service for verifying tokens +Each setting object maps port methods to Command/Query constructor classes that +your application registers as CQRS handlers. See the +[End-to-End Example](#end-to-end-example) for a full wiring pattern. + +**Built-in default commands/queries** (exported and reusable for `ports.token`): -```ts -//... - AuthenticationModule.forRoot({ - verifyTokenService: new MyVerifyTokenService(), // Custom token verification service - }), -//... +| Symbol | Type | +|---|---| +| `IssueAccessTokenCommand` | `Command` | +| `IssueRefreshTokenCommand` | `Command` | +| `IssueAuthenticatedResponseCommand` | `Command` | +| `VerifyAccessTokenQuery` | `Query` | +| `VerifyRefreshTokenQuery` | `Query` | +| `ValidateTokenQuery` | `Query` | +| `ValidateAndVerifyAccessTokenQuery` | `Query` | +| `ValidateAndVerifyRefreshTokenQuery` | `Query` | + +The built-in token handlers are always registered — point `ports.token` at +these classes if you do not need custom token-issuance logic. + +### Extras + +```typescript +AuthenticationModule.forRoot({ + // extras are passed directly alongside settings/ports: + appGuard?: false | CanActivate; + guards?: AuthRouterGuardConfigInterface[]; +}) ``` -**Implementation:** +- `appGuard: false` — disables the global `APP_GUARD` entirely. +- `appGuard: MyCustomGuard` — replaces the default `JwtGuard` as the APP_GUARD. +- `guards` — registers named guards for `AuthRouterGuard` dispatch. -```ts -import { Injectable } from '@nestjs/common'; -import { JwtVerifyService } from '@concepta/nestjs-jwt'; -import { ValidateTokenServiceInterface } from '../interfaces/validate-token-service.interface'; -import { VerifyTokenServiceInterface } from '../interfaces/verify-token-service.interface'; -import { BadRequestException } from '@nestjs/common'; +--- -@Injectable() -export class MyVerifyTokenService implements VerifyTokenServiceInterface { - constructor( - protected readonly jwtVerifyService: JwtVerifyService, - protected readonly validateTokenService?: ValidateTokenServiceInterface, - ) {} +## Exceptions - async accessToken(...args: Parameters) { - const token = await this.jwtVerifyService.accessToken(...args); +All exceptions in this package extend `RuntimeException` from +`@concepta/nestjs-core`, which itself extends Nest's `HttpException` — no +custom exception filter registration is required. Errors render on the wire +as: - if (await this.validateToken(token)) { - return token; - } else { - throw new BadRequestException( - 'Access token was verified, but failed further validation.', - ); - } - } +```json +{ + "statusCode": 401, + "message": "Invalid credentials.", + "errorCode": "AUTH_LOCAL_INVALID_CREDENTIALS_ERROR", + "error": "Unauthorized" +} +``` + +The full list of exported exception classes is in the +[Exports Reference](#exports-reference). + +--- + +## Advanced + +### Two-Tier CQRS Architecture - async refreshToken(...args: Parameters) { - const token = await this.jwtVerifyService.refreshToken(...args); +The module uses a two-tier CQRS chain for token operations: - if (await this.validateToken(token)) { - return token; - } else { - throw new BadRequestException( - 'Refresh token was verified, but failed further validation.', - ); - } +```text +TokenPort + ↓ dispatches IssueAccessTokenCommand + → IssueAccessTokenHandler + ↓ calls JwtPort.signAccessToken() + → JwtPort dispatches SignAccessTokenCommand + → SignAccessTokenHandler + ↓ calls JwtService.sign() +``` + +This means you can override at either tier: + +- **Override at the JwtPort tier** — provide custom `ports.jwt` settings to + swap out the signing/verification CQRS classes (affects raw JWT operations). +- **Override at the TokenPort tier** — provide custom `ports.token` settings to + swap out how access/refresh tokens are issued and verified. + +Default `JwtPort` settings use the built-in `SignAccessTokenCommand`, +`SignRefreshTokenCommand`, `JwtVerifyAccessTokenQuery`, and +`JwtVerifyRefreshTokenQuery`. + +### Custom Notification Commands + +Recovery and verify notifications are dispatched fire-and-forget. The module +provides the port interface contracts; you provide the handlers: + +```typescript +import { Command } from '@nestjs/cqrs'; +import { + SendRecoverPasswordNotificationCommandInterface, +} from '@concepta/nestjs-authentication'; + +export class SendRecoverPasswordCommand + extends Command + implements SendRecoverPasswordNotificationCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly email: string, + public readonly passcode: string, + public readonly tokenExp: Date, + ) { + super(); } +} + +@CommandHandler(SendRecoverPasswordCommand) +export class SendRecoverPasswordHandler + implements ICommandHandler +{ + constructor(private readonly mailer: MailerService) {} - private async validateToken( - payload: Record, - ): Promise { - if (this.validateTokenService) { - return this.validateTokenService.validateToken(payload); - } else { - return true; - } + async execute(command: SendRecoverPasswordCommand): Promise { + await this.mailer.sendMail({ + to: command.email, + template: 'recover-password', + context: { passcode: command.passcode, expires: command.tokenExp }, + }); } } ``` -##### 4. validateTokenService: Custom service for validating tokens +If your command handler rejects, the port publishes `NotificationSendFailedEvent` +on the `EventBus` — it carries `ctx`, `email`, the command class, and an +`AuthenticationEmailException` wrapping the original error. Register an +`@EventsHandler(NotificationSendFailedEvent)` to log or retry; the send +itself is never retried by the module and the originating request is +unaffected. + +### Disabling the Global Guard + +To disable the global guard entirely: -```ts -//... +```typescript AuthenticationModule.forRoot({ - validateTokenService: new MyValidateTokenService(), // Custom token validation service -}), -//... + settings: { strategies: { jwt: {} } }, + appGuard: false, +}) ``` -**Implementation:** +To protect only specific routes, leave the global guard enabled and use +`@AuthPublic()` on handlers (or `@AuthPublic({ classLevel: true })` on +controllers) that should be publicly accessible. -```ts -import { Injectable } from '@nestjs/common'; -import { ValidateTokenServiceInterface } from '../interfaces/validate-token-service.interface'; +To replace the default `JwtGuard` with a custom guard as the global APP_GUARD: -@Injectable() -export class MyValidateTokenService implements ValidateTokenServiceInterface { - async validateToken(payload: Record): Promise { - // Custom logic to validate the token - return true; - } +```typescript +import { JwtGuard } from '@concepta/nestjs-authentication'; + +class MyAppGuard extends JwtGuard { + // override canActivate or handleRequest } + +AuthenticationModule.forRoot({ + settings: { strategies: { jwt: {} } }, + appGuard: new MyAppGuard(), +}) +``` + +### Context Overlay + +`AuthUserContextOverlay` is registered as an `APP_INTERCEPTOR` and publishes +`request.user` into the Rockets `AppContextHost` via the `AuthUserCtx` +overlay reference: + +```typescript +import { AuthUserCtx } from '@concepta/nestjs-authentication'; +import { getAppContext } from '@concepta/nestjs-core'; + +// In a handler or interceptor: +const { user } = getAppContext(request).with(AuthUserCtx); ``` -## Explanation - -### Conceptual Overview - -#### What is This Library? - -The `@concepta/nestjs-authentication` library is a comprehensive -solution for managing authentication processes within a NestJS application. -It provides services for issuing JWTs, validating user credentials, and -verifying tokens. - -The library integrates seamlessly with other modules in the authentication -suite, such as `@concepta/nestjs-auth-jwt`, `@concepta/nestjs-auth-local`, -`@concepta/nestjs-auth-recovery`, and `@concepta/nestjs-auth-refresh`, making -it a versatile choice for various authentication needs. - -#### Benefits of Using This Library - -- **Secure Token Management**: Robust mechanisms for issuing and managing - access and refresh tokens. -- **Abstract User Validation Service**: Flexible user validation service that - can be customized to meet specific requirements. -- **Token Verification**: Capabilities to verify the authenticity and validity - of tokens, with support for additional custom validations. -- **Customizable and Extensible**: Designed to be flexible, allowing - customization of token generation, user validation, and token verification - processes. -- **Integration with NestJS Ecosystem**: Seamlessly integrates with other - NestJS modules and services, leveraging the framework's features for enhanced - functionality and performance. - -### Design Choices - -#### Why Use NestJS Guards? - -NestJS guards provide a way to control access to various parts of the -application by checking certain conditions before the route handler is executed. -In the `nestjs-authentication` module, guards are used to implement -authentication and authorization logic. By using guards, developers can apply -security policies across routes efficiently, ensuring that only authenticated -and authorized users can access protected resources. - -#### Global, Synchronous vs Asynchronous Registration - -The `nestjs-authentication` module supports both synchronous and asynchronous -registration: - -- **Global Registration**: Makes the module available throughout the entire - application. This approach is useful when JWT authentication is required across - all or most routes in the application. -- **Synchronous Registration**: This method is used when the configuration - options are static and available at application startup. It simplifies the - setup process and is suitable for most use cases where configuration values do - not depend on external services. -- **Asynchronous Registration**: This method is beneficial when configuration - options need to be retrieved from external sources, such as a database or an - external API, at runtime. It allows for more flexible and dynamic - configuration but requires an asynchronous factory function. - -### Integration Details - -#### Integrating with Other Modules - -The `nestjs-authentication` module integrates smoothly with other modules in the -authentication suite. Here are some integration details: - -- **@concepta/nestjs-auth-jwt**: Use `@concepta/nestjs-auth-jwt` for JWT-based - authentication. Configure it to handle the issuance and verification of JWT - tokens. -- **@concepta/nestjs-auth-local**: Use `@concepta/nestjs-auth-local` for local - authentication strategies such as username and password. -- **@concepta/nestjs-auth-recovery**: Use `@concepta/nestjs-auth-recovery` for - account recovery processes like password reset. -- **@concepta/nestjs-auth-refresh**: Use `@concepta/nestjs-auth-refresh` for - handling token refresh mechanisms. - -By combining these modules, you can create a comprehensive authentication system -that meets various security requirements and user needs. +This is exactly how the `@AuthUser()` decorator resolves the user, and it is +useful in command/query handlers that need access to the authenticated user +without taking it as a parameter. + +--- + +## Exports Reference + +### Module and Registration + +| Symbol | Description | +|---|---| +| `AuthenticationModule` | `forRoot`, `forRootAsync`, `register`, `registerAsync` | +| `AuthenticationOptionsInterface` | Module options shape | +| `AuthenticationOptionsExtrasInterface` | Extras (appGuard, guards) shape | +| `AuthenticationPortsInterface` | Ports configuration shape | +| `AuthenticationSettingsInterface` | Settings shape (jwt, strategies, mfa, guards) | +| `AuthenticationStrategiesSettingsInterface` | `settings.strategies` shape | +| `AuthenticationMfaSettingsInterface` | `settings.mfa` shape | + +### Guards and Strategies + +| Symbol | Description | +|---|---| +| `JwtGuard` | JWT bearer guard (`AuthGuard('jwt')`) | +| `LocalGuard` | Local strategy guard (`AuthGuard('local')`) | +| `RefreshGuard` | Refresh token guard (`AuthGuard('refresh')`) | +| `AuthRouterGuard` | OAuth provider dispatcher | +| `AuthRouterGuardsRecord` | Named-guard record type for the router | +| `AuthRouterGuardConfigInterface` | `{ name, guard }` config entry | +| `AuthGuard` | Guard factory function | +| `AuthGuardOptions` | `{ canDisable? }` guard options | +| `AuthGuardCtr` | Guard constructor type | +| `JwtStrategy` | `passport-jwt` strategy | +| `JwtPassportStrategy` | Low-level Passport JWT strategy base | +| `JwtPassportOptionsInterface` | Options for `JwtPassportStrategy` | +| `PassportStrategyFactory` | Factory for creating Passport strategies | +| `createVerifyTokenCallback` | Builds a `JwtVerifyTokenCallback` from `JwtPort` | +| `JwtVerifyTokenCallback` | Token verification callback type | + +### Decorators + +| Symbol | Description | +|---|---| +| `@AuthPublic(options?)` | Exempts a route (or, with `{ classLevel: true }`, a controller) from the global guard | +| `AuthPublicOptions` / `AuthPublicMetadata` | Decorator option/metadata types | +| `@AuthUser()` | Injects the authenticated user into a route parameter | + +### Schemas + +| Symbol | Description | +|---|---| +| `authenticationResponseSchema` | Access + refresh token response (OpenAPI component `AuthenticationResponse`) | +| `localLoginSchema` | Default login body (`username`, `password`) | +| `refreshSchema` | Refresh request body (`refreshToken` as JWT) | +| `recoveryRecoverLoginSchema` | Recover-login request body | +| `recoveryRecoverPasswordSchema` | Recover-password request body | +| `recoveryUpdatePasswordSchema` | Update-password request body | +| `recoveryValidatePasscodeSchema` | Validate-passcode request body | +| `verifySchema` | Verify send request body | +| `verifyUpdateSchema` | Verify confirm request body | + +### Services + +| Symbol | Description | +|---|---| +| `JwtService` | Low-level JWT sign/verify service | +| `LocalService` | Username/password user validation (`validateUser`) | +| `RecoveryService` | Recovery flows (see [Password Recovery](#password-recovery)) | +| `VerifyService` | Verification flows (see [Email Verification](#email-verification)) | + +### Ports + +| Symbol | Description | +|---|---| +| `JwtPort` / `JwtPortSettings` | Sign/verify raw JWTs | +| `TokenPort` / `TokenPortSettings` | Issue/verify/validate access and refresh tokens | +| `UserPort` / `UserPortSettings` | User lookup and update | +| `PasswordPort` / `PasswordPortSettings` | Password validation and set | +| `OtpPort` / `OtpPortSettings` | OTP create/validate/clear | +| `RecoveryNotificationPort` / `RecoveryNotificationPortSettings` | Recovery email dispatch | +| `VerifyNotificationPort` / `VerifyNotificationPortSettings` | Verify email dispatch | +| `AUTHENTICATION_JWT_PORT_TOKEN` | Injection token for `JwtPort` | + +**Port command/query contracts:** `SignTokenCommandInterface`, +`JwtVerifyTokenQueryInterface`, `IssueTokenCommandInterface`, +`VerifyTokenQueryInterface`, `ValidateTokenQueryInterface`, +`GetUserByIdQueryInterface`, `GetUserBySubjectQueryInterface`, +`GetUserByUsernameQueryInterface`, `GetUserByEmailQueryInterface`, +`UpdateUserCommandInterface`, `ValidatePasswordCommandInterface`, +`SetPasswordCommandInterface`, `CreateOtpCommandInterface`, +`ValidateOtpQueryInterface`, `ClearOtpCommandInterface`, +`SendRecoverLoginNotificationCommandInterface`, +`SendRecoverPasswordNotificationCommandInterface`, +`SendPasswordUpdatedNotificationCommandInterface`, +`SendVerifyNotificationCommandInterface`. + +### Policies + +| Symbol | Description | +|---|---| +| `JwtPolicy` / `JwtPolicySettingsInterface` | JWT signing settings (access/refresh secrets, expiry) | +| `JwtStrategyPolicy` / `JwtStrategyPolicySettingsInterface` | JWT Passport strategy settings | +| `LocalStrategyPolicy` / `LocalStrategyPolicySettingsInterface` | Local strategy settings (fields, `loginSchema`) | +| `RefreshStrategyPolicy` / `RefreshStrategyPolicySettingsInterface` | Refresh strategy settings | +| `GuardsPolicy` / `GuardsPolicySettingsInterface` | Guard enable/disable settings | +| `RecoveryPolicy` / `RecoveryPolicySettingsInterface` | Recovery OTP settings | +| `VerifyPolicy` / `VerifyPolicySettingsInterface` | Verify OTP settings | +| `OtpPolicy` / `OtpPolicySettingsInterface` | Base OTP policy | + +### Domain Interfaces, Aggregates, and Events + +| Symbol | Description | +|---|---| +| `Token` | Token lifecycle aggregate | +| `TokenIssuedEvent` | Emitted when a token is issued | +| `TokenRevokedEvent` | Emitted when a token is revoked | +| `NotificationSendFailedEvent` | Emitted when a verify/recovery notification send fails | +| `TokenInterface` / `TokenType` / `TokenCreatableInterface` | Token shapes | +| `TokenOptionsInterface` | Per-token JWT options (extends `JwtModuleOptions`) | +| `AuthenticatedUserInterface` | Shape of `request.user` | +| `AuthenticatedResponseInterface` | `{ accessToken, refreshToken }` | +| `AuthenticationLoginInterface` | Login credentials shape | +| `AuthenticationRefreshInterface` | `{ refreshToken }` shape | +| `AuthenticationAccessInterface` | `{ accessToken }` shape | +| `AuthorizationPayloadInterface` | JWT payload (`sub`, ...) | +| `AuthenticationUserInterface` / `AuthenticationUserResult` | UserPort result shapes | +| `AuthenticationOtpInterface` / `AuthenticationOtpCreatableInterface` | OtpPort shapes | +| `LocalCredentialsInterface` / `LocalValidateUserInterface` / `LocalServiceInterface` | Local login contracts | +| `RecoveryRecoverLoginParamsInterface` | `{ email }` | +| `RecoveryRecoverPasswordParamsInterface` | `{ email }` | +| `RecoveryUpdatePasswordParamsInterface` | `{ passcode, newPassword }` | +| `RecoveryValidatePasscodeParamsInterface` | `{ passcode }` | +| `VerifySendParamsInterface` | `{ email }` | +| `VerifyConfirmParamsInterface` | `{ passcode }` | + +### Exception Classes + +| Symbol | Description | +|---|---| +| `AuthenticationException` | Base domain exception | +| `AuthenticationEmailException` | Notification dispatch failure | +| `TokenException` | Base token exception | +| `TokenAlreadyRevokedException` | Token revoked twice | +| `AuthenticationAccessTokenException` | Access token error | +| `AuthenticationRefreshTokenException` | Refresh token error | +| `JwtException` | Base JWT exception | +| `JwtVerifyException` | JWT verification failure | +| `JwtAuthenticationException` | JWT auth failure | +| `JwtUnauthorizedException` | JWT strategy unauthorized | +| `LocalException` | Base local exception | +| `LocalUnauthorizedException` | Local strategy unauthorized | +| `LocalUsernameNotFoundException` | Username not found | +| `LocalUserInactiveException` | User is inactive | +| `LocalInvalidPasswordException` | Password mismatch | +| `LocalInvalidLoginDataException` | Login body failed schema validation | +| `LocalInvalidCredentialsException` | Credentials rejected | +| `RefreshException` | Base refresh exception | +| `RefreshUnauthorizedException` | Refresh strategy unauthorized | +| `RecoveryException` | Base recovery exception | +| `RecoveryOtpInvalidException` | Recovery OTP invalid | +| `VerifyException` | Base verify exception | +| `VerifyOtpInvalidException` | Verify OTP invalid | +| `AuthRouterException` | Base router exception (500 by default; `ProviderMissing`/`ProviderNotSupported` render 400. `AuthRouterGuard` classifies an unexpected provider-guard failure as 500/internal; an `HttpException` raised by the delegated guard is re-thrown unchanged.) | +| `AuthenticationUserPortRequiredException` | UserPort not configured | +| `AuthenticationFeatureConfigException` | Feature misconfiguration | + +### Default CQRS Commands and Queries + +| Symbol | Tier | +|---|---| +| `IssueAccessTokenCommand` | TokenPort | +| `IssueRefreshTokenCommand` | TokenPort | +| `IssueAuthenticatedResponseCommand` | TokenPort | +| `VerifyAccessTokenQuery` | TokenPort | +| `VerifyRefreshTokenQuery` | TokenPort | +| `ValidateTokenQuery` | TokenPort | +| `ValidateAndVerifyAccessTokenQuery` (+ `ValidateAndVerifyAccessTokenQueryInterface`) | TokenPort | +| `ValidateAndVerifyRefreshTokenQuery` (+ `ValidateAndVerifyRefreshTokenQueryInterface`) | TokenPort | +| `SignAccessTokenCommand` | JwtPort | +| `SignRefreshTokenCommand` | JwtPort | +| `JwtVerifyAccessTokenQuery` | JwtPort | +| `JwtVerifyRefreshTokenQuery` | JwtPort | + +### Context Overlay Exports + +| Symbol | Description | +|---|---| +| `AuthUserCtx` | `OverlayRef` for the authenticated user context | +| `AuthUserContextOverlay` | `APP_INTERCEPTOR` that publishes `request.user` | +| `AuthUserContextInterface` | Shape of the user context overlay | + +### OAuth Utilities + +| Symbol | Description | +|---|---| +| `processOAuthParams` | Extract and normalize OAuth callback params | +| `OAuthAuthenticateOptionsInterface` | Options passed to `passport.authenticate()` | +| `OAuthParamsInterface` | Normalized OAuth params (provider, state, code) | +| `OAuthRequestInterface` | Extended request with OAuth state | +| `ExtractJwt` / `JwtFromRequestFunction` | Re-exports from `passport-jwt` | + +--- + +## Related Packages + +**Runtime dependencies:** + +- [`@concepta/nestjs-core`](../nestjs-core) — `AppContextHost`, `OverlayRef`, + `ReferenceId`, schema helpers (`conformsTo`, `withOpenApi`, + `withNamedComponent`), event/exception base classes. +- [`@concepta/nestjs-password`](../nestjs-password) — password hashing and + validation (typically used in `PasswordPort` command handlers). +- [`@concepta/nestjs-user`](../nestjs-user) — ready-made user module with + CQRS queries/commands that satisfy `UserPortSettings`. + +**OAuth provider packages** (strategies, guards, and controllers live here): + +- [`@concepta/nestjs-auth-apple`](../nestjs-auth-apple) — Apple OAuth2 strategy. +- [`@concepta/nestjs-auth-github`](../nestjs-auth-github) — GitHub OAuth2 strategy. +- [`@concepta/nestjs-auth-google`](../nestjs-auth-google) — Google OAuth2 strategy. +- [`@concepta/nestjs-federated`](../nestjs-federated) — Federated identity storage + (required by all OAuth provider packages). + +**OTP integration** (required for recovery and verify features): + +- [`@concepta/nestjs-otp`](../nestjs-otp) — ready-made OTP module with CQRS + queries/commands that satisfy `OtpPortSettings`. diff --git a/packages/nestjs-authentication/package.json b/packages/nestjs-authentication/package.json index d836809d9..b18fb9414 100644 --- a/packages/nestjs-authentication/package.json +++ b/packages/nestjs-authentication/package.json @@ -1,35 +1,67 @@ { "name": "@concepta/nestjs-authentication", - "version": "7.0.0-alpha.10", + "version": "8.0.0-alpha.10", "description": "Rockets NestJS Authentication", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "license": "BSD-3-Clause", "publishConfig": { "access": "public" }, "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" ], "dependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@concepta/nestjs-jwt": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", - "@nestjs/passport": "^11.0.5", - "@nestjs/swagger": "^11.2.2", + "@concepta/nestjs-core": "^8.0.0-alpha.10", + "@concepta/nestjs-password": "8.0.0-alpha.10", + "@concepta/nestjs-user": "8.0.0-alpha.10", + "@nestjs/jwt": "^12.0.1", + "@nestjs/passport": "^12.0.0", + "@standard-schema/spec": "^1.0.0", + "jsonwebtoken": "^9.0.0", + "ms": "^2.1.3", "passport": "^0.7.0", - "passport-strategy": "^1.0.0" + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", + "passport-strategy": "^1.0.0", + "zod": "^4.4.3" }, "devDependencies": { - "@nestjs/jwt": "^11.0.1", - "@nestjs/testing": "^11.1.9", - "jest-mock-extended": "^4.0.0" + "@concepta/nestjs-crud": "8.0.0-alpha.10", + "@concepta/nestjs-otp": "8.0.0-alpha.10", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/swagger": "^12.0.1", + "@nestjs/testing": "^12.0.1", + "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", + "@types/passport-strategy": "^0.2.38", + "express": "^4.21.0", + "supertest": "^6.3.4", + "vitest-mock-extended": "^4.0.0" }, "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/swagger": "^12.0.1", "rxjs": "^7.1.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "peerDependenciesMeta": { + "@nestjs/cqrs": { + "optional": true + } } } diff --git a/packages/nestjs-authentication/src/__fixtures__/global.module.fixture.ts b/packages/nestjs-authentication/src/__fixtures__/global.module.fixture.ts deleted file mode 100644 index ea8a76ec5..000000000 --- a/packages/nestjs-authentication/src/__fixtures__/global.module.fixture.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { IssueTokenServiceFixture } from './services/issue-token.service.fixture'; -import { ValidateTokenServiceFixture } from './services/validate-token.service.fixture'; -import { VerifyTokenServiceFixture } from './services/verify-token.service.fixture'; - -@Global() -@Module({ - providers: [ - IssueTokenServiceFixture, - VerifyTokenServiceFixture, - ValidateTokenServiceFixture, - ], - exports: [ - IssueTokenServiceFixture, - VerifyTokenServiceFixture, - ValidateTokenServiceFixture, - ], -}) -export class GlobalModuleFixture {} diff --git a/packages/nestjs-authentication/src/__fixtures__/services/issue-token.service.fixture.ts b/packages/nestjs-authentication/src/__fixtures__/services/issue-token.service.fixture.ts deleted file mode 100644 index 279d3b982..000000000 --- a/packages/nestjs-authentication/src/__fixtures__/services/issue-token.service.fixture.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { JwtSignOptions } from '@nestjs/jwt'; - -import { AuthenticationResponseInterface } from '@concepta/nestjs-common'; - -import { IssueTokenServiceInterface } from '../../interfaces/issue-token-service.interface'; - -export class IssueTokenServiceFixture implements IssueTokenServiceInterface { - public discriminator = 'default'; - - responsePayload(_id: string): Promise { - throw new Error('Method not implemented.'); - } - accessToken( - _payload: string | object | Buffer, - _options?: JwtSignOptions, - ): Promise { - throw new Error('Method not implemented.'); - } - refreshToken( - _payload: string | object | Buffer, - _options?: JwtSignOptions, - ): Promise { - throw new Error('Method not implemented.'); - } -} diff --git a/packages/nestjs-authentication/src/__fixtures__/services/validate-token.service.fixture.ts b/packages/nestjs-authentication/src/__fixtures__/services/validate-token.service.fixture.ts deleted file mode 100644 index ee088edaf..000000000 --- a/packages/nestjs-authentication/src/__fixtures__/services/validate-token.service.fixture.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ValidateTokenServiceInterface } from '../../interfaces/validate-token-service.interface'; - -export class ValidateTokenServiceFixture - implements ValidateTokenServiceInterface -{ - public discriminator = 'default'; - - async validateToken(_payload: object): Promise { - throw new Error('Method not implemented.'); - } -} diff --git a/packages/nestjs-authentication/src/__fixtures__/services/verify-token.service.fixture.ts b/packages/nestjs-authentication/src/__fixtures__/services/verify-token.service.fixture.ts deleted file mode 100644 index 0c06abd40..000000000 --- a/packages/nestjs-authentication/src/__fixtures__/services/verify-token.service.fixture.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { JwtVerifyOptions } from '@nestjs/jwt'; - -import { VerifyTokenServiceInterface } from '../../interfaces/verify-token-service.interface'; - -export class VerifyTokenServiceFixture implements VerifyTokenServiceInterface { - public discriminator = 'default'; - - async accessToken( - _token: string, - _options?: JwtVerifyOptions, - ): Promise { - throw new Error('Method not implemented.'); - } - - async refreshToken( - _token: string, - _options?: JwtVerifyOptions, - ): Promise { - throw new Error('Method not implemented.'); - } -} diff --git a/packages/nestjs-authentication/src/__tests__/exception-fault.spec.ts b/packages/nestjs-authentication/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..92e0d127d --- /dev/null +++ b/packages/nestjs-authentication/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,228 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { AuthenticationAccessTokenException } from '../application/exceptions/authentication-access-token.exception.js'; +import { AuthenticationRefreshTokenException } from '../application/exceptions/authentication-refresh-token.exception.js'; +import { AuthenticationUserPortRequiredException } from '../application/exceptions/authentication-user-port-required.exception.js'; +import { LocalInvalidPasswordException } from '../application/exceptions/local-invalid-password.exception.js'; +import { LocalUserInactiveException } from '../application/exceptions/local-user-inactive.exception.js'; +import { LocalUsernameNotFoundException } from '../application/exceptions/local-username-not-found.exception.js'; +import { VerifyOtpInvalidException } from '../application/exceptions/verify-otp-invalid.exception.js'; +import { AuthenticationEmailException } from '../domain/exceptions/authentication-email.exception.js'; +import { AuthenticationException } from '../domain/exceptions/authentication.exception.js'; +import { TokenAlreadyRevokedException } from '../domain/exceptions/token-already-revoked.exception.js'; +import { TokenException } from '../domain/exceptions/token.exception.js'; +import { AuthenticationFeatureConfigException } from '../infrastructure/exceptions/authentication-feature-config.exception.js'; +import { JwtVerifyException } from '../infrastructure/jwt/exceptions/jwt-verify.exception.js'; +import { JwtException } from '../infrastructure/jwt/exceptions/jwt.exception.js'; +import { RecoveryOtpInvalidException } from '../infrastructure/mfa/recovery/exceptions/recovery-otp-invalid.exception.js'; +import { RecoveryException } from '../infrastructure/mfa/recovery/exceptions/recovery.exception.js'; +import { VerifyException } from '../infrastructure/mfa/verify/exceptions/verify.exception.js'; +import { AuthRouterAuthenticationFailedException } from '../infrastructure/router/exceptions/auth-router-authentication-failed.exception.js'; +import { AuthRouterConfigNotAvailableException } from '../infrastructure/router/exceptions/auth-router-config-not-available.exception.js'; +import { AuthRouterGuardInvalidException } from '../infrastructure/router/exceptions/auth-router-guard-invalid.exception.js'; +import { AuthRouterProviderMissingException } from '../infrastructure/router/exceptions/auth-router-provider-missing.exception.js'; +import { AuthRouterProviderNotSupportedException } from '../infrastructure/router/exceptions/auth-router-provider-not-supported.exception.js'; +import { AuthRouterException } from '../infrastructure/router/exceptions/auth-router.exception.js'; +import { JwtAuthenticationException } from '../infrastructure/strategies/jwt/exceptions/jwt-authentication.exception.js'; +import { JwtUnauthorizedException } from '../infrastructure/strategies/jwt/exceptions/jwt-unauthorized.exception.js'; +import { LocalInvalidCredentialsException } from '../infrastructure/strategies/local/exceptions/local-invalid-credentials.exception.js'; +import { LocalInvalidLoginDataException } from '../infrastructure/strategies/local/exceptions/local-invalid-login-data.exception.js'; +import { LocalUnauthorizedException } from '../infrastructure/strategies/local/exceptions/local-unauthorized.exception.js'; +import { LocalException } from '../infrastructure/strategies/local/exceptions/local.exception.js'; +import { RefreshUnauthorizedException } from '../infrastructure/strategies/refresh/exceptions/refresh-unauthorized.exception.js'; +import { RefreshException } from '../infrastructure/strategies/refresh/exceptions/refresh.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. Does not cover the `fault` override at the + * `LocalException` call site in `local.strategy.ts` (schema-not-configured) + * — the second `LocalException` call site there relies on the class + * default. Those are call-site, not class-level, classifications. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'AuthenticationAccessTokenException', + build: () => new AuthenticationAccessTokenException(), + fault: 'client', + }, + { + name: 'AuthenticationRefreshTokenException', + build: () => new AuthenticationRefreshTokenException(), + fault: 'client', + }, + { + name: 'AuthenticationUserPortRequiredException', + build: () => new AuthenticationUserPortRequiredException(), + fault: 'usage', + }, + { + name: 'LocalInvalidPasswordException', + build: () => new LocalInvalidPasswordException('someUser'), + fault: 'client', + }, + { + name: 'LocalUserInactiveException', + build: () => new LocalUserInactiveException('someUser'), + fault: 'client', + }, + { + name: 'LocalUsernameNotFoundException', + build: () => new LocalUsernameNotFoundException('someUser'), + fault: 'client', + }, + { + name: 'VerifyOtpInvalidException', + build: () => new VerifyOtpInvalidException(), + fault: 'client', + }, + { + name: 'AuthenticationEmailException', + build: () => new AuthenticationEmailException(), + fault: 'internal', + }, + { + name: 'AuthenticationException (default)', + build: () => new AuthenticationException(), + fault: 'internal', + }, + { + name: 'TokenAlreadyRevokedException', + build: () => new TokenAlreadyRevokedException('token-id'), + fault: 'client', + }, + { + name: 'TokenException (default)', + build: () => new TokenException(), + fault: 'internal', + }, + { + name: 'AuthenticationFeatureConfigException', + build: () => + new AuthenticationFeatureConfigException('someFeature', ['somePort']), + fault: 'usage', + }, + { + name: 'JwtVerifyException', + build: () => new JwtVerifyException(), + fault: 'client', + }, + { + name: 'JwtException (default)', + build: () => new JwtException(), + fault: 'internal', + }, + { + name: 'RecoveryOtpInvalidException', + build: () => new RecoveryOtpInvalidException(), + fault: 'client', + }, + { + name: 'RecoveryException (default)', + build: () => new RecoveryException(), + fault: 'internal', + }, + { + name: 'VerifyException (default)', + build: () => new VerifyException(), + fault: 'internal', + }, + { + name: 'AuthRouterAuthenticationFailedException', + build: () => + new AuthRouterAuthenticationFailedException('someProvider', 'reason'), + fault: 'client', + }, + { + name: 'AuthRouterConfigNotAvailableException', + build: () => new AuthRouterConfigNotAvailableException(), + fault: 'usage', + }, + { + name: 'AuthRouterGuardInvalidException', + build: () => new AuthRouterGuardInvalidException('someProvider'), + fault: 'usage', + }, + { + name: 'AuthRouterProviderMissingException', + build: () => new AuthRouterProviderMissingException(), + fault: 'client', + }, + { + name: 'AuthRouterProviderNotSupportedException', + build: () => new AuthRouterProviderNotSupportedException('someProvider'), + fault: 'client', + }, + { + name: 'AuthRouterException (default)', + build: () => new AuthRouterException(), + fault: 'internal', + }, + { + name: 'JwtAuthenticationException (default)', + build: () => new JwtAuthenticationException(), + fault: 'internal', + }, + { + name: 'JwtUnauthorizedException', + build: () => new JwtUnauthorizedException(), + fault: 'client', + }, + { + name: 'LocalInvalidCredentialsException', + build: () => new LocalInvalidCredentialsException(), + fault: 'client', + }, + { + name: 'LocalInvalidLoginDataException', + build: () => new LocalInvalidLoginDataException(), + fault: 'client', + }, + { + name: 'LocalUnauthorizedException (default)', + build: () => new LocalUnauthorizedException(), + fault: 'internal', + }, + { + name: 'LocalException (default)', + build: () => new LocalException(), + fault: 'internal', + }, + { + name: 'RefreshUnauthorizedException', + build: () => new RefreshUnauthorizedException(), + fault: 'client', + }, + { + name: 'RefreshException (default)', + build: () => new RefreshException(), + fault: 'internal', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-authentication/src/__tests__/fault-override.spec.ts b/packages/nestjs-authentication/src/__tests__/fault-override.spec.ts new file mode 100644 index 000000000..afbe74617 --- /dev/null +++ b/packages/nestjs-authentication/src/__tests__/fault-override.spec.ts @@ -0,0 +1,53 @@ +import { AuthenticationAccessTokenException } from '../application/exceptions/authentication-access-token.exception.js'; +import { AuthenticationRefreshTokenException } from '../application/exceptions/authentication-refresh-token.exception.js'; +import { AuthenticationUserPortRequiredException } from '../application/exceptions/authentication-user-port-required.exception.js'; +import { AuthenticationFeatureConfigException } from '../infrastructure/exceptions/authentication-feature-config.exception.js'; +import { JwtUnauthorizedException } from '../infrastructure/strategies/jwt/exceptions/jwt-unauthorized.exception.js'; +import { RefreshUnauthorizedException } from '../infrastructure/strategies/refresh/exceptions/refresh-unauthorized.exception.js'; + +/** + * Regression check for classes whose constructor previously spread + * `...options` before setting `fault`, silently discarding a caller's + * `fault` override while `httpStatus` (correctly excluded from the options + * type) stayed pinned. `fault` must now be settable the same way it is on + * every other exception in this package. + */ +describe('fault is overridable via options, not silently discarded', () => { + it('AuthenticationAccessTokenException accepts fault: usage', () => { + expect( + new AuthenticationAccessTokenException({ fault: 'usage' }).fault, + ).toBe('usage'); + }); + + it('AuthenticationRefreshTokenException accepts fault: usage', () => { + expect( + new AuthenticationRefreshTokenException({ fault: 'usage' }).fault, + ).toBe('usage'); + }); + + it('AuthenticationUserPortRequiredException accepts fault: client', () => { + expect( + new AuthenticationUserPortRequiredException({ fault: 'client' }).fault, + ).toBe('client'); + }); + + it('AuthenticationFeatureConfigException accepts fault: client', () => { + expect( + new AuthenticationFeatureConfigException('feature', ['port'], { + fault: 'client', + }).fault, + ).toBe('client'); + }); + + it('JwtUnauthorizedException accepts fault: internal', () => { + expect(new JwtUnauthorizedException({ fault: 'internal' }).fault).toBe( + 'internal', + ); + }); + + it('RefreshUnauthorizedException accepts fault: internal', () => { + expect(new RefreshUnauthorizedException({ fault: 'internal' }).fault).toBe( + 'internal', + ); + }); +}); diff --git a/packages/nestjs-authentication/src/__tests__/fixtures/app.module.fixture.ts b/packages/nestjs-authentication/src/__tests__/fixtures/app.module.fixture.ts new file mode 100644 index 000000000..70825a0e0 --- /dev/null +++ b/packages/nestjs-authentication/src/__tests__/fixtures/app.module.fixture.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { AuthenticationModule } from '../../authentication.module.js'; + +@Module({ + imports: [ + CqrsModule, + AuthenticationModule.forRoot({ + appGuard: false, + settings: { + jwt: { + access: { + secret: 'test-access-secret', + signOptions: { expiresIn: '1h' }, + }, + refresh: { + secret: 'test-refresh-secret', + signOptions: { expiresIn: '7d' }, + }, + }, + }, + }), + ], + exports: [CqrsModule], +}) +export class AppModuleFixture {} diff --git a/packages/nestjs-authentication/src/__tests__/fixtures/global.module.fixture.ts b/packages/nestjs-authentication/src/__tests__/fixtures/global.module.fixture.ts new file mode 100644 index 000000000..30b4247c6 --- /dev/null +++ b/packages/nestjs-authentication/src/__tests__/fixtures/global.module.fixture.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { AUTHENTICATION_USER_PORT_TOKEN } from '../../authentication.constants.js'; + +import { + createMockUserPortProvider, + mockUserPortHandlers, +} from './ports/mock-user-port.provider.js'; + +@Global() +@Module({ + imports: [CqrsModule], + providers: [createMockUserPortProvider(), ...mockUserPortHandlers], + exports: [AUTHENTICATION_USER_PORT_TOKEN], +}) +export class GlobalModuleFixture {} diff --git a/packages/nestjs-authentication/src/__tests__/fixtures/ports/mock-password-port.provider.ts b/packages/nestjs-authentication/src/__tests__/fixtures/ports/mock-password-port.provider.ts new file mode 100644 index 000000000..09f714e9e --- /dev/null +++ b/packages/nestjs-authentication/src/__tests__/fixtures/ports/mock-password-port.provider.ts @@ -0,0 +1,88 @@ +import { PlainLiteralObject, Provider } from '@nestjs/common'; +import { + Command, + CommandBus, + CommandHandler, + ICommandHandler, +} from '@nestjs/cqrs'; + +import { ReferenceId, ReferenceIdInterface } from '@concepta/nestjs-core'; + +import { AUTHENTICATION_PASSWORD_PORT_TOKEN } from '../../../authentication.constants.js'; +import { + PasswordPort, + PasswordPortSettings, + SetPasswordCommandInterface, + ValidatePasswordCommandInterface, +} from '../../../domain/ports/password.port.js'; + +// ── Mock commands ── + +export class MockValidatePasswordCommand + extends Command + implements ValidatePasswordCommandInterface +{ + constructor( + public ctx: PlainLiteralObject, + public password: string, + public target: ReferenceIdInterface, + ) { + super(); + } +} + +export class MockSetPasswordCommand + extends Command + implements SetPasswordCommandInterface +{ + constructor( + public ctx: PlainLiteralObject, + public password: string, + public assigneeId: ReferenceId, + ) { + super(); + } +} + +// ── Mock handlers ── + +@CommandHandler(MockValidatePasswordCommand) +export class MockValidatePasswordHandler implements ICommandHandler { + async execute(_command: MockValidatePasswordCommand): Promise { + return true; + } +} + +@CommandHandler(MockSetPasswordCommand) +export class MockSetPasswordHandler implements ICommandHandler { + async execute(_command: MockSetPasswordCommand): Promise { + return; + } +} + +// ── Port settings ── + +export const mockPasswordPortSettings: PasswordPortSettings = { + validateCommand: MockValidatePasswordCommand, + setPasswordCommand: MockSetPasswordCommand, +}; + +// ── Reusable mock handlers array ── + +export const mockPasswordPortHandlers = [ + MockValidatePasswordHandler, + MockSetPasswordHandler, +]; + +// ── Provider factory ── + +export function createMockPasswordPortProvider( + settings: PasswordPortSettings = mockPasswordPortSettings, +): Provider { + return { + provide: AUTHENTICATION_PASSWORD_PORT_TOKEN, + inject: [CommandBus], + useFactory: (commandBus: CommandBus) => + new PasswordPort(settings, commandBus), + }; +} diff --git a/packages/nestjs-authentication/src/__tests__/fixtures/ports/mock-user-port.provider.ts b/packages/nestjs-authentication/src/__tests__/fixtures/ports/mock-user-port.provider.ts new file mode 100644 index 000000000..b78b3547f --- /dev/null +++ b/packages/nestjs-authentication/src/__tests__/fixtures/ports/mock-user-port.provider.ts @@ -0,0 +1,173 @@ +import { PlainLiteralObject, Provider } from '@nestjs/common'; +import { + Command, + CommandBus, + CommandHandler, + ICommandHandler, + IQueryHandler, + Query, + QueryBus, + QueryHandler, +} from '@nestjs/cqrs'; + +import { + ReferenceEmail, + ReferenceId, + ReferenceSubject, +} from '@concepta/nestjs-core'; + +import { AUTHENTICATION_USER_PORT_TOKEN } from '../../../authentication.constants.js'; +import { + AuthenticationUserInterface, + AuthenticationUserResult, + GetUserByEmailQueryInterface, + GetUserByIdQueryInterface, + GetUserBySubjectQueryInterface, + GetUserByUsernameQueryInterface, + UpdateUserCommandInterface, + UserPort, + UserPortSettings, +} from '../../../domain/ports/user.port.js'; + +// ── Mock queries/commands ── + +export class MockGetUserByIdQuery + extends Query + implements GetUserByIdQueryInterface +{ + constructor( + public ctx: PlainLiteralObject, + public id: ReferenceId, + ) { + super(); + } +} + +export class MockGetUserBySubjectQuery + extends Query + implements GetUserBySubjectQueryInterface +{ + constructor( + public ctx: PlainLiteralObject, + public subject: ReferenceSubject, + ) { + super(); + } +} + +export class MockGetUserByUsernameQuery + extends Query + implements GetUserByUsernameQueryInterface +{ + constructor( + public ctx: PlainLiteralObject, + public username: string, + ) { + super(); + } +} + +export class MockGetUserByEmailQuery + extends Query + implements GetUserByEmailQueryInterface +{ + constructor( + public ctx: PlainLiteralObject, + public email: ReferenceEmail, + ) { + super(); + } +} + +export class MockUpdateUserCommand + extends Command + implements UpdateUserCommandInterface +{ + constructor( + public ctx: PlainLiteralObject, + public id: ReferenceId, + public dto: Partial, + ) { + super(); + } +} + +// ── Mock handlers ── + +@QueryHandler(MockGetUserByIdQuery) +export class MockGetUserByIdHandler implements IQueryHandler { + async execute( + _query: MockGetUserByIdQuery, + ): Promise { + return null; + } +} + +@QueryHandler(MockGetUserBySubjectQuery) +export class MockGetUserBySubjectHandler implements IQueryHandler { + async execute( + _query: MockGetUserBySubjectQuery, + ): Promise { + return null; + } +} + +@QueryHandler(MockGetUserByUsernameQuery) +export class MockGetUserByUsernameHandler implements IQueryHandler { + async execute( + _query: MockGetUserByUsernameQuery, + ): Promise { + return null; + } +} + +@QueryHandler(MockGetUserByEmailQuery) +export class MockGetUserByEmailHandler implements IQueryHandler { + async execute( + _query: MockGetUserByEmailQuery, + ): Promise { + return null; + } +} + +@CommandHandler(MockUpdateUserCommand) +export class MockUpdateUserHandler implements ICommandHandler { + async execute( + _command: MockUpdateUserCommand, + ): Promise { + return null; + } +} + +// ── Port settings ── + +export const mockUserPortSettings: UserPortSettings = { + getByIdQuery: MockGetUserByIdQuery, + getBySubjectQuery: MockGetUserBySubjectQuery, + getByUsernameQuery: MockGetUserByUsernameQuery, + getByEmailQuery: MockGetUserByEmailQuery, + updateCommand: MockUpdateUserCommand, +}; + +// ── Reusable mock handlers array ── + +export const mockUserPortHandlers = [ + MockGetUserByIdHandler, + MockGetUserBySubjectHandler, + MockGetUserByUsernameHandler, + MockGetUserByEmailHandler, + MockUpdateUserHandler, +]; + +// ── Provider factory ── + +export function createMockUserPortProvider( + settings: UserPortSettings = mockUserPortSettings, +): Provider { + return { + provide: AUTHENTICATION_USER_PORT_TOKEN, + inject: [QueryBus, CommandBus], + useFactory: (queryBus: QueryBus, commandBus: CommandBus) => + new UserPort(settings, queryBus, commandBus), + }; +} diff --git a/packages/nestjs-authentication/src/__tests__/fixtures/ports/stub-unused-ports.fixture.ts b/packages/nestjs-authentication/src/__tests__/fixtures/ports/stub-unused-ports.fixture.ts new file mode 100644 index 000000000..06caad6a6 --- /dev/null +++ b/packages/nestjs-authentication/src/__tests__/fixtures/ports/stub-unused-ports.fixture.ts @@ -0,0 +1,149 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command, Query } from '@nestjs/cqrs'; + +import { + type AssigneeRelationInterface, + type ReferenceEmail, +} from '@concepta/nestjs-core'; + +import { + type AuthenticationOtpCreatableInterface, + type AuthenticationOtpInterface, + type ClearOtpCommandInterface, + type CreateOtpCommandInterface, + type OtpCreateOptions, + type OtpPortSettings, + type ValidateOtpQueryInterface, +} from '../../../domain/ports/otp.port.js'; +import { + type RecoveryNotificationPortSettings, + type SendPasswordUpdatedNotificationCommandInterface, + type SendRecoverLoginNotificationCommandInterface, + type SendRecoverPasswordNotificationCommandInterface, +} from '../../../domain/ports/recovery-notification.port.js'; +import { + type SendVerifyNotificationCommandInterface, + type VerifyNotificationPortSettings, +} from '../../../domain/ports/verify-notification.port.js'; + +/** + * `AuthenticationPortsInterface` requires `otp`, `recoveryNotification` and + * `verifyNotification` whenever `ports` is supplied, even for fixtures that + * only exercise jwt/local/refresh strategies. These command/query classes + * are never dispatched by those fixtures — no handlers are registered for + * them on purpose. + */ + +class StubCreateOtpCommand + extends Command + implements CreateOtpCommandInterface +{ + constructor( + public ctx: PlainLiteralObject, + public namespace: string, + public otp: AuthenticationOtpCreatableInterface, + public options?: OtpCreateOptions, + ) { + super(); + } +} + +class StubValidateOtpQuery + extends Query + implements ValidateOtpQueryInterface +{ + constructor( + public ctx: PlainLiteralObject, + public namespace: string, + public otp: Pick, + ) { + super(); + } +} + +class StubClearOtpCommand + extends Command + implements ClearOtpCommandInterface +{ + constructor( + public ctx: PlainLiteralObject, + public namespace: string, + public otp: Pick, + ) { + super(); + } +} + +export const stubOtpPortSettings: OtpPortSettings = { + createCommand: StubCreateOtpCommand, + validateQuery: StubValidateOtpQuery, + clearCommand: StubClearOtpCommand, +}; + +class StubSendRecoverLoginNotificationCommand + extends Command + implements SendRecoverLoginNotificationCommandInterface +{ + constructor( + public ctx: PlainLiteralObject, + public email: ReferenceEmail, + public username: string, + ) { + super(); + } +} + +class StubSendRecoverPasswordNotificationCommand + extends Command + implements SendRecoverPasswordNotificationCommandInterface +{ + constructor( + public ctx: PlainLiteralObject, + public email: ReferenceEmail, + public passcode: string, + public tokenExp: Date, + ) { + super(); + } +} + +class StubSendPasswordUpdatedNotificationCommand + extends Command + implements SendPasswordUpdatedNotificationCommandInterface +{ + constructor( + public ctx: PlainLiteralObject, + public email: ReferenceEmail, + ) { + super(); + } +} + +export const stubRecoveryNotificationPortSettings: RecoveryNotificationPortSettings = + { + sendRecoverLoginNotificationCommand: + StubSendRecoverLoginNotificationCommand, + sendRecoverPasswordNotificationCommand: + StubSendRecoverPasswordNotificationCommand, + sendPasswordUpdatedNotificationCommand: + StubSendPasswordUpdatedNotificationCommand, + }; + +class StubSendVerifyNotificationCommand + extends Command + implements SendVerifyNotificationCommandInterface +{ + constructor( + public ctx: PlainLiteralObject, + public email: ReferenceEmail, + public passcode: string, + public tokenExp: Date, + ) { + super(); + } +} + +export const stubVerifyNotificationPortSettings: VerifyNotificationPortSettings = + { + sendVerifyNotificationCommand: StubSendVerifyNotificationCommand, + }; diff --git a/packages/nestjs-authentication/src/__tests__/fixtures/user.module.fixture.ts b/packages/nestjs-authentication/src/__tests__/fixtures/user.module.fixture.ts new file mode 100644 index 000000000..a857f3adc --- /dev/null +++ b/packages/nestjs-authentication/src/__tests__/fixtures/user.module.fixture.ts @@ -0,0 +1,38 @@ +import { Global, Module } from '@nestjs/common'; +import { CqrsModule, QueryHandler } from '@nestjs/cqrs'; + +import { + MockGetUserByIdHandler, + MockGetUserByEmailHandler, + MockGetUserByUsernameHandler, + MockGetUserBySubjectQuery, + MockUpdateUserHandler, +} from './ports/mock-user-port.provider.js'; + +export const FIXTURE_USER = { + id: 'fixture-user-id', + active: true, +}; + +@QueryHandler(MockGetUserBySubjectQuery) +class GetUserBySubjectHandler { + async execute() { + return FIXTURE_USER; + } +} + +// AUTHENTICATION_USER_PORT_TOKEN is provided by AuthenticationModule itself +// via `ports.user` (see AppModuleFixture) — this module only supplies the +// CQRS handlers that UserPort dispatches to. +@Global() +@Module({ + imports: [CqrsModule], + providers: [ + MockGetUserByIdHandler, + GetUserBySubjectHandler, + MockGetUserByUsernameHandler, + MockGetUserByEmailHandler, + MockUpdateUserHandler, + ], +}) +export class UserModuleFixture {} diff --git a/packages/nestjs-authentication/src/application/commands/handlers/__tests__/issue-authenticated-response.handler.spec.ts b/packages/nestjs-authentication/src/application/commands/handlers/__tests__/issue-authenticated-response.handler.spec.ts new file mode 100644 index 000000000..f3d86e5e9 --- /dev/null +++ b/packages/nestjs-authentication/src/application/commands/handlers/__tests__/issue-authenticated-response.handler.spec.ts @@ -0,0 +1,108 @@ +import { randomUUID } from 'crypto'; + +import { mock } from 'vitest-mock-extended'; + +import { type EventPublisher } from '@nestjs/cqrs'; + +import { type Token } from '../../../../domain/aggregates/token.aggregate.js'; +import { TokenIssuedEvent } from '../../../../domain/events/token-issued.event.js'; +import { type AuthenticatedResponseInterface } from '../../../../domain/interfaces/authenticated-response.interface.js'; +import { type JwtPolicy } from '../../../../domain/policies/jwt.policy.js'; +import { type JwtPort } from '../../../../domain/ports/jwt.port.js'; +import { IssueAuthenticatedResponseCommand } from '../../impl/issue-authenticated-response.command.js'; +import { IssueAuthenticatedResponseHandler } from '../issue-authenticated-response.handler.js'; + +describe(IssueAuthenticatedResponseHandler.name, () => { + const accessTokenStr = 'accessToken'; + const refreshTokenStr = 'refreshToken'; + const userId = randomUUID(); + const now = new Date(); + const accessExp = new Date(now.getTime() + 3600_000); + const refreshExp = new Date(now.getTime() + 86_400_000); + + let handler: IssueAuthenticatedResponseHandler; + let jwtPort: JwtPort; + let jwtPolicy: JwtPolicy; + let eventPublisher: EventPublisher; + + beforeEach(() => { + jwtPort = mock(); + jwtPolicy = mock(); + eventPublisher = mock(); + + void jwtPort.signAccessToken; + vi.spyOn(jwtPort, 'signAccessToken').mockResolvedValue(accessTokenStr); + void jwtPort.signRefreshToken; + vi.spyOn(jwtPort, 'signRefreshToken').mockResolvedValue(refreshTokenStr); + void jwtPolicy.getAccessExpiry; + vi.spyOn(jwtPolicy, 'getAccessExpiry').mockReturnValue(accessExp); + void jwtPolicy.getRefreshExpiry; + vi.spyOn(jwtPolicy, 'getRefreshExpiry').mockReturnValue(refreshExp); + void eventPublisher.mergeObjectContext; + vi.spyOn(eventPublisher, 'mergeObjectContext').mockImplementation( + (agg) => agg as Token, + ); + + handler = new IssueAuthenticatedResponseHandler( + jwtPort, + jwtPolicy, + eventPublisher, + ); + }); + + it('should return response with accessToken and refreshToken', async () => { + const command = new IssueAuthenticatedResponseCommand({}, userId); + const result: AuthenticatedResponseInterface = + await handler.execute(command); + + expect(result).toEqual({ + accessToken: accessTokenStr, + refreshToken: refreshTokenStr, + }); + }); + + it('should sign access token with an access-type Token aggregate', async () => { + const command = new IssueAuthenticatedResponseCommand({}, userId); + await handler.execute(command); + + expect(jwtPort.signAccessToken).toHaveBeenCalledWith( + {}, + expect.objectContaining({ sub: userId, type: 'access' }), + ); + }); + + it('should sign refresh token with a refresh-type Token aggregate', async () => { + const command = new IssueAuthenticatedResponseCommand({}, userId); + await handler.execute(command); + + expect(jwtPort.signRefreshToken).toHaveBeenCalledWith( + {}, + expect.objectContaining({ sub: userId, type: 'refresh' }), + ); + }); + + it('should share one correlationId/causationId across access and refresh token events', async () => { + // `mergeObjectContext` runs right after each `Token.create()`, before + // the handler's own `commit()` clears the aggregate's uncommitted + // events — capture the events here rather than after `execute` returns. + const capturedEvents: TokenIssuedEvent[] = []; + vi.spyOn(eventPublisher, 'mergeObjectContext').mockImplementation((agg) => { + for (const event of agg.getUncommittedEvents()) { + if (event instanceof TokenIssuedEvent) capturedEvents.push(event); + } + return agg as Token; + }); + + const command = new IssueAuthenticatedResponseCommand({}, userId); + await handler.execute(command); + + const [accessEvent, refreshEvent] = capturedEvents; + + expect(refreshEvent.eventContext.getHeader('correlationId')).toBe( + accessEvent.eventContext.getHeader('correlationId'), + ); + expect(refreshEvent.eventContext.getHeader('causationId')).toBe( + accessEvent.eventContext.getHeader('causationId'), + ); + }); +}); diff --git a/packages/nestjs-authentication/src/application/commands/handlers/issue-access-token.handler.ts b/packages/nestjs-authentication/src/application/commands/handlers/issue-access-token.handler.ts new file mode 100644 index 000000000..ae67f7fae --- /dev/null +++ b/packages/nestjs-authentication/src/application/commands/handlers/issue-access-token.handler.ts @@ -0,0 +1,42 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; + +import { AUTHENTICATION_JWT_PORT_TOKEN } from '../../../authentication.constants.js'; +import { Token } from '../../../domain/aggregates/token.aggregate.js'; +import { JwtPolicy } from '../../../domain/policies/jwt.policy.js'; +import { JwtPort } from '../../../domain/ports/jwt.port.js'; +import { IssueAccessTokenCommand } from '../impl/issue-access-token.command.js'; + +@CommandHandler(IssueAccessTokenCommand) +export class IssueAccessTokenHandler implements ICommandHandler< + IssueAccessTokenCommand, + string +> { + constructor( + @Inject(AUTHENTICATION_JWT_PORT_TOKEN) + private readonly jwtPort: JwtPort, + @Inject(JwtPolicy) + private readonly jwtPolicy: JwtPolicy, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: IssueAccessTokenCommand): Promise { + const { ctx, payload } = command; + const now = new Date(); + + const token = this.eventPublisher.mergeObjectContext( + Token.create(createEventContext(ctx, {}, {}), { + sub: payload.sub, + type: 'access', + iat: now, + exp: this.jwtPolicy.getAccessExpiry(now), + }), + ); + + const signed = await this.jwtPort.signAccessToken(ctx, token); + token.commit(); + return signed; + } +} diff --git a/packages/nestjs-authentication/src/application/commands/handlers/issue-authenticated-response.handler.ts b/packages/nestjs-authentication/src/application/commands/handlers/issue-authenticated-response.handler.ts new file mode 100644 index 000000000..28236630a --- /dev/null +++ b/packages/nestjs-authentication/src/application/commands/handlers/issue-authenticated-response.handler.ts @@ -0,0 +1,61 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; + +import { AUTHENTICATION_JWT_PORT_TOKEN } from '../../../authentication.constants.js'; +import { Token } from '../../../domain/aggregates/token.aggregate.js'; +import { AuthenticatedResponseInterface } from '../../../domain/interfaces/authenticated-response.interface.js'; +import { JwtPolicy } from '../../../domain/policies/jwt.policy.js'; +import { JwtPort } from '../../../domain/ports/jwt.port.js'; +import { IssueAuthenticatedResponseCommand } from '../impl/issue-authenticated-response.command.js'; + +@CommandHandler(IssueAuthenticatedResponseCommand) +export class IssueAuthenticatedResponseHandler implements ICommandHandler< + IssueAuthenticatedResponseCommand, + AuthenticatedResponseInterface +> { + constructor( + @Inject(AUTHENTICATION_JWT_PORT_TOKEN) + private readonly jwtPort: JwtPort, + @Inject(JwtPolicy) + private readonly jwtPolicy: JwtPolicy, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute( + command: IssueAuthenticatedResponseCommand, + ): Promise { + const { ctx, id } = command; + const now = new Date(); + const eventContext = createEventContext(ctx, {}, {}); + + const accessAgg = this.eventPublisher.mergeObjectContext( + Token.create(eventContext, { + sub: id, + type: 'access', + iat: now, + exp: this.jwtPolicy.getAccessExpiry(now), + }), + ); + + const refreshAgg = this.eventPublisher.mergeObjectContext( + Token.create(eventContext, { + sub: id, + type: 'refresh', + iat: now, + exp: this.jwtPolicy.getRefreshExpiry(now), + }), + ); + + const [accessToken, refreshToken] = await Promise.all([ + this.jwtPort.signAccessToken(ctx, accessAgg), + this.jwtPort.signRefreshToken(ctx, refreshAgg), + ]); + + accessAgg.commit(); + refreshAgg.commit(); + + return { accessToken, refreshToken }; + } +} diff --git a/packages/nestjs-authentication/src/application/commands/handlers/issue-refresh-token.handler.ts b/packages/nestjs-authentication/src/application/commands/handlers/issue-refresh-token.handler.ts new file mode 100644 index 000000000..ff733f00e --- /dev/null +++ b/packages/nestjs-authentication/src/application/commands/handlers/issue-refresh-token.handler.ts @@ -0,0 +1,42 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; + +import { AUTHENTICATION_JWT_PORT_TOKEN } from '../../../authentication.constants.js'; +import { Token } from '../../../domain/aggregates/token.aggregate.js'; +import { JwtPolicy } from '../../../domain/policies/jwt.policy.js'; +import { JwtPort } from '../../../domain/ports/jwt.port.js'; +import { IssueRefreshTokenCommand } from '../impl/issue-refresh-token.command.js'; + +@CommandHandler(IssueRefreshTokenCommand) +export class IssueRefreshTokenHandler implements ICommandHandler< + IssueRefreshTokenCommand, + string +> { + constructor( + @Inject(AUTHENTICATION_JWT_PORT_TOKEN) + private readonly jwtPort: JwtPort, + @Inject(JwtPolicy) + private readonly jwtPolicy: JwtPolicy, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: IssueRefreshTokenCommand): Promise { + const { ctx, payload } = command; + const now = new Date(); + + const token = this.eventPublisher.mergeObjectContext( + Token.create(createEventContext(ctx, {}, {}), { + sub: payload.sub, + type: 'refresh', + iat: now, + exp: this.jwtPolicy.getRefreshExpiry(now), + }), + ); + + const signed = await this.jwtPort.signRefreshToken(ctx, token); + token.commit(); + return signed; + } +} diff --git a/packages/nestjs-authentication/src/application/commands/handlers/sign-access-token.handler.ts b/packages/nestjs-authentication/src/application/commands/handlers/sign-access-token.handler.ts new file mode 100644 index 000000000..c90ac63e1 --- /dev/null +++ b/packages/nestjs-authentication/src/application/commands/handlers/sign-access-token.handler.ts @@ -0,0 +1,16 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { JwtService } from '../../../infrastructure/jwt/jwt.service.js'; +import { SignAccessTokenCommand } from '../impl/sign-access-token.command.js'; + +@CommandHandler(SignAccessTokenCommand) +export class SignAccessTokenHandler implements ICommandHandler< + SignAccessTokenCommand, + string +> { + constructor(private readonly jwtService: JwtService) {} + + async execute(command: SignAccessTokenCommand): Promise { + return this.jwtService.signAccessToken(command.token); + } +} diff --git a/packages/nestjs-authentication/src/application/commands/handlers/sign-refresh-token.handler.ts b/packages/nestjs-authentication/src/application/commands/handlers/sign-refresh-token.handler.ts new file mode 100644 index 000000000..7b54626cd --- /dev/null +++ b/packages/nestjs-authentication/src/application/commands/handlers/sign-refresh-token.handler.ts @@ -0,0 +1,16 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { JwtService } from '../../../infrastructure/jwt/jwt.service.js'; +import { SignRefreshTokenCommand } from '../impl/sign-refresh-token.command.js'; + +@CommandHandler(SignRefreshTokenCommand) +export class SignRefreshTokenHandler implements ICommandHandler< + SignRefreshTokenCommand, + string +> { + constructor(private readonly jwtService: JwtService) {} + + async execute(command: SignRefreshTokenCommand): Promise { + return this.jwtService.signRefreshToken(command.token); + } +} diff --git a/packages/nestjs-authentication/src/application/commands/impl/issue-access-token.command.ts b/packages/nestjs-authentication/src/application/commands/impl/issue-access-token.command.ts new file mode 100644 index 000000000..63887da0c --- /dev/null +++ b/packages/nestjs-authentication/src/application/commands/impl/issue-access-token.command.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type AuthorizationPayloadInterface } from '../../../domain/interfaces/authorization-payload.interface.js'; +import { type IssueTokenCommandInterface } from '../../../domain/ports/token.port.js'; + +export class IssueAccessTokenCommand + extends Command + implements IssueTokenCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly payload: AuthorizationPayloadInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/commands/impl/issue-authenticated-response.command.ts b/packages/nestjs-authentication/src/application/commands/impl/issue-authenticated-response.command.ts new file mode 100644 index 000000000..da4e2d1f7 --- /dev/null +++ b/packages/nestjs-authentication/src/application/commands/impl/issue-authenticated-response.command.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type AuthenticatedResponseInterface } from '../../../domain/interfaces/authenticated-response.interface.js'; + +export class IssueAuthenticatedResponseCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/commands/impl/issue-refresh-token.command.ts b/packages/nestjs-authentication/src/application/commands/impl/issue-refresh-token.command.ts new file mode 100644 index 000000000..3dbc371cf --- /dev/null +++ b/packages/nestjs-authentication/src/application/commands/impl/issue-refresh-token.command.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type AuthorizationPayloadInterface } from '../../../domain/interfaces/authorization-payload.interface.js'; +import { type IssueTokenCommandInterface } from '../../../domain/ports/token.port.js'; + +export class IssueRefreshTokenCommand + extends Command + implements IssueTokenCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly payload: AuthorizationPayloadInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/commands/impl/sign-access-token.command.ts b/packages/nestjs-authentication/src/application/commands/impl/sign-access-token.command.ts new file mode 100644 index 000000000..d67ca4a6a --- /dev/null +++ b/packages/nestjs-authentication/src/application/commands/impl/sign-access-token.command.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type Token } from '../../../domain/aggregates/token.aggregate.js'; +import { type SignTokenCommandInterface } from '../../../domain/ports/jwt.port.js'; + +export class SignAccessTokenCommand + extends Command + implements SignTokenCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: Token, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/commands/impl/sign-refresh-token.command.ts b/packages/nestjs-authentication/src/application/commands/impl/sign-refresh-token.command.ts new file mode 100644 index 000000000..d5367855d --- /dev/null +++ b/packages/nestjs-authentication/src/application/commands/impl/sign-refresh-token.command.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type Token } from '../../../domain/aggregates/token.aggregate.js'; +import { type SignTokenCommandInterface } from '../../../domain/ports/jwt.port.js'; + +export class SignRefreshTokenCommand + extends Command + implements SignTokenCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: Token, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/exceptions/authentication-access-token.exception.ts b/packages/nestjs-authentication/src/application/exceptions/authentication-access-token.exception.ts new file mode 100644 index 000000000..810c98278 --- /dev/null +++ b/packages/nestjs-authentication/src/application/exceptions/authentication-access-token.exception.ts @@ -0,0 +1,20 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthenticationException } from '../../domain/exceptions/authentication.exception.js'; + +/** + * Exception for authentication + */ +export class AuthenticationAccessTokenException extends AuthenticationException { + constructor(options?: Omit) { + super({ + message: 'Access token was verified, but failed further validation.', + fault: 'client', + ...options, + httpStatus: HttpStatus.UNAUTHORIZED, + }); + this.errorCode = 'AUTHENTICATION_ACCESS_TOKEN_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/application/exceptions/authentication-refresh-token.exception.ts b/packages/nestjs-authentication/src/application/exceptions/authentication-refresh-token.exception.ts new file mode 100644 index 000000000..ce656b514 --- /dev/null +++ b/packages/nestjs-authentication/src/application/exceptions/authentication-refresh-token.exception.ts @@ -0,0 +1,20 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthenticationException } from '../../domain/exceptions/authentication.exception.js'; + +/** + * Exception for authentication + */ +export class AuthenticationRefreshTokenException extends AuthenticationException { + constructor(options?: Omit) { + super({ + message: 'Refresh token was verified, but failed further validation.', + fault: 'client', + ...options, + httpStatus: HttpStatus.UNAUTHORIZED, + }); + this.errorCode = 'AUTHENTICATION_REFRESH_TOKEN_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/application/exceptions/authentication-user-port-required.exception.ts b/packages/nestjs-authentication/src/application/exceptions/authentication-user-port-required.exception.ts new file mode 100644 index 000000000..ed9ef1859 --- /dev/null +++ b/packages/nestjs-authentication/src/application/exceptions/authentication-user-port-required.exception.ts @@ -0,0 +1,21 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthenticationException } from '../../domain/exceptions/authentication.exception.js'; + +/** + * Exception thrown when requireUserValidation is enabled but no user port is configured. + */ +export class AuthenticationUserPortRequiredException extends AuthenticationException { + constructor(options?: Omit) { + super({ + message: + 'User port is required when requireUserValidation is enabled, but no user port was configured.', + fault: 'usage', + ...options, + httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + }); + this.errorCode = 'AUTHENTICATION_USER_PORT_REQUIRED_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/application/exceptions/local-invalid-password.exception.ts b/packages/nestjs-authentication/src/application/exceptions/local-invalid-password.exception.ts new file mode 100644 index 000000000..109ef6414 --- /dev/null +++ b/packages/nestjs-authentication/src/application/exceptions/local-invalid-password.exception.ts @@ -0,0 +1,15 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { LocalInvalidCredentialsException } from '../../infrastructure/strategies/local/exceptions/local-invalid-credentials.exception.js'; + +export class LocalInvalidPasswordException extends LocalInvalidCredentialsException { + constructor(userName: string, options?: RuntimeExceptionOptions) { + super({ + message: `Invalid password for username: %s`, + messageParams: [userName], + ...options, + }); + + this.errorCode = 'AUTH_LOCAL_INVALID_PASSWORD_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/application/exceptions/local-user-inactive.exception.ts b/packages/nestjs-authentication/src/application/exceptions/local-user-inactive.exception.ts new file mode 100644 index 000000000..48684129e --- /dev/null +++ b/packages/nestjs-authentication/src/application/exceptions/local-user-inactive.exception.ts @@ -0,0 +1,15 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { LocalInvalidCredentialsException } from '../../infrastructure/strategies/local/exceptions/local-invalid-credentials.exception.js'; + +export class LocalUserInactiveException extends LocalInvalidCredentialsException { + constructor(userName: string, options?: RuntimeExceptionOptions) { + super({ + message: `User with username '%s' is inactive`, + messageParams: [userName], + ...options, + }); + + this.errorCode = 'AUTH_LOCAL_USER_INACTIVE_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/application/exceptions/local-username-not-found.exception.ts b/packages/nestjs-authentication/src/application/exceptions/local-username-not-found.exception.ts new file mode 100644 index 000000000..d066279d5 --- /dev/null +++ b/packages/nestjs-authentication/src/application/exceptions/local-username-not-found.exception.ts @@ -0,0 +1,15 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { LocalInvalidCredentialsException } from '../../infrastructure/strategies/local/exceptions/local-invalid-credentials.exception.js'; + +export class LocalUsernameNotFoundException extends LocalInvalidCredentialsException { + constructor(userName: string, options?: RuntimeExceptionOptions) { + super({ + message: `No user found for username: %s`, + messageParams: [userName], + ...options, + }); + + this.errorCode = 'AUTH_LOCAL_USERNAME_NOT_FOUND_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/application/exceptions/verify-otp-invalid.exception.ts b/packages/nestjs-authentication/src/application/exceptions/verify-otp-invalid.exception.ts new file mode 100644 index 000000000..bf7672c65 --- /dev/null +++ b/packages/nestjs-authentication/src/application/exceptions/verify-otp-invalid.exception.ts @@ -0,0 +1,18 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { VerifyException } from '../../infrastructure/mfa/verify/exceptions/verify.exception.js'; + +export class VerifyOtpInvalidException extends VerifyException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: `Invalid confirmation code provided`, + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + + this.errorCode = 'AUTH_VERIFY_OTP_INVALID_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/application/queries/handlers/__tests__/validate-and-verify-access-token.handler.spec.ts b/packages/nestjs-authentication/src/application/queries/handlers/__tests__/validate-and-verify-access-token.handler.spec.ts new file mode 100644 index 000000000..a28300b44 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/__tests__/validate-and-verify-access-token.handler.spec.ts @@ -0,0 +1,71 @@ +import { mock } from 'vitest-mock-extended'; + +import { type JwtPort } from '../../../../domain/ports/jwt.port.js'; +import { type UserPort } from '../../../../domain/ports/user.port.js'; +import { AuthenticationAccessTokenException } from '../../../exceptions/authentication-access-token.exception.js'; +import { ValidateAndVerifyAccessTokenQuery } from '../../impl/validate-and-verify-access-token.query.js'; +import { ValidateAndVerifyAccessTokenHandler } from '../validate-and-verify-access-token.handler.js'; + +describe(ValidateAndVerifyAccessTokenHandler.name, () => { + const token = 'access-token'; + const payload = { sub: 'user-1' }; + const user = { id: 'user-1' }; + + describe('with UserPort', () => { + let jwtPort: JwtPort; + let userPort: UserPort; + let handler: ValidateAndVerifyAccessTokenHandler; + + beforeEach(() => { + jwtPort = mock(); + userPort = mock(); + void jwtPort.verifyAccessToken; + vi.spyOn(jwtPort, 'verifyAccessToken').mockResolvedValue(payload); + handler = new ValidateAndVerifyAccessTokenHandler(jwtPort, userPort); + }); + + it('should return payload when token is valid and user is found', async () => { + void userPort.getBySubject; + vi.spyOn(userPort, 'getBySubject').mockResolvedValue(user as never); + const query = new ValidateAndVerifyAccessTokenQuery({}, token); + const result = await handler.execute(query); + expect(result).toEqual(payload); + }); + + it('should throw AuthenticationAccessTokenException when user is not found', async () => { + void userPort.getBySubject; + vi.spyOn(userPort, 'getBySubject').mockResolvedValue(null); + const query = new ValidateAndVerifyAccessTokenQuery({}, token); + await expect(handler.execute(query)).rejects.toThrow( + AuthenticationAccessTokenException, + ); + }); + + it('should throw when JWT verify fails', async () => { + void jwtPort.verifyAccessToken; + vi.spyOn(jwtPort, 'verifyAccessToken').mockRejectedValue( + new Error('invalid'), + ); + const query = new ValidateAndVerifyAccessTokenQuery({}, token); + await expect(handler.execute(query)).rejects.toThrow(); + }); + }); + + describe('without UserPort', () => { + let jwtPort: JwtPort; + let handler: ValidateAndVerifyAccessTokenHandler; + + beforeEach(() => { + jwtPort = mock(); + void jwtPort.verifyAccessToken; + vi.spyOn(jwtPort, 'verifyAccessToken').mockResolvedValue(payload); + handler = new ValidateAndVerifyAccessTokenHandler(jwtPort, null); + }); + + it('should return payload without user validation', async () => { + const query = new ValidateAndVerifyAccessTokenQuery({}, token); + const result = await handler.execute(query); + expect(result).toEqual(payload); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/application/queries/handlers/__tests__/validate-and-verify-refresh-token.handler.spec.ts b/packages/nestjs-authentication/src/application/queries/handlers/__tests__/validate-and-verify-refresh-token.handler.spec.ts new file mode 100644 index 000000000..ebb1cc6c4 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/__tests__/validate-and-verify-refresh-token.handler.spec.ts @@ -0,0 +1,71 @@ +import { mock } from 'vitest-mock-extended'; + +import { type JwtPort } from '../../../../domain/ports/jwt.port.js'; +import { type UserPort } from '../../../../domain/ports/user.port.js'; +import { AuthenticationRefreshTokenException } from '../../../exceptions/authentication-refresh-token.exception.js'; +import { ValidateAndVerifyRefreshTokenQuery } from '../../impl/validate-and-verify-refresh-token.query.js'; +import { ValidateAndVerifyRefreshTokenHandler } from '../validate-and-verify-refresh-token.handler.js'; + +describe(ValidateAndVerifyRefreshTokenHandler.name, () => { + const token = 'refresh-token'; + const payload = { sub: 'user-1' }; + const user = { id: 'user-1' }; + + describe('with UserPort', () => { + let jwtPort: JwtPort; + let userPort: UserPort; + let handler: ValidateAndVerifyRefreshTokenHandler; + + beforeEach(() => { + jwtPort = mock(); + userPort = mock(); + void jwtPort.verifyRefreshToken; + vi.spyOn(jwtPort, 'verifyRefreshToken').mockResolvedValue(payload); + handler = new ValidateAndVerifyRefreshTokenHandler(jwtPort, userPort); + }); + + it('should return payload when token is valid and user is found', async () => { + void userPort.getBySubject; + vi.spyOn(userPort, 'getBySubject').mockResolvedValue(user as never); + const query = new ValidateAndVerifyRefreshTokenQuery({}, token); + const result = await handler.execute(query); + expect(result).toEqual(payload); + }); + + it('should throw AuthenticationRefreshTokenException when user is not found', async () => { + void userPort.getBySubject; + vi.spyOn(userPort, 'getBySubject').mockResolvedValue(null); + const query = new ValidateAndVerifyRefreshTokenQuery({}, token); + await expect(handler.execute(query)).rejects.toThrow( + AuthenticationRefreshTokenException, + ); + }); + + it('should throw when JWT verify fails', async () => { + void jwtPort.verifyRefreshToken; + vi.spyOn(jwtPort, 'verifyRefreshToken').mockRejectedValue( + new Error('invalid'), + ); + const query = new ValidateAndVerifyRefreshTokenQuery({}, token); + await expect(handler.execute(query)).rejects.toThrow(); + }); + }); + + describe('without UserPort', () => { + let jwtPort: JwtPort; + let handler: ValidateAndVerifyRefreshTokenHandler; + + beforeEach(() => { + jwtPort = mock(); + void jwtPort.verifyRefreshToken; + vi.spyOn(jwtPort, 'verifyRefreshToken').mockResolvedValue(payload); + handler = new ValidateAndVerifyRefreshTokenHandler(jwtPort, null); + }); + + it('should return payload without user validation', async () => { + const query = new ValidateAndVerifyRefreshTokenQuery({}, token); + const result = await handler.execute(query); + expect(result).toEqual(payload); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/application/queries/handlers/__tests__/validate-token.handler.spec.ts b/packages/nestjs-authentication/src/application/queries/handlers/__tests__/validate-token.handler.spec.ts new file mode 100644 index 000000000..105a4ad61 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/__tests__/validate-token.handler.spec.ts @@ -0,0 +1,75 @@ +import { mock } from 'vitest-mock-extended'; + +import { JwtStrategyPolicy } from '../../../../domain/policies/jwt-strategy.policy.js'; +import { type UserPort } from '../../../../domain/ports/user.port.js'; +import { AuthenticationUserPortRequiredException } from '../../../exceptions/authentication-user-port-required.exception.js'; +import { ValidateTokenQuery } from '../../impl/validate-token.query.js'; +import { ValidateTokenHandler } from '../validate-token.handler.js'; + +describe(ValidateTokenHandler.name, () => { + const payload = { sub: 'user-1' }; + const user = { id: 'user-1' }; + + describe('with UserPort', () => { + let userPort: UserPort; + let handler: ValidateTokenHandler; + + beforeEach(() => { + userPort = mock(); + handler = new ValidateTokenHandler(new JwtStrategyPolicy({}), userPort); + }); + + it('should return true when user is found', async () => { + void userPort.getBySubject; + vi.spyOn(userPort, 'getBySubject').mockResolvedValue(user as never); + const query = new ValidateTokenQuery({}, payload); + const result = await handler.execute(query); + expect(result).toBe(true); + }); + + it('should return false when user is not found', async () => { + void userPort.getBySubject; + vi.spyOn(userPort, 'getBySubject').mockResolvedValue(null); + const query = new ValidateTokenQuery({}, payload); + const result = await handler.execute(query); + expect(result).toBe(false); + }); + + it('should return false when sub is not a string', async () => { + const query = new ValidateTokenQuery({}, { sub: 123 }); + const result = await handler.execute(query); + expect(result).toBe(false); + expect(userPort.getBySubject).not.toHaveBeenCalled(); + }); + }); + + describe('without UserPort', () => { + it('should return true (fail-open) when requireUserValidation is not set', async () => { + const handler = new ValidateTokenHandler(new JwtStrategyPolicy({}), null); + const query = new ValidateTokenQuery({}, payload); + const result = await handler.execute(query); + expect(result).toBe(true); + }); + + it('should return true (fail-open) when requireUserValidation is false', async () => { + const handler = new ValidateTokenHandler( + new JwtStrategyPolicy({ requireUserValidation: false }), + null, + ); + const query = new ValidateTokenQuery({}, payload); + const result = await handler.execute(query); + expect(result).toBe(true); + }); + + it('should throw AuthenticationUserPortRequiredException when requireUserValidation is true', async () => { + const handler = new ValidateTokenHandler( + new JwtStrategyPolicy({ requireUserValidation: true }), + null, + ); + const query = new ValidateTokenQuery({}, payload); + await expect(handler.execute(query)).rejects.toThrow( + AuthenticationUserPortRequiredException, + ); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/application/queries/handlers/__tests__/verify-access-token.handler.spec.ts b/packages/nestjs-authentication/src/application/queries/handlers/__tests__/verify-access-token.handler.spec.ts new file mode 100644 index 000000000..c79b3c737 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/__tests__/verify-access-token.handler.spec.ts @@ -0,0 +1,32 @@ +import { mock } from 'vitest-mock-extended'; + +import { type JwtPort } from '../../../../domain/ports/jwt.port.js'; +import { VerifyAccessTokenQuery } from '../../impl/verify-access-token.query.js'; +import { VerifyAccessTokenHandler } from '../verify-access-token.handler.js'; + +describe(VerifyAccessTokenHandler.name, () => { + const token = 'token'; + const decoded = { sub: 'user-1' }; + let jwtPort: JwtPort; + let handler: VerifyAccessTokenHandler; + + beforeEach(() => { + jwtPort = mock(); + void jwtPort.verifyAccessToken; + vi.spyOn(jwtPort, 'verifyAccessToken').mockResolvedValue(decoded); + handler = new VerifyAccessTokenHandler(jwtPort); + }); + + it('should return decoded token', async () => { + const query = new VerifyAccessTokenQuery({}, token); + const result = await handler.execute(query); + expect(result).toEqual(decoded); + }); + + it('should throw error on verify failure', async () => { + void jwtPort.verifyAccessToken; + vi.spyOn(jwtPort, 'verifyAccessToken').mockRejectedValue(new Error()); + const query = new VerifyAccessTokenQuery({}, token); + await expect(handler.execute(query)).rejects.toThrow(); + }); +}); diff --git a/packages/nestjs-authentication/src/application/queries/handlers/__tests__/verify-refresh-token.handler.spec.ts b/packages/nestjs-authentication/src/application/queries/handlers/__tests__/verify-refresh-token.handler.spec.ts new file mode 100644 index 000000000..bc6198145 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/__tests__/verify-refresh-token.handler.spec.ts @@ -0,0 +1,32 @@ +import { mock } from 'vitest-mock-extended'; + +import { type JwtPort } from '../../../../domain/ports/jwt.port.js'; +import { VerifyRefreshTokenQuery } from '../../impl/verify-refresh-token.query.js'; +import { VerifyRefreshTokenHandler } from '../verify-refresh-token.handler.js'; + +describe(VerifyRefreshTokenHandler.name, () => { + const token = 'token'; + const decoded = { sub: 'user-1' }; + let jwtPort: JwtPort; + let handler: VerifyRefreshTokenHandler; + + beforeEach(() => { + jwtPort = mock(); + void jwtPort.verifyRefreshToken; + vi.spyOn(jwtPort, 'verifyRefreshToken').mockResolvedValue(decoded); + handler = new VerifyRefreshTokenHandler(jwtPort); + }); + + it('should return decoded token', async () => { + const query = new VerifyRefreshTokenQuery({}, token); + const result = await handler.execute(query); + expect(result).toEqual(decoded); + }); + + it('should throw error on verify failure', async () => { + void jwtPort.verifyRefreshToken; + vi.spyOn(jwtPort, 'verifyRefreshToken').mockRejectedValue(new Error()); + const query = new VerifyRefreshTokenQuery({}, token); + await expect(handler.execute(query)).rejects.toThrow(); + }); +}); diff --git a/packages/nestjs-authentication/src/application/queries/handlers/jwt-verify-access-token.handler.ts b/packages/nestjs-authentication/src/application/queries/handlers/jwt-verify-access-token.handler.ts new file mode 100644 index 000000000..4eae1595d --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/jwt-verify-access-token.handler.ts @@ -0,0 +1,16 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { JwtService } from '../../../infrastructure/jwt/jwt.service.js'; +import { JwtVerifyAccessTokenQuery } from '../impl/jwt-verify-access-token.query.js'; + +@QueryHandler(JwtVerifyAccessTokenQuery) +export class JwtVerifyAccessTokenHandler implements IQueryHandler< + JwtVerifyAccessTokenQuery, + object +> { + constructor(private readonly jwtService: JwtService) {} + + async execute(query: JwtVerifyAccessTokenQuery): Promise { + return this.jwtService.verifyAccessToken(query.token); + } +} diff --git a/packages/nestjs-authentication/src/application/queries/handlers/jwt-verify-refresh-token.handler.ts b/packages/nestjs-authentication/src/application/queries/handlers/jwt-verify-refresh-token.handler.ts new file mode 100644 index 000000000..16a77ddae --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/jwt-verify-refresh-token.handler.ts @@ -0,0 +1,16 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { JwtService } from '../../../infrastructure/jwt/jwt.service.js'; +import { JwtVerifyRefreshTokenQuery } from '../impl/jwt-verify-refresh-token.query.js'; + +@QueryHandler(JwtVerifyRefreshTokenQuery) +export class JwtVerifyRefreshTokenHandler implements IQueryHandler< + JwtVerifyRefreshTokenQuery, + object +> { + constructor(private readonly jwtService: JwtService) {} + + async execute(query: JwtVerifyRefreshTokenQuery): Promise { + return this.jwtService.verifyRefreshToken(query.token); + } +} diff --git a/packages/nestjs-authentication/src/application/queries/handlers/validate-and-verify-access-token.handler.ts b/packages/nestjs-authentication/src/application/queries/handlers/validate-and-verify-access-token.handler.ts new file mode 100644 index 000000000..e58bc6426 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/validate-and-verify-access-token.handler.ts @@ -0,0 +1,43 @@ +import { Inject, Optional, PlainLiteralObject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { + AUTHENTICATION_JWT_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, +} from '../../../authentication.constants.js'; +import { JwtPort } from '../../../domain/ports/jwt.port.js'; +import { UserPort } from '../../../domain/ports/user.port.js'; +import { AuthenticationAccessTokenException } from '../../exceptions/authentication-access-token.exception.js'; +import { ValidateAndVerifyAccessTokenQuery } from '../impl/validate-and-verify-access-token.query.js'; + +@QueryHandler(ValidateAndVerifyAccessTokenQuery) +export class ValidateAndVerifyAccessTokenHandler implements IQueryHandler< + ValidateAndVerifyAccessTokenQuery, + PlainLiteralObject +> { + constructor( + @Inject(AUTHENTICATION_JWT_PORT_TOKEN) + private readonly jwtPort: JwtPort, + @Optional() + @Inject(AUTHENTICATION_USER_PORT_TOKEN) + private readonly userPort: UserPort | null, + ) {} + + async execute( + query: ValidateAndVerifyAccessTokenQuery, + ): Promise { + const { ctx, token } = query; + + const payload = await this.jwtPort.verifyAccessToken(ctx, token); + + if (this.userPort) { + const { sub } = payload; + const user = await this.userPort.getBySubject(ctx, sub); + if (!user) { + throw new AuthenticationAccessTokenException(); + } + } + + return payload; + } +} diff --git a/packages/nestjs-authentication/src/application/queries/handlers/validate-and-verify-refresh-token.handler.ts b/packages/nestjs-authentication/src/application/queries/handlers/validate-and-verify-refresh-token.handler.ts new file mode 100644 index 000000000..236564d80 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/validate-and-verify-refresh-token.handler.ts @@ -0,0 +1,43 @@ +import { Inject, Optional, PlainLiteralObject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { + AUTHENTICATION_JWT_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, +} from '../../../authentication.constants.js'; +import { JwtPort } from '../../../domain/ports/jwt.port.js'; +import { UserPort } from '../../../domain/ports/user.port.js'; +import { AuthenticationRefreshTokenException } from '../../exceptions/authentication-refresh-token.exception.js'; +import { ValidateAndVerifyRefreshTokenQuery } from '../impl/validate-and-verify-refresh-token.query.js'; + +@QueryHandler(ValidateAndVerifyRefreshTokenQuery) +export class ValidateAndVerifyRefreshTokenHandler implements IQueryHandler< + ValidateAndVerifyRefreshTokenQuery, + PlainLiteralObject +> { + constructor( + @Inject(AUTHENTICATION_JWT_PORT_TOKEN) + private readonly jwtPort: JwtPort, + @Optional() + @Inject(AUTHENTICATION_USER_PORT_TOKEN) + private readonly userPort: UserPort | null, + ) {} + + async execute( + query: ValidateAndVerifyRefreshTokenQuery, + ): Promise { + const { ctx, token } = query; + + const payload = await this.jwtPort.verifyRefreshToken(ctx, token); + + if (this.userPort) { + const { sub } = payload; + const user = await this.userPort.getBySubject(ctx, sub); + if (!user) { + throw new AuthenticationRefreshTokenException(); + } + } + + return payload; + } +} diff --git a/packages/nestjs-authentication/src/application/queries/handlers/validate-token.handler.ts b/packages/nestjs-authentication/src/application/queries/handlers/validate-token.handler.ts new file mode 100644 index 000000000..7ae372abe --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/validate-token.handler.ts @@ -0,0 +1,38 @@ +import { Inject, Optional } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { AUTHENTICATION_USER_PORT_TOKEN } from '../../../authentication.constants.js'; +import { JwtStrategyPolicy } from '../../../domain/policies/jwt-strategy.policy.js'; +import { UserPort } from '../../../domain/ports/user.port.js'; +import { AuthenticationUserPortRequiredException } from '../../exceptions/authentication-user-port-required.exception.js'; +import { ValidateTokenQuery } from '../impl/validate-token.query.js'; + +@QueryHandler(ValidateTokenQuery) +export class ValidateTokenHandler implements IQueryHandler< + ValidateTokenQuery, + boolean +> { + constructor( + @Inject(JwtStrategyPolicy) + private readonly policy: JwtStrategyPolicy, + @Optional() + @Inject(AUTHENTICATION_USER_PORT_TOKEN) + private readonly userPort: UserPort | null, + ) {} + + async execute(query: ValidateTokenQuery): Promise { + if (!this.userPort) { + if (this.policy.requireUserValidation) { + throw new AuthenticationUserPortRequiredException(); + } + return true; + } + const { ctx, payload } = query; + const { sub } = payload; + const user = + typeof sub === 'string' + ? await this.userPort.getBySubject(ctx, sub) + : null; + return !!user; + } +} diff --git a/packages/nestjs-authentication/src/application/queries/handlers/verify-access-token.handler.ts b/packages/nestjs-authentication/src/application/queries/handlers/verify-access-token.handler.ts new file mode 100644 index 000000000..46680419c --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/verify-access-token.handler.ts @@ -0,0 +1,22 @@ +import { Inject, PlainLiteralObject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { AUTHENTICATION_JWT_PORT_TOKEN } from '../../../authentication.constants.js'; +import { JwtPort } from '../../../domain/ports/jwt.port.js'; +import { VerifyAccessTokenQuery } from '../impl/verify-access-token.query.js'; + +@QueryHandler(VerifyAccessTokenQuery) +export class VerifyAccessTokenHandler implements IQueryHandler< + VerifyAccessTokenQuery, + PlainLiteralObject +> { + constructor( + @Inject(AUTHENTICATION_JWT_PORT_TOKEN) + private readonly jwtPort: JwtPort, + ) {} + + async execute(query: VerifyAccessTokenQuery): Promise { + const { ctx, token } = query; + return this.jwtPort.verifyAccessToken(ctx, token); + } +} diff --git a/packages/nestjs-authentication/src/application/queries/handlers/verify-refresh-token.handler.ts b/packages/nestjs-authentication/src/application/queries/handlers/verify-refresh-token.handler.ts new file mode 100644 index 000000000..782e6124c --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/handlers/verify-refresh-token.handler.ts @@ -0,0 +1,22 @@ +import { Inject, PlainLiteralObject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { AUTHENTICATION_JWT_PORT_TOKEN } from '../../../authentication.constants.js'; +import { JwtPort } from '../../../domain/ports/jwt.port.js'; +import { VerifyRefreshTokenQuery } from '../impl/verify-refresh-token.query.js'; + +@QueryHandler(VerifyRefreshTokenQuery) +export class VerifyRefreshTokenHandler implements IQueryHandler< + VerifyRefreshTokenQuery, + PlainLiteralObject +> { + constructor( + @Inject(AUTHENTICATION_JWT_PORT_TOKEN) + private readonly jwtPort: JwtPort, + ) {} + + async execute(query: VerifyRefreshTokenQuery): Promise { + const { ctx, token } = query; + return this.jwtPort.verifyRefreshToken(ctx, token); + } +} diff --git a/packages/nestjs-authentication/src/application/queries/impl/jwt-verify-access-token.query.ts b/packages/nestjs-authentication/src/application/queries/impl/jwt-verify-access-token.query.ts new file mode 100644 index 000000000..3e4853214 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/impl/jwt-verify-access-token.query.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type JwtVerifyTokenQueryInterface } from '../../../domain/ports/jwt.port.js'; + +export class JwtVerifyAccessTokenQuery + extends Query + implements JwtVerifyTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: string, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/queries/impl/jwt-verify-refresh-token.query.ts b/packages/nestjs-authentication/src/application/queries/impl/jwt-verify-refresh-token.query.ts new file mode 100644 index 000000000..4c63986e2 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/impl/jwt-verify-refresh-token.query.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type JwtVerifyTokenQueryInterface } from '../../../domain/ports/jwt.port.js'; + +export class JwtVerifyRefreshTokenQuery + extends Query + implements JwtVerifyTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: string, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/queries/impl/validate-and-verify-access-token.query.ts b/packages/nestjs-authentication/src/application/queries/impl/validate-and-verify-access-token.query.ts new file mode 100644 index 000000000..e750db376 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/impl/validate-and-verify-access-token.query.ts @@ -0,0 +1,19 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +export interface ValidateAndVerifyAccessTokenQueryInterface { + ctx: PlainLiteralObject; + token: string; +} + +export class ValidateAndVerifyAccessTokenQuery + extends Query + implements ValidateAndVerifyAccessTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: string, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/queries/impl/validate-and-verify-refresh-token.query.ts b/packages/nestjs-authentication/src/application/queries/impl/validate-and-verify-refresh-token.query.ts new file mode 100644 index 000000000..b4273ee54 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/impl/validate-and-verify-refresh-token.query.ts @@ -0,0 +1,19 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +export interface ValidateAndVerifyRefreshTokenQueryInterface { + ctx: PlainLiteralObject; + token: string; +} + +export class ValidateAndVerifyRefreshTokenQuery + extends Query + implements ValidateAndVerifyRefreshTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: string, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/queries/impl/validate-token.query.ts b/packages/nestjs-authentication/src/application/queries/impl/validate-token.query.ts new file mode 100644 index 000000000..945fdb141 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/impl/validate-token.query.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type ValidateTokenQueryInterface } from '../../../domain/ports/token.port.js'; + +export class ValidateTokenQuery + extends Query + implements ValidateTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly payload: PlainLiteralObject, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/queries/impl/verify-access-token.query.ts b/packages/nestjs-authentication/src/application/queries/impl/verify-access-token.query.ts new file mode 100644 index 000000000..a2ab5c476 --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/impl/verify-access-token.query.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type VerifyTokenQueryInterface } from '../../../domain/ports/token.port.js'; + +export class VerifyAccessTokenQuery + extends Query + implements VerifyTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: string, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/queries/impl/verify-refresh-token.query.ts b/packages/nestjs-authentication/src/application/queries/impl/verify-refresh-token.query.ts new file mode 100644 index 000000000..87005fc0b --- /dev/null +++ b/packages/nestjs-authentication/src/application/queries/impl/verify-refresh-token.query.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type VerifyTokenQueryInterface } from '../../../domain/ports/token.port.js'; + +export class VerifyRefreshTokenQuery + extends Query + implements VerifyTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: string, + ) { + super(); + } +} diff --git a/packages/nestjs-authentication/src/application/services/local/__tests__/local.service.spec.ts b/packages/nestjs-authentication/src/application/services/local/__tests__/local.service.spec.ts new file mode 100644 index 000000000..89827b469 --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/local/__tests__/local.service.spec.ts @@ -0,0 +1,95 @@ +import { mock } from 'vitest-mock-extended'; + +import { type PasswordPort } from '../../../../domain/ports/password.port.js'; +import { type UserPort } from '../../../../domain/ports/user.port.js'; +import { type LocalValidateUserInterface } from '../interfaces/local-validate-user.interface.js'; +import { LocalService } from '../local.service.js'; + +describe(LocalService.name, () => { + const USERNAME = 'test'; + const PASSWORD = 'test'; + + let service: LocalService; + let userPort: UserPort; + let passwordPort: PasswordPort; + + beforeEach(() => { + userPort = mock(); + passwordPort = mock(); + + service = new LocalService(userPort, passwordPort); + }); + + describe('validateUser', () => { + const USER = { + active: false, + id: 'uuid', + username: 'username', + email: 'user@test.com', + password: 'password', + passwordHash: 'hash', + passwordSalt: 'salt', + }; + it('should throw an error if no user is found for the given username', async () => { + void userPort.getByUsername; + vi.spyOn(userPort, 'getByUsername').mockResolvedValue(null); + + const t = () => + service.validateUser({}, { + username: USERNAME, + password: PASSWORD, + } as LocalValidateUserInterface); + await expect(t).rejects.toThrow( + `No user found for username: ${USERNAME}`, + ); + }); + + it('should throw an error if the user is inactive', async () => { + void userPort.getByUsername; + vi.spyOn(userPort, 'getByUsername').mockResolvedValue(USER); + + const t = () => + service.validateUser({}, { + username: USERNAME, + password: PASSWORD, + } as LocalValidateUserInterface); + await expect(t).rejects.toThrow( + `User with username '${USERNAME}' is inactive`, + ); + }); + + it('should throw an error if the password is invalid', async () => { + void userPort.getByUsername; + vi.spyOn(userPort, 'getByUsername').mockResolvedValue({ + ...USER, + active: true, + }); + void passwordPort.validate; + vi.spyOn(passwordPort, 'validate').mockResolvedValue(false); + + const t = () => + service.validateUser({}, { + username: USER.username, + password: USER.password, + } as LocalValidateUserInterface); + await expect(t).rejects.toThrow( + `Invalid password for username: ${USER.username}`, + ); + }); + + it('should return the user if the user is found, active, and the password is valid', async () => { + const activeUser = { ...USER, active: true }; + void userPort.getByUsername; + vi.spyOn(userPort, 'getByUsername').mockResolvedValue(activeUser); + void passwordPort.validate; + vi.spyOn(passwordPort, 'validate').mockResolvedValue(true); + + const result = await service.validateUser({}, { + username: USER.username, + password: USER.password, + } as LocalValidateUserInterface); + + expect(result).toEqual(activeUser); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/application/services/local/interfaces/local-service.interface.ts b/packages/nestjs-authentication/src/application/services/local/interfaces/local-service.interface.ts new file mode 100644 index 000000000..1d7294b51 --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/local/interfaces/local-service.interface.ts @@ -0,0 +1,12 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceIdInterface } from '@concepta/nestjs-core'; + +import { type LocalValidateUserInterface } from './local-validate-user.interface.js'; + +export interface LocalServiceInterface { + validateUser( + ctx: PlainLiteralObject, + dto: LocalValidateUserInterface, + ): Promise; +} diff --git a/packages/nestjs-authentication/src/application/services/local/interfaces/local-validate-user.interface.ts b/packages/nestjs-authentication/src/application/services/local/interfaces/local-validate-user.interface.ts new file mode 100644 index 000000000..b70517db0 --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/local/interfaces/local-validate-user.interface.ts @@ -0,0 +1,4 @@ +export interface LocalValidateUserInterface { + username: string; + password: string; +} diff --git a/packages/nestjs-authentication/src/application/services/local/local.service.ts b/packages/nestjs-authentication/src/application/services/local/local.service.ts new file mode 100644 index 000000000..e92202fea --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/local/local.service.ts @@ -0,0 +1,49 @@ +import { Inject, Injectable, PlainLiteralObject } from '@nestjs/common'; + +import { ReferenceIdInterface } from '@concepta/nestjs-core'; + +import { + AUTHENTICATION_PASSWORD_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, +} from '../../../authentication.constants.js'; +import { PasswordPort } from '../../../domain/ports/password.port.js'; +import { UserPort } from '../../../domain/ports/user.port.js'; +import { LocalInvalidPasswordException } from '../../exceptions/local-invalid-password.exception.js'; +import { LocalUserInactiveException } from '../../exceptions/local-user-inactive.exception.js'; +import { LocalUsernameNotFoundException } from '../../exceptions/local-username-not-found.exception.js'; + +import { LocalServiceInterface } from './interfaces/local-service.interface.js'; +import { LocalValidateUserInterface } from './interfaces/local-validate-user.interface.js'; + +@Injectable() +export class LocalService implements LocalServiceInterface { + constructor( + @Inject(AUTHENTICATION_USER_PORT_TOKEN) + protected readonly userPort: UserPort, + @Inject(AUTHENTICATION_PASSWORD_PORT_TOKEN) + protected readonly passwordPort: PasswordPort, + ) {} + + async validateUser( + ctx: PlainLiteralObject, + dto: LocalValidateUserInterface, + ): Promise { + const user = await this.userPort.getByUsername(ctx, dto.username); + + if (!user) { + throw new LocalUsernameNotFoundException(dto.username); + } + + if (user.active !== true) { + throw new LocalUserInactiveException(dto.username); + } + + const isValid = await this.passwordPort.validate(ctx, dto.password, user); + + if (!isValid) { + throw new LocalInvalidPasswordException(user.username); + } + + return user; + } +} diff --git a/packages/nestjs-authentication/src/application/services/recovery/__tests__/recovery.service.spec.ts b/packages/nestjs-authentication/src/application/services/recovery/__tests__/recovery.service.spec.ts new file mode 100644 index 000000000..8fa97be48 --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/recovery/__tests__/recovery.service.spec.ts @@ -0,0 +1,227 @@ +import { mock } from 'vitest-mock-extended'; + +import { RecoveryPolicy } from '../../../../domain/policies/recovery.policy.js'; +import { type OtpPort } from '../../../../domain/ports/otp.port.js'; +import { type PasswordPort } from '../../../../domain/ports/password.port.js'; +import { type RecoveryNotificationPort } from '../../../../domain/ports/recovery-notification.port.js'; +import { type UserPort } from '../../../../domain/ports/user.port.js'; +import { RecoveryService } from '../recovery.service.js'; + +describe(RecoveryService, () => { + const UserFixture = { + id: 'abc', + email: 'me@dispostable.com', + username: 'me@dispostable.com', + active: true, + }; + + let recoveryService: RecoveryService; + let otpPort: OtpPort; + let userPort: UserPort; + let passwordPort: PasswordPort; + let recoveryNotificationPort: RecoveryNotificationPort; + let policy: RecoveryPolicy; + + beforeEach(async () => { + policy = new RecoveryPolicy({ + otp: { + category: 'auth-recovery', + namespace: 'userOtp', + type: 'uuid', + expiresIn: '1h', + duplicateStrategy: 'DEACTIVATE', + rateSeconds: 60, + rateThreshold: 5, + }, + }); + + otpPort = mock(); + userPort = mock(); + passwordPort = mock(); + recoveryNotificationPort = mock(); + + recoveryService = new RecoveryService( + policy, + otpPort, + userPort, + passwordPort, + recoveryNotificationPort, + ); + }); + + describe(RecoveryService.prototype.recoverLogin, () => { + it('should send login recovery', async () => { + void userPort.getByEmail; + vi.spyOn(userPort, 'getByEmail').mockResolvedValue(UserFixture); + + const result = await recoveryService.recoverLogin({}, UserFixture.email); + + expect(result).toBeUndefined(); + expect(userPort.getByEmail).toHaveBeenCalledTimes(1); + expect(userPort.getByEmail).toHaveBeenCalledWith({}, UserFixture.email); + + expect(recoveryNotificationPort.sendRecoverLogin).toHaveBeenCalledTimes( + 1, + ); + expect(recoveryNotificationPort.sendRecoverLogin).toHaveBeenCalledWith( + {}, + UserFixture.email, + UserFixture.username, + ); + }); + }); + + describe(RecoveryService.prototype.recoverPassword, () => { + it('should send password recovery', async () => { + void userPort.getByEmail; + vi.spyOn(userPort, 'getByEmail').mockResolvedValue(UserFixture); + void otpPort.create; + vi.spyOn(otpPort, 'create').mockResolvedValue({ + category: 'auth-recovery', + type: 'uuid', + passcode: 'GOOD_PASSCODE', + expirationDate: new Date(), + active: true, + assigneeId: UserFixture.id, + }); + + const result = await recoveryService.recoverPassword( + {}, + UserFixture.email, + ); + + expect(result).toBeUndefined(); + expect(userPort.getByEmail).toHaveBeenCalledTimes(1); + expect(userPort.getByEmail).toHaveBeenCalledWith({}, UserFixture.email); + + expect( + recoveryNotificationPort.sendRecoverPassword, + ).toHaveBeenCalledTimes(1); + expect(recoveryNotificationPort.sendRecoverPassword).toHaveBeenCalledWith( + {}, + UserFixture.email, + { + passcode: 'GOOD_PASSCODE', + tokenExp: expect.any(Date), + }, + ); + }); + }); + + describe(RecoveryService.prototype.validatePasscode, () => { + it('should call otp validator', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue({ + assigneeId: UserFixture.id, + }); + + await recoveryService.validatePasscode({}, 'GOOD_PASSCODE'); + + expect(otpPort.validate).toHaveBeenCalledWith({}, policy.otpNamespace, { + category: policy.otpCategory, + passcode: 'GOOD_PASSCODE', + }); + }); + + it('should validate good passcode', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue({ + assigneeId: UserFixture.id, + }); + + const otp = await recoveryService.validatePasscode({}, 'GOOD_PASSCODE'); + expect(otp).toEqual({ assigneeId: UserFixture.id }); + }); + + it('should not validate bad passcode', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue(null); + + const otp = await recoveryService.validatePasscode({}, 'BAD_PASSCODE'); + expect(otp).toBeNull(); + }); + }); + + describe(RecoveryService.prototype.updatePassword, () => { + it('should call password port setPassword', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue({ + assigneeId: UserFixture.id, + }); + void userPort.getById; + vi.spyOn(userPort, 'getById').mockResolvedValue(UserFixture); + void userPort.getByEmail; + vi.spyOn(userPort, 'getByEmail').mockResolvedValue(UserFixture); + + await recoveryService.updatePassword( + {}, + 'GOOD_PASSCODE', + '$!Abc123bsksl6764579', + ); + + expect(passwordPort.setPassword).toHaveBeenCalledTimes(1); + expect(passwordPort.setPassword).toHaveBeenCalledWith( + {}, + '$!Abc123bsksl6764579', + UserFixture.id, + ); + }); + + it('should send success email', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue({ + assigneeId: UserFixture.id, + }); + void userPort.getById; + vi.spyOn(userPort, 'getById').mockResolvedValue(UserFixture); + void userPort.getByEmail; + vi.spyOn(userPort, 'getByEmail').mockResolvedValue(UserFixture); + + await recoveryService.updatePassword( + {}, + 'GOOD_PASSCODE', + 'any_string_will_do', + ); + + expect( + recoveryNotificationPort.sendPasswordUpdated, + ).toHaveBeenCalledTimes(1); + expect(recoveryNotificationPort.sendPasswordUpdated).toHaveBeenCalledWith( + {}, + UserFixture.email, + ); + }); + + it('should update password', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue({ + assigneeId: UserFixture.id, + }); + void userPort.getById; + vi.spyOn(userPort, 'getById').mockResolvedValue(UserFixture); + void userPort.getByEmail; + vi.spyOn(userPort, 'getByEmail').mockResolvedValue(UserFixture); + + const user = await recoveryService.updatePassword( + {}, + 'GOOD_PASSCODE', + '$!Abc123bsksl6764579', + ); + + expect(user).toEqual(UserFixture); + }); + + it('should fail to update password', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue(null); + + const user = await recoveryService.updatePassword( + {}, + 'FAKE_PASSCODE', + '$!Abc123bsksl6764579', + ); + + expect(user).toBeNull(); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-recover-login-params.interface.ts b/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-recover-login-params.interface.ts new file mode 100644 index 000000000..7043f6279 --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-recover-login-params.interface.ts @@ -0,0 +1,3 @@ +export interface RecoveryRecoverLoginParamsInterface { + email: string; +} diff --git a/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-recover-password-params.interface.ts b/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-recover-password-params.interface.ts new file mode 100644 index 000000000..fb66172cf --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-recover-password-params.interface.ts @@ -0,0 +1,3 @@ +export interface RecoveryRecoverPasswordParamsInterface { + email: string; +} diff --git a/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-update-password-params.interface.ts b/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-update-password-params.interface.ts new file mode 100644 index 000000000..1ee04c188 --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-update-password-params.interface.ts @@ -0,0 +1,4 @@ +export interface RecoveryUpdatePasswordParamsInterface { + passcode: string; + newPassword: string; +} diff --git a/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-validate-passcode-params.interface.ts b/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-validate-passcode-params.interface.ts new file mode 100644 index 000000000..e24f1d870 --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/recovery/interfaces/recovery-validate-passcode-params.interface.ts @@ -0,0 +1,3 @@ +export interface RecoveryValidatePasscodeParamsInterface { + passcode: string; +} diff --git a/packages/nestjs-authentication/src/application/services/recovery/recovery.service.ts b/packages/nestjs-authentication/src/application/services/recovery/recovery.service.ts new file mode 100644 index 000000000..5568103d4 --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/recovery/recovery.service.ts @@ -0,0 +1,176 @@ +import { Inject, Injectable, PlainLiteralObject } from '@nestjs/common'; + +import { + AssigneeRelationInterface, + ReferenceIdInterface, +} from '@concepta/nestjs-core'; + +import { + AUTHENTICATION_OTP_PORT_TOKEN, + AUTHENTICATION_PASSWORD_PORT_TOKEN, + AUTHENTICATION_RECOVERY_NOTIFICATION_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, +} from '../../../authentication.constants.js'; +import { RecoveryPolicy } from '../../../domain/policies/recovery.policy.js'; +import { OtpPort } from '../../../domain/ports/otp.port.js'; +import { PasswordPort } from '../../../domain/ports/password.port.js'; +import { RecoveryNotificationPort } from '../../../domain/ports/recovery-notification.port.js'; +import { UserPort } from '../../../domain/ports/user.port.js'; + +@Injectable() +export class RecoveryService { + constructor( + @Inject(RecoveryPolicy) + private readonly policy: RecoveryPolicy, + @Inject(AUTHENTICATION_OTP_PORT_TOKEN) + private readonly otpPort: OtpPort, + @Inject(AUTHENTICATION_USER_PORT_TOKEN) + private readonly userPort: UserPort, + @Inject(AUTHENTICATION_PASSWORD_PORT_TOKEN) + private readonly passwordPort: PasswordPort, + @Inject(AUTHENTICATION_RECOVERY_NOTIFICATION_PORT_TOKEN) + private readonly recoveryNotificationPort: RecoveryNotificationPort, + ) {} + + /** + * Recover lost username providing an email and send the username by email. + * + * @param ctx - context object + * @param email - user email + */ + async recoverLogin(ctx: PlainLiteralObject, email: string): Promise { + // recover the user by providing an email + const user = await this.userPort.getByEmail(ctx, email); + + // did we find the user? + if (user) { + // yes, send an email with the recovered login + this.recoveryNotificationPort.sendRecoverLogin(ctx, email, user.username); + } + + // !!! Falling through to void is intentional !!!! + // !!! Do NOT give any indication if e-mail does not exist !!!! + } + + /** + * Recover lost password providing an email and send the passcode token by email. + * + * @param ctx - context object + * @param email - user email + */ + async recoverPassword(ctx: PlainLiteralObject, email: string): Promise { + // recover the user by providing an email + const user = await this.userPort.getByEmail(ctx, email); + + // did we find a user? + if (user) { + const { + otpCategory: category, + otpNamespace: namespace, + otpType: type, + otpExpiresIn: expiresIn, + otpDuplicateStrategy: duplicateStrategy, + otpRateSeconds: rateSeconds, + otpRateThreshold: rateThreshold, + } = this.policy; + + // create an OTP save it in the database + const otp = await this.otpPort.create( + ctx, + namespace, + { + category, + type, + expiresIn, + assigneeId: user.id, + rateSeconds, + rateThreshold, + }, + { duplicateStrategy, rateSeconds, rateThreshold }, + ); + + // send an email with a recover OTP + this.recoveryNotificationPort.sendRecoverPassword(ctx, email, { + passcode: otp.passcode, + tokenExp: otp.expirationDate, + }); + } + + // !!! Falling through to void is intentional !!!! + // !!! Do NOT give any indication if e-mail does not exist !!!! + } + + /** + * Validate passcode and return it's user. + * + * @param ctx - context + * @param passcode - user's passcode + */ + async validatePasscode( + ctx: PlainLiteralObject, + passcode: string, + ): Promise { + const { otpCategory: category, otpNamespace: namespace } = this.policy; + + return this.otpPort.validate(ctx, namespace, { category, passcode }); + } + + /** + * Change user's password by providing it's OTP passcode and the new password. + * + * @param ctx - context + * @param passcode - OTP user's passcode + * @param newPassword - new user password + */ + async updatePassword( + ctx: PlainLiteralObject, + passcode: string, + newPassword: string, + ): Promise { + // get otp by passcode + const otp = await this.validatePasscode(ctx, passcode); + + // did we get an otp? + if (otp) { + // get user by otp assigneeId + const user = await this.userPort.getById(ctx, otp.assigneeId); + + if (user) { + await this.passwordPort.setPassword(ctx, newPassword, otp.assigneeId); + + this.recoveryNotificationPort.sendPasswordUpdated(ctx, user.email); + + await this.revokeAllUserPasswordRecoveries(ctx, user.email); + } + + return user; + } + + // otp was not found + return null; + } + + /** + * Revoke all password recovery OTPs for a user. + * + * @param ctx - context + * @param email - user email + */ + async revokeAllUserPasswordRecoveries( + ctx: PlainLiteralObject, + email: string, + ): Promise { + const user = await this.userPort.getByEmail(ctx, email); + + if (user) { + const { otpCategory: category, otpNamespace: namespace } = this.policy; + await this.otpPort.clear(ctx, namespace, { + category, + assigneeId: user.id, + }); + } + + // !!! Falling through to void is intentional !!!! + // !!! Do NOT give any indication if e-mail does not exist !!!! + } +} diff --git a/packages/nestjs-authentication/src/application/services/verify/__tests__/verify.service.spec.ts b/packages/nestjs-authentication/src/application/services/verify/__tests__/verify.service.spec.ts new file mode 100644 index 000000000..413b7d967 --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/verify/__tests__/verify.service.spec.ts @@ -0,0 +1,166 @@ +import { mock } from 'vitest-mock-extended'; + +import { VerifyPolicy } from '../../../../domain/policies/verify.policy.js'; +import { type OtpPort } from '../../../../domain/ports/otp.port.js'; +import { type UserPort } from '../../../../domain/ports/user.port.js'; +import { type VerifyNotificationPort } from '../../../../domain/ports/verify-notification.port.js'; +import { VerifyOtpInvalidException } from '../../../exceptions/verify-otp-invalid.exception.js'; +import { VerifyService } from '../verify.service.js'; + +describe(VerifyService, () => { + const UserFixture = { + id: 'abc', + email: 'me@dispostable.com', + username: 'me@dispostable.com', + active: true, + }; + + let verifyService: VerifyService; + let otpPort: OtpPort; + let userPort: UserPort; + let verifyNotificationPort: VerifyNotificationPort; + let policy: VerifyPolicy; + + beforeEach(async () => { + policy = new VerifyPolicy({ + otp: { + category: 'auth-verify', + namespace: 'userOtp', + type: 'uuid', + expiresIn: '1h', + duplicateStrategy: 'DEACTIVATE', + rateSeconds: 60, + rateThreshold: 5, + }, + }); + + otpPort = mock(); + userPort = mock(); + verifyNotificationPort = mock(); + + verifyService = new VerifyService( + policy, + otpPort, + userPort, + verifyNotificationPort, + ); + }); + + describe(VerifyService.prototype.send, () => { + it('should send passcode verify', async () => { + void userPort.getByEmail; + vi.spyOn(userPort, 'getByEmail').mockResolvedValue(UserFixture); + void otpPort.create; + vi.spyOn(otpPort, 'create').mockResolvedValue({ + category: 'auth-verify', + type: 'uuid', + passcode: 'GOOD_PASSCODE', + expirationDate: new Date(), + active: true, + assigneeId: UserFixture.id, + }); + + const result = await verifyService.send({}, { email: UserFixture.email }); + + expect(result).toBeUndefined(); + expect(userPort.getByEmail).toHaveBeenCalledTimes(1); + expect(userPort.getByEmail).toHaveBeenCalledWith({}, UserFixture.email); + + expect(verifyNotificationPort.sendVerify).toHaveBeenCalledTimes(1); + expect(verifyNotificationPort.sendVerify).toHaveBeenCalledWith( + {}, + UserFixture.email, + { passcode: 'GOOD_PASSCODE', tokenExp: expect.any(Date) }, + ); + }); + }); + + describe(VerifyService.prototype.validatePasscode, () => { + it('should call otp validator', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue({ + assigneeId: UserFixture.id, + }); + + await verifyService.validatePasscode({}, { passcode: 'GOOD_PASSCODE' }); + + expect(otpPort.validate).toHaveBeenCalledWith({}, policy.otpNamespace, { + category: policy.otpCategory, + passcode: 'GOOD_PASSCODE', + }); + }); + + it('should validate good passcode', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue({ + assigneeId: UserFixture.id, + }); + + const otp = await verifyService.validatePasscode( + {}, + { passcode: 'GOOD_PASSCODE' }, + ); + expect(otp).toEqual({ assigneeId: UserFixture.id }); + }); + + it('should not validate bad passcode', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue(null); + + const otp = await verifyService.validatePasscode( + {}, + { passcode: 'BAD_PASSCODE' }, + ); + expect(otp).toBeNull(); + }); + }); + + describe(VerifyService.prototype.confirmUser, () => { + it('should call user port update', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue({ + assigneeId: UserFixture.id, + }); + void userPort.update; + vi.spyOn(userPort, 'update').mockResolvedValue(UserFixture); + void userPort.getByEmail; + vi.spyOn(userPort, 'getByEmail').mockResolvedValue(UserFixture); + + await verifyService.confirmUser({}, { passcode: 'GOOD_PASSCODE' }); + + expect(userPort.update).toHaveBeenCalledTimes(1); + expect(userPort.update).toHaveBeenCalledWith({}, UserFixture.id, { + active: true, + }); + }); + + it('should confirm user', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue({ + assigneeId: UserFixture.id, + }); + void userPort.update; + vi.spyOn(userPort, 'update').mockResolvedValue(UserFixture); + void userPort.getByEmail; + vi.spyOn(userPort, 'getByEmail').mockResolvedValue(UserFixture); + + const user = await verifyService.confirmUser( + {}, + { passcode: 'GOOD_PASSCODE' }, + ); + + expect(user).toEqual(UserFixture); + }); + + it('should fail to confirm user', async () => { + void otpPort.validate; + vi.spyOn(otpPort, 'validate').mockResolvedValue(null); + + const t = async () => { + await verifyService.confirmUser({}, { passcode: 'FAKE_PASSCODE' }); + }; + + await expect(t).rejects.toThrow(VerifyOtpInvalidException); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/application/services/verify/interfaces/verify-confirm-params.interface.ts b/packages/nestjs-authentication/src/application/services/verify/interfaces/verify-confirm-params.interface.ts new file mode 100644 index 000000000..91e0bab98 --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/verify/interfaces/verify-confirm-params.interface.ts @@ -0,0 +1,3 @@ +export interface VerifyConfirmParamsInterface { + passcode: string; +} diff --git a/packages/nestjs-authentication/src/application/services/verify/interfaces/verify-send-params.interface.ts b/packages/nestjs-authentication/src/application/services/verify/interfaces/verify-send-params.interface.ts new file mode 100644 index 000000000..43fc01aae --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/verify/interfaces/verify-send-params.interface.ts @@ -0,0 +1,3 @@ +export interface VerifySendParamsInterface { + email: string; +} diff --git a/packages/nestjs-authentication/src/application/services/verify/verify.service.ts b/packages/nestjs-authentication/src/application/services/verify/verify.service.ts new file mode 100644 index 000000000..50296a02b --- /dev/null +++ b/packages/nestjs-authentication/src/application/services/verify/verify.service.ts @@ -0,0 +1,152 @@ +import { Inject, Injectable, PlainLiteralObject } from '@nestjs/common'; + +import { + AssigneeRelationInterface, + ReferenceIdInterface, +} from '@concepta/nestjs-core'; + +import { + AUTHENTICATION_OTP_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, + AUTHENTICATION_VERIFY_NOTIFICATION_PORT_TOKEN, +} from '../../../authentication.constants.js'; +import { VerifyPolicy } from '../../../domain/policies/verify.policy.js'; +import { OtpPort } from '../../../domain/ports/otp.port.js'; +import { UserPort } from '../../../domain/ports/user.port.js'; +import { VerifyNotificationPort } from '../../../domain/ports/verify-notification.port.js'; +import { VerifyOtpInvalidException } from '../../exceptions/verify-otp-invalid.exception.js'; + +import { VerifyConfirmParamsInterface } from './interfaces/verify-confirm-params.interface.js'; +import { VerifySendParamsInterface } from './interfaces/verify-send-params.interface.js'; + +@Injectable() +export class VerifyService { + constructor( + @Inject(VerifyPolicy) + private readonly policy: VerifyPolicy, + @Inject(AUTHENTICATION_OTP_PORT_TOKEN) + private readonly otpPort: OtpPort, + @Inject(AUTHENTICATION_USER_PORT_TOKEN) + private readonly userPort: UserPort, + @Inject(AUTHENTICATION_VERIFY_NOTIFICATION_PORT_TOKEN) + private readonly verifyNotificationPort: VerifyNotificationPort, + ) {} + + /** + * Send an email to verify a user's email address. + * + * @param ctx - context + * @param params - Parameters for sending verification email + */ + async send( + ctx: PlainLiteralObject, + params: VerifySendParamsInterface, + ): Promise { + const { email } = params; + + const user = await this.userPort.getByEmail(ctx, email); + + if (user) { + const { + otpCategory: category, + otpNamespace: namespace, + otpType: type, + otpExpiresIn: expiresIn, + otpDuplicateStrategy: duplicateStrategy, + otpRateSeconds: rateSeconds, + otpRateThreshold: rateThreshold, + } = this.policy; + + const otp = await this.otpPort.create( + ctx, + namespace, + { + category, + type, + expiresIn, + assigneeId: user.id, + rateSeconds, + rateThreshold, + }, + { duplicateStrategy, rateSeconds, rateThreshold }, + ); + + this.verifyNotificationPort.sendVerify(ctx, email, { + passcode: otp.passcode, + tokenExp: otp.expirationDate, + }); + } + + // !!! Falling through to void is intentional !!!! + // !!! Do NOT give any indication if e-mail does not exist !!!! + } + + /** + * Validate a passcode OTP. + * + * @param ctx - context + * @param params - Parameters for validating passcode + */ + async validatePasscode( + ctx: PlainLiteralObject, + params: VerifyConfirmParamsInterface, + ): Promise { + const { passcode } = params; + const { otpCategory: category, otpNamespace: namespace } = this.policy; + + return this.otpPort.validate(ctx, namespace, { category, passcode }); + } + + /** + * Confirms a user's account by validating their OTP passcode. + * + * @param ctx - context + * @param params - Parameters for confirming user + */ + async confirmUser( + ctx: PlainLiteralObject, + params: VerifyConfirmParamsInterface, + ): Promise { + const { passcode } = params; + + const otp = await this.validatePasscode(ctx, { passcode }); + + if (otp) { + const user = await this.userPort.update(ctx, otp.assigneeId, { + active: true, + }); + + if (user) { + await this.revokeAllUserVerifyToken(ctx, { + email: user.email, + }); + + return user; + } + } + + throw new VerifyOtpInvalidException(); + } + + /** + * Revokes all verification tokens for a given user. + * + * @param ctx - context + * @param params - Parameters for revoking tokens + */ + async revokeAllUserVerifyToken( + ctx: PlainLiteralObject, + params: VerifySendParamsInterface, + ): Promise { + const { email } = params; + const user = await this.userPort.getByEmail(ctx, email); + + if (user) { + const { otpCategory: category, otpNamespace: namespace } = this.policy; + await this.otpPort.clear(ctx, namespace, { + category, + assigneeId: user.id, + }); + } + } +} diff --git a/packages/nestjs-authentication/src/authentication.constants.ts b/packages/nestjs-authentication/src/authentication.constants.ts index edda58af3..beb0ba6bb 100644 --- a/packages/nestjs-authentication/src/authentication.constants.ts +++ b/packages/nestjs-authentication/src/authentication.constants.ts @@ -1,12 +1,31 @@ -export const AUTHENTICATION_MODULE_SETTINGS_TOKEN = - 'AUTHENTICATION_MODULE_SETTINGS_TOKEN'; +export const AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN = + 'AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN'; -export const AUTHENTICATION_MODULE_DEFAULT_SETTINGS_TOKEN = - 'AUTHENTICATION_MODULE_DEFAULT_SETTINGS_TOKEN'; +// Port tokens +export const AUTHENTICATION_JWT_PORT_TOKEN = Symbol( + '__AUTHENTICATION_JWT_PORT_TOKEN__', +); -export const ValidateTokenService = Symbol( - '__AUTHENTICATION_MODULE_VALIDATE_TOKEN_SERVICE_TOKEN__', +export const AUTHENTICATION_TOKEN_PORT_TOKEN = Symbol( + '__AUTHENTICATION_TOKEN_PORT_TOKEN__', ); -export const AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN = - 'AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN'; +export const AUTHENTICATION_USER_PORT_TOKEN = Symbol( + '__AUTHENTICATION_USER_PORT_TOKEN__', +); + +export const AUTHENTICATION_PASSWORD_PORT_TOKEN = Symbol( + '__AUTHENTICATION_PASSWORD_PORT_TOKEN__', +); + +export const AUTHENTICATION_OTP_PORT_TOKEN = Symbol( + '__AUTHENTICATION_OTP_PORT_TOKEN__', +); + +export const AUTHENTICATION_RECOVERY_NOTIFICATION_PORT_TOKEN = Symbol( + '__AUTHENTICATION_RECOVERY_NOTIFICATION_PORT_TOKEN__', +); + +export const AUTHENTICATION_VERIFY_NOTIFICATION_PORT_TOKEN = Symbol( + '__AUTHENTICATION_VERIFY_NOTIFICATION_PORT_TOKEN__', +); diff --git a/packages/nestjs-authentication/src/authentication.module-definition.ts b/packages/nestjs-authentication/src/authentication.module-definition.ts index 9bfb6f827..d674a079d 100644 --- a/packages/nestjs-authentication/src/authentication.module-definition.ts +++ b/packages/nestjs-authentication/src/authentication.module-definition.ts @@ -1,28 +1,61 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { CqrsModule } from '@nestjs/cqrs'; -import { createSettingsProvider } from '@concepta/nestjs-common'; -import { - JwtIssueTokenService, - JwtVerifyTokenService, - JwtVerifyTokenServiceInterface, -} from '@concepta/nestjs-jwt'; - -import { - AUTHENTICATION_MODULE_SETTINGS_TOKEN, - ValidateTokenService, -} from './authentication.constants'; -import { authenticationDefaultConfig } from './config/authentication-default.config'; -import { AuthenticationOptionsExtrasInterface } from './interfaces/authentication-options-extras.interface'; -import { AuthenticationOptionsInterface } from './interfaces/authentication-options.interface'; -import { AuthenticationSettingsInterface } from './interfaces/authentication-settings.interface'; -import { ValidateTokenServiceInterface } from './interfaces/validate-token-service.interface'; -import { IssueTokenService } from './services/issue-token.service'; -import { VerifyTokenService } from './services/verify-token.service'; +import { IssueAccessTokenHandler } from './application/commands/handlers/issue-access-token.handler.js'; +import { IssueAuthenticatedResponseHandler } from './application/commands/handlers/issue-authenticated-response.handler.js'; +import { IssueRefreshTokenHandler } from './application/commands/handlers/issue-refresh-token.handler.js'; +import { SignAccessTokenHandler } from './application/commands/handlers/sign-access-token.handler.js'; +import { SignRefreshTokenHandler } from './application/commands/handlers/sign-refresh-token.handler.js'; +import { JwtVerifyAccessTokenHandler } from './application/queries/handlers/jwt-verify-access-token.handler.js'; +import { JwtVerifyRefreshTokenHandler } from './application/queries/handlers/jwt-verify-refresh-token.handler.js'; +import { ValidateAndVerifyAccessTokenHandler } from './application/queries/handlers/validate-and-verify-access-token.handler.js'; +import { ValidateAndVerifyRefreshTokenHandler } from './application/queries/handlers/validate-and-verify-refresh-token.handler.js'; +import { ValidateTokenHandler } from './application/queries/handlers/validate-token.handler.js'; +import { VerifyAccessTokenHandler } from './application/queries/handlers/verify-access-token.handler.js'; +import { VerifyRefreshTokenHandler } from './application/queries/handlers/verify-refresh-token.handler.js'; +import { LocalService } from './application/services/local/local.service.js'; +import { RecoveryService } from './application/services/recovery/recovery.service.js'; +import { VerifyService } from './application/services/verify/verify.service.js'; +import { AUTHENTICATION_JWT_PORT_TOKEN } from './authentication.constants.js'; +import { GuardsPolicy } from './domain/policies/guards.policy.js'; +import { JwtPolicy } from './domain/policies/jwt.policy.js'; +import { AuthUserContextOverlay } from './gateways/auth-user-context.overlay.js'; +import { authenticationDefaultConfig } from './infrastructure/config/authentication-default.config.js'; +import { type AuthenticationOptionsExtrasInterface } from './infrastructure/config/interfaces/authentication-options-extras.interface.js'; +import { type AuthenticationOptionsInterface } from './infrastructure/config/interfaces/authentication-options.interface.js'; +import { NestJwtModule } from './infrastructure/jwt/jwt.externals.js'; +import { JwtService } from './infrastructure/jwt/jwt.service.js'; +import { AuthRouterGuards } from './infrastructure/router/auth-router.constants.js'; +import { JwtGuard } from './infrastructure/strategies/jwt/jwt.guard.js'; +import { JwtStrategy } from './infrastructure/strategies/jwt/jwt.strategy.js'; +import { createAuthRouterGuardsProviders } from './infrastructure/utils/create-auth-router-guards-providers.js'; +import { createGuardsPolicyProvider } from './infrastructure/utils/create-guards-policy-provider.js'; +import { createJwtAppGuardProvider } from './infrastructure/utils/create-jwt-app-guard-provider.js'; +import { createJwtPolicyProvider } from './infrastructure/utils/create-jwt-policy-provider.js'; +import { createJwtPortProvider } from './infrastructure/utils/create-jwt-port-provider.js'; +import { createJwtStrategyPolicyProvider } from './infrastructure/utils/create-jwt-strategy-policy-provider.js'; +import { createJwtStrategyProvider } from './infrastructure/utils/create-jwt-strategy-provider.js'; +import { createLocalStrategyPolicyProvider } from './infrastructure/utils/create-local-strategy-policy-provider.js'; +import { createLocalStrategyProvider } from './infrastructure/utils/create-local-strategy-provider.js'; +import { createLocalValidateUserServiceProvider } from './infrastructure/utils/create-local-validate-user-service-provider.js'; +import { createOtpPortProvider } from './infrastructure/utils/create-otp-port-provider.js'; +import { createPasswordPortProvider } from './infrastructure/utils/create-password-port-provider.js'; +import { createRecoveryNotificationPortProvider } from './infrastructure/utils/create-recovery-notification-port-provider.js'; +import { createRecoveryPolicyProvider } from './infrastructure/utils/create-recovery-policy-provider.js'; +import { createRecoveryServiceProvider } from './infrastructure/utils/create-recovery-service-provider.js'; +import { createRefreshStrategyPolicyProvider } from './infrastructure/utils/create-refresh-strategy-policy-provider.js'; +import { createRefreshStrategyProvider } from './infrastructure/utils/create-refresh-strategy-provider.js'; +import { createTokenPortProvider } from './infrastructure/utils/create-token-port-provider.js'; +import { createUserPortProvider } from './infrastructure/utils/create-user-port-provider.js'; +import { createVerifyNotificationPortProvider } from './infrastructure/utils/create-verify-notification-port-provider.js'; +import { createVerifyPolicyProvider } from './infrastructure/utils/create-verify-policy-provider.js'; +import { createVerifyServiceProvider } from './infrastructure/utils/create-verify-service-provider.js'; const RAW_OPTIONS_TOKEN = Symbol('__AUTHENTICATION_MODULE_RAW_OPTIONS_TOKEN__'); @@ -61,11 +94,11 @@ function definitionTransform( ...definition, global, imports: createAuthenticationImports({ imports }), - providers: createAuthenticationProviders({ providers }), + providers: createAuthenticationProviders({ providers, extras }), exports: [ ConfigModule, RAW_OPTIONS_TOKEN, - ...(createAuthenticationExports() ?? []), + ...(createAuthenticationExports(extras) ?? []), ], }; } @@ -75,88 +108,99 @@ export function createAuthenticationImports(options: { }): DynamicModule['imports'] { return [ ...(options.imports || []), + CqrsModule, + NestJwtModule.register({}), ConfigModule.forFeature(authenticationDefaultConfig), ]; } -export function createAuthenticationExports(): DynamicModule['exports'] { - return [ - AUTHENTICATION_MODULE_SETTINGS_TOKEN, - ValidateTokenService, - IssueTokenService, - VerifyTokenService, +export function createAuthenticationExports( + extras?: AuthenticationOptionsExtrasInterface, +): DynamicModule['exports'] { + const exports: DynamicModule['exports'] = [ + AUTHENTICATION_JWT_PORT_TOKEN, + JwtService, + JwtPolicy, + JwtStrategy, + JwtGuard, + GuardsPolicy, + LocalService, + RecoveryService, + VerifyService, ]; + + if (extras?.guards?.length) { + exports.push( + AuthRouterGuards, + ...extras.guards.map((config) => config.guard), + ); + } + + return exports; } export function createAuthenticationProviders(options: { - overrides?: AuthenticationOptions; providers?: Provider[]; + extras?: AuthenticationOptionsExtrasInterface; }): Provider[] { return [ ...(options.providers ?? []), - JwtIssueTokenService, - JwtVerifyTokenService, - createAuthenticationOptionsProvider(options.overrides), - createAuthenticationVerifyTokenServiceProvider(options.overrides), - createAuthenticationIssueTokenServiceProvider(options.overrides), - createAuthenticationValidateTokenServiceProvider(options.overrides), + // Port providers (from options.ports config, resolved at DI time) + ...createAuthenticationPortProviders(), + // JWT infrastructure + JwtService, + // Policies (always registered with defaults) + createJwtPolicyProvider(RAW_OPTIONS_TOKEN), + createJwtStrategyPolicyProvider(RAW_OPTIONS_TOKEN), + createLocalStrategyPolicyProvider(RAW_OPTIONS_TOKEN), + createRefreshStrategyPolicyProvider(RAW_OPTIONS_TOKEN), + createRecoveryPolicyProvider(RAW_OPTIONS_TOKEN), + createVerifyPolicyProvider(RAW_OPTIONS_TOKEN), + createGuardsPolicyProvider(RAW_OPTIONS_TOKEN), + // CQRS handlers — JwtPort default handlers (infrastructure) + SignAccessTokenHandler, + SignRefreshTokenHandler, + JwtVerifyAccessTokenHandler, + JwtVerifyRefreshTokenHandler, + // CQRS handlers — TokenPort default handlers (application) + IssueAccessTokenHandler, + IssueRefreshTokenHandler, + VerifyAccessTokenHandler, + VerifyRefreshTokenHandler, + IssueAuthenticatedResponseHandler, + ValidateTokenHandler, + ValidateAndVerifyAccessTokenHandler, + ValidateAndVerifyRefreshTokenHandler, + // Auth-JWT feature + JwtGuard, + createJwtStrategyProvider(RAW_OPTIONS_TOKEN), + createJwtAppGuardProvider(RAW_OPTIONS_TOKEN, options.extras ?? {}), + // Auth-Local feature + createLocalValidateUserServiceProvider(RAW_OPTIONS_TOKEN), + createLocalStrategyProvider(RAW_OPTIONS_TOKEN), + // Auth-Refresh feature + createRefreshStrategyProvider(RAW_OPTIONS_TOKEN), + // Auth-Recovery feature + createRecoveryServiceProvider(RAW_OPTIONS_TOKEN), + // Auth-Verify feature + createVerifyServiceProvider(RAW_OPTIONS_TOKEN), + // Auth-Router feature + ...(options.extras?.guards?.length + ? createAuthRouterGuardsProviders(options.extras.guards) + : []), + // Context overlays + { provide: APP_INTERCEPTOR, useClass: AuthUserContextOverlay }, ]; } -export function createAuthenticationOptionsProvider( - optionsOverrides?: AuthenticationOptions, -): Provider { - return createSettingsProvider< - AuthenticationSettingsInterface, - AuthenticationOptionsInterface - >({ - settingsToken: AUTHENTICATION_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: authenticationDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createAuthenticationIssueTokenServiceProvider( - optionsOverrides?: AuthenticationOptions, -): Provider { - return { - provide: IssueTokenService, - inject: [RAW_OPTIONS_TOKEN, JwtIssueTokenService], - useFactory: async ( - options: AuthenticationOptionsInterface, - jwtIssueTokenService: JwtIssueTokenService, - ) => - optionsOverrides?.issueTokenService ?? - options.issueTokenService ?? - new IssueTokenService(jwtIssueTokenService), - }; -} - -export function createAuthenticationVerifyTokenServiceProvider( - optionsOverrides?: AuthenticationOptions, -): Provider { - return { - provide: VerifyTokenService, - inject: [RAW_OPTIONS_TOKEN, JwtVerifyTokenService, ValidateTokenService], - useFactory: async ( - options: AuthenticationOptionsInterface, - jwtVerifyService: JwtVerifyTokenServiceInterface, - validateTokenService: ValidateTokenServiceInterface, - ) => - optionsOverrides?.verifyTokenService ?? - options.verifyTokenService ?? - new VerifyTokenService(jwtVerifyService, validateTokenService), - }; -} - -export function createAuthenticationValidateTokenServiceProvider( - optionsOverrides?: AuthenticationOptions, -): Provider { - return { - provide: ValidateTokenService, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: AuthenticationOptionsInterface) => - optionsOverrides?.validateTokenService ?? options.validateTokenService, - }; +export function createAuthenticationPortProviders(): Provider[] { + return [ + createJwtPortProvider(RAW_OPTIONS_TOKEN), + createTokenPortProvider(RAW_OPTIONS_TOKEN), + createUserPortProvider(RAW_OPTIONS_TOKEN), + createPasswordPortProvider(RAW_OPTIONS_TOKEN), + createOtpPortProvider(RAW_OPTIONS_TOKEN), + createRecoveryNotificationPortProvider(RAW_OPTIONS_TOKEN), + createVerifyNotificationPortProvider(RAW_OPTIONS_TOKEN), + ]; } diff --git a/packages/nestjs-authentication/src/authentication.module.spec.ts b/packages/nestjs-authentication/src/authentication.module.spec.ts index d7fe8659d..046fc7530 100644 --- a/packages/nestjs-authentication/src/authentication.module.spec.ts +++ b/packages/nestjs-authentication/src/authentication.module.spec.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'crypto'; + import { DynamicModule, Inject, @@ -5,38 +7,52 @@ import { Module, ModuleMetadata, } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; import { Test, TestingModule } from '@nestjs/testing'; -import { JwtModule } from '@concepta/nestjs-jwt'; - -import { ValidateTokenService } from './authentication.constants'; -import { AuthenticationModule } from './authentication.module'; -import { IssueTokenServiceInterface } from './interfaces/issue-token-service.interface'; -import { ValidateTokenServiceInterface } from './interfaces/validate-token-service.interface'; -import { VerifyTokenServiceInterface } from './interfaces/verify-token-service.interface'; -import { IssueTokenService } from './services/issue-token.service'; -import { VerifyTokenService } from './services/verify-token.service'; - -import { GlobalModuleFixture } from './__fixtures__/global.module.fixture'; -import { IssueTokenServiceFixture } from './__fixtures__/services/issue-token.service.fixture'; -import { ValidateTokenServiceFixture } from './__fixtures__/services/validate-token.service.fixture'; -import { VerifyTokenServiceFixture } from './__fixtures__/services/verify-token.service.fixture'; +import { GlobalModuleFixture } from './__tests__/fixtures/global.module.fixture.js'; +import { mockPasswordPortSettings } from './__tests__/fixtures/ports/mock-password-port.provider.js'; +import { mockUserPortSettings } from './__tests__/fixtures/ports/mock-user-port.provider.js'; +import { + stubOtpPortSettings, + stubRecoveryNotificationPortSettings, + stubVerifyNotificationPortSettings, +} from './__tests__/fixtures/ports/stub-unused-ports.fixture.js'; +import { LocalService } from './application/services/local/local.service.js'; +import { + AUTHENTICATION_JWT_PORT_TOKEN, + AUTHENTICATION_PASSWORD_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, +} from './authentication.constants.js'; +import { AuthenticationModule } from './authentication.module.js'; +import { Token } from './domain/aggregates/token.aggregate.js'; +import { JwtPolicy } from './domain/policies/jwt.policy.js'; +import { JwtPort } from './domain/ports/jwt.port.js'; +import { PasswordPort } from './domain/ports/password.port.js'; +import { UserPort } from './domain/ports/user.port.js'; +import { JwtService } from './infrastructure/jwt/jwt.service.js'; describe(AuthenticationModule, () => { let testModule: TestingModule; let authenticationModule: AuthenticationModule; - let issueTokenService: IssueTokenServiceInterface; - let verifyTokenService: VerifyTokenServiceInterface; - let validateTokenService: ValidateTokenServiceInterface; describe(AuthenticationModule.forRoot, () => { beforeEach(async () => { testModule = await Test.createTestingModule( testModuleFactory([ AuthenticationModule.forRoot({ - verifyTokenService: new VerifyTokenServiceFixture(), - issueTokenService: new IssueTokenServiceFixture(), - validateTokenService: new ValidateTokenServiceFixture(), + settings: { + jwt: { + access: { + secret: 'access-secret', + signOptions: { expiresIn: '1h' }, + }, + refresh: { + secret: 'refresh-secret', + signOptions: { expiresIn: '7d' }, + }, + }, + }, }), ]), ).compile(); @@ -48,14 +64,100 @@ describe(AuthenticationModule, () => { }); }); + describe('AuthenticationModule.forRoot with ports.user/ports.password (local strategy)', () => { + beforeEach(async () => { + testModule = await Test.createTestingModule({ + imports: [ + CqrsModule, + AuthenticationModule.forRoot({ + settings: { + strategies: { + local: {}, + }, + }, + ports: { + user: mockUserPortSettings, + password: mockPasswordPortSettings, + otp: stubOtpPortSettings, + recoveryNotification: stubRecoveryNotificationPortSettings, + verifyNotification: stubVerifyNotificationPortSettings, + }, + }), + ], + }).compile(); + }); + + it('resolves UserPort and PasswordPort from ports config instead of null', () => { + expect(testModule.get(AUTHENTICATION_USER_PORT_TOKEN)).toBeInstanceOf( + UserPort, + ); + expect(testModule.get(AUTHENTICATION_PASSWORD_PORT_TOKEN)).toBeInstanceOf( + PasswordPort, + ); + }); + + it('does not throw AuthenticationFeatureConfigException for local strategy', () => { + expect(testModule.get(LocalService)).toBeInstanceOf(LocalService); + }); + }); + + describe('AuthenticationModule.forRootAsync with ports.user/ports.password (local strategy)', () => { + beforeEach(async () => { + testModule = await Test.createTestingModule({ + imports: [ + CqrsModule, + AuthenticationModule.forRootAsync({ + inject: [], + useFactory: () => ({ + settings: { + strategies: { + local: {}, + }, + }, + ports: { + user: mockUserPortSettings, + password: mockPasswordPortSettings, + otp: stubOtpPortSettings, + recoveryNotification: stubRecoveryNotificationPortSettings, + verifyNotification: stubVerifyNotificationPortSettings, + }, + }), + }), + ], + }).compile(); + }); + + it('resolves UserPort and PasswordPort from an async ports factory instead of null', () => { + expect(testModule.get(AUTHENTICATION_USER_PORT_TOKEN)).toBeInstanceOf( + UserPort, + ); + expect(testModule.get(AUTHENTICATION_PASSWORD_PORT_TOKEN)).toBeInstanceOf( + PasswordPort, + ); + }); + + it('does not throw AuthenticationFeatureConfigException for local strategy', () => { + expect(testModule.get(LocalService)).toBeInstanceOf(LocalService); + }); + }); + describe(AuthenticationModule.register, () => { beforeEach(async () => { testModule = await Test.createTestingModule( testModuleFactory([ AuthenticationModule.register({ - verifyTokenService: new VerifyTokenServiceFixture(), - issueTokenService: new IssueTokenServiceFixture(), - validateTokenService: new ValidateTokenServiceFixture(), + settings: { + jwt: { + access: { + secret: 'access-secret', + signOptions: { expiresIn: '1h' }, + }, + refresh: { + secret: 'refresh-secret', + signOptions: { expiresIn: '7d' }, + }, + }, + }, }), ]), ).compile(); @@ -72,19 +174,20 @@ describe(AuthenticationModule, () => { testModule = await Test.createTestingModule( testModuleFactory([ AuthenticationModule.forRootAsync({ - inject: [ - VerifyTokenServiceFixture, - IssueTokenServiceFixture, - ValidateTokenServiceFixture, - ], - useFactory: ( - verifyTokenService: VerifyTokenServiceInterface, - issueTokenService: IssueTokenServiceInterface, - validateTokenService: ValidateTokenServiceInterface, - ) => ({ - verifyTokenService, - issueTokenService, - validateTokenService, + inject: [], + useFactory: () => ({ + settings: { + jwt: { + access: { + secret: 'access-secret', + signOptions: { expiresIn: '1h' }, + }, + refresh: { + secret: 'refresh-secret', + signOptions: { expiresIn: '7d' }, + }, + }, + }, }), }), ]), @@ -98,48 +201,55 @@ describe(AuthenticationModule, () => { }); describe(AuthenticationModule.forRootAsync, () => { - class TestController { - constructor( - @Inject(IssueTokenService) - private readonly issueTokenService: IssueTokenService, - ) { - // TestController with injected IssueTokenService - } - } @Injectable() class TestService { constructor( - @Inject(IssueTokenService) - private readonly issueTokenService: IssueTokenService, - @Inject(VerifyTokenService) - private readonly verifyTokenService: VerifyTokenService, + @Inject(JwtService) + private readonly jwtService: JwtService, + @Inject(JwtPolicy) + private readonly jwtPolicy: JwtPolicy, ) {} - // Method to issue tokens using TEMP secrets async issueAccessToken(payload: { sub: string }) { - return this.issueTokenService.accessToken(payload); + const now = new Date(); + const token = new Token(randomUUID(), { + sub: payload.sub, + type: 'access', + scope: [], + iat: now, + exp: this.jwtPolicy.getAccessExpiry(now), + }); + return this.jwtService.signAccessToken(token); } async issueRefreshToken(payload: { sub: string }) { - return this.issueTokenService.refreshToken(payload); + const now = new Date(); + const token = new Token(randomUUID(), { + sub: payload.sub, + type: 'refresh', + scope: [], + iat: now, + exp: this.jwtPolicy.getRefreshExpiry(now), + }); + return this.jwtService.signRefreshToken(token); } - // Method to verify tokens using TEMP secrets async verifyAccessToken(token: string) { - return this.verifyTokenService.accessToken(token); + return this.jwtService.verifyAccessToken(token); } async verifyRefreshToken(token: string) { - return this.verifyTokenService.refreshToken(token); + return this.jwtService.verifyRefreshToken(token); } } @Module({ imports: [ AuthenticationModule.registerAsync({ - imports: [ - JwtModule.forRoot({ - settings: { + inject: [], + useFactory: () => ({ + settings: { + jwt: { access: { secret: 'TEMP', signOptions: { @@ -153,13 +263,10 @@ describe(AuthenticationModule, () => { }, }, }, - }), - ], - inject: [], - useFactory: () => ({}), + }, + }), }), ], - controllers: [TestController], providers: [TestService], }) class TestModule {} @@ -170,7 +277,20 @@ describe(AuthenticationModule, () => { TestModule, AuthenticationModule.forRootAsync({ inject: [], - useFactory: () => ({}), + useFactory: () => ({ + settings: { + jwt: { + access: { + secret: 'global-access-secret', + signOptions: { expiresIn: '1h' }, + }, + refresh: { + secret: 'global-refresh-secret', + signOptions: { expiresIn: '7d' }, + }, + }, + }, + }), }), ]), ).compile(); @@ -179,46 +299,58 @@ describe(AuthenticationModule, () => { it('should isolate TEMP secrets from global secrets - cross-verification should fail', async () => { commonVars(); - // Get services from the main testModule (global JWT) - // These are the services from the testModuleFactory with 'global' secrets - const globalIssueService = issueTokenService; - const globalVerifyService = verifyTokenService; - - // Get TestService from TestModule (which has TEMP JWT injected) + const globalJwtService = testModule.get(JwtService); const testService = testModule.get(TestService); const payload = { sub: 'test-user-id' }; + const globalJwtPolicy = testModule.get(JwtPolicy); - // Create token with TEMP secret (via TestService) const tempAccessToken = await testService.issueAccessToken(payload); const tempRefreshToken = await testService.issueRefreshToken(payload); - // Create token with global secret (from main testModule) - const globalAccessToken = await globalIssueService.accessToken(payload); - const globalRefreshToken = await globalIssueService.refreshToken(payload); - - // Test 1: TEMP token should NOT be verifiable by global service + const makeToken = (type: 'access' | 'refresh') => { + const now = new Date(); + return new Token(randomUUID(), { + sub: payload.sub, + type, + scope: [], + iat: now, + exp: + type === 'access' + ? globalJwtPolicy.getAccessExpiry(now) + : globalJwtPolicy.getRefreshExpiry(now), + }); + }; + + const globalAccessToken = await globalJwtService.signAccessToken( + makeToken('access'), + ); + const globalRefreshToken = await globalJwtService.signRefreshToken( + makeToken('refresh'), + ); + + // TEMP token should NOT be verifiable by global service await expect( - globalVerifyService.accessToken(tempAccessToken), - ).rejects.toThrow(); // Should fail due to wrong secret + globalJwtService.verifyAccessToken(tempAccessToken), + ).rejects.toThrow(); await expect( - globalVerifyService.refreshToken(tempRefreshToken), - ).rejects.toThrow(); // Should fail due to wrong secret + globalJwtService.verifyRefreshToken(tempRefreshToken), + ).rejects.toThrow(); - // Test 2: Global token should NOT be verifiable by TEMP service (via TestService) + // Global token should NOT be verifiable by TEMP service await expect( testService.verifyAccessToken(globalAccessToken), - ).rejects.toThrow(); // Should fail due to wrong secret + ).rejects.toThrow(); await expect( testService.verifyRefreshToken(globalRefreshToken), - ).rejects.toThrow(); // Should fail due to wrong secret + ).rejects.toThrow(); - // Test 3: But tokens should be verifiable by their own services (sanity check) + // Tokens should be verifiable by their own services const tempVerified = await testService.verifyAccessToken(tempAccessToken); const globalVerified = - await globalVerifyService.accessToken(globalAccessToken); + await globalJwtService.verifyAccessToken(globalAccessToken); expect(tempVerified).toBeDefined(); expect(globalVerified).toBeDefined(); @@ -230,19 +362,20 @@ describe(AuthenticationModule, () => { testModule = await Test.createTestingModule( testModuleFactory([ AuthenticationModule.registerAsync({ - inject: [ - VerifyTokenServiceFixture, - IssueTokenServiceFixture, - ValidateTokenServiceFixture, - ], - useFactory: ( - verifyTokenService: VerifyTokenServiceInterface, - issueTokenService: IssueTokenServiceInterface, - validateTokenService: ValidateTokenServiceInterface, - ) => ({ - verifyTokenService, - issueTokenService, - validateTokenService, + inject: [], + useFactory: () => ({ + settings: { + jwt: { + access: { + secret: 'access-secret', + signOptions: { expiresIn: '1h' }, + }, + refresh: { + secret: 'refresh-secret', + signOptions: { expiresIn: '7d' }, + }, + }, + }, }), }), ]), @@ -257,28 +390,23 @@ describe(AuthenticationModule, () => { function commonVars() { authenticationModule = testModule.get(AuthenticationModule); - verifyTokenService = testModule.get(VerifyTokenService); - issueTokenService = testModule.get(IssueTokenService); - validateTokenService = testModule.get(ValidateTokenService); } function commonTests() { expect(authenticationModule).toBeInstanceOf(AuthenticationModule); - expect(issueTokenService).toBeInstanceOf(IssueTokenServiceFixture); - expect(verifyTokenService).toBeInstanceOf(VerifyTokenServiceFixture); - expect(validateTokenService).toBeInstanceOf(ValidateTokenServiceFixture); + + const jwtPort = testModule.get(AUTHENTICATION_JWT_PORT_TOKEN); + expect(jwtPort).toBeInstanceOf(JwtPort); + + const jwtService = testModule.get(JwtService); + expect(jwtService).toBeInstanceOf(JwtService); } }); -/** - * Factory function to create test module configuration - * - * @param extraImports - Additional imports to include in the test module - */ function testModuleFactory( extraImports: DynamicModule['imports'] = [], ): ModuleMetadata { return { - imports: [GlobalModuleFixture, JwtModule.forRoot({}), ...extraImports], + imports: [GlobalModuleFixture, ...extraImports], }; } diff --git a/packages/nestjs-authentication/src/authentication.module.ts b/packages/nestjs-authentication/src/authentication.module.ts index ebcf53409..ffb57f57a 100644 --- a/packages/nestjs-authentication/src/authentication.module.ts +++ b/packages/nestjs-authentication/src/authentication.module.ts @@ -4,7 +4,7 @@ import { AuthenticationAsyncOptions, AuthenticationModuleClass, AuthenticationOptions, -} from './authentication.module-definition'; +} from './authentication.module-definition.js'; /** * Authentication module diff --git a/packages/nestjs-authentication/src/authentication.types.ts b/packages/nestjs-authentication/src/authentication.types.ts deleted file mode 100644 index 0f0cf9aa7..000000000 --- a/packages/nestjs-authentication/src/authentication.types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { CanActivate } from '@nestjs/common'; - -export interface AuthGuardOptions { - canDisable?: boolean; -} - -export type AuthGuardCtr = new ( - strategyName: string, - options: AuthGuardOptions, -) => CanActivate; diff --git a/packages/nestjs-authentication/src/config/authentication-default.config.ts b/packages/nestjs-authentication/src/config/authentication-default.config.ts deleted file mode 100644 index 45376fe84..000000000 --- a/packages/nestjs-authentication/src/config/authentication-default.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { AUTHENTICATION_MODULE_SETTINGS_TOKEN } from '../authentication.constants'; -import { AuthenticationSettingsInterface } from '../interfaces/authentication-settings.interface'; - -export const authenticationDefaultConfig = registerAs( - AUTHENTICATION_MODULE_SETTINGS_TOKEN, - (): AuthenticationSettingsInterface => ({ - enableGuards: true, - }), -); diff --git a/packages/nestjs-authentication/src/decorators/auth-public.decorator.spec.ts b/packages/nestjs-authentication/src/decorators/auth-public.decorator.spec.ts deleted file mode 100644 index d39613be4..000000000 --- a/packages/nestjs-authentication/src/decorators/auth-public.decorator.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -import { AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN } from '../authentication.constants'; - -import { AuthPublic } from './auth-public.decorator'; - -jest.mock('@nestjs/common', () => { - return { - SetMetadata: jest.fn().mockImplementation(() => 'mocked SetMetadata'), // Mock SetMetadata - }; -}); - -describe(AuthPublic.name, () => { - it('should set metadata to disable guards', () => { - AuthPublic(); - // Assert that SetMetadata was called with specific arguments - expect(SetMetadata).toHaveBeenCalledWith( - AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, - true, - ); - }); -}); diff --git a/packages/nestjs-authentication/src/decorators/auth-public.decorator.ts b/packages/nestjs-authentication/src/decorators/auth-public.decorator.ts deleted file mode 100644 index e2d059736..000000000 --- a/packages/nestjs-authentication/src/decorators/auth-public.decorator.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -import { AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN } from '../authentication.constants'; - -/** - * Disable ONLY AuthGuards that have the `canDisable` option set to true. - */ -export const AuthPublic = () => - SetMetadata(AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, true); diff --git a/packages/nestjs-authentication/src/decorators/auth-user.decorator.spec.ts b/packages/nestjs-authentication/src/decorators/auth-user.decorator.spec.ts deleted file mode 100644 index 6e7b0ac2b..000000000 --- a/packages/nestjs-authentication/src/decorators/auth-user.decorator.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AuthUser } from './auth-user.decorator'; - -describe(AuthUser.name, () => { - it('AuthUser should be imported', () => { - expect(AuthUser).toBeInstanceOf(Function); - }); -}); diff --git a/packages/nestjs-authentication/src/decorators/auth-user.decorator.ts b/packages/nestjs-authentication/src/decorators/auth-user.decorator.ts deleted file mode 100644 index 3e17838d4..000000000 --- a/packages/nestjs-authentication/src/decorators/auth-user.decorator.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * BACK COMPAT, DO NOT REMOVE - */ -export { AuthUser } from '@concepta/nestjs-common'; diff --git a/packages/nestjs-authentication/src/domain/aggregates/__tests__/token.aggregate.spec.ts b/packages/nestjs-authentication/src/domain/aggregates/__tests__/token.aggregate.spec.ts new file mode 100644 index 000000000..25324fe70 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/aggregates/__tests__/token.aggregate.spec.ts @@ -0,0 +1,168 @@ +import { createTestEventContext } from '@concepta/nestjs-core/testing'; + +import { TokenIssuedEvent } from '../../events/token-issued.event.js'; +import { TokenRevokedEvent } from '../../events/token-revoked.event.js'; +import { TokenAlreadyRevokedException } from '../../exceptions/token-already-revoked.exception.js'; +import { Token } from '../token.aggregate.js'; + +const eventContext = createTestEventContext({}, {}); + +const makeDto = ( + overrides: Partial[1]> = {}, +) => ({ + sub: 'user-1', + type: 'access' as const, + exp: new Date(Date.now() + 3_600_000), + ...overrides, +}); + +describe(Token.name, () => { + describe('create', () => { + it('should create a token with a generated UUID id', () => { + const token = Token.create(eventContext, makeDto()); + expect(token.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it('should default iat to current time when not supplied', () => { + const before = new Date(); + const token = Token.create(eventContext, makeDto()); + const after = new Date(); + expect(token.iat.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(token.iat.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it('should use supplied iat', () => { + const iat = new Date('2025-01-01T00:00:00.000Z'); + const token = Token.create(eventContext, makeDto({ iat })); + expect(token.iat).toEqual(iat); + }); + + it('should default scope to empty array', () => { + const token = Token.create(eventContext, makeDto()); + expect(token.scope).toEqual([]); + }); + + it('should apply TokenIssuedEvent', () => { + const token = Token.create(eventContext, makeDto()); + const events = token.getUncommittedEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(TokenIssuedEvent); + }); + + it('should include refreshedFrom on TokenIssuedEvent when supplied', () => { + const prevId = 'prev-token-id'; + const token = Token.create(eventContext, makeDto(), prevId); + const event = token.getUncommittedEvents()[0] as TokenIssuedEvent; + expect(event.refreshedFrom).toBe(prevId); + }); + + it('should not include refreshedFrom when omitted', () => { + const token = Token.create(eventContext, makeDto()); + const event = token.getUncommittedEvents()[0] as TokenIssuedEvent; + expect(event.refreshedFrom).toBeUndefined(); + }); + }); + + describe('createWithId', () => { + it('should use the supplied id', () => { + const token = Token.createWithId(eventContext, 'my-custom-id', makeDto()); + expect(token.id).toBe('my-custom-id'); + }); + }); + + describe('isExpired', () => { + it('should return false when exp is in the future', () => { + const token = Token.create( + eventContext, + makeDto({ exp: new Date(Date.now() + 60_000) }), + ); + expect(token.isExpired()).toBe(false); + }); + + it('should return true when exp is in the past', () => { + const token = Token.create( + eventContext, + makeDto({ exp: new Date(Date.now() - 1) }), + ); + expect(token.isExpired()).toBe(true); + }); + + it('should use the supplied now date', () => { + const exp = new Date('2025-06-01T00:00:00.000Z'); + const token = Token.create(eventContext, makeDto({ exp })); + expect(token.isExpired(new Date('2025-05-31T00:00:00.000Z'))).toBe(false); + expect(token.isExpired(new Date('2025-06-01T00:00:00.000Z'))).toBe(true); + }); + }); + + describe('isRevoked', () => { + it('should return false for a newly issued token', () => { + const token = Token.create(eventContext, makeDto()); + expect(token.isRevoked()).toBe(false); + }); + + it('should return true after revoke()', () => { + const token = Token.create(eventContext, makeDto()); + token.revoke(eventContext); + expect(token.isRevoked()).toBe(true); + }); + }); + + describe('isActive', () => { + it('should return true for a valid non-revoked token', () => { + const token = Token.create(eventContext, makeDto()); + expect(token.isActive()).toBe(true); + }); + + it('should return false when expired', () => { + const token = Token.create( + eventContext, + makeDto({ exp: new Date(Date.now() - 1) }), + ); + expect(token.isActive()).toBe(false); + }); + + it('should return false when revoked', () => { + const token = Token.create(eventContext, makeDto()); + token.revoke(eventContext); + expect(token.isActive()).toBe(false); + }); + }); + + describe('revoke', () => { + it('should apply TokenRevokedEvent', () => { + const token = Token.create(eventContext, makeDto()); + token.revoke(eventContext); + + const events = token.getUncommittedEvents(); + const revokedEvents = events.filter( + (e) => e instanceof TokenRevokedEvent, + ); + expect(revokedEvents).toHaveLength(1); + }); + + it('should set revokedAt to the provided now date', () => { + const now = new Date('2025-06-01T12:00:00.000Z'); + const token = Token.create(eventContext, makeDto()); + token.revoke(eventContext, now); + expect(token.revokedAt).toEqual(now); + }); + + it('should throw TokenAlreadyRevokedException on double revoke', () => { + const token = Token.create(eventContext, makeDto()); + token.revoke(eventContext); + expect(() => token.revoke(eventContext)).toThrow( + TokenAlreadyRevokedException, + ); + }); + + it('should not increment version', () => { + const token = Token.create(eventContext, makeDto()); + const versionBefore = token.version; + token.revoke(eventContext); + expect(token.version).toBe(versionBefore); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/aggregates/token.aggregate.ts b/packages/nestjs-authentication/src/domain/aggregates/token.aggregate.ts new file mode 100644 index 000000000..fef589ac2 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/aggregates/token.aggregate.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'crypto'; + +import { + type DomainFactory, + type EventContextHost, +} from '@concepta/nestjs-core'; +import { + type AggregateMetaInterface, + DomainAggregate, +} from '@concepta/nestjs-core/aggregate'; + +import { TokenIssuedEvent } from '../events/token-issued.event.js'; +import { TokenRevokedEvent } from '../events/token-revoked.event.js'; +import { TokenAlreadyRevokedException } from '../exceptions/token-already-revoked.exception.js'; +import { type TokenCreatableInterface } from '../interfaces/token-creatable.interface.js'; +import { type TokenInterface } from '../interfaces/token.interface.js'; + +export class Token extends DomainAggregate { + constructor( + id: string, + props: TokenInterface, + version?: number, + meta?: AggregateMetaInterface, + ) { + super(id, props, version, meta); + } + + get sub() { + return this.props.sub; + } + + get type() { + return this.props.type; + } + + get scope() { + return this.props.scope; + } + + get iat() { + return this.props.iat; + } + + get exp() { + return this.props.exp; + } + + get revokedAt() { + return this.props.revokedAt; + } + + isExpired(now: Date = new Date()): boolean { + return now >= this.props.exp; + } + + isRevoked(): boolean { + return this.props.revokedAt !== undefined; + } + + isActive(now: Date = new Date()): boolean { + return !this.isExpired(now) && !this.isRevoked(); + } + + static create( + eventContext: EventContextHost, + dto: TokenCreatableInterface, + refreshedFrom?: string, + ): Token { + return Token.createWithId(eventContext, randomUUID(), dto, refreshedFrom); + } + + static createWithId( + eventContext: EventContextHost, + id: string, + dto: TokenCreatableInterface, + refreshedFrom?: string, + ): Token { + const iat = dto.iat ?? new Date(); + const props: TokenInterface = { + sub: dto.sub, + type: dto.type, + scope: dto.scope ?? [], + iat, + exp: dto.exp, + }; + const token = new Token(id, props); + token.apply( + new TokenIssuedEvent(eventContext, token.toPlain(), refreshedFrom), + ); + return token; + } + + revoke(eventContext: EventContextHost, now: Date = new Date()): void { + if (this.isRevoked()) { + throw new TokenAlreadyRevokedException(this.id); + } + this.props = { ...this.props, revokedAt: now }; + this.apply(new TokenRevokedEvent(eventContext, this.toPlain())); + } +} + +Token satisfies DomainFactory; diff --git a/packages/nestjs-authentication/src/domain/events/notification-send-failed.event.ts b/packages/nestjs-authentication/src/domain/events/notification-send-failed.event.ts new file mode 100644 index 000000000..fcddc30b7 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/events/notification-send-failed.event.ts @@ -0,0 +1,18 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; +import { type Command, type IEvent } from '@nestjs/cqrs'; + +import { type ReferenceEmail } from '@concepta/nestjs-core'; + +import { type AuthenticationEmailException } from '../exceptions/authentication-email.exception.js'; + +export class NotificationSendFailedEvent implements IEvent { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly email: ReferenceEmail, + public readonly command: Type>, + // A classified RuntimeException (fault: 'internal'), not a bare + // `unknown` — a subscriber can read `.message`/`.context.originalError` + // without an `instanceof Error` guess first. + public readonly error: AuthenticationEmailException, + ) {} +} diff --git a/packages/nestjs-authentication/src/domain/events/token-issued.event.ts b/packages/nestjs-authentication/src/domain/events/token-issued.event.ts new file mode 100644 index 000000000..a5de213f1 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/events/token-issued.event.ts @@ -0,0 +1,13 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type TokenInterface } from '../interfaces/token.interface.js'; + +export class TokenIssuedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly token: TokenInterface & { id: string }, + public readonly refreshedFrom?: string, + ) {} +} diff --git a/packages/nestjs-authentication/src/domain/events/token-revoked.event.ts b/packages/nestjs-authentication/src/domain/events/token-revoked.event.ts new file mode 100644 index 000000000..e372d3434 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/events/token-revoked.event.ts @@ -0,0 +1,12 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type TokenInterface } from '../interfaces/token.interface.js'; + +export class TokenRevokedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly token: TokenInterface & { id: string }, + ) {} +} diff --git a/packages/nestjs-authentication/src/domain/exceptions/__tests__/authentication.exception.spec.ts b/packages/nestjs-authentication/src/domain/exceptions/__tests__/authentication.exception.spec.ts new file mode 100644 index 000000000..be68bad38 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/exceptions/__tests__/authentication.exception.spec.ts @@ -0,0 +1,16 @@ +import { RuntimeException } from '@concepta/nestjs-core'; + +import { AuthenticationException } from '../authentication.exception.js'; + +describe(AuthenticationException.name, () => { + it('should extend RuntimeException', () => { + const exception = new AuthenticationException(); + expect(exception).toBeInstanceOf(RuntimeException); + }); + + it('should have default message "Credentials are incorrect."', () => { + const exception = new AuthenticationException(); + + expect(exception.message).toEqual('Runtime Exception'); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/exceptions/authentication-email.exception.ts b/packages/nestjs-authentication/src/domain/exceptions/authentication-email.exception.ts new file mode 100644 index 000000000..247b1a581 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/exceptions/authentication-email.exception.ts @@ -0,0 +1,11 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +export class AuthenticationEmailException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ fault: 'internal', ...options }); + this.errorCode = 'AUTHENTICATION_EMAIL_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/exceptions/authentication.exception.ts b/packages/nestjs-authentication/src/domain/exceptions/authentication.exception.ts similarity index 80% rename from packages/nestjs-authentication/src/exceptions/authentication.exception.ts rename to packages/nestjs-authentication/src/domain/exceptions/authentication.exception.ts index 14ee6205d..16304d808 100644 --- a/packages/nestjs-authentication/src/exceptions/authentication.exception.ts +++ b/packages/nestjs-authentication/src/domain/exceptions/authentication.exception.ts @@ -1,7 +1,7 @@ import { RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; /** * Exception for authentication diff --git a/packages/nestjs-authentication/src/domain/exceptions/token-already-revoked.exception.ts b/packages/nestjs-authentication/src/domain/exceptions/token-already-revoked.exception.ts new file mode 100644 index 000000000..4d684aa2d --- /dev/null +++ b/packages/nestjs-authentication/src/domain/exceptions/token-already-revoked.exception.ts @@ -0,0 +1,20 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeException } from '@concepta/nestjs-core'; + +import { TokenException } from './token.exception.js'; + +export class TokenAlreadyRevokedException extends TokenException { + declare context: RuntimeException['context'] & { tokenId: string }; + + constructor(tokenId: string) { + super({ + httpStatus: HttpStatus.CONFLICT, + message: 'Token %s has already been revoked', + messageParams: [tokenId], + fault: 'client', + }); + this.errorCode = 'TOKEN_ALREADY_REVOKED_ERROR'; + this.context = { ...this.context, tokenId }; + } +} diff --git a/packages/nestjs-authentication/src/domain/exceptions/token.exception.ts b/packages/nestjs-authentication/src/domain/exceptions/token.exception.ts new file mode 100644 index 000000000..5c0d73f84 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/exceptions/token.exception.ts @@ -0,0 +1,11 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +export class TokenException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super(options); + this.errorCode = 'TOKEN_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/domain/interfaces/authenticated-response.interface.ts b/packages/nestjs-authentication/src/domain/interfaces/authenticated-response.interface.ts new file mode 100644 index 000000000..604845893 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/interfaces/authenticated-response.interface.ts @@ -0,0 +1,8 @@ +import { type AuthenticationAccessInterface } from './authentication-access.interface.js'; +import { type AuthenticationRefreshInterface } from './authentication-refresh.interface.js'; + +/** + * Authentication response interface + */ +export interface AuthenticatedResponseInterface + extends AuthenticationAccessInterface, AuthenticationRefreshInterface {} diff --git a/packages/nestjs-authentication/src/domain/interfaces/authenticated-user.interface.ts b/packages/nestjs-authentication/src/domain/interfaces/authenticated-user.interface.ts new file mode 100644 index 000000000..9193c3693 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/interfaces/authenticated-user.interface.ts @@ -0,0 +1,3 @@ +import { type ReferenceIdInterface } from '@concepta/nestjs-core'; + +export interface AuthenticatedUserInterface extends ReferenceIdInterface {} diff --git a/packages/nestjs-common/src/domain/authentication/interfaces/authentication-access.interface.ts b/packages/nestjs-authentication/src/domain/interfaces/authentication-access.interface.ts similarity index 100% rename from packages/nestjs-common/src/domain/authentication/interfaces/authentication-access.interface.ts rename to packages/nestjs-authentication/src/domain/interfaces/authentication-access.interface.ts diff --git a/packages/nestjs-authentication/src/domain/interfaces/authentication-login.interface.ts b/packages/nestjs-authentication/src/domain/interfaces/authentication-login.interface.ts new file mode 100644 index 000000000..8260debe6 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/interfaces/authentication-login.interface.ts @@ -0,0 +1,5 @@ +import { type ReferenceUsernameInterface } from '@concepta/nestjs-core'; +import { type PasswordPlainInterface } from '@concepta/nestjs-password'; + +export interface AuthenticationLoginInterface + extends ReferenceUsernameInterface, PasswordPlainInterface {} diff --git a/packages/nestjs-common/src/domain/authentication/interfaces/authentication-refresh.interface.ts b/packages/nestjs-authentication/src/domain/interfaces/authentication-refresh.interface.ts similarity index 100% rename from packages/nestjs-common/src/domain/authentication/interfaces/authentication-refresh.interface.ts rename to packages/nestjs-authentication/src/domain/interfaces/authentication-refresh.interface.ts diff --git a/packages/nestjs-authentication/src/domain/interfaces/authorization-payload.interface.ts b/packages/nestjs-authentication/src/domain/interfaces/authorization-payload.interface.ts new file mode 100644 index 000000000..cca97a578 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/interfaces/authorization-payload.interface.ts @@ -0,0 +1,3 @@ +import { type ReferenceSubjectInterface } from '@concepta/nestjs-core'; + +export interface AuthorizationPayloadInterface extends ReferenceSubjectInterface {} diff --git a/packages/nestjs-authentication/src/domain/interfaces/token-creatable.interface.ts b/packages/nestjs-authentication/src/domain/interfaces/token-creatable.interface.ts new file mode 100644 index 000000000..51567a5af --- /dev/null +++ b/packages/nestjs-authentication/src/domain/interfaces/token-creatable.interface.ts @@ -0,0 +1,9 @@ +import { type TokenType } from './token.interface.js'; + +export interface TokenCreatableInterface { + sub: string; + type: TokenType; + scope?: string[]; + iat?: Date; + exp: Date; +} diff --git a/packages/nestjs-authentication/src/domain/interfaces/token-options.interface.ts b/packages/nestjs-authentication/src/domain/interfaces/token-options.interface.ts new file mode 100644 index 000000000..1d4cfb952 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/interfaces/token-options.interface.ts @@ -0,0 +1,15 @@ +import { type JwtModuleOptions } from '@nestjs/jwt'; + +/** + * Token configuration options (excludes secretOrPrivateKey). + */ +export interface TokenOptionsInterface extends Omit< + JwtModuleOptions, + 'secretOrPrivateKey' | 'secret' +> { + /** + * Narrowed from jwt.Secret → string | Buffer. + * KeyObject is not supported for per-call sign/verify options. + */ + secret?: string | Buffer; +} diff --git a/packages/nestjs-authentication/src/domain/interfaces/token.interface.ts b/packages/nestjs-authentication/src/domain/interfaces/token.interface.ts new file mode 100644 index 000000000..8cafadbf8 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/interfaces/token.interface.ts @@ -0,0 +1,10 @@ +export type TokenType = 'access' | 'refresh'; + +export interface TokenInterface { + sub: string; + type: TokenType; + scope: string[]; + iat: Date; + exp: Date; + revokedAt?: Date; +} diff --git a/packages/nestjs-authentication/src/domain/policies/__tests__/guards.policy.spec.ts b/packages/nestjs-authentication/src/domain/policies/__tests__/guards.policy.spec.ts new file mode 100644 index 000000000..0ee4dc377 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/__tests__/guards.policy.spec.ts @@ -0,0 +1,33 @@ +import { GuardsPolicy } from '../guards.policy.js'; + +describe(GuardsPolicy.name, () => { + it('should default enable to true and disable to always-false fn', () => { + const policy = new GuardsPolicy(); + + expect(policy.enable).toBe(true); + expect(policy.disable({} as never, {} as never)).toBe(false); + }); + + it('should apply provided enable flag', () => { + const policy = new GuardsPolicy({ enable: false }); + + expect(policy.enable).toBe(false); + }); + + it('should apply provided disable function', () => { + const disableFn = vi.fn().mockReturnValue(true); + const policy = new GuardsPolicy({ disable: disableFn }); + + const result = policy.disable({} as never, {} as never); + + expect(result).toBe(true); + expect(disableFn).toHaveBeenCalledTimes(1); + }); + + it('should create with empty settings object', () => { + const policy = new GuardsPolicy({}); + + expect(policy.enable).toBe(true); + expect(policy.disable({} as never, {} as never)).toBe(false); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/policies/__tests__/jwt-strategy.policy.spec.ts b/packages/nestjs-authentication/src/domain/policies/__tests__/jwt-strategy.policy.spec.ts new file mode 100644 index 000000000..d659ad728 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/__tests__/jwt-strategy.policy.spec.ts @@ -0,0 +1,36 @@ +import { ExtractJwt } from 'passport-jwt'; + +import { JwtStrategyPolicy } from '../jwt-strategy.policy.js'; + +describe(JwtStrategyPolicy.name, () => { + it('should default requireUserValidation to false', () => { + const policy = new JwtStrategyPolicy({}); + + expect(policy.jwtFromRequest).toBeUndefined(); + expect(policy.requireUserValidation).toBe(false); + }); + + it('should apply provided jwtFromRequest', () => { + const extractor = ExtractJwt.fromAuthHeaderAsBearerToken(); + const policy = new JwtStrategyPolicy({ jwtFromRequest: extractor }); + + expect(policy.jwtFromRequest).toBe(extractor); + }); + + it('should apply provided requireUserValidation', () => { + const policy = new JwtStrategyPolicy({ requireUserValidation: true }); + + expect(policy.requireUserValidation).toBe(true); + }); + + it('should create with all settings', () => { + const extractor = ExtractJwt.fromBodyField('token'); + const policy = new JwtStrategyPolicy({ + jwtFromRequest: extractor, + requireUserValidation: true, + }); + + expect(policy.jwtFromRequest).toBe(extractor); + expect(policy.requireUserValidation).toBe(true); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/policies/__tests__/jwt.policy.spec.ts b/packages/nestjs-authentication/src/domain/policies/__tests__/jwt.policy.spec.ts new file mode 100644 index 000000000..5319e2360 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/__tests__/jwt.policy.spec.ts @@ -0,0 +1,235 @@ +import { type MockInstance } from 'vitest'; + +import { JwtPolicy } from '../jwt.policy.js'; + +describe(JwtPolicy.name, () => { + let emitWarningSpy: MockInstance; + + beforeEach(() => { + emitWarningSpy = vi + .spyOn(process, 'emitWarning') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + emitWarningSpy.mockRestore(); + }); + + const strongSecret = 'a'.repeat(32); + const otherStrongSecret = 'b'.repeat(32); + + describe('construction', () => { + it('should default to empty options when none provided', () => { + const policy = new JwtPolicy({}); + + expect(policy.access).toEqual({}); + expect(policy.refresh).toEqual({}); + }); + + it('should expose provided access and refresh options', () => { + const policy = new JwtPolicy({ + access: { secret: strongSecret, signOptions: { expiresIn: '15m' } }, + refresh: { + secret: otherStrongSecret, + signOptions: { expiresIn: '7d' }, + }, + }); + + expect(policy.access).toEqual({ + secret: strongSecret, + signOptions: { expiresIn: '15m' }, + }); + expect(policy.refresh).toEqual({ + secret: otherStrongSecret, + signOptions: { expiresIn: '7d' }, + }); + }); + }); + + describe('security warnings', () => { + it('should warn when access secret is shorter than 32 characters', () => { + new JwtPolicy({ + access: { secret: 'short', signOptions: { expiresIn: '15m' } }, + refresh: { + secret: otherStrongSecret, + signOptions: { expiresIn: '7d' }, + }, + }); + + expect(emitWarningSpy).toHaveBeenCalledWith( + expect.stringContaining('JWT access token secret is shorter than 32'), + { code: 'ROCKETS_JWT_WEAK_SECRET' }, + ); + }); + + it('should warn when refresh secret is shorter than 32 characters', () => { + new JwtPolicy({ + access: { secret: strongSecret, signOptions: { expiresIn: '15m' } }, + refresh: { secret: 'short', signOptions: { expiresIn: '7d' } }, + }); + + expect(emitWarningSpy).toHaveBeenCalledWith( + expect.stringContaining('JWT refresh token secret is shorter than 32'), + { code: 'ROCKETS_JWT_WEAK_SECRET' }, + ); + }); + + it('should warn when access and refresh secrets are identical', () => { + new JwtPolicy({ + access: { secret: strongSecret, signOptions: { expiresIn: '15m' } }, + refresh: { secret: strongSecret, signOptions: { expiresIn: '7d' } }, + }); + + expect(emitWarningSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'JWT access and refresh token secrets are identical', + ), + { code: 'ROCKETS_JWT_SHARED_SECRET' }, + ); + }); + + it('should not warn about shared secrets when both are undefined', () => { + new JwtPolicy({ + access: { signOptions: { expiresIn: '15m' } }, + refresh: { signOptions: { expiresIn: '7d' } }, + }); + + const sharedCalls = emitWarningSpy.mock.calls.filter( + ([, opts]) => opts?.code === 'ROCKETS_JWT_SHARED_SECRET', + ); + expect(sharedCalls).toHaveLength(0); + }); + + it('should warn when access expiresIn is not set', () => { + new JwtPolicy({ + access: { secret: strongSecret }, + refresh: { + secret: otherStrongSecret, + signOptions: { expiresIn: '7d' }, + }, + }); + + expect(emitWarningSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'JWT access token expiresIn is not set. Defaulting to 1h', + ), + { code: 'ROCKETS_JWT_NO_EXPIRY' }, + ); + }); + + it('should warn when refresh expiresIn is not set', () => { + new JwtPolicy({ + access: { secret: strongSecret, signOptions: { expiresIn: '15m' } }, + refresh: { secret: otherStrongSecret }, + }); + + expect(emitWarningSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'JWT refresh token expiresIn is not set. Defaulting to 24h', + ), + { code: 'ROCKETS_JWT_NO_EXPIRY' }, + ); + }); + + it('should not warn when secrets are strong and expiresIn is set', () => { + new JwtPolicy({ + access: { secret: strongSecret, signOptions: { expiresIn: '15m' } }, + refresh: { + secret: otherStrongSecret, + signOptions: { expiresIn: '7d' }, + }, + }); + + expect(emitWarningSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getAccessExpiry', () => { + const now = new Date('2026-01-01T00:00:00.000Z'); + + it('should default to 1 hour when expiresIn is not set', () => { + const policy = new JwtPolicy({ + access: { secret: strongSecret }, + refresh: { + secret: otherStrongSecret, + signOptions: { expiresIn: '7d' }, + }, + }); + + const expiry = policy.getAccessExpiry(now); + + expect(expiry.getTime() - now.getTime()).toBe(60 * 60 * 1000); + }); + + it('should multiply numeric expiresIn by 1000 (seconds)', () => { + const policy = new JwtPolicy({ + access: { secret: strongSecret, signOptions: { expiresIn: 900 } }, + refresh: { + secret: otherStrongSecret, + signOptions: { expiresIn: '7d' }, + }, + }); + + const expiry = policy.getAccessExpiry(now); + + expect(expiry.getTime() - now.getTime()).toBe(900 * 1000); + }); + + it('should parse string expiresIn via ms()', () => { + const policy = new JwtPolicy({ + access: { secret: strongSecret, signOptions: { expiresIn: '30m' } }, + refresh: { + secret: otherStrongSecret, + signOptions: { expiresIn: '7d' }, + }, + }); + + const expiry = policy.getAccessExpiry(now); + + expect(expiry.getTime() - now.getTime()).toBe(30 * 60 * 1000); + }); + }); + + describe('getRefreshExpiry', () => { + const now = new Date('2026-01-01T00:00:00.000Z'); + + it('should default to 24 hours when expiresIn is not set', () => { + const policy = new JwtPolicy({ + access: { secret: strongSecret, signOptions: { expiresIn: '15m' } }, + refresh: { secret: otherStrongSecret }, + }); + + const expiry = policy.getRefreshExpiry(now); + + expect(expiry.getTime() - now.getTime()).toBe(24 * 60 * 60 * 1000); + }); + + it('should multiply numeric expiresIn by 1000 (seconds)', () => { + const policy = new JwtPolicy({ + access: { secret: strongSecret, signOptions: { expiresIn: '15m' } }, + refresh: { + secret: otherStrongSecret, + signOptions: { expiresIn: 604800 }, + }, + }); + + const expiry = policy.getRefreshExpiry(now); + + expect(expiry.getTime() - now.getTime()).toBe(604800 * 1000); + }); + + it('should parse string expiresIn via ms()', () => { + const policy = new JwtPolicy({ + access: { secret: strongSecret, signOptions: { expiresIn: '15m' } }, + refresh: { + secret: otherStrongSecret, + signOptions: { expiresIn: '7d' }, + }, + }); + + const expiry = policy.getRefreshExpiry(now); + + expect(expiry.getTime() - now.getTime()).toBe(7 * 24 * 60 * 60 * 1000); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/policies/__tests__/local-strategy.policy.spec.ts b/packages/nestjs-authentication/src/domain/policies/__tests__/local-strategy.policy.spec.ts new file mode 100644 index 000000000..823685753 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/__tests__/local-strategy.policy.spec.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +import { LocalStrategyPolicy } from '../local-strategy.policy.js'; + +const mockLoginSchema = z.object({}); + +describe(LocalStrategyPolicy.name, () => { + it('should create with default values', () => { + const policy = new LocalStrategyPolicy({}); + + expect(policy.loginSchema).toBeUndefined(); + expect(policy.usernameField).toBe('username'); + expect(policy.passwordField).toBe('password'); + }); + + it('should create with provided settings', () => { + const policy = new LocalStrategyPolicy({ + loginSchema: mockLoginSchema, + usernameField: 'email', + passwordField: 'passcode', + }); + + expect(policy.loginSchema).toBe(mockLoginSchema); + expect(policy.usernameField).toBe('email'); + expect(policy.passwordField).toBe('passcode'); + }); + + it('should use defaults when optional fields omitted', () => { + const policy = new LocalStrategyPolicy({ + loginSchema: mockLoginSchema, + }); + + expect(policy.loginSchema).toBe(mockLoginSchema); + expect(policy.usernameField).toBe('username'); + expect(policy.passwordField).toBe('password'); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/policies/__tests__/recovery.policy.spec.ts b/packages/nestjs-authentication/src/domain/policies/__tests__/recovery.policy.spec.ts new file mode 100644 index 000000000..a4663c60e --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/__tests__/recovery.policy.spec.ts @@ -0,0 +1,42 @@ +import { + RecoveryPolicy, + type RecoveryPolicySettingsInterface, +} from '../recovery.policy.js'; + +describe(RecoveryPolicy.name, () => { + const defaultSettings: RecoveryPolicySettingsInterface = { + otp: { + category: 'auth-recovery', + namespace: 'userOtp', + type: 'uuid', + expiresIn: '24h', + }, + }; + + it('should create with all OTP settings', () => { + const policy = new RecoveryPolicy(defaultSettings); + + expect(policy.otpCategory).toBe('auth-recovery'); + expect(policy.otpNamespace).toBe('userOtp'); + expect(policy.otpType).toBe('uuid'); + expect(policy.otpExpiresIn).toBe('24h'); + expect(policy.otpDuplicateStrategy).toBeUndefined(); + expect(policy.otpRateSeconds).toBe(0); + expect(policy.otpRateThreshold).toBe(0); + }); + + it('should use provided OTP optional values', () => { + const policy = new RecoveryPolicy({ + otp: { + ...defaultSettings.otp, + duplicateStrategy: 'DEACTIVATE', + rateSeconds: 60, + rateThreshold: 5, + }, + }); + + expect(policy.otpDuplicateStrategy).toBe('DEACTIVATE'); + expect(policy.otpRateSeconds).toBe(60); + expect(policy.otpRateThreshold).toBe(5); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/policies/__tests__/refresh-strategy.policy.spec.ts b/packages/nestjs-authentication/src/domain/policies/__tests__/refresh-strategy.policy.spec.ts new file mode 100644 index 000000000..a8cfa7155 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/__tests__/refresh-strategy.policy.spec.ts @@ -0,0 +1,18 @@ +import { ExtractJwt } from 'passport-jwt'; + +import { RefreshStrategyPolicy } from '../refresh-strategy.policy.js'; + +describe(RefreshStrategyPolicy.name, () => { + it('should default jwtFromRequest to undefined', () => { + const policy = new RefreshStrategyPolicy({}); + + expect(policy.jwtFromRequest).toBeUndefined(); + }); + + it('should apply provided jwtFromRequest', () => { + const extractor = ExtractJwt.fromBodyField('refreshToken'); + const policy = new RefreshStrategyPolicy({ jwtFromRequest: extractor }); + + expect(policy.jwtFromRequest).toBe(extractor); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/policies/__tests__/verify.policy.spec.ts b/packages/nestjs-authentication/src/domain/policies/__tests__/verify.policy.spec.ts new file mode 100644 index 000000000..643b125c9 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/__tests__/verify.policy.spec.ts @@ -0,0 +1,42 @@ +import { + VerifyPolicy, + type VerifyPolicySettingsInterface, +} from '../verify.policy.js'; + +describe(VerifyPolicy.name, () => { + const defaultSettings: VerifyPolicySettingsInterface = { + otp: { + category: 'auth-verify', + namespace: 'userOtp', + type: 'uuid', + expiresIn: '48h', + }, + }; + + it('should create with all OTP settings', () => { + const policy = new VerifyPolicy(defaultSettings); + + expect(policy.otpCategory).toBe('auth-verify'); + expect(policy.otpNamespace).toBe('userOtp'); + expect(policy.otpType).toBe('uuid'); + expect(policy.otpExpiresIn).toBe('48h'); + expect(policy.otpDuplicateStrategy).toBeUndefined(); + expect(policy.otpRateSeconds).toBe(0); + expect(policy.otpRateThreshold).toBe(0); + }); + + it('should use provided OTP optional values', () => { + const policy = new VerifyPolicy({ + otp: { + ...defaultSettings.otp, + duplicateStrategy: 'DEACTIVATE', + rateSeconds: 30, + rateThreshold: 3, + }, + }); + + expect(policy.otpDuplicateStrategy).toBe('DEACTIVATE'); + expect(policy.otpRateSeconds).toBe(30); + expect(policy.otpRateThreshold).toBe(3); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/policies/guards.policy.ts b/packages/nestjs-authentication/src/domain/policies/guards.policy.ts new file mode 100644 index 000000000..cfe97720f --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/guards.policy.ts @@ -0,0 +1,22 @@ +import { type CanActivate, type ExecutionContext } from '@nestjs/common'; + +export interface GuardsPolicySettingsInterface { + enable?: boolean; + disable?: ( + context: ExecutionContext, + guard: T, + ) => boolean; +} + +export class GuardsPolicy { + readonly enable: boolean; + readonly disable: ( + context: ExecutionContext, + guard: T, + ) => boolean; + + constructor(settings?: GuardsPolicySettingsInterface) { + this.enable = settings?.enable ?? true; + this.disable = settings?.disable ?? (() => false); + } +} diff --git a/packages/nestjs-authentication/src/domain/policies/jwt-strategy.policy.ts b/packages/nestjs-authentication/src/domain/policies/jwt-strategy.policy.ts new file mode 100644 index 000000000..7d99f134e --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/jwt-strategy.policy.ts @@ -0,0 +1,16 @@ +import { type JwtFromRequestFunction } from 'passport-jwt'; + +export interface JwtStrategyPolicySettingsInterface { + jwtFromRequest?: JwtFromRequestFunction; + requireUserValidation?: boolean; +} + +export class JwtStrategyPolicy { + readonly jwtFromRequest: JwtFromRequestFunction | undefined; + readonly requireUserValidation: boolean; + + constructor(settings: JwtStrategyPolicySettingsInterface) { + this.jwtFromRequest = settings.jwtFromRequest; + this.requireUserValidation = settings.requireUserValidation ?? false; + } +} diff --git a/packages/nestjs-authentication/src/domain/policies/jwt.policy.ts b/packages/nestjs-authentication/src/domain/policies/jwt.policy.ts new file mode 100644 index 000000000..bb99e8575 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/jwt.policy.ts @@ -0,0 +1,84 @@ +import ms from 'ms'; + +import { type TokenOptionsInterface } from '../interfaces/token-options.interface.js'; + +export interface JwtPolicySettingsInterface { + access?: TokenOptionsInterface; + refresh?: TokenOptionsInterface; +} + +export class JwtPolicy { + readonly access: TokenOptionsInterface; + readonly refresh: TokenOptionsInterface; + + constructor(settings: JwtPolicySettingsInterface) { + const { access = {}, refresh = {} } = settings; + + this.access = access; + this.refresh = refresh; + + const minLength = 32; + const { secret: accessSecret } = access; + const { secret: refreshSecret } = refresh; + + if (typeof accessSecret === 'string' && accessSecret.length < minLength) { + process.emitWarning( + `JWT access token secret is shorter than ${minLength} characters. Use at least ${minLength} characters for HS256.`, + { code: 'ROCKETS_JWT_WEAK_SECRET' }, + ); + } + + if (typeof refreshSecret === 'string' && refreshSecret.length < minLength) { + process.emitWarning( + `JWT refresh token secret is shorter than ${minLength} characters. Use at least ${minLength} characters for HS256.`, + { code: 'ROCKETS_JWT_WEAK_SECRET' }, + ); + } + + if (accessSecret !== undefined && accessSecret === refreshSecret) { + process.emitWarning( + 'JWT access and refresh token secrets are identical. Use separate secrets to prevent token type confusion attacks.', + { code: 'ROCKETS_JWT_SHARED_SECRET' }, + ); + } + + if (!access.signOptions?.expiresIn) { + process.emitWarning( + 'JWT access token expiresIn is not set. Defaulting to 1h. Set signOptions.expiresIn explicitly (e.g. "15m").', + { code: 'ROCKETS_JWT_NO_EXPIRY' }, + ); + } + + if (!refresh.signOptions?.expiresIn) { + process.emitWarning( + 'JWT refresh token expiresIn is not set. Defaulting to 24h. Set signOptions.expiresIn explicitly (e.g. "7d").', + { code: 'ROCKETS_JWT_NO_EXPIRY' }, + ); + } + } + + getAccessExpiry(from: Date = new Date()): Date { + return this.computeExpiry(this.access, 60 * 60 * 1000, from); + } + + getRefreshExpiry(from: Date = new Date()): Date { + return this.computeExpiry(this.refresh, 24 * 60 * 60 * 1000, from); + } + + private computeExpiry( + options: TokenOptionsInterface, + defaultTtlMs: number, + from: Date, + ): Date { + const expiresIn = options.signOptions?.expiresIn; + let ttlMs: number; + if (typeof expiresIn === 'number') { + ttlMs = expiresIn * 1000; + } else if (expiresIn !== undefined) { + ttlMs = ms(expiresIn); + } else { + ttlMs = defaultTtlMs; + } + return new Date(from.getTime() + ttlMs); + } +} diff --git a/packages/nestjs-authentication/src/domain/policies/local-strategy.policy.ts b/packages/nestjs-authentication/src/domain/policies/local-strategy.policy.ts new file mode 100644 index 000000000..5881246ec --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/local-strategy.policy.ts @@ -0,0 +1,25 @@ +import { type StandardSchemaV1 } from '@standard-schema/spec'; + +export interface LocalStrategyPolicySettingsInterface { + loginSchema?: StandardSchemaV1; + usernameField?: string; + passwordField?: string; +} + +export class LocalStrategyPolicy { + readonly loginSchema: StandardSchemaV1 | undefined; + readonly usernameField: string; + readonly passwordField: string; + + constructor(settings: LocalStrategyPolicySettingsInterface) { + const { + loginSchema, + usernameField = 'username', + passwordField = 'password', + } = settings; + + this.loginSchema = loginSchema; + this.usernameField = usernameField; + this.passwordField = passwordField; + } +} diff --git a/packages/nestjs-authentication/src/domain/policies/otp.policy.ts b/packages/nestjs-authentication/src/domain/policies/otp.policy.ts new file mode 100644 index 000000000..e73a2892d --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/otp.policy.ts @@ -0,0 +1,41 @@ +export interface OtpPolicySettingsInterface { + otp: { + category: string; + namespace: string; + type: string; + expiresIn: string; + duplicateStrategy?: 'ALLOW' | 'DEACTIVATE'; + rateSeconds?: number; + rateThreshold?: number; + }; +} + +export class OtpPolicy { + readonly otpCategory: string; + readonly otpNamespace: string; + readonly otpType: string; + readonly otpExpiresIn: string; + readonly otpDuplicateStrategy?: 'ALLOW' | 'DEACTIVATE'; + readonly otpRateSeconds: number; + readonly otpRateThreshold: number; + + constructor(settings: OtpPolicySettingsInterface) { + const { otp } = settings; + + this.otpCategory = otp.category; + this.otpNamespace = otp.namespace; + this.otpType = otp.type; + this.otpExpiresIn = otp.expiresIn; + this.otpDuplicateStrategy = otp.duplicateStrategy; + this.otpRateSeconds = otp.rateSeconds ?? 0; + this.otpRateThreshold = otp.rateThreshold ?? 0; + + if (this.otpRateSeconds === 0 && this.otpRateThreshold === 0) { + process.emitWarning( + `OTP rate limiting is disabled for category "${this.otpCategory}". ` + + 'Set rateSeconds and rateThreshold to prevent brute-force attacks.', + { code: 'ROCKETS_OTP_NO_RATE_LIMIT' }, + ); + } + } +} diff --git a/packages/nestjs-authentication/src/domain/policies/recovery.policy.ts b/packages/nestjs-authentication/src/domain/policies/recovery.policy.ts new file mode 100644 index 000000000..7df3c494e --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/recovery.policy.ts @@ -0,0 +1,9 @@ +import { OtpPolicy, type OtpPolicySettingsInterface } from './otp.policy.js'; + +export interface RecoveryPolicySettingsInterface extends OtpPolicySettingsInterface {} + +export class RecoveryPolicy extends OtpPolicy { + constructor(settings: RecoveryPolicySettingsInterface) { + super(settings); + } +} diff --git a/packages/nestjs-authentication/src/domain/policies/refresh-strategy.policy.ts b/packages/nestjs-authentication/src/domain/policies/refresh-strategy.policy.ts new file mode 100644 index 000000000..15617824d --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/refresh-strategy.policy.ts @@ -0,0 +1,13 @@ +import { type JwtFromRequestFunction } from 'passport-jwt'; + +export interface RefreshStrategyPolicySettingsInterface { + jwtFromRequest?: JwtFromRequestFunction; +} + +export class RefreshStrategyPolicy { + readonly jwtFromRequest: JwtFromRequestFunction | undefined; + + constructor(settings: RefreshStrategyPolicySettingsInterface) { + this.jwtFromRequest = settings.jwtFromRequest; + } +} diff --git a/packages/nestjs-authentication/src/domain/policies/verify.policy.ts b/packages/nestjs-authentication/src/domain/policies/verify.policy.ts new file mode 100644 index 000000000..f29b9b48a --- /dev/null +++ b/packages/nestjs-authentication/src/domain/policies/verify.policy.ts @@ -0,0 +1,9 @@ +import { OtpPolicy, type OtpPolicySettingsInterface } from './otp.policy.js'; + +export interface VerifyPolicySettingsInterface extends OtpPolicySettingsInterface {} + +export class VerifyPolicy extends OtpPolicy { + constructor(settings: VerifyPolicySettingsInterface) { + super(settings); + } +} diff --git a/packages/nestjs-authentication/src/domain/ports/__tests__/jwt.port.spec.ts b/packages/nestjs-authentication/src/domain/ports/__tests__/jwt.port.spec.ts new file mode 100644 index 000000000..07c740e16 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/__tests__/jwt.port.spec.ts @@ -0,0 +1,169 @@ +import { mock } from 'vitest-mock-extended'; + +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command, type CommandBus, Query, type QueryBus } from '@nestjs/cqrs'; + +import { Token } from '../../../domain/aggregates/token.aggregate.js'; +import { + JwtPort, + type JwtPortSettings, + type JwtVerifyTokenQueryInterface, + type SignTokenCommandInterface, +} from '../jwt.port.js'; + +const makeToken = (): Token => + new Token('test-jti', { + sub: 'user-1', + type: 'access', + scope: [], + iat: new Date(), + exp: new Date(Date.now() + 3_600_000), + }); + +class MockSignAccessTokenCommand + extends Command + implements SignTokenCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: Token, + ) { + super(); + } +} + +class MockSignRefreshTokenCommand + extends Command + implements SignTokenCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: Token, + ) { + super(); + } +} + +class MockVerifyAccessTokenQuery + extends Query + implements JwtVerifyTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: string, + ) { + super(); + } +} + +class MockVerifyRefreshTokenQuery + extends Query + implements JwtVerifyTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: string, + ) { + super(); + } +} + +describe(JwtPort.name, () => { + let port: JwtPort; + let commandBus: CommandBus; + let queryBus: QueryBus; + + const portSettings: JwtPortSettings = { + signAccessTokenCommand: MockSignAccessTokenCommand, + signRefreshTokenCommand: MockSignRefreshTokenCommand, + verifyAccessTokenQuery: MockVerifyAccessTokenQuery, + verifyRefreshTokenQuery: MockVerifyRefreshTokenQuery, + }; + + beforeEach(() => { + commandBus = mock(); + queryBus = mock(); + port = new JwtPort(portSettings, commandBus, queryBus); + }); + + describe('signAccessToken', () => { + it('should dispatch SignAccessTokenCommand via commandBus', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue('signed-access-jwt'); + + const result = await port.signAccessToken({}, makeToken()); + + expect(result).toBe('signed-access-jwt'); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockSignAccessTokenCommand), + ); + }); + + it('should forward token to command', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue('token'); + const token = makeToken(); + + await port.signAccessToken({}, token); + + expect(commandBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ token }), + ); + }); + }); + + describe('signRefreshToken', () => { + it('should dispatch SignRefreshTokenCommand via commandBus', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue('signed-refresh-jwt'); + + const result = await port.signRefreshToken({}, makeToken()); + + expect(result).toBe('signed-refresh-jwt'); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockSignRefreshTokenCommand), + ); + }); + }); + + describe('verifyAccessToken', () => { + it('should dispatch VerifyAccessTokenQuery via queryBus', async () => { + const decoded = { sub: 'user-1', iat: 123 }; + void queryBus.execute; + vi.spyOn(queryBus, 'execute').mockResolvedValue(decoded); + + const result = await port.verifyAccessToken({}, 'jwt-token'); + + expect(result).toEqual(decoded); + expect(queryBus.execute).toHaveBeenCalledWith( + expect.any(MockVerifyAccessTokenQuery), + ); + }); + + it('should forward token string to query', async () => { + void queryBus.execute; + vi.spyOn(queryBus, 'execute').mockResolvedValue({}); + + await port.verifyAccessToken({}, 'my-jwt'); + + expect(queryBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ token: 'my-jwt' }), + ); + }); + }); + + describe('verifyRefreshToken', () => { + it('should dispatch VerifyRefreshTokenQuery via queryBus', async () => { + const decoded = { sub: 'user-1', iat: 456 }; + void queryBus.execute; + vi.spyOn(queryBus, 'execute').mockResolvedValue(decoded); + + const result = await port.verifyRefreshToken({}, 'refresh-jwt'); + + expect(result).toEqual(decoded); + expect(queryBus.execute).toHaveBeenCalledWith( + expect.any(MockVerifyRefreshTokenQuery), + ); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/ports/__tests__/otp.port.spec.ts b/packages/nestjs-authentication/src/domain/ports/__tests__/otp.port.spec.ts new file mode 100644 index 000000000..ac1d6389a --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/__tests__/otp.port.spec.ts @@ -0,0 +1,173 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command, CommandBus, Query, QueryBus } from '@nestjs/cqrs'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { type AssigneeRelationInterface } from '@concepta/nestjs-core'; + +import { + OtpPort, + type OtpPortSettings, + type OtpCreateOptions, + type AuthenticationOtpCreatableInterface, + type AuthenticationOtpInterface, + type CreateOtpCommandInterface, + type ValidateOtpQueryInterface, + type ClearOtpCommandInterface, +} from '../otp.port.js'; + +class MockCreateOtpCommand + extends Command + implements CreateOtpCommandInterface +{ + duplicateStrategy?: 'ALLOW' | 'DEACTIVATE'; + rateSeconds?: number; + rateThreshold?: number; + + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly otp: AuthenticationOtpCreatableInterface, + options?: OtpCreateOptions, + ) { + super(); + this.duplicateStrategy = options?.duplicateStrategy; + this.rateSeconds = options?.rateSeconds; + this.rateThreshold = options?.rateThreshold; + } +} + +class MockValidateOtpQuery + extends Query + implements ValidateOtpQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly otp: Pick< + AuthenticationOtpInterface, + 'category' | 'passcode' + >, + ) { + super(); + } +} + +class MockClearOtpCommand + extends Command + implements ClearOtpCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly otp: Pick< + AuthenticationOtpInterface, + 'category' | 'assigneeId' + >, + ) { + super(); + } +} + +describe(OtpPort.name, () => { + let port: OtpPort; + let commandBus: CommandBus; + let queryBus: QueryBus; + + const portSettings: OtpPortSettings = { + createCommand: MockCreateOtpCommand, + validateQuery: MockValidateOtpQuery, + clearCommand: MockClearOtpCommand, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: CommandBus, + useValue: { execute: vi.fn() }, + }, + { + provide: QueryBus, + useValue: { execute: vi.fn() }, + }, + ], + }).compile(); + + commandBus = module.get(CommandBus); + queryBus = module.get(QueryBus); + port = new OtpPort(portSettings, commandBus, queryBus); + }); + + describe('create', () => { + it('should dispatch CreateOtpCommand via commandBus', async () => { + const mockOtp = { + category: 'test', + type: 'uuid', + passcode: '123456', + expirationDate: new Date(), + active: true, + assigneeId: 'user-1', + }; + vi.spyOn(commandBus, 'execute').mockResolvedValue(mockOtp); + + const otp: AuthenticationOtpCreatableInterface = { + category: 'test', + type: 'uuid', + assigneeId: 'user-1', + expiresIn: '24h', + }; + + const result = await port.create({}, 'userOtp', otp, { + duplicateStrategy: 'DEACTIVATE', + }); + + expect(result).toEqual(mockOtp); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockCreateOtpCommand), + ); + }); + }); + + describe('validate', () => { + it('should dispatch ValidateOtpQuery via queryBus', async () => { + const mockAssignee = { assigneeId: 'user-1' }; + vi.spyOn(queryBus, 'execute').mockResolvedValue(mockAssignee); + + const result = await port.validate({}, 'userOtp', { + category: 'test', + passcode: '123456', + }); + + expect(result).toEqual(mockAssignee); + expect(queryBus.execute).toHaveBeenCalledWith( + expect.any(MockValidateOtpQuery), + ); + }); + + it('should return null when OTP not found', async () => { + vi.spyOn(queryBus, 'execute').mockResolvedValue(null); + + const result = await port.validate({}, 'userOtp', { + category: 'test', + passcode: 'invalid', + }); + + expect(result).toBeNull(); + }); + }); + + describe('clear', () => { + it('should dispatch ClearOtpCommand via commandBus', async () => { + vi.spyOn(commandBus, 'execute').mockResolvedValue(undefined); + + await port.clear({}, 'userOtp', { + category: 'test', + assigneeId: 'user-1', + }); + + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockClearOtpCommand), + ); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/ports/__tests__/password.port.spec.ts b/packages/nestjs-authentication/src/domain/ports/__tests__/password.port.spec.ts new file mode 100644 index 000000000..d892229f6 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/__tests__/password.port.spec.ts @@ -0,0 +1,110 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command, CommandBus } from '@nestjs/cqrs'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { + type ReferenceId, + type ReferenceIdInterface, +} from '@concepta/nestjs-core'; + +import { + PasswordPort, + type PasswordPortSettings, + type SetPasswordCommandInterface, + type ValidatePasswordCommandInterface, +} from '../password.port.js'; + +class MockValidatePasswordCommand + extends Command + implements ValidatePasswordCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly password: string, + public readonly target: ReferenceIdInterface, + ) { + super(); + } +} + +class MockSetPasswordCommand + extends Command + implements SetPasswordCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly password: string, + public readonly assigneeId: ReferenceId, + ) { + super(); + } +} + +describe(PasswordPort.name, () => { + let port: PasswordPort; + let commandBus: CommandBus; + + const portSettings: PasswordPortSettings = { + validateCommand: MockValidatePasswordCommand, + setPasswordCommand: MockSetPasswordCommand, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [{ provide: CommandBus, useValue: { execute: vi.fn() } }], + }).compile(); + + commandBus = module.get(CommandBus); + port = new PasswordPort(portSettings, commandBus); + }); + + describe('validate', () => { + it('should dispatch ValidatePasswordCommand via commandBus', async () => { + const target: ReferenceIdInterface = { id: 'user-1' }; + vi.spyOn(commandBus, 'execute').mockResolvedValue(true); + + const result = await port.validate({}, 'my-password', target); + + expect(result).toBe(true); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockValidatePasswordCommand), + ); + }); + + it('should forward password and target to command', async () => { + const target: ReferenceIdInterface = { id: 'user-1' }; + vi.spyOn(commandBus, 'execute').mockResolvedValue(false); + + await port.validate({}, 'secret', target); + + expect(commandBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ password: 'secret', target }), + ); + }); + }); + + describe('setPassword', () => { + it('should dispatch SetPasswordCommand via commandBus', async () => { + vi.spyOn(commandBus, 'execute').mockResolvedValue(undefined); + + await port.setPassword({}, 'new-password', 'user-1'); + + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockSetPasswordCommand), + ); + }); + + it('should forward password and assigneeId to command', async () => { + vi.spyOn(commandBus, 'execute').mockResolvedValue(undefined); + + await port.setPassword({}, 'new-pass', 'user-42'); + + expect(commandBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ + password: 'new-pass', + assigneeId: 'user-42', + }), + ); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/ports/__tests__/recovery-notification.port.spec.ts b/packages/nestjs-authentication/src/domain/ports/__tests__/recovery-notification.port.spec.ts new file mode 100644 index 000000000..43dd1acd6 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/__tests__/recovery-notification.port.spec.ts @@ -0,0 +1,254 @@ +import { mock } from 'vitest-mock-extended'; + +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command, type CommandBus, type EventBus } from '@nestjs/cqrs'; + +import { NotificationSendFailedEvent } from '../../events/notification-send-failed.event.js'; +import { AuthenticationEmailException } from '../../exceptions/authentication-email.exception.js'; +import { + RecoveryNotificationPort, + type RecoveryNotificationPortSettings, + type SendPasswordUpdatedNotificationCommandInterface, + type SendRecoverLoginNotificationCommandInterface, + type SendRecoverPasswordNotificationCommandInterface, +} from '../recovery-notification.port.js'; + +class MockSendRecoverLoginCommand + extends Command + implements SendRecoverLoginNotificationCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly email: string, + public readonly username: string, + ) { + super(); + } +} + +class MockSendRecoverPasswordCommand + extends Command + implements SendRecoverPasswordNotificationCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly email: string, + public readonly passcode: string, + public readonly tokenExp: Date, + ) { + super(); + } +} + +class MockSendPasswordUpdatedCommand + extends Command + implements SendPasswordUpdatedNotificationCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly email: string, + ) { + super(); + } +} + +const ctx = { requestId: 'req-1' }; +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +describe(RecoveryNotificationPort.name, () => { + let port: RecoveryNotificationPort; + let commandBus: CommandBus; + let eventBus: EventBus; + + const portSettings: RecoveryNotificationPortSettings = { + sendRecoverLoginNotificationCommand: MockSendRecoverLoginCommand, + sendRecoverPasswordNotificationCommand: MockSendRecoverPasswordCommand, + sendPasswordUpdatedNotificationCommand: MockSendPasswordUpdatedCommand, + }; + + beforeEach(() => { + commandBus = mock(); + eventBus = mock(); + port = new RecoveryNotificationPort(portSettings, commandBus, eventBus); + }); + + describe('sendRecoverLogin', () => { + it('should dispatch command via commandBus', () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue(undefined); + + port.sendRecoverLogin(ctx, 'me@mail.com', 'username'); + + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockSendRecoverLoginCommand), + ); + }); + + it('should not publish when the command succeeds', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue(undefined); + + port.sendRecoverLogin(ctx, 'me@mail.com', 'username'); + + await flush(); + + expect(eventBus.publish).not.toHaveBeenCalled(); + }); + + it('should not leave an unhandled rejection when the command fails', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockRejectedValue( + new Error('send failed'), + ); + + port.sendRecoverLogin(ctx, 'me@mail.com', 'username'); + + await expect(flush()).resolves.toBeUndefined(); + }); + + it('should publish a NotificationSendFailedEvent when the command fails', async () => { + void commandBus.execute; + const error = new Error('send failed'); + vi.spyOn(commandBus, 'execute').mockRejectedValue(error); + + port.sendRecoverLogin(ctx, 'me@mail.com', 'username'); + + await flush(); + + expect(eventBus.publish).toHaveBeenCalledTimes(1); + const published = vi.mocked(eventBus.publish).mock + .calls[0][0] as NotificationSendFailedEvent; + expect(published).toBeInstanceOf(NotificationSendFailedEvent); + expect(published.ctx).toBe(ctx); + expect(published.email).toBe('me@mail.com'); + expect(published.command).toBe(MockSendRecoverLoginCommand); + expect(published.error).toBeInstanceOf(AuthenticationEmailException); + expect(published.error.context.originalError).toBe(error); + }); + + it('should not leave an unhandled rejection when eventBus.publish rejects', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockRejectedValue( + new Error('send failed'), + ); + vi.mocked(eventBus.publish).mockRejectedValue( + new Error('publish also failed'), + ); + + port.sendRecoverLogin(ctx, 'me@mail.com', 'username'); + + await expect(flush()).resolves.toBeUndefined(); + }); + + it('should not leave an unhandled rejection when eventBus.publish throws synchronously', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockRejectedValue( + new Error('send failed'), + ); + vi.mocked(eventBus.publish).mockImplementation(() => { + throw new Error('publish threw synchronously'); + }); + + port.sendRecoverLogin(ctx, 'me@mail.com', 'username'); + + await expect(flush()).resolves.toBeUndefined(); + }); + }); + + describe('sendRecoverPassword', () => { + it('should dispatch command via commandBus', () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue(undefined); + + port.sendRecoverPassword(ctx, 'me@mail.com', { + passcode: 'abc123', + tokenExp: new Date(), + }); + + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockSendRecoverPasswordCommand), + ); + }); + + it('should not leave an unhandled rejection when the command fails', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockRejectedValue( + new Error('send failed'), + ); + + port.sendRecoverPassword(ctx, 'me@mail.com', { + passcode: 'abc123', + tokenExp: new Date(), + }); + + await expect(flush()).resolves.toBeUndefined(); + }); + + it('should publish a NotificationSendFailedEvent when the command fails', async () => { + void commandBus.execute; + const error = new Error('send failed'); + vi.spyOn(commandBus, 'execute').mockRejectedValue(error); + + port.sendRecoverPassword(ctx, 'me@mail.com', { + passcode: 'abc123', + tokenExp: new Date(), + }); + + await flush(); + + expect(eventBus.publish).toHaveBeenCalledTimes(1); + const published = vi.mocked(eventBus.publish).mock + .calls[0][0] as NotificationSendFailedEvent; + expect(published).toBeInstanceOf(NotificationSendFailedEvent); + expect(published.ctx).toBe(ctx); + expect(published.email).toBe('me@mail.com'); + expect(published.command).toBe(MockSendRecoverPasswordCommand); + expect(published.error).toBeInstanceOf(AuthenticationEmailException); + expect(published.error.context.originalError).toBe(error); + }); + }); + + describe('sendPasswordUpdated', () => { + it('should dispatch command via commandBus', () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue(undefined); + + port.sendPasswordUpdated(ctx, 'me@mail.com'); + + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockSendPasswordUpdatedCommand), + ); + }); + + it('should not leave an unhandled rejection when the command fails', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockRejectedValue( + new Error('send failed'), + ); + + port.sendPasswordUpdated(ctx, 'me@mail.com'); + + await expect(flush()).resolves.toBeUndefined(); + }); + + it('should publish a NotificationSendFailedEvent when the command fails', async () => { + void commandBus.execute; + const error = new Error('send failed'); + vi.spyOn(commandBus, 'execute').mockRejectedValue(error); + + port.sendPasswordUpdated(ctx, 'me@mail.com'); + + await flush(); + + expect(eventBus.publish).toHaveBeenCalledTimes(1); + const published = vi.mocked(eventBus.publish).mock + .calls[0][0] as NotificationSendFailedEvent; + expect(published).toBeInstanceOf(NotificationSendFailedEvent); + expect(published.ctx).toBe(ctx); + expect(published.email).toBe('me@mail.com'); + expect(published.command).toBe(MockSendPasswordUpdatedCommand); + expect(published.error).toBeInstanceOf(AuthenticationEmailException); + expect(published.error.context.originalError).toBe(error); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/ports/__tests__/token.port.spec.ts b/packages/nestjs-authentication/src/domain/ports/__tests__/token.port.spec.ts new file mode 100644 index 000000000..3993059c2 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/__tests__/token.port.spec.ts @@ -0,0 +1,175 @@ +import { mock } from 'vitest-mock-extended'; + +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command, type CommandBus, Query, type QueryBus } from '@nestjs/cqrs'; + +import { type AuthorizationPayloadInterface } from '../../../domain/interfaces/authorization-payload.interface.js'; +import { + TokenPort, + type TokenPortSettings, + type IssueTokenCommandInterface, + type ValidateTokenQueryInterface, + type VerifyTokenQueryInterface, +} from '../token.port.js'; + +class MockIssueAccessTokenCommand + extends Command + implements IssueTokenCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly payload: AuthorizationPayloadInterface, + ) { + super(); + } +} + +class MockIssueRefreshTokenCommand + extends Command + implements IssueTokenCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly payload: AuthorizationPayloadInterface, + ) { + super(); + } +} + +class MockVerifyAccessTokenQuery + extends Query + implements VerifyTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: string, + ) { + super(); + } +} + +class MockVerifyRefreshTokenQuery + extends Query + implements VerifyTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly token: string, + ) { + super(); + } +} + +class MockValidateTokenQuery + extends Query + implements ValidateTokenQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly payload: PlainLiteralObject, + ) { + super(); + } +} + +describe(TokenPort.name, () => { + let port: TokenPort; + let commandBus: CommandBus; + let queryBus: QueryBus; + + const portSettings: TokenPortSettings = { + issueAccessTokenCommand: MockIssueAccessTokenCommand, + issueRefreshTokenCommand: MockIssueRefreshTokenCommand, + verifyAccessTokenQuery: MockVerifyAccessTokenQuery, + verifyRefreshTokenQuery: MockVerifyRefreshTokenQuery, + validateTokenQuery: MockValidateTokenQuery, + }; + + beforeEach(() => { + commandBus = mock(); + queryBus = mock(); + port = new TokenPort(portSettings, commandBus, queryBus); + }); + + describe('issueAccessToken', () => { + it('should dispatch IssueAccessTokenCommand via commandBus', async () => { + const expectedToken = 'access-token-123'; + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue(expectedToken); + + const result = await port.issueAccessToken({}, { sub: 'user-1' }); + + expect(result).toBe(expectedToken); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ payload: { sub: 'user-1' } }), + ); + }); + + it('should create correct command type', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue('token'); + + await port.issueAccessToken({}, { sub: 'user-1' }); + + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockIssueAccessTokenCommand), + ); + }); + }); + + describe('issueRefreshToken', () => { + it('should dispatch IssueRefreshTokenCommand via commandBus', async () => { + const expectedToken = 'refresh-token-123'; + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue(expectedToken); + + const result = await port.issueRefreshToken({}, { sub: 'user-1' }); + + expect(result).toBe(expectedToken); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockIssueRefreshTokenCommand), + ); + }); + }); + + describe('verifyAccessToken', () => { + it('should dispatch VerifyAccessTokenQuery via queryBus', async () => { + const decoded = { sub: 'user-1', iat: 123 }; + void queryBus.execute; + vi.spyOn(queryBus, 'execute').mockResolvedValue(decoded); + + const result = await port.verifyAccessToken({}, 'jwt-token'); + + expect(result).toEqual(decoded); + expect(queryBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ token: 'jwt-token' }), + ); + }); + + it('should create correct query type', async () => { + void queryBus.execute; + vi.spyOn(queryBus, 'execute').mockResolvedValue({}); + + await port.verifyAccessToken({}, 'jwt-token'); + + expect(queryBus.execute).toHaveBeenCalledWith( + expect.any(MockVerifyAccessTokenQuery), + ); + }); + }); + + describe('verifyRefreshToken', () => { + it('should dispatch VerifyRefreshTokenQuery via queryBus', async () => { + const decoded = { sub: 'user-1', iat: 456 }; + void queryBus.execute; + vi.spyOn(queryBus, 'execute').mockResolvedValue(decoded); + + const result = await port.verifyRefreshToken({}, 'refresh-jwt'); + + expect(result).toEqual(decoded); + expect(queryBus.execute).toHaveBeenCalledWith( + expect.any(MockVerifyRefreshTokenQuery), + ); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/ports/__tests__/user.port.spec.ts b/packages/nestjs-authentication/src/domain/ports/__tests__/user.port.spec.ts new file mode 100644 index 000000000..4f433e466 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/__tests__/user.port.spec.ts @@ -0,0 +1,174 @@ +import { mock } from 'vitest-mock-extended'; + +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command, type CommandBus, Query, type QueryBus } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { + type AuthenticationUserInterface, + UserPort, + type UserPortSettings, + type AuthenticationUserResult, + type GetUserByIdQueryInterface, + type GetUserBySubjectQueryInterface, + type GetUserByUsernameQueryInterface, + type GetUserByEmailQueryInterface, + type UpdateUserCommandInterface, +} from '../user.port.js'; + +class MockGetByIdQuery + extends Query + implements GetUserByIdQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly id: string, + ) { + super(); + } +} + +class MockGetBySubjectQuery + extends Query + implements GetUserBySubjectQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly subject: string, + ) { + super(); + } +} + +class MockGetByUsernameQuery + extends Query + implements GetUserByUsernameQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly username: string, + ) { + super(); + } +} + +class MockGetByEmailQuery + extends Query + implements GetUserByEmailQueryInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly email: string, + ) { + super(); + } +} + +class MockUpdateCommand + extends Command + implements UpdateUserCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly id: ReferenceId, + public readonly dto: Partial, + ) { + super(); + } +} + +describe(UserPort.name, () => { + let port: UserPort; + let queryBus: QueryBus; + let commandBus: CommandBus; + + const portSettings: UserPortSettings = { + getByIdQuery: MockGetByIdQuery, + getBySubjectQuery: MockGetBySubjectQuery, + getByUsernameQuery: MockGetByUsernameQuery, + getByEmailQuery: MockGetByEmailQuery, + updateCommand: MockUpdateCommand, + }; + + const mockUser: AuthenticationUserResult = { + id: 'user-1', + email: 'test@example.com', + username: 'testuser', + active: true, + }; + + beforeEach(() => { + queryBus = mock(); + commandBus = mock(); + port = new UserPort(portSettings, queryBus, commandBus); + }); + + describe('getById', () => { + it('should dispatch GetByIdQuery via queryBus', async () => { + void queryBus.execute; + vi.spyOn(queryBus, 'execute').mockResolvedValue(mockUser); + + const result = await port.getById({}, 'user-1'); + + expect(result).toEqual(mockUser); + expect(queryBus.execute).toHaveBeenCalledWith( + expect.any(MockGetByIdQuery), + ); + expect(queryBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-1' }), + ); + }); + + it('should return null when user not found', async () => { + void queryBus.execute; + vi.spyOn(queryBus, 'execute').mockResolvedValue(null); + + const result = await port.getById({}, 'unknown'); + + expect(result).toBeNull(); + }); + }); + + describe('getBySubject', () => { + it('should dispatch GetBySubjectQuery via queryBus', async () => { + void queryBus.execute; + vi.spyOn(queryBus, 'execute').mockResolvedValue(mockUser); + + const result = await port.getBySubject({}, 'user-1'); + + expect(result).toEqual(mockUser); + expect(queryBus.execute).toHaveBeenCalledWith( + expect.any(MockGetBySubjectQuery), + ); + }); + }); + + describe('getByUsername', () => { + it('should dispatch GetByUsernameQuery via queryBus', async () => { + void queryBus.execute; + vi.spyOn(queryBus, 'execute').mockResolvedValue(mockUser); + + const result = await port.getByUsername({}, 'testuser'); + + expect(result).toEqual(mockUser); + expect(queryBus.execute).toHaveBeenCalledWith( + expect.any(MockGetByUsernameQuery), + ); + }); + }); + + describe('getByEmail', () => { + it('should dispatch GetByEmailQuery via queryBus', async () => { + void queryBus.execute; + vi.spyOn(queryBus, 'execute').mockResolvedValue(mockUser); + + const result = await port.getByEmail({}, 'test@example.com'); + + expect(result).toEqual(mockUser); + expect(queryBus.execute).toHaveBeenCalledWith( + expect.any(MockGetByEmailQuery), + ); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/ports/__tests__/verify-notification.port.spec.ts b/packages/nestjs-authentication/src/domain/ports/__tests__/verify-notification.port.spec.ts new file mode 100644 index 000000000..fef974033 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/__tests__/verify-notification.port.spec.ts @@ -0,0 +1,146 @@ +import { mock } from 'vitest-mock-extended'; + +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command, type CommandBus, type EventBus } from '@nestjs/cqrs'; + +import { NotificationSendFailedEvent } from '../../events/notification-send-failed.event.js'; +import { AuthenticationEmailException } from '../../exceptions/authentication-email.exception.js'; +import { + type SendVerifyNotificationCommandInterface, + VerifyNotificationPort, + type VerifyNotificationPortSettings, +} from '../verify-notification.port.js'; + +class MockSendVerifyNotificationCommand + extends Command + implements SendVerifyNotificationCommandInterface +{ + constructor( + public readonly ctx: PlainLiteralObject, + public readonly email: string, + public readonly passcode: string, + public readonly tokenExp: Date, + ) { + super(); + } +} + +const ctx = { requestId: 'req-1' }; +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +describe(VerifyNotificationPort.name, () => { + let port: VerifyNotificationPort; + let commandBus: CommandBus; + let eventBus: EventBus; + + const portSettings: VerifyNotificationPortSettings = { + sendVerifyNotificationCommand: MockSendVerifyNotificationCommand, + }; + + beforeEach(() => { + commandBus = mock(); + eventBus = mock(); + port = new VerifyNotificationPort(portSettings, commandBus, eventBus); + }); + + describe('sendVerify', () => { + it('should dispatch command via commandBus', () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue(undefined); + + port.sendVerify(ctx, 'me@mail.com', { + passcode: 'abc123', + tokenExp: new Date(), + }); + + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(MockSendVerifyNotificationCommand), + ); + }); + + it('should not publish when the command succeeds', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue(undefined); + + port.sendVerify(ctx, 'me@mail.com', { + passcode: 'abc123', + tokenExp: new Date(), + }); + + await flush(); + + expect(eventBus.publish).not.toHaveBeenCalled(); + }); + + it('should not leave an unhandled rejection when the command fails', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockRejectedValue( + new Error('send failed'), + ); + + port.sendVerify(ctx, 'me@mail.com', { + passcode: 'abc123', + tokenExp: new Date(), + }); + + await expect(flush()).resolves.toBeUndefined(); + }); + + it('should publish a NotificationSendFailedEvent when the command fails', async () => { + void commandBus.execute; + const error = new Error('send failed'); + vi.spyOn(commandBus, 'execute').mockRejectedValue(error); + + port.sendVerify(ctx, 'me@mail.com', { + passcode: 'abc123', + tokenExp: new Date(), + }); + + await flush(); + + expect(eventBus.publish).toHaveBeenCalledTimes(1); + const published = vi.mocked(eventBus.publish).mock + .calls[0][0] as NotificationSendFailedEvent; + expect(published).toBeInstanceOf(NotificationSendFailedEvent); + expect(published.ctx).toBe(ctx); + expect(published.email).toBe('me@mail.com'); + expect(published.command).toBe(MockSendVerifyNotificationCommand); + expect(published.error).toBeInstanceOf(AuthenticationEmailException); + expect(published.error.context.originalError).toBe(error); + }); + + it('should not leave an unhandled rejection when eventBus.publish rejects', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockRejectedValue( + new Error('send failed'), + ); + vi.mocked(eventBus.publish).mockRejectedValue( + new Error('publish also failed'), + ); + + port.sendVerify(ctx, 'me@mail.com', { + passcode: 'abc123', + tokenExp: new Date(), + }); + + await expect(flush()).resolves.toBeUndefined(); + }); + + it('should not leave an unhandled rejection when eventBus.publish throws synchronously', async () => { + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockRejectedValue( + new Error('send failed'), + ); + vi.mocked(eventBus.publish).mockImplementation(() => { + throw new Error('publish threw synchronously'); + }); + + port.sendVerify(ctx, 'me@mail.com', { + passcode: 'abc123', + tokenExp: new Date(), + }); + + await expect(flush()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/domain/ports/jwt.port.ts b/packages/nestjs-authentication/src/domain/ports/jwt.port.ts new file mode 100644 index 000000000..879df2bc7 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/jwt.port.ts @@ -0,0 +1,66 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { Command, CommandBus, Query, QueryBus } from '@nestjs/cqrs'; + +import { Token } from '../../domain/aggregates/token.aggregate.js'; + +export interface SignTokenCommandInterface extends Command { + ctx: PlainLiteralObject; + token: Token; +} + +export interface JwtVerifyTokenQueryInterface extends Query { + ctx: PlainLiteralObject; + token: string; +} + +export interface JwtPortSettings { + signAccessTokenCommand: Type; + signRefreshTokenCommand: Type; + verifyAccessTokenQuery: Type; + verifyRefreshTokenQuery: Type; +} + +@Injectable() +export class JwtPort { + constructor( + private readonly portSettings: JwtPortSettings, + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + async signAccessToken( + ctx: PlainLiteralObject, + token: Token, + ): Promise { + return this.commandBus.execute( + new this.portSettings.signAccessTokenCommand(ctx, token), + ); + } + + async signRefreshToken( + ctx: PlainLiteralObject, + token: Token, + ): Promise { + return this.commandBus.execute( + new this.portSettings.signRefreshTokenCommand(ctx, token), + ); + } + + async verifyAccessToken( + ctx: PlainLiteralObject, + token: string, + ): Promise { + return this.queryBus.execute( + new this.portSettings.verifyAccessTokenQuery(ctx, token), + ); + } + + async verifyRefreshToken( + ctx: PlainLiteralObject, + token: string, + ): Promise { + return this.queryBus.execute( + new this.portSettings.verifyRefreshTokenQuery(ctx, token), + ); + } +} diff --git a/packages/nestjs-authentication/src/domain/ports/otp.port.ts b/packages/nestjs-authentication/src/domain/ports/otp.port.ts new file mode 100644 index 000000000..118f36790 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/otp.port.ts @@ -0,0 +1,95 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { Command, CommandBus, Query, QueryBus } from '@nestjs/cqrs'; + +import { AssigneeRelationInterface, ReferenceId } from '@concepta/nestjs-core'; + +export interface AuthenticationOtpCreatableInterface { + category: string; + type: string; + assigneeId: ReferenceId; + expiresIn: string; + rateSeconds?: number; + rateThreshold?: number; +} + +export interface AuthenticationOtpInterface { + category: string; + type: string; + passcode: string; + expirationDate: Date; + active: boolean; + assigneeId: ReferenceId; +} + +export interface OtpCreateOptions { + duplicateStrategy?: 'ALLOW' | 'DEACTIVATE'; + rateSeconds?: number; + rateThreshold?: number; +} + +export interface CreateOtpCommandInterface extends Command { + ctx: PlainLiteralObject; + namespace: string; + otp: AuthenticationOtpCreatableInterface; + duplicateStrategy?: 'ALLOW' | 'DEACTIVATE'; + rateSeconds?: number; + rateThreshold?: number; +} + +export interface ValidateOtpQueryInterface extends Query { + ctx: PlainLiteralObject; + namespace: string; + otp: Pick; +} + +export interface ClearOtpCommandInterface extends Command { + ctx: PlainLiteralObject; + namespace: string; + otp: Pick; +} + +export interface OtpPortSettings { + createCommand: Type; + validateQuery: Type; + clearCommand: Type; +} + +@Injectable() +export class OtpPort { + constructor( + private readonly portSettings: OtpPortSettings, + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + async create( + ctx: PlainLiteralObject, + namespace: string, + otp: AuthenticationOtpCreatableInterface, + options?: OtpCreateOptions, + ): Promise { + return this.commandBus.execute( + new this.portSettings.createCommand(ctx, namespace, otp, options), + ); + } + + async validate( + ctx: PlainLiteralObject, + namespace: string, + otp: Pick, + ): Promise { + return this.queryBus.execute( + new this.portSettings.validateQuery(ctx, namespace, otp), + ); + } + + async clear( + ctx: PlainLiteralObject, + namespace: string, + otp: Pick, + ): Promise { + return this.commandBus.execute( + new this.portSettings.clearCommand(ctx, namespace, otp), + ); + } +} diff --git a/packages/nestjs-authentication/src/domain/ports/password.port.ts b/packages/nestjs-authentication/src/domain/ports/password.port.ts new file mode 100644 index 000000000..467adea7c --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/password.port.ts @@ -0,0 +1,49 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { Command, CommandBus } from '@nestjs/cqrs'; + +import { ReferenceId, ReferenceIdInterface } from '@concepta/nestjs-core'; + +export interface ValidatePasswordCommandInterface extends Command { + ctx: PlainLiteralObject; + password: string; + target: ReferenceIdInterface; +} + +export interface SetPasswordCommandInterface extends Command { + ctx: PlainLiteralObject; + password: string; + assigneeId: ReferenceId; +} + +export interface PasswordPortSettings { + validateCommand: Type; + setPasswordCommand: Type; +} + +@Injectable() +export class PasswordPort { + constructor( + private readonly portSettings: PasswordPortSettings, + private readonly commandBus: CommandBus, + ) {} + + async validate( + ctx: PlainLiteralObject, + password: string, + target: ReferenceIdInterface, + ): Promise { + return this.commandBus.execute( + new this.portSettings.validateCommand(ctx, password, target), + ); + } + + async setPassword( + ctx: PlainLiteralObject, + password: string, + assigneeId: ReferenceId, + ): Promise { + return this.commandBus.execute( + new this.portSettings.setPasswordCommand(ctx, password, assigneeId), + ); + } +} diff --git a/packages/nestjs-authentication/src/domain/ports/recovery-notification.port.ts b/packages/nestjs-authentication/src/domain/ports/recovery-notification.port.ts new file mode 100644 index 000000000..c23edbc1c --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/recovery-notification.port.ts @@ -0,0 +1,108 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { Command, CommandBus, EventBus } from '@nestjs/cqrs'; + +import { ReferenceEmail } from '@concepta/nestjs-core'; + +import { NotificationSendFailedEvent } from '../events/notification-send-failed.event.js'; +import { AuthenticationEmailException } from '../exceptions/authentication-email.exception.js'; + +export interface SendRecoverLoginNotificationCommandInterface extends Command { + ctx: PlainLiteralObject; + email: ReferenceEmail; + username: string; +} + +export interface SendRecoverPasswordNotificationCommandInterface extends Command { + ctx: PlainLiteralObject; + email: ReferenceEmail; + passcode: string; + tokenExp: Date; +} + +export interface SendPasswordUpdatedNotificationCommandInterface extends Command { + ctx: PlainLiteralObject; + email: ReferenceEmail; +} + +export interface RecoveryNotificationPortSettings { + sendRecoverLoginNotificationCommand: Type; + sendRecoverPasswordNotificationCommand: Type; + sendPasswordUpdatedNotificationCommand: Type; +} + +@Injectable() +export class RecoveryNotificationPort { + constructor( + private readonly portSettings: RecoveryNotificationPortSettings, + private readonly commandBus: CommandBus, + private readonly eventBus: EventBus, + ) {} + + sendRecoverLogin( + ctx: PlainLiteralObject, + email: ReferenceEmail, + username: string, + ): void { + const command = this.portSettings.sendRecoverLoginNotificationCommand; + void this.commandBus + .execute(new command(ctx, email, username)) + .catch((error: unknown) => + this.notifyFailure(ctx, email, command, error), + ); + } + + sendRecoverPassword( + ctx: PlainLiteralObject, + email: ReferenceEmail, + params: { passcode: string; tokenExp: Date }, + ): void { + const command = this.portSettings.sendRecoverPasswordNotificationCommand; + void this.commandBus + .execute(new command(ctx, email, params.passcode, params.tokenExp)) + .catch((error: unknown) => + this.notifyFailure(ctx, email, command, error), + ); + } + + sendPasswordUpdated(ctx: PlainLiteralObject, email: ReferenceEmail): void { + const command = this.portSettings.sendPasswordUpdatedNotificationCommand; + void this.commandBus + .execute(new command(ctx, email)) + .catch((error: unknown) => + this.notifyFailure(ctx, email, command, error), + ); + } + + /** + * Reports a send failure via `EventBus` instead of swallowing it, while + * staying fire-and-forget: nothing here is allowed to produce a rejected + * promise with no handler. `eventBus.publish()` normally returns + * synchronously (the default in-memory publisher), but `IEventPublisher` + * permits a Promise — and a custom publisher (Kafka, an outbox) could both + * throw synchronously and reject — so this is wrapped in `try`/`catch`, + * and also terminates any returned promise with its own `.catch()`. + */ + private notifyFailure( + ctx: PlainLiteralObject, + email: ReferenceEmail, + command: + | Type + | Type + | Type, + error: unknown, + ): Promise { + try { + const event = new NotificationSendFailedEvent( + ctx, + email, + command, + new AuthenticationEmailException({ originalError: error }), + ); + return Promise.resolve(this.eventBus.publish(event)).catch( + () => undefined, + ); + } catch { + return Promise.resolve(undefined); + } + } +} diff --git a/packages/nestjs-authentication/src/domain/ports/token.port.ts b/packages/nestjs-authentication/src/domain/ports/token.port.ts new file mode 100644 index 000000000..350a96bae --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/token.port.ts @@ -0,0 +1,81 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { Command, CommandBus, Query, QueryBus } from '@nestjs/cqrs'; + +import { AuthorizationPayloadInterface } from '../interfaces/authorization-payload.interface.js'; + +export interface IssueTokenCommandInterface extends Command { + ctx: PlainLiteralObject; + payload: AuthorizationPayloadInterface; +} + +export interface VerifyTokenQueryInterface extends Query { + ctx: PlainLiteralObject; + token: string; +} + +export interface ValidateTokenQueryInterface extends Query { + ctx: PlainLiteralObject; + payload: PlainLiteralObject; +} + +export interface TokenPortSettings { + issueAccessTokenCommand: Type; + issueRefreshTokenCommand: Type; + verifyAccessTokenQuery: Type; + verifyRefreshTokenQuery: Type; + validateTokenQuery: Type; +} + +@Injectable() +export class TokenPort { + constructor( + private readonly portSettings: TokenPortSettings, + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + async issueAccessToken( + ctx: PlainLiteralObject, + payload: AuthorizationPayloadInterface, + ): Promise { + return this.commandBus.execute( + new this.portSettings.issueAccessTokenCommand(ctx, payload), + ); + } + + async issueRefreshToken( + ctx: PlainLiteralObject, + payload: AuthorizationPayloadInterface, + ): Promise { + return this.commandBus.execute( + new this.portSettings.issueRefreshTokenCommand(ctx, payload), + ); + } + + async verifyAccessToken( + ctx: PlainLiteralObject, + token: string, + ): Promise { + return this.queryBus.execute( + new this.portSettings.verifyAccessTokenQuery(ctx, token), + ); + } + + async verifyRefreshToken( + ctx: PlainLiteralObject, + token: string, + ): Promise { + return this.queryBus.execute( + new this.portSettings.verifyRefreshTokenQuery(ctx, token), + ); + } + + async validateToken( + ctx: PlainLiteralObject, + payload: PlainLiteralObject, + ): Promise { + return this.queryBus.execute( + new this.portSettings.validateTokenQuery(ctx, payload), + ); + } +} diff --git a/packages/nestjs-authentication/src/domain/ports/user.port.ts b/packages/nestjs-authentication/src/domain/ports/user.port.ts new file mode 100644 index 000000000..8becf0641 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/user.port.ts @@ -0,0 +1,106 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { Command, CommandBus, Query, QueryBus } from '@nestjs/cqrs'; + +import { + ReferenceEmail, + ReferenceId, + ReferenceIdInterface, + ReferenceSubject, +} from '@concepta/nestjs-core'; + +export interface AuthenticationUserInterface { + email: ReferenceEmail; + username: string; + active: boolean; +} + +export type AuthenticationUserResult = + | (ReferenceIdInterface & AuthenticationUserInterface) + | null; + +export interface GetUserByIdQueryInterface extends Query { + ctx: PlainLiteralObject; + id: ReferenceId; +} + +export interface GetUserBySubjectQueryInterface extends Query { + ctx: PlainLiteralObject; + subject: ReferenceSubject; +} + +export interface GetUserByUsernameQueryInterface extends Query { + ctx: PlainLiteralObject; + username: string; +} + +export interface GetUserByEmailQueryInterface extends Query { + ctx: PlainLiteralObject; + email: ReferenceEmail; +} + +export interface UpdateUserCommandInterface extends Command { + ctx: PlainLiteralObject; + id: ReferenceId; + dto: Partial; +} + +export interface UserPortSettings { + getByIdQuery: Type; + getBySubjectQuery: Type; + getByUsernameQuery: Type; + getByEmailQuery: Type; + updateCommand: Type; +} + +@Injectable() +export class UserPort { + constructor( + private readonly portSettings: UserPortSettings, + private readonly queryBus: QueryBus, + private readonly commandBus: CommandBus, + ) {} + + async getById( + ctx: PlainLiteralObject, + id: ReferenceId, + ): Promise { + return this.queryBus.execute(new this.portSettings.getByIdQuery(ctx, id)); + } + + async getBySubject( + ctx: PlainLiteralObject, + subject: ReferenceSubject, + ): Promise { + return this.queryBus.execute( + new this.portSettings.getBySubjectQuery(ctx, subject), + ); + } + + async getByUsername( + ctx: PlainLiteralObject, + username: string, + ): Promise { + return this.queryBus.execute( + new this.portSettings.getByUsernameQuery(ctx, username), + ); + } + + async getByEmail( + ctx: PlainLiteralObject, + email: ReferenceEmail, + ): Promise { + return this.queryBus.execute( + new this.portSettings.getByEmailQuery(ctx, email), + ); + } + + async update( + ctx: PlainLiteralObject, + id: ReferenceId, + dto: Partial, + ): Promise { + return this.commandBus.execute( + new this.portSettings.updateCommand(ctx, id, dto), + ); + } +} diff --git a/packages/nestjs-authentication/src/domain/ports/verify-notification.port.ts b/packages/nestjs-authentication/src/domain/ports/verify-notification.port.ts new file mode 100644 index 000000000..aa5f99291 --- /dev/null +++ b/packages/nestjs-authentication/src/domain/ports/verify-notification.port.ts @@ -0,0 +1,70 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { Command, CommandBus, EventBus } from '@nestjs/cqrs'; + +import { ReferenceEmail } from '@concepta/nestjs-core'; + +import { NotificationSendFailedEvent } from '../events/notification-send-failed.event.js'; +import { AuthenticationEmailException } from '../exceptions/authentication-email.exception.js'; + +export interface SendVerifyNotificationCommandInterface extends Command { + ctx: PlainLiteralObject; + email: ReferenceEmail; + passcode: string; + tokenExp: Date; +} + +export interface VerifyNotificationPortSettings { + sendVerifyNotificationCommand: Type; +} + +@Injectable() +export class VerifyNotificationPort { + constructor( + private readonly portSettings: VerifyNotificationPortSettings, + private readonly commandBus: CommandBus, + private readonly eventBus: EventBus, + ) {} + + sendVerify( + ctx: PlainLiteralObject, + email: ReferenceEmail, + params: { passcode: string; tokenExp: Date }, + ): void { + const command = this.portSettings.sendVerifyNotificationCommand; + void this.commandBus + .execute(new command(ctx, email, params.passcode, params.tokenExp)) + .catch((error: unknown) => + this.notifyFailure(ctx, email, command, error), + ); + } + + /** + * Reports a send failure via `EventBus` instead of swallowing it, while + * staying fire-and-forget: nothing here is allowed to produce a rejected + * promise with no handler. `eventBus.publish()` normally returns + * synchronously (the default in-memory publisher), but `IEventPublisher` + * permits a Promise — and a custom publisher (Kafka, an outbox) could both + * throw synchronously and reject — so this is wrapped in `try`/`catch`, + * and also terminates any returned promise with its own `.catch()`. + */ + private notifyFailure( + ctx: PlainLiteralObject, + email: ReferenceEmail, + command: Type, + error: unknown, + ): Promise { + try { + const event = new NotificationSendFailedEvent( + ctx, + email, + command, + new AuthenticationEmailException({ originalError: error }), + ); + return Promise.resolve(this.eventBus.publish(event)).catch( + () => undefined, + ); + } catch { + return Promise.resolve(undefined); + } + } +} diff --git a/packages/nestjs-authentication/src/dto/authentication-jwt-response.dto.ts b/packages/nestjs-authentication/src/dto/authentication-jwt-response.dto.ts deleted file mode 100644 index 3f3c6c13f..000000000 --- a/packages/nestjs-authentication/src/dto/authentication-jwt-response.dto.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { AuthenticationResponseInterface } from '@concepta/nestjs-common'; - -@Exclude() -export class AuthenticationJwtResponseDto - implements AuthenticationResponseInterface -{ - @Expose() - @ApiProperty({ - type: 'string', - description: 'JWT access token to use for request authorization.', - }) - accessToken = ''; - - @Expose() - @ApiProperty({ - type: 'string', - description: 'JWT refresh token to use for obtaining a new access token.', - }) - refreshToken = ''; -} diff --git a/packages/nestjs-authentication/src/exceptions/authentication-access-token.exception.ts b/packages/nestjs-authentication/src/exceptions/authentication-access-token.exception.ts deleted file mode 100644 index 12be151d8..000000000 --- a/packages/nestjs-authentication/src/exceptions/authentication-access-token.exception.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthenticationException } from './authentication.exception'; - -/** - * Exception for authentication - */ -export class AuthenticationAccessTokenException extends AuthenticationException { - constructor(options?: Omit) { - super({ - message: 'Access token was verified, but failed further validation.', - ...options, - httpStatus: HttpStatus.UNAUTHORIZED, - }); - this.errorCode = 'AUTHENTICATION_ACCESS_TOKEN_ERROR'; - } -} diff --git a/packages/nestjs-authentication/src/exceptions/authentication-refresh-token.exception.ts b/packages/nestjs-authentication/src/exceptions/authentication-refresh-token.exception.ts deleted file mode 100644 index 9e997bacc..000000000 --- a/packages/nestjs-authentication/src/exceptions/authentication-refresh-token.exception.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { AuthenticationException } from './authentication.exception'; - -/** - * Exception for authentication - */ -export class AuthenticationRefreshTokenException extends AuthenticationException { - constructor(options?: Omit) { - super({ - message: 'Refresh token was verified, but failed further validation.', - ...options, - httpStatus: HttpStatus.UNAUTHORIZED, - }); - this.errorCode = 'AUTHENTICATION_REFRESH_TOKEN_ERROR'; - } -} diff --git a/packages/nestjs-authentication/src/exceptions/authentication.exception.spec.ts b/packages/nestjs-authentication/src/exceptions/authentication.exception.spec.ts deleted file mode 100644 index 09e845fdd..000000000 --- a/packages/nestjs-authentication/src/exceptions/authentication.exception.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { RuntimeException } from '@concepta/nestjs-common'; - -import { AuthenticationException } from './authentication.exception'; - -describe(AuthenticationException.name, () => { - it('should extend BadRequestException', () => { - const exception = new AuthenticationException(); - expect(exception).toBeInstanceOf(RuntimeException); - }); - - it('should have default message "Credentials are incorrect."', () => { - const exception = new AuthenticationException(); - - expect(exception.message).toEqual('Runtime Exception'); - }); -}); diff --git a/packages/nestjs-authentication/src/gateways/__tests__/auth-user-context.overlay.spec.ts b/packages/nestjs-authentication/src/gateways/__tests__/auth-user-context.overlay.spec.ts new file mode 100644 index 000000000..72facb8aa --- /dev/null +++ b/packages/nestjs-authentication/src/gateways/__tests__/auth-user-context.overlay.spec.ts @@ -0,0 +1,52 @@ +import { mock } from 'vitest-mock-extended'; + +import { type ArgumentsHost, type ExecutionContext } from '@nestjs/common'; + +import { getAppContext } from '@concepta/nestjs-core'; + +type HttpArgumentsHost = ReturnType; + +import { + AuthUserCtx, + AuthUserContextOverlay, +} from '../auth-user-context.overlay.js'; + +const makeCtx = (request: object): ExecutionContext => { + const httpArgsHost = mock(); + httpArgsHost.getRequest.mockReturnValue(request); + const ctx = mock(); + ctx.switchToHttp.mockReturnValue(httpArgsHost); + return ctx; +}; + +describe(AuthUserContextOverlay.name, () => { + let overlay: AuthUserContextOverlay; + + beforeEach(() => { + overlay = new AuthUserContextOverlay(); + }); + + it('should define AuthUserCtx with user from request', () => { + const user = { id: 'user-1' }; + const request = { user }; + overlay.attach(makeCtx(request)); + expect(getAppContext(request).with(AuthUserCtx)).toEqual({ user }); + }); + + it('should define AuthUserCtx with user undefined when request has no user', () => { + const request = {}; + overlay.attach(makeCtx(request)); + expect(getAppContext(request).with(AuthUserCtx)).toEqual({ + user: undefined, + }); + }); + + it('should be idempotent when attached twice', () => { + const user = { id: 'user-1' }; + const request = { user }; + overlay.attach(makeCtx(request)); + request.user = { id: 'user-2' }; + overlay.attach(makeCtx(request)); + expect(getAppContext(request).with(AuthUserCtx).user?.id).toBe('user-1'); + }); +}); diff --git a/packages/nestjs-authentication/src/gateways/auth-user-context.overlay.ts b/packages/nestjs-authentication/src/gateways/auth-user-context.overlay.ts new file mode 100644 index 000000000..b0461cd15 --- /dev/null +++ b/packages/nestjs-authentication/src/gateways/auth-user-context.overlay.ts @@ -0,0 +1,29 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; + +import { + ContextOverlayInterceptor, + getAppContext, + OverlayRef, +} from '@concepta/nestjs-core'; + +import { AuthenticatedUserInterface } from '../domain/interfaces/authenticated-user.interface.js'; + +import { AuthUserContextInterface } from './interfaces/auth-user-context.interface.js'; + +export const AuthUserCtx = new OverlayRef< + 'withAuthUser', + AuthUserContextInterface +>('withAuthUser'); + +@Injectable() +export class AuthUserContextOverlay extends ContextOverlayInterceptor { + readonly ref = AuthUserCtx; + + attach(context: ExecutionContext): void { + const request = context + .switchToHttp() + .getRequest<{ user?: AuthenticatedUserInterface }>(); + const ctx = getAppContext(request); + ctx.defineOverlay(AuthUserCtx, { user: request.user }); + } +} diff --git a/packages/nestjs-authentication/src/gateways/interfaces/auth-user-context.interface.ts b/packages/nestjs-authentication/src/gateways/interfaces/auth-user-context.interface.ts new file mode 100644 index 000000000..0b9d436cb --- /dev/null +++ b/packages/nestjs-authentication/src/gateways/interfaces/auth-user-context.interface.ts @@ -0,0 +1,5 @@ +import { type AuthenticatedUserInterface } from '../../domain/interfaces/authenticated-user.interface.js'; + +export interface AuthUserContextInterface { + user?: AuthenticatedUserInterface; +} diff --git a/packages/nestjs-authentication/src/guards/auth.guard.spec.ts b/packages/nestjs-authentication/src/guards/auth.guard.spec.ts deleted file mode 100644 index 5cccde795..000000000 --- a/packages/nestjs-authentication/src/guards/auth.guard.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { ExecutionContext } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; - -import { AuthGuard } from './auth.guard'; - -jest.mock('@nestjs/passport', () => ({ - AuthGuard: jest.fn().mockImplementation(() => jest.fn()), -})); -jest.mock('./fastify-auth.guard', () => ({ - FastifyAuthGuard: jest.fn().mockImplementation(() => jest.fn()), -})); - -describe(AuthGuard.name, () => { - let reflector: Reflector; - - beforeEach(() => { - reflector = new Reflector(); - }); - - it('should use PassportAuthGuard for Express', () => { - AuthGuard('local'); - expect(PassportAuthGuard).toHaveBeenCalledWith('local'); - }); - - it('should always activate if guards are disabled globally', () => { - const mockContext = { - getHandler: jest.fn(), - getClass: jest.fn(), - } as unknown as ExecutionContext; - const mockSettings = { enableGuards: false }; - jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true); - - const Guard = AuthGuard('local', { canDisable: true }); - const guardInstance = new Guard(mockSettings, reflector); - expect(guardInstance.canActivate(mockContext)).toBeTruthy(); - }); - - it('should respect disableGuard callback', () => { - const mockContext = { - getHandler: jest.fn(), - getClass: jest.fn(), - } as unknown as ExecutionContext; - - const mockSettings = { enableGuards: false }; - - const Guard = AuthGuard('local', { canDisable: true }); - const guardInstance = new Guard(mockSettings, reflector); - expect(guardInstance.canActivate(mockContext)).toBeTruthy(); - }); - - it('should respect enable guard and disabled from reflector callback', () => { - const mockContext = { - getHandler: jest.fn(), - getClass: jest.fn(), - } as unknown as ExecutionContext; - - const mockSettings = { enableGuards: true }; - jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true); - const Guard = AuthGuard('local', { canDisable: true }); - const guardInstance = new Guard(mockSettings, reflector); - expect(guardInstance.canActivate(mockContext)).toBeTruthy(); - }); - - it('should respect disableGuard callback', () => { - const mockContext = { - getHandler: jest.fn(), - getClass: jest.fn(), - } as unknown as ExecutionContext; - - const mockSettings = { enableGuards: true, disableGuard: () => true }; - jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); - const Guard = AuthGuard('local', { canDisable: true }); - const guardInstance = new Guard(mockSettings, reflector); - expect(guardInstance.canActivate(mockContext)).toBeTruthy(); - }); -}); diff --git a/packages/nestjs-authentication/src/guards/auth.guard.ts b/packages/nestjs-authentication/src/guards/auth.guard.ts deleted file mode 100644 index c06bd35bd..000000000 --- a/packages/nestjs-authentication/src/guards/auth.guard.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; - -import { - AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, - AUTHENTICATION_MODULE_SETTINGS_TOKEN, -} from '../authentication.constants'; -import { AuthGuardCtr, AuthGuardOptions } from '../authentication.types'; -import { AuthenticationSettingsInterface } from '../interfaces/authentication-settings.interface'; - -import { FastifyAuthGuard } from './fastify-auth.guard'; - -/** - * A Guard to use passport for express or fastify - * - * @example - * ```ts - * @UseGuards(AuthGuard('local')) - * @Post('login') - * async authenticateWithGuard( - * @AuthUser() user: AuthLocalCredentialsInterface, - * ): Promise { - * - * const token = this.issueTokenService.issueAccessToken(user.username); - * - * return { - * ...user, - * ...token, - * }; - * } - * ``` - */ -export const AuthGuard = ( - strategyName: string, - options: AuthGuardOptions = { canDisable: false }, -) => { - // TODO: Add logic to get this information dynamically - const isExpress = true; - - // the base class - let AuthGuardBaseClass: AuthGuardCtr; - - if (isExpress) { - AuthGuardBaseClass = PassportAuthGuard(strategyName); - } else { - AuthGuardBaseClass = FastifyAuthGuard(strategyName); - } - - @Injectable() - class AuthGuard extends AuthGuardBaseClass implements CanActivate { - readonly options: AuthGuardOptions = {}; - - constructor( - @Inject(AUTHENTICATION_MODULE_SETTINGS_TOKEN) - public readonly authenticationSettings: AuthenticationSettingsInterface, - public readonly reflector: Reflector, - ) { - super(strategyName, options); - this.options = options; - } - - canActivate(context: ExecutionContext) { - // does this guard allow disabling? - if (this.options.canDisable === true) { - // check if guards are enabled globally, default to true - const enableGuards = - this.authenticationSettings?.enableGuards === false ? false : true; - - // guards are disabled globally? - if (enableGuards === false) { - // yes, immediate activation - return true; - } - - // get the context handler and class - const contextHandler = context.getHandler(); - const contextClass = context.getClass(); - - // check if guards are disabled on the handler or class - const isDisabled = this.reflector.getAllAndOverride( - AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, - [contextHandler, contextClass], - ); - - // disabled via context? - if (isDisabled === true) { - // yes, immediate activation - return true; - } - - // get the disable guard callback from authentication settings, defaults to false - const disableGuardCb = - this.authenticationSettings?.disableGuard ?? (() => false); - - // execute callback to determine if guard should be disabled - // via custom logic for context and guard instance - const cbDisabled = disableGuardCb(context, this); - - // disabled via callback? - if (cbDisabled === true) { - // yes, immediate activation - return true; - } - } - - // call parent - return super.canActivate(context); - } - } - - return AuthGuard; -}; diff --git a/packages/nestjs-authentication/src/guards/fastify-auth.guard.ts b/packages/nestjs-authentication/src/guards/fastify-auth.guard.ts deleted file mode 100644 index 1d3139d59..000000000 --- a/packages/nestjs-authentication/src/guards/fastify-auth.guard.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - CanActivate, - ExecutionContext, - NotImplementedException, -} from '@nestjs/common'; - -import { AuthGuardCtr } from '../authentication.types'; - -export const FastifyAuthGuard = (_strategyName: string): AuthGuardCtr => { - class FastifyAuthGuard implements CanActivate { - canActivate( - _context: ExecutionContext, - ): ReturnType { - throw new NotImplementedException(); - } - } - - return FastifyAuthGuard; -}; diff --git a/packages/nestjs-authentication/src/index.spec.ts b/packages/nestjs-authentication/src/index.spec.ts deleted file mode 100644 index 64d8171d5..000000000 --- a/packages/nestjs-authentication/src/index.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - AuthUser, - AuthPublic, - AuthenticationJwtResponseDto, - IssueTokenService, - VerifyTokenService, - ValidateUserService, -} from './index'; - -describe('Authentication Module Exports', () => { - it('AuthUser should be a function', () => { - expect(AuthUser).toBeInstanceOf(Function); - }); - - it('AuthPublic should be a function', () => { - expect(AuthPublic).toBeInstanceOf(Function); - }); - - it('AuthenticationJwtResponseDto should be a function', () => { - expect(AuthenticationJwtResponseDto).toBeInstanceOf(Function); - }); - - it('IssueTokenService should be a class', () => { - expect(IssueTokenService).toBeInstanceOf(Function); - }); - - it('VerifyTokenService should be a class', () => { - expect(VerifyTokenService).toBeInstanceOf(Function); - }); - - it('ValidateUserService should be a class', () => { - expect(ValidateUserService).toBeInstanceOf(Function); - }); -}); diff --git a/packages/nestjs-authentication/src/index.ts b/packages/nestjs-authentication/src/index.ts index 69c0e7c63..d648f6117 100644 --- a/packages/nestjs-authentication/src/index.ts +++ b/packages/nestjs-authentication/src/index.ts @@ -1,38 +1,249 @@ -export { AuthenticationModule } from './authentication.module'; +// gateways (context overlays) +export { + AuthUserCtx, + AuthUserContextOverlay, +} from './gateways/auth-user-context.overlay.js'; +export { AuthUserContextInterface } from './gateways/interfaces/auth-user-context.interface.js'; + +// domain aggregates +export { Token } from './domain/aggregates/token.aggregate.js'; + +// domain interfaces +export { AuthenticatedUserInterface } from './domain/interfaces/authenticated-user.interface.js'; +export { AuthenticationAccessInterface } from './domain/interfaces/authentication-access.interface.js'; +export { AuthenticationLoginInterface } from './domain/interfaces/authentication-login.interface.js'; +export { AuthenticationRefreshInterface } from './domain/interfaces/authentication-refresh.interface.js'; +export { AuthenticatedResponseInterface } from './domain/interfaces/authenticated-response.interface.js'; +export { AuthorizationPayloadInterface } from './domain/interfaces/authorization-payload.interface.js'; +export { + TokenInterface, + TokenType, +} from './domain/interfaces/token.interface.js'; +export { TokenCreatableInterface } from './domain/interfaces/token-creatable.interface.js'; + +// domain events +export { TokenIssuedEvent } from './domain/events/token-issued.event.js'; +export { TokenRevokedEvent } from './domain/events/token-revoked.event.js'; +export { NotificationSendFailedEvent } from './domain/events/notification-send-failed.event.js'; + +export { AuthenticationModule } from './authentication.module.js'; + +export { PassportStrategyFactory } from './infrastructure/passport/passport-strategy.factory.js'; +export { AuthGuard } from './infrastructure/auth.guard.js'; + +export { AuthUser } from './infrastructure/decorators/auth-user.decorator.js'; +export { + AuthPublic, + AuthPublicMetadata, + AuthPublicOptions, +} from './infrastructure/decorators/auth-public.decorator.js'; +export { isAuthPublic } from './infrastructure/decorators/is-auth-public.util.js'; + +export { AuthenticationOptionsInterface } from './infrastructure/config/interfaces/authentication-options.interface.js'; +export { AuthenticationOptionsExtrasInterface } from './infrastructure/config/interfaces/authentication-options-extras.interface.js'; +export { + AuthenticationSettingsInterface, + AuthenticationStrategiesSettingsInterface, + AuthenticationMfaSettingsInterface, +} from './infrastructure/config/interfaces/authentication-settings.interface.js'; +export { OAuthAuthenticateOptionsInterface } from './infrastructure/config/interfaces/oauth-authenticate-options.interface.js'; +export { OAuthParamsInterface } from './infrastructure/config/interfaces/oauth-params.interface.js'; +export { OAuthRequestInterface } from './infrastructure/config/interfaces/oauth-request.interface.js'; + +export { authenticationResponseSchema } from './infrastructure/schemas/authentication-response.schema.js'; + +export { + AuthGuardOptions, + AuthGuardCtr, +} from './infrastructure/auth.guard.types.js'; +export { AuthenticationEmailException } from './domain/exceptions/authentication-email.exception.js'; +export { TokenException } from './domain/exceptions/token.exception.js'; +export { TokenAlreadyRevokedException } from './domain/exceptions/token-already-revoked.exception.js'; +export { AuthenticationException } from './domain/exceptions/authentication.exception.js'; +export { AuthenticationAccessTokenException } from './application/exceptions/authentication-access-token.exception.js'; +export { AuthenticationRefreshTokenException } from './application/exceptions/authentication-refresh-token.exception.js'; +export { AuthenticationUserPortRequiredException } from './application/exceptions/authentication-user-port-required.exception.js'; +export { AuthenticationFeatureConfigException } from './infrastructure/exceptions/authentication-feature-config.exception.js'; + +export { processOAuthParams } from './infrastructure/utils/oauth-auth-params.util.js'; + +// jwt (tokens layer) +export { JwtService } from './infrastructure/jwt/jwt.service.js'; +export { + JwtPort, + JwtPortSettings, + SignTokenCommandInterface, + JwtVerifyTokenQueryInterface, +} from './domain/ports/jwt.port.js'; +export { AUTHENTICATION_JWT_PORT_TOKEN } from './authentication.constants.js'; +export { ExtractJwt, JwtFromRequestFunction } from 'passport-jwt'; +export { JwtException } from './infrastructure/jwt/exceptions/jwt.exception.js'; +export { JwtVerifyException } from './infrastructure/jwt/exceptions/jwt-verify.exception.js'; + +// passport adapter layer +export { JwtVerifyTokenCallback } from './infrastructure/passport/jwt-passport.types.js'; +export { JwtPassportOptionsInterface } from './infrastructure/passport/interfaces/jwt-passport-options.interface.js'; +export { JwtPassportStrategy } from './infrastructure/passport/jwt-passport.strategy.js'; +export { createVerifyTokenCallback } from './infrastructure/passport/utils/create-verify-token-callback.util.js'; -export { authenticationDefaultConfig } from './config/authentication-default.config'; +// auth-jwt feature +export { JwtStrategy } from './infrastructure/strategies/jwt/jwt.strategy.js'; +export { JwtGuard } from './infrastructure/strategies/jwt/jwt.guard.js'; +export { JwtAuthenticationException } from './infrastructure/strategies/jwt/exceptions/jwt-authentication.exception.js'; +export { JwtUnauthorizedException } from './infrastructure/strategies/jwt/exceptions/jwt-unauthorized.exception.js'; -export { PassportStrategyFactory } from './factories/passport-strategy.factory'; -export { AuthGuard } from './guards/auth.guard'; +// auth-local feature +export { LocalService } from './application/services/local/local.service.js'; +export { LocalGuard } from './infrastructure/strategies/local/local.guard.js'; +export { LocalValidateUserInterface } from './application/services/local/interfaces/local-validate-user.interface.js'; +export { LocalServiceInterface } from './application/services/local/interfaces/local-service.interface.js'; +export { LocalCredentialsInterface } from './infrastructure/strategies/local/interfaces/local-credentials.interface.js'; +export { localLoginSchema } from './infrastructure/strategies/local/schemas/local-login.schema.js'; +export { LocalException } from './infrastructure/strategies/local/exceptions/local.exception.js'; +export { LocalUsernameNotFoundException } from './application/exceptions/local-username-not-found.exception.js'; +export { LocalUserInactiveException } from './application/exceptions/local-user-inactive.exception.js'; +export { LocalUnauthorizedException } from './infrastructure/strategies/local/exceptions/local-unauthorized.exception.js'; +export { LocalInvalidPasswordException } from './application/exceptions/local-invalid-password.exception.js'; +export { LocalInvalidLoginDataException } from './infrastructure/strategies/local/exceptions/local-invalid-login-data.exception.js'; +export { LocalInvalidCredentialsException } from './infrastructure/strategies/local/exceptions/local-invalid-credentials.exception.js'; -export { AuthUser } from './decorators/auth-user.decorator'; -export { AuthPublic } from './decorators/auth-public.decorator'; +// auth-refresh feature +export { refreshSchema } from './infrastructure/strategies/refresh/schemas/refresh.schema.js'; +export { RefreshGuard } from './infrastructure/strategies/refresh/refresh.guard.js'; +export { RefreshException } from './infrastructure/strategies/refresh/exceptions/refresh.exception.js'; +export { RefreshUnauthorizedException } from './infrastructure/strategies/refresh/exceptions/refresh-unauthorized.exception.js'; -export { AuthenticationOptionsInterface } from './interfaces/authentication-options.interface'; -export { AuthenticationOptionsExtrasInterface } from './interfaces/authentication-options-extras.interface'; -export { AuthenticationSettingsInterface } from './interfaces/authentication-settings.interface'; -export { VerifyTokenServiceInterface } from './interfaces/verify-token-service.interface'; -export { ValidateTokenServiceInterface } from './interfaces/validate-token-service.interface'; -export { IssueTokenServiceInterface } from './interfaces/issue-token-service.interface'; -export { ValidateUserServiceInterface } from './interfaces/validate-user-service.interface'; -export { OAuthAuthenticateOptionsInterface } from './interfaces/oauth-authenticate-options.interface'; -export { OAuthParamsInterface } from './interfaces/oauth-params.interface'; -export { OAuthRequestInterface } from './interfaces/oauth-request.interface'; +// auth-recovery feature +export { RecoveryService } from './application/services/recovery/recovery.service.js'; +export { recoveryRecoverLoginSchema } from './infrastructure/mfa/recovery/schemas/recovery-recover-login.schema.js'; +export { recoveryRecoverPasswordSchema } from './infrastructure/mfa/recovery/schemas/recovery-recover-password.schema.js'; +export { recoveryUpdatePasswordSchema } from './infrastructure/mfa/recovery/schemas/recovery-update-password.schema.js'; +export { recoveryValidatePasscodeSchema } from './infrastructure/mfa/recovery/schemas/recovery-validate-passcode.schema.js'; +export { RecoveryRecoverLoginParamsInterface } from './application/services/recovery/interfaces/recovery-recover-login-params.interface.js'; +export { RecoveryRecoverPasswordParamsInterface } from './application/services/recovery/interfaces/recovery-recover-password-params.interface.js'; +export { RecoveryUpdatePasswordParamsInterface } from './application/services/recovery/interfaces/recovery-update-password-params.interface.js'; +export { RecoveryValidatePasscodeParamsInterface } from './application/services/recovery/interfaces/recovery-validate-passcode-params.interface.js'; +export { RecoveryException } from './infrastructure/mfa/recovery/exceptions/recovery.exception.js'; +export { RecoveryOtpInvalidException } from './infrastructure/mfa/recovery/exceptions/recovery-otp-invalid.exception.js'; -export { AuthenticationJwtResponseDto } from './dto/authentication-jwt-response.dto'; +// auth-verify feature +export { VerifyService } from './application/services/verify/verify.service.js'; +export { verifySchema } from './infrastructure/mfa/verify/schemas/verify.schema.js'; +export { verifyUpdateSchema } from './infrastructure/mfa/verify/schemas/verify-update.schema.js'; +export { VerifyConfirmParamsInterface } from './application/services/verify/interfaces/verify-confirm-params.interface.js'; +export { VerifySendParamsInterface } from './application/services/verify/interfaces/verify-send-params.interface.js'; +export { VerifyException } from './infrastructure/mfa/verify/exceptions/verify.exception.js'; +export { VerifyOtpInvalidException } from './application/exceptions/verify-otp-invalid.exception.js'; -export { IssueTokenService } from './services/issue-token.service'; -export { VerifyTokenService } from './services/verify-token.service'; -export { ValidateUserService } from './services/validate-user.service'; +// auth-router feature +export { AuthRouterGuard } from './infrastructure/router/auth-router.guard.js'; +export { AuthRouterGuardsRecord } from './infrastructure/router/auth-router.types.js'; +export { AuthRouterException } from './infrastructure/router/exceptions/auth-router.exception.js'; +export { AuthRouterGuardConfigInterface } from './infrastructure/router/interfaces/auth-router-guard-config.interface.js'; + +// domain policies +export { + OtpPolicy, + OtpPolicySettingsInterface, +} from './domain/policies/otp.policy.js'; +export { + JwtPolicy, + JwtPolicySettingsInterface, +} from './domain/policies/jwt.policy.js'; +export { TokenOptionsInterface } from './domain/interfaces/token-options.interface.js'; +export { + JwtStrategyPolicy, + JwtStrategyPolicySettingsInterface, +} from './domain/policies/jwt-strategy.policy.js'; +export { + LocalStrategyPolicy, + LocalStrategyPolicySettingsInterface, +} from './domain/policies/local-strategy.policy.js'; +export { + RefreshStrategyPolicy, + RefreshStrategyPolicySettingsInterface, +} from './domain/policies/refresh-strategy.policy.js'; +export { + GuardsPolicy, + GuardsPolicySettingsInterface, +} from './domain/policies/guards.policy.js'; +export { + RecoveryPolicy, + RecoveryPolicySettingsInterface, +} from './domain/policies/recovery.policy.js'; +export { + VerifyPolicy, + VerifyPolicySettingsInterface, +} from './domain/policies/verify.policy.js'; + +// domain ports +export { + TokenPort, + TokenPortSettings, + IssueTokenCommandInterface, + VerifyTokenQueryInterface, + ValidateTokenQueryInterface, +} from './domain/ports/token.port.js'; +export { + UserPort, + UserPortSettings, + AuthenticationUserInterface, + AuthenticationUserResult, + GetUserByIdQueryInterface, + GetUserBySubjectQueryInterface, + GetUserByUsernameQueryInterface, + GetUserByEmailQueryInterface, + UpdateUserCommandInterface, +} from './domain/ports/user.port.js'; +export { + PasswordPort, + PasswordPortSettings, + ValidatePasswordCommandInterface, + SetPasswordCommandInterface, +} from './domain/ports/password.port.js'; +export { + OtpPort, + OtpPortSettings, + AuthenticationOtpCreatableInterface, + AuthenticationOtpInterface, + CreateOtpCommandInterface, + ValidateOtpQueryInterface, + ClearOtpCommandInterface, +} from './domain/ports/otp.port.js'; export { - ValidateTokenService, - AUTHENTICATION_MODULE_SETTINGS_TOKEN, - AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, -} from './authentication.constants'; + RecoveryNotificationPort, + RecoveryNotificationPortSettings, + SendRecoverLoginNotificationCommandInterface, + SendRecoverPasswordNotificationCommandInterface, + SendPasswordUpdatedNotificationCommandInterface, +} from './domain/ports/recovery-notification.port.js'; +export { + VerifyNotificationPort, + VerifyNotificationPortSettings, + SendVerifyNotificationCommandInterface, +} from './domain/ports/verify-notification.port.js'; + +// ports options +export { AuthenticationPortsInterface } from './infrastructure/config/interfaces/authentication-options.interface.js'; -export { AuthGuardOptions, AuthGuardCtr } from './authentication.types'; -export { AuthenticationException } from './exceptions/authentication.exception'; -export { AuthenticationAccessTokenException } from './exceptions/authentication-access-token.exception'; -export { AuthenticationRefreshTokenException } from './exceptions/authentication-refresh-token.exception'; +// default CQRS commands/queries (for TokenPort) +export { IssueAccessTokenCommand } from './application/commands/impl/issue-access-token.command.js'; +export { IssueRefreshTokenCommand } from './application/commands/impl/issue-refresh-token.command.js'; +export { IssueAuthenticatedResponseCommand } from './application/commands/impl/issue-authenticated-response.command.js'; +export { VerifyAccessTokenQuery } from './application/queries/impl/verify-access-token.query.js'; +export { VerifyRefreshTokenQuery } from './application/queries/impl/verify-refresh-token.query.js'; +export { ValidateTokenQuery } from './application/queries/impl/validate-token.query.js'; +export { + ValidateAndVerifyAccessTokenQuery, + ValidateAndVerifyAccessTokenQueryInterface, +} from './application/queries/impl/validate-and-verify-access-token.query.js'; +export { + ValidateAndVerifyRefreshTokenQuery, + ValidateAndVerifyRefreshTokenQueryInterface, +} from './application/queries/impl/validate-and-verify-refresh-token.query.js'; -export { processOAuthParams } from './utils/oauth-auth-params.util'; +// default CQRS commands/queries (for JwtPort) +export { SignAccessTokenCommand } from './application/commands/impl/sign-access-token.command.js'; +export { SignRefreshTokenCommand } from './application/commands/impl/sign-refresh-token.command.js'; +export { JwtVerifyAccessTokenQuery } from './application/queries/impl/jwt-verify-access-token.query.js'; +export { JwtVerifyRefreshTokenQuery } from './application/queries/impl/jwt-verify-refresh-token.query.js'; diff --git a/packages/nestjs-authentication/src/infrastructure/__tests__/auth.guard.spec.ts b/packages/nestjs-authentication/src/infrastructure/__tests__/auth.guard.spec.ts new file mode 100644 index 000000000..ef6e7540d --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/__tests__/auth.guard.spec.ts @@ -0,0 +1,69 @@ +import { mock } from 'vitest-mock-extended'; + +import { type ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; + +import { GuardsPolicy } from '../../domain/policies/guards.policy.js'; +import { AuthGuard } from '../auth.guard.js'; + +vi.mock('@nestjs/passport', () => ({ + AuthGuard: vi.fn().mockImplementation(() => vi.fn()), +})); + +describe(AuthGuard.name, () => { + let reflector: Reflector; + let mockContext: ExecutionContext; + + beforeEach(() => { + reflector = new Reflector(); + mockContext = mock(); + }); + + it('should use PassportAuthGuard for Express', () => { + AuthGuard('local'); + expect(PassportAuthGuard).toHaveBeenCalledWith('local'); + }); + + it('should always activate if guards are disabled globally', () => { + vi.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true); + + const Guard = AuthGuard('local', { canDisable: true }); + const guardInstance = new Guard( + new GuardsPolicy({ enable: false }), + reflector, + ); + expect(guardInstance.canActivate(mockContext)).toBeTruthy(); + }); + + it('should respect disable callback', () => { + const Guard = AuthGuard('local', { canDisable: true }); + const guardInstance = new Guard( + new GuardsPolicy({ enable: false }), + reflector, + ); + expect(guardInstance.canActivate(mockContext)).toBeTruthy(); + }); + + it('should respect enable guard and disabled from reflector callback', () => { + vi.spyOn(reflector, 'get') + .mockReturnValueOnce(true) + .mockReturnValueOnce(undefined); + const Guard = AuthGuard('local', { canDisable: true }); + const guardInstance = new Guard( + new GuardsPolicy({ enable: true }), + reflector, + ); + expect(guardInstance.canActivate(mockContext)).toBeTruthy(); + }); + + it('should respect guards.disable callback', () => { + vi.spyOn(reflector, 'get').mockReturnValue(undefined); + const Guard = AuthGuard('local', { canDisable: true }); + const guardInstance = new Guard( + new GuardsPolicy({ enable: true, disable: () => true }), + reflector, + ); + expect(guardInstance.canActivate(mockContext)).toBeTruthy(); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/auth.guard.ts b/packages/nestjs-authentication/src/infrastructure/auth.guard.ts new file mode 100644 index 000000000..8dcc5b4f0 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/auth.guard.ts @@ -0,0 +1,137 @@ +import { + CanActivate, + ExecutionContext, + Inject, + Injectable, + NotImplementedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; + +import { AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN } from '../authentication.constants.js'; +import { GuardsPolicy } from '../domain/policies/guards.policy.js'; + +import { AuthGuardCtr, AuthGuardOptions } from './auth.guard.types.js'; +import { AuthPublicMetadata } from './decorators/auth-public.decorator.js'; + +/** + * A Guard to use passport for express or fastify + * + * @example + * ```ts + * @UseGuards(AuthGuard('local')) + * @Post('login') + * async authenticateWithGuard( + * @AuthUser() user: LocalCredentialsInterface, + * ): Promise { + * + * const token = this.issueTokenService.issueAccessToken(user.username); + * + * return { + * ...user, + * ...token, + * }; + * } + * ``` + */ +export const AuthGuard = ( + strategyName: string, + options: AuthGuardOptions = { canDisable: false }, +) => { + // TODO: Add logic to get this information dynamically + const isExpress = true; + + // the base class + let AuthGuardBaseClass: AuthGuardCtr; + + if (isExpress) { + AuthGuardBaseClass = PassportAuthGuard(strategyName); + } else { + AuthGuardBaseClass = FastifyAuthGuard(strategyName); + } + + @Injectable() + class AuthGuard extends AuthGuardBaseClass implements CanActivate { + readonly options: AuthGuardOptions = {}; + + constructor( + @Inject(GuardsPolicy) + public readonly guardsPolicy: GuardsPolicy, + public readonly reflector: Reflector, + ) { + super(strategyName, options); + this.options = options; + } + + canActivate(context: ExecutionContext) { + // does this guard allow disabling? + if (this.options.canDisable === true) { + // check if guards are enabled globally, default to true + const enableGuards = this.guardsPolicy.enable; + + // guards are disabled globally? + if (!enableGuards) { + // yes, immediate activation + return true; + } + + // get the context handler and class + const contextHandler = context.getHandler(); + const contextClass = context.getClass(); + + // check if guards are disabled on the handler or class + const handlerDisabled = this.reflector.get( + AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, + contextHandler, + ); + const classDisabled = this.reflector.get( + AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, + contextClass, + ); + + // class-level @AuthPublic() without { classLevel: true } — warn on every request + if (handlerDisabled === undefined && classDisabled === true) { + process.emitWarning( + '@AuthPublic() applied at class level disables authentication for ALL methods. ' + + 'Use @AuthPublic({ classLevel: true }) to suppress this warning.', + { code: 'ROCKETS_AUTH_PUBLIC_CLASS_LEVEL' }, + ); + } + + const isDisabled = handlerDisabled ?? classDisabled; + + // disabled via context? + if (isDisabled) { + // yes, immediate activation + return true; + } + + // execute callback to determine if guard should be disabled + const cbDisabled = this.guardsPolicy.disable(context, this); + + // disabled via callback? + if (cbDisabled === true) { + // yes, immediate activation + return true; + } + } + + // call parent + return super.canActivate(context); + } + } + + return AuthGuard; +}; + +export const FastifyAuthGuard = (_strategyName: string): AuthGuardCtr => { + class FastifyAuthGuard implements CanActivate { + canActivate( + _context: ExecutionContext, + ): ReturnType { + throw new NotImplementedException(); + } + } + + return FastifyAuthGuard; +}; diff --git a/packages/nestjs-authentication/src/infrastructure/auth.guard.types.ts b/packages/nestjs-authentication/src/infrastructure/auth.guard.types.ts new file mode 100644 index 000000000..d0c952eb6 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/auth.guard.types.ts @@ -0,0 +1,10 @@ +import { type CanActivate } from '@nestjs/common'; + +export interface AuthGuardOptions { + canDisable?: boolean; +} + +export type AuthGuardCtr = new ( + strategyName: string, + options: AuthGuardOptions, +) => CanActivate; diff --git a/packages/nestjs-authentication/src/infrastructure/config/authentication-default.config.ts b/packages/nestjs-authentication/src/infrastructure/config/authentication-default.config.ts new file mode 100644 index 000000000..746fa1844 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/config/authentication-default.config.ts @@ -0,0 +1,64 @@ +import { ExtractJwt } from 'passport-jwt'; + +import { registerAs } from '@nestjs/config'; + +import { localLoginSchema } from '../strategies/local/schemas/local-login.schema.js'; + +import { + type AuthenticationMfaSettingsInterface, + type AuthenticationStrategiesSettingsInterface, +} from './interfaces/authentication-settings.interface.js'; + +export const AUTHENTICATION_MODULE_DEFAULTS_TOKEN = + 'AUTHENTICATION_MODULE_DEFAULTS_TOKEN'; + +export interface AuthenticationModuleDefaultsInterface { + strategies: Required; + mfa: Required; +} + +/** + * Default configuration for the authentication module. + */ +export const authenticationDefaultConfig = registerAs( + AUTHENTICATION_MODULE_DEFAULTS_TOKEN, + (): AuthenticationModuleDefaultsInterface => ({ + strategies: { + jwt: { + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + }, + local: { + loginSchema: localLoginSchema, + usernameField: process.env['AUTH_LOCAL_USERNAME_FIELD'] ?? 'username', + passwordField: process.env['AUTH_LOCAL_PASSWORD_FIELD'] ?? 'password', + }, + refresh: { + jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'), + }, + }, + mfa: { + recovery: { + otp: { + namespace: 'userOtp', + category: 'auth-recovery', + type: 'uuid', + expiresIn: '1h', + duplicateStrategy: 'DEACTIVATE', + rateSeconds: 60, + rateThreshold: 5, + }, + }, + verify: { + otp: { + namespace: 'userOtp', + category: 'auth-verify', + type: 'uuid', + expiresIn: '24h', + duplicateStrategy: 'DEACTIVATE', + rateSeconds: 60, + rateThreshold: 5, + }, + }, + }, + }), +); diff --git a/packages/nestjs-authentication/src/infrastructure/config/interfaces/authentication-options-extras.interface.ts b/packages/nestjs-authentication/src/infrastructure/config/interfaces/authentication-options-extras.interface.ts new file mode 100644 index 000000000..e1057bb65 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/config/interfaces/authentication-options-extras.interface.ts @@ -0,0 +1,20 @@ +import { type CanActivate, type DynamicModule } from '@nestjs/common'; + +import { type AuthRouterGuardConfigInterface } from '../../router/interfaces/auth-router-guard-config.interface.js'; + +export interface AuthenticationOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> { + /** + * APP_GUARD configuration. Set to `false` to disable the global guard. + * Only applies when `settings.strategies.jwt` is configured. + */ + appGuard?: false | CanActivate; + + /** + * Auth router guard configurations. + * Each entry registers a named guard for route-based authentication. + */ + guards?: AuthRouterGuardConfigInterface[]; +} diff --git a/packages/nestjs-authentication/src/infrastructure/config/interfaces/authentication-options.interface.ts b/packages/nestjs-authentication/src/infrastructure/config/interfaces/authentication-options.interface.ts new file mode 100644 index 000000000..6e5d1abae --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/config/interfaces/authentication-options.interface.ts @@ -0,0 +1,27 @@ +import { type JwtPortSettings } from '../../../domain/ports/jwt.port.js'; +import { type OtpPortSettings } from '../../../domain/ports/otp.port.js'; +import { type PasswordPortSettings } from '../../../domain/ports/password.port.js'; +import { type RecoveryNotificationPortSettings } from '../../../domain/ports/recovery-notification.port.js'; +import { type TokenPortSettings } from '../../../domain/ports/token.port.js'; +import { type UserPortSettings } from '../../../domain/ports/user.port.js'; +import { type VerifyNotificationPortSettings } from '../../../domain/ports/verify-notification.port.js'; + +import { type AuthenticationSettingsInterface } from './authentication-settings.interface.js'; + +export interface AuthenticationPortsInterface { + jwt?: JwtPortSettings; + token?: TokenPortSettings; + user: UserPortSettings; + password: PasswordPortSettings; + otp: OtpPortSettings; + recoveryNotification: RecoveryNotificationPortSettings; + verifyNotification: VerifyNotificationPortSettings; +} + +/** + * Authentication module configuration options interface + */ +export interface AuthenticationOptionsInterface { + settings?: AuthenticationSettingsInterface; + ports?: AuthenticationPortsInterface; +} diff --git a/packages/nestjs-authentication/src/infrastructure/config/interfaces/authentication-settings.interface.ts b/packages/nestjs-authentication/src/infrastructure/config/interfaces/authentication-settings.interface.ts new file mode 100644 index 000000000..d0120e7a6 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/config/interfaces/authentication-settings.interface.ts @@ -0,0 +1,42 @@ +import { type GuardsPolicySettingsInterface } from '../../../domain/policies/guards.policy.js'; +import { type JwtStrategyPolicySettingsInterface } from '../../../domain/policies/jwt-strategy.policy.js'; +import { type JwtPolicySettingsInterface } from '../../../domain/policies/jwt.policy.js'; +import { type LocalStrategyPolicySettingsInterface } from '../../../domain/policies/local-strategy.policy.js'; +import { type RecoveryPolicySettingsInterface } from '../../../domain/policies/recovery.policy.js'; +import { type RefreshStrategyPolicySettingsInterface } from '../../../domain/policies/refresh-strategy.policy.js'; +import { type VerifyPolicySettingsInterface } from '../../../domain/policies/verify.policy.js'; + +export interface AuthenticationStrategiesSettingsInterface { + jwt?: JwtStrategyPolicySettingsInterface; + local?: LocalStrategyPolicySettingsInterface; + refresh?: RefreshStrategyPolicySettingsInterface; +} + +export interface AuthenticationMfaSettingsInterface { + recovery?: RecoveryPolicySettingsInterface; + verify?: VerifyPolicySettingsInterface; +} + +export interface AuthenticationSettingsInterface { + /** + * JWT token signing settings (access/refresh secrets, expiry, etc.) + */ + jwt?: JwtPolicySettingsInterface; + + /** + * Passport strategy settings (jwt, local, refresh). + * Presence of a strategy key activates that strategy. + */ + strategies?: AuthenticationStrategiesSettingsInterface; + + /** + * Multi-factor authentication settings (recovery, verify). + * Presence of a key activates that MFA feature. + */ + mfa?: AuthenticationMfaSettingsInterface; + + /** + * Guard enablement and disable callback settings. + */ + guards?: GuardsPolicySettingsInterface; +} diff --git a/packages/nestjs-authentication/src/interfaces/oauth-authenticate-options.interface.ts b/packages/nestjs-authentication/src/infrastructure/config/interfaces/oauth-authenticate-options.interface.ts similarity index 79% rename from packages/nestjs-authentication/src/interfaces/oauth-authenticate-options.interface.ts rename to packages/nestjs-authentication/src/infrastructure/config/interfaces/oauth-authenticate-options.interface.ts index ba3917e8a..60b36b730 100644 --- a/packages/nestjs-authentication/src/interfaces/oauth-authenticate-options.interface.ts +++ b/packages/nestjs-authentication/src/infrastructure/config/interfaces/oauth-authenticate-options.interface.ts @@ -1,4 +1,4 @@ -import { OAuthParamsInterface } from './oauth-params.interface'; +import { type OAuthParamsInterface } from './oauth-params.interface.js'; /** * Interface for OAuth authentication parameters that can be passed via query parameters diff --git a/packages/nestjs-authentication/src/interfaces/oauth-params.interface.ts b/packages/nestjs-authentication/src/infrastructure/config/interfaces/oauth-params.interface.ts similarity index 100% rename from packages/nestjs-authentication/src/interfaces/oauth-params.interface.ts rename to packages/nestjs-authentication/src/infrastructure/config/interfaces/oauth-params.interface.ts diff --git a/packages/nestjs-authentication/src/infrastructure/config/interfaces/oauth-request.interface.ts b/packages/nestjs-authentication/src/infrastructure/config/interfaces/oauth-request.interface.ts new file mode 100644 index 000000000..2f03db313 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/config/interfaces/oauth-request.interface.ts @@ -0,0 +1,10 @@ +import { type Request } from 'express'; + +import { type OAuthParamsInterface } from './oauth-params.interface.js'; + +/** + * Interface for OAuth authentication request with query parameters + */ +export interface OAuthRequestInterface extends Omit { + query: OAuthParamsInterface; +} diff --git a/packages/nestjs-authentication/src/infrastructure/decorators/__tests__/auth-public.decorator.spec.ts b/packages/nestjs-authentication/src/infrastructure/decorators/__tests__/auth-public.decorator.spec.ts new file mode 100644 index 000000000..0c7db58f0 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/decorators/__tests__/auth-public.decorator.spec.ts @@ -0,0 +1,28 @@ +import { SetMetadata } from '@nestjs/common'; + +import { AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN } from '../../../authentication.constants.js'; +import { AuthPublic } from '../auth-public.decorator.js'; + +vi.mock('@nestjs/common', () => { + return { + SetMetadata: vi.fn().mockImplementation(() => 'mocked SetMetadata'), // Mock SetMetadata + }; +}); + +describe(AuthPublic.name, () => { + it('should set metadata to disable guards (method-level)', () => { + AuthPublic(); + expect(SetMetadata).toHaveBeenCalledWith( + AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, + true, + ); + }); + + it('should set metadata with classLevel marker', () => { + AuthPublic({ classLevel: true }); + expect(SetMetadata).toHaveBeenCalledWith( + AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, + 'classLevel', + ); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/decorators/__tests__/auth-user.decorator.spec.ts b/packages/nestjs-authentication/src/infrastructure/decorators/__tests__/auth-user.decorator.spec.ts new file mode 100644 index 000000000..2db8ec168 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/decorators/__tests__/auth-user.decorator.spec.ts @@ -0,0 +1,74 @@ +import { mock } from 'vitest-mock-extended'; + +import { type ArgumentsHost, type ExecutionContext } from '@nestjs/common'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; + +type HttpArgumentsHost = ReturnType; + +import { AuthUserContextOverlay } from '../../../gateways/auth-user-context.overlay.js'; +import { AuthUser } from '../auth-user.decorator.js'; + +type ParamFactory = (data: unknown, ctx: ExecutionContext) => unknown; + +const getDecoratorFactory = (): ParamFactory => { + class Probe { + test(@AuthUser() _user: unknown): void { + return; + } + } + + const metadata = Reflect.getMetadata( + ROUTE_ARGS_METADATA, + Probe, + 'test', + ) as Record; + const key = Object.keys(metadata)[0]; + return metadata[key].factory; +}; + +const buildExecutionContext = (request: object): ExecutionContext => { + const httpArgsHost = mock(); + httpArgsHost.getRequest.mockReturnValue(request); + const ctx = mock(); + ctx.switchToHttp.mockReturnValue(httpArgsHost); + return ctx; +}; + +const attachOverlay = (request: object): void => { + const overlay = new AuthUserContextOverlay(); + overlay.attach(buildExecutionContext(request)); +}; + +describe('AuthUser', () => { + it('should return the user from the AuthUserCtx overlay', () => { + const factory = getDecoratorFactory(); + const user = { id: 'user-1', username: 'alice' }; + const request = { user }; + attachOverlay(request); + + const result = factory(undefined, buildExecutionContext(request)); + + expect(result).toEqual(user); + }); + + it('should return undefined when the overlay has no user', () => { + const factory = getDecoratorFactory(); + const request: Record = {}; + attachOverlay(request); + + const result = factory(undefined, buildExecutionContext(request)); + + expect(result).toBeUndefined(); + }); + + it('should read the user via the overlay, not directly from the request', () => { + const factory = getDecoratorFactory(); + const request: Record = { user: { id: 'user-1' } }; + attachOverlay(request); + request.user = { id: 'attacker' }; + + const result = factory(undefined, buildExecutionContext(request)); + + expect(result).toEqual({ id: 'user-1' }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/decorators/__tests__/is-auth-public.util.spec.ts b/packages/nestjs-authentication/src/infrastructure/decorators/__tests__/is-auth-public.util.spec.ts new file mode 100644 index 000000000..1f6273827 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/decorators/__tests__/is-auth-public.util.spec.ts @@ -0,0 +1,57 @@ +import { AuthPublic } from '../auth-public.decorator.js'; +import { isAuthPublic } from '../is-auth-public.util.js'; + +describe('isAuthPublic', () => { + it('should return false when no target carries the metadata', () => { + class TestClass { + testMethod() { + return 'test'; + } + } + + expect(isAuthPublic(TestClass.prototype.testMethod)).toBe(false); + }); + + it('should return true when the given target is decorated with @AuthPublic()', () => { + class TestClass { + @AuthPublic() + testMethod() { + return 'test'; + } + } + + expect(isAuthPublic(TestClass.prototype.testMethod)).toBe(true); + }); + + it('should return true when a class is decorated with @AuthPublic({ classLevel: true })', () => { + @AuthPublic({ classLevel: true }) + class TestClass { + testMethod() { + return 'test'; + } + } + + expect(isAuthPublic(TestClass)).toBe(true); + }); + + it('should check every given target, not just the first', () => { + @AuthPublic({ classLevel: true }) + class TestClass { + testMethod() { + return 'test'; + } + } + + expect(isAuthPublic(TestClass.prototype.testMethod, TestClass)).toBe(true); + }); + + it('should return false when none of the given targets carry the metadata', () => { + class TestClass { + testMethod() { + return 'test'; + } + } + + expect(isAuthPublic(TestClass.prototype.testMethod, TestClass)).toBe(false); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/decorators/auth-public.decorator.ts b/packages/nestjs-authentication/src/infrastructure/decorators/auth-public.decorator.ts new file mode 100644 index 000000000..b40afbbf8 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/decorators/auth-public.decorator.ts @@ -0,0 +1,36 @@ +import { SetMetadata } from '@nestjs/common'; + +import { AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN } from '../../authentication.constants.js'; + +export interface AuthPublicOptions { + classLevel?: boolean; +} + +export type AuthPublicMetadata = true | 'classLevel'; + +/** + * Disable ONLY AuthGuards that have the `canDisable` option set to true. + * + * When applied at the **class level**, pass `{ classLevel: true }` to + * make the intent explicit and suppress the runtime warning. Without this option, + * a warning is emitted on every request where the class-level decorator is active. + * + * @example Method-level (default, no warning): + * ```ts + * @Get('public') + * @AuthPublic() + * getPublic() {} + * ``` + * + * @example Class-level (explicit opt-in, no warning): + * ```ts + * @AuthPublic({ classLevel: true }) + * @Controller('public') + * class PublicController {} + * ``` + */ +export const AuthPublic = (options?: AuthPublicOptions) => + SetMetadata( + AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, + options?.classLevel ? 'classLevel' : true, + ); diff --git a/packages/nestjs-authentication/src/infrastructure/decorators/auth-user.decorator.ts b/packages/nestjs-authentication/src/infrastructure/decorators/auth-user.decorator.ts new file mode 100644 index 000000000..fd0f2822f --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/decorators/auth-user.decorator.ts @@ -0,0 +1,12 @@ +import { createParamDecorator, type ExecutionContext } from '@nestjs/common'; + +import { getAppContext } from '@concepta/nestjs-core'; + +import { AuthUserCtx } from '../../gateways/auth-user-context.overlay.js'; + +export const AuthUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return getAppContext(request).with(AuthUserCtx).user; + }, +); diff --git a/packages/nestjs-authentication/src/infrastructure/decorators/is-auth-public.util.ts b/packages/nestjs-authentication/src/infrastructure/decorators/is-auth-public.util.ts new file mode 100644 index 000000000..909489a69 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/decorators/is-auth-public.util.ts @@ -0,0 +1,26 @@ +import { AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN } from '../../authentication.constants.js'; + +import { type AuthPublicMetadata } from './auth-public.decorator.js'; + +/** + * Whether any of the given targets is decorated with `@AuthPublic()`. + * + * Checks every target given (not just the first) — unlike `@Transactional()`, + * `@AuthPublic()` has no method-overrides-class precedence: a handler and + * its class are each read independently by `AuthGuard`, so a caller wanting + * that same "public if the metadata is set anywhere in the chain" answer + * should pass both. + * + * `AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN` itself stays unexported so + * consumers don't couple to how this metadata is stored — read it through + * this function instead. + */ +export function isAuthPublic(...targets: object[]): boolean { + return targets.some((target) => { + const value: AuthPublicMetadata | undefined = Reflect.getMetadata( + AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN, + target, + ); + return value === true || value === 'classLevel'; + }); +} diff --git a/packages/nestjs-authentication/src/infrastructure/exceptions/authentication-feature-config.exception.ts b/packages/nestjs-authentication/src/infrastructure/exceptions/authentication-feature-config.exception.ts new file mode 100644 index 000000000..78481b9a2 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/exceptions/authentication-feature-config.exception.ts @@ -0,0 +1,29 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthenticationException } from '../../domain/exceptions/authentication.exception.js'; + +/** + * Exception thrown when a feature is configured but required ports are missing. + */ +export class AuthenticationFeatureConfigException extends AuthenticationException { + constructor( + feature: string, + missingPorts: string[], + options?: Omit< + RuntimeExceptionOptions, + 'httpStatus' | 'message' | 'messageParams' + >, + ) { + super({ + message: + "Feature '%s' requires [%s] to be configured but no provider was found.", + messageParams: [feature, missingPorts.join(', ')], + fault: 'usage', + ...options, + httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + }); + this.errorCode = 'AUTHENTICATION_FEATURE_CONFIG_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/jwt/__tests__/jwt.service.spec.ts b/packages/nestjs-authentication/src/infrastructure/jwt/__tests__/jwt.service.spec.ts new file mode 100644 index 000000000..fdfe4cdbd --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/jwt/__tests__/jwt.service.spec.ts @@ -0,0 +1,140 @@ +import { mock } from 'vitest-mock-extended'; + +import { Token } from '../../../domain/aggregates/token.aggregate.js'; +import { JwtPolicy } from '../../../domain/policies/jwt.policy.js'; +import { type NestJwtService } from '../jwt.externals.js'; +import { JwtService } from '../jwt.service.js'; + +describe(JwtService, () => { + const signedToken = 'signed-token'; + const decoded = { sub: 'user-1' }; + + const iat = new Date('2025-01-01T00:00:00.000Z'); + const exp = new Date('2025-01-01T01:00:00.000Z'); + + const accessToken = new Token('test-jti-access', { + sub: 'user-1', + type: 'access', + scope: [], + iat, + exp, + }); + + const refreshToken = new Token('test-jti-refresh', { + sub: 'user-1', + type: 'refresh', + scope: ['offline_access'], + iat, + exp, + }); + + let jwtService: JwtService; + let nestJwtService: NestJwtService; + let tokenPolicy: JwtPolicy; + + beforeEach(() => { + nestJwtService = mock(); + tokenPolicy = new JwtPolicy({ + access: { secret: 'access-secret', signOptions: { expiresIn: '1h' } }, + refresh: { secret: 'refresh-secret', signOptions: { expiresIn: '7d' } }, + }); + jwtService = new JwtService(tokenPolicy, nestJwtService); + }); + + describe(JwtService.prototype.signAccessToken, () => { + it('should sign with JWT claims from token', async () => { + void nestJwtService.signAsync; + vi.spyOn(nestJwtService, 'signAsync').mockResolvedValue(signedToken); + + const result = await jwtService.signAccessToken(accessToken); + + expect(result).toBe(signedToken); + expect(nestJwtService.signAsync).toHaveBeenCalledWith( + { + jti: 'test-jti-access', + sub: 'user-1', + iat: Math.floor(iat.getTime() / 1000), + exp: Math.floor(exp.getTime() / 1000), + }, + { secret: 'access-secret' }, + ); + }); + + it('should include scope claim when scopes are present', async () => { + void nestJwtService.signAsync; + vi.spyOn(nestJwtService, 'signAsync').mockResolvedValue(signedToken); + + await jwtService.signRefreshToken(refreshToken); + + expect(nestJwtService.signAsync).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'offline_access' }), + expect.anything(), + ); + }); + + it('should throw when signAsync rejects', async () => { + void nestJwtService.signAsync; + vi.spyOn(nestJwtService, 'signAsync').mockRejectedValue(new Error()); + await expect(jwtService.signAccessToken(accessToken)).rejects.toThrow(); + }); + }); + + describe(JwtService.prototype.signRefreshToken, () => { + it('should sign with refresh options (secret, no expiresIn)', async () => { + void nestJwtService.signAsync; + vi.spyOn(nestJwtService, 'signAsync').mockResolvedValue(signedToken); + + const result = await jwtService.signRefreshToken(refreshToken); + + expect(result).toBe(signedToken); + expect(nestJwtService.signAsync).toHaveBeenCalledWith( + expect.objectContaining({ sub: 'user-1', scope: 'offline_access' }), + { secret: 'refresh-secret' }, + ); + }); + }); + + describe(JwtService.prototype.verifyAccessToken, () => { + it('should verify with access options', async () => { + void nestJwtService.verifyAsync; + vi.spyOn(nestJwtService, 'verifyAsync').mockResolvedValue(decoded); + const result = await jwtService.verifyAccessToken(signedToken); + expect(result).toEqual(decoded); + expect(nestJwtService.verifyAsync).toHaveBeenCalledWith(signedToken, { + secret: 'access-secret', + }); + }); + + it('should throw when verifyAsync rejects', async () => { + void nestJwtService.verifyAsync; + vi.spyOn(nestJwtService, 'verifyAsync').mockRejectedValue(new Error()); + await expect(jwtService.verifyAccessToken(signedToken)).rejects.toThrow(); + }); + }); + + describe(JwtService.prototype.verifyRefreshToken, () => { + it('should verify with refresh options', async () => { + void nestJwtService.verifyAsync; + vi.spyOn(nestJwtService, 'verifyAsync').mockResolvedValue(decoded); + const result = await jwtService.verifyRefreshToken(signedToken); + expect(result).toEqual(decoded); + expect(nestJwtService.verifyAsync).toHaveBeenCalledWith(signedToken, { + secret: 'refresh-secret', + }); + }); + }); + + describe('JwtPolicy#getAccessExpiry / getRefreshExpiry', () => { + it('should compute access expiry from policy expiresIn', () => { + const from = new Date('2025-06-01T00:00:00.000Z'); + const result = tokenPolicy.getAccessExpiry(from); + expect(result.getTime()).toBe(from.getTime() + 3_600_000); + }); + + it('should compute refresh expiry from policy expiresIn', () => { + const from = new Date('2025-06-01T00:00:00.000Z'); + const result = tokenPolicy.getRefreshExpiry(from); + expect(result.getTime()).toBe(from.getTime() + 7 * 86_400_000); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/jwt/exceptions/jwt-verify.exception.ts b/packages/nestjs-authentication/src/infrastructure/jwt/exceptions/jwt-verify.exception.ts new file mode 100644 index 000000000..dbed94f0b --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/jwt/exceptions/jwt-verify.exception.ts @@ -0,0 +1,20 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { JwtException } from './jwt.exception.js'; + +/** + * Generic exception. + */ +export class JwtVerifyException extends JwtException { + constructor(options?: RuntimeExceptionOptions) { + super({ + safeMessage: 'Error on JWT verification', + httpStatus: HttpStatus.UNAUTHORIZED, + fault: 'client', + ...options, + }); + this.errorCode = 'JWT_VERIFY_ERROR'; + } +} diff --git a/packages/nestjs-jwt/src/exceptions/jwt.exception.ts b/packages/nestjs-authentication/src/infrastructure/jwt/exceptions/jwt.exception.ts similarity index 77% rename from packages/nestjs-jwt/src/exceptions/jwt.exception.ts rename to packages/nestjs-authentication/src/infrastructure/jwt/exceptions/jwt.exception.ts index c42bf7331..4830e8a47 100644 --- a/packages/nestjs-jwt/src/exceptions/jwt.exception.ts +++ b/packages/nestjs-authentication/src/infrastructure/jwt/exceptions/jwt.exception.ts @@ -1,7 +1,7 @@ import { RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; /** * Generic exception. diff --git a/packages/nestjs-authentication/src/infrastructure/jwt/jwt.externals.ts b/packages/nestjs-authentication/src/infrastructure/jwt/jwt.externals.ts new file mode 100644 index 000000000..d13b00332 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/jwt/jwt.externals.ts @@ -0,0 +1,4 @@ +export { + JwtModule as NestJwtModule, + JwtService as NestJwtService, +} from '@nestjs/jwt'; diff --git a/packages/nestjs-authentication/src/infrastructure/jwt/jwt.service.ts b/packages/nestjs-authentication/src/infrastructure/jwt/jwt.service.ts new file mode 100644 index 000000000..7c9461f63 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/jwt/jwt.service.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable, PlainLiteralObject } from '@nestjs/common'; + +import { Token } from '../../domain/aggregates/token.aggregate.js'; +import { JwtPolicy } from '../../domain/policies/jwt.policy.js'; + +import { NestJwtService } from './jwt.externals.js'; + +@Injectable() +export class JwtService { + constructor( + @Inject(JwtPolicy) + private readonly tokenPolicy: JwtPolicy, + private readonly nestJwtService: NestJwtService, + ) {} + + async signAccessToken(token: Token): Promise { + const { signOptions, secret } = this.tokenPolicy.access; + // strip expiresIn — exp is set explicitly in claims; both together is a + // runtime error in jsonwebtoken ("expiresIn and exp are mutually exclusive") + const { expiresIn: _expiresIn, ...opts } = signOptions ?? {}; + return this.nestJwtService.signAsync(this.toClaims(token), { + ...opts, + secret, + }); + } + + async signRefreshToken(token: Token): Promise { + const { signOptions, secret } = this.tokenPolicy.refresh; + const { expiresIn: _expiresIn, ...opts } = signOptions ?? {}; + return this.nestJwtService.signAsync(this.toClaims(token), { + ...opts, + secret, + }); + } + + async verifyAccessToken(token: string): Promise { + const { verifyOptions, secret } = this.tokenPolicy.access; + return this.nestJwtService.verifyAsync(token, { + ...verifyOptions, + secret, + }); + } + + async verifyRefreshToken(token: string): Promise { + const { verifyOptions, secret } = this.tokenPolicy.refresh; + return this.nestJwtService.verifyAsync(token, { + ...verifyOptions, + secret, + }); + } + + private toClaims(token: Token): PlainLiteralObject { + return { + jti: token.id, + sub: token.sub, + iat: Math.floor(token.iat.getTime() / 1000), + exp: Math.floor(token.exp.getTime() / 1000), + ...(token.scope.length > 0 ? { scope: token.scope.join(' ') } : {}), + }; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/__tests__/fixtures/recovery.controller.fixture.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/__tests__/fixtures/recovery.controller.fixture.ts new file mode 100644 index 000000000..442e72c99 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/__tests__/fixtures/recovery.controller.fixture.ts @@ -0,0 +1,122 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + PlainLiteralObject, + Post, + StandardSchemaValidationPipe, +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; + +import { Ctx } from '@concepta/nestjs-core'; + +import { RecoveryRecoverLoginParamsInterface } from '../../../../../application/services/recovery/interfaces/recovery-recover-login-params.interface.js'; +import { RecoveryRecoverPasswordParamsInterface } from '../../../../../application/services/recovery/interfaces/recovery-recover-password-params.interface.js'; +import { RecoveryUpdatePasswordParamsInterface } from '../../../../../application/services/recovery/interfaces/recovery-update-password-params.interface.js'; +import { RecoveryService } from '../../../../../application/services/recovery/recovery.service.js'; +import { AuthPublic } from '../../../../decorators/auth-public.decorator.js'; +import { RecoveryOtpInvalidException } from '../../exceptions/recovery-otp-invalid.exception.js'; +import { recoveryRecoverLoginSchema } from '../../schemas/recovery-recover-login.schema.js'; +import { recoveryRecoverPasswordSchema } from '../../schemas/recovery-recover-password.schema.js'; +import { recoveryUpdatePasswordSchema } from '../../schemas/recovery-update-password.schema.js'; + +@Controller('auth/recovery') +@AuthPublic({ classLevel: true }) +@ApiTags('auth') +export class RecoveryController { + constructor( + @Inject(RecoveryService) + private readonly recoveryService: RecoveryService, + ) {} + + @ApiOperation({ + summary: + 'Recover account username password by providing an email that will receive an username.', + }) + @ApiOkResponse() + @Post('/login') + async recoverLogin( + @Ctx() ctx: PlainLiteralObject, + @Body({ + schema: recoveryRecoverLoginSchema, + pipes: [new StandardSchemaValidationPipe()], + }) + recoverLoginParams: RecoveryRecoverLoginParamsInterface, + ): Promise { + await this.recoveryService.recoverLogin(ctx, recoverLoginParams.email); + } + + @ApiOperation({ + summary: + 'Recover account email password by providing an email that will receive a password reset link.', + }) + @ApiOkResponse() + @Post('/password') + async recoverPassword( + @Ctx() ctx: PlainLiteralObject, + @Body({ + schema: recoveryRecoverPasswordSchema, + pipes: [new StandardSchemaValidationPipe()], + }) + recoverPasswordParams: RecoveryRecoverPasswordParamsInterface, + ): Promise { + await this.recoveryService.recoverPassword( + ctx, + recoverPasswordParams.email, + ); + } + + @ApiOperation({ + summary: 'Check if passcode is valid.', + }) + @ApiOkResponse() + @ApiNotFoundResponse() + @Get('/passcode/:passcode') + async validatePasscode( + @Ctx() ctx: PlainLiteralObject, + @Param('passcode') passcode: string, + ): Promise { + const otp = await this.recoveryService.validatePasscode(ctx, passcode); + + if (!otp) { + throw new RecoveryOtpInvalidException(); + } + } + + @ApiOperation({ + summary: 'Update lost password by providing passcode and new password.', + }) + @ApiOkResponse() + @ApiBadRequestResponse() + @Patch('/password') + async updatePassword( + @Ctx() ctx: PlainLiteralObject, + @Body({ + schema: recoveryUpdatePasswordSchema, + pipes: [new StandardSchemaValidationPipe()], + }) + updatePasswordParams: RecoveryUpdatePasswordParamsInterface, + ): Promise { + const { passcode, newPassword } = updatePasswordParams; + + const user = await this.recoveryService.updatePassword( + ctx, + passcode, + newPassword, + ); + + if (!user) { + // the client should have checked using validate passcode first + throw new RecoveryOtpInvalidException(); + } + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/__tests__/recovery.controller.e2e-spec.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/__tests__/recovery.controller.e2e-spec.ts new file mode 100644 index 000000000..f482b1b87 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/__tests__/recovery.controller.e2e-spec.ts @@ -0,0 +1,111 @@ +import supertest from 'supertest'; + +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { AppModuleFixture } from '../../../../__tests__/fixtures/app.module.fixture.js'; +import { RecoveryService } from '../../../../application/services/recovery/recovery.service.js'; + +import { RecoveryController } from './fixtures/recovery.controller.fixture.js'; + +describe('RecoveryController (e2e)', () => { + let app: INestApplication; + + const mockRecoveryService = { + recoverLogin: vi.fn().mockResolvedValue(undefined), + recoverPassword: vi.fn().mockResolvedValue(undefined), + validatePasscode: vi.fn(), + updatePassword: vi.fn(), + revokeAllUserPasswordRecoveries: vi.fn().mockResolvedValue(undefined), + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + controllers: [RecoveryController], + }) + .overrideProvider(RecoveryService) + .useValue(mockRecoveryService) + .compile(); + + app = moduleFixture.createNestApplication(); + + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('POST /auth/recovery/login', () => { + it('should return 201 and call recoverLogin', async () => { + await supertest(app.getHttpServer()) + .post('/auth/recovery/login') + .send({ email: 'user@example.com' }) + .expect(201); + + expect(mockRecoveryService.recoverLogin).toHaveBeenCalledWith( + expect.any(Object), + 'user@example.com', + ); + }); + }); + + describe('POST /auth/recovery/password', () => { + it('should return 201 and call recoverPassword', async () => { + await supertest(app.getHttpServer()) + .post('/auth/recovery/password') + .send({ email: 'user@example.com' }) + .expect(201); + + expect(mockRecoveryService.recoverPassword).toHaveBeenCalledWith( + expect.any(Object), + 'user@example.com', + ); + }); + }); + + describe('GET /auth/recovery/passcode/:passcode', () => { + it('should return 200 when passcode is valid', async () => { + mockRecoveryService.validatePasscode.mockResolvedValueOnce({ + assigneeId: 'user-1', + }); + + await supertest(app.getHttpServer()) + .get('/auth/recovery/passcode/valid-passcode') + .expect(200); + }); + + it('should return 400 when passcode is invalid', async () => { + mockRecoveryService.validatePasscode.mockResolvedValueOnce(null); + + await supertest(app.getHttpServer()) + .get('/auth/recovery/passcode/bad-passcode') + .expect(400); + }); + }); + + describe('PATCH /auth/recovery/password', () => { + it('should return 200 when passcode resolves to a user', async () => { + mockRecoveryService.updatePassword.mockResolvedValueOnce({ + id: 'user-1', + }); + + await supertest(app.getHttpServer()) + .patch('/auth/recovery/password') + .send({ passcode: 'valid-passcode', newPassword: 'NewP@ss1234' }) + .expect(200); + }); + + it('should return 400 when passcode is invalid', async () => { + mockRecoveryService.updatePassword.mockResolvedValueOnce(null); + + await supertest(app.getHttpServer()) + .patch('/auth/recovery/password') + .send({ passcode: 'bad-passcode', newPassword: 'NewP@ss1234' }) + .expect(400); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/__tests__/recovery.controller.spec.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/__tests__/recovery.controller.spec.ts new file mode 100644 index 000000000..8a62dd095 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/__tests__/recovery.controller.spec.ts @@ -0,0 +1,114 @@ +import { mock } from 'vitest-mock-extended'; + +import { type RecoveryRecoverLoginParamsInterface } from '../../../../application/services/recovery/interfaces/recovery-recover-login-params.interface.js'; +import { type RecoveryUpdatePasswordParamsInterface } from '../../../../application/services/recovery/interfaces/recovery-update-password-params.interface.js'; +import { type RecoveryService } from '../../../../application/services/recovery/recovery.service.js'; +import { RecoveryOtpInvalidException } from '../exceptions/recovery-otp-invalid.exception.js'; + +import { RecoveryController } from './fixtures/recovery.controller.fixture.js'; + +describe(RecoveryController.name, () => { + let controller: RecoveryController; + let recoveryService: RecoveryService; + const dto: RecoveryRecoverLoginParamsInterface = { + email: 'test@example.com', + }; + const passwordDto: RecoveryUpdatePasswordParamsInterface = { + passcode: '123456', + newPassword: 'newPassword', + }; + beforeEach(() => { + recoveryService = mock(); + controller = new RecoveryController(recoveryService); + }); + + describe('recoverLogin', () => { + it('should call recoverLogin method of RecoveryService', async () => { + void recoveryService.recoverLogin; + const recoverLoginSpy = vi.spyOn(recoveryService, 'recoverLogin'); + + await controller.recoverLogin({}, dto); + + expect(recoverLoginSpy).toHaveBeenCalledWith({}, dto.email); + }); + }); + + describe('recoverPassword', () => { + it('should call recoverPassword method of RecoveryService', async () => { + void recoveryService.recoverPassword; + const recoverPasswordSpy = vi.spyOn(recoveryService, 'recoverPassword'); + + await controller.recoverPassword({}, dto); + + expect(recoverPasswordSpy).toHaveBeenCalledWith({}, dto.email); + }); + }); + + describe('validatePasscode', () => { + it('should call validatePasscode method of RecoveryService', async () => { + void recoveryService.validatePasscode; + const validatePasscodeSpy = vi + .spyOn(recoveryService, 'validatePasscode') + .mockResolvedValue(null); + + const t = () => controller.validatePasscode({}, passwordDto.passcode); + await expect(t).rejects.toThrow(RecoveryOtpInvalidException); + + expect(validatePasscodeSpy).toHaveBeenCalledWith( + {}, + passwordDto.passcode, + ); + }); + + it('should call validatePasscode method of RecoveryService', async () => { + void recoveryService.validatePasscode; + const validatePasscodeSpy = vi + .spyOn(recoveryService, 'validatePasscode') + .mockResolvedValue({ + assigneeId: '1', + }); + + await controller.validatePasscode({}, passwordDto.passcode); + + expect(validatePasscodeSpy).toHaveBeenCalledWith( + {}, + passwordDto.passcode, + ); + }); + }); + + describe('updatePassword', () => { + it('should call updatePassword method of RecoveryService', async () => { + void recoveryService.updatePassword; + const updatePasswordSpy = vi + .spyOn(recoveryService, 'updatePassword') + .mockResolvedValue(null); + + const t = () => controller.updatePassword({}, passwordDto); + await expect(t).rejects.toThrow(RecoveryOtpInvalidException); + + expect(updatePasswordSpy).toHaveBeenCalledWith( + {}, + passwordDto.passcode, + passwordDto.newPassword, + ); + }); + + it('should call updatePassword method of RecoveryService', async () => { + void recoveryService.updatePassword; + const updatePasswordSpy = vi + .spyOn(recoveryService, 'updatePassword') + .mockResolvedValue({ + id: '1', + }); + + await controller.updatePassword({}, passwordDto); + + expect(updatePasswordSpy).toHaveBeenCalledWith( + {}, + passwordDto.passcode, + passwordDto.newPassword, + ); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/exceptions/recovery-otp-invalid.exception.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/exceptions/recovery-otp-invalid.exception.ts new file mode 100644 index 000000000..ef389deba --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/exceptions/recovery-otp-invalid.exception.ts @@ -0,0 +1,18 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { RecoveryException } from './recovery.exception.js'; + +export class RecoveryOtpInvalidException extends RecoveryException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: `Invalid recovery code provided`, + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + + this.errorCode = 'AUTH_RECOVERY_OTP_INVALID_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/exceptions/recovery.exception.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/exceptions/recovery.exception.ts new file mode 100644 index 000000000..fddc8e533 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/exceptions/recovery.exception.ts @@ -0,0 +1,10 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthenticationException } from '../../../../domain/exceptions/authentication.exception.js'; + +export class RecoveryException extends AuthenticationException { + constructor(options?: RuntimeExceptionOptions) { + super(options); + this.errorCode = 'AUTH_RECOVERY_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-login.schema.spec.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-login.schema.spec.ts new file mode 100644 index 000000000..f2017b4f4 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-login.schema.spec.ts @@ -0,0 +1,19 @@ +import { recoveryRecoverLoginSchema } from './recovery-recover-login.schema.js'; + +describe('recoveryRecoverLoginSchema', () => { + it('accepts a valid email', () => { + expect( + recoveryRecoverLoginSchema.parse({ email: 'user@example.com' }), + ).toEqual({ email: 'user@example.com' }); + }); + + it('rejects a malformed email', () => { + expect( + recoveryRecoverLoginSchema.safeParse({ email: 'not-an-email' }).success, + ).toBe(false); + }); + + it('rejects a missing email', () => { + expect(recoveryRecoverLoginSchema.safeParse({}).success).toBe(false); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-login.schema.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-login.schema.ts new file mode 100644 index 000000000..12f979430 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-login.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type RecoveryRecoverLoginParamsInterface } from '../../../../application/services/recovery/interfaces/recovery-recover-login-params.interface.js'; + +export const recoveryRecoverLoginSchema = withOpenApi( + conformsTo()( + z.object({ + email: z.string().email().meta({ + description: + 'Recover email login by providing an email that will receive an username', + }), + }), + ), +); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-password.schema.spec.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-password.schema.spec.ts new file mode 100644 index 000000000..73c923bd2 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-password.schema.spec.ts @@ -0,0 +1,20 @@ +import { recoveryRecoverPasswordSchema } from './recovery-recover-password.schema.js'; + +describe('recoveryRecoverPasswordSchema', () => { + it('accepts a valid email', () => { + expect( + recoveryRecoverPasswordSchema.parse({ email: 'user@example.com' }), + ).toEqual({ email: 'user@example.com' }); + }); + + it('rejects a malformed email', () => { + expect( + recoveryRecoverPasswordSchema.safeParse({ email: 'not-an-email' }) + .success, + ).toBe(false); + }); + + it('rejects a missing email', () => { + expect(recoveryRecoverPasswordSchema.safeParse({}).success).toBe(false); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-password.schema.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-password.schema.ts new file mode 100644 index 000000000..4f7546b96 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-recover-password.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type RecoveryRecoverPasswordParamsInterface } from '../../../../application/services/recovery/interfaces/recovery-recover-password-params.interface.js'; + +export const recoveryRecoverPasswordSchema = withOpenApi( + conformsTo()( + z.object({ + email: z.string().email().meta({ + description: + 'Recover email password by providing an email that will receive a password reset link', + }), + }), + ), +); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-update-password.schema.spec.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-update-password.schema.spec.ts new file mode 100644 index 000000000..7ca20625d --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-update-password.schema.spec.ts @@ -0,0 +1,26 @@ +import { recoveryUpdatePasswordSchema } from './recovery-update-password.schema.js'; + +describe('recoveryUpdatePasswordSchema', () => { + const valid = { passcode: '123456', newPassword: 'a' }; + + it('accepts a valid payload', () => { + expect(recoveryUpdatePasswordSchema.parse(valid)).toEqual(valid); + }); + + it('accepts a single-character newPassword (faithful to legacy @IsString() with no minimum)', () => { + expect(recoveryUpdatePasswordSchema.parse(valid)).toEqual(valid); + }); + + it('rejects a newPassword longer than 72 characters', () => { + const result = recoveryUpdatePasswordSchema.safeParse({ + ...valid, + newPassword: 'a'.repeat(73), + }); + expect(result.success).toBe(false); + }); + + it('rejects a missing passcode', () => { + const { passcode: _passcode, ...rest } = valid; + expect(recoveryUpdatePasswordSchema.safeParse(rest).success).toBe(false); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-update-password.schema.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-update-password.schema.ts new file mode 100644 index 000000000..f1447e8cb --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-update-password.schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type RecoveryUpdatePasswordParamsInterface } from '../../../../application/services/recovery/interfaces/recovery-update-password-params.interface.js'; + +export const recoveryUpdatePasswordSchema = withOpenApi( + conformsTo()( + z.object({ + passcode: z + .string() + .max(36) + .meta({ description: 'Passcode used to reset account password' }), + newPassword: z + .string() + .max(72) + .meta({ description: 'New password account' }), + }), + ), +); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-validate-passcode.schema.spec.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-validate-passcode.schema.spec.ts new file mode 100644 index 000000000..249c44eb9 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-validate-passcode.schema.spec.ts @@ -0,0 +1,20 @@ +import { recoveryValidatePasscodeSchema } from './recovery-validate-passcode.schema.js'; + +describe('recoveryValidatePasscodeSchema', () => { + it('accepts a valid passcode', () => { + expect( + recoveryValidatePasscodeSchema.parse({ passcode: '123456' }), + ).toEqual({ passcode: '123456' }); + }); + + it('rejects a passcode longer than 36 characters', () => { + const result = recoveryValidatePasscodeSchema.safeParse({ + passcode: 'a'.repeat(37), + }); + expect(result.success).toBe(false); + }); + + it('rejects a missing passcode', () => { + expect(recoveryValidatePasscodeSchema.safeParse({}).success).toBe(false); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-validate-passcode.schema.ts b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-validate-passcode.schema.ts new file mode 100644 index 000000000..290c4130d --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/recovery/schemas/recovery-validate-passcode.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type RecoveryValidatePasscodeParamsInterface } from '../../../../application/services/recovery/interfaces/recovery-validate-passcode-params.interface.js'; + +export const recoveryValidatePasscodeSchema = withOpenApi( + conformsTo()( + z.object({ + passcode: z.string().max(36).meta({ + description: 'User passcode used to verify if it valid or not.', + }), + }), + ), +); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/verify/__tests__/fixtures/verify.controller.fixture.ts b/packages/nestjs-authentication/src/infrastructure/mfa/verify/__tests__/fixtures/verify.controller.fixture.ts new file mode 100644 index 000000000..8f4e9aeda --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/verify/__tests__/fixtures/verify.controller.fixture.ts @@ -0,0 +1,66 @@ +import { + Body, + Controller, + Patch, + PlainLiteralObject, + Post, + StandardSchemaValidationPipe, +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; + +import { Ctx } from '@concepta/nestjs-core'; + +import { VerifyConfirmParamsInterface } from '../../../../../application/services/verify/interfaces/verify-confirm-params.interface.js'; +import { VerifySendParamsInterface } from '../../../../../application/services/verify/interfaces/verify-send-params.interface.js'; +import { VerifyService } from '../../../../../application/services/verify/verify.service.js'; +import { AuthPublic } from '../../../../decorators/auth-public.decorator.js'; +import { verifyUpdateSchema } from '../../schemas/verify-update.schema.js'; +import { verifySchema } from '../../schemas/verify.schema.js'; + +@Controller('auth/verify') +@AuthPublic({ classLevel: true }) +@ApiTags('auth') +export class VerifyControllerFixture { + constructor(private readonly verifyService: VerifyService) {} + + @ApiOperation({ + summary: + 'Send Verify account email by providing an email that will receive link to confirm account.', + }) + @ApiOkResponse() + @Post('/send') + async send( + @Ctx() ctx: PlainLiteralObject, + @Body({ + schema: verifySchema, + pipes: [new StandardSchemaValidationPipe()], + }) + verifyParams: VerifySendParamsInterface, + ): Promise { + await this.verifyService.send(ctx, { email: verifyParams.email }); + } + + @ApiOperation({ + summary: 'confirm email providing passcode.', + }) + @ApiOkResponse() + @ApiBadRequestResponse() + @Patch('/confirm') + async confirm( + @Ctx() ctx: PlainLiteralObject, + @Body({ + schema: verifyUpdateSchema, + pipes: [new StandardSchemaValidationPipe()], + }) + verifyUpdateParams: VerifyConfirmParamsInterface, + ): Promise { + const { passcode } = verifyUpdateParams; + + await this.verifyService.confirmUser(ctx, { passcode }); + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/verify/__tests__/verify.controller.e2e-spec.ts b/packages/nestjs-authentication/src/infrastructure/mfa/verify/__tests__/verify.controller.e2e-spec.ts new file mode 100644 index 000000000..e7839035f --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/verify/__tests__/verify.controller.e2e-spec.ts @@ -0,0 +1,81 @@ +import supertest from 'supertest'; + +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { AppModuleFixture } from '../../../../__tests__/fixtures/app.module.fixture.js'; +import { VerifyOtpInvalidException } from '../../../../application/exceptions/verify-otp-invalid.exception.js'; +import { VerifyService } from '../../../../application/services/verify/verify.service.js'; + +import { VerifyControllerFixture } from './fixtures/verify.controller.fixture.js'; + +describe('VerifyController (e2e)', () => { + let app: INestApplication; + + const mockVerifyService = { + send: vi.fn().mockResolvedValue(undefined), + confirmUser: vi.fn(), + validatePasscode: vi.fn(), + revokeAllUserVerifyToken: vi.fn().mockResolvedValue(undefined), + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + controllers: [VerifyControllerFixture], + }) + .overrideProvider(VerifyService) + .useValue(mockVerifyService) + .compile(); + + app = moduleFixture.createNestApplication(); + + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('POST /auth/verify/send', () => { + it('should return 201 and call send', async () => { + await supertest(app.getHttpServer()) + .post('/auth/verify/send') + .send({ email: 'user@example.com' }) + .expect(201); + + expect(mockVerifyService.send).toHaveBeenCalledWith(expect.any(Object), { + email: 'user@example.com', + }); + }); + }); + + describe('PATCH /auth/verify/confirm', () => { + it('should return 200 when passcode is valid', async () => { + mockVerifyService.confirmUser.mockResolvedValueOnce({ id: 'user-1' }); + + await supertest(app.getHttpServer()) + .patch('/auth/verify/confirm') + .send({ passcode: 'valid-passcode' }) + .expect(200); + + expect(mockVerifyService.confirmUser).toHaveBeenCalledWith( + expect.any(Object), + { passcode: 'valid-passcode' }, + ); + }); + + it('should return 400 when service throws VerifyOtpInvalidException', async () => { + mockVerifyService.confirmUser.mockRejectedValueOnce( + new VerifyOtpInvalidException(), + ); + + await supertest(app.getHttpServer()) + .patch('/auth/verify/confirm') + .send({ passcode: 'bad-passcode' }) + .expect(400); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/verify/__tests__/verify.controller.spec.ts b/packages/nestjs-authentication/src/infrastructure/mfa/verify/__tests__/verify.controller.spec.ts new file mode 100644 index 000000000..ba5e3d1b3 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/verify/__tests__/verify.controller.spec.ts @@ -0,0 +1,69 @@ +import { mock } from 'vitest-mock-extended'; + +import { type VerifyConfirmParamsInterface } from '../../../../application/services/verify/interfaces/verify-confirm-params.interface.js'; +import { type VerifySendParamsInterface } from '../../../../application/services/verify/interfaces/verify-send-params.interface.js'; +import { type VerifyService } from '../../../../application/services/verify/verify.service.js'; + +import { VerifyControllerFixture } from './fixtures/verify.controller.fixture.js'; + +describe(VerifyControllerFixture.name, () => { + let controller: VerifyControllerFixture; + let verifyService: VerifyService; + const dto: VerifySendParamsInterface = { + email: 'test@example.com', + }; + const verifyUpdateDto: VerifyConfirmParamsInterface = { + passcode: '123456', + }; + beforeEach(() => { + verifyService = mock(); + controller = new VerifyControllerFixture(verifyService); + }); + + describe('send', () => { + it('should call send method of VerifyService', async () => { + void verifyService.send; + const verifySendSpy = vi.spyOn(verifyService, 'send'); + + await controller.send({}, dto); + + expect(verifySendSpy).toHaveBeenCalledWith({}, { email: dto.email }); + }); + }); + + describe('confirm', () => { + it('should call confirmUser method of VerifyService', async () => { + void verifyService.confirmUser; + const confirmUserSpy = vi + .spyOn(verifyService, 'confirmUser') + .mockResolvedValue(null); + + await controller.confirm({}, verifyUpdateDto); + + expect(confirmUserSpy).toHaveBeenCalledWith( + {}, + { + passcode: verifyUpdateDto.passcode, + }, + ); + }); + + it('should call confirmUser method of VerifyService', async () => { + void verifyService.confirmUser; + const confirmUserSpy = vi + .spyOn(verifyService, 'confirmUser') + .mockResolvedValue({ + id: '1', + }); + + await controller.confirm({}, verifyUpdateDto); + + expect(confirmUserSpy).toHaveBeenCalledWith( + {}, + { + passcode: verifyUpdateDto.passcode, + }, + ); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/verify/exceptions/verify.exception.ts b/packages/nestjs-authentication/src/infrastructure/mfa/verify/exceptions/verify.exception.ts new file mode 100644 index 000000000..74ae32d98 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/verify/exceptions/verify.exception.ts @@ -0,0 +1,10 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthenticationException } from '../../../../domain/exceptions/authentication.exception.js'; + +export class VerifyException extends AuthenticationException { + constructor(options?: RuntimeExceptionOptions) { + super(options); + this.errorCode = 'AUTH_VERIFY_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify-update.schema.spec.ts b/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify-update.schema.spec.ts new file mode 100644 index 000000000..0d6604cfd --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify-update.schema.spec.ts @@ -0,0 +1,20 @@ +import { verifyUpdateSchema } from './verify-update.schema.js'; + +describe('verifyUpdateSchema', () => { + it('accepts a valid passcode', () => { + expect(verifyUpdateSchema.parse({ passcode: '123456' })).toEqual({ + passcode: '123456', + }); + }); + + it('rejects a passcode longer than 36 characters', () => { + const result = verifyUpdateSchema.safeParse({ + passcode: 'a'.repeat(37), + }); + expect(result.success).toBe(false); + }); + + it('rejects a missing passcode', () => { + expect(verifyUpdateSchema.safeParse({}).success).toBe(false); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify-update.schema.ts b/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify-update.schema.ts new file mode 100644 index 000000000..aedaff3bf --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify-update.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type VerifyConfirmParamsInterface } from '../../../../application/services/verify/interfaces/verify-confirm-params.interface.js'; + +export const verifyUpdateSchema = withOpenApi( + conformsTo()( + z.object({ + passcode: z + .string() + .max(36) + .meta({ description: 'Passcode used to confirm account' }), + }), + ), +); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify.schema.spec.ts b/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify.schema.spec.ts new file mode 100644 index 000000000..dde47d9a0 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify.schema.spec.ts @@ -0,0 +1,19 @@ +import { verifySchema } from './verify.schema.js'; + +describe('verifySchema', () => { + it('accepts a valid email', () => { + expect(verifySchema.parse({ email: 'user@example.com' })).toEqual({ + email: 'user@example.com', + }); + }); + + it('rejects a malformed email', () => { + expect(verifySchema.safeParse({ email: 'not-an-email' }).success).toBe( + false, + ); + }); + + it('rejects a missing email', () => { + expect(verifySchema.safeParse({}).success).toBe(false); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify.schema.ts b/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify.schema.ts new file mode 100644 index 000000000..277be73ad --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/mfa/verify/schemas/verify.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type VerifySendParamsInterface } from '../../../../application/services/verify/interfaces/verify-send-params.interface.js'; + +export const verifySchema = withOpenApi( + conformsTo()( + z.object({ + email: z.string().email().meta({ + description: + 'Verify email by providing an email that will receive a confirmation link', + }), + }), + ), +); diff --git a/packages/nestjs-authentication/src/infrastructure/passport/__tests__/jwt-passport.strategy.spec.ts b/packages/nestjs-authentication/src/infrastructure/passport/__tests__/jwt-passport.strategy.spec.ts new file mode 100644 index 000000000..5885002df --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/passport/__tests__/jwt-passport.strategy.spec.ts @@ -0,0 +1,51 @@ +import { mock } from 'vitest-mock-extended'; + +import { NotAnErrorException } from '@concepta/nestjs-core'; + +import { type JwtPassportOptionsInterface } from '../interfaces/jwt-passport-options.interface.js'; +import { JwtPassportStrategy } from '../jwt-passport.strategy.js'; + +describe(JwtPassportStrategy, () => { + let jwtStrategyOptions: JwtPassportOptionsInterface; + let verifyCallback: (...args: unknown[]) => void; + let jwtStrategy: JwtPassportStrategy; + + beforeEach(async () => { + jwtStrategyOptions = mock({ + jwtFromRequest: () => 'rawToken', + verifyToken: () => true, + }); + verifyCallback = vi.fn(); + jwtStrategy = new JwtPassportStrategy(jwtStrategyOptions, verifyCallback); + }); + + describe(JwtPassportStrategy.prototype.authenticate, () => { + const req = mock[0]>(); + it('should success', async () => { + const userResponse = jwtStrategy.authenticate(req); + expect(userResponse).toBe(true); + }); + + it('should throw when jwtFromRequest returns empty string', () => { + void jwtStrategyOptions.jwtFromRequest; + vi.spyOn(jwtStrategyOptions, 'jwtFromRequest').mockReturnValue(''); + expect(() => jwtStrategy.authenticate(req)).toThrow(); + }); + + it('should throw when verifyToken throws a standard Error', () => { + void jwtStrategyOptions.verifyToken; + vi.spyOn(jwtStrategyOptions, 'verifyToken').mockImplementationOnce(() => { + throw new Error(); + }); + expect(() => jwtStrategy.authenticate(req)).toThrow(); + }); + + it('should throw when verifyToken throws a NotAnErrorException', () => { + void jwtStrategyOptions.verifyToken; + vi.spyOn(jwtStrategyOptions, 'verifyToken').mockImplementationOnce(() => { + throw new NotAnErrorException(new Error()); + }); + expect(() => jwtStrategy.authenticate(req)).toThrow(); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/factories/passport-strategy.factory.spec.ts b/packages/nestjs-authentication/src/infrastructure/passport/__tests__/passport-strategy.factory.spec.ts similarity index 84% rename from packages/nestjs-authentication/src/factories/passport-strategy.factory.spec.ts rename to packages/nestjs-authentication/src/infrastructure/passport/__tests__/passport-strategy.factory.spec.ts index e7e065d53..6244f538c 100644 --- a/packages/nestjs-authentication/src/factories/passport-strategy.factory.spec.ts +++ b/packages/nestjs-authentication/src/infrastructure/passport/__tests__/passport-strategy.factory.spec.ts @@ -2,10 +2,10 @@ import { Strategy } from 'passport-strategy'; import { PassportStrategy } from '@nestjs/passport'; -import { PassportStrategyFactory } from './passport-strategy.factory'; +import { PassportStrategyFactory } from '../passport-strategy.factory.js'; -jest.mock('@nestjs/passport', () => ({ - PassportStrategy: jest.fn().mockImplementation((strategy, name) => ({ +vi.mock('@nestjs/passport', () => ({ + PassportStrategy: vi.fn().mockImplementation((strategy, name) => ({ strategy, name, })), diff --git a/packages/nestjs-authentication/src/infrastructure/passport/interfaces/jwt-passport-options.interface.ts b/packages/nestjs-authentication/src/infrastructure/passport/interfaces/jwt-passport-options.interface.ts new file mode 100644 index 000000000..67ed08bff --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/passport/interfaces/jwt-passport-options.interface.ts @@ -0,0 +1,10 @@ +import { type StrategyOptions } from 'passport-jwt'; + +import { type JwtVerifyTokenCallback } from '../jwt-passport.types.js'; + +export interface JwtPassportOptionsInterface extends Pick< + StrategyOptions, + 'jwtFromRequest' +> { + verifyToken: JwtVerifyTokenCallback; +} diff --git a/packages/nestjs-authentication/src/infrastructure/passport/jwt-passport.strategy.ts b/packages/nestjs-authentication/src/infrastructure/passport/jwt-passport.strategy.ts new file mode 100644 index 000000000..158df4d12 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/passport/jwt-passport.strategy.ts @@ -0,0 +1,69 @@ +import { type Strategy } from 'passport-jwt'; +import { Strategy as PassportStrategy } from 'passport-strategy'; + +import { HttpStatus } from '@nestjs/common'; + +import { NotAnErrorException } from '@concepta/nestjs-core'; + +import { JwtVerifyException } from '../jwt/exceptions/jwt-verify.exception.js'; + +import { type JwtPassportOptionsInterface } from './interfaces/jwt-passport-options.interface.js'; + +export class JwtPassportStrategy extends PassportStrategy { + constructor( + private options: JwtPassportOptionsInterface, + private verify: (...args: unknown[]) => void, + ) { + super(); + } + + authenticate(...args: Parameters) { + const [req] = args; + + const rawToken = this.options.jwtFromRequest(req); + + if (!rawToken) { + return this.fail('Missing authorization token', HttpStatus.UNAUTHORIZED); + } + + try { + return this.options.verifyToken( + rawToken, + (e?: Error, decodedToken?: unknown) => + this.verifyTokenCallback(req, e, decodedToken), + ); + } catch (e) { + const exception = new JwtVerifyException({ + originalError: e, + }); + return this.error(exception); + } + } + + private verifyTokenCallback(req: unknown, e?: Error, decodedToken?: unknown) { + if (e) { + return this.error(e); + } + + try { + return this.verify(decodedToken, req, this.isVerifiedCallback.bind(this)); + } catch (e) { + const exception = e instanceof Error ? e : new NotAnErrorException(e); + return this.error(exception); + } + } + + private isVerifiedCallback( + error: Error | null, + user: unknown, + info: unknown, + ) { + if (error) { + return this.error(error); + } else if (!user) { + return this.fail(info, HttpStatus.UNAUTHORIZED); + } else { + return this.success(user, info); + } + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/passport/jwt-passport.types.ts b/packages/nestjs-authentication/src/infrastructure/passport/jwt-passport.types.ts new file mode 100644 index 000000000..dc4242021 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/passport/jwt-passport.types.ts @@ -0,0 +1,7 @@ +export type JwtVerifyTokenCallback< + ErrorType extends Error = Error, + DecodedTokenType = unknown, +> = ( + token: string, + done: (err?: ErrorType, decodedToken?: DecodedTokenType) => void, +) => void; diff --git a/packages/nestjs-authentication/src/factories/passport-strategy.factory.ts b/packages/nestjs-authentication/src/infrastructure/passport/passport-strategy.factory.ts similarity index 75% rename from packages/nestjs-authentication/src/factories/passport-strategy.factory.ts rename to packages/nestjs-authentication/src/infrastructure/passport/passport-strategy.factory.ts index 8824647c9..f3e8fe0ef 100644 --- a/packages/nestjs-authentication/src/factories/passport-strategy.factory.ts +++ b/packages/nestjs-authentication/src/infrastructure/passport/passport-strategy.factory.ts @@ -1,6 +1,6 @@ -import { Strategy } from 'passport-strategy'; +import { type Strategy } from 'passport-strategy'; -import { NotImplementedException, Type } from '@nestjs/common'; +import { NotImplementedException, type Type } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; export const PassportStrategyFactory = ( diff --git a/packages/nestjs-authentication/src/infrastructure/passport/utils/__tests__/create-verify-token-callback.util.spec.ts b/packages/nestjs-authentication/src/infrastructure/passport/utils/__tests__/create-verify-token-callback.util.spec.ts new file mode 100644 index 000000000..e8a3934b0 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/passport/utils/__tests__/create-verify-token-callback.util.spec.ts @@ -0,0 +1,77 @@ +import { mock } from 'vitest-mock-extended'; + +import { type JwtPort } from '../../../../domain/ports/jwt.port.js'; +import { createVerifyTokenCallback } from '../create-verify-token-callback.util.js'; + +describe('createVerifyTokenCallback', () => { + const token = 'raw.jwt.token'; + const decoded = { sub: 'user-1' }; + + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + describe("when type is 'access'", () => { + it('should verify via verifyAccessToken and invoke done with decoded token', async () => { + const jwtPort = mock(); + jwtPort.verifyAccessToken.mockResolvedValue(decoded); + const done = vi.fn(); + + createVerifyTokenCallback(jwtPort, 'access')(token, done); + await flush(); + + expect(jwtPort.verifyAccessToken).toHaveBeenCalledTimes(1); + expect(jwtPort.verifyAccessToken).toHaveBeenCalledWith({}, token); + expect(jwtPort.verifyRefreshToken).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledWith(undefined, decoded); + }); + + it('should invoke done with the error when verify rejects', async () => { + const jwtPort = mock(); + const err = new Error('invalid signature'); + jwtPort.verifyAccessToken.mockRejectedValue(err); + const done = vi.fn(); + + createVerifyTokenCallback(jwtPort, 'access')(token, done); + await flush(); + + expect(done).toHaveBeenCalledWith(err); + }); + }); + + describe("when type is 'refresh'", () => { + it('should verify via verifyRefreshToken and invoke done with decoded token', async () => { + const jwtPort = mock(); + jwtPort.verifyRefreshToken.mockResolvedValue(decoded); + const done = vi.fn(); + + createVerifyTokenCallback(jwtPort, 'refresh')(token, done); + await flush(); + + expect(jwtPort.verifyRefreshToken).toHaveBeenCalledTimes(1); + expect(jwtPort.verifyRefreshToken).toHaveBeenCalledWith({}, token); + expect(jwtPort.verifyAccessToken).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledWith(undefined, decoded); + }); + + it('should invoke done with the error when verify rejects', async () => { + const jwtPort = mock(); + const err = new Error('expired'); + jwtPort.verifyRefreshToken.mockRejectedValue(err); + const done = vi.fn(); + + createVerifyTokenCallback(jwtPort, 'refresh')(token, done); + await flush(); + + expect(done).toHaveBeenCalledWith(err); + }); + }); + + it('should return a callback synchronously without throwing on rejection', () => { + const jwtPort = mock(); + jwtPort.verifyAccessToken.mockRejectedValue(new Error('boom')); + const done = vi.fn(); + + expect(() => + createVerifyTokenCallback(jwtPort, 'access')(token, done), + ).not.toThrow(); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/passport/utils/create-verify-token-callback.util.ts b/packages/nestjs-authentication/src/infrastructure/passport/utils/create-verify-token-callback.util.ts new file mode 100644 index 000000000..a496cd237 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/passport/utils/create-verify-token-callback.util.ts @@ -0,0 +1,20 @@ +import { type JwtPort } from '../../../domain/ports/jwt.port.js'; +import { type JwtVerifyTokenCallback } from '../jwt-passport.types.js'; + +export const createVerifyTokenCallback = ( + jwtPort: JwtPort, + type: 'access' | 'refresh', +): JwtVerifyTokenCallback => { + return ( + token: string, + done: (error?: Error, decodedToken?: unknown) => void, + ): void => { + const verify = + type === 'access' + ? jwtPort.verifyAccessToken.bind(jwtPort) + : jwtPort.verifyRefreshToken.bind(jwtPort); + verify({}, token) + .then((decodedToken: unknown) => done(undefined, decodedToken)) + .catch((error) => done(error)); + }; +}; diff --git a/packages/nestjs-authentication/src/infrastructure/router/__tests__/auth-router.guard.spec.ts b/packages/nestjs-authentication/src/infrastructure/router/__tests__/auth-router.guard.spec.ts new file mode 100644 index 000000000..a58b55ed4 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/__tests__/auth-router.guard.spec.ts @@ -0,0 +1,457 @@ +import { mock } from 'vitest-mock-extended'; + +import { + type ArgumentsHost, + type CanActivate, + type ExecutionContext, + HttpStatus, + UnauthorizedException, +} from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { AuthRouterGuards } from '../auth-router.constants.js'; +import { AuthRouterGuard } from '../auth-router.guard.js'; +import { AuthRouterAuthenticationFailedException } from '../exceptions/auth-router-authentication-failed.exception.js'; +import { AuthRouterConfigNotAvailableException } from '../exceptions/auth-router-config-not-available.exception.js'; +import { AuthRouterGuardInvalidException } from '../exceptions/auth-router-guard-invalid.exception.js'; +import { AuthRouterProviderMissingException } from '../exceptions/auth-router-provider-missing.exception.js'; +import { AuthRouterProviderNotSupportedException } from '../exceptions/auth-router-provider-not-supported.exception.js'; + +type HttpArgumentsHost = ReturnType; + +// Mock guard classes for testing +class MockSuccessGuard implements CanActivate { + canActivate(_context: ExecutionContext): boolean { + return true; + } +} + +class MockFailureGuard implements CanActivate { + canActivate(_context: ExecutionContext): boolean { + return false; + } +} + +class MockAsyncSuccessGuard implements CanActivate { + canActivate(_context: ExecutionContext): Promise { + return Promise.resolve(true); + } +} + +class MockErrorGuard implements CanActivate { + canActivate(_context: ExecutionContext): boolean { + throw new Error('Mock guard error'); + } +} + +class MockAsyncErrorGuard implements CanActivate { + canActivate(_context: ExecutionContext): Promise { + return Promise.reject(new Error('Mock async guard error')); + } +} + +class MockHttpExceptionGuard implements CanActivate { + canActivate(_context: ExecutionContext): boolean { + throw new UnauthorizedException('bad credentials'); + } +} + +describe(AuthRouterGuard.name, () => { + let guard: AuthRouterGuard; + let mockExecutionContext: ExecutionContext; + let mockAuthRouterGuards: Record; + + const createMockExecutionContext = (provider?: string): ExecutionContext => { + const mockRequest = { query: { provider } }; + const httpArgsHost = mock(); + httpArgsHost.getRequest.mockReturnValue(mockRequest); + const ctx = mock(); + ctx.switchToHttp.mockReturnValue(httpArgsHost); + return ctx; + }; + + beforeEach(async () => { + mockAuthRouterGuards = { + google: new MockSuccessGuard(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthRouterGuard, + { + provide: AuthRouterGuards, + useValue: mockAuthRouterGuards, + }, + ], + }).compile(); + + guard = module.get(AuthRouterGuard); + }); + + describe('Guard Instance', () => { + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('should be an instance of AuthRouter', () => { + expect(guard).toBeInstanceOf(AuthRouterGuard); + }); + }); + + describe('canActivate - Provider Validation', () => { + it('should throw AuthRouterProviderMissingException when provider is missing', async () => { + mockExecutionContext = createMockExecutionContext(); + + await expect( + guard.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterProviderMissingException); + }); + + it('should throw AuthRouterProviderMissingException when provider is empty string', async () => { + mockExecutionContext = createMockExecutionContext(''); + + await expect( + guard.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterProviderMissingException); + }); + + it('should throw AuthRouterProviderMissingException when provider is null', async () => { + mockExecutionContext = createMockExecutionContext( + null as unknown as string, + ); + + await expect( + guard.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterProviderMissingException); + }); + + it('should throw AuthRouterProviderMissingException when provider is undefined', async () => { + mockExecutionContext = createMockExecutionContext(undefined); + + await expect( + guard.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterProviderMissingException); + }); + }); + + describe('canActivate - Guards Configuration Validation', () => { + it('should throw AuthRouterConfigNotAvailableException when guards record is not found', async () => { + mockExecutionContext = createMockExecutionContext('google'); + + const guardWithoutGuards = new AuthRouterGuard( + null as unknown as Record, + ); + + await expect( + guardWithoutGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterConfigNotAvailableException); + }); + + it('should throw AuthRouterConfigNotAvailableException when guards record is undefined', async () => { + mockExecutionContext = createMockExecutionContext('google'); + + const guardWithUndefinedGuards = new AuthRouterGuard( + undefined as unknown as Record, + ); + + await expect( + guardWithUndefinedGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterConfigNotAvailableException); + }); + + it('should throw AuthRouterConfigNotAvailableException when guards record is not an object', async () => { + mockExecutionContext = createMockExecutionContext('google'); + + const guardWithInvalidGuards = new AuthRouterGuard( + 'not an object' as unknown as Record, + ); + + await expect( + guardWithInvalidGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterConfigNotAvailableException); + }); + }); + + describe('canActivate - Provider Support Validation', () => { + it('should throw AuthRouterProviderNotSupportedException when provider is not in guards record', async () => { + mockExecutionContext = createMockExecutionContext('unsupported'); + mockAuthRouterGuards = { + google: new MockSuccessGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + + await expect( + guardWithGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterProviderNotSupportedException); + }); + + it('should throw AuthRouterProviderNotSupportedException with correct provider name', async () => { + mockExecutionContext = createMockExecutionContext('facebook'); + mockAuthRouterGuards = { + google: new MockSuccessGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + const call = guardWithGuards.canActivate(mockExecutionContext); + + await expect(call).rejects.toBeInstanceOf( + AuthRouterProviderNotSupportedException, + ); + await expect(call).rejects.toMatchObject({ + safeMessage: expect.stringContaining('facebook'), + }); + }); + }); + + describe('canActivate - Guard Instance Validation', () => { + it('should throw AuthRouterGuardInvalidException when guard instance canActivate is not a function', async () => { + mockExecutionContext = createMockExecutionContext('google'); + mockAuthRouterGuards = { + google: { + canActivate: 'not a function', + } as unknown as CanActivate, + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + + await expect( + guardWithGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterGuardInvalidException); + }); + }); + + describe('canActivate - Guard Execution Success Cases', () => { + it('should return true when guard returns boolean true', async () => { + mockExecutionContext = createMockExecutionContext('google'); + mockAuthRouterGuards = { + google: new MockSuccessGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + const result = await guardWithGuards.canActivate(mockExecutionContext); + + expect(result).toBe(true); + }); + + it('should return false when guard returns boolean false', async () => { + mockExecutionContext = createMockExecutionContext('google'); + mockAuthRouterGuards = { + google: new MockFailureGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + const result = await guardWithGuards.canActivate(mockExecutionContext); + + expect(result).toBe(false); + }); + + it('should return true when guard returns Promise', async () => { + mockExecutionContext = createMockExecutionContext('google'); + mockAuthRouterGuards = { + google: new MockAsyncSuccessGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + const result = await guardWithGuards.canActivate(mockExecutionContext); + + expect(result).toBe(true); + }); + }); + + describe('canActivate - Guard Execution Error Cases', () => { + it('should throw AuthRouterAuthenticationFailedException when guard throws error', async () => { + mockExecutionContext = createMockExecutionContext('google'); + mockAuthRouterGuards = { + google: new MockErrorGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + + await expect( + guardWithGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterAuthenticationFailedException); + }); + + it('should throw AuthRouterAuthenticationFailedException when async guard throws error', async () => { + mockExecutionContext = createMockExecutionContext('google'); + mockAuthRouterGuards = { + google: new MockAsyncErrorGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + + await expect( + guardWithGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterAuthenticationFailedException); + }); + + it('should include provider name in AuthRouterAuthenticationFailedException', async () => { + mockExecutionContext = createMockExecutionContext('github'); + mockAuthRouterGuards = { + github: new MockErrorGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + const call = guardWithGuards.canActivate(mockExecutionContext); + + await expect(call).rejects.toBeInstanceOf( + AuthRouterAuthenticationFailedException, + ); + await expect(call).rejects.toMatchObject({ + safeMessage: expect.stringMatching(/github.*Mock guard error/), + }); + }); + + it('should handle unknown error types in AuthRouterAuthenticationFailedException', async () => { + mockExecutionContext = createMockExecutionContext('google'); + mockAuthRouterGuards = { + google: { + canActivate: () => { + throw 'String error'; + }, + } as unknown as CanActivate, + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + const call = guardWithGuards.canActivate(mockExecutionContext); + + await expect(call).rejects.toBeInstanceOf( + AuthRouterAuthenticationFailedException, + ); + await expect(call).rejects.toMatchObject({ + safeMessage: expect.stringContaining('Unknown error'), + }); + }); + + it('should classify a wrapped unexpected error as internal/500, not client/401', async () => { + mockExecutionContext = createMockExecutionContext('google'); + mockAuthRouterGuards = { + google: new MockErrorGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + + try { + await guardWithGuards.canActivate(mockExecutionContext); + throw new Error('Expected AuthRouterAuthenticationFailedException'); + } catch (e) { + expect(e).toBeInstanceOf(AuthRouterAuthenticationFailedException); + expect((e as AuthRouterAuthenticationFailedException).httpStatus).toBe( + HttpStatus.INTERNAL_SERVER_ERROR, + ); + expect((e as AuthRouterAuthenticationFailedException).fault).toBe( + 'internal', + ); + } + }); + + it('should pass through an HttpException thrown by the delegated guard unwrapped', async () => { + mockExecutionContext = createMockExecutionContext('google'); + mockAuthRouterGuards = { + google: new MockHttpExceptionGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + + await expect( + guardWithGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + }); + + describe('canActivate - Exception Re-throwing', () => { + it('should re-throw AuthRouterProviderMissingException without wrapping', async () => { + mockExecutionContext = createMockExecutionContext(); + + await expect( + guard.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterProviderMissingException); + }); + + it('should re-throw AuthRouterConfigNotAvailableException without wrapping', async () => { + mockExecutionContext = createMockExecutionContext('google'); + + const guardWithoutGuards = new AuthRouterGuard( + null as unknown as Record, + ); + + await expect( + guardWithoutGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterConfigNotAvailableException); + }); + + it('should re-throw AuthRouterProviderNotSupportedException without wrapping', async () => { + mockExecutionContext = createMockExecutionContext('unsupported'); + mockAuthRouterGuards = { + google: new MockSuccessGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + + await expect( + guardWithGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterProviderNotSupportedException); + }); + + it('should re-throw AuthRouterGuardInvalidException without wrapping', async () => { + mockExecutionContext = createMockExecutionContext('google'); + mockAuthRouterGuards = { + google: { + canActivate: 'not a function', + } as unknown as CanActivate, + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + + await expect( + guardWithGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterGuardInvalidException); + }); + }); + + describe('canActivate - Edge Cases', () => { + it('should handle multiple providers in guards record correctly', async () => { + mockExecutionContext = createMockExecutionContext('github'); + mockAuthRouterGuards = { + google: new MockSuccessGuard(), + github: new MockFailureGuard(), + facebook: new MockSuccessGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + const result = await guardWithGuards.canActivate(mockExecutionContext); + + expect(result).toBe(false); // github guard returns false + }); + + it('should handle provider name case sensitivity', async () => { + mockExecutionContext = createMockExecutionContext('Google'); + mockAuthRouterGuards = { + google: new MockSuccessGuard(), + }; + + const guardWithGuards = new AuthRouterGuard(mockAuthRouterGuards); + + await expect( + guardWithGuards.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterProviderNotSupportedException); + }); + + it('should handle empty provider name', async () => { + mockExecutionContext = createMockExecutionContext(' '); + + await expect( + guard.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterProviderMissingException); + }); + + it('should handle whitespace-only provider name', async () => { + mockExecutionContext = createMockExecutionContext(' '); + + await expect( + guard.canActivate(mockExecutionContext), + ).rejects.toBeInstanceOf(AuthRouterProviderMissingException); + }); + }); +}); diff --git a/packages/nestjs-auth-router/src/__fixtures__/auth-router-fixture.guards.ts b/packages/nestjs-authentication/src/infrastructure/router/__tests__/fixtures/router-guards.fixture.ts similarity index 100% rename from packages/nestjs-auth-router/src/__fixtures__/auth-router-fixture.guards.ts rename to packages/nestjs-authentication/src/infrastructure/router/__tests__/fixtures/router-guards.fixture.ts diff --git a/packages/nestjs-authentication/src/infrastructure/router/__tests__/fixtures/router.controller.fixture.ts b/packages/nestjs-authentication/src/infrastructure/router/__tests__/fixtures/router.controller.fixture.ts new file mode 100644 index 000000000..b0cd06f04 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/__tests__/fixtures/router.controller.fixture.ts @@ -0,0 +1,51 @@ +import { Controller, Get, HttpStatus, Post, UseGuards } from '@nestjs/common'; +import { ApiOkResponse, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { AuthenticatedUserInterface } from '../../../../domain/interfaces/authenticated-user.interface.js'; +import { AuthPublic } from '../../../decorators/auth-public.decorator.js'; +import { AuthUser } from '../../../decorators/auth-user.decorator.js'; +import { authenticationResponseSchema } from '../../../schemas/authentication-response.schema.js'; +import { AuthRouterGuard } from '../../auth-router.guard.js'; + +@Controller('auth-router') +@UseGuards(AuthRouterGuard) +@AuthPublic({ classLevel: true }) +@ApiTags('auth') +export class RouterControllerFixture { + constructor() {} + + /** + * Login + */ + @ApiOkResponse({ + description: 'Users are redirected to request their Auth Router identity.', + }) + @Get('login') + login(): void { + return; + } + + @ApiResponse({ + status: HttpStatus.OK, + standardSchema: authenticationResponseSchema, + description: 'Schema containing an access token and a refresh token.', + }) + @Get('callback') + async callback(@AuthUser() _user: AuthenticatedUserInterface) { + return { + ok: 'success', + }; + } + + @ApiResponse({ + status: HttpStatus.OK, + standardSchema: authenticationResponseSchema, + description: 'Schema containing an access token and a refresh token.', + }) + @Post('callback') + async postCallback(@AuthUser() _user: AuthenticatedUserInterface) { + return { + ok: 'success', + }; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/router/__tests__/router.controller.e2e-spec.ts b/packages/nestjs-authentication/src/infrastructure/router/__tests__/router.controller.e2e-spec.ts new file mode 100644 index 000000000..8d081b00a --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/__tests__/router.controller.e2e-spec.ts @@ -0,0 +1,100 @@ +import supertest from 'supertest'; + +import { type INestApplication, type CanActivate } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { AuthenticationModule } from '../../../authentication.module.js'; +import { AuthRouterGuards } from '../auth-router.constants.js'; + +import { AuthRouterFixtureGuard } from './fixtures/router-guards.fixture.js'; +import { RouterControllerFixture } from './fixtures/router.controller.fixture.js'; + +describe('RouterController (e2e)', () => { + let app: INestApplication; + let moduleFixture: TestingModule; + let guardsRecord: { google: CanActivate }; + + beforeAll(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [ + AuthenticationModule.forRoot({ + guards: [ + { + name: 'google', + guard: AuthRouterFixtureGuard, + }, + ], + }), + ], + controllers: [RouterControllerFixture], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + // Get the guards record from the module + guardsRecord = moduleFixture.get(AuthRouterGuards); + }); + + afterAll(async () => { + await app.close(); + }); + + describe(RouterControllerFixture.prototype.login, () => { + it('should call the Auth Router guard and return successfully when provider is specified', async () => { + const googleGuard = guardsRecord.google; + const guardSpy = vi.spyOn(googleGuard, 'canActivate'); + + await supertest(app.getHttpServer()) + .get('/auth-router/login?provider=google') + .expect(200); + + // Verify the guard was called + expect(guardSpy).toHaveBeenCalled(); + + // Verify the guard received the correct execution context + const executionContext = guardSpy.mock.calls[0][0]; + const httpRequest = executionContext.switchToHttp().getRequest(); + expect(httpRequest.query.provider).toBe('google'); + }); + + it('should return 400 when provider is missing (Auth Router exception)', async () => { + await supertest(app.getHttpServer()) + .get('/auth-router/login') + .expect(400); + }); + + it('should return 400 when provider is not supported (Auth Router exception)', async () => { + await supertest(app.getHttpServer()) + .get('/auth-router/login?provider=unsupported') + .expect(400); + }); + }); + + describe(RouterControllerFixture.prototype.callback, () => { + it('should call the Auth Router guard and return success response when provider is specified', async () => { + const googleGuard = guardsRecord.google; + const guardSpy = vi.spyOn(googleGuard, 'canActivate'); + + const response = await supertest(app.getHttpServer()) + .get('/auth-router/callback?provider=google') + .expect(200); + + // Verify the guard was called + expect(guardSpy).toHaveBeenCalled(); + + // Verify the response contains the expected data + expect(response.body).toEqual({ ok: 'success' }); + + // Verify the guard received the correct execution context + const executionContext = guardSpy.mock.calls[0][0]; + const httpRequest = executionContext.switchToHttp().getRequest(); + expect(httpRequest.query.provider).toBe('google'); + + // Verify the user was attached by the guard + expect(httpRequest.user).toBeDefined(); + expect(httpRequest.user.id).toBe('fixture-user-allow'); + expect(httpRequest.user.provider).toBe('google'); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/router/auth-router.constants.ts b/packages/nestjs-authentication/src/infrastructure/router/auth-router.constants.ts new file mode 100644 index 000000000..2e1698cce --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/auth-router.constants.ts @@ -0,0 +1 @@ +export const AuthRouterGuards = Symbol('AUTH_ROUTER_GUARDS_TOKEN'); diff --git a/packages/nestjs-authentication/src/infrastructure/router/auth-router.guard.ts b/packages/nestjs-authentication/src/infrastructure/router/auth-router.guard.ts new file mode 100644 index 000000000..889e2bfff --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/auth-router.guard.ts @@ -0,0 +1,152 @@ +import { firstValueFrom, isObservable } from 'rxjs'; + +import { + CanActivate, + Injectable, + ExecutionContext, + HttpException, + HttpStatus, + Inject, +} from '@nestjs/common'; + +import { AuthRouterGuards } from './auth-router.constants.js'; +import { AuthRouterGuardsRecord } from './auth-router.types.js'; +import { AuthRouterAuthenticationFailedException } from './exceptions/auth-router-authentication-failed.exception.js'; +import { AuthRouterConfigNotAvailableException } from './exceptions/auth-router-config-not-available.exception.js'; +import { AuthRouterGuardInvalidException } from './exceptions/auth-router-guard-invalid.exception.js'; +import { AuthRouterProviderMissingException } from './exceptions/auth-router-provider-missing.exception.js'; +import { AuthRouterProviderNotSupportedException } from './exceptions/auth-router-provider-not-supported.exception.js'; + +/** + * Auth Router + * + * This guard is responsible for handling Auth Router authentication by delegating + * to provider-specific guards based on the 'provider' query parameter. + */ +@Injectable() +export class AuthRouterGuard implements CanActivate { + constructor( + @Inject(AuthRouterGuards) + private readonly allAuthRouterGuards: AuthRouterGuardsRecord, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const rawProvider = request.query?.provider; + const rawCode = request.query?.code; + const rawState = request.query?.state; + const provider = typeof rawProvider === 'string' ? rawProvider : undefined; + const code = typeof rawCode === 'string' ? rawCode : undefined; + const state = typeof rawState === 'string' ? rawState : undefined; + + // Handle callback case (when code is present) + if (code) { + const callbackProvider = provider ?? this.extractProviderFromState(state); + + if (!callbackProvider) { + throw new AuthRouterProviderMissingException(); + } + + return this.executeProviderGuard(callbackProvider.trim(), context); + } + + // Handle initial authorization request + if (!provider) { + throw new AuthRouterProviderMissingException(); + } + + const trimmedProvider = provider.trim(); + if (!trimmedProvider) { + throw new AuthRouterProviderMissingException(); + } + + return this.executeProviderGuard(trimmedProvider, context); + } + + private async executeProviderGuard( + provider: string, + context: ExecutionContext, + ): Promise { + try { + if ( + !this.allAuthRouterGuards || + typeof this.allAuthRouterGuards !== 'object' + ) { + throw new AuthRouterConfigNotAvailableException(); + } + + const guardInstance = this.getProviderGuard(provider); + const result = guardInstance.canActivate(context); + + // Handle Observable, Promise, or boolean return types + if (isObservable(result)) { + const observableResult = await firstValueFrom(result); + return Boolean(observableResult); + } else if (result instanceof Promise) { + const promiseResult = await result; + return Boolean(promiseResult); + } else { + return Boolean(result); + } + } catch (error) { + // Re-throw our own Auth Router exceptions and anything the delegated + // guard already rendered as an HttpException (e.g. a passport strategy + // rejecting bad credentials) — those already carry the right status + // and body. Only an unexpected non-HTTP failure (provider outage, + // misconfiguration, a bug) reaches the wrap below, so it's classified + // as internal/500 rather than the client/401 that would tell a caller + // their credentials were wrong when the server is actually broken. + if (error instanceof HttpException) { + throw error; + } + + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + throw new AuthRouterAuthenticationFailedException( + provider, + errorMessage, + { + httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + fault: 'internal', + }, + ); + } + } + + private extractProviderFromState( + state: string | undefined, + ): string | undefined { + if (!state) { + return undefined; + } + try { + const stateData = JSON.parse(state) as Record; + return typeof stateData.provider === 'string' + ? stateData.provider + : undefined; + } catch { + return undefined; + } + } + + /** + * Get the guard instance for the given provider. + * Similar to CacheService.getAssignmentRepo() + * + * @param provider - The Auth Router provider name + */ + protected getProviderGuard(provider: string): CanActivate { + // Get the guard instance from the injected guards record + const guardInstance = this.allAuthRouterGuards[provider]; + + if (!guardInstance) { + throw new AuthRouterProviderNotSupportedException(provider); + } + + if (typeof guardInstance.canActivate !== 'function') { + throw new AuthRouterGuardInvalidException(provider); + } + + return guardInstance; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/router/auth-router.types.ts b/packages/nestjs-authentication/src/infrastructure/router/auth-router.types.ts new file mode 100644 index 000000000..b8d3643a7 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/auth-router.types.ts @@ -0,0 +1,3 @@ +import { type CanActivate } from '@nestjs/common'; + +export type AuthRouterGuardsRecord = Record; diff --git a/packages/nestjs-authentication/src/infrastructure/router/exceptions/__tests__/auth-router-authentication-failed.exception.spec.ts b/packages/nestjs-authentication/src/infrastructure/router/exceptions/__tests__/auth-router-authentication-failed.exception.spec.ts new file mode 100644 index 000000000..a8f7e84da --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/exceptions/__tests__/auth-router-authentication-failed.exception.spec.ts @@ -0,0 +1,48 @@ +import { HttpStatus } from '@nestjs/common'; + +import { AuthRouterAuthenticationFailedException } from '../auth-router-authentication-failed.exception.js'; +import { AuthRouterException } from '../auth-router.exception.js'; + +describe(AuthRouterAuthenticationFailedException.name, () => { + it('should be an instance of AuthRouterException', () => { + const exception = new AuthRouterAuthenticationFailedException( + 'google', + 'bad credentials', + ); + expect(exception).toBeInstanceOf(AuthRouterException); + }); + + it('should have httpStatus UNAUTHORIZED by default', () => { + const exception = new AuthRouterAuthenticationFailedException( + 'google', + 'bad credentials', + ); + expect(exception.httpStatus).toBe(HttpStatus.UNAUTHORIZED); + }); + + it('should have fault client by default', () => { + const exception = new AuthRouterAuthenticationFailedException( + 'google', + 'bad credentials', + ); + expect(exception.fault).toBe('client'); + }); + + it('should allow options to override httpStatus and fault', () => { + const exception = new AuthRouterAuthenticationFailedException( + 'google', + 'provider outage', + { httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, fault: 'internal' }, + ); + expect(exception.httpStatus).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(exception.fault).toBe('internal'); + }); + + it('should have errorCode AUTH_ROUTER_AUTHENTICATION_FAILED_ERROR', () => { + const exception = new AuthRouterAuthenticationFailedException( + 'google', + 'bad credentials', + ); + expect(exception.errorCode).toBe('AUTH_ROUTER_AUTHENTICATION_FAILED_ERROR'); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-authentication-failed.exception.ts b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-authentication-failed.exception.ts new file mode 100644 index 000000000..d09bd533b --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-authentication-failed.exception.ts @@ -0,0 +1,22 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthRouterException } from './auth-router.exception.js'; + +export class AuthRouterAuthenticationFailedException extends AuthRouterException { + constructor( + provider: string, + errorMessage: string, + options?: RuntimeExceptionOptions, + ) { + super({ + safeMessage: `Auth Router authentication failed for provider '${provider}': ${errorMessage}`, + httpStatus: HttpStatus.UNAUTHORIZED, + fault: 'client', + ...options, + }); + + this.errorCode = 'AUTH_ROUTER_AUTHENTICATION_FAILED_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-config-not-available.exception.ts b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-config-not-available.exception.ts new file mode 100644 index 000000000..2f6023267 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-config-not-available.exception.ts @@ -0,0 +1,15 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthRouterException } from './auth-router.exception.js'; + +export class AuthRouterConfigNotAvailableException extends AuthRouterException { + constructor(options?: RuntimeExceptionOptions) { + super({ + safeMessage: 'Auth Router configuration is not available or invalid.', + fault: 'usage', + ...options, + }); + + this.errorCode = 'AUTH_ROUTER_CONFIG_NOT_AVAILABLE_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-guard-invalid.exception.ts b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-guard-invalid.exception.ts new file mode 100644 index 000000000..d5010fa3f --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-guard-invalid.exception.ts @@ -0,0 +1,15 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthRouterException } from './auth-router.exception.js'; + +export class AuthRouterGuardInvalidException extends AuthRouterException { + constructor(provider: string, options?: RuntimeExceptionOptions) { + super({ + safeMessage: `Invalid guard configuration for Auth Router provider '${provider}'.`, + fault: 'usage', + ...options, + }); + + this.errorCode = 'AUTH_ROUTER_GUARD_INVALID_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-provider-missing.exception.ts b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-provider-missing.exception.ts new file mode 100644 index 000000000..a67dcea12 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-provider-missing.exception.ts @@ -0,0 +1,19 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthRouterException } from './auth-router.exception.js'; + +export class AuthRouterProviderMissingException extends AuthRouterException { + constructor(options?: RuntimeExceptionOptions) { + super({ + safeMessage: + 'Auth Router provider is required in the request query parameters.', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + + this.errorCode = 'AUTH_ROUTER_PROVIDER_MISSING_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-provider-not-supported.exception.ts b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-provider-not-supported.exception.ts new file mode 100644 index 000000000..515e45e16 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router-provider-not-supported.exception.ts @@ -0,0 +1,18 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthRouterException } from './auth-router.exception.js'; + +export class AuthRouterProviderNotSupportedException extends AuthRouterException { + constructor(provider: string, options?: RuntimeExceptionOptions) { + super({ + safeMessage: `Auth Router provider '${provider}' is not supported.`, + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + + this.errorCode = 'AUTH_ROUTER_PROVIDER_NOT_SUPPORTED_ERROR'; + } +} diff --git a/packages/nestjs-auth-router/src/exceptions/auth-router.exception.ts b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router.exception.ts similarity index 79% rename from packages/nestjs-auth-router/src/exceptions/auth-router.exception.ts rename to packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router.exception.ts index cbc23f14b..b5811241e 100644 --- a/packages/nestjs-auth-router/src/exceptions/auth-router.exception.ts +++ b/packages/nestjs-authentication/src/infrastructure/router/exceptions/auth-router.exception.ts @@ -1,7 +1,7 @@ import { RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; /** * Generic auth router exception. */ diff --git a/packages/nestjs-authentication/src/infrastructure/router/interfaces/auth-router-guard-config.interface.ts b/packages/nestjs-authentication/src/infrastructure/router/interfaces/auth-router-guard-config.interface.ts new file mode 100644 index 000000000..f9ce58b34 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/router/interfaces/auth-router-guard-config.interface.ts @@ -0,0 +1,6 @@ +import { type CanActivate, type Type } from '@nestjs/common'; + +export interface AuthRouterGuardConfigInterface { + name: string; + guard: Type; +} diff --git a/packages/nestjs-authentication/src/infrastructure/schemas/authentication-response.schema.spec.ts b/packages/nestjs-authentication/src/infrastructure/schemas/authentication-response.schema.spec.ts new file mode 100644 index 000000000..267515d07 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/schemas/authentication-response.schema.spec.ts @@ -0,0 +1,25 @@ +import { authenticationResponseSchema } from './authentication-response.schema.js'; + +describe('authenticationResponseSchema', () => { + const valid = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + }; + + it('accepts a valid response', () => { + expect(authenticationResponseSchema.parse(valid)).toEqual(valid); + }); + + it('rejects a missing accessToken', () => { + const { accessToken: _accessToken, ...rest } = valid; + expect(authenticationResponseSchema.safeParse(rest).success).toBe(false); + }); + + it('strips unknown keys', () => { + const result = authenticationResponseSchema.parse({ + ...valid, + _internal: 'x', + }); + expect(result).not.toHaveProperty('_internal'); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/schemas/authentication-response.schema.ts b/packages/nestjs-authentication/src/infrastructure/schemas/authentication-response.schema.ts new file mode 100644 index 000000000..bb8599fc1 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/schemas/authentication-response.schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { conformsTo, withNamedComponent } from '@concepta/nestjs-core'; + +import { type AuthenticatedResponseInterface } from '../../domain/interfaces/authenticated-response.interface.js'; + +export const authenticationResponseSchema = withNamedComponent( + conformsTo()( + z.object({ + accessToken: z.string().meta({ + description: 'JWT access token to use for request authorization.', + }), + refreshToken: z.string().meta({ + description: + 'JWT refresh token to use for obtaining a new access token.', + }), + }), + ), + 'AuthenticationResponse', +); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/fixtures/app.module.fixture.ts b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/fixtures/app.module.fixture.ts new file mode 100644 index 000000000..339504402 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/fixtures/app.module.fixture.ts @@ -0,0 +1,49 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { mockPasswordPortSettings } from '../../../../../__tests__/fixtures/ports/mock-password-port.provider.js'; +import { mockUserPortSettings } from '../../../../../__tests__/fixtures/ports/mock-user-port.provider.js'; +import { + stubOtpPortSettings, + stubRecoveryNotificationPortSettings, + stubVerifyNotificationPortSettings, +} from '../../../../../__tests__/fixtures/ports/stub-unused-ports.fixture.js'; +import { UserModuleFixture } from '../../../../../__tests__/fixtures/user.module.fixture.js'; +import { AuthenticationModule } from '../../../../../authentication.module.js'; + +import { UserControllerFixtures } from './user.controller.fixture.js'; + +@Module({ + imports: [ + CqrsModule, + UserModuleFixture, + AuthenticationModule.forRoot({ + appGuard: false, + settings: { + jwt: { + access: { + secret: 'test-access-secret', + signOptions: { expiresIn: '1h' }, + }, + refresh: { + secret: 'test-refresh-secret', + signOptions: { expiresIn: '7d' }, + }, + }, + strategies: { + jwt: {}, + }, + }, + ports: { + user: mockUserPortSettings, + password: mockPasswordPortSettings, + otp: stubOtpPortSettings, + recoveryNotification: stubRecoveryNotificationPortSettings, + verifyNotification: stubVerifyNotificationPortSettings, + }, + }), + ], + controllers: [UserControllerFixtures], + exports: [CqrsModule], +}) +export class AppModuleFixture {} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/fixtures/user.controller.fixture.ts b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/fixtures/user.controller.fixture.ts new file mode 100644 index 000000000..e28dcb816 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/fixtures/user.controller.fixture.ts @@ -0,0 +1,15 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; + +import { JwtGuard } from '../../jwt.guard.js'; + +@Controller('user') +@UseGuards(JwtGuard) +export class UserControllerFixtures { + /** + * Status + */ + @Get('status') + getStatus(): boolean { + return true; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/fixtures/user.module.fixture.ts b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/fixtures/user.module.fixture.ts new file mode 100644 index 000000000..b08c5c7c2 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/fixtures/user.module.fixture.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; + +import { GuardsPolicy } from '../../../../../domain/policies/guards.policy.js'; + +import { UserControllerFixtures } from './user.controller.fixture.js'; + +@Global() +@Module({ + controllers: [UserControllerFixtures], + providers: [{ provide: GuardsPolicy, useValue: new GuardsPolicy() }], +}) +export class UserModuleFixture {} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/jwt.guard.e2e-spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/jwt.guard.e2e-spec.ts new file mode 100644 index 000000000..7139800a8 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/jwt.guard.e2e-spec.ts @@ -0,0 +1,58 @@ +import { sign } from 'jsonwebtoken'; +import supertest from 'supertest'; + +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { FIXTURE_USER } from '../../../../__tests__/fixtures/user.module.fixture.js'; + +import { AppModuleFixture } from './fixtures/app.module.fixture.js'; + +describe('JwtGuard (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + app = moduleFixture.createNestApplication(); + + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('GET /user/status', () => { + it('should return 401 when no Authorization header is present', async () => { + await supertest(app.getHttpServer()).get('/user/status').expect(401); + }); + + it('should return 401 when bearer token is invalid', async () => { + await supertest(app.getHttpServer()) + .get('/user/status') + .set('Authorization', 'Bearer invalid.jwt.token') + .expect(401); + }); + + it('should return 401 when token is signed with wrong secret', async () => { + const token = sign({ sub: FIXTURE_USER.id }, 'wrong-secret'); + + await supertest(app.getHttpServer()) + .get('/user/status') + .set('Authorization', `Bearer ${token}`) + .expect(401); + }); + + it('should return 200 when bearer token is valid', async () => { + const token = sign({ sub: FIXTURE_USER.id }, 'test-access-secret'); + + await supertest(app.getHttpServer()) + .get('/user/status') + .set('Authorization', `Bearer ${token}`) + .expect(200); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/jwt.guard.spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/jwt.guard.spec.ts new file mode 100644 index 000000000..b2d3d3635 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/jwt.guard.spec.ts @@ -0,0 +1,65 @@ +import { randomUUID } from 'crypto'; + +import { type MockInstance } from 'vitest'; +import { mock } from 'vitest-mock-extended'; + +import { type ExecutionContext } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; + +import { type ReferenceIdInterface } from '@concepta/nestjs-core'; + +import { JwtUnauthorizedException } from '../exceptions/jwt-unauthorized.exception.js'; +import { JwtGuard } from '../jwt.guard.js'; + +import { UserModuleFixture } from './fixtures/user.module.fixture.js'; + +describe(JwtGuard, () => { + let context: ExecutionContext; + let jwtGuard: JwtGuard; + let spyCanActivate: MockInstance; + let user: ReferenceIdInterface; + + beforeEach(async () => { + context = mock(); + + const moduleRef = await Test.createTestingModule({ + imports: [UserModuleFixture], + }).compile(); + jwtGuard = moduleRef.get(JwtGuard); + spyCanActivate = vi + .spyOn(JwtGuard.prototype, 'canActivate') + .mockImplementation(() => true); + user = { id: randomUUID() }; + }); + + describe(JwtGuard.prototype.canActivate, () => { + it('should be success', async () => { + await jwtGuard.canActivate(context); + expect(spyCanActivate).toHaveBeenCalled(); + expect(spyCanActivate).toHaveBeenCalledWith(context); + }); + }); + + describe(JwtGuard.prototype.handleRequest, () => { + it('should return user', () => { + const response = jwtGuard.handleRequest( + undefined, + user, + ); + expect(response?.id).toBe(user.id); + }); + it('should throw error', () => { + const error = new Error(); + const t = () => { + jwtGuard.handleRequest(error, user); + }; + expect(t).toThrow(); + }); + it('should throw error unauthorized', () => { + const t = () => { + jwtGuard.handleRequest(undefined, undefined); + }; + expect(t).toThrow(JwtUnauthorizedException); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/jwt.strategy.spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/jwt.strategy.spec.ts new file mode 100644 index 000000000..e9f4c0c72 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/__tests__/jwt.strategy.spec.ts @@ -0,0 +1,58 @@ +import { randomUUID } from 'crypto'; + +import { mock } from 'vitest-mock-extended'; + +import { type AuthorizationPayloadInterface } from '../../../../domain/interfaces/authorization-payload.interface.js'; +import { JwtStrategyPolicy } from '../../../../domain/policies/jwt-strategy.policy.js'; +import { type JwtPort } from '../../../../domain/ports/jwt.port.js'; +import { + type AuthenticationUserResult, + type UserPort, +} from '../../../../domain/ports/user.port.js'; +import { JwtUnauthorizedException } from '../exceptions/jwt-unauthorized.exception.js'; +import { JwtStrategy } from '../jwt.strategy.js'; + +describe(JwtStrategy, () => { + let user: NonNullable; + let jwtPort: JwtPort; + let userPort: UserPort; + let jwtStrategy: JwtStrategy; + let authorizationPayload: AuthorizationPayloadInterface; + + beforeEach(async () => { + jwtPort = mock(); + userPort = mock(); + jwtStrategy = new JwtStrategy(new JwtStrategyPolicy({}), jwtPort, userPort); + authorizationPayload = mock(); + user = { + id: randomUUID(), + email: 'test@example.com', + username: 'test', + active: true, + }; + }); + + describe(JwtStrategy.prototype.validate, () => { + it('should return user', async () => { + void userPort.getBySubject; + vi.spyOn(userPort, 'getBySubject').mockImplementationOnce(async () => { + return user; + }); + const userResponse = await jwtStrategy.validate(authorizationPayload, {}); + expect(userResponse.id).toBe(user.id); + }); + + it('should throw error', async () => { + void userPort.getBySubject; + vi.spyOn(userPort, 'getBySubject').mockImplementationOnce(() => { + return new Promise((resolve) => { + resolve(null); + }); + }); + const t = async () => { + await jwtStrategy.validate(authorizationPayload, {}); + }; + await expect(t).rejects.toThrow(JwtUnauthorizedException); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/jwt/exceptions/jwt-authentication.exception.ts b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/exceptions/jwt-authentication.exception.ts new file mode 100644 index 000000000..186dd86a4 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/exceptions/jwt-authentication.exception.ts @@ -0,0 +1,10 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthenticationException } from '../../../../domain/exceptions/authentication.exception.js'; + +export class JwtAuthenticationException extends AuthenticationException { + constructor(options?: RuntimeExceptionOptions) { + super(options); + this.errorCode = 'AUTH_JWT_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/jwt/exceptions/jwt-unauthorized.exception.ts b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/exceptions/jwt-unauthorized.exception.ts new file mode 100644 index 000000000..6a70fb9d1 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/exceptions/jwt-unauthorized.exception.ts @@ -0,0 +1,18 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { JwtAuthenticationException } from './jwt-authentication.exception.js'; + +export class JwtUnauthorizedException extends JwtAuthenticationException { + constructor(options?: Omit) { + super({ + safeMessage: 'Unable to authenticate user with provided JWT token.', + fault: 'client', + ...options, + httpStatus: HttpStatus.UNAUTHORIZED, + }); + + this.errorCode = 'AUTH_JWT_UNAUTHORIZED_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/jwt/jwt.constants.ts b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/jwt.constants.ts new file mode 100644 index 000000000..d04be798d --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/jwt.constants.ts @@ -0,0 +1 @@ +export const JWT_STRATEGY_NAME = 'jwt'; diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/jwt/jwt.guard.ts b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/jwt.guard.ts new file mode 100644 index 000000000..38c478b0c --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/jwt.guard.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { ReferenceIdInterface } from '@concepta/nestjs-core'; + +import { GuardsPolicy } from '../../../domain/policies/guards.policy.js'; +import { AuthGuard } from '../../auth.guard.js'; + +import { JwtUnauthorizedException } from './exceptions/jwt-unauthorized.exception.js'; +import { JWT_STRATEGY_NAME } from './jwt.constants.js'; + +@Injectable() +export class JwtGuard extends AuthGuard(JWT_STRATEGY_NAME, { + canDisable: true, +}) { + constructor(guardsPolicy: GuardsPolicy, reflector: Reflector) { + super(guardsPolicy, reflector); + } + + handleRequest( + err: Error | undefined, + user: T, + info?: Error, + ) { + // You can throw an exception based on either "info" or "err" arguments + if (err || !user) { + // deliberately collapsed to one status: distinguishing "expired" from + // "invalid signature" from "user deleted" is an oracle for an attacker. + throw new JwtUnauthorizedException({ originalError: err ?? info }); + } + return user; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/jwt/jwt.strategy.ts b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/jwt.strategy.ts new file mode 100644 index 000000000..758cfc2ce --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/jwt/jwt.strategy.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { ReferenceIdInterface, getAppContext } from '@concepta/nestjs-core'; + +import { + AUTHENTICATION_JWT_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, +} from '../../../authentication.constants.js'; +import { AuthorizationPayloadInterface } from '../../../domain/interfaces/authorization-payload.interface.js'; +import { JwtStrategyPolicy } from '../../../domain/policies/jwt-strategy.policy.js'; +import { JwtPort } from '../../../domain/ports/jwt.port.js'; +import { UserPort } from '../../../domain/ports/user.port.js'; +import { JwtPassportStrategy } from '../../passport/jwt-passport.strategy.js'; +import { PassportStrategyFactory } from '../../passport/passport-strategy.factory.js'; +import { createVerifyTokenCallback } from '../../passport/utils/create-verify-token-callback.util.js'; + +import { JwtUnauthorizedException } from './exceptions/jwt-unauthorized.exception.js'; +import { JWT_STRATEGY_NAME } from './jwt.constants.js'; + +@Injectable() +export class JwtStrategy extends PassportStrategyFactory( + JwtPassportStrategy, + JWT_STRATEGY_NAME, +) { + constructor( + @Inject(JwtStrategyPolicy) + policy: JwtStrategyPolicy, + @Inject(AUTHENTICATION_JWT_PORT_TOKEN) + jwtPort: JwtPort, + @Inject(AUTHENTICATION_USER_PORT_TOKEN) + private userPort: UserPort, + ) { + super({ + jwtFromRequest: policy.jwtFromRequest, + verifyToken: createVerifyTokenCallback(jwtPort, 'access'), + }); + } + + async validate( + payload: AuthorizationPayloadInterface, + req: unknown, + ): Promise { + const user = await this.userPort.getBySubject( + getAppContext(req), + payload.sub, + ); + + if (user) { + return user; + } else { + throw new JwtUnauthorizedException(); + } + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/app.module.fixture.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/app.module.fixture.ts new file mode 100644 index 000000000..0056cc9ea --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/app.module.fixture.ts @@ -0,0 +1,49 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { mockPasswordPortSettings } from '../../../../../__tests__/fixtures/ports/mock-password-port.provider.js'; +import { mockUserPortSettings } from '../../../../../__tests__/fixtures/ports/mock-user-port.provider.js'; +import { + stubOtpPortSettings, + stubRecoveryNotificationPortSettings, + stubVerifyNotificationPortSettings, +} from '../../../../../__tests__/fixtures/ports/stub-unused-ports.fixture.js'; +import { AuthenticationModule } from '../../../../../authentication.module.js'; + +import { UserModuleFixture } from './user.module.fixture.js'; + +@Module({ + imports: [ + CqrsModule, + AuthenticationModule.forRoot({ + appGuard: false, + settings: { + jwt: { + access: { + secret: 'test-access-secret', + signOptions: { expiresIn: '1h' }, + }, + refresh: { + secret: 'test-refresh-secret', + signOptions: { expiresIn: '7d' }, + }, + }, + strategies: { + jwt: {}, + local: {}, + refresh: {}, + }, + }, + ports: { + user: mockUserPortSettings, + password: mockPasswordPortSettings, + otp: stubOtpPortSettings, + recoveryNotification: stubRecoveryNotificationPortSettings, + verifyNotification: stubVerifyNotificationPortSettings, + }, + }), + UserModuleFixture, + ], + exports: [CqrsModule], +}) +export class AppModuleFixture {} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/constants.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/constants.ts new file mode 100644 index 000000000..2e174059c --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/constants.ts @@ -0,0 +1,15 @@ +import { randomUUID } from 'crypto'; + +import { type LocalCredentialsInterface } from '../../interfaces/local-credentials.interface.js'; + +export const LOGIN_SUCCESS = { + username: 'random_username', + password: 'random_password', +}; + +export const USER_SUCCESS: LocalCredentialsInterface = { + id: randomUUID(), + active: true, + passwordHash: LOGIN_SUCCESS.password, + username: LOGIN_SUCCESS.username, +}; diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/local.controller.fixture.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/local.controller.fixture.ts new file mode 100644 index 000000000..f254ffcbf --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/local.controller.fixture.ts @@ -0,0 +1,60 @@ +import { Controller, HttpStatus, Post, UseGuards } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { + ApiBody, + ApiResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; + +import { IssueAuthenticatedResponseCommand } from '../../../../../application/commands/impl/issue-authenticated-response.command.js'; +import { AuthenticatedResponseInterface } from '../../../../../domain/interfaces/authenticated-response.interface.js'; +import { AuthenticatedUserInterface } from '../../../../../domain/interfaces/authenticated-user.interface.js'; +import { AuthPublic } from '../../../../decorators/auth-public.decorator.js'; +import { AuthUser } from '../../../../decorators/auth-user.decorator.js'; +import { authenticationResponseSchema } from '../../../../schemas/authentication-response.schema.js'; +import { LocalGuard } from '../../local.guard.js'; +import { localLoginSchema } from '../../schemas/local-login.schema.js'; + +const localLoginBodySchema = localLoginSchema['~standard'].jsonSchema?.input?.({ + target: 'openapi-3.0', +}); + +if (!localLoginBodySchema) { + throw new Error( + 'localLoginSchema is missing its OpenAPI bridge — wrap it with withOpenApi() first.', + ); +} + +/** + * Auth Local controller + */ +@Controller('auth/login') +@UseGuards(LocalGuard) +@AuthPublic({ classLevel: true }) +@ApiTags('auth') +export class LocalControllerFixture { + constructor(private readonly commandBus: CommandBus) {} + + /** + * Login + */ + @ApiBody({ + schema: localLoginBodySchema, + description: 'Schema containing username and password.', + }) + @ApiResponse({ + status: HttpStatus.OK, + standardSchema: authenticationResponseSchema, + description: 'Schema containing an access token and a refresh token.', + }) + @ApiUnauthorizedResponse() + @Post() + async login( + @AuthUser() user: AuthenticatedUserInterface, + ): Promise { + return this.commandBus.execute( + new IssueAuthenticatedResponseCommand({}, user.id), + ); + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/user.module.fixture.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/user.module.fixture.ts new file mode 100644 index 000000000..10fe832ca --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/fixtures/user.module.fixture.ts @@ -0,0 +1,47 @@ +import { Global, Module } from '@nestjs/common'; +import { CqrsModule, QueryHandler } from '@nestjs/cqrs'; + +import { mockPasswordPortHandlers } from '../../../../../__tests__/fixtures/ports/mock-password-port.provider.js'; +import { + MockGetUserByIdHandler, + MockGetUserByEmailHandler, + MockGetUserBySubjectQuery, + MockGetUserByUsernameQuery, + MockUpdateUserHandler, +} from '../../../../../__tests__/fixtures/ports/mock-user-port.provider.js'; + +import { USER_SUCCESS } from './constants.js'; + +@QueryHandler(MockGetUserByUsernameQuery) +class GetUserByUsernameHandler { + async execute(query: MockGetUserByUsernameQuery) { + return query.username === USER_SUCCESS.username ? USER_SUCCESS : null; + } +} + +@QueryHandler(MockGetUserBySubjectQuery) +class GetUserBySubjectHandler { + async execute() { + return USER_SUCCESS; + } +} + +// AUTHENTICATION_USER_PORT_TOKEN / AUTHENTICATION_PASSWORD_PORT_TOKEN are +// provided by AuthenticationModule itself via `ports.user`/`ports.password` +// (see AppModuleFixture) — this module only supplies the CQRS handlers that +// UserPort/PasswordPort dispatch to. +@Global() +@Module({ + imports: [CqrsModule], + providers: [ + // user port handlers (override username + subject lookups) + MockGetUserByIdHandler, + GetUserByUsernameHandler, + GetUserBySubjectHandler, + MockGetUserByEmailHandler, + MockUpdateUserHandler, + // password port handlers + ...mockPasswordPortHandlers, + ], +}) +export class UserModuleFixture {} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/local.controller.e2e-spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/local.controller.e2e-spec.ts new file mode 100644 index 000000000..3cd610746 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/local.controller.e2e-spec.ts @@ -0,0 +1,138 @@ +import supertest from 'supertest'; + +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { PasswordValidationService } from '@concepta/nestjs-password'; + +import { LocalService } from '../../../../application/services/local/local.service.js'; +import { LocalInvalidCredentialsException } from '../exceptions/local-invalid-credentials.exception.js'; + +import { AppModuleFixture } from './fixtures/app.module.fixture.js'; +import { LOGIN_SUCCESS } from './fixtures/constants.js'; +import { LocalControllerFixture } from './fixtures/local.controller.fixture.js'; + +describe('AuthLocalController (e2e)', () => { + let app: INestApplication; + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + controllers: [LocalControllerFixture], + }) + .overrideProvider(PasswordValidationService) + .useValue({ + validate: () => { + return true; + }, + }) + .compile(); + app = moduleFixture.createNestApplication(); + + await app.init(); + }); + + it('POST auth/login success', async () => { + await supertest(app.getHttpServer()) + .post('/auth/login') + .send(LOGIN_SUCCESS) + .then((response) => { + expect(response.body.accessToken).toBeDefined(); + expect(response.body.refreshToken).toBeDefined(); + expect(response.status).toBe(201); + }); + }); + + it('POST auth/login username not found ', async () => { + await supertest(app.getHttpServer()) + .post('/auth/login') + .send({ + ...LOGIN_SUCCESS, + username: 'no_user', + }) + .then((response) => { + expect(response.body.message).toBe( + 'The provided username or password is incorrect. Please try again.', + ); + expect(response.status).toBe(401); + }); + }); + + it('POST auth/login username not found with custom message', async () => { + const validateUserService = app.get(LocalService); + + vi.spyOn(validateUserService, 'validateUser').mockImplementationOnce(() => { + throw new LocalInvalidCredentialsException({ + safeMessage: 'Custom invalid credentials message', + }); + }); + + await supertest(app.getHttpServer()) + .post('/auth/login') + .send({ + ...LOGIN_SUCCESS, + username: 'no_user', + }) + .then((response) => { + expect(response.body.message).toBe( + 'Custom invalid credentials message', + ); + expect(response.status).toBe(401); + }); + }); + + it('POST auth/login password fail ', async () => { + await supertest(app.getHttpServer()) + .post('/auth/login') + .send({ + ...LOGIN_SUCCESS, + password: '', + }) + .then((response) => { + expect(response.body.message).toBe('Unauthorized'); + expect(response.status).toBe(401); + }); + }); + + it('POST auth/login username fail ', async () => { + await supertest(app.getHttpServer()) + .post('/auth/login') + .send({ + ...LOGIN_SUCCESS, + username: '', + }) + .then((response) => { + expect(response.body.message).toBe('Unauthorized'); + expect(response.status).toBe(401); + }); + }); + + it('POST auth/login username fail ', async () => { + await supertest(app.getHttpServer()) + .post('/auth/login') + .send({ + ...LOGIN_SUCCESS, + username: 999, + }) + .then((response) => { + expect(response.body.message).toBe( + 'The login data provided is invalid.', + ); + expect(response.status).toBe(400); + }); + }); + + it('POST auth/login password fail ', async () => { + await supertest(app.getHttpServer()) + .post('/auth/login') + .send({ + ...LOGIN_SUCCESS, + password: 999, + }) + .then((response) => { + expect(response.body.message).toBe( + 'The login data provided is invalid.', + ); + expect(response.status).toBe(400); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/local.controller.spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/local.controller.spec.ts new file mode 100644 index 000000000..b30243f08 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/local.controller.spec.ts @@ -0,0 +1,46 @@ +import { randomUUID } from 'crypto'; + +import { type MockProxy, mock } from 'vitest-mock-extended'; + +import { type CommandBus } from '@nestjs/cqrs'; + +import { IssueAuthenticatedResponseCommand } from '../../../../application/commands/impl/issue-authenticated-response.command.js'; +import { type AuthenticatedResponseInterface } from '../../../../domain/interfaces/authenticated-response.interface.js'; +import { type AuthenticatedUserInterface } from '../../../../domain/interfaces/authenticated-user.interface.js'; + +import { LocalControllerFixture } from './fixtures/local.controller.fixture.js'; + +describe(LocalControllerFixture, () => { + const accessToken = 'accessToken'; + const refreshToken = 'refreshToken'; + let controller: LocalControllerFixture; + let commandBus: MockProxy; + const response: AuthenticatedResponseInterface = { + accessToken, + refreshToken, + }; + + beforeEach(async () => { + commandBus = mock(); + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue(response); + controller = new LocalControllerFixture(commandBus); + }); + + describe(LocalControllerFixture.prototype.login, () => { + it('should return user', async () => { + const user: AuthenticatedUserInterface = { + id: randomUUID(), + }; + const result = await controller.login(user); + expect(result.accessToken).toBe(response.accessToken); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(IssueAuthenticatedResponseCommand), + ); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ id: user.id }), + ); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/local.strategy.spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/local.strategy.spec.ts new file mode 100644 index 000000000..9eef12ea6 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/__tests__/local.strategy.spec.ts @@ -0,0 +1,150 @@ +import { randomUUID } from 'crypto'; + +import { mock } from 'vitest-mock-extended'; + +import { HttpStatus } from '@nestjs/common'; + +import { type ReferenceIdInterface } from '@concepta/nestjs-core'; + +import { type LocalServiceInterface } from '../../../../application/services/local/interfaces/local-service.interface.js'; +import { type LocalValidateUserInterface } from '../../../../application/services/local/interfaces/local-validate-user.interface.js'; +import { LocalService } from '../../../../application/services/local/local.service.js'; +import { LocalStrategyPolicy } from '../../../../domain/policies/local-strategy.policy.js'; +import { type PasswordPort } from '../../../../domain/ports/password.port.js'; +import { + type AuthenticationUserResult, + type UserPort, +} from '../../../../domain/ports/user.port.js'; +import { LocalInvalidCredentialsException } from '../exceptions/local-invalid-credentials.exception.js'; +import { LocalInvalidLoginDataException } from '../exceptions/local-invalid-login-data.exception.js'; +import { LocalException } from '../exceptions/local.exception.js'; +import { LocalStrategy } from '../local.strategy.js'; +import { localLoginSchema } from '../schemas/local-login.schema.js'; + +describe(LocalStrategy.name, () => { + const USERNAME = 'username'; + const PASSWORD = 'password'; + + let user: NonNullable; + let policy: LocalStrategyPolicy; + let userPort: UserPort; + let passwordPort: PasswordPort; + let validateUserService: LocalServiceInterface; + let localStrategy: LocalStrategy; + + beforeEach(async () => { + policy = new LocalStrategyPolicy({ + loginSchema: localLoginSchema, + usernameField: USERNAME, + passwordField: PASSWORD, + }); + + userPort = mock(); + passwordPort = mock(); + validateUserService = new LocalService(userPort, passwordPort); + localStrategy = new LocalStrategy(policy, validateUserService); + + user = { + id: randomUUID(), + email: 'test@example.com', + username: 'test', + active: true, + }; + vi.resetAllMocks(); + void userPort.getByUsername; + vi.spyOn(userPort, 'getByUsername').mockResolvedValue(user); + }); + + describe(LocalStrategy.prototype.validate, () => { + it('should return user', async () => { + void passwordPort.validate; + vi.spyOn(passwordPort, 'validate').mockResolvedValue(true); + + const result = await localStrategy.validate({}, USERNAME, PASSWORD); + expect(result.id).toBe(user.id); + }); + + it('should fail to validate user', async () => { + vi.spyOn(validateUserService, 'validateUser').mockImplementationOnce( + (_ctx, _dto: LocalValidateUserInterface) => { + return null as unknown as Promise>; + }, + ); + + const t = () => localStrategy.validate({}, USERNAME, PASSWORD); + await expect(t).rejects.toThrow(LocalInvalidCredentialsException); + }); + + it('should fail to validate user with custom message', async () => { + vi.spyOn(validateUserService, 'validateUser').mockImplementation( + (_ctx, _dto: LocalValidateUserInterface) => { + throw new LocalInvalidCredentialsException({ + message: 'Custom message', + safeMessage: 'Custom safe message', + }); + }, + ); + + const call = localStrategy.validate({}, USERNAME, PASSWORD); + + await expect(call).rejects.toBeInstanceOf( + LocalInvalidCredentialsException, + ); + await expect(call).rejects.toMatchObject({ + httpStatus: HttpStatus.UNAUTHORIZED, + message: 'Custom message', + safeMessage: 'Custom safe message', + }); + }); + + it('should fail with internal server error', async () => { + vi.spyOn(validateUserService, 'validateUser').mockImplementation( + (_ctx, _dto: LocalValidateUserInterface) => { + throw new Error('This is really bad'); + }, + ); + + const call = localStrategy.validate({}, USERNAME, PASSWORD); + + await expect(call).rejects.toBeInstanceOf(LocalException); + await expect(call).rejects.toMatchObject({ + httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + context: expect.objectContaining({ + originalError: expect.objectContaining({ + message: 'This is really bad', + }), + }), + }); + }); + + it('should throw error on validateOrReject', async () => { + const t = () => localStrategy.validate({}, USERNAME, ''); + await expect(t).rejects.toThrow(); + }); + + it('should throw BadRequest when login schema validation fails', async () => { + vi.spyOn(localLoginSchema['~standard'], 'validate').mockResolvedValueOnce( + { issues: [{ message: 'invalid' }] }, + ); + + const t = () => localStrategy.validate({}, USERNAME, PASSWORD); + await expect(t).rejects.toThrow(LocalInvalidLoginDataException); + }); + + it('should return no user on userPort.getByUsername', async () => { + void userPort.getByUsername; + vi.spyOn(userPort, 'getByUsername').mockResolvedValue(null); + + const t = () => localStrategy.validate({}, USERNAME, PASSWORD); + await expect(t).rejects.toThrow(LocalInvalidCredentialsException); + }); + + it('should be invalid on passwordPort.validate', async () => { + void passwordPort.validate; + vi.spyOn(passwordPort, 'validate').mockResolvedValue(false); + + const t = () => localStrategy.validate({}, USERNAME, PASSWORD); + await expect(t).rejects.toThrow(LocalInvalidCredentialsException); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local-invalid-credentials.exception.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local-invalid-credentials.exception.ts new file mode 100644 index 000000000..28a48eb97 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local-invalid-credentials.exception.ts @@ -0,0 +1,16 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { LocalUnauthorizedException } from './local-unauthorized.exception.js'; + +export class LocalInvalidCredentialsException extends LocalUnauthorizedException { + constructor(options?: Omit) { + super({ + safeMessage: + 'The provided username or password is incorrect. Please try again.', + fault: 'client', + ...options, + }); + + this.errorCode = 'AUTH_LOCAL_INVALID_CREDENTIALS_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local-invalid-login-data.exception.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local-invalid-login-data.exception.ts new file mode 100644 index 000000000..a0f7403d6 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local-invalid-login-data.exception.ts @@ -0,0 +1,19 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { LocalException } from './local.exception.js'; + +export class LocalInvalidLoginDataException extends LocalException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Data validation error occurred before user validation.', + safeMessage: 'The login data provided is invalid.', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + + this.errorCode = 'AUTH_LOCAL_INVALID_LOGIN_DATA_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local-unauthorized.exception.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local-unauthorized.exception.ts new file mode 100644 index 000000000..82767c68c --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local-unauthorized.exception.ts @@ -0,0 +1,18 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { LocalException } from './local.exception.js'; + +export class LocalUnauthorizedException extends LocalException { + constructor(options?: Omit) { + super({ + message: 'Unauthorized', + safeMessage: 'Unauthorized', + ...options, + httpStatus: HttpStatus.UNAUTHORIZED, + }); + + this.errorCode = 'AUTH_LOCAL_UNAUTHORIZED_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local.exception.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local.exception.ts new file mode 100644 index 000000000..2ef05f86f --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/exceptions/local.exception.ts @@ -0,0 +1,10 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthenticationException } from '../../../../domain/exceptions/authentication.exception.js'; + +export class LocalException extends AuthenticationException { + constructor(options?: RuntimeExceptionOptions) { + super(options); + this.errorCode = 'AUTH_LOCAL_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/interfaces/local-credentials.interface.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/interfaces/local-credentials.interface.ts new file mode 100644 index 000000000..c276f1fdb --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/interfaces/local-credentials.interface.ts @@ -0,0 +1,13 @@ +import { + type ReferenceActiveInterface, + type ReferenceIdInterface, + type ReferenceUsernameInterface, +} from '@concepta/nestjs-core'; +import { type PasswordStorageInterface } from '@concepta/nestjs-password'; + +export interface LocalCredentialsInterface + extends + ReferenceIdInterface, + ReferenceUsernameInterface, + ReferenceActiveInterface, + PasswordStorageInterface {} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/local.constants.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/local.constants.ts new file mode 100644 index 000000000..8d37a04b8 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/local.constants.ts @@ -0,0 +1 @@ +export const LOCAL_STRATEGY_NAME = 'local'; diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/local.guard.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/local.guard.ts new file mode 100644 index 000000000..7560c0ba2 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/local.guard.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; + +import { AuthGuard } from '../../auth.guard.js'; + +import { LOCAL_STRATEGY_NAME } from './local.constants.js'; + +@Injectable() +export class LocalGuard extends AuthGuard(LOCAL_STRATEGY_NAME, { + canDisable: false, +}) {} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/local.strategy.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/local.strategy.ts new file mode 100644 index 000000000..2568ed49a --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/local.strategy.ts @@ -0,0 +1,113 @@ +import { Strategy } from 'passport-local'; + +import { Inject, Injectable } from '@nestjs/common'; + +import { + ReferenceIdInterface, + ReferenceUsername, + getAppContext, +} from '@concepta/nestjs-core'; + +import { LocalServiceInterface } from '../../../application/services/local/interfaces/local-service.interface.js'; +import { LocalService } from '../../../application/services/local/local.service.js'; +import { LocalStrategyPolicy } from '../../../domain/policies/local-strategy.policy.js'; +import { PassportStrategyFactory } from '../../passport/passport-strategy.factory.js'; + +import { LocalInvalidCredentialsException } from './exceptions/local-invalid-credentials.exception.js'; +import { LocalInvalidLoginDataException } from './exceptions/local-invalid-login-data.exception.js'; +import { LocalException } from './exceptions/local.exception.js'; +import { LOCAL_STRATEGY_NAME } from './local.constants.js'; + +/** + * Define the Local strategy using passport. + * + * Local strategy is used to authenticate a user using a username and password. + * The field username and password can be configured using the `usernameField` and `passwordField` properties. + */ +@Injectable() +export class LocalStrategy extends PassportStrategyFactory( + Strategy, + LOCAL_STRATEGY_NAME, +) { + /** + * @param policy - The local strategy policy + * @param validateUserService - The service used validate passwords + */ + constructor( + @Inject(LocalStrategyPolicy) + private policy: LocalStrategyPolicy, + @Inject(LocalService) + private validateUserService: LocalServiceInterface, + ) { + super({ + usernameField: policy.usernameField, + passwordField: policy.passwordField, + passReqToCallback: true, + }); + } + + /** + * Validate the user based on the username and password + * from the request body + * + * @param req - The request object + * @param username - The username to authenticate + * @param password - The plain text password + */ + async validate(req: unknown, username: ReferenceUsername, password: string) { + const { loginSchema, usernameField, passwordField } = this.policy; + + if (!loginSchema) { + throw new LocalException({ + message: 'Login schema is not configured.', + fault: 'usage', + }); + } + + const result = await loginSchema['~standard'].validate({ + [usernameField]: username, + [passwordField]: password, + }); + + if (result.issues) { + throw new LocalInvalidLoginDataException({ + originalError: result.issues, + }); + } + + let validatedUser: ReferenceIdInterface; + + try { + // try to get fully validated user + validatedUser = await this.validateUserService.validateUser( + getAppContext(req), + { + username, + password, + }, + ); + } catch (e) { + // did they throw an invalid credentials exception? + if (e instanceof LocalInvalidCredentialsException) { + // yes, use theirs + throw e; + } else { + // something else went wrong — deliberately flattened to a generic + // 500 rather than passed through: a distinguishable status + // (e.g. a 404 UserNotFoundException) here is a username-enumeration + // oracle on a login endpoint. + throw new LocalException({ originalError: e }); + } + } + + // did we get a valid user? + if (!validatedUser) { + throw new LocalInvalidCredentialsException({ + message: `Unable to validate user with username: %s`, + messageParams: [username], + }); + } + + return validatedUser; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/schemas/local-login.schema.spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/schemas/local-login.schema.spec.ts new file mode 100644 index 000000000..e5f9b26cd --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/schemas/local-login.schema.spec.ts @@ -0,0 +1,29 @@ +import { localLoginSchema } from './local-login.schema.js'; + +describe('localLoginSchema', () => { + const valid = { username: 'user', password: 'pass' }; + + it('accepts a valid login payload', () => { + expect(localLoginSchema.parse(valid)).toEqual(valid); + }); + + it('accepts an empty password (faithful to legacy @IsString() with no minimum)', () => { + expect(localLoginSchema.parse({ ...valid, password: '' })).toEqual({ + ...valid, + password: '', + }); + }); + + it('rejects a username longer than 255 characters', () => { + const result = localLoginSchema.safeParse({ + ...valid, + username: 'a'.repeat(256), + }); + expect(result.success).toBe(false); + }); + + it('rejects a missing password', () => { + const { password: _password, ...rest } = valid; + expect(localLoginSchema.safeParse(rest).success).toBe(false); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/local/schemas/local-login.schema.ts b/packages/nestjs-authentication/src/infrastructure/strategies/local/schemas/local-login.schema.ts new file mode 100644 index 000000000..e416a405e --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/local/schemas/local-login.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type AuthenticationLoginInterface } from '../../../../domain/interfaces/authentication-login.interface.js'; + +export const localLoginSchema = withOpenApi( + conformsTo()( + z.object({ + username: z.string().max(255).meta({ description: 'Username' }), + password: z.string().max(72).meta({ description: 'Password' }), + }), + ), +); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/fixtures/app.module.fixture.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/fixtures/app.module.fixture.ts new file mode 100644 index 000000000..f89ece676 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/fixtures/app.module.fixture.ts @@ -0,0 +1,49 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { mockPasswordPortSettings } from '../../../../../__tests__/fixtures/ports/mock-password-port.provider.js'; +import { mockUserPortSettings } from '../../../../../__tests__/fixtures/ports/mock-user-port.provider.js'; +import { + stubOtpPortSettings, + stubRecoveryNotificationPortSettings, + stubVerifyNotificationPortSettings, +} from '../../../../../__tests__/fixtures/ports/stub-unused-ports.fixture.js'; +import { UserModuleFixture } from '../../../../../__tests__/fixtures/user.module.fixture.js'; +import { AuthenticationModule } from '../../../../../authentication.module.js'; + +import { RefreshControllerFixture } from './refresh.controller.fixture.js'; + +@Module({ + imports: [ + CqrsModule, + UserModuleFixture, + AuthenticationModule.forRoot({ + appGuard: false, + settings: { + jwt: { + access: { + secret: 'test-access-secret', + signOptions: { expiresIn: '1h' }, + }, + refresh: { + secret: 'test-refresh-secret', + signOptions: { expiresIn: '7d' }, + }, + }, + strategies: { + refresh: {}, + }, + }, + ports: { + user: mockUserPortSettings, + password: mockPasswordPortSettings, + otp: stubOtpPortSettings, + recoveryNotification: stubRecoveryNotificationPortSettings, + verifyNotification: stubVerifyNotificationPortSettings, + }, + }), + ], + controllers: [RefreshControllerFixture], + exports: [CqrsModule], +}) +export class AppModuleFixture {} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/fixtures/refresh.controller.fixture.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/fixtures/refresh.controller.fixture.ts new file mode 100644 index 000000000..81c360c5f --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/fixtures/refresh.controller.fixture.ts @@ -0,0 +1,60 @@ +import { Controller, HttpStatus, Post, UseGuards } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { + ApiBody, + ApiResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; + +import { IssueAuthenticatedResponseCommand } from '../../../../../application/commands/impl/issue-authenticated-response.command.js'; +import { AuthenticatedResponseInterface } from '../../../../../domain/interfaces/authenticated-response.interface.js'; +import { AuthenticatedUserInterface } from '../../../../../domain/interfaces/authenticated-user.interface.js'; +import { AuthPublic } from '../../../../decorators/auth-public.decorator.js'; +import { AuthUser } from '../../../../decorators/auth-user.decorator.js'; +import { authenticationResponseSchema } from '../../../../schemas/authentication-response.schema.js'; +import { RefreshGuard } from '../../refresh.guard.js'; +import { refreshSchema } from '../../schemas/refresh.schema.js'; + +const refreshBodySchema = refreshSchema['~standard'].jsonSchema?.input?.({ + target: 'openapi-3.0', +}); + +if (!refreshBodySchema) { + throw new Error( + 'refreshSchema is missing its OpenAPI bridge — wrap it with withOpenApi() first.', + ); +} + +/** + * Auth Local controller + */ +@Controller('token/refresh') +@UseGuards(RefreshGuard) +@AuthPublic({ classLevel: true }) +@ApiTags('auth') +export class RefreshControllerFixture { + constructor(private readonly commandBus: CommandBus) {} + + /** + * Login + */ + @ApiBody({ + schema: refreshBodySchema, + description: 'Schema containing a refresh token.', + }) + @ApiResponse({ + status: HttpStatus.OK, + standardSchema: authenticationResponseSchema, + description: 'Schema containing an access token and a refresh token.', + }) + @ApiUnauthorizedResponse() + @Post() + async refresh( + @AuthUser() user: AuthenticatedUserInterface, + ): Promise { + return this.commandBus.execute( + new IssueAuthenticatedResponseCommand({}, user.id), + ); + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.controller.e2e-spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.controller.e2e-spec.ts new file mode 100644 index 000000000..1537bc3ac --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.controller.e2e-spec.ts @@ -0,0 +1,61 @@ +import { sign } from 'jsonwebtoken'; +import supertest from 'supertest'; + +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { FIXTURE_USER } from '../../../../__tests__/fixtures/user.module.fixture.js'; + +import { AppModuleFixture } from './fixtures/app.module.fixture.js'; + +describe('RefreshController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + app = moduleFixture.createNestApplication(); + + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('POST /token/refresh', () => { + it('should return 201 with new tokens when refresh token is valid', async () => { + const refreshToken = sign( + { sub: FIXTURE_USER.id }, + 'test-refresh-secret', + ); + + await supertest(app.getHttpServer()) + .post('/token/refresh') + .send({ refreshToken }) + .then((response) => { + expect(response.status).toBe(201); + expect(response.body.accessToken).toBeDefined(); + expect(response.body.refreshToken).toBeDefined(); + }); + }); + + it('should return 401 when refresh token is invalid', async () => { + await supertest(app.getHttpServer()) + .post('/token/refresh') + .send({ refreshToken: 'invalid.jwt.token' }) + .expect(401); + }); + + it('should return 401 when refresh token is signed with wrong secret', async () => { + const wrongToken = sign({ sub: FIXTURE_USER.id }, 'wrong-secret'); + + await supertest(app.getHttpServer()) + .post('/token/refresh') + .send({ refreshToken: wrongToken }) + .expect(401); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.controller.spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.controller.spec.ts new file mode 100644 index 000000000..a30b43fca --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.controller.spec.ts @@ -0,0 +1,46 @@ +import { randomUUID } from 'crypto'; + +import { type MockProxy, mock } from 'vitest-mock-extended'; + +import { type CommandBus } from '@nestjs/cqrs'; + +import { IssueAuthenticatedResponseCommand } from '../../../../application/commands/impl/issue-authenticated-response.command.js'; +import { type AuthenticatedResponseInterface } from '../../../../domain/interfaces/authenticated-response.interface.js'; +import { type AuthenticatedUserInterface } from '../../../../domain/interfaces/authenticated-user.interface.js'; + +import { RefreshControllerFixture } from './fixtures/refresh.controller.fixture.js'; + +describe(RefreshControllerFixture, () => { + const accessToken = 'accessToken'; + const refreshToken = 'refreshToken'; + let controller: RefreshControllerFixture; + let commandBus: MockProxy; + const response: AuthenticatedResponseInterface = { + accessToken, + refreshToken, + }; + + beforeEach(async () => { + commandBus = mock(); + void commandBus.execute; + vi.spyOn(commandBus, 'execute').mockResolvedValue(response); + controller = new RefreshControllerFixture(commandBus); + }); + + describe(RefreshControllerFixture.prototype.refresh, () => { + it('should return user', async () => { + const user: AuthenticatedUserInterface = { + id: randomUUID(), + }; + const result = await controller.refresh(user); + expect(result.accessToken).toBe(response.accessToken); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(IssueAuthenticatedResponseCommand), + ); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ id: user.id }), + ); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.guard.spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.guard.spec.ts new file mode 100644 index 000000000..d6d819373 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.guard.spec.ts @@ -0,0 +1,84 @@ +import { mock } from 'vitest-mock-extended'; + +import { type ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; + +import { GuardsPolicy } from '../../../../domain/policies/guards.policy.js'; +import { RefreshUnauthorizedException } from '../exceptions/refresh-unauthorized.exception.js'; +import { REFRESH_STRATEGY_NAME } from '../refresh.constants.js'; +import { RefreshGuard } from '../refresh.guard.js'; + +vi.mock('@nestjs/passport', () => ({ + AuthGuard: vi.fn().mockImplementation(() => vi.fn()), +})); + +describe(RefreshGuard.name, () => { + let guard: RefreshGuard; + let context: ExecutionContext; + + beforeEach(() => { + context = mock(); + guard = new RefreshGuard( + new GuardsPolicy({ enable: true }), + new Reflector(), + ); + }); + + it('should be configured with the refresh passport strategy', () => { + expect(PassportAuthGuard).toHaveBeenCalledWith(REFRESH_STRATEGY_NAME); + }); + + describe('handleRequest', () => { + it('should return the user on success', () => { + const user = { id: 'user-1' }; + expect(guard.handleRequest(undefined, user)).toBe(user); + }); + + it('should throw RefreshUnauthorizedException when an error is provided', () => { + const err = new Error('token expired'); + expect(() => guard.handleRequest(err, undefined)).toThrow( + RefreshUnauthorizedException, + ); + }); + + it('should throw RefreshUnauthorizedException when user is undefined', () => { + expect(() => guard.handleRequest(undefined, undefined)).toThrow( + RefreshUnauthorizedException, + ); + }); + + it('should throw RefreshUnauthorizedException when user is null', () => { + expect(() => guard.handleRequest(undefined, null)).toThrow( + RefreshUnauthorizedException, + ); + }); + + it('should include the info error as originalError when only info is provided', () => { + const info = new Error('jwt expired'); + let caught: unknown; + try { + guard.handleRequest(undefined, undefined, info); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(RefreshUnauthorizedException); + if (caught instanceof RefreshUnauthorizedException) { + expect(caught.context.originalError).toBe(info); + } + }); + }); + + describe('canActivate', () => { + it('should delegate to the passport canActivate', () => { + const spy = vi + .spyOn(RefreshGuard.prototype, 'canActivate') + .mockReturnValue(true); + + const result = guard.canActivate(context); + + expect(spy).toHaveBeenCalledWith(context); + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.strategy.spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.strategy.spec.ts new file mode 100644 index 000000000..4636b0f7f --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/__tests__/refresh.strategy.spec.ts @@ -0,0 +1,66 @@ +import { randomUUID } from 'crypto'; + +import { mock } from 'vitest-mock-extended'; + +import { type AuthorizationPayloadInterface } from '../../../../domain/interfaces/authorization-payload.interface.js'; +import { RefreshStrategyPolicy } from '../../../../domain/policies/refresh-strategy.policy.js'; +import { type JwtPort } from '../../../../domain/ports/jwt.port.js'; +import { + type AuthenticationUserResult, + type UserPort, +} from '../../../../domain/ports/user.port.js'; +import { RefreshUnauthorizedException } from '../exceptions/refresh-unauthorized.exception.js'; +import { RefreshStrategy } from '../refresh.strategy.js'; + +describe(RefreshStrategy, () => { + const USERNAME = 'username'; + + let user: NonNullable; + let userPort: UserPort; + let jwtPort: JwtPort; + let refreshStrategy: RefreshStrategy; + let authorizationPayloadInterface: AuthorizationPayloadInterface; + + beforeEach(async () => { + userPort = mock(); + jwtPort = mock(); + refreshStrategy = new RefreshStrategy( + new RefreshStrategyPolicy({}), + jwtPort, + userPort, + ); + + user = { + id: randomUUID(), + email: 'test@example.com', + username: 'test', + active: true, + }; + + authorizationPayloadInterface = { + sub: USERNAME, + }; + + void userPort.getBySubject; + vi.spyOn(userPort, 'getBySubject').mockResolvedValue(user); + }); + + describe(RefreshStrategy.prototype.validate, () => { + it('should return user', async () => { + const result = await refreshStrategy.validate( + authorizationPayloadInterface, + {}, + ); + expect(result.id).toBe(user.id); + }); + + it(`should throw UnauthorizedException`, async () => { + void userPort.getBySubject; + vi.spyOn(userPort, 'getBySubject').mockResolvedValue(null); + + const t = () => + refreshStrategy.validate(authorizationPayloadInterface, {}); + await expect(t).rejects.toThrow(RefreshUnauthorizedException); + }); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/exceptions/refresh-unauthorized.exception.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/exceptions/refresh-unauthorized.exception.ts new file mode 100644 index 000000000..a1ef14162 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/exceptions/refresh-unauthorized.exception.ts @@ -0,0 +1,19 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { RefreshException } from './refresh.exception.js'; + +export class RefreshUnauthorizedException extends RefreshException { + constructor(options?: Omit) { + super({ + message: `Unauthorized refresh attempt`, + safeMessage: 'Unauthorized refresh attempt.', + fault: 'client', + ...options, + httpStatus: HttpStatus.UNAUTHORIZED, + }); + + this.errorCode = 'AUTH_REFRESH_NOT_AUTHORIZED_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/exceptions/refresh.exception.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/exceptions/refresh.exception.ts new file mode 100644 index 000000000..71b9a1f18 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/exceptions/refresh.exception.ts @@ -0,0 +1,10 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { AuthenticationException } from '../../../../domain/exceptions/authentication.exception.js'; + +export class RefreshException extends AuthenticationException { + constructor(options?: RuntimeExceptionOptions) { + super(options); + this.errorCode = 'AUTH_REFRESH_ERROR'; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/refresh.constants.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/refresh.constants.ts new file mode 100644 index 000000000..c2da25fa3 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/refresh.constants.ts @@ -0,0 +1 @@ +export const REFRESH_STRATEGY_NAME = 'refresh'; diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/refresh.guard.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/refresh.guard.ts new file mode 100644 index 000000000..2caa6a346 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/refresh.guard.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { GuardsPolicy } from '../../../domain/policies/guards.policy.js'; +import { AuthGuard } from '../../auth.guard.js'; + +import { RefreshUnauthorizedException } from './exceptions/refresh-unauthorized.exception.js'; +import { REFRESH_STRATEGY_NAME } from './refresh.constants.js'; + +@Injectable() +export class RefreshGuard extends AuthGuard(REFRESH_STRATEGY_NAME, { + canDisable: false, +}) { + constructor(guardsPolicy: GuardsPolicy, reflector: Reflector) { + super(guardsPolicy, reflector); + } + + handleRequest(err: Error | undefined, user: T, info?: Error) { + if (err || !user) { + // deliberately collapsed to one status: distinguishing "expired" from + // "invalid signature" from "user deleted" is an oracle for an attacker. + throw new RefreshUnauthorizedException({ originalError: err ?? info }); + } + return user; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/refresh.strategy.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/refresh.strategy.ts new file mode 100644 index 000000000..5dd634f82 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/refresh.strategy.ts @@ -0,0 +1,51 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { getAppContext } from '@concepta/nestjs-core'; + +import { + AUTHENTICATION_JWT_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, +} from '../../../authentication.constants.js'; +import { AuthorizationPayloadInterface } from '../../../domain/interfaces/authorization-payload.interface.js'; +import { RefreshStrategyPolicy } from '../../../domain/policies/refresh-strategy.policy.js'; +import { JwtPort } from '../../../domain/ports/jwt.port.js'; +import { UserPort } from '../../../domain/ports/user.port.js'; +import { JwtPassportStrategy } from '../../passport/jwt-passport.strategy.js'; +import { PassportStrategyFactory } from '../../passport/passport-strategy.factory.js'; +import { createVerifyTokenCallback } from '../../passport/utils/create-verify-token-callback.util.js'; + +import { RefreshUnauthorizedException } from './exceptions/refresh-unauthorized.exception.js'; +import { REFRESH_STRATEGY_NAME } from './refresh.constants.js'; + +@Injectable() +export class RefreshStrategy extends PassportStrategyFactory( + JwtPassportStrategy, + REFRESH_STRATEGY_NAME, +) { + constructor( + @Inject(RefreshStrategyPolicy) + policy: RefreshStrategyPolicy, + @Inject(AUTHENTICATION_JWT_PORT_TOKEN) + jwtPort: JwtPort, + @Inject(AUTHENTICATION_USER_PORT_TOKEN) + private userPort: UserPort, + ) { + super({ + jwtFromRequest: policy.jwtFromRequest, + verifyToken: createVerifyTokenCallback(jwtPort, 'refresh'), + }); + } + + async validate(payload: AuthorizationPayloadInterface, req: unknown) { + const user = await this.userPort.getBySubject( + getAppContext(req), + payload.sub, + ); + + if (!user) { + throw new RefreshUnauthorizedException(); + } + + return user; + } +} diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/schemas/refresh.schema.spec.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/schemas/refresh.schema.spec.ts new file mode 100644 index 000000000..40300c4ef --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/schemas/refresh.schema.spec.ts @@ -0,0 +1,21 @@ +import { refreshSchema } from './refresh.schema.js'; + +describe('refreshSchema', () => { + const validJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'; + + it('accepts a valid JWT', () => { + expect(refreshSchema.parse({ refreshToken: validJwt })).toEqual({ + refreshToken: validJwt, + }); + }); + + it('rejects a non-JWT string', () => { + const result = refreshSchema.safeParse({ refreshToken: 'not-a-jwt' }); + expect(result.success).toBe(false); + }); + + it('rejects a missing refreshToken', () => { + expect(refreshSchema.safeParse({}).success).toBe(false); + }); +}); diff --git a/packages/nestjs-authentication/src/infrastructure/strategies/refresh/schemas/refresh.schema.ts b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/schemas/refresh.schema.ts new file mode 100644 index 000000000..ff71e8c69 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/strategies/refresh/schemas/refresh.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type AuthenticationRefreshInterface } from '../../../../domain/interfaces/authentication-refresh.interface.js'; + +export const refreshSchema = withOpenApi( + conformsTo()( + z.object({ + refreshToken: z.jwt().meta({ + description: + 'JWT refresh token to use for obtaining a new pair of tokens.', + }), + }), + ), +); diff --git a/packages/nestjs-authentication/src/utils/oauth-auth-params.util.spec.ts b/packages/nestjs-authentication/src/infrastructure/utils/__tests__/oauth-auth-params.util.spec.ts similarity index 96% rename from packages/nestjs-authentication/src/utils/oauth-auth-params.util.spec.ts rename to packages/nestjs-authentication/src/infrastructure/utils/__tests__/oauth-auth-params.util.spec.ts index 5feef252d..b5575cb97 100644 --- a/packages/nestjs-authentication/src/utils/oauth-auth-params.util.spec.ts +++ b/packages/nestjs-authentication/src/infrastructure/utils/__tests__/oauth-auth-params.util.spec.ts @@ -1,6 +1,5 @@ -import { OAuthParamsInterface } from '../interfaces/oauth-params.interface'; - -import { processOAuthParams } from './oauth-auth-params.util'; +import { type OAuthParamsInterface } from '../../config/interfaces/oauth-params.interface.js'; +import { processOAuthParams } from '../oauth-auth-params.util.js'; describe('processOAuthParams', () => { describe('when all parameters are provided', () => { @@ -325,7 +324,8 @@ describe('processOAuthParams', () => { }; const result = processOAuthParams(query); - const parsedState = JSON.parse(result.state!); + expect(result.state).toBeDefined(); + const parsedState = JSON.parse(result.state ?? ''); expect(parsedState).toEqual({ provider: 'test-provider', @@ -339,7 +339,8 @@ describe('processOAuthParams', () => { }; const result = processOAuthParams(query); - const parsedState = JSON.parse(result.state!); + expect(result.state).toBeDefined(); + const parsedState = JSON.parse(result.state ?? ''); expect(parsedState).toEqual({ provider: 'test-provider', diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-auth-router-guards-providers.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-auth-router-guards-providers.ts new file mode 100644 index 000000000..321ddc910 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-auth-router-guards-providers.ts @@ -0,0 +1,36 @@ +import { type CanActivate, type Provider, type Type } from '@nestjs/common'; + +import { AuthRouterGuards } from '../router/auth-router.constants.js'; +import { type AuthRouterGuardsRecord } from '../router/auth-router.types.js'; +import { type AuthRouterGuardConfigInterface } from '../router/interfaces/auth-router-guard-config.interface.js'; + +export function createAuthRouterGuardsProviders( + guards: AuthRouterGuardConfigInterface[], +): Provider[] { + const guardsToInject: Type[] = []; + const providerTracker: Record = {}; + + let guardIdx = 0; + for (const guardConfig of guards) { + guardsToInject[guardIdx] = guardConfig.guard; + providerTracker[guardConfig.name] = guardIdx++; + } + + return [ + // Register each guard class as a provider + ...guardsToInject, + // Create the guards record + { + provide: AuthRouterGuards, + inject: guardsToInject, + useFactory: (...args: CanActivate[]): AuthRouterGuardsRecord => { + const guardInstances: AuthRouterGuardsRecord = {}; + for (const guardConfig of guards) { + guardInstances[guardConfig.name] = + args[providerTracker[guardConfig.name]]; + } + return guardInstances; + }, + }, + ]; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-guards-policy-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-guards-policy-provider.ts new file mode 100644 index 000000000..72d79ece7 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-guards-policy-provider.ts @@ -0,0 +1,13 @@ +import { type Provider } from '@nestjs/common'; + +import { GuardsPolicy } from '../../domain/policies/guards.policy.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createGuardsPolicyProvider(rawOptionsToken: symbol): Provider { + return { + provide: GuardsPolicy, + inject: [rawOptionsToken], + useFactory: (options: AuthenticationOptionsInterface) => + new GuardsPolicy(options.settings?.guards), + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-app-guard-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-app-guard-provider.ts new file mode 100644 index 000000000..a6c9e9d82 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-app-guard-provider.ts @@ -0,0 +1,24 @@ +import { type Provider } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; + +import { type AuthenticationOptionsExtrasInterface } from '../config/interfaces/authentication-options-extras.interface.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; +import { JwtGuard } from '../strategies/jwt/jwt.guard.js'; + +export function createJwtAppGuardProvider( + rawOptionsToken: symbol, + extras: AuthenticationOptionsExtrasInterface, +): Provider { + return { + provide: APP_GUARD, + inject: [rawOptionsToken, JwtGuard], + useFactory: ( + options: AuthenticationOptionsInterface, + defaultGuard: JwtGuard, + ) => { + if (!options.settings?.strategies?.jwt) return null; + if (extras.appGuard === false) return null; + return extras.appGuard ?? defaultGuard; + }, + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-policy-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-policy-provider.ts new file mode 100644 index 000000000..2ad3adc14 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-policy-provider.ts @@ -0,0 +1,13 @@ +import { type Provider } from '@nestjs/common'; + +import { JwtPolicy } from '../../domain/policies/jwt.policy.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createJwtPolicyProvider(rawOptionsToken: symbol): Provider { + return { + provide: JwtPolicy, + inject: [rawOptionsToken], + useFactory: (options: AuthenticationOptionsInterface) => + new JwtPolicy(options.settings?.jwt ?? {}), + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-port-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-port-provider.ts new file mode 100644 index 000000000..5c747a74d --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-port-provider.ts @@ -0,0 +1,34 @@ +import { type Provider } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; + +import { SignAccessTokenCommand } from '../../application/commands/impl/sign-access-token.command.js'; +import { SignRefreshTokenCommand } from '../../application/commands/impl/sign-refresh-token.command.js'; +import { JwtVerifyAccessTokenQuery } from '../../application/queries/impl/jwt-verify-access-token.query.js'; +import { JwtVerifyRefreshTokenQuery } from '../../application/queries/impl/jwt-verify-refresh-token.query.js'; +import { AUTHENTICATION_JWT_PORT_TOKEN } from '../../authentication.constants.js'; +import { JwtPort, type JwtPortSettings } from '../../domain/ports/jwt.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export const DEFAULT_JWT_PORT_SETTINGS: JwtPortSettings = { + signAccessTokenCommand: SignAccessTokenCommand, + signRefreshTokenCommand: SignRefreshTokenCommand, + verifyAccessTokenQuery: JwtVerifyAccessTokenQuery, + verifyRefreshTokenQuery: JwtVerifyRefreshTokenQuery, +}; + +export function createJwtPortProvider(rawOptionsToken: symbol): Provider { + return { + provide: AUTHENTICATION_JWT_PORT_TOKEN, + inject: [rawOptionsToken, CommandBus, QueryBus], + useFactory: ( + options: AuthenticationOptionsInterface, + commandBus: CommandBus, + queryBus: QueryBus, + ) => + new JwtPort( + { ...DEFAULT_JWT_PORT_SETTINGS, ...options.ports?.jwt }, + commandBus, + queryBus, + ), + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-strategy-policy-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-strategy-policy-provider.ts new file mode 100644 index 000000000..cc9be7cdd --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-strategy-policy-provider.ts @@ -0,0 +1,25 @@ +import { type Provider } from '@nestjs/common'; + +import { JwtStrategyPolicy } from '../../domain/policies/jwt-strategy.policy.js'; +import { + authenticationDefaultConfig, + type AuthenticationModuleDefaultsInterface, +} from '../config/authentication-default.config.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createJwtStrategyPolicyProvider( + rawOptionsToken: symbol, +): Provider { + return { + provide: JwtStrategyPolicy, + inject: [rawOptionsToken, authenticationDefaultConfig.KEY], + useFactory: ( + options: AuthenticationOptionsInterface, + defaults: AuthenticationModuleDefaultsInterface, + ) => + new JwtStrategyPolicy({ + ...defaults.strategies.jwt, + ...(options.settings?.strategies?.jwt ?? {}), + }), + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-strategy-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-strategy-provider.ts new file mode 100644 index 000000000..163a33670 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-jwt-strategy-provider.ts @@ -0,0 +1,48 @@ +import { type Provider } from '@nestjs/common'; + +import { + AUTHENTICATION_JWT_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, +} from '../../authentication.constants.js'; +import { JwtStrategyPolicy } from '../../domain/policies/jwt-strategy.policy.js'; +import { JwtPolicy } from '../../domain/policies/jwt.policy.js'; +import { type JwtPort } from '../../domain/ports/jwt.port.js'; +import { type UserPort } from '../../domain/ports/user.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; +import { AuthenticationFeatureConfigException } from '../exceptions/authentication-feature-config.exception.js'; +import { JwtStrategy } from '../strategies/jwt/jwt.strategy.js'; + +export function createJwtStrategyProvider(rawOptionsToken: symbol): Provider { + return { + provide: JwtStrategy, + inject: [ + rawOptionsToken, + JwtStrategyPolicy, + JwtPolicy, + AUTHENTICATION_JWT_PORT_TOKEN, + { token: AUTHENTICATION_USER_PORT_TOKEN, optional: true }, + ], + useFactory: ( + options: AuthenticationOptionsInterface, + jwtStrategyPolicy: JwtStrategyPolicy, + jwtPolicy: JwtPolicy, + jwtPort: JwtPort, + userPort: UserPort | null, + ) => { + if (!options.settings?.strategies?.jwt) return null; + if (!options.settings?.jwt) { + throw new AuthenticationFeatureConfigException('jwt strategy', [ + 'jwt token config', + ]); + } + if (!userPort) { + throw new AuthenticationFeatureConfigException('jwt strategy', [ + 'UserPort', + ]); + } + // jwtPolicy validates the jwt token config is properly wired + void jwtPolicy; + return new JwtStrategy(jwtStrategyPolicy, jwtPort, userPort); + }, + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-local-strategy-policy-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-local-strategy-policy-provider.ts new file mode 100644 index 000000000..3ffcbc422 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-local-strategy-policy-provider.ts @@ -0,0 +1,25 @@ +import { type Provider } from '@nestjs/common'; + +import { LocalStrategyPolicy } from '../../domain/policies/local-strategy.policy.js'; +import { + authenticationDefaultConfig, + type AuthenticationModuleDefaultsInterface, +} from '../config/authentication-default.config.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createLocalStrategyPolicyProvider( + rawOptionsToken: symbol, +): Provider { + return { + provide: LocalStrategyPolicy, + inject: [rawOptionsToken, authenticationDefaultConfig.KEY], + useFactory: ( + options: AuthenticationOptionsInterface, + defaults: AuthenticationModuleDefaultsInterface, + ) => + new LocalStrategyPolicy({ + ...defaults.strategies.local, + ...(options.settings?.strategies?.local ?? {}), + }), + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-local-strategy-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-local-strategy-provider.ts new file mode 100644 index 000000000..df581869d --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-local-strategy-provider.ts @@ -0,0 +1,32 @@ +import { type Provider } from '@nestjs/common'; + +import { LocalService } from '../../application/services/local/local.service.js'; +import { LocalStrategyPolicy } from '../../domain/policies/local-strategy.policy.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; +import { AuthenticationFeatureConfigException } from '../exceptions/authentication-feature-config.exception.js'; +import { LocalStrategy } from '../strategies/local/local.strategy.js'; + +export function createLocalStrategyProvider(rawOptionsToken: symbol): Provider { + return { + provide: LocalStrategy, + inject: [ + rawOptionsToken, + LocalStrategyPolicy, + { token: LocalService, optional: true }, + ], + useFactory: ( + options: AuthenticationOptionsInterface, + localPolicy: LocalStrategyPolicy, + validateUserService: LocalService | null, + ) => { + if (!options.settings?.strategies?.local) return null; + if (!validateUserService) { + throw new AuthenticationFeatureConfigException('local strategy', [ + 'UserPort', + 'PasswordPort', + ]); + } + return new LocalStrategy(localPolicy, validateUserService); + }, + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-local-validate-user-service-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-local-validate-user-service-provider.ts new file mode 100644 index 000000000..d02dc18e9 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-local-validate-user-service-provider.ts @@ -0,0 +1,42 @@ +import { type Provider } from '@nestjs/common'; + +import { LocalService } from '../../application/services/local/local.service.js'; +import { + AUTHENTICATION_USER_PORT_TOKEN, + AUTHENTICATION_PASSWORD_PORT_TOKEN, +} from '../../authentication.constants.js'; +import { type PasswordPort } from '../../domain/ports/password.port.js'; +import { type UserPort } from '../../domain/ports/user.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; +import { AuthenticationFeatureConfigException } from '../exceptions/authentication-feature-config.exception.js'; + +export function createLocalValidateUserServiceProvider( + rawOptionsToken: symbol, +): Provider { + return { + provide: LocalService, + inject: [ + rawOptionsToken, + { token: AUTHENTICATION_USER_PORT_TOKEN, optional: true }, + { token: AUTHENTICATION_PASSWORD_PORT_TOKEN, optional: true }, + ], + useFactory: ( + options: AuthenticationOptionsInterface, + userPort: UserPort | null, + passwordPort: PasswordPort | null, + ) => { + if (!options.settings?.strategies?.local) return null; + if (!userPort || !passwordPort) { + const missing = [ + !userPort && 'UserPort', + !passwordPort && 'PasswordPort', + ].filter((m): m is string => Boolean(m)); + throw new AuthenticationFeatureConfigException( + 'local strategy', + missing, + ); + } + return new LocalService(userPort, passwordPort); + }, + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-otp-port-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-otp-port-provider.ts new file mode 100644 index 000000000..a49622aa2 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-otp-port-provider.ts @@ -0,0 +1,21 @@ +import { type Provider } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; + +import { AUTHENTICATION_OTP_PORT_TOKEN } from '../../authentication.constants.js'; +import { OtpPort } from '../../domain/ports/otp.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createOtpPortProvider(rawOptionsToken: symbol): Provider { + return { + provide: AUTHENTICATION_OTP_PORT_TOKEN, + inject: [rawOptionsToken, CommandBus, QueryBus], + useFactory: ( + options: AuthenticationOptionsInterface, + commandBus: CommandBus, + queryBus: QueryBus, + ) => + options.ports?.otp + ? new OtpPort(options.ports.otp, commandBus, queryBus) + : null, + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-password-port-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-password-port-provider.ts new file mode 100644 index 000000000..47d6466f9 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-password-port-provider.ts @@ -0,0 +1,20 @@ +import { type Provider } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { AUTHENTICATION_PASSWORD_PORT_TOKEN } from '../../authentication.constants.js'; +import { PasswordPort } from '../../domain/ports/password.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createPasswordPortProvider(rawOptionsToken: symbol): Provider { + return { + provide: AUTHENTICATION_PASSWORD_PORT_TOKEN, + inject: [rawOptionsToken, CommandBus], + useFactory: ( + options: AuthenticationOptionsInterface, + commandBus: CommandBus, + ) => + options.ports?.password + ? new PasswordPort(options.ports.password, commandBus) + : null, + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-recovery-notification-port-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-recovery-notification-port-provider.ts new file mode 100644 index 000000000..05d500ddd --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-recovery-notification-port-provider.ts @@ -0,0 +1,27 @@ +import { type Provider } from '@nestjs/common'; +import { CommandBus, EventBus } from '@nestjs/cqrs'; + +import { AUTHENTICATION_RECOVERY_NOTIFICATION_PORT_TOKEN } from '../../authentication.constants.js'; +import { RecoveryNotificationPort } from '../../domain/ports/recovery-notification.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createRecoveryNotificationPortProvider( + rawOptionsToken: symbol, +): Provider { + return { + provide: AUTHENTICATION_RECOVERY_NOTIFICATION_PORT_TOKEN, + inject: [rawOptionsToken, CommandBus, EventBus], + useFactory: ( + options: AuthenticationOptionsInterface, + commandBus: CommandBus, + eventBus: EventBus, + ) => + options.ports?.recoveryNotification + ? new RecoveryNotificationPort( + options.ports.recoveryNotification, + commandBus, + eventBus, + ) + : null, + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-recovery-policy-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-recovery-policy-provider.ts new file mode 100644 index 000000000..ee39cd552 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-recovery-policy-provider.ts @@ -0,0 +1,25 @@ +import { type Provider } from '@nestjs/common'; + +import { RecoveryPolicy } from '../../domain/policies/recovery.policy.js'; +import { + authenticationDefaultConfig, + type AuthenticationModuleDefaultsInterface, +} from '../config/authentication-default.config.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createRecoveryPolicyProvider( + rawOptionsToken: symbol, +): Provider { + return { + provide: RecoveryPolicy, + inject: [rawOptionsToken, authenticationDefaultConfig.KEY], + useFactory: ( + options: AuthenticationOptionsInterface, + defaults: AuthenticationModuleDefaultsInterface, + ) => + new RecoveryPolicy({ + ...defaults.mfa.recovery, + ...(options.settings?.mfa?.recovery ?? {}), + }), + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-recovery-service-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-recovery-service-provider.ts new file mode 100644 index 000000000..b1226a89a --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-recovery-service-provider.ts @@ -0,0 +1,61 @@ +import { type Provider } from '@nestjs/common'; + +import { RecoveryService } from '../../application/services/recovery/recovery.service.js'; +import { + AUTHENTICATION_OTP_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, + AUTHENTICATION_PASSWORD_PORT_TOKEN, + AUTHENTICATION_RECOVERY_NOTIFICATION_PORT_TOKEN, +} from '../../authentication.constants.js'; +import { RecoveryPolicy } from '../../domain/policies/recovery.policy.js'; +import { type OtpPort } from '../../domain/ports/otp.port.js'; +import { type PasswordPort } from '../../domain/ports/password.port.js'; +import { type RecoveryNotificationPort } from '../../domain/ports/recovery-notification.port.js'; +import { type UserPort } from '../../domain/ports/user.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; +import { AuthenticationFeatureConfigException } from '../exceptions/authentication-feature-config.exception.js'; + +export function createRecoveryServiceProvider( + rawOptionsToken: symbol, +): Provider { + return { + provide: RecoveryService, + inject: [ + rawOptionsToken, + RecoveryPolicy, + { token: AUTHENTICATION_OTP_PORT_TOKEN, optional: true }, + { token: AUTHENTICATION_USER_PORT_TOKEN, optional: true }, + { token: AUTHENTICATION_PASSWORD_PORT_TOKEN, optional: true }, + { + token: AUTHENTICATION_RECOVERY_NOTIFICATION_PORT_TOKEN, + optional: true, + }, + ], + useFactory: ( + options: AuthenticationOptionsInterface, + recoveryPolicy: RecoveryPolicy, + otpPort: OtpPort | null, + userPort: UserPort | null, + passwordPort: PasswordPort | null, + recoveryNotificationPort: RecoveryNotificationPort | null, + ) => { + if (!options.settings?.mfa?.recovery) return null; + if (!otpPort || !userPort || !passwordPort || !recoveryNotificationPort) { + const missing = [ + !otpPort && 'OtpPort', + !userPort && 'UserPort', + !passwordPort && 'PasswordPort', + !recoveryNotificationPort && 'RecoveryNotificationPort', + ].filter((m): m is string => Boolean(m)); + throw new AuthenticationFeatureConfigException('mfa.recovery', missing); + } + return new RecoveryService( + recoveryPolicy, + otpPort, + userPort, + passwordPort, + recoveryNotificationPort, + ); + }, + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-refresh-strategy-policy-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-refresh-strategy-policy-provider.ts new file mode 100644 index 000000000..fdaa0954d --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-refresh-strategy-policy-provider.ts @@ -0,0 +1,25 @@ +import { type Provider } from '@nestjs/common'; + +import { RefreshStrategyPolicy } from '../../domain/policies/refresh-strategy.policy.js'; +import { + authenticationDefaultConfig, + type AuthenticationModuleDefaultsInterface, +} from '../config/authentication-default.config.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createRefreshStrategyPolicyProvider( + rawOptionsToken: symbol, +): Provider { + return { + provide: RefreshStrategyPolicy, + inject: [rawOptionsToken, authenticationDefaultConfig.KEY], + useFactory: ( + options: AuthenticationOptionsInterface, + defaults: AuthenticationModuleDefaultsInterface, + ) => + new RefreshStrategyPolicy({ + ...defaults.strategies.refresh, + ...(options.settings?.strategies?.refresh ?? {}), + }), + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-refresh-strategy-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-refresh-strategy-provider.ts new file mode 100644 index 000000000..4c50dd588 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-refresh-strategy-provider.ts @@ -0,0 +1,45 @@ +import { type Provider } from '@nestjs/common'; + +import { + AUTHENTICATION_JWT_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, +} from '../../authentication.constants.js'; +import { RefreshStrategyPolicy } from '../../domain/policies/refresh-strategy.policy.js'; +import { type JwtPort } from '../../domain/ports/jwt.port.js'; +import { type UserPort } from '../../domain/ports/user.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; +import { AuthenticationFeatureConfigException } from '../exceptions/authentication-feature-config.exception.js'; +import { RefreshStrategy } from '../strategies/refresh/refresh.strategy.js'; + +export function createRefreshStrategyProvider( + rawOptionsToken: symbol, +): Provider { + return { + provide: RefreshStrategy, + inject: [ + rawOptionsToken, + RefreshStrategyPolicy, + AUTHENTICATION_JWT_PORT_TOKEN, + { token: AUTHENTICATION_USER_PORT_TOKEN, optional: true }, + ], + useFactory: ( + options: AuthenticationOptionsInterface, + refreshPolicy: RefreshStrategyPolicy, + jwtPort: JwtPort, + userPort: UserPort | null, + ) => { + if (!options.settings?.strategies?.refresh) return null; + if (!options.settings?.jwt) { + throw new AuthenticationFeatureConfigException('refresh strategy', [ + 'jwt token config', + ]); + } + if (!userPort) { + throw new AuthenticationFeatureConfigException('refresh strategy', [ + 'UserPort', + ]); + } + return new RefreshStrategy(refreshPolicy, jwtPort, userPort); + }, + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-token-port-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-token-port-provider.ts new file mode 100644 index 000000000..98044cd0c --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-token-port-provider.ts @@ -0,0 +1,39 @@ +import { type Provider } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; + +import { IssueAccessTokenCommand } from '../../application/commands/impl/issue-access-token.command.js'; +import { IssueRefreshTokenCommand } from '../../application/commands/impl/issue-refresh-token.command.js'; +import { ValidateTokenQuery } from '../../application/queries/impl/validate-token.query.js'; +import { VerifyAccessTokenQuery } from '../../application/queries/impl/verify-access-token.query.js'; +import { VerifyRefreshTokenQuery } from '../../application/queries/impl/verify-refresh-token.query.js'; +import { AUTHENTICATION_TOKEN_PORT_TOKEN } from '../../authentication.constants.js'; +import { + TokenPort, + type TokenPortSettings, +} from '../../domain/ports/token.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export const DEFAULT_TOKEN_PORT_SETTINGS: TokenPortSettings = { + issueAccessTokenCommand: IssueAccessTokenCommand, + issueRefreshTokenCommand: IssueRefreshTokenCommand, + verifyAccessTokenQuery: VerifyAccessTokenQuery, + verifyRefreshTokenQuery: VerifyRefreshTokenQuery, + validateTokenQuery: ValidateTokenQuery, +}; + +export function createTokenPortProvider(rawOptionsToken: symbol): Provider { + return { + provide: AUTHENTICATION_TOKEN_PORT_TOKEN, + inject: [rawOptionsToken, CommandBus, QueryBus], + useFactory: ( + options: AuthenticationOptionsInterface, + commandBus: CommandBus, + queryBus: QueryBus, + ) => + new TokenPort( + { ...DEFAULT_TOKEN_PORT_SETTINGS, ...options.ports?.token }, + commandBus, + queryBus, + ), + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-user-port-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-user-port-provider.ts new file mode 100644 index 000000000..5f7a4e904 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-user-port-provider.ts @@ -0,0 +1,21 @@ +import { type Provider } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; + +import { AUTHENTICATION_USER_PORT_TOKEN } from '../../authentication.constants.js'; +import { UserPort } from '../../domain/ports/user.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createUserPortProvider(rawOptionsToken: symbol): Provider { + return { + provide: AUTHENTICATION_USER_PORT_TOKEN, + inject: [rawOptionsToken, QueryBus, CommandBus], + useFactory: ( + options: AuthenticationOptionsInterface, + queryBus: QueryBus, + commandBus: CommandBus, + ) => + options.ports?.user + ? new UserPort(options.ports.user, queryBus, commandBus) + : null, + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-verify-notification-port-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-verify-notification-port-provider.ts new file mode 100644 index 000000000..f0d5ea939 --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-verify-notification-port-provider.ts @@ -0,0 +1,27 @@ +import { type Provider } from '@nestjs/common'; +import { CommandBus, EventBus } from '@nestjs/cqrs'; + +import { AUTHENTICATION_VERIFY_NOTIFICATION_PORT_TOKEN } from '../../authentication.constants.js'; +import { VerifyNotificationPort } from '../../domain/ports/verify-notification.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createVerifyNotificationPortProvider( + rawOptionsToken: symbol, +): Provider { + return { + provide: AUTHENTICATION_VERIFY_NOTIFICATION_PORT_TOKEN, + inject: [rawOptionsToken, CommandBus, EventBus], + useFactory: ( + options: AuthenticationOptionsInterface, + commandBus: CommandBus, + eventBus: EventBus, + ) => + options.ports?.verifyNotification + ? new VerifyNotificationPort( + options.ports.verifyNotification, + commandBus, + eventBus, + ) + : null, + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-verify-policy-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-verify-policy-provider.ts new file mode 100644 index 000000000..d1f62f35a --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-verify-policy-provider.ts @@ -0,0 +1,23 @@ +import { type Provider } from '@nestjs/common'; + +import { VerifyPolicy } from '../../domain/policies/verify.policy.js'; +import { + authenticationDefaultConfig, + type AuthenticationModuleDefaultsInterface, +} from '../config/authentication-default.config.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; + +export function createVerifyPolicyProvider(rawOptionsToken: symbol): Provider { + return { + provide: VerifyPolicy, + inject: [rawOptionsToken, authenticationDefaultConfig.KEY], + useFactory: ( + options: AuthenticationOptionsInterface, + defaults: AuthenticationModuleDefaultsInterface, + ) => + new VerifyPolicy({ + ...defaults.mfa.verify, + ...(options.settings?.mfa?.verify ?? {}), + }), + }; +} diff --git a/packages/nestjs-authentication/src/infrastructure/utils/create-verify-service-provider.ts b/packages/nestjs-authentication/src/infrastructure/utils/create-verify-service-provider.ts new file mode 100644 index 000000000..6b96c16de --- /dev/null +++ b/packages/nestjs-authentication/src/infrastructure/utils/create-verify-service-provider.ts @@ -0,0 +1,50 @@ +import { type Provider } from '@nestjs/common'; + +import { VerifyService } from '../../application/services/verify/verify.service.js'; +import { + AUTHENTICATION_OTP_PORT_TOKEN, + AUTHENTICATION_USER_PORT_TOKEN, + AUTHENTICATION_VERIFY_NOTIFICATION_PORT_TOKEN, +} from '../../authentication.constants.js'; +import { VerifyPolicy } from '../../domain/policies/verify.policy.js'; +import { type OtpPort } from '../../domain/ports/otp.port.js'; +import { type UserPort } from '../../domain/ports/user.port.js'; +import { type VerifyNotificationPort } from '../../domain/ports/verify-notification.port.js'; +import { type AuthenticationOptionsInterface } from '../config/interfaces/authentication-options.interface.js'; +import { AuthenticationFeatureConfigException } from '../exceptions/authentication-feature-config.exception.js'; + +export function createVerifyServiceProvider(rawOptionsToken: symbol): Provider { + return { + provide: VerifyService, + inject: [ + rawOptionsToken, + VerifyPolicy, + { token: AUTHENTICATION_OTP_PORT_TOKEN, optional: true }, + { token: AUTHENTICATION_USER_PORT_TOKEN, optional: true }, + { token: AUTHENTICATION_VERIFY_NOTIFICATION_PORT_TOKEN, optional: true }, + ], + useFactory: ( + options: AuthenticationOptionsInterface, + verifyPolicy: VerifyPolicy, + otpPort: OtpPort | null, + userPort: UserPort | null, + verifyNotificationPort: VerifyNotificationPort | null, + ) => { + if (!options.settings?.mfa?.verify) return null; + if (!otpPort || !userPort || !verifyNotificationPort) { + const missing = [ + !otpPort && 'OtpPort', + !userPort && 'UserPort', + !verifyNotificationPort && 'VerifyNotificationPort', + ].filter((m): m is string => Boolean(m)); + throw new AuthenticationFeatureConfigException('mfa.verify', missing); + } + return new VerifyService( + verifyPolicy, + otpPort, + userPort, + verifyNotificationPort, + ); + }, + }; +} diff --git a/packages/nestjs-authentication/src/utils/oauth-auth-params.util.ts b/packages/nestjs-authentication/src/infrastructure/utils/oauth-auth-params.util.ts similarity index 82% rename from packages/nestjs-authentication/src/utils/oauth-auth-params.util.ts rename to packages/nestjs-authentication/src/infrastructure/utils/oauth-auth-params.util.ts index 7dd221340..d33e052b5 100644 --- a/packages/nestjs-authentication/src/utils/oauth-auth-params.util.ts +++ b/packages/nestjs-authentication/src/infrastructure/utils/oauth-auth-params.util.ts @@ -1,5 +1,5 @@ -import { OAuthAuthenticateOptionsInterface } from '../interfaces/oauth-authenticate-options.interface'; -import { OAuthParamsInterface } from '../interfaces/oauth-params.interface'; +import { type OAuthAuthenticateOptionsInterface } from '../config/interfaces/oauth-authenticate-options.interface.js'; +import { type OAuthParamsInterface } from '../config/interfaces/oauth-params.interface.js'; /** * Processes OAuth authentication parameters and returns auth options diff --git a/packages/nestjs-authentication/src/interfaces/authentication-options-extras.interface.ts b/packages/nestjs-authentication/src/interfaces/authentication-options-extras.interface.ts deleted file mode 100644 index c3d3dff2a..000000000 --- a/packages/nestjs-authentication/src/interfaces/authentication-options-extras.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface AuthenticationOptionsExtrasInterface - extends Pick {} diff --git a/packages/nestjs-authentication/src/interfaces/authentication-options.interface.ts b/packages/nestjs-authentication/src/interfaces/authentication-options.interface.ts deleted file mode 100644 index f65da9983..000000000 --- a/packages/nestjs-authentication/src/interfaces/authentication-options.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AuthenticationSettingsInterface } from './authentication-settings.interface'; -import { IssueTokenServiceInterface } from './issue-token-service.interface'; -import { ValidateTokenServiceInterface } from './validate-token-service.interface'; -import { VerifyTokenServiceInterface } from './verify-token-service.interface'; - -/** - * Authentication module configuration options interface - */ -export interface AuthenticationOptionsInterface { - settings?: AuthenticationSettingsInterface; - issueTokenService?: IssueTokenServiceInterface; - verifyTokenService?: VerifyTokenServiceInterface; - validateTokenService?: ValidateTokenServiceInterface; -} diff --git a/packages/nestjs-authentication/src/interfaces/authentication-settings.interface.ts b/packages/nestjs-authentication/src/interfaces/authentication-settings.interface.ts deleted file mode 100644 index 416da44a9..000000000 --- a/packages/nestjs-authentication/src/interfaces/authentication-settings.interface.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { CanActivate, ExecutionContext } from '@nestjs/common'; - -export interface AuthenticationSettingsInterface { - /** - * Enable or disable guards globally. Defaults to `true`. - * - * Only applies to guards that have the `canDisable` setting set to `true`. - */ - enableGuards?: boolean; - - /** - * Callback function for determining if a guard should be disabled. - * - * Only applies to guards that have the `canDisable` setting set to `true`. - */ - disableGuard?: ( - context: ExecutionContext, - guard: T, - ) => boolean; -} diff --git a/packages/nestjs-authentication/src/interfaces/issue-token-service.interface.ts b/packages/nestjs-authentication/src/interfaces/issue-token-service.interface.ts deleted file mode 100644 index d87580730..000000000 --- a/packages/nestjs-authentication/src/interfaces/issue-token-service.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - ReferenceId, - AuthenticationResponseInterface, -} from '@concepta/nestjs-common'; -import { JwtIssueTokenServiceInterface } from '@concepta/nestjs-jwt'; - -export interface IssueTokenServiceInterface - extends JwtIssueTokenServiceInterface { - responsePayload(id: ReferenceId): Promise; -} diff --git a/packages/nestjs-authentication/src/interfaces/oauth-request.interface.ts b/packages/nestjs-authentication/src/interfaces/oauth-request.interface.ts deleted file mode 100644 index ee90c0da7..000000000 --- a/packages/nestjs-authentication/src/interfaces/oauth-request.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { OAuthParamsInterface } from './oauth-params.interface'; - -/** - * Interface for OAuth authentication request with query parameters - */ -export interface OAuthRequestInterface extends Request { - query: OAuthParamsInterface; -} diff --git a/packages/nestjs-authentication/src/interfaces/validate-token-service.interface.ts b/packages/nestjs-authentication/src/interfaces/validate-token-service.interface.ts deleted file mode 100644 index 8b26ae2a1..000000000 --- a/packages/nestjs-authentication/src/interfaces/validate-token-service.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface ValidateTokenServiceInterface { - validateToken: (payload: object) => Promise; -} diff --git a/packages/nestjs-authentication/src/interfaces/validate-user-service.interface.ts b/packages/nestjs-authentication/src/interfaces/validate-user-service.interface.ts deleted file mode 100644 index 9d04e1982..000000000 --- a/packages/nestjs-authentication/src/interfaces/validate-user-service.interface.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { - ReferenceActiveInterface, - ReferenceIdInterface, -} from '@concepta/nestjs-common'; - -export interface ValidateUserServiceInterface< - T extends unknown[] = unknown[], - R extends ReferenceIdInterface = ReferenceIdInterface, -> { - validateUser: (..._: T) => Promise; - isActive: (user: R & ReferenceActiveInterface) => Promise; -} diff --git a/packages/nestjs-authentication/src/interfaces/verify-token-service.interface.ts b/packages/nestjs-authentication/src/interfaces/verify-token-service.interface.ts deleted file mode 100644 index 9de2d8f55..000000000 --- a/packages/nestjs-authentication/src/interfaces/verify-token-service.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { JwtVerifyTokenServiceInterface } from '@concepta/nestjs-jwt'; - -export interface VerifyTokenServiceInterface - extends JwtVerifyTokenServiceInterface {} diff --git a/packages/nestjs-authentication/src/services/issue-token.service.spec.ts b/packages/nestjs-authentication/src/services/issue-token.service.spec.ts deleted file mode 100644 index 797a575d7..000000000 --- a/packages/nestjs-authentication/src/services/issue-token.service.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { mock } from 'jest-mock-extended'; - -import { - AuthenticatedUserInterface, - AuthenticationResponseInterface, -} from '@concepta/nestjs-common'; -import { JwtIssueTokenService } from '@concepta/nestjs-jwt'; - -import { IssueTokenService } from './issue-token.service'; - -describe(IssueTokenService, () => { - const accessToken = 'accessToken'; - const refreshToken = 'refreshToken'; - const user: AuthenticatedUserInterface = { - id: randomUUID(), - }; - let issueTokenService: IssueTokenService; - const response: AuthenticationResponseInterface = { - accessToken, - refreshToken, - }; - - beforeEach(async () => { - const jwtIssueTokenService = mock({ - accessToken: (): Promise => { - return new Promise((resolve) => { - resolve('accessToken'); - }); - }, - refreshToken: (): Promise => { - return new Promise((resolve) => { - resolve('refreshToken'); - }); - }, - }); - issueTokenService = new IssueTokenService(jwtIssueTokenService); - }); - - describe(IssueTokenService.prototype.accessToken, () => { - it('should return accessToken', async () => { - const result = await issueTokenService.accessToken(user); - expect(result).toBe(response.accessToken); - }); - }); - - describe(IssueTokenService.prototype.refreshToken, () => { - it('should return refreshToken', async () => { - const result = await issueTokenService.refreshToken(user); - expect(result).toBe(response.refreshToken); - }); - }); - - describe(IssueTokenService.prototype.responsePayload, () => { - it('should return responsePayload', async () => { - const result = await issueTokenService.responsePayload(user.id); - expect(result.accessToken).toBe(response.accessToken); - expect(result.refreshToken).toBe(response.refreshToken); - }); - }); -}); diff --git a/packages/nestjs-authentication/src/services/issue-token.service.ts b/packages/nestjs-authentication/src/services/issue-token.service.ts deleted file mode 100644 index d8bebe2b0..000000000 --- a/packages/nestjs-authentication/src/services/issue-token.service.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { - ReferenceId, - AuthenticationResponseInterface, -} from '@concepta/nestjs-common'; -import { - JwtIssueTokenService, - JwtIssueTokenServiceInterface, - JwtSignOptions, - JwtSignStringOptions, -} from '@concepta/nestjs-jwt'; - -import { AuthenticationJwtResponseDto } from '../dto/authentication-jwt-response.dto'; -import { IssueTokenServiceInterface } from '../interfaces/issue-token-service.interface'; - -@Injectable() -export class IssueTokenService implements IssueTokenServiceInterface { - constructor( - @Inject(JwtIssueTokenService) - protected readonly jwtIssueTokenService: JwtIssueTokenServiceInterface, - ) {} - - /** - * Generate access token for a payload. - */ - accessToken(payload: string, options?: JwtSignStringOptions): Promise; - - accessToken( - payload: Buffer | object, - options?: JwtSignOptions, - ): Promise; - - async accessToken( - payload: string | Buffer | object, - options?: JwtSignOptions, - ) { - if (typeof payload === 'string') { - return this.jwtIssueTokenService.accessToken(payload, options); - } else { - return this.jwtIssueTokenService.accessToken(payload, options); - } - } - - /** - * Generate refresh token for a payload. - */ - refreshToken( - payload: string, - options?: JwtSignStringOptions, - ): Promise; - - refreshToken( - payload: Buffer | object, - options?: JwtSignOptions, - ): Promise; - - async refreshToken( - payload: string | Buffer | object, - options?: JwtSignOptions, - ) { - if (typeof payload === 'string') { - return this.jwtIssueTokenService.refreshToken(payload, options); - } else { - return this.jwtIssueTokenService.refreshToken(payload, options); - } - } - - /** - * Generate the response payload. - * - * @param id - user id or name for `sub` claim - */ - async responsePayload( - id: ReferenceId, - ): Promise { - // TODO: need pattern for events and/or callbacks to mutate this object before signing - const payload = { sub: id }; - - // create the dto - const dto = new AuthenticationJwtResponseDto(); - - // set access and refresh tokens - dto.accessToken = await this.accessToken(payload); - dto.refreshToken = await this.refreshToken(payload); - - // return the payload - return dto; - } -} diff --git a/packages/nestjs-authentication/src/services/validate-user.service.spec.ts b/packages/nestjs-authentication/src/services/validate-user.service.spec.ts deleted file mode 100644 index eeea4b3bf..000000000 --- a/packages/nestjs-authentication/src/services/validate-user.service.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { ValidateUserService } from './validate-user.service'; - -interface TestUser { - id: string; - active: boolean; -} -class ConcreteValidateUserService extends ValidateUserService { - async validateUser(..._args: unknown[]): Promise { - // Implementation of the abstract method for testing purposes - return { id: 'user1', active: true } as TestUser; - } -} - -describe(ValidateUserService.name, () => { - let service: ConcreteValidateUserService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ConcreteValidateUserService], - }).compile(); - - service = module.get(ConcreteValidateUserService); - }); - - it('should return true if user is active', async () => { - const user = { id: 'user1', active: true }; - expect(await service.isActive(user)).toBe(true); - }); - - it('should return false if user is not active', async () => { - const user = { id: 'user1', active: false }; - expect(await service.isActive(user)).toBe(false); - }); -}); diff --git a/packages/nestjs-authentication/src/services/validate-user.service.ts b/packages/nestjs-authentication/src/services/validate-user.service.ts deleted file mode 100644 index 0b85868eb..000000000 --- a/packages/nestjs-authentication/src/services/validate-user.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ReferenceActiveInterface, - ReferenceIdInterface, -} from '@concepta/nestjs-common'; - -import { ValidateUserServiceInterface } from '../interfaces/validate-user-service.interface'; - -@Injectable() -export abstract class ValidateUserService< - T extends unknown[] = unknown[], - R extends ReferenceIdInterface = ReferenceIdInterface, -> implements ValidateUserServiceInterface -{ - /** - * Returns validated user - */ - abstract validateUser(...rest: T): Promise; - - /** - * Returns true if user is considered valid for authentication purposes. - */ - async isActive( - user: ReferenceIdInterface & ReferenceActiveInterface, - ): Promise { - return user.active === true; - } -} diff --git a/packages/nestjs-authentication/src/services/verify-token.service.spec.ts b/packages/nestjs-authentication/src/services/verify-token.service.spec.ts deleted file mode 100644 index bc749a880..000000000 --- a/packages/nestjs-authentication/src/services/verify-token.service.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { JwtVerifyTokenService } from '@concepta/nestjs-jwt'; - -import { AuthenticationAccessTokenException } from '../exceptions/authentication-access-token.exception'; -import { AuthenticationRefreshTokenException } from '../exceptions/authentication-refresh-token.exception'; -import { ValidateTokenServiceInterface } from '../interfaces/validate-token-service.interface'; - -import { VerifyTokenService } from './verify-token.service'; - -describe(VerifyTokenService, () => { - const token = 'token'; - let verifyTokenService: VerifyTokenService; - - const jwtVerifyTokenService = mock(); - const validateTokenService = mock(); - - describe(VerifyTokenService.prototype.accessToken, () => { - it('should success', async () => { - verifyTokenService = new VerifyTokenService(jwtVerifyTokenService); - jest.spyOn(jwtVerifyTokenService, 'accessToken').mockResolvedValue({}); - const result = await verifyTokenService.accessToken(token); - expect(result).toEqual({}); - }); - - it('should throw exception', async () => { - verifyTokenService = new VerifyTokenService( - jwtVerifyTokenService, - validateTokenService, - ); - jest.spyOn(jwtVerifyTokenService, 'accessToken').mockResolvedValue({}); - jest - .spyOn(validateTokenService, 'validateToken') - .mockResolvedValue(false); - - const t = async () => { - await verifyTokenService.accessToken(token); - }; - - await expect(t).rejects.toThrow(AuthenticationAccessTokenException); - }); - - it('should throw exception', async () => { - verifyTokenService = new VerifyTokenService( - jwtVerifyTokenService, - validateTokenService, - ); - jest.spyOn(jwtVerifyTokenService, 'accessToken').mockResolvedValue({}); - jest - .spyOn(validateTokenService, 'validateToken') - .mockResolvedValue(false); - - const t = async () => { - await verifyTokenService.accessToken(token); - }; - - await expect(t).rejects.toThrow( - 'Access token was verified, but failed further validation.', - ); - }); - }); - - describe(VerifyTokenService.prototype.refreshToken, () => { - it('should success', async () => { - verifyTokenService = new VerifyTokenService(jwtVerifyTokenService); - jest.spyOn(jwtVerifyTokenService, 'refreshToken').mockResolvedValue({}); - const result = await verifyTokenService.refreshToken(token); - expect(result).toEqual({}); - }); - - it('should throw exception', async () => { - verifyTokenService = new VerifyTokenService( - jwtVerifyTokenService, - validateTokenService, - ); - jest.spyOn(jwtVerifyTokenService, 'refreshToken').mockResolvedValue({}); - jest - .spyOn(validateTokenService, 'validateToken') - .mockResolvedValue(false); - - const t = async () => { - await verifyTokenService.refreshToken(token); - }; - await expect(t).rejects.toThrow(AuthenticationRefreshTokenException); - }); - - it('should throw exception', async () => { - verifyTokenService = new VerifyTokenService( - jwtVerifyTokenService, - validateTokenService, - ); - jest.spyOn(jwtVerifyTokenService, 'refreshToken').mockResolvedValue({}); - jest - .spyOn(validateTokenService, 'validateToken') - .mockResolvedValue(false); - - const t = async () => { - await verifyTokenService.refreshToken(token); - }; - await expect(t).rejects.toThrow( - 'Refresh token was verified, but failed further validation.', - ); - }); - }); -}); diff --git a/packages/nestjs-authentication/src/services/verify-token.service.ts b/packages/nestjs-authentication/src/services/verify-token.service.ts deleted file mode 100644 index 096796e90..000000000 --- a/packages/nestjs-authentication/src/services/verify-token.service.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Inject, Injectable, Optional } from '@nestjs/common'; - -import { - JwtVerifyTokenService, - JwtVerifyTokenServiceInterface, -} from '@concepta/nestjs-jwt'; - -import { ValidateTokenService } from '../authentication.constants'; -import { AuthenticationAccessTokenException } from '../exceptions/authentication-access-token.exception'; -import { AuthenticationRefreshTokenException } from '../exceptions/authentication-refresh-token.exception'; -import { ValidateTokenServiceInterface } from '../interfaces/validate-token-service.interface'; -import { VerifyTokenServiceInterface } from '../interfaces/verify-token-service.interface'; - -@Injectable() -export class VerifyTokenService implements VerifyTokenServiceInterface { - constructor( - @Inject(JwtVerifyTokenService) - protected readonly jwtVerifyTokenService: JwtVerifyTokenServiceInterface, - @Optional() - @Inject(ValidateTokenService) - protected readonly validateTokenService?: ValidateTokenServiceInterface, - ) {} - - /** - * Verify an access token. - */ - async accessToken(...args: Parameters) { - // decode the token - const token = await this.jwtVerifyTokenService.accessToken(...args); - - // try to validate the token - if (await this.validateToken(token)) { - return token; - } else { - throw new AuthenticationAccessTokenException(); - } - } - - /** - * Verify a refresh token. - */ - async refreshToken( - ...args: Parameters - ) { - // decode the token - const token = await this.jwtVerifyTokenService.refreshToken(...args); - - // try to validate the token - if (await this.validateToken(token)) { - return token; - } else { - throw new AuthenticationRefreshTokenException(); - } - } - - /** - * Further validate the authenticity of a token. - * - * For example, You may want to check if it's id exists in a database or some other source. - * - * @param payload - Payload object - */ - private async validateToken(payload: object): Promise { - if (this.validateTokenService) { - return this.validateTokenService.validateToken(payload); - } else { - return true; - } - } -} diff --git a/packages/nestjs-authentication/tsconfig.json b/packages/nestjs-authentication/tsconfig.json index ef9980950..edc11225e 100644 --- a/packages/nestjs-authentication/tsconfig.json +++ b/packages/nestjs-authentication/tsconfig.json @@ -4,10 +4,13 @@ "composite": true, "rootDir": "./src", "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", "typeRoots": [ "./node_modules/@types", - "../../node_modules/@types" - ] + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] }, "include": [ "src/**/*.ts" diff --git a/packages/nestjs-cache/README.md b/packages/nestjs-cache/README.md index a8f65e9a3..e458dbfab 100644 --- a/packages/nestjs-cache/README.md +++ b/packages/nestjs-cache/README.md @@ -1,791 +1,575 @@ -# Rockets NestJS Cache Documentation +# @concepta/nestjs-cache -The Rockets NestJS Cache module offers a robust caching solution for NestJS -applications, enhancing data management efficiency. It integrates seamlessly -with the NestJS framework, supporting both synchronous and asynchronous -registration of cache configurations. This module enables CRUD operations on -cache entries directly from the database, facilitating data reuse across -different parts of an application or even different applications. It is -especially useful for boosting application performance, reducing database load, -and improving user experience by minimizing data retrieval times. +Database-backed cache module for NestJS using DDD/CQRS. Provides typed cache +entries keyed by `key`, `type`, and `assigneeId` with optional TTL expiration. ## Project -[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-auth-local)](https://www.npmjs.com/package/@concepta/nestjs-auth-local) -[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-auth-local)](https://www.npmjs.com/package/@concepta/nestjs-auth-local) +[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-cache)](https://www.npmjs.com/package/@concepta/nestjs-cache) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-cache)](https://www.npmjs.com/package/@concepta/nestjs-cache) [![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) [![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-cache%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) ## Table of Contents -- [Tutorials](#tutorials) - - [Getting Started with Rockets NestJS Cache](#getting-started-with-rockets-nestjs-cache) - - [Introduction](#introduction) - - [Installation](#installation) - - [Basic Setup in a NestJS Project](#basic-setup-in-a-nestjs-project) - - [Using the RestFull endpoints to access cache](#using-the-restfull-endpoints-to-access-cache) -- [How-to Guides](#how-to-guides) - - [Registering CacheModule Synchronously](#registering-cachemodule-synchronously) - - [Registering CacheModule Asynchronously](#registering-cachemodule-asynchronously) - - [Global Registering CacheModule Asynchronously](#global-registering-cachemodule-asynchronously) - - [Registering CacheModule Asynchronously for Multiple Entities](#registering-cachemodule-asynchronously-for-multiple-entities) - - [Using the CacheService to Access Cache](#using-the-cacheservice-to-access-cache) -- [Reference](#reference) -- [Explanation](#explanation) - - [Conceptual Overview of Caching](#conceptual-overview-of-caching) - - [What is Caching?](#what-is-caching) - - [Benefits of Using Cache](#benefits-of-using-cache) - - [Why Use NestJS Cache?](#why-use-nestjs-cache) - - [When to Use NestJS Cache](#when-to-use-nestjs-cache) - - [How CacheOptionsInterface is Used in the Controller and Endpoints](#how-cacheoptionsinterface-is-used-in-the-controller-and-endpoints) - - [Design Choices in CacheModule](#design-choices-in-cachemodule) - - [Synchronous vs Asynchronous Registration](#global-vs-synchronous-vs-asynchronous-registration) - -## Tutorials - -### Getting Started with Rockets NestJS Cache - -#### Introduction - -The Rockets NestJS Cache module is designed to provide an easy and efficient way -to manage cached data in your application. This tutorial will guide you through -the initial steps to set up and use the Rockets NestJS Cache module. - -#### Installation - -To install the module, use the following command: +- [Installation](#installation) +- [Module Registration](#module-registration) +- [Architecture Overview](#architecture-overview) +- [App Context](#app-context) +- [Commands](#commands) +- [Queries](#queries) +- [Domain Events](#domain-events) +- [Cache Aggregate](#cache-aggregate) +- [Expiration Policy](#expiration-policy) +- [Repository](#repository) +- [Schemas](#schemas) +- [Exceptions](#exceptions) +- [HTTP Controller with CRUD Module](#http-controller-with-crud-module) +- [Entry Points](#entry-points) +- [Seeding](#seeding) +- [Environment Variables](#environment-variables) + +## Installation ```sh -npm install typeorm -npm install class-transformer -npm install class-validator -npm install @nestjs/typeorm -npm install @concepta/nestjs-crud -npm install @concepta/nestjs-typeorm-ext -npm install @concepta/nestjs-cache - -or - -yarn add typeorm -yarn add class-transformer -yarn add class-validator -yarn add @nestjs/typeorm -yarn add @concepta/nestjs-crud -yarn add @concepta/nestjs-typeorm-ext -yarn add @concepta/nestjs-cache +yarn add @concepta/nestjs-cache @nestjs/common @nestjs/config @nestjs/core ``` -On this documentation we will use `sqlite3` as database, but you can use -whatever you want +This package is **ESM-only** and targets **NestJS 12** on +**Node >= 22.12**. -```sh -yarn add sqlite3 -``` - -#### Basic Setup in a NestJS Project - -1. **User Module**: Let's create a simple UserModule with Entity, Service, - Controller, and Module, to be used in our tutorial so we can cache - user-related information with the cache module: - -```typescript -import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; -import { UserCache } from '../user-cache/user-cache.entity'; - -@Entity() -export class User { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column() - name: string; - - @OneToMany(() => UserCache, (userCache) => userCache.assignee) - userCaches!: UserCache[]; -} -``` - -```typescript -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User } from './user.entity'; - -@Injectable() -export class UserService { - constructor( - @InjectRepository(User) - private userRepository: Repository, - ) {} - - findAll(): Promise { - return this.userRepository.find(); - } - - findOne(id: string): Promise { - return this.userRepository.findOne({ - where: { id }, - }); - } - - async create(userData: Partial): Promise { - const newUser = this.userRepository.create(userData); - await this.userRepository.save(newUser); - return newUser; - } -} -``` - -```typescript -import { Controller, Get, Post, Body, Param } from '@nestjs/common'; -import { UserService } from './user.service'; -import { User } from './user.entity'; - -@Controller('user') -export class UserController { - constructor(private readonly userService: UserService) {} - - @Get() - async findAll(): Promise { - return this.userService.findAll(); - } +### Dependencies - @Get(':id') - async findOne(@Param('id') id: string): Promise { - return this.userService.findOne(id); - } +`zod` is a direct dependency — request/response shapes are Zod v4 +(Standard Schema) schemas. - @Post() - async create(@Body() userData: Partial): Promise { - return this.userService.create(userData); - } -} -``` +### Peer Dependencies -```typescript -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { UserController } from './user.controller'; -import { UserService } from './user.service'; -import { User } from './user.entity'; +| Package | Required | Notes | +| --- | --- | --- | +| `@nestjs/common` | Yes | NestJS core — install explicitly, no longer bundled | +| `@nestjs/config` | Yes | Module option registration | +| `@nestjs/core` | Yes | Module reference and reflection | +| `@nestjs/cqrs` | No | Optional peer — required in practice for `CommandBus`/`QueryBus`/`EventBus` | +| `typeorm` | No | Only if using the TypeORM repository adapter | +| `@concepta/nestjs-crud` | No | Only if using the HTTP gateway layer | +| `@concepta/typeorm-seeding` | No | Only for database seeding | +| `@faker-js/faker` | No | Only for database seeding | -@Module({ - imports: [TypeOrmModule.forFeature([User])], - controllers: [UserController], - providers: [UserService], -}) -export class UserModule {} -``` +## Module Registration -1. **User Cache Module**: Let's create the entity `UserCache` and the - `UserCacheModule` that imports our `CacheModule` passing all configurations - needed. Please note that `CacheSqliteEntity` and `CachePostgresEntity` are - provided by the Rockets NestJS Cache module, so you can use them to create - your cache entity. They have a unique index with the following properties: - `'key', 'type', 'assignee.id'` and it will throw a - `CacheEntityAlreadyExistsException` if duplicated: - -```typescript -import { Entity, ManyToOne } from 'typeorm'; -import { User } from '../user/user.entity'; -import { CacheSqliteEntity } from '@concepta/nestjs-cache'; -import { ReferenceIdInterface } from '@concepta/nestjs-common'; - -@Entity() -export class UserCache extends CacheSqliteEntity { - @ManyToOne(() => User, (user) => user.userCaches) - assignee!: ReferenceIdInterface; -} -``` +### Synchronous -```typescript -import { Module } from '@nestjs/common'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { CrudModule } from '@concepta/nestjs-crud'; +```ts import { CacheModule } from '@concepta/nestjs-cache'; -import { User } from '../user/user.entity'; -import { UserCache } from './user-cache.entity'; @Module({ imports: [ - TypeOrmExtModule.forFeature({ - userCache: { - entity: UserCache, - }, - }), CacheModule.register({ settings: { - assignments: { - user: { entityKey: 'userCache' }, - }, + expiresIn: '1h', }, }), - CrudModule.forRoot({}), ], }) -export class UserCacheModule {} +export class AppModule {} ``` -1. **App Module**:And let's create our app module to connect everything. +### Asynchronous ```ts -import { Module } from '@nestjs/common'; -import { UserCacheModule } from './user-cache/user-cache.module'; -import { UserModule } from './user/user.module'; -import { User } from './user/user.entity'; -import { UserCache } from './user-cache/user-cache.entity'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - @Module({ -imports: [ - TypeOrmExtModule.forRoot({ - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [User, UserCache], - }), - UserCacheModule, - UserModule, -], -controllers: [], -providers: [], + imports: [ + CacheModule.registerAsync({ + useFactory: async () => ({ + settings: { + expiresIn: '1h', + }, + }), + }), + ], }) export class AppModule {} ``` -#### Using the RestFull endpoints to access cache +`register()` / `registerAsync()` register the module **locally** (scoped to +the importing module). -After setting up the basic configuration, you can start using the caching -functionality in your application. +`forRoot()` / `forRootAsync()` register the module **globally**. This is +required when using `forFeature()` in other modules, since `forFeature()` +injects tokens exported by the core module. -```ts -assignments: { - user: { entityKey: 'userCache' }, -}, -``` +### Multi-Tenancy with forFeature -The code above will generate a route for the client to have access, the module -will generate the following endpoint `/cache/user`. This endpoint will be -referencing whatever entity was associated in the entities section, as you can -see below. +Use `forFeature()` to register dynamic `CacheRepository` providers for each +entity key. This allows different parts of your application to maintain +separate cache tables. ```ts -entities: { - userCache: { - entity: UserCacheEntityFixture, - }, -}, +@Module({ + imports: [ + CacheModule.forFeature(['userCache', 'sessionCache']), + ], +}) +export class UserModule {} ``` -This will make the following endpoints available: +Each entity key maps to a `CacheRepository` instance resolved at runtime by +`CacheRepositoryResolver`. -1. **Create (POST)**: To create a new cache entry, the request body should -match the `CacheCreatableInterface`; Properties `key, type and assignee.id` -are unique and will throw a `CacheEntityAlreadyExistsException` error on -attempt to insert duplicated data: +### Options + +`forRoot()` and `registerAsync()` accept `CacheOptionsInterface` merged with +`CacheExtrasInterface` (extras are passed to `setExtras` on the +`ConfigurableModuleBuilder`): ```ts -export interface CacheCreatableInterface extends Pick { - expiresIn: string | null; +interface CacheExtrasInterface { + global?: boolean; + providers?: Provider[]; + repositories?: { + cache?: Type; + }; } -``` -Example curl command: +interface CacheOptionsInterface { + settings?: CacheSettingsInterface; +} -```sh -curl -X POST http://your-api-url/cache/user \ --H "Content-Type: application/json" \ --d '{ - "key": "exampleKey", - "type": "exampleType", - "data": "{data: 'example'}", - "assignee": { id: 'exampleId'}, - "expiresIn": "1h" -}' +interface CacheSettingsInterface { + expiresIn?: string | null; +} ``` -1. **Read (GET)**: To read a cache entry by its ID: - -```sh -curl -X GET http://your-api-url/cache/user/{id} -``` +The `expiresIn` value accepts time span strings (e.g. `'60'`, `'2 days'`, +`'10h'`, `'7d'`). When not provided, entries do not expire. -1. **Update (PUT)**: To update an existing cache entry, the request body should -match the `CacheUpdatableInterface`: +`forFeature()` accepts an array of entity key strings. Each key creates a +dynamic `CacheRepository` provider: ```ts -export interface CacheUpdatableInterface extends Pick { - expiresIn: string | null; -} +CacheModule.forFeature(entityKeys: string[]) ``` -Example curl command: +Pass `repositories.cache` to override the default `CacheRepository` with a +custom implementation. -```sh -curl -X PUT http://your-api-url/cache/user/{id} \ --H "Content-Type: application/json" \ --d '{ - "key": "updatedKey", - "type": "updatedType", - "data": "updatedData", - "assignee": "updatedAssignee", - "expiresIn": "2d" -}' -``` +## Architecture Overview -1. **Delete (DELETE)**: To delete a cache entry by its ID: +The module follows a DDD/CQRS architecture with four layers: -```sh -curl -X DELETE http://your-api-url/cache/user/{id} +```text +Gateway (HTTP) + | +Application (Commands / Queries) + | +Domain (Cache aggregate, Events) + | +Infrastructure (Repository, Mapper, Schemas, Config) ``` -Replace `http://your-api-url` with the actual base URL of your API, and `{id}` -with the actual ID of the cache entry you wish to interact with. - -1. **Testing the cache**: You can test the cache by creating a new user and -then accessing the cache endpoint: +- **Domain** -- `Cache` aggregate extending `DomainAggregate`, + domain events +- **Application** -- 7 commands and 3 queries dispatched via `@nestjs/cqrs` +- **Infrastructure** -- `CacheRepository` with ctx-first signatures, + `CacheMapper` for entity-to-aggregate conversion (DI-injected), + `CacheRepositoryResolver` for multi-tenancy, Zod schemas +- **Gateway** -- HTTP request handlers bridging `@concepta/nestjs-crud` + to domain commands -```bash -curl -X POST http://your-api-url/user \ --H "Content-Type: application/json" \ --d '{ - "name": "John Doe", -}' -``` +## App Context -The response will be something like this: +Commands, queries, and repository methods accept a `PlainLiteralObject` as +their `ctx` argument. This context is threaded through the transaction scope +and repository layer automatically. In HTTP contexts the gateway provides +the context; for programmatic use, pass any plain object: -```json -{ - "name": "John Doe", - "id": "5f84d150-7ebd-4c59-997a-df65a5935123" -} +```ts +const cache = await this.commandBus.execute( + new CreateCacheCommand({}, 'userCache', dto), +); ``` -Now, let's add something to the cache with reference of the user - -```bash -curl -X POST http://your-api-url/cache/user \ --H "Content-Type: application/json" \ --d '{ - "key": "user", - "type": "filter", - "data": "{data: 'example'}", - "assignee": { "id": "5f84d150-7ebd-4c59-997a-df65a5935123"}, - "expiresIn": "1h" -}' -``` +## Context Overlay -It will give you a response similar to this. - -```json -{ - "id": "a70e629b-7e6d-4dcc-9e74-a2e376f1c19a", - "dateCreated": "2024-06-07T15:16:56.000Z", - "dateUpdated": "2024-06-07T15:16:56.000Z", - "dateDeleted": null, - "version": 1, - "key": "user", - "data": "{data: 'example'}", - "type": "filter", - "assignee": { - "id": "0e5bee5d-5d53-46ef-a94a-22aceea81fc5" - } -} -``` +The cache module uses a context overlay to resolve the entity namespace for +each HTTP request. This is required when using the CRUD gateway. -Now, if you access the cache endpoint `/cache/user`, you will see the new user -cached: +### CacheNamespace Decorator -```bash - curl -X GET http://your-api-url/cache/user -``` +Apply `@CacheNamespace({ name })` to a controller (or via `extraDecorators` +on a generated CRUD controller) to associate it with a cache entity key: -```json -[ - { - "id": "24864a7e-372e-4426-97f0-7e1c7514be16", - "dateCreated": "2024-06-07T15:47:38.000Z", - "dateUpdated": "2024-06-07T15:47:38.000Z", - "dateDeleted": null, - "version": 1, - "key": "user", - "data": "{data: 'example'}", - "type": "filter", - "assignee": { - "id": "5f84d150-7ebd-4c59-997a-df65a5935123" - } - } -] +```ts +import { CacheNamespace } from '@concepta/nestjs-cache'; + +// For generated CRUD controllers, pass via extraDecorators: +CrudModule.forFeature({ + crud: { + controller: { + entity: 'userCache', + path: 'cache/user', + extraDecorators: [CacheNamespace({ name: 'userCache' })], + // ... + }, + }, +}) ``` -# How-to Guides +### How It Works -## Registering CacheModule Synchronously +1. `CacheContextOverlay` reads `@CacheNamespace` metadata via `Reflector` +2. `CacheContextOverlay` extends `ContextOverlayInterceptor` and is registered + as a global `APP_INTERCEPTOR`. Its `attach()` method resolves + the namespace and calls `ctx.defineOverlay(CacheCtx, resolved)` +3. Gateway request handlers use `@Ctx(CacheCtx)` (or `ctx.with(CacheCtx)`) + to get `{ namespace }`, used as the entity key for repository resolution -To register the CacheModule synchronously, you can use the `register` method. -This method allows you to pass configuration options directly. +## Commands -```ts -@Module({ - imports: [ - TypeOrmExtModule.forFeature({ - userCache: { - entity: UserCacheEntityFixture, - }, - }), - CacheModule.register({ - settings: { - assignments: { - user: { entityKey: 'userCache' }, - }, - }, - }), - ], -}) -export class AppModule {} -``` - -## Registering CacheModule Asynchronously +| Command | Description | +| --- | --- | +| `CreateCacheCommand` | `(ctx, namespace, dto)` -- Create a new cache entry | +| `UpdateCacheCommand` | `(ctx, namespace, id, dto)` -- Partial update (data and expiresIn) | +| `ReplaceCacheCommand` | `(ctx, namespace, id, dto)` -- Full replacement (creates if ID not found) | +| `UpsertCacheCommand` | `(ctx, namespace, dto)` -- Create or update by key/type/assigneeId | +| `RemoveCacheCommand` | `(ctx, namespace, id)` -- Hard delete by ID | +| `ArchiveCacheCommand` | `(ctx, namespace, id)` -- Soft delete by ID | +| `ClearCachesByAssigneeCommand` | `(ctx, namespace, assigneeId)` -- Remove all entries for an assignee | -For more advanced use cases, you can register the CacheModule asynchronously using -the `registerAsync` method. This method is useful when you need to perform -asynchronous operations to get the configuration options. +### Dispatching a Command ```ts -@Module({ - imports: [ - CacheModule.registerAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - userCache: { - entity: UserCacheEntityFixture, - }, - }), - ], - entities: ['userCache'], - useFactory: async () => ({ - settings: { - assignments: { - user: { entityKey: 'userCache' }, - }, - }, - }), - }), - ], -}) -export class AppModule {} +import { CommandBus } from '@nestjs/cqrs'; +import { CreateCacheCommand, Cache } from '@concepta/nestjs-cache'; + +const cache = await this.commandBus.execute( + new CreateCacheCommand(ctx, 'userCache', { + key: 'dashboard-filter', + type: 'user-preference', + assigneeId: userId, + data: JSON.stringify(filterState), + expiresIn: '7d', + }), +); ``` -## Global Registering CacheModule Asynchronously +## Queries + +| Query | Description | +| --- | --- | +| `GetCacheQuery` | `(ctx, namespace, id)` -- Get by ID (throws `CacheNotFoundException`) | +| `FindOneCacheQuery` | `(ctx, namespace, key, type, assigneeId)` -- Find by key/type/assigneeId (returns null) | +| `FindCachesByAssigneeQuery` | `(ctx, namespace, assigneeId)` -- Find all entries for an assigneeId | -For more advanced use cases, you can register the global CacheModule asynchronously -using the `forRootAsync` method. This method is useful when you need to perform -asynchronous operations to get the configuration options. +### Dispatching a Query ```ts -@Module({ - imports: [ - CacheModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - userCache: { - entity: UserCacheEntityFixture, - }, - }), - ], - entities: ['userCache'], - useFactory: async () => ({ - settings: { - assignments: { - user: { entityKey: 'userCache' }, - }, - }, - }), - }), - ], -}) -export class AppModule {} +import { QueryBus } from '@nestjs/cqrs'; +import { FindOneCacheQuery, Cache } from '@concepta/nestjs-cache'; + +const cache = await this.queryBus.execute( + new FindOneCacheQuery(ctx, 'userCache', 'dashboard-filter', 'user-preference', userId), +); ``` -## Registering CacheModule Asynchronously for multiple entities +## Domain Events + +All events carry an `eventContext` and a plain `CacheInterface` snapshot. -This section demonstrates how to register the CacheModule asynchronously when -dealing with multiple entities. +| Event | Emitted When | +| --- | --- | +| `CacheCreatedEvent` | New cache entry created | +| `CacheUpdatedEvent` | Cache data updated | +| `CacheReplacedEvent` | Cache fully replaced | +| `CacheExtendedEvent` | Cache expiration extended | -### Example +### Handling an Event ```ts -@Module({ - imports: [ - CacheModule.registerAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - userCache: { - entity: UserCacheEntityFixture, - }, - petCache: { - entity: PetCacheEntity, - }, - }), - ], - entities: ['userCache'], - useFactory: async () => ({ - settings: { - assignments: { - user: { entityKey: 'userCache' }, - pet: { entityKey: 'petCache' }, - }, - }, - }), - }), - ], -}) -export class AppModule {} +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { CacheCreatedEvent } from '@concepta/nestjs-cache'; + +@EventsHandler(CacheCreatedEvent) +export class CacheCreatedListener implements IEventHandler { + handle(event: CacheCreatedEvent): void { + const { eventContext, cache } = event; + // react to cache creation + } +} ``` -## Using the CacheService to access cache +## Cache Aggregate -The `CacheService` provided by the Rockets NestJS Cache module offers a -comprehensive set of methods to manage cache entries programmatically from the -API side. This service allows for creating, updating, retrieving, and deleting -cache entries, as well as managing cache entries for specific assignees. Below -is an overview of how to use these services in your application. +The `Cache` class extends `DomainAggregate` and encapsulates +all cache domain logic. -### Creating a Cache Entry +### Factory Methods -To create a new cache entry, you can use the `create` method of the `CacheService`. -This method requires specifying the cache assignment, the cache data, and -optionally, query options. +```ts +// Create with auto-generated UUID +const cache = Cache.create(eventContext, dto, expirationDate); + +// Create with a specific ID +const cache = Cache.createWithId(eventContext, id, dto, expirationDate); +``` -### CacheService Methods Documentation +Reconstitution from a database entity is handled by `CacheMapper` (see +[Repository](#repository)). -CacheService is exported in the CacheModule, so -Below is a simple documentation for each method in the `CacheService` class, including - examples of how to use them. +### Operations -#### 1. `create(assignment, cache, queryOptions)` +```ts +// Replace all fields (preserves id and dateCreated) +cache.replace(eventContext, dto, expirationDate); + +// Update only the data field +cache.updateData(eventContext, newData); -Creates a new cache entry. +// Extend expiration +cache.extend(eventContext, expirationDate); -**Parameters:** +// Convert to plain CacheInterface object (inherited from DomainAggregate) +const plain = cache.toPlain(); +``` -- `assignment`: The cache assignment. -- `cache`: The data to create, implementing `CacheCreatableInterface`. -- `queryOptions`: Optional. Additional options for the query. +## Expiration Policy -**Example:** -Create a cache entry with a unique combination of `key`, `type`, and `assignee.id`: +`CacheExpirationPolicy` (exported with its `CacheExpirationSettings` +interface) converts `expiresIn` time spans into concrete expiration dates. +It is provided in DI from the module settings, so the module's default +`expiresIn` is used when a request does not supply one. ```ts -await cacheService.create('userCache', { - key: 'userSession', - type: 'session', - data: { sessionData: 'abc123' }, - assignee: { id: 'user1' }, - expiresIn: '24h' -}); +interface CacheExpirationSettings { + expiresIn?: string | null; +} + +class CacheExpirationPolicy { + constructor(settings?: CacheExpirationSettings); + get defaultExpiresIn(): string | null; + resolveExpirationDate(expiresIn?: string | null): Date | null; +} ``` -#### 2. `update(assignment, cache, queryOptions)` +`resolveExpirationDate()` resolves the given time span (falling back to the +default) into a `Date`, or `null` when no expiration applies. An invalid +time span throws `CacheInvalidExpiredDateException`. -Updates an existing cache entry. +## Repository -**Parameters:** +`CacheRepository` uses a ctx-first calling convention for multi-tenancy +support. All methods take `PlainLiteralObject` as the first argument. -- `assignment`: The cache assignment. -- `cache`: The data to update, implementing `CacheUpdatableInterface`. -- `queryOptions`: Optional. Additional options for the query. +The repository receives a DI-injected `CacheMapper` that converts database +entities to `Cache` aggregates via `toDomain()` and aggregates back to +persistence form via `toPersistence()`. -**Example:** -Update a cache entry identified by `key`, `type`, and `assignee.id`: +| Method | Signature | +| --- | --- | +| `get` | `(ctx, id) => Promise` | +| `findOne` | `(ctx, { key, type, assigneeId }) => Promise` | +| `findAllByAssignee` | `(ctx, assigneeId) => Promise` | +| `save` | `(ctx, cache) => Promise` | +| `remove` | `(ctx, cache) => Promise` | +| `removeAllByAssignee` | `(ctx, assigneeId) => Promise` | +| `softRemove` | `(ctx, cache) => Promise` | + +### Repository Resolution ```ts -await cacheService.update('userCache', { - key: 'userSession', - type: 'session', - data: { sessionData: 'updated123' }, - assignee: { id: 'user1' } -}); +const cacheRepo = this.repositoryResolver.resolve(ctx.entity); +const cache = await cacheRepo.get(ctx, id); ``` -#### 3. `delete(assignment, cache, queryOptions)` +`CacheRepositoryResolver` looks up the repository by entity key. Entity keys +are registered via `CacheModule.forFeature()`. -Deletes a cache entry. +## Schemas -**Parameters:** +Request and response shapes are Zod v4 (Standard Schema) schemas. -- `assignment`: The cache assignment. -- `cache`: The cache to delete, specifying `key`, `type`, and `assignee`. +| Schema | Entry Point | Fields | +| --- | --- | --- | +| `cacheCreateSchema` | main | key, type, assigneeId, data (optional, nullable), expiresIn (optional, nullable) | +| `cacheUpdateSchema` | main | data (optional, nullable), expiresIn (optional, nullable) | +| `cacheSchema` | main | Full entity response (key, type, data, assigneeId, expirationDate, + common entity fields) | +| `cachePaginatedSchema` | `optional/crud` | Paginated envelope wrapping `cacheSchema` | -**Example:** -Delete a cache entry using `key`, `type`, and `assignee.id`: +The `expiresIn` field accepts time span strings: `'60'`, `'2 days'`, `'10h'`, +`'7d'`. -```ts -await cacheService.delete('userCache', { - key: 'userSession', - type: 'session', - assignee: { id: 'user1' } -}); -``` +`expiresIn` is request-only: the response schema `cacheSchema` intentionally +omits it. The domain converts `expiresIn` into the computed `expirationDate`, +which is what persisted entities and responses carry. -#### 4. `getAssignedCaches(assignment, cache, queryOptions)` +## Exceptions -Retrieves all cache entries for a given assignee. +| Exception | Description | +| --- | --- | +| `CacheNotFoundException` | Cache ID not found (HTTP 404) | +| `CacheEntityNotFoundException` | Entity key not registered via `forFeature()` | +| `CacheInvalidExpiredDateException` | Invalid `expiresIn` format (HTTP 400) | +| `CacheException` | Base cache exception | -**Parameters:** +## HTTP Controller with CRUD Module -- `assignment`: The cache assignment. -- `cache`: The cache to get assignments, specifying `assignee`. +Use `@concepta/nestjs-crud` to expose cache operations as REST endpoints. The +gateway request/handler classes are exported from +`@concepta/nestjs-cache/optional/crud`. -**Example:** -Retrieve all caches for a specific assignee: +The CRUD gateway needs the surrounding modules registered as well: +`CqrsModule.forRoot()`, `CrudModule.forRoot()` (with `CrudCqrsResolver` as +the default resolver), `CoreModule.forRoot()` (context overlays), and a +`RepositoryModule.forFeature()` mapping the entity key to your cache entity +class. ```ts -const caches = await cacheService.getAssignedCaches('userCache', { assignee: { id: 'userId' } }); +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { CoreModule, Operation } from '@concepta/nestjs-core'; +import { CrudCqrsResolver, CrudModule } from '@concepta/nestjs-crud'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; +import { + CacheInterface, + CacheModule, + CacheNamespace, + cacheCreateSchema, + cacheUpdateSchema, + cacheSchema, +} from '@concepta/nestjs-cache'; +import { + cachePaginatedSchema, + CreateCacheRequest, + CreateCacheRequestHandler, + UpdateCacheRequest, + UpdateCacheRequestHandler, + ReplaceCacheRequest, + ReplaceCacheRequestHandler, + DeleteCacheRequest, + DeleteCacheRequestHandler, + ListCachesRequest, + ListCachesRequestHandler, + ReadCacheRequest, + ReadCacheRequestHandler, +} from '@concepta/nestjs-cache/optional/crud'; + +@Module({ + imports: [ + CqrsModule.forRoot(), + RepositoryModule.forRoot({}), + CrudModule.forRoot({ + defaultResolver: CrudCqrsResolver, + }), + CoreModule.forRoot(), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [{ key: 'userCache', entity: UserCacheEntity }], + }), + CacheModule.forFeature(['userCache']), + CrudModule.forFeature({ + crud: { + controller: { + entity: 'userCache', + path: 'cache/user', + resolver: CrudCqrsResolver, + transactional: true, + extraDecorators: [CacheNamespace({ name: 'userCache' })], + request: { body: cacheCreateSchema }, + response: { + resource: cacheSchema, + paginated: cachePaginatedSchema, + }, + }, + operations: [ + { + operation: Operation.List, + query: ListCachesRequest, + queryHandler: ListCachesRequestHandler, + }, + { + operation: Operation.Read, + query: ReadCacheRequest, + queryHandler: ReadCacheRequestHandler, + }, + { + operation: Operation.Create, + request: { body: cacheCreateSchema }, + command: CreateCacheRequest, + commandHandler: CreateCacheRequestHandler, + }, + { + operation: Operation.Update, + request: { body: cacheUpdateSchema }, + command: UpdateCacheRequest, + commandHandler: UpdateCacheRequestHandler, + }, + { + operation: Operation.Replace, + request: { body: cacheCreateSchema }, + command: ReplaceCacheRequest, + commandHandler: ReplaceCacheRequestHandler, + }, + { + operation: Operation.Delete, + command: DeleteCacheRequest, + commandHandler: DeleteCacheRequestHandler, + }, + ], + }, + }), + ], +}) +export class UserCacheModule {} ``` -#### 5. `get(assignment, cache, queryOptions)` +This registers a CRUD controller at `/cache/user` with List, Read, Create, +Update, Replace, and Delete operations. The `CrudCqrsResolver` bridges HTTP +requests to domain commands and queries via the CQRS bus. Set `transactional: +true` to wrap each operation in a database transaction. -Retrieves a specific cache entry. +`CacheModule.forRoot()` (or `forRootAsync()`) must be registered globally +in a parent module for `forFeature()` to resolve its dependencies. -**Parameters:** +This is a minimal example. `CrudModule.forFeature()` supports additional +options including custom resolvers, route guards, schema-based per-operation +serialization overrides, and per-operation request overrides. See the +`@concepta/nestjs-crud` documentation for the full API. -- `assignment`: The cache assignment. -- `cache`: The cache to get, specifying `key`, `type`, and `assignee`. +### OpenAPI -**Example:** -Retrieve a specific cache entry using `key`, `type`, and `assignee.id`: +Create the swagger document with the `standardSchemaConverter` from +`@concepta/nestjs-core`: ```ts -const cacheEntry = await cacheService.get('userCache', { - key: 'userSession', - type: 'session', - assignee: { id: 'user1' } -}); +SwaggerModule.createDocument(app, config, { standardSchemaConverter }); ``` -#### 6. `clear(assignment, cache, queryOptions)` +The cache schemas register as bare OpenAPI component ids: `Cache` +(`cacheSchema`) and `CachePaginated` (`cachePaginatedSchema`). Request body +schemas are documented inline. -Clears all caches for a given assignee. +## Entry Points -**Parameters:** +| Import Path | Contents | +| --- | --- | +| `@concepta/nestjs-cache` | Module, aggregate, commands, queries, events, handlers, schemas, expiration policy, repository, exceptions, domain interfaces | +| `@concepta/nestjs-cache/optional/crud` | CRUD request/handler classes, paginated schema | +| `@concepta/nestjs-cache/optional/typeorm` | `CacheSqliteEntity`, `CachePostgresEntity` | +| `@concepta/nestjs-cache/optional/seeding` | `CacheFactory` | -- `assignment`: The cache assignment. -- `cache`: The cache to clear, specifying `assignee`. +## Seeding -**Example:** -Clear all caches for a specific assignee: +A `CacheFactory` is available for test seeding: ```ts -await cacheService.clear('userCache', { assignee: { id: 'user1' } }); +import { CacheFactory } from '@concepta/nestjs-cache/optional/seeding'; ``` -These methods provide a comprehensive interface for managing cache entries in a -NestJS application using the `CacheService`. Each method supports optional query -options for more granular control over the database operations. - -## Reference - -For detailed information on the properties, methods, and classes used in the -`@concepta/nestjs-cache`, please refer to the API documentation -available at [CacheModule API Documentation](https://www.rockets.tools/reference/rockets/nestjs-access-control/README). -This documentation provides comprehensive details on the interfaces and services -that you can utilize to start using cache functionality within your NestJS -application. - -## Explanation - -### Conceptual Overview of Caching - -#### What is Caching? - -Caching is a technique used to store copies of data in a temporary storage location -(cache) so that future requests for that data can be served faster. It helps in -reducing the time required to access data and decreases the load on the primary -data source. - -#### Benefits of Using Cache - -- **Improved Performance**: By serving data from the cache, applications can - respond to requests faster than retrieving the data from the primary source - each time. -- **Reduced Latency**: Caching reduces the latency involved in data retrieval - operations, improving the user experience. -- **Lower Database Load**: By reducing the number of direct database queries, - caching helps in decreasing the load on the database, leading to better overall - performance. -- **Scalability**: Caching allows applications to handle higher loads by serving - frequent requests from the cache instead of the database. - -#### Why Use NestJS Cache? - -NestJS Cache provides a powerful and flexible caching solution that integrates -seamlessly with the NestJS framework and stores your cache on the database, so -you can reuse it in any other part of your application or even in other -applications that are calling your API. It allows developers to manage cached -data efficiently and provides built-in support for CRUD operations on cache -entries. Here are some key reasons to use NestJS Cache: - -- **Integration with NestJS Ecosystem**: The module integrates well with other - NestJS modules and leverages the framework's features, such as decorators and - dependency injection. -- **Customizable and Extensible**: It allows for customization through various - configuration options and can be extended with custom services and guards. -- **Ease of Use**: The module provides straightforward APIs for managing cache - entries, making it easy to implement caching in your application. -- **Automatic Expiration Handling**: The module can automatically handle - expiration times for cache entries, ensuring that stale data is not served. - -#### When to Use NestJS Cache - -NestJS Cache is useful in scenarios where data is frequently accessed but does -not change often. It is also beneficial when the performance of data retrieval -operations needs to be improved. Here are some examples of when to use NestJS -Cache: - -- **Storing Filters for a Specific Dashboard**: If you have a dashboard with - complex filters that are expensive to compute, you can cache the filters for - each user. This way, subsequent requests can be served from the cache, reducing - the load on the server and improving response times. - -Example: -When a user applies a filter on a dashboard, the filter settings can be cached. -The next time the user accesses the dashboard, the cached filter can be retrieved -quickly without recomputing it. - -#### How CacheOptionsInterface is Used in the Controller and Endpoints - -The `CacheSettingsInterface` and `CacheOptionsInterface` are used to configure -the caching behavior in the `CacheCrudController`. The `CacheCrudController` -provides endpoints for CRUD operations on cache entries and uses these -interfaces to manage the settings and services for each cache assignment. - -- `CacheSettingsInterface` manages how entities are assigned for caching and - specifies the expiration time for cache entries. It is used to ensure the - correct service and configuration are applied to each cache assignment. -- `CacheOptionsInterface` includes the settings for managing cache assignments - and expiration times. It is used to register and configure the CacheModule, - determining which entities should be cached and how they should be handled. - -By using these interfaces, the `CacheCrudController` can dynamically handle -different cache assignments and provide a consistent caching strategy across -the application. The endpoints in the controller allow for creating, reading, -updating, and deleting cache entries, ensuring that the caching behavior is -flexible and easily configurable. - -#### Design Choices in CacheModule - -##### Global vs Synchronous vs Asynchronous Registration - -- **Global Registration**: Registers the CacheModule at the root level, making it - available throughout the entire application. It is useful for shared - configurations that need to be applied universally. -- **Synchronous Registration**: This method is used when all configuration options - are available at the time of module registration. It is simple and - straightforward, making it suitable for most use cases. -- **Asynchronous Registration**: This method is used when configuration options - need to be fetched or computed asynchronously. It provides greater flexibility - and is useful for advanced scenarios where configuration depends on runtime - conditions. +## Environment Variables + +| Variable | Default | Description | +| --- | --- | --- | +| `CACHE_EXPIRE_IN` | `null` | Default expiration time span for cache entries | diff --git a/packages/nestjs-cache/package.json b/packages/nestjs-cache/package.json index 04da8c288..cb093c348 100644 --- a/packages/nestjs-cache/package.json +++ b/packages/nestjs-cache/package.json @@ -1,38 +1,80 @@ { "name": "@concepta/nestjs-cache", - "version": "7.0.0-alpha.10", - "description": "Rockets NestJS User", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "version": "8.0.0-alpha.10", + "description": "Rockets NestJS Cache", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./optional/crud": { + "types": "./dist/optional-crud.d.ts", + "default": "./dist/optional-crud.js" + }, + "./optional/seeding": { + "types": "./dist/optional-seeding.d.ts", + "default": "./dist/optional-seeding.js" + }, + "./optional/typeorm": { + "types": "./dist/optional-typeorm.d.ts", + "default": "./dist/optional-typeorm.js" + } + }, "license": "BSD-3-Clause", "publishConfig": { "access": "public" }, "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" ], "dependencies": { - "@concepta/nestjs-access-control": "^7.0.0-alpha.10", - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", - "@nestjs/swagger": "^11.2.2" + "@concepta/nestjs-core": "^8.0.0-alpha.10", + "@concepta/nestjs-repository": "8.0.0-alpha.10", + "zod": "^4.4.3" }, "devDependencies": { - "@concepta/nestjs-crud": "^7.0.0-alpha.10", - "@concepta/nestjs-typeorm-ext": "^7.0.0-alpha.10", + "@concepta/nestjs-crud": "8.0.0-alpha.10", + "@concepta/nestjs-repository-typeorm": "8.0.0-alpha.10", "@concepta/typeorm-seeding": "^4.0.0", "@faker-js/faker": "^8.4.1", - "@nestjs/testing": "^11.1.9", - "@nestjs/typeorm": "^11.0.0", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/swagger": "^12.0.1", + "@nestjs/testing": "^12.0.1", + "@nestjs/typeorm": "^12.0.1", "@types/supertest": "^6.0.3", - "jest-mock-extended": "^4.0.0", - "supertest": "^6.3.4" + "supertest": "^6.3.4", + "vitest-mock-extended": "^4.0.0" }, "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", + "@concepta/nestjs-crud": "8.0.0-alpha.10", + "@concepta/typeorm-seeding": "^4.0.0", + "@faker-js/faker": "^8.4.1", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", "typeorm": "^0.3.0" + }, + "peerDependenciesMeta": { + "@concepta/nestjs-crud": { + "optional": true + }, + "@concepta/typeorm-seeding": { + "optional": true + }, + "@faker-js/faker": { + "optional": true + }, + "@nestjs/cqrs": { + "optional": true + } } } diff --git a/packages/nestjs-cache/src/__fixtures__/app-crud.module.fixture.ts b/packages/nestjs-cache/src/__fixtures__/app-crud.module.fixture.ts deleted file mode 100644 index 5457b9f3c..000000000 --- a/packages/nestjs-cache/src/__fixtures__/app-crud.module.fixture.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Module } from '@nestjs/common'; -import { APP_FILTER } from '@nestjs/core'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { ExceptionsFilter } from '@concepta/nestjs-common'; -import { CrudModule } from '@concepta/nestjs-crud'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { CACHE_MODULE_CACHE_ENTITY_KEY } from '../cache.constants'; - -import { CacheCrudControllerFixture } from './cache-crud.controller.fixture'; -import { CacheCrudServiceFixture } from './cache-crud.service.fixture'; -import { CacheTypeOrmCrudAdapterFixture } from './cache-typeorm-crud.adapter.fixture'; -import { UserCacheEntityFixture } from './entities/user-cache-entity.fixture'; -import { UserEntityFixture } from './entities/user-entity.fixture'; - -@Module({ - imports: [ - TypeOrmModule.forRoot({ - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [UserEntityFixture, UserCacheEntityFixture], - }), - TypeOrmExtModule.forFeature({ - [CACHE_MODULE_CACHE_ENTITY_KEY]: { - entity: UserCacheEntityFixture, - }, - }), - CrudModule.forRoot({}), - ], - controllers: [CacheCrudControllerFixture], - providers: [ - CacheTypeOrmCrudAdapterFixture, - CacheCrudServiceFixture, - { - provide: APP_FILTER, - useClass: ExceptionsFilter, - }, - ], -}) -export class AppCrudModuleFixture {} diff --git a/packages/nestjs-cache/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-cache/src/__fixtures__/app.module.fixture.ts deleted file mode 100644 index 054350018..000000000 --- a/packages/nestjs-cache/src/__fixtures__/app.module.fixture.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Module } from '@nestjs/common'; -import { APP_FILTER } from '@nestjs/core'; - -import { ExceptionsFilter } from '@concepta/nestjs-common'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { CacheModule } from '../cache.module'; - -import { UserCacheEntityFixture } from './entities/user-cache-entity.fixture'; -import { UserEntityFixture } from './entities/user-entity.fixture'; - -@Module({ - imports: [ - TypeOrmExtModule.forRoot({ - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [UserEntityFixture, UserCacheEntityFixture], - }), - CacheModule.registerAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - userCache: { - entity: UserCacheEntityFixture, - }, - }), - ], - useFactory: () => ({ - settings: { - assignments: { - user: { entityKey: 'userCache' }, - }, - }, - }), - entities: ['userCache'], - }), - ], - controllers: [], - providers: [ - { - provide: APP_FILTER, - useClass: ExceptionsFilter, - }, - ], -}) -export class AppModuleFixture {} diff --git a/packages/nestjs-cache/src/__fixtures__/cache-crud.controller.fixture.ts b/packages/nestjs-cache/src/__fixtures__/cache-crud.controller.fixture.ts deleted file mode 100644 index 44e45f180..000000000 --- a/packages/nestjs-cache/src/__fixtures__/cache-crud.controller.fixture.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; - -import { - AccessControlCreateOne, - AccessControlDeleteOne, - AccessControlReadMany, - AccessControlReadOne, -} from '@concepta/nestjs-access-control'; -import { - CacheCreatableInterface, - CacheInterface, - CacheUpdatableInterface, -} from '@concepta/nestjs-common'; -import { - CrudBody, - CrudController, - CrudControllerInterface, - CrudCreateOne, - CrudDeleteOne, - CrudReadMany, - CrudReadOne, - CrudReplaceOne, - CrudRequest, - CrudRequestInterface, - CrudUpdateOne, -} from '@concepta/nestjs-crud'; - -import { CacheResource } from '../cache.types'; -import { CacheCreateDto } from '../dto/cache-create.dto'; -import { CachePaginatedDto } from '../dto/cache-paginated.dto'; -import { CacheUpdateDto } from '../dto/cache-update.dto'; -import { CacheDto } from '../dto/cache.dto'; -import getExpirationDate from '../utils/get-expiration-date.util'; - -import { CacheCrudServiceFixture } from './cache-crud.service.fixture'; -/** - * Cache assignment controller. - */ -@ApiTags('cache') -@CrudController({ - path: 'cache/user', - model: { - type: CacheDto, - paginatedType: CachePaginatedDto, - }, - params: { - id: { field: 'id', type: 'string', primary: true }, - }, -}) -export class CacheCrudControllerFixture - implements - CrudControllerInterface< - CacheInterface, - CacheCreatableInterface, - CacheUpdatableInterface, - CacheCreatableInterface - > -{ - /** - * Constructor. - * - * @param cacheCrudService - instances of all crud services - */ - constructor(private cacheCrudService: CacheCrudServiceFixture) {} - - /** - * Get many - * - * @param crudRequest - the CRUD request object - */ - @CrudReadMany() - @AccessControlReadMany(CacheResource.Many) - async getMany(@CrudRequest() crudRequest: CrudRequestInterface) { - return this.cacheCrudService.getMany(crudRequest); - } - - /** - * Get one - * - * @param crudRequest - the CRUD request object - */ - @CrudReadOne() - @AccessControlReadOne(CacheResource.One) - async getOne(@CrudRequest() crudRequest: CrudRequestInterface) { - return this.cacheCrudService.getOne(crudRequest); - } - - /** - * Create one - * - * @param crudRequest - the CRUD request object - * @param cacheCreateDto - cache create dto - */ - @CrudCreateOne() - @AccessControlCreateOne(CacheResource.One) - async createOne( - @CrudRequest() crudRequest: CrudRequestInterface, - @CrudBody() cacheCreateDto: CacheCreateDto, - ) { - const expirationDate = getExpirationDate(cacheCreateDto.expiresIn); - - // call crud service to create - return this.cacheCrudService.createOne(crudRequest, { - ...cacheCreateDto, - expirationDate, - }); - } - - /** - * Create one - * - * @param crudRequest - the CRUD request object - * @param cacheUpdateDto - cache update dto - */ - @CrudUpdateOne() - @AccessControlCreateOne(CacheResource.One) - async updateOne( - @CrudRequest() crudRequest: CrudRequestInterface, - @CrudBody() cacheUpdateDto: CacheUpdateDto, - ) { - const expirationDate = getExpirationDate(cacheUpdateDto.expiresIn); - - // call crud service to create - return this.cacheCrudService.updateOne(crudRequest, { - ...cacheUpdateDto, - expirationDate, - }); - } - - /** - * Delete one - * - * @param crudRequest - the CRUD request object - */ - @CrudDeleteOne() - @AccessControlDeleteOne(CacheResource.One) - async deleteOne(@CrudRequest() crudRequest: CrudRequestInterface) { - return this.cacheCrudService.deleteOne(crudRequest); - } - - /** - * Do a Upsert operation for cache - * - * @param crudRequest - the CRUD request object - * @param cacheUpdateDto - cache update dto - */ - @ApiOkResponse({ - type: CacheDto, - }) - @CrudReplaceOne() - @AccessControlCreateOne(CacheResource.One) - async replaceOne( - @CrudRequest() crudRequest: CrudRequestInterface, - @CrudBody() cacheUpdateDto: CacheUpdateDto, - ) { - let cache; - try { - cache = await this.getOne(crudRequest); - } catch (error) { - // error is NOT a not found exception? - if (error instanceof NotFoundException !== true) { - // rethrow it - throw error; - } - } - if (cache && cache?.id) { - const expirationDate = getExpirationDate(cacheUpdateDto.expiresIn); - - // call crud service to create - return this.cacheCrudService.replaceOne(crudRequest, { - ...cacheUpdateDto, - expirationDate, - }); - } else { - return this.createOne(crudRequest, cacheUpdateDto); - } - } -} diff --git a/packages/nestjs-cache/src/__fixtures__/cache-crud.service.fixture.ts b/packages/nestjs-cache/src/__fixtures__/cache-crud.service.fixture.ts deleted file mode 100644 index bd9f8dfef..000000000 --- a/packages/nestjs-cache/src/__fixtures__/cache-crud.service.fixture.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { CacheInterface } from '@concepta/nestjs-common'; -import { CrudAdapter, CrudService } from '@concepta/nestjs-crud'; - -import { CacheTypeOrmCrudAdapterFixture } from './cache-typeorm-crud.adapter.fixture'; - -/** - * Cache CRUD service - */ -@Injectable() -export class CacheCrudServiceFixture extends CrudService { - /** - * Constructor - * - * @param crudAdapter - instance of the cache crud adapter. - */ - constructor( - @Inject(CacheTypeOrmCrudAdapterFixture) - crudAdapter: CrudAdapter, - ) { - super(crudAdapter); - } -} diff --git a/packages/nestjs-cache/src/__fixtures__/cache-typeorm-crud.adapter.fixture.ts b/packages/nestjs-cache/src/__fixtures__/cache-typeorm-crud.adapter.fixture.ts deleted file mode 100644 index 3ed77ad28..000000000 --- a/packages/nestjs-cache/src/__fixtures__/cache-typeorm-crud.adapter.fixture.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - CacheInterface, - InjectDynamicRepository, -} from '@concepta/nestjs-common'; -import { TypeOrmCrudAdapter } from '@concepta/nestjs-crud'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { CACHE_MODULE_CACHE_ENTITY_KEY } from '../cache.constants'; - -/** - * Cache typeorm CRUD adapter - */ -@Injectable() -export class CacheTypeOrmCrudAdapterFixture extends TypeOrmCrudAdapter { - /** - * Constructor - * - * @param repoAdapter - instance of the cache repository adapter. - */ - constructor( - @InjectDynamicRepository(CACHE_MODULE_CACHE_ENTITY_KEY) - repoAdapter: TypeOrmRepositoryAdapter, - ) { - super(repoAdapter); - } -} diff --git a/packages/nestjs-cache/src/__fixtures__/entities/user-cache-entity.fixture.ts b/packages/nestjs-cache/src/__fixtures__/entities/user-cache-entity.fixture.ts deleted file mode 100644 index f631ccb27..000000000 --- a/packages/nestjs-cache/src/__fixtures__/entities/user-cache-entity.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Entity, Unique } from 'typeorm'; - -import { CacheSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * Cache Entity Fixture - */ -@Entity() -@Unique(['key', 'type', 'assigneeId']) -export class UserCacheEntityFixture extends CacheSqliteEntity {} diff --git a/packages/nestjs-cache/src/__fixtures__/entities/user-entity.fixture.ts b/packages/nestjs-cache/src/__fixtures__/entities/user-entity.fixture.ts deleted file mode 100644 index 9129b0070..000000000 --- a/packages/nestjs-cache/src/__fixtures__/entities/user-entity.fixture.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; - -import { ReferenceIdInterface } from '@concepta/nestjs-common'; - -/** - * User Entity Fixture - */ -@Entity() -export class UserEntityFixture implements ReferenceIdInterface { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ default: false }) - isActive!: boolean; -} diff --git a/packages/nestjs-cache/src/__fixtures__/factories/user.factory.fixture.ts b/packages/nestjs-cache/src/__fixtures__/factories/user.factory.fixture.ts deleted file mode 100644 index 0d0651dda..000000000 --- a/packages/nestjs-cache/src/__fixtures__/factories/user.factory.fixture.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Factory } from '@concepta/typeorm-seeding'; - -import { UserEntityFixture } from '../entities/user-entity.fixture'; - -export class UserFactoryFixture extends Factory { - options = { - entity: UserEntityFixture, - }; -} diff --git a/packages/nestjs-cache/src/__tests__/cache.module.spec.ts b/packages/nestjs-cache/src/__tests__/cache.module.spec.ts new file mode 100644 index 000000000..a70163eae --- /dev/null +++ b/packages/nestjs-cache/src/__tests__/cache.module.spec.ts @@ -0,0 +1,84 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { CACHE_REPOSITORY_RESOLVER_TOKEN } from '../cache.constants.js'; +import { CacheModule } from '../cache.module.js'; +import { type CacheRepositoryResolverInterface } from '../domain/repositories/cache-repository-resolver.interface.js'; +import { CacheRepository } from '../infrastructure/persistence/cache.repository.js'; + +import { AppModuleFixture } from './fixtures/app.module.fixture.js'; + +describe(CacheModule.name, () => { + let cacheModule: CacheModule; + + beforeEach(async () => { + const testModule: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + cacheModule = testModule.get(CacheModule); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('module', () => { + it('should be loaded', async () => { + expect(cacheModule).toBeInstanceOf(CacheModule); + }); + }); + + describe('register', () => { + it('should return a dynamic module', () => { + const result = CacheModule.register({}); + expect(result.module).toBe(CacheModule); + expect(result.imports).toHaveLength(1); + }); + }); + + describe('registerAsync', () => { + it('should return a dynamic module', () => { + const result = CacheModule.registerAsync({}); + expect(result.module).toBe(CacheModule); + expect(result.imports).toHaveLength(1); + }); + }); + + describe('forRoot', () => { + it('should return a global dynamic module', () => { + const result = CacheModule.forRoot({}); + expect(result.module).toBe(CacheModule); + expect(result.imports).toHaveLength(1); + }); + }); + + describe('forRootAsync', () => { + it('should return a global dynamic module', () => { + const result = CacheModule.forRootAsync({}); + expect(result.module).toBe(CacheModule); + expect(result.imports).toHaveLength(1); + }); + }); + + describe('forFeature', () => { + it('should return providers for each entity key', () => { + const result = CacheModule.forFeature(['userCache', 'sessionCache']); + expect(result.module).toBe(CacheModule); + expect(result.providers).toHaveLength(2); + expect(result.exports).toHaveLength(2); + }); + + it('should resolve CacheRepository via CacheRepositoryResolver', async () => { + const testModule: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + const resolver = testModule.get( + CACHE_REPOSITORY_RESOLVER_TOKEN, + ); + const repo = resolver.resolve('userCache'); + + expect(repo).toBeInstanceOf(CacheRepository); + }); + }); +}); diff --git a/packages/nestjs-cache/src/__tests__/exception-fault.spec.ts b/packages/nestjs-cache/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..bfeaa525e --- /dev/null +++ b/packages/nestjs-cache/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,61 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { CacheNotFoundException } from '../application/exceptions/cache-not-found.exception.js'; +import { CacheInvalidExpiredDateException } from '../domain/exceptions/cache-invalid-expired-date.exception.js'; +import { CacheException } from '../domain/exceptions/cache.exception.js'; +import { CacheEntityNotFoundException } from '../infrastructure/exceptions/cache-entity-not-found.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'CacheException (default)', + build: () => new CacheException(), + fault: 'internal', + }, + { + name: 'CacheInvalidExpiredDateException', + build: () => new CacheInvalidExpiredDateException(), + fault: 'client', + }, + { + name: 'CacheNotFoundException', + build: () => new CacheNotFoundException('id'), + fault: 'client', + }, + { + name: 'CacheEntityNotFoundException', + build: () => new CacheEntityNotFoundException('SomeEntity'), + fault: 'usage', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-cache/src/__tests__/fixtures/app.module.fixture.ts b/packages/nestjs-cache/src/__tests__/fixtures/app.module.fixture.ts new file mode 100644 index 000000000..7ee9f4d9d --- /dev/null +++ b/packages/nestjs-cache/src/__tests__/fixtures/app.module.fixture.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { CacheModule } from '../../cache.module.js'; + +import { UserCacheEntityFixture } from './entities/user-cache-entity.fixture.js'; +import { UserEntityFixture } from './entities/user-entity.fixture.js'; + +@Module({ + imports: [ + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [UserEntityFixture, UserCacheEntityFixture], + }), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [{ key: 'userCache', entity: UserCacheEntityFixture }], + }), + CacheModule.forRoot({}), + CacheModule.forFeature(['userCache']), + ], + controllers: [], + providers: [], +}) +export class AppModuleFixture {} diff --git a/packages/nestjs-cache/src/__tests__/fixtures/cache.seeder.fixture.ts b/packages/nestjs-cache/src/__tests__/fixtures/cache.seeder.fixture.ts new file mode 100644 index 000000000..20ed859d9 --- /dev/null +++ b/packages/nestjs-cache/src/__tests__/fixtures/cache.seeder.fixture.ts @@ -0,0 +1,30 @@ +import { Seeder } from '@concepta/typeorm-seeding'; + +import { CacheFactory } from '../../infrastructure/persistence/cache.factory.js'; + +import { UserFactoryFixture } from './factories/user.factory.fixture.js'; + +/** + * Cache seeder + */ +export class CacheSeederFixture extends Seeder { + /** + * Runner + */ + public async run(): Promise { + // number of caches to create + const createAmount = process.env?.CACHE_MODULE_SEEDER_AMOUNT + ? Number(process.env.CACHE_MODULE_SEEDER_AMOUNT) + : 50; + + // the factory + const cacheFactory = this.factory(CacheFactory); + const userFactory = this.factory(UserFactoryFixture); + const user = await userFactory.create(); + + // create a bunch + await cacheFactory.createMany(createAmount, { + assigneeId: user.id, + }); + } +} diff --git a/packages/nestjs-cache/src/__tests__/fixtures/entities/user-cache-entity.fixture.ts b/packages/nestjs-cache/src/__tests__/fixtures/entities/user-cache-entity.fixture.ts new file mode 100644 index 000000000..963bc8df9 --- /dev/null +++ b/packages/nestjs-cache/src/__tests__/fixtures/entities/user-cache-entity.fixture.ts @@ -0,0 +1,10 @@ +import { Entity, Unique } from 'typeorm'; + +import { CacheSqliteEntity } from '../../../infrastructure/persistence/typeorm/cache-sqlite.entity.js'; + +/** + * Cache Entity Fixture + */ +@Entity() +@Unique(['key', 'type', 'assigneeId']) +export class UserCacheEntityFixture extends CacheSqliteEntity {} diff --git a/packages/nestjs-cache/src/__tests__/fixtures/entities/user-entity.fixture.ts b/packages/nestjs-cache/src/__tests__/fixtures/entities/user-entity.fixture.ts new file mode 100644 index 000000000..a010efc68 --- /dev/null +++ b/packages/nestjs-cache/src/__tests__/fixtures/entities/user-entity.fixture.ts @@ -0,0 +1,15 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +import { ReferenceIdInterface } from '@concepta/nestjs-core'; + +/** + * User Entity Fixture + */ +@Entity() +export class UserEntityFixture implements ReferenceIdInterface { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ default: false }) + isActive!: boolean; +} diff --git a/packages/nestjs-cache/src/__fixtures__/factories/user-cache.factory.fixture.ts b/packages/nestjs-cache/src/__tests__/fixtures/factories/user-cache.factory.fixture.ts similarity index 96% rename from packages/nestjs-cache/src/__fixtures__/factories/user-cache.factory.fixture.ts rename to packages/nestjs-cache/src/__tests__/fixtures/factories/user-cache.factory.fixture.ts index d815193e7..592dd4d16 100644 --- a/packages/nestjs-cache/src/__fixtures__/factories/user-cache.factory.fixture.ts +++ b/packages/nestjs-cache/src/__tests__/fixtures/factories/user-cache.factory.fixture.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; import { Factory } from '@concepta/typeorm-seeding'; -import { UserCacheEntityFixture } from '../entities/user-cache-entity.fixture'; +import { UserCacheEntityFixture } from '../entities/user-cache-entity.fixture.js'; export class UserCacheFactoryFixture extends Factory { protected options = { diff --git a/packages/nestjs-cache/src/__tests__/fixtures/factories/user.factory.fixture.ts b/packages/nestjs-cache/src/__tests__/fixtures/factories/user.factory.fixture.ts new file mode 100644 index 000000000..e034a6a62 --- /dev/null +++ b/packages/nestjs-cache/src/__tests__/fixtures/factories/user.factory.fixture.ts @@ -0,0 +1,9 @@ +import { Factory } from '@concepta/typeorm-seeding'; + +import { UserEntityFixture } from '../entities/user-entity.fixture.js'; + +export class UserFactoryFixture extends Factory { + options = { + entity: UserEntityFixture, + }; +} diff --git a/packages/nestjs-cache/src/__tests__/helpers/mock.helpers.ts b/packages/nestjs-cache/src/__tests__/helpers/mock.helpers.ts new file mode 100644 index 000000000..311af3ef6 --- /dev/null +++ b/packages/nestjs-cache/src/__tests__/helpers/mock.helpers.ts @@ -0,0 +1,88 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { ActionEnum, AppContextHost, Operation } from '@concepta/nestjs-core'; +import { + createMockCommandBus, + createMockEventPublisher, + createTestEventContext, +} from '@concepta/nestjs-core/testing'; +import { type CrudContextInterface, CrudCtx } from '@concepta/nestjs-crud'; +import { createMockTransaction } from '@concepta/nestjs-repository/testing'; + +import { type Cache } from '../../domain/aggregates/cache.js'; +import { CacheCtx } from '../../gateways/cache-context.overlay.js'; +import { type CacheRepositoryResolver } from '../../infrastructure/persistence/cache-repository.resolver.js'; +import { CacheMapper } from '../../infrastructure/persistence/cache.mapper.js'; +import { type CacheRepository } from '../../infrastructure/persistence/cache.repository.js'; +import { type CacheEntityInterface } from '../../infrastructure/persistence/interfaces/cache-entity.interface.js'; + +export const DEFAULT_CACHE_NAMESPACE = 'UserCache'; + +export { + createMockCommandBus, + createMockEventPublisher, + createMockTransaction, +}; +export type { MockTransactionHandle } from '@concepta/nestjs-repository/testing'; + +export function createMockCacheRepository(): DeepMockProxy { + return mockDeep(); +} + +export function createMockRepositoryResolver( + repo: CacheRepository, +): DeepMockProxy { + const resolver = mockDeep(); + resolver.resolve.mockReturnValue(repo); + return resolver; +} + +export function createMockEventContext(namespace = DEFAULT_CACHE_NAMESPACE) { + return createTestEventContext({ namespace }, {}); +} + +export function createMockCacheEntity( + overrides: Partial = {}, +): CacheEntityInterface { + return { + id: 'test-id', + key: 'test-key', + type: 'test-type', + assigneeId: 'test-assignee', + data: 'test-data', + expirationDate: new Date('2027-01-01'), + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + ...overrides, + }; +} + +export function createMockCacheContext( + crudOverrides: Partial = {}, + namespace = DEFAULT_CACHE_NAMESPACE, +) { + const ctx = new AppContextHost(); + + ctx.defineOverlay(CrudCtx, { + entity: crudOverrides.entity ?? 'UserCache', + params: crudOverrides.params ?? {}, + query: crudOverrides.query ?? {}, + options: crudOverrides.options ?? {}, + operation: crudOverrides.operation ?? Operation.Read, + action: crudOverrides.action ?? ActionEnum.READ, + }); + + ctx.defineOverlay(CacheCtx, { namespace }); + + // Return the resolved CRUD child — has CRUD props (own) + // and withCache() inherited via prototype chain. + return ctx.require(CrudCtx, CacheCtx).withCrud(); +} + +const cacheMapper = new CacheMapper(); + +export function toCacheDomain(entity: CacheEntityInterface): Cache { + return cacheMapper.toDomain(entity); +} diff --git a/packages/nestjs-cache/src/__tests__/index.spec.ts b/packages/nestjs-cache/src/__tests__/index.spec.ts new file mode 100644 index 000000000..73c48a220 --- /dev/null +++ b/packages/nestjs-cache/src/__tests__/index.spec.ts @@ -0,0 +1,78 @@ +import { + CacheModule, + Cache, + CacheRepository, + CacheRepositoryResolver, + cacheCreateSchema, + UpsertCacheCommand, + ClearCachesByAssigneeCommand, + CreateCacheCommand, + UpdateCacheCommand, + RemoveCacheCommand, + ReplaceCacheCommand, + ArchiveCacheCommand, + GetCacheQuery, + FindOneCacheQuery, + FindCachesByAssigneeQuery, + UpsertCacheHandler, + ClearCachesByAssigneeHandler, + CreateCacheHandler, + UpdateCacheHandler, + RemoveCacheHandler, + ReplaceCacheHandler, + ArchiveCacheHandler, + GetCacheHandler, + FindOneCacheHandler, + FindCachesByAssigneeHandler, +} from '../index.js'; + +describe('index', () => { + it('should export CacheModule', () => { + expect(CacheModule).toBeInstanceOf(Function); + }); + + it('should export Cache domain object', () => { + expect(Cache).toBeInstanceOf(Function); + }); + + it('should export CacheRepository', () => { + expect(CacheRepository).toBeInstanceOf(Function); + }); + + it('should export CacheRepositoryResolver', () => { + expect(CacheRepositoryResolver).toBeInstanceOf(Function); + }); + + it('should export cacheCreateSchema', () => { + expect(cacheCreateSchema.meta).toBeInstanceOf(Function); + }); + + it('should export domain commands', () => { + expect(UpsertCacheCommand).toBeInstanceOf(Function); + expect(ClearCachesByAssigneeCommand).toBeInstanceOf(Function); + expect(CreateCacheCommand).toBeInstanceOf(Function); + expect(UpdateCacheCommand).toBeInstanceOf(Function); + expect(RemoveCacheCommand).toBeInstanceOf(Function); + expect(ReplaceCacheCommand).toBeInstanceOf(Function); + expect(ArchiveCacheCommand).toBeInstanceOf(Function); + }); + + it('should export domain queries', () => { + expect(GetCacheQuery).toBeInstanceOf(Function); + expect(FindOneCacheQuery).toBeInstanceOf(Function); + expect(FindCachesByAssigneeQuery).toBeInstanceOf(Function); + }); + + it('should export domain handlers', () => { + expect(UpsertCacheHandler).toBeInstanceOf(Function); + expect(ClearCachesByAssigneeHandler).toBeInstanceOf(Function); + expect(CreateCacheHandler).toBeInstanceOf(Function); + expect(UpdateCacheHandler).toBeInstanceOf(Function); + expect(RemoveCacheHandler).toBeInstanceOf(Function); + expect(ReplaceCacheHandler).toBeInstanceOf(Function); + expect(ArchiveCacheHandler).toBeInstanceOf(Function); + expect(GetCacheHandler).toBeInstanceOf(Function); + expect(FindOneCacheHandler).toBeInstanceOf(Function); + expect(FindCachesByAssigneeHandler).toBeInstanceOf(Function); + }); +}); diff --git a/packages/nestjs-cache/src/__tests__/optional-crud.spec.ts b/packages/nestjs-cache/src/__tests__/optional-crud.spec.ts new file mode 100644 index 000000000..74b9c1487 --- /dev/null +++ b/packages/nestjs-cache/src/__tests__/optional-crud.spec.ts @@ -0,0 +1,39 @@ +import { + cachePaginatedSchema, + CreateCacheRequest, + UpdateCacheRequest, + DeleteCacheRequest, + ReplaceCacheRequest, + ListCachesRequest, + ReadCacheRequest, + CreateCacheRequestHandler, + UpdateCacheRequestHandler, + DeleteCacheRequestHandler, + ReplaceCacheRequestHandler, + ListCachesRequestHandler, + ReadCacheRequestHandler, +} from '../optional-crud.js'; + +describe('optional-crud', () => { + it('should export cachePaginatedSchema', () => { + expect(cachePaginatedSchema.meta).toBeInstanceOf(Function); + }); + + it('should export requests', () => { + expect(CreateCacheRequest).toBeInstanceOf(Function); + expect(UpdateCacheRequest).toBeInstanceOf(Function); + expect(DeleteCacheRequest).toBeInstanceOf(Function); + expect(ReplaceCacheRequest).toBeInstanceOf(Function); + expect(ListCachesRequest).toBeInstanceOf(Function); + expect(ReadCacheRequest).toBeInstanceOf(Function); + }); + + it('should export request handlers', () => { + expect(CreateCacheRequestHandler).toBeInstanceOf(Function); + expect(UpdateCacheRequestHandler).toBeInstanceOf(Function); + expect(DeleteCacheRequestHandler).toBeInstanceOf(Function); + expect(ReplaceCacheRequestHandler).toBeInstanceOf(Function); + expect(ListCachesRequestHandler).toBeInstanceOf(Function); + expect(ReadCacheRequestHandler).toBeInstanceOf(Function); + }); +}); diff --git a/packages/nestjs-cache/src/application/commands/handlers/__tests__/archive-cache.handler.spec.ts b/packages/nestjs-cache/src/application/commands/handlers/__tests__/archive-cache.handler.spec.ts new file mode 100644 index 000000000..5c1b137a6 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/__tests__/archive-cache.handler.spec.ts @@ -0,0 +1,48 @@ +import { + createMockCacheRepository, + createMockRepositoryResolver, + createMockTransaction, + createMockCacheEntity, + toCacheDomain, + DEFAULT_CACHE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Cache } from '../../../../domain/aggregates/cache.js'; +import { ArchiveCacheCommand } from '../../impl/archive-cache.command.js'; +import { ArchiveCacheHandler } from '../archive-cache.handler.js'; + +describe(ArchiveCacheHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: ArchiveCacheHandler; + + beforeEach(() => { + mockRepo = createMockCacheRepository(); + const { transaction } = createMockTransaction(); + + handler = new ArchiveCacheHandler( + createMockRepositoryResolver(mockRepo), + transaction as never, + ); + }); + + it('should return the archived Cache', async () => { + mockRepo.get.mockResolvedValue(toCacheDomain(createMockCacheEntity())); + + const result = await handler.execute( + new ArchiveCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, 'test-id'), + ); + + expect(result).toBeInstanceOf(Cache); + expect(result.id).toBe('test-id'); + }); + + it('should call softRemove on the repository', async () => { + mockRepo.get.mockResolvedValue(toCacheDomain(createMockCacheEntity())); + + await handler.execute( + new ArchiveCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, 'test-id'), + ); + + expect(mockRepo.softRemove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nestjs-cache/src/application/commands/handlers/__tests__/clear-caches-by-assignee.handler.spec.ts b/packages/nestjs-cache/src/application/commands/handlers/__tests__/clear-caches-by-assignee.handler.spec.ts new file mode 100644 index 000000000..3d71bcf99 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/__tests__/clear-caches-by-assignee.handler.spec.ts @@ -0,0 +1,39 @@ +import { + createMockCacheRepository, + createMockRepositoryResolver, + createMockTransaction, + DEFAULT_CACHE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { ClearCachesByAssigneeCommand } from '../../impl/clear-caches-by-assignee.command.js'; +import { ClearCachesByAssigneeHandler } from '../clear-caches-by-assignee.handler.js'; + +describe(ClearCachesByAssigneeHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: ClearCachesByAssigneeHandler; + + beforeEach(() => { + mockRepo = createMockCacheRepository(); + const { transaction } = createMockTransaction(); + + handler = new ClearCachesByAssigneeHandler( + createMockRepositoryResolver(mockRepo), + transaction as never, + ); + }); + + it('should call removeAllByAssignee with the assignee id', async () => { + await handler.execute( + new ClearCachesByAssigneeCommand( + ctx, + DEFAULT_CACHE_NAMESPACE, + 'test-assignee', + ), + ); + + expect(mockRepo.removeAllByAssignee).toHaveBeenCalledWith( + expect.anything(), + 'test-assignee', + ); + }); +}); diff --git a/packages/nestjs-cache/src/application/commands/handlers/__tests__/create-cache.handler.spec.ts b/packages/nestjs-cache/src/application/commands/handlers/__tests__/create-cache.handler.spec.ts new file mode 100644 index 000000000..131db95ec --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/__tests__/create-cache.handler.spec.ts @@ -0,0 +1,82 @@ +import { + createMockCacheRepository, + createMockRepositoryResolver, + createMockTransaction, + createMockEventPublisher, + DEFAULT_CACHE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Cache } from '../../../../domain/aggregates/cache.js'; +import { CacheExpirationPolicy } from '../../../../domain/policies/cache-expiration.policy.js'; +import { CreateCacheCommand } from '../../impl/create-cache.command.js'; +import { CreateCacheHandler } from '../create-cache.handler.js'; + +describe(CreateCacheHandler.name, () => { + const ctx = {}; + const policy = new CacheExpirationPolicy({ expiresIn: '1h' }); + let mockRepo: ReturnType; + let handler: CreateCacheHandler; + let trxHandle: ReturnType['trxHandle']; + + beforeEach(() => { + mockRepo = createMockCacheRepository(); + const { transaction, trxHandle: trx } = createMockTransaction(); + trxHandle = trx; + + handler = new CreateCacheHandler( + createMockRepositoryResolver(mockRepo), + transaction as never, + createMockEventPublisher() as never, + policy, + ); + }); + + it('should return a Cache instance', async () => { + const dto = { + key: 'test-key', + type: 'test-type', + data: 'test-data', + assigneeId: 'test-assignee', + expiresIn: '1h', + }; + + const result = await handler.execute( + new CreateCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, dto), + ); + + expect(result).toBeInstanceOf(Cache); + expect(result.key).toBe('test-key'); + }); + + it('should persist the cache via save', async () => { + const dto = { + key: 'k', + type: 't', + data: 'd', + assigneeId: 'a', + expiresIn: '1h', + }; + + await handler.execute( + new CreateCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, dto), + ); + + expect(mockRepo.save).toHaveBeenCalledTimes(1); + }); + + it('should register onCommit and onRollback', async () => { + const dto = { + key: 'k', + type: 't', + data: 'd', + assigneeId: 'a', + expiresIn: null, + }; + + await handler.execute( + new CreateCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, dto), + ); + + expect(trxHandle.onCommit).toHaveBeenCalledTimes(1); + expect(trxHandle.onRollback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nestjs-cache/src/application/commands/handlers/__tests__/remove-cache.handler.spec.ts b/packages/nestjs-cache/src/application/commands/handlers/__tests__/remove-cache.handler.spec.ts new file mode 100644 index 000000000..b2b9595aa --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/__tests__/remove-cache.handler.spec.ts @@ -0,0 +1,48 @@ +import { + createMockCacheRepository, + createMockRepositoryResolver, + createMockTransaction, + createMockCacheEntity, + toCacheDomain, + DEFAULT_CACHE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Cache } from '../../../../domain/aggregates/cache.js'; +import { RemoveCacheCommand } from '../../impl/remove-cache.command.js'; +import { RemoveCacheHandler } from '../remove-cache.handler.js'; + +describe(RemoveCacheHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: RemoveCacheHandler; + + beforeEach(() => { + mockRepo = createMockCacheRepository(); + const { transaction } = createMockTransaction(); + + handler = new RemoveCacheHandler( + createMockRepositoryResolver(mockRepo), + transaction as never, + ); + }); + + it('should return the removed Cache', async () => { + mockRepo.get.mockResolvedValue(toCacheDomain(createMockCacheEntity())); + + const result = await handler.execute( + new RemoveCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, 'test-id'), + ); + + expect(result).toBeInstanceOf(Cache); + expect(result.id).toBe('test-id'); + }); + + it('should call remove on the repository', async () => { + mockRepo.get.mockResolvedValue(toCacheDomain(createMockCacheEntity())); + + await handler.execute( + new RemoveCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, 'test-id'), + ); + + expect(mockRepo.remove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nestjs-cache/src/application/commands/handlers/__tests__/replace-cache.handler.spec.ts b/packages/nestjs-cache/src/application/commands/handlers/__tests__/replace-cache.handler.spec.ts new file mode 100644 index 000000000..cf8661bbb --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/__tests__/replace-cache.handler.spec.ts @@ -0,0 +1,72 @@ +import { + createMockCacheRepository, + createMockRepositoryResolver, + createMockTransaction, + createMockEventPublisher, + createMockCacheEntity, + toCacheDomain, + DEFAULT_CACHE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Cache } from '../../../../domain/aggregates/cache.js'; +import { CacheExpirationPolicy } from '../../../../domain/policies/cache-expiration.policy.js'; +import { ReplaceCacheCommand } from '../../impl/replace-cache.command.js'; +import { ReplaceCacheHandler } from '../replace-cache.handler.js'; + +describe(ReplaceCacheHandler.name, () => { + const ctx = {}; + const policy = new CacheExpirationPolicy({ expiresIn: '1h' }); + let mockRepo: ReturnType; + let handler: ReplaceCacheHandler; + + beforeEach(() => { + mockRepo = createMockCacheRepository(); + const { transaction } = createMockTransaction(); + + handler = new ReplaceCacheHandler( + createMockRepositoryResolver(mockRepo), + transaction as never, + createMockEventPublisher() as never, + policy, + ); + }); + + const dto = { + key: 'test-key', + type: 'test-type', + data: 'replaced-data', + assigneeId: 'test-assignee', + expiresIn: '1h', + }; + + it('should replace an existing cache', async () => { + mockRepo.get.mockResolvedValue(toCacheDomain(createMockCacheEntity())); + + const result = await handler.execute( + new ReplaceCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, 'test-id', dto), + ); + + expect(result).toBeInstanceOf(Cache); + expect(result.data).toBe('replaced-data'); + }); + + it('should create a new cache when not found', async () => { + mockRepo.get.mockResolvedValue(null); + + const result = await handler.execute( + new ReplaceCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, 'new-id', dto), + ); + + expect(result).toBeInstanceOf(Cache); + expect(result.id).toBe('new-id'); + }); + + it('should save in both paths', async () => { + mockRepo.get.mockResolvedValue(null); + + await handler.execute( + new ReplaceCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, 'new-id', dto), + ); + + expect(mockRepo.save).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nestjs-cache/src/application/commands/handlers/__tests__/update-cache.handler.spec.ts b/packages/nestjs-cache/src/application/commands/handlers/__tests__/update-cache.handler.spec.ts new file mode 100644 index 000000000..7175884b5 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/__tests__/update-cache.handler.spec.ts @@ -0,0 +1,69 @@ +import { + createMockCacheRepository, + createMockRepositoryResolver, + createMockTransaction, + createMockEventPublisher, + createMockCacheEntity, + toCacheDomain, + DEFAULT_CACHE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Cache } from '../../../../domain/aggregates/cache.js'; +import { CacheExpirationPolicy } from '../../../../domain/policies/cache-expiration.policy.js'; +import { UpdateCacheCommand } from '../../impl/update-cache.command.js'; +import { UpdateCacheHandler } from '../update-cache.handler.js'; + +describe(UpdateCacheHandler.name, () => { + const ctx = {}; + const policy = new CacheExpirationPolicy({ expiresIn: '1h' }); + let mockRepo: ReturnType; + let handler: UpdateCacheHandler; + + beforeEach(() => { + mockRepo = createMockCacheRepository(); + const { transaction } = createMockTransaction(); + + handler = new UpdateCacheHandler( + createMockRepositoryResolver(mockRepo), + transaction as never, + createMockEventPublisher() as never, + policy, + ); + }); + + it('should return the updated Cache', async () => { + mockRepo.get.mockResolvedValue(toCacheDomain(createMockCacheEntity())); + + const dto = { + key: 'test-key', + type: 'test-type', + data: 'new-data', + assigneeId: 'test-assignee', + expiresIn: '2h', + }; + + const result = await handler.execute( + new UpdateCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, 'test-id', dto), + ); + + expect(result).toBeInstanceOf(Cache); + expect(result.data).toBe('new-data'); + }); + + it('should save the cache', async () => { + mockRepo.get.mockResolvedValue(toCacheDomain(createMockCacheEntity())); + + const dto = { + key: 'test-key', + type: 'test-type', + data: 'new-data', + assigneeId: 'test-assignee', + expiresIn: null, + }; + + await handler.execute( + new UpdateCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, 'test-id', dto), + ); + + expect(mockRepo.save).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nestjs-cache/src/application/commands/handlers/__tests__/upsert-cache.handler.spec.ts b/packages/nestjs-cache/src/application/commands/handlers/__tests__/upsert-cache.handler.spec.ts new file mode 100644 index 000000000..4d074c606 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/__tests__/upsert-cache.handler.spec.ts @@ -0,0 +1,72 @@ +import { + createMockCacheRepository, + createMockRepositoryResolver, + createMockTransaction, + createMockEventPublisher, + createMockCacheEntity, + toCacheDomain, + DEFAULT_CACHE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Cache } from '../../../../domain/aggregates/cache.js'; +import { CacheExpirationPolicy } from '../../../../domain/policies/cache-expiration.policy.js'; +import { UpsertCacheCommand } from '../../impl/upsert-cache.command.js'; +import { UpsertCacheHandler } from '../upsert-cache.handler.js'; + +describe(UpsertCacheHandler.name, () => { + const ctx = {}; + const policy = new CacheExpirationPolicy({ expiresIn: '1h' }); + let mockRepo: ReturnType; + let handler: UpsertCacheHandler; + + beforeEach(() => { + mockRepo = createMockCacheRepository(); + const { transaction } = createMockTransaction(); + + handler = new UpsertCacheHandler( + createMockRepositoryResolver(mockRepo), + transaction as never, + createMockEventPublisher() as never, + policy, + ); + }); + + const dto = { + key: 'test-key', + type: 'test-type', + data: 'upsert-data', + assigneeId: 'test-assignee', + expiresIn: '2h', + }; + + it('should update existing cache when found', async () => { + mockRepo.findOne.mockResolvedValue(toCacheDomain(createMockCacheEntity())); + + const result = await handler.execute( + new UpsertCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, dto), + ); + + expect(result).toBeInstanceOf(Cache); + expect(result.data).toBe('upsert-data'); + }); + + it('should create a new cache when not found', async () => { + mockRepo.findOne.mockResolvedValue(null); + + const result = await handler.execute( + new UpsertCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, dto), + ); + + expect(result).toBeInstanceOf(Cache); + expect(result.key).toBe('test-key'); + }); + + it('should save in both paths', async () => { + mockRepo.findOne.mockResolvedValue(null); + + await handler.execute( + new UpsertCacheCommand(ctx, DEFAULT_CACHE_NAMESPACE, dto), + ); + + expect(mockRepo.save).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nestjs-cache/src/application/commands/handlers/archive-cache.handler.ts b/packages/nestjs-cache/src/application/commands/handlers/archive-cache.handler.ts new file mode 100644 index 000000000..1ed2b071e --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/archive-cache.handler.ts @@ -0,0 +1,35 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { CACHE_REPOSITORY_RESOLVER_TOKEN } from '../../../cache.constants.js'; +import { Cache } from '../../../domain/aggregates/cache.js'; +import { CacheRepositoryResolverInterface } from '../../../domain/repositories/cache-repository-resolver.interface.js'; +import { CacheNotFoundException } from '../../exceptions/cache-not-found.exception.js'; +import { ArchiveCacheCommand } from '../impl/archive-cache.command.js'; + +@CommandHandler(ArchiveCacheCommand) +export class ArchiveCacheHandler implements ICommandHandler { + constructor( + @Inject(CACHE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: CacheRepositoryResolverInterface, + private readonly txScope: TransactionScope, + ) {} + + async execute(command: ArchiveCacheCommand): Promise { + const { ctx, namespace, id } = command; + const cacheRepo = this.repositoryResolver.resolve(namespace); + + return this.txScope.run(ctx, async (txCtx) => { + const cache = await cacheRepo.get(txCtx, id); + + if (!cache) { + throw new CacheNotFoundException(id); + } + + await cacheRepo.softRemove(txCtx, cache); + return cache; + }); + } +} diff --git a/packages/nestjs-cache/src/application/commands/handlers/clear-caches-by-assignee.handler.ts b/packages/nestjs-cache/src/application/commands/handlers/clear-caches-by-assignee.handler.ts new file mode 100644 index 000000000..4a3c9f0c1 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/clear-caches-by-assignee.handler.ts @@ -0,0 +1,26 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { CACHE_REPOSITORY_RESOLVER_TOKEN } from '../../../cache.constants.js'; +import { CacheRepositoryResolverInterface } from '../../../domain/repositories/cache-repository-resolver.interface.js'; +import { ClearCachesByAssigneeCommand } from '../impl/clear-caches-by-assignee.command.js'; + +@CommandHandler(ClearCachesByAssigneeCommand) +export class ClearCachesByAssigneeHandler implements ICommandHandler { + constructor( + @Inject(CACHE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: CacheRepositoryResolverInterface, + private readonly txScope: TransactionScope, + ) {} + + async execute(command: ClearCachesByAssigneeCommand): Promise { + const { ctx, namespace, assigneeId } = command; + const cacheRepo = this.repositoryResolver.resolve(namespace); + + return this.txScope.run(ctx, async (txCtx) => { + await cacheRepo.removeAllByAssignee(txCtx, assigneeId); + }); + } +} diff --git a/packages/nestjs-cache/src/application/commands/handlers/create-cache.handler.ts b/packages/nestjs-cache/src/application/commands/handlers/create-cache.handler.ts new file mode 100644 index 000000000..93bda1f09 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/create-cache.handler.ts @@ -0,0 +1,46 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { CACHE_REPOSITORY_RESOLVER_TOKEN } from '../../../cache.constants.js'; +import { Cache } from '../../../domain/aggregates/cache.js'; +import { CacheExpirationPolicy } from '../../../domain/policies/cache-expiration.policy.js'; +import { CacheRepositoryResolverInterface } from '../../../domain/repositories/cache-repository-resolver.interface.js'; +import { CreateCacheCommand } from '../impl/create-cache.command.js'; + +@CommandHandler(CreateCacheCommand) +export class CreateCacheHandler implements ICommandHandler { + constructor( + @Inject(CACHE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: CacheRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + private readonly expirationPolicy: CacheExpirationPolicy, + ) {} + + async execute(command: CreateCacheCommand): Promise { + const { ctx, namespace, dto } = command; + const cacheRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const expirationDate = this.expirationPolicy.resolveExpirationDate( + dto.expiresIn, + ); + + const cache = this.eventPublisher.mergeObjectContext( + Cache.create(eventContext, dto, expirationDate), + ); + + await cacheRepo.save(txCtx, cache); + + txCtx.trx.onCommit(() => cache.commit()); + txCtx.trx.onRollback(() => cache.uncommit()); + + return cache; + }); + } +} diff --git a/packages/nestjs-cache/src/application/commands/handlers/remove-cache.handler.ts b/packages/nestjs-cache/src/application/commands/handlers/remove-cache.handler.ts new file mode 100644 index 000000000..448f94974 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/remove-cache.handler.ts @@ -0,0 +1,35 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { CACHE_REPOSITORY_RESOLVER_TOKEN } from '../../../cache.constants.js'; +import { Cache } from '../../../domain/aggregates/cache.js'; +import { CacheRepositoryResolverInterface } from '../../../domain/repositories/cache-repository-resolver.interface.js'; +import { CacheNotFoundException } from '../../exceptions/cache-not-found.exception.js'; +import { RemoveCacheCommand } from '../impl/remove-cache.command.js'; + +@CommandHandler(RemoveCacheCommand) +export class RemoveCacheHandler implements ICommandHandler { + constructor( + @Inject(CACHE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: CacheRepositoryResolverInterface, + private readonly txScope: TransactionScope, + ) {} + + async execute(command: RemoveCacheCommand): Promise { + const { ctx, namespace, id } = command; + const cacheRepo = this.repositoryResolver.resolve(namespace); + + return this.txScope.run(ctx, async (txCtx) => { + const cache = await cacheRepo.get(txCtx, id); + + if (!cache) { + throw new CacheNotFoundException(id); + } + + await cacheRepo.remove(txCtx, cache); + return cache; + }); + } +} diff --git a/packages/nestjs-cache/src/application/commands/handlers/replace-cache.handler.ts b/packages/nestjs-cache/src/application/commands/handlers/replace-cache.handler.ts new file mode 100644 index 000000000..7bbee0c5c --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/replace-cache.handler.ts @@ -0,0 +1,55 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { CACHE_REPOSITORY_RESOLVER_TOKEN } from '../../../cache.constants.js'; +import { Cache } from '../../../domain/aggregates/cache.js'; +import { CacheExpirationPolicy } from '../../../domain/policies/cache-expiration.policy.js'; +import { CacheRepositoryResolverInterface } from '../../../domain/repositories/cache-repository-resolver.interface.js'; +import { ReplaceCacheCommand } from '../impl/replace-cache.command.js'; + +@CommandHandler(ReplaceCacheCommand) +export class ReplaceCacheHandler implements ICommandHandler { + constructor( + @Inject(CACHE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: CacheRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + private readonly expirationPolicy: CacheExpirationPolicy, + ) {} + + async execute(command: ReplaceCacheCommand): Promise { + const { ctx, namespace, id, dto } = command; + const cacheRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const expirationDate = this.expirationPolicy.resolveExpirationDate( + dto.expiresIn, + ); + + let cache: Cache; + + const existing = await cacheRepo.get(txCtx, id); + + if (existing) { + cache = this.eventPublisher.mergeObjectContext(existing); + cache.replace(eventContext, dto, expirationDate); + } else { + cache = this.eventPublisher.mergeObjectContext( + Cache.createWithId(eventContext, id, dto, expirationDate), + ); + } + + await cacheRepo.save(txCtx, cache); + + txCtx.trx.onCommit(() => cache.commit()); + txCtx.trx.onRollback(() => cache.uncommit()); + + return cache; + }); + } +} diff --git a/packages/nestjs-cache/src/application/commands/handlers/update-cache.handler.ts b/packages/nestjs-cache/src/application/commands/handlers/update-cache.handler.ts new file mode 100644 index 000000000..41b7b0f4e --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/update-cache.handler.ts @@ -0,0 +1,60 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { CACHE_REPOSITORY_RESOLVER_TOKEN } from '../../../cache.constants.js'; +import { Cache } from '../../../domain/aggregates/cache.js'; +import { CacheExpirationPolicy } from '../../../domain/policies/cache-expiration.policy.js'; +import { CacheRepositoryResolverInterface } from '../../../domain/repositories/cache-repository-resolver.interface.js'; +import { CacheNotFoundException } from '../../exceptions/cache-not-found.exception.js'; +import { UpdateCacheCommand } from '../impl/update-cache.command.js'; + +@CommandHandler(UpdateCacheCommand) +export class UpdateCacheHandler implements ICommandHandler { + constructor( + @Inject(CACHE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: CacheRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + private readonly expirationPolicy: CacheExpirationPolicy, + ) {} + + async execute(command: UpdateCacheCommand): Promise { + const { ctx, namespace, id, dto } = command; + const { data, expiresIn } = dto; + const cacheRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const existing = await cacheRepo.get(txCtx, id); + + if (!existing) { + throw new CacheNotFoundException(id); + } + + const cache = this.eventPublisher.mergeObjectContext(existing); + + // omitted `data` means "leave unchanged" (partial update) — matching + // the existing conditional pattern for `expiresIn` below. + if (data !== undefined) { + cache.updateData(eventContext, data); + } + + if (expiresIn) { + const expirationDate = + this.expirationPolicy.resolveExpirationDate(expiresIn); + cache.extend(eventContext, expirationDate); + } + + await cacheRepo.save(txCtx, cache); + + txCtx.trx.onCommit(() => cache.commit()); + txCtx.trx.onRollback(() => cache.uncommit()); + + return cache; + }); + } +} diff --git a/packages/nestjs-cache/src/application/commands/handlers/upsert-cache.handler.ts b/packages/nestjs-cache/src/application/commands/handlers/upsert-cache.handler.ts new file mode 100644 index 000000000..394240d13 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/handlers/upsert-cache.handler.ts @@ -0,0 +1,69 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { CACHE_REPOSITORY_RESOLVER_TOKEN } from '../../../cache.constants.js'; +import { Cache } from '../../../domain/aggregates/cache.js'; +import { CacheExpirationPolicy } from '../../../domain/policies/cache-expiration.policy.js'; +import { CacheRepositoryResolverInterface } from '../../../domain/repositories/cache-repository-resolver.interface.js'; +import { UpsertCacheCommand } from '../impl/upsert-cache.command.js'; + +@CommandHandler(UpsertCacheCommand) +export class UpsertCacheHandler implements ICommandHandler { + constructor( + @Inject(CACHE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: CacheRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + private readonly expirationPolicy: CacheExpirationPolicy, + ) {} + + async execute(command: UpsertCacheCommand): Promise { + const { ctx, namespace, dto } = command; + const { key, type, data, assigneeId, expiresIn } = dto; + const cacheRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + let cache: Cache; + + const existing = await cacheRepo.findOne(txCtx, { + key, + type, + assigneeId, + }); + + if (existing) { + cache = this.eventPublisher.mergeObjectContext(existing); + + // omitted `data` means "leave unchanged" (partial update) — + // matching the existing conditional pattern for `expiresIn` below. + if (data !== undefined) { + cache.updateData(eventContext, data); + } + + if (expiresIn) { + const expirationDate = + this.expirationPolicy.resolveExpirationDate(expiresIn); + cache.extend(eventContext, expirationDate); + } + } else { + const expirationDate = + this.expirationPolicy.resolveExpirationDate(expiresIn); + cache = this.eventPublisher.mergeObjectContext( + Cache.create(eventContext, dto, expirationDate), + ); + } + + await cacheRepo.save(txCtx, cache); + + txCtx.trx.onCommit(() => cache.commit()); + txCtx.trx.onRollback(() => cache.uncommit()); + + return cache; + }); + } +} diff --git a/packages/nestjs-cache/src/application/commands/impl/archive-cache.command.ts b/packages/nestjs-cache/src/application/commands/impl/archive-cache.command.ts new file mode 100644 index 000000000..de7108c97 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/impl/archive-cache.command.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Cache } from '../../../domain/aggregates/cache.js'; + +export class ArchiveCacheCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-cache/src/application/commands/impl/clear-caches-by-assignee.command.ts b/packages/nestjs-cache/src/application/commands/impl/clear-caches-by-assignee.command.ts new file mode 100644 index 000000000..7ba27432a --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/impl/clear-caches-by-assignee.command.ts @@ -0,0 +1,12 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +export class ClearCachesByAssigneeCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly assigneeId: string, + ) { + super(); + } +} diff --git a/packages/nestjs-cache/src/application/commands/impl/create-cache.command.ts b/packages/nestjs-cache/src/application/commands/impl/create-cache.command.ts new file mode 100644 index 000000000..b5567c685 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/impl/create-cache.command.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type Cache } from '../../../domain/aggregates/cache.js'; +import { type CacheCreatableInterface } from '../../../domain/interfaces/cache-creatable.interface.js'; + +export class CreateCacheCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly dto: CacheCreatableInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-cache/src/application/commands/impl/remove-cache.command.ts b/packages/nestjs-cache/src/application/commands/impl/remove-cache.command.ts new file mode 100644 index 000000000..4b348b654 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/impl/remove-cache.command.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Cache } from '../../../domain/aggregates/cache.js'; + +export class RemoveCacheCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-cache/src/application/commands/impl/replace-cache.command.ts b/packages/nestjs-cache/src/application/commands/impl/replace-cache.command.ts new file mode 100644 index 000000000..c3da257d9 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/impl/replace-cache.command.ts @@ -0,0 +1,18 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Cache } from '../../../domain/aggregates/cache.js'; +import { type CacheCreatableInterface } from '../../../domain/interfaces/cache-creatable.interface.js'; + +export class ReplaceCacheCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly id: ReferenceId, + public readonly dto: CacheCreatableInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-cache/src/application/commands/impl/update-cache.command.ts b/packages/nestjs-cache/src/application/commands/impl/update-cache.command.ts new file mode 100644 index 000000000..6a3464e71 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/impl/update-cache.command.ts @@ -0,0 +1,18 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Cache } from '../../../domain/aggregates/cache.js'; +import { type CacheUpdatableInterface } from '../../../domain/interfaces/cache-updatable.interface.js'; + +export class UpdateCacheCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly id: ReferenceId, + public readonly dto: CacheUpdatableInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-cache/src/application/commands/impl/upsert-cache.command.ts b/packages/nestjs-cache/src/application/commands/impl/upsert-cache.command.ts new file mode 100644 index 000000000..d8690bbc7 --- /dev/null +++ b/packages/nestjs-cache/src/application/commands/impl/upsert-cache.command.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type Cache } from '../../../domain/aggregates/cache.js'; +import { type CacheCreatableInterface } from '../../../domain/interfaces/cache-creatable.interface.js'; + +export class UpsertCacheCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly dto: CacheCreatableInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-cache/src/application/exceptions/__tests__/cache-not-found.exception.spec.ts b/packages/nestjs-cache/src/application/exceptions/__tests__/cache-not-found.exception.spec.ts new file mode 100644 index 000000000..5199d54e7 --- /dev/null +++ b/packages/nestjs-cache/src/application/exceptions/__tests__/cache-not-found.exception.spec.ts @@ -0,0 +1,36 @@ +import { HttpStatus } from '@nestjs/common'; + +import { CacheException } from '../../../domain/exceptions/cache.exception.js'; +import { CacheNotFoundException } from '../cache-not-found.exception.js'; + +describe(CacheNotFoundException.name, () => { + it('should be an instance of CacheException', () => { + const exception = new CacheNotFoundException('abc'); + expect(exception).toBeInstanceOf(CacheException); + }); + + it('should interpolate id into message', () => { + const exception = new CacheNotFoundException('abc'); + expect(exception.message).toBe('Cache not found for id=abc'); + }); + + it('should have httpStatus NOT_FOUND', () => { + const exception = new CacheNotFoundException('abc'); + expect(exception.httpStatus).toBe(HttpStatus.NOT_FOUND); + }); + + it('should have errorCode CACHE_NOT_FOUND_ERROR', () => { + const exception = new CacheNotFoundException('abc'); + expect(exception.errorCode).toBe('CACHE_NOT_FOUND_ERROR'); + }); + + it('should include id in context', () => { + const exception = new CacheNotFoundException('abc'); + expect(exception.context).toEqual(expect.objectContaining({ id: 'abc' })); + }); + + it('should accept a custom message', () => { + const exception = new CacheNotFoundException('abc', 'Custom %s'); + expect(exception.message).toBe('Custom abc'); + }); +}); diff --git a/packages/nestjs-cache/src/application/exceptions/cache-not-found.exception.ts b/packages/nestjs-cache/src/application/exceptions/cache-not-found.exception.ts new file mode 100644 index 000000000..e46ef5f95 --- /dev/null +++ b/packages/nestjs-cache/src/application/exceptions/cache-not-found.exception.ts @@ -0,0 +1,27 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeException } from '@concepta/nestjs-core'; + +import { CacheException } from '../../domain/exceptions/cache.exception.js'; + +export class CacheNotFoundException extends CacheException { + declare context: RuntimeException['context'] & { + id: string; + }; + + constructor(id: string, message = 'Cache not found for id=%s') { + super({ + httpStatus: HttpStatus.NOT_FOUND, + message, + messageParams: [id], + fault: 'client', + }); + + this.errorCode = 'CACHE_NOT_FOUND_ERROR'; + + this.context = { + ...this.context, + id, + }; + } +} diff --git a/packages/nestjs-cache/src/application/queries/handlers/__tests__/find-caches-by-assignee.handler.spec.ts b/packages/nestjs-cache/src/application/queries/handlers/__tests__/find-caches-by-assignee.handler.spec.ts new file mode 100644 index 000000000..b7134ea79 --- /dev/null +++ b/packages/nestjs-cache/src/application/queries/handlers/__tests__/find-caches-by-assignee.handler.spec.ts @@ -0,0 +1,50 @@ +import { + createMockCacheRepository, + createMockRepositoryResolver, + createMockCacheEntity, + toCacheDomain, + DEFAULT_CACHE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { FindCachesByAssigneeQuery } from '../../impl/find-caches-by-assignee.query.js'; +import { FindCachesByAssigneeHandler } from '../find-caches-by-assignee.handler.js'; + +describe(FindCachesByAssigneeHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: FindCachesByAssigneeHandler; + + beforeEach(() => { + mockRepo = createMockCacheRepository(); + handler = new FindCachesByAssigneeHandler( + createMockRepositoryResolver(mockRepo), + ); + }); + + it('should return an array of caches', async () => { + const entity = createMockCacheEntity(); + mockRepo.findAllByAssignee.mockResolvedValue([ + toCacheDomain(entity), + toCacheDomain({ ...entity, id: 'id-2' }), + ]); + + const result = await handler.execute( + new FindCachesByAssigneeQuery( + ctx, + DEFAULT_CACHE_NAMESPACE, + 'test-assignee', + ), + ); + + expect(result).toHaveLength(2); + }); + + it('should return empty array when no matches', async () => { + mockRepo.findAllByAssignee.mockResolvedValue([]); + + const result = await handler.execute( + new FindCachesByAssigneeQuery(ctx, DEFAULT_CACHE_NAMESPACE, 'no-match'), + ); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/nestjs-cache/src/application/queries/handlers/__tests__/find-one-cache.handler.spec.ts b/packages/nestjs-cache/src/application/queries/handlers/__tests__/find-one-cache.handler.spec.ts new file mode 100644 index 000000000..7c97dc183 --- /dev/null +++ b/packages/nestjs-cache/src/application/queries/handlers/__tests__/find-one-cache.handler.spec.ts @@ -0,0 +1,53 @@ +import { + createMockCacheRepository, + createMockRepositoryResolver, + createMockCacheEntity, + toCacheDomain, + DEFAULT_CACHE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { FindOneCacheQuery } from '../../impl/find-one-cache.query.js'; +import { FindOneCacheHandler } from '../find-one-cache.handler.js'; + +describe(FindOneCacheHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: FindOneCacheHandler; + + beforeEach(() => { + mockRepo = createMockCacheRepository(); + handler = new FindOneCacheHandler(createMockRepositoryResolver(mockRepo)); + }); + + it('should return a Cache when found', async () => { + mockRepo.findOne.mockResolvedValue(toCacheDomain(createMockCacheEntity())); + + const result = await handler.execute( + new FindOneCacheQuery( + ctx, + DEFAULT_CACHE_NAMESPACE, + 'key', + 'type', + 'assignee', + ), + ); + + expect(result).not.toBeNull(); + expect(result!.id).toBe('test-id'); + }); + + it('should return null when not found', async () => { + mockRepo.findOne.mockResolvedValue(null); + + const result = await handler.execute( + new FindOneCacheQuery( + ctx, + DEFAULT_CACHE_NAMESPACE, + 'key', + 'type', + 'assignee', + ), + ); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/nestjs-cache/src/application/queries/handlers/__tests__/get-cache.handler.spec.ts b/packages/nestjs-cache/src/application/queries/handlers/__tests__/get-cache.handler.spec.ts new file mode 100644 index 000000000..50b6e929b --- /dev/null +++ b/packages/nestjs-cache/src/application/queries/handlers/__tests__/get-cache.handler.spec.ts @@ -0,0 +1,33 @@ +import { + createMockCacheRepository, + createMockRepositoryResolver, + createMockCacheEntity, + toCacheDomain, + DEFAULT_CACHE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Cache } from '../../../../domain/aggregates/cache.js'; +import { GetCacheQuery } from '../../impl/get-cache.query.js'; +import { GetCacheHandler } from '../get-cache.handler.js'; + +describe(GetCacheHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: GetCacheHandler; + + beforeEach(() => { + mockRepo = createMockCacheRepository(); + handler = new GetCacheHandler(createMockRepositoryResolver(mockRepo)); + }); + + it('should return a Cache for a valid id', async () => { + const entity = createMockCacheEntity(); + mockRepo.get.mockResolvedValue(toCacheDomain(entity)); + + const result = await handler.execute( + new GetCacheQuery(ctx, DEFAULT_CACHE_NAMESPACE, 'test-id'), + ); + + expect(result).toBeInstanceOf(Cache); + expect(result.id).toBe('test-id'); + }); +}); diff --git a/packages/nestjs-cache/src/application/queries/handlers/find-caches-by-assignee.handler.ts b/packages/nestjs-cache/src/application/queries/handlers/find-caches-by-assignee.handler.ts new file mode 100644 index 000000000..79d9222f7 --- /dev/null +++ b/packages/nestjs-cache/src/application/queries/handlers/find-caches-by-assignee.handler.ts @@ -0,0 +1,23 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { CACHE_REPOSITORY_RESOLVER_TOKEN } from '../../../cache.constants.js'; +import { Cache } from '../../../domain/aggregates/cache.js'; +import { CacheRepositoryResolverInterface } from '../../../domain/repositories/cache-repository-resolver.interface.js'; +import { FindCachesByAssigneeQuery } from '../impl/find-caches-by-assignee.query.js'; + +@QueryHandler(FindCachesByAssigneeQuery) +export class FindCachesByAssigneeHandler implements IQueryHandler { + constructor( + @Inject(CACHE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: CacheRepositoryResolverInterface, + ) {} + + async execute(query: FindCachesByAssigneeQuery): Promise { + const { ctx, namespace, assigneeId } = query; + + const cacheRepo = this.repositoryResolver.resolve(namespace); + + return cacheRepo.findAllByAssignee(ctx, assigneeId); + } +} diff --git a/packages/nestjs-cache/src/application/queries/handlers/find-one-cache.handler.ts b/packages/nestjs-cache/src/application/queries/handlers/find-one-cache.handler.ts new file mode 100644 index 000000000..be194a79d --- /dev/null +++ b/packages/nestjs-cache/src/application/queries/handlers/find-one-cache.handler.ts @@ -0,0 +1,23 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { CACHE_REPOSITORY_RESOLVER_TOKEN } from '../../../cache.constants.js'; +import { Cache } from '../../../domain/aggregates/cache.js'; +import { CacheRepositoryResolverInterface } from '../../../domain/repositories/cache-repository-resolver.interface.js'; +import { FindOneCacheQuery } from '../impl/find-one-cache.query.js'; + +@QueryHandler(FindOneCacheQuery) +export class FindOneCacheHandler implements IQueryHandler { + constructor( + @Inject(CACHE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: CacheRepositoryResolverInterface, + ) {} + + async execute(query: FindOneCacheQuery): Promise { + const { ctx, namespace, key, type, assigneeId } = query; + + const cacheRepo = this.repositoryResolver.resolve(namespace); + + return cacheRepo.findOne(ctx, { key, type, assigneeId }); + } +} diff --git a/packages/nestjs-cache/src/application/queries/handlers/get-cache.handler.ts b/packages/nestjs-cache/src/application/queries/handlers/get-cache.handler.ts new file mode 100644 index 000000000..0bbccd384 --- /dev/null +++ b/packages/nestjs-cache/src/application/queries/handlers/get-cache.handler.ts @@ -0,0 +1,30 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { CACHE_REPOSITORY_RESOLVER_TOKEN } from '../../../cache.constants.js'; +import { Cache } from '../../../domain/aggregates/cache.js'; +import { CacheRepositoryResolverInterface } from '../../../domain/repositories/cache-repository-resolver.interface.js'; +import { CacheNotFoundException } from '../../exceptions/cache-not-found.exception.js'; +import { GetCacheQuery } from '../impl/get-cache.query.js'; + +@QueryHandler(GetCacheQuery) +export class GetCacheHandler implements IQueryHandler { + constructor( + @Inject(CACHE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: CacheRepositoryResolverInterface, + ) {} + + async execute(query: GetCacheQuery): Promise { + const { ctx, namespace, id } = query; + + const cacheRepo = this.repositoryResolver.resolve(namespace); + + const cache = await cacheRepo.get(ctx, id); + + if (!cache) { + throw new CacheNotFoundException(id); + } + + return cache; + } +} diff --git a/packages/nestjs-cache/src/application/queries/impl/find-caches-by-assignee.query.ts b/packages/nestjs-cache/src/application/queries/impl/find-caches-by-assignee.query.ts new file mode 100644 index 000000000..eb227b3ac --- /dev/null +++ b/packages/nestjs-cache/src/application/queries/impl/find-caches-by-assignee.query.ts @@ -0,0 +1,14 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type Cache } from '../../../domain/aggregates/cache.js'; + +export class FindCachesByAssigneeQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly assigneeId: string, + ) { + super(); + } +} diff --git a/packages/nestjs-cache/src/application/queries/impl/find-one-cache.query.ts b/packages/nestjs-cache/src/application/queries/impl/find-one-cache.query.ts new file mode 100644 index 000000000..f40992811 --- /dev/null +++ b/packages/nestjs-cache/src/application/queries/impl/find-one-cache.query.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type Cache } from '../../../domain/aggregates/cache.js'; + +export class FindOneCacheQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly key: string, + public readonly type: string, + public readonly assigneeId: string, + ) { + super(); + } +} diff --git a/packages/nestjs-cache/src/application/queries/impl/get-cache.query.ts b/packages/nestjs-cache/src/application/queries/impl/get-cache.query.ts new file mode 100644 index 000000000..563b4ca99 --- /dev/null +++ b/packages/nestjs-cache/src/application/queries/impl/get-cache.query.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Cache } from '../../../domain/aggregates/cache.js'; + +export class GetCacheQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-cache/src/application/utils/__tests__/assert-cache-id.util.spec.ts b/packages/nestjs-cache/src/application/utils/__tests__/assert-cache-id.util.spec.ts new file mode 100644 index 000000000..85f527d30 --- /dev/null +++ b/packages/nestjs-cache/src/application/utils/__tests__/assert-cache-id.util.spec.ts @@ -0,0 +1,51 @@ +import { HttpStatus } from '@nestjs/common'; + +import { CacheException } from '../../../domain/exceptions/cache.exception.js'; +import { assertCacheId } from '../assert-cache-id.util.js'; + +describe('assertCacheId', () => { + it('should not throw for a valid string', () => { + expect(() => assertCacheId('abc-123')).not.toThrow(); + }); + + it('should throw CacheException for an empty string', () => { + expect(() => assertCacheId('')).toThrow(CacheException); + }); + + it('should throw CacheException for number', () => { + expect(() => assertCacheId(123)).toThrow(CacheException); + }); + + it('should throw CacheException for undefined', () => { + expect(() => assertCacheId(undefined)).toThrow(CacheException); + }); + + it('should throw CacheException for null', () => { + expect(() => assertCacheId(null)).toThrow(CacheException); + }); + + it('should throw CacheException for object', () => { + expect(() => assertCacheId({})).toThrow(CacheException); + }); + + it('should include typeof in error message', () => { + try { + assertCacheId(42); + throw new Error('Expected CacheException'); + } catch (e) { + expect(e).toBeInstanceOf(CacheException); + expect((e as CacheException).message).toContain('number'); + } + }); + + it('should throw with httpStatus BAD_REQUEST and a safe message', () => { + try { + assertCacheId(42); + throw new Error('Expected CacheException'); + } catch (e) { + expect(e).toBeInstanceOf(CacheException); + expect((e as CacheException).httpStatus).toBe(HttpStatus.BAD_REQUEST); + expect((e as CacheException).safeMessage).toBe('Invalid id'); + } + }); +}); diff --git a/packages/nestjs-cache/src/application/utils/assert-cache-id.util.ts b/packages/nestjs-cache/src/application/utils/assert-cache-id.util.ts new file mode 100644 index 000000000..91dee9ad1 --- /dev/null +++ b/packages/nestjs-cache/src/application/utils/assert-cache-id.util.ts @@ -0,0 +1,26 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { CacheException } from '../../domain/exceptions/cache.exception.js'; + +/** + * Asserts that `value` is a non-empty string id. + * + * Classified `fault: 'client'` for the common case of a caller sending a + * malformed id directly. A controller whose id param is configured with + * `type: 'number'` (see `CrudParams`) will also route through here on every + * request — that's a module wiring mistake, not a client one, but the + * distinction isn't visible from inside this assertion. + */ +export function assertCacheId(value: unknown): asserts value is ReferenceId { + if (typeof value !== 'string' || value.trim() === '') { + throw new CacheException({ + message: 'Expected cache id to be a non-empty string, got %s', + messageParams: [typeof value], + safeMessage: 'Invalid id', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } +} diff --git a/packages/nestjs-cache/src/cache-core.module-definition.ts b/packages/nestjs-cache/src/cache-core.module-definition.ts new file mode 100644 index 000000000..e78eb358e --- /dev/null +++ b/packages/nestjs-cache/src/cache-core.module-definition.ts @@ -0,0 +1,128 @@ +import { + ConfigurableModuleBuilder, + type DynamicModule, + type Provider, +} from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { createSettingsProvider } from '@concepta/nestjs-core'; + +import { ArchiveCacheHandler } from './application/commands/handlers/archive-cache.handler.js'; +import { ClearCachesByAssigneeHandler } from './application/commands/handlers/clear-caches-by-assignee.handler.js'; +import { CreateCacheHandler } from './application/commands/handlers/create-cache.handler.js'; +import { RemoveCacheHandler } from './application/commands/handlers/remove-cache.handler.js'; +import { ReplaceCacheHandler } from './application/commands/handlers/replace-cache.handler.js'; +import { UpdateCacheHandler } from './application/commands/handlers/update-cache.handler.js'; +import { UpsertCacheHandler } from './application/commands/handlers/upsert-cache.handler.js'; +import { FindCachesByAssigneeHandler } from './application/queries/handlers/find-caches-by-assignee.handler.js'; +import { FindOneCacheHandler } from './application/queries/handlers/find-one-cache.handler.js'; +import { GetCacheHandler } from './application/queries/handlers/get-cache.handler.js'; +import { + CACHE_CUSTOM_REPOSITORY_TOKEN, + CACHE_MODULE_SETTINGS_TOKEN, + CACHE_REPOSITORY_RESOLVER_TOKEN, +} from './cache.constants.js'; +import { CacheExpirationPolicy } from './domain/policies/cache-expiration.policy.js'; +import { CacheContextOverlay } from './gateways/cache-context.overlay.js'; +import { cacheDefaultConfig } from './infrastructure/config/cache-default.config.js'; +import { type CacheExtrasInterface } from './infrastructure/config/interfaces/cache-extras.interface.js'; +import { type CacheOptionsInterface } from './infrastructure/config/interfaces/cache-options.interface.js'; +import { type CacheSettingsInterface } from './infrastructure/config/interfaces/cache-settings.interface.js'; +import { CacheRepositoryResolver } from './infrastructure/persistence/cache-repository.resolver.js'; +import { CacheMapper } from './infrastructure/persistence/cache.mapper.js'; +import { createCacheExpirationPolicyProvider } from './infrastructure/utils/create-cache-expiration-policy-provider.js'; + +const RAW_OPTIONS_TOKEN = Symbol('__CACHE_MODULE_RAW_OPTIONS_TOKEN__'); + +export const { + ConfigurableModuleClass: CacheCoreModuleClass, + OPTIONS_TYPE: CACHE_CORE_OPTIONS_TYPE, + ASYNC_OPTIONS_TYPE: CACHE_CORE_ASYNC_OPTIONS_TYPE, +} = new ConfigurableModuleBuilder({ + moduleName: 'CacheCore', + optionsInjectionToken: RAW_OPTIONS_TOKEN, +}) + .setExtras({ global: true }, definitionTransform) + .build(); + +export type CacheCoreOptions = typeof CACHE_CORE_OPTIONS_TYPE; +export type CacheCoreAsyncOptions = typeof CACHE_CORE_ASYNC_OPTIONS_TYPE; + +function definitionTransform( + definition: DynamicModule, + { global, providers: overrideProviders, repositories }: CacheExtrasInterface, +): DynamicModule { + const { providers = [], imports = [] } = definition; + + return { + ...definition, + global, + imports: createCacheImports({ imports }), + providers: createCacheProviders({ + providers: [...providers, ...(overrideProviders ?? [])], + repositories, + }), + exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createCacheExports()], + }; +} + +export function createCacheImports(options: { + imports: DynamicModule['imports']; +}): DynamicModule['imports'] { + return [ + ...(options.imports || []), + ConfigModule.forFeature(cacheDefaultConfig), + CqrsModule.forRoot(), + ]; +} + +export function createCacheProviders(options: { + overrides?: CacheCoreOptions; + providers?: Provider[]; + repositories?: CacheExtrasInterface['repositories']; +}): Provider[] { + return [ + createCacheSettingsProvider(options.overrides), + createCacheExpirationPolicyProvider(), + CacheMapper, + { + provide: CACHE_CUSTOM_REPOSITORY_TOKEN, + useValue: options.repositories?.cache ?? null, + }, + { + provide: CACHE_REPOSITORY_RESOLVER_TOKEN, + useClass: CacheRepositoryResolver, + }, + UpsertCacheHandler, + ClearCachesByAssigneeHandler, + CreateCacheHandler, + UpdateCacheHandler, + RemoveCacheHandler, + ReplaceCacheHandler, + ArchiveCacheHandler, + GetCacheHandler, + FindOneCacheHandler, + FindCachesByAssigneeHandler, + { provide: APP_INTERCEPTOR, useClass: CacheContextOverlay }, + ...(options.providers ?? []), + ]; +} + +export function createCacheExports(): Required< + Pick +>['exports'] { + return [CACHE_MODULE_SETTINGS_TOKEN, CacheExpirationPolicy, CacheMapper]; +} + +export function createCacheSettingsProvider( + optionsOverrides?: CacheCoreOptions, +): Provider { + return createSettingsProvider({ + settingsToken: CACHE_MODULE_SETTINGS_TOKEN, + optionsToken: RAW_OPTIONS_TOKEN, + settingsKey: cacheDefaultConfig.KEY, + optionsOverrides, + }); +} diff --git a/packages/nestjs-cache/src/cache.constants.ts b/packages/nestjs-cache/src/cache.constants.ts index fc381a43d..cb508b9e3 100644 --- a/packages/nestjs-cache/src/cache.constants.ts +++ b/packages/nestjs-cache/src/cache.constants.ts @@ -1,9 +1,10 @@ export const CACHE_MODULE_SETTINGS_TOKEN = 'CACHE_MODULE_SETTINGS_TOKEN'; -export const CACHE_MODULE_REPOSITORIES_TOKEN = - 'CACHE_MODULE_REPOSITORIES_TOKEN'; - export const CACHE_MODULE_DEFAULT_SETTINGS_TOKEN = 'CACHE_MODULE_DEFAULT_SETTINGS_TOKEN'; export const CACHE_MODULE_CACHE_ENTITY_KEY = 'cache'; + +export const CACHE_REPOSITORY_RESOLVER_TOKEN = + 'CACHE_REPOSITORY_RESOLVER_TOKEN'; +export const CACHE_CUSTOM_REPOSITORY_TOKEN = 'CACHE_CUSTOM_REPOSITORY_TOKEN'; diff --git a/packages/nestjs-cache/src/cache.module-definition.ts b/packages/nestjs-cache/src/cache.module-definition.ts deleted file mode 100644 index 76b1100e1..000000000 --- a/packages/nestjs-cache/src/cache.module-definition.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - ConfigurableModuleBuilder, - DynamicModule, - Provider, -} from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; - -import { - createSettingsProvider, - getDynamicRepositoryToken, -} from '@concepta/nestjs-common'; - -import { - CACHE_MODULE_REPOSITORIES_TOKEN, - CACHE_MODULE_SETTINGS_TOKEN, -} from './cache.constants'; -import { cacheDefaultConfig } from './config/cache-default.config'; -import { CacheMissingEntitiesOptionException } from './exceptions/cache-missing-entities-option.exception'; -import { CacheOptionsExtrasInterface } from './interfaces/cache-options-extras.interface'; -import { CacheOptionsInterface } from './interfaces/cache-options.interface'; -import { CacheSettingsInterface } from './interfaces/cache-settings.interface'; -import { CacheService } from './services/cache.service'; - -const RAW_OPTIONS_TOKEN = Symbol('__CACHE_MODULE_RAW_OPTIONS_TOKEN__'); - -export const { - ConfigurableModuleClass: CacheModuleClass, - OPTIONS_TYPE: CACHE_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: CACHE_ASYNC_OPTIONS_TYPE, -} = new ConfigurableModuleBuilder({ - moduleName: 'Cache', - optionsInjectionToken: RAW_OPTIONS_TOKEN, -}) - .setExtras( - { global: false }, - definitionTransform, - ) - .build(); - -export type CacheOptions = Omit; -export type CacheAsyncOptions = Omit; - -function definitionTransform( - definition: DynamicModule, - extras: CacheOptionsExtrasInterface, -): DynamicModule { - const { imports, providers } = definition; - const { global = false, entities } = extras; - - if (!entities || entities.length === 0) { - throw new CacheMissingEntitiesOptionException(); - } - - return { - ...definition, - global, - imports: createCacheImports({ imports }), - providers: createCacheProviders({ entities, providers }), - exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createCacheExports()], - }; -} - -export function createCacheImports(options: { - imports: DynamicModule['imports']; -}): DynamicModule['imports'] { - return [ - ...(options.imports || []), - ConfigModule.forFeature(cacheDefaultConfig), - ]; -} - -export function createCacheProviders(options: { - entities: string[]; - overrides?: CacheOptions; - providers?: Provider[]; -}): Provider[] { - return [ - ...(options.providers ?? []), - createCacheSettingsProvider(options.overrides), - ...createCacheRepositoriesProvider({ - entities: options.entities, - }), - CacheService, - ]; -} - -export function createCacheExports(): Required< - Pick ->['exports'] { - return [ - CACHE_MODULE_SETTINGS_TOKEN, - CACHE_MODULE_REPOSITORIES_TOKEN, - CacheService, - ]; -} - -export function createCacheSettingsProvider( - optionsOverrides?: CacheOptions, -): Provider { - return createSettingsProvider({ - settingsToken: CACHE_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: cacheDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createCacheRepositoriesProvider(options: { - entities: string[]; -}): Provider[] { - const { entities } = options; - - const reposToInject = []; - const keyTracker: Record = {}; - - let entityIdx = 0; - - for (const entityKey of entities) { - reposToInject[entityIdx] = getDynamicRepositoryToken(entityKey); - keyTracker[entityKey] = entityIdx++; - } - - return [ - { - provide: CACHE_MODULE_REPOSITORIES_TOKEN, - inject: reposToInject, - useFactory: (...args: string[]) => { - const repoInstances: Record = {}; - - for (const entityKey of entities) { - repoInstances[entityKey] = args[keyTracker[entityKey]]; - } - - return repoInstances; - }, - }, - ]; -} diff --git a/packages/nestjs-cache/src/cache.module.spec.ts b/packages/nestjs-cache/src/cache.module.spec.ts deleted file mode 100644 index c5685fc3c..000000000 --- a/packages/nestjs-cache/src/cache.module.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { CacheInterface, RepositoryInterface } from '@concepta/nestjs-common'; - -import { CACHE_MODULE_REPOSITORIES_TOKEN } from './cache.constants'; -import { CacheModule } from './cache.module'; -import { CacheService } from './services/cache.service'; - -import { AppModuleFixture } from './__fixtures__/app.module.fixture'; - -describe(CacheModule.name, () => { - let cacheModule: CacheModule; - let cacheService: CacheService; - let cacheDynamicRepo: Record>; - - beforeEach(async () => { - const testModule: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - - cacheModule = testModule.get(CacheModule); - cacheService = testModule.get(CacheService); - cacheDynamicRepo = testModule.get< - Record> - >(CACHE_MODULE_REPOSITORIES_TOKEN); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('module', () => { - it('should be loaded', async () => { - expect(cacheModule).toBeInstanceOf(CacheModule); - expect(cacheService).toBeInstanceOf(CacheService); - expect(cacheDynamicRepo).toBeDefined(); - }); - }); - - describe('CacheModule functions', () => { - const spyRegister = jest - .spyOn(CacheModule, 'register') - .mockImplementation(() => { - return {} as DynamicModule; - }); - - const spyRegisterAsync = jest - .spyOn(CacheModule, 'registerAsync') - .mockImplementation(() => { - return {} as DynamicModule; - }); - - it('should call super.register in register method', () => { - CacheModule.register({}); - expect(spyRegister).toHaveBeenCalled(); - }); - - it('should call super.registerAsync in register method', () => { - CacheModule.registerAsync({}); - expect(spyRegisterAsync).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/nestjs-cache/src/cache.module.ts b/packages/nestjs-cache/src/cache.module.ts index 82dd78b34..c7f927604 100644 --- a/packages/nestjs-cache/src/cache.module.ts +++ b/packages/nestjs-cache/src/cache.module.ts @@ -1,29 +1,60 @@ import { DynamicModule, Module } from '@nestjs/common'; import { - CacheAsyncOptions, - CacheModuleClass, - CacheOptions, -} from './cache.module-definition'; + CacheCoreAsyncOptions, + CacheCoreModuleClass, + CacheCoreOptions, +} from './cache-core.module-definition.js'; +import { createCacheRepositoryProvider } from './infrastructure/utils/create-cache-repository-provider.js'; +type CacheOptions = Omit; +type CacheAsyncOptions = Omit; /** * Cache Module */ @Module({}) -export class CacheModule extends CacheModuleClass { +export class CacheModule { static register(options: CacheOptions): DynamicModule { - return super.register(options); + return { + module: CacheModule, + imports: [CacheCoreModuleClass.register({ ...options, global: false })], + }; } static registerAsync(options: CacheAsyncOptions): DynamicModule { - return super.registerAsync(options); + return { + module: CacheModule, + imports: [ + CacheCoreModuleClass.registerAsync({ ...options, global: false }), + ], + }; } static forRoot(options: CacheOptions): DynamicModule { - return super.register({ ...options, global: true }); + return { + module: CacheModule, + imports: [CacheCoreModuleClass.register({ ...options, global: true })], + }; } static forRootAsync(options: CacheAsyncOptions): DynamicModule { - return super.registerAsync({ ...options, global: true }); + return { + module: CacheModule, + imports: [ + CacheCoreModuleClass.registerAsync({ ...options, global: true }), + ], + }; + } + + static forFeature(entityKeys: string[]): DynamicModule { + const providers = entityKeys.map((entityKey) => + createCacheRepositoryProvider(entityKey), + ); + + return { + module: CacheModule, + providers, + exports: providers, + }; } } diff --git a/packages/nestjs-cache/src/cache.seeder.ts b/packages/nestjs-cache/src/cache.seeder.ts deleted file mode 100644 index 1b53d0f7f..000000000 --- a/packages/nestjs-cache/src/cache.seeder.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Seeder } from '@concepta/typeorm-seeding'; - -import { CacheFactory } from './cache.factory'; - -import { UserFactoryFixture } from './__fixtures__/factories/user.factory.fixture'; - -/** - * Cache seeder - */ -export class CacheSeeder extends Seeder { - /** - * Runner - */ - public async run(): Promise { - // number of caches to create - const createAmount = process.env?.CACHE_MODULE_SEEDER_AMOUNT - ? Number(process.env.CACHE_MODULE_SEEDER_AMOUNT) - : 50; - - // the factory - const cacheFactory = this.factory(CacheFactory); - const userFactory = this.factory(UserFactoryFixture); - const user = await userFactory.create(); - - // create a bunch - await cacheFactory.createMany(createAmount, { - assigneeId: user.id, - }); - } -} diff --git a/packages/nestjs-cache/src/cache.types.spec.ts b/packages/nestjs-cache/src/cache.types.spec.ts deleted file mode 100644 index 3d4c82e20..000000000 --- a/packages/nestjs-cache/src/cache.types.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { CacheResource } from './cache.types'; - -describe('Cache Types', () => { - describe('Cache enum', () => { - it('should match', async () => { - expect(CacheResource.One).toEqual('cache'); - expect(CacheResource.Many).toEqual('cache-list'); - }); - }); -}); diff --git a/packages/nestjs-cache/src/cache.types.ts b/packages/nestjs-cache/src/cache.types.ts deleted file mode 100644 index a89ba67c8..000000000 --- a/packages/nestjs-cache/src/cache.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum CacheResource { - 'One' = 'cache', - 'Many' = 'cache-list', -} diff --git a/packages/nestjs-cache/src/config/cache-default.config.ts b/packages/nestjs-cache/src/config/cache-default.config.ts deleted file mode 100644 index 601f68915..000000000 --- a/packages/nestjs-cache/src/config/cache-default.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { CACHE_MODULE_DEFAULT_SETTINGS_TOKEN } from '../cache.constants'; -import { CacheSettingsInterface } from '../interfaces/cache-settings.interface'; - -/** - * Default configuration for Cache module. - */ -export const cacheDefaultConfig = registerAs( - CACHE_MODULE_DEFAULT_SETTINGS_TOKEN, - (): Partial => ({ - expiresIn: process.env.CACHE_EXPIRE_IN ? process.env.CACHE_EXPIRE_IN : null, - }), -); diff --git a/packages/nestjs-cache/src/controllers/cache-crud.controller.e2e-spec.ts b/packages/nestjs-cache/src/controllers/cache-crud.controller.e2e-spec.ts deleted file mode 100644 index 91dccb9a5..000000000 --- a/packages/nestjs-cache/src/controllers/cache-crud.controller.e2e-spec.ts +++ /dev/null @@ -1,336 +0,0 @@ -import assert from 'assert'; -import { randomUUID } from 'crypto'; - -import supertest from 'supertest'; - -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { CacheCreatableInterface } from '@concepta/nestjs-common'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { CacheFactory } from '../cache.factory'; -import { CacheSeeder } from '../cache.seeder'; - -import { AppCrudModuleFixture } from '../__fixtures__/app-crud.module.fixture'; -import { UserCacheEntityFixture } from '../__fixtures__/entities/user-cache-entity.fixture'; -import { UserEntityFixture } from '../__fixtures__/entities/user-entity.fixture'; -import { UserCacheFactoryFixture } from '../__fixtures__/factories/user-cache.factory.fixture'; -import { UserFactoryFixture } from '../__fixtures__/factories/user.factory.fixture'; - -describe('CacheAssignmentController (e2e)', () => { - let app: INestApplication; - let seedingSource: SeedingSource; - let userFactory: UserFactoryFixture; - let userCacheFactory: UserCacheFactoryFixture; - let user: UserEntityFixture; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppCrudModuleFixture], - }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - - seedingSource = new SeedingSource({ - dataSource: app.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - userFactory = new UserFactoryFixture({ seedingSource }); - userCacheFactory = new UserCacheFactoryFixture({ seedingSource }); - - const cacheSeeder = new CacheSeeder({ - factories: [new CacheFactory({ entity: UserCacheEntityFixture })], - }); - - await seedingSource.run.one(cacheSeeder); - - user = await userFactory.create(); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - it('GET /cache/user', async () => { - await userCacheFactory - .map((userCache) => { - userCache.assigneeId = user.id; - }) - .createMany(2); - - await supertest(app.getHttpServer()) - .get('/cache/user?limit=2') - .expect(200) - .then((res) => { - assert.strictEqual(res.body.data.length, 2); - }); - }); - - it('GET /cache/user/:id', async () => { - const userCache = await userCacheFactory - .map((userCache) => { - userCache.assigneeId = user.id; - }) - .create(); - - await supertest(app.getHttpServer()) - .get( - `/cache/user/${userCache.id}` + `?filter[0]=key||$eq||${userCache.key}`, - ) - .expect(200) - .then((res) => { - assert.strictEqual(res.body.assigneeId, user.id); - }); - }); - - it('GET /cache/user/ with key and type filters', async () => { - const userCache = await userCacheFactory - .map((userCache) => { - userCache.assigneeId = user.id; - userCache.key = 'specific-key'; - userCache.type = 'specific-type'; - userCache.data = JSON.stringify({ name: 'John Doe' }); - }) - .create(); - - const url = - `/cache/user/` + - `?filter[0]=key||$eq||${userCache.key}` + - `&filter[1]=type||$eq||${userCache.type}`; - // Assuming your endpoint can filter by key and type - await supertest(app.getHttpServer()) - .get(url) - .expect(200) - .then((res) => { - const response = res.body.data[0]; - assert.strictEqual(response.assigneeId, user.id); - assert.strictEqual(response.key, userCache.key); - assert.strictEqual(response.type, userCache.type); - assert.strictEqual(response.data, userCache.data); - }); - }); - - it('POST /cache/user creating user with success', async () => { - const payload: CacheCreatableInterface = { - key: 'dashboard-1', - type: 'filter', - data: '{}', - expiresIn: '1d', - assigneeId: user.id, - }; - - await supertest(app.getHttpServer()) - .post('/cache/user') - .send(payload) - .expect(201) - .then((res) => { - expect(res.body.key).toBe(payload.key); - expect(res.body.assigneeId).toBe(user.id); - }); - }); - - it('POST /cache/user assignee id null', async () => { - const payload = { - key: 'dashboard-1', - type: 'filter', - data: '{}', - expiresIn: '1d', - assignee: { id: null }, - }; - - await supertest(app.getHttpServer()) - .post('/cache/user') - .send(payload) - .expect(400); - }); - - it('POST /cache/user Duplicated', async () => { - const payload: CacheCreatableInterface = { - key: 'dashboard-1', - type: 'filter', - data: '{}', - expiresIn: '1d', - assigneeId: user.id, - }; - - await supertest(app.getHttpServer()) - .post('/cache/user') - .send(payload) - .expect(201) - .then((res) => { - expect(res.body.key).toBe(payload.key); - expect(res.body.assigneeId).toBe(user.id); - }); - }); - - it('POST /cache/user null after create', async () => { - interface ExtendedCacheCreatableInterface - extends Pick< - CacheCreatableInterface, - 'key' | 'expiresIn' | 'type' | 'data' - > { - assigneeId: string | null; - } - const payload: ExtendedCacheCreatableInterface = { - key: 'dashboard-1', - type: 'filter', - data: '{}', - expiresIn: '1d', - assigneeId: user.id, - }; - - await supertest(app.getHttpServer()) - .post('/cache/user') - .send(payload) - .expect(201) - .then((res) => { - expect(res.body.key).toBe(payload.key); - expect(res.body.assigneeId).toBe(user.id); - }); - - payload.data = '{ "name": "John Doe" }'; - payload.expiresIn = null; - payload.assigneeId = null; - - await supertest(app.getHttpServer()) - .post('/cache/user') - .send(payload) - .expect(400); - - payload.assigneeId = ''; - await supertest(app.getHttpServer()) - .post('/cache/user') - .send(payload) - .expect(400); - - payload.assigneeId = null; - await supertest(app.getHttpServer()) - .post('/cache/user') - .send(payload) - .expect(400); - }); - - it('PATCH /cache/user Update', async () => { - const payload: CacheCreatableInterface = { - key: 'dashboard-1', - type: 'filter', - data: '{}', - expiresIn: '1d', - assigneeId: user.id, - }; - - let cacheId = ''; - - await supertest(app.getHttpServer()) - .post('/cache/user') - .send(payload) - .expect(201) - .then((res) => { - cacheId = res.body.id; - expect(typeof res.body.id).toEqual('string'); - expect(res.body.key).toBe(payload.key); - expect(res.body.assigneeId).toBe(user.id); - }); - - payload.data = '{ "name": "John Doe" }'; - payload.expiresIn = null; - - await supertest(app.getHttpServer()) - .patch(`/cache/user/${cacheId}`) - .send(payload) - .expect(200) - .then((res) => { - expect(res.body.key).toBe(payload.key); - expect(res.body.data).toBe(payload.data); - expect(res.body.assigneeId).toBe(user.id); - }); - - const url = - `/cache/user/` + - `?filter[0]=key||$eq||${payload.key}` + - `&filter[1]=type||$eq||${payload.type}` + - `&filter[2]=assigneeId||$eq||${payload.assigneeId}`; - - // Assuming your endpoint can filter by key and type - await supertest(app.getHttpServer()) - .get(url) - .expect(200) - .then((res) => { - const response = res.body.data[0]; - assert.strictEqual(response.assigneeId, user.id); - assert.strictEqual(response.key, payload.key); - assert.strictEqual(response.type, payload.type); - assert.strictEqual(response.data, payload.data); - }); - }); - - it.skip('PUT /cache/user', async () => { - const payload: CacheCreatableInterface = { - key: 'dashboard-1', - type: 'filter', - data: '{}', - expiresIn: '1d', - assigneeId: user.id, - }; - - const cacheId = randomUUID(); - - await supertest(app.getHttpServer()) - .put(`/cache/user/${cacheId}`) - .send(payload) - .expect(200) - .then((res) => { - expect(res.body.id).toBe(cacheId); - expect(res.body.key).toBe(payload.key); - expect(res.body.assigneeId).toBe(user.id); - }); - - payload.data = '{ "name": "John Doe" }'; - payload.expiresIn = null; - - await supertest(app.getHttpServer()) - .put(`/cache/user/${cacheId}`) - .send(payload) - .expect(200) - .then((res) => { - expect(res.body.key).toBe(payload.key); - expect(res.body.data).toBe(payload.data); - expect(res.body.assigneeId).toBe(user.id); - }); - - const url = - `/cache/user/` + - `?filter[0]=key||$eq||${payload.key}` + - `&filter[1]=type||$eq||${payload.type}` + - `&filter[2]=assigneeId||$eq||${payload.assigneeId}`; - - // Assuming your endpoint can filter by key and type - await supertest(app.getHttpServer()) - .get(url) - .expect(200) - .then((res) => { - const response = res.body.data[0]; - assert.strictEqual(response.assigneeId, user.id); - assert.strictEqual(response.key, payload.key); - assert.strictEqual(response.type, payload.type); - assert.strictEqual(response.data, payload.data); - }); - }); - - it('DELETE /cache/user/:id', async () => { - const userCache = await userCacheFactory - .map((userCache) => { - userCache.assigneeId = user.id; - }) - .create(); - - await supertest(app.getHttpServer()) - .delete(`/cache/user/${userCache.id}`) - .expect(200); - }); -}); diff --git a/packages/nestjs-cache/src/domain/aggregates/__tests__/cache.spec.ts b/packages/nestjs-cache/src/domain/aggregates/__tests__/cache.spec.ts new file mode 100644 index 000000000..65c3d6735 --- /dev/null +++ b/packages/nestjs-cache/src/domain/aggregates/__tests__/cache.spec.ts @@ -0,0 +1,154 @@ +import { + createMockEventContext, + toCacheDomain, +} from '../../../__tests__/helpers/mock.helpers.js'; +import { type CacheEntityInterface } from '../../../infrastructure/persistence/interfaces/cache-entity.interface.js'; +import { type CacheCreatableInterface } from '../../interfaces/cache-creatable.interface.js'; +import { CacheExpirationPolicy } from '../../policies/cache-expiration.policy.js'; +import { Cache } from '../cache.js'; + +describe(Cache.name, () => { + const eventContext = createMockEventContext(); + + const policy = new CacheExpirationPolicy({ expiresIn: '1h' }); + + const validCreateDto: CacheCreatableInterface = { + key: 'testKey', + type: 'testType', + data: 'testData', + assigneeId: 'testAssignee', + expiresIn: null, + }; + + const mockEntity: CacheEntityInterface = { + id: 'test-uuid', + key: 'entityKey', + type: 'entityType', + assigneeId: 'entityAssignee', + data: 'entityData', + expirationDate: new Date('2026-12-31'), + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + }; + + describe('constructor', () => { + it('should hydrate all properties from entity', () => { + const cache = toCacheDomain(mockEntity); + + expect(cache.id).toBe(mockEntity.id); + expect(cache.key).toBe(mockEntity.key); + expect(cache.type).toBe(mockEntity.type); + expect(cache.assigneeId).toBe(mockEntity.assigneeId); + expect(cache.data).toBe(mockEntity.data); + expect(cache.expirationDate).toEqual(mockEntity.expirationDate); + expect(cache.meta.dateCreated).toEqual(mockEntity.dateCreated); + expect(cache.meta.dateUpdated).toEqual(mockEntity.dateUpdated); + expect(cache.meta.dateDeleted).toBe(mockEntity.dateDeleted); + expect(cache.version).toBe(mockEntity.version); + }); + }); + + describe('create', () => { + it('should create a Cache with a computed expirationDate', () => { + const expirationDate = policy.resolveExpirationDate( + validCreateDto.expiresIn, + ); + const cache = Cache.create(eventContext, validCreateDto, expirationDate); + + expect(cache).toBeInstanceOf(Cache); + expect(cache.key).toBe('testKey'); + expect(cache.type).toBe('testType'); + expect(cache.data).toBe('testData'); + expect(cache.assigneeId).toBe('testAssignee'); + expect(cache.expirationDate).toBeInstanceOf(Date); + expect(cache.version).toBe(1); + }); + + it('should use dto expiresIn when provided', () => { + const dto: CacheCreatableInterface = { + ...validCreateDto, + expiresIn: '2d', + }; + + const expirationDate = policy.resolveExpirationDate(dto.expiresIn); + const cache = Cache.create(eventContext, dto, expirationDate); + + expect(cache.expirationDate).toBeInstanceOf(Date); + }); + + it('should set expirationDate to null when passed null', () => { + const cache = Cache.create(eventContext, validCreateDto, null); + + expect(cache.expirationDate).toBeNull(); + }); + + it('should generate a uuid for id', () => { + const expirationDate = policy.resolveExpirationDate( + validCreateDto.expiresIn, + ); + const cache = Cache.create(eventContext, validCreateDto, expirationDate); + + expect(cache.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + }); + + describe('toPlain', () => { + it('should return a CacheEntityInterface snapshot', () => { + const cache = toCacheDomain(mockEntity); + const plain = cache.toPlain(); + + expect(plain).toEqual(mockEntity); + }); + + it('should return a new object each time', () => { + const cache = toCacheDomain(mockEntity); + + expect(cache.toPlain()).not.toBe(cache.toPlain()); + }); + }); + + describe('updateData', () => { + it('should update data and bump version', () => { + const cache = toCacheDomain(mockEntity); + + cache.updateData(eventContext, 'newData'); + + expect(cache.data).toBe('newData'); + expect(cache.version).toBe(mockEntity.version + 1); + }); + + it('should not change expirationDate', () => { + const cache = toCacheDomain(mockEntity); + const originalExpiration = cache.expirationDate; + + cache.updateData(eventContext, 'newData'); + + expect(cache.expirationDate).toBe(originalExpiration); + }); + }); + + describe('extend', () => { + it('should update expirationDate and bump version', () => { + const cache = toCacheDomain(mockEntity); + const newExpiration = policy.resolveExpirationDate('2h'); + + cache.extend(eventContext, newExpiration); + + expect(cache.expirationDate).toBeInstanceOf(Date); + expect(cache.version).toBe(mockEntity.version + 1); + }); + + it('should set expirationDate to null when passed null', () => { + const cache = toCacheDomain(mockEntity); + + cache.extend(eventContext, null); + + expect(cache.expirationDate).toBeNull(); + expect(cache.version).toBe(mockEntity.version + 1); + }); + }); +}); diff --git a/packages/nestjs-cache/src/domain/aggregates/cache.ts b/packages/nestjs-cache/src/domain/aggregates/cache.ts new file mode 100644 index 000000000..f6518afd8 --- /dev/null +++ b/packages/nestjs-cache/src/domain/aggregates/cache.ts @@ -0,0 +1,117 @@ +import { randomUUID } from 'crypto'; + +import { + type DomainFactory, + type EventContextHost, +} from '@concepta/nestjs-core'; +import { + type AggregateMetaInterface, + DomainAggregate, +} from '@concepta/nestjs-core/aggregate'; + +import { CacheCreatedEvent } from '../events/cache-created.event.js'; +import { CacheExtendedEvent } from '../events/cache-extended.event.js'; +import { CacheReplacedEvent } from '../events/cache-replaced.event.js'; +import { CacheUpdatedEvent } from '../events/cache-updated.event.js'; +import { type CacheEventHeaderInterface } from '../events/interfaces/cache-event-header.interface.js'; +import { type CacheCreatableInterface } from '../interfaces/cache-creatable.interface.js'; +import { type CacheInterface } from '../interfaces/cache.interface.js'; + +export class Cache extends DomainAggregate { + constructor( + id: string, + props: CacheInterface, + version?: number, + meta?: AggregateMetaInterface, + ) { + super(id, props, version, meta); + } + + get key() { + return this.props.key; + } + get type() { + return this.props.type; + } + get assigneeId() { + return this.props.assigneeId; + } + get data() { + return this.props.data; + } + get expirationDate() { + return this.props.expirationDate; + } + + static create( + eventContext: EventContextHost, + dto: CacheCreatableInterface, + expirationDate: Date | null, + ): Cache { + return Cache.createWithId(eventContext, randomUUID(), dto, expirationDate); + } + + static createWithId( + eventContext: EventContextHost, + id: string, + dto: CacheCreatableInterface, + expirationDate: Date | null, + ): Cache { + const { key, type, assigneeId, data } = dto; + + const cache = new Cache(id, { + key, + type, + assigneeId, + data: data ?? null, + expirationDate, + }); + + cache.apply(new CacheCreatedEvent(eventContext, cache.toPlain())); + + return cache; + } + + replace( + eventContext: EventContextHost, + dto: CacheCreatableInterface, + expirationDate: Date | null, + ): void { + const { key, type, assigneeId, data } = dto; + this.props = { + key, + type, + assigneeId, + data: data ?? null, + expirationDate, + }; + this.incrementVersion(); + this.apply(new CacheReplacedEvent(eventContext, this.toPlain())); + } + + updateData( + eventContext: EventContextHost, + newData: string | null, + ): void { + this.props = { + ...this.props, + data: newData, + }; + this.incrementVersion(); + this.apply(new CacheUpdatedEvent(eventContext, this.toPlain())); + } + + extend( + eventContext: EventContextHost, + expirationDate: Date | null, + ): void { + this.props = { + ...this.props, + expirationDate, + }; + this.incrementVersion(); + this.apply(new CacheExtendedEvent(eventContext, this.toPlain())); + } +} + +Cache satisfies DomainFactory; diff --git a/packages/nestjs-cache/src/domain/events/cache-created.event.ts b/packages/nestjs-cache/src/domain/events/cache-created.event.ts new file mode 100644 index 000000000..ce2350746 --- /dev/null +++ b/packages/nestjs-cache/src/domain/events/cache-created.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type CacheInterface } from '../interfaces/cache.interface.js'; + +import { type CacheEventHeaderInterface } from './interfaces/cache-event-header.interface.js'; + +export class CacheCreatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly cache: CacheInterface, + ) {} +} diff --git a/packages/nestjs-cache/src/domain/events/cache-extended.event.ts b/packages/nestjs-cache/src/domain/events/cache-extended.event.ts new file mode 100644 index 000000000..0f40c747d --- /dev/null +++ b/packages/nestjs-cache/src/domain/events/cache-extended.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type CacheInterface } from '../interfaces/cache.interface.js'; + +import { type CacheEventHeaderInterface } from './interfaces/cache-event-header.interface.js'; + +export class CacheExtendedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly cache: CacheInterface, + ) {} +} diff --git a/packages/nestjs-cache/src/domain/events/cache-replaced.event.ts b/packages/nestjs-cache/src/domain/events/cache-replaced.event.ts new file mode 100644 index 000000000..8e1d649ab --- /dev/null +++ b/packages/nestjs-cache/src/domain/events/cache-replaced.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type CacheInterface } from '../interfaces/cache.interface.js'; + +import { type CacheEventHeaderInterface } from './interfaces/cache-event-header.interface.js'; + +export class CacheReplacedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly cache: CacheInterface, + ) {} +} diff --git a/packages/nestjs-cache/src/domain/events/cache-updated.event.ts b/packages/nestjs-cache/src/domain/events/cache-updated.event.ts new file mode 100644 index 000000000..8c7f0bf21 --- /dev/null +++ b/packages/nestjs-cache/src/domain/events/cache-updated.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type CacheInterface } from '../interfaces/cache.interface.js'; + +import { type CacheEventHeaderInterface } from './interfaces/cache-event-header.interface.js'; + +export class CacheUpdatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly cache: CacheInterface, + ) {} +} diff --git a/packages/nestjs-cache/src/domain/events/interfaces/cache-event-header.interface.ts b/packages/nestjs-cache/src/domain/events/interfaces/cache-event-header.interface.ts new file mode 100644 index 000000000..6be54b0d5 --- /dev/null +++ b/packages/nestjs-cache/src/domain/events/interfaces/cache-event-header.interface.ts @@ -0,0 +1,5 @@ +import { type EventContextHeadersInterface } from '@concepta/nestjs-core'; + +export interface CacheEventHeaderInterface extends EventContextHeadersInterface { + namespace: string; +} diff --git a/packages/nestjs-cache/src/domain/exceptions/__tests__/cache-invalid-expired-date.exception.spec.ts b/packages/nestjs-cache/src/domain/exceptions/__tests__/cache-invalid-expired-date.exception.spec.ts new file mode 100644 index 000000000..27bb6d2b1 --- /dev/null +++ b/packages/nestjs-cache/src/domain/exceptions/__tests__/cache-invalid-expired-date.exception.spec.ts @@ -0,0 +1,26 @@ +import { HttpStatus } from '@nestjs/common'; + +import { CacheInvalidExpiredDateException } from '../cache-invalid-expired-date.exception.js'; +import { CacheException } from '../cache.exception.js'; + +describe(CacheInvalidExpiredDateException.name, () => { + it('should be an instance of CacheException', () => { + const exception = new CacheInvalidExpiredDateException(); + expect(exception).toBeInstanceOf(CacheException); + }); + + it('should have httpStatus BAD_REQUEST', () => { + const exception = new CacheInvalidExpiredDateException(); + expect(exception.httpStatus).toBe(HttpStatus.BAD_REQUEST); + }); + + it('should have message "Invalid expiresIn"', () => { + const exception = new CacheInvalidExpiredDateException(); + expect(exception.message).toBe('Invalid expiresIn'); + }); + + it('should have errorCode CACHE_INVALID_EXPIRES_IN', () => { + const exception = new CacheInvalidExpiredDateException(); + expect(exception.errorCode).toBe('CACHE_INVALID_EXPIRES_IN'); + }); +}); diff --git a/packages/nestjs-cache/src/domain/exceptions/__tests__/cache.exception.spec.ts b/packages/nestjs-cache/src/domain/exceptions/__tests__/cache.exception.spec.ts new file mode 100644 index 000000000..d8bd3ee45 --- /dev/null +++ b/packages/nestjs-cache/src/domain/exceptions/__tests__/cache.exception.spec.ts @@ -0,0 +1,29 @@ +import { HttpStatus } from '@nestjs/common'; + +import { RuntimeException } from '@concepta/nestjs-core'; + +import { CacheException } from '../cache.exception.js'; + +describe(CacheException.name, () => { + it('should be an instance of RuntimeException', () => { + const exception = new CacheException(); + expect(exception).toBeInstanceOf(RuntimeException); + }); + + it('should have errorCode CACHE_ERROR', () => { + const exception = new CacheException(); + expect(exception.errorCode).toBe('CACHE_ERROR'); + }); + + it('should accept a custom message', () => { + const exception = new CacheException({ message: 'custom error' }); + expect(exception.message).toBe('custom error'); + }); + + it('should accept a custom httpStatus', () => { + const exception = new CacheException({ + httpStatus: HttpStatus.CONFLICT, + }); + expect(exception.httpStatus).toBe(HttpStatus.CONFLICT); + }); +}); diff --git a/packages/nestjs-cache/src/domain/exceptions/cache-invalid-expired-date.exception.ts b/packages/nestjs-cache/src/domain/exceptions/cache-invalid-expired-date.exception.ts new file mode 100644 index 000000000..8858a7d65 --- /dev/null +++ b/packages/nestjs-cache/src/domain/exceptions/cache-invalid-expired-date.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { CacheException } from './cache.exception.js'; + +export class CacheInvalidExpiredDateException extends CacheException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Invalid expiresIn', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + this.errorCode = 'CACHE_INVALID_EXPIRES_IN'; + } +} diff --git a/packages/nestjs-cache/src/exceptions/cache.exception.ts b/packages/nestjs-cache/src/domain/exceptions/cache.exception.ts similarity index 78% rename from packages/nestjs-cache/src/exceptions/cache.exception.ts rename to packages/nestjs-cache/src/domain/exceptions/cache.exception.ts index f96ba1b7b..83aa97580 100644 --- a/packages/nestjs-cache/src/exceptions/cache.exception.ts +++ b/packages/nestjs-cache/src/domain/exceptions/cache.exception.ts @@ -1,7 +1,7 @@ import { RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; /** * Generic cache exception. */ diff --git a/packages/nestjs-cache/src/domain/interfaces/cache-creatable.interface.ts b/packages/nestjs-cache/src/domain/interfaces/cache-creatable.interface.ts new file mode 100644 index 000000000..14ee8e030 --- /dev/null +++ b/packages/nestjs-cache/src/domain/interfaces/cache-creatable.interface.ts @@ -0,0 +1,9 @@ +import { type CacheInterface } from './cache.interface.js'; + +export interface CacheCreatableInterface extends Pick< + CacheInterface, + 'key' | 'type' | 'assigneeId' +> { + data?: string | null; + expiresIn?: string | null; +} diff --git a/packages/nestjs-cache/src/domain/interfaces/cache-updatable.interface.ts b/packages/nestjs-cache/src/domain/interfaces/cache-updatable.interface.ts new file mode 100644 index 000000000..95f919825 --- /dev/null +++ b/packages/nestjs-cache/src/domain/interfaces/cache-updatable.interface.ts @@ -0,0 +1,4 @@ +export interface CacheUpdatableInterface { + data?: string | null; + expiresIn?: string | null; +} diff --git a/packages/nestjs-cache/src/domain/interfaces/cache.interface.ts b/packages/nestjs-cache/src/domain/interfaces/cache.interface.ts new file mode 100644 index 000000000..9bfc1eaf5 --- /dev/null +++ b/packages/nestjs-cache/src/domain/interfaces/cache.interface.ts @@ -0,0 +1,23 @@ +import { type AssigneeRelationInterface } from '@concepta/nestjs-core'; + +export interface CacheInterface extends AssigneeRelationInterface { + /** + * key to be used as reference for the cache data + */ + key: string; + + /** + * Type of the cache + */ + type: string; + + /** + * data of the cache + */ + data: string | null; + + /** + * Date it will expire + */ + expirationDate: Date | null; +} diff --git a/packages/nestjs-cache/src/domain/policies/__tests__/cache-expiration.policy.spec.ts b/packages/nestjs-cache/src/domain/policies/__tests__/cache-expiration.policy.spec.ts new file mode 100644 index 000000000..ecdd7369b --- /dev/null +++ b/packages/nestjs-cache/src/domain/policies/__tests__/cache-expiration.policy.spec.ts @@ -0,0 +1,35 @@ +import { type RuntimeException } from '@concepta/nestjs-core'; + +import { CacheExpirationPolicy } from '../cache-expiration.policy.js'; + +describe(CacheExpirationPolicy.name, () => { + it('should return null when no expiresIn or default is configured', () => { + const policy = new CacheExpirationPolicy(); + expect(policy.resolveExpirationDate()).toBeNull(); + }); + + it('should resolve a client-supplied expiresIn', () => { + const policy = new CacheExpirationPolicy(); + expect(policy.resolveExpirationDate('1h')).toBeInstanceOf(Date); + }); + + it('should classify a malformed client-supplied expiresIn as client fault', () => { + const policy = new CacheExpirationPolicy(); + try { + policy.resolveExpirationDate('not-a-duration'); + throw new Error('Expected a throw'); + } catch (e) { + expect((e as RuntimeException).fault).toBe('client'); + } + }); + + it('should classify a malformed module-configured default as usage fault', () => { + const policy = new CacheExpirationPolicy({ expiresIn: 'not-a-duration' }); + try { + policy.resolveExpirationDate(); + throw new Error('Expected a throw'); + } catch (e) { + expect((e as RuntimeException).fault).toBe('usage'); + } + }); +}); diff --git a/packages/nestjs-cache/src/domain/policies/cache-expiration.policy.ts b/packages/nestjs-cache/src/domain/policies/cache-expiration.policy.ts new file mode 100644 index 000000000..0e9d08e03 --- /dev/null +++ b/packages/nestjs-cache/src/domain/policies/cache-expiration.policy.ts @@ -0,0 +1,30 @@ +import { getExpirationDate } from '../utils/get-expiration-date.util.js'; + +export interface CacheExpirationSettings { + expiresIn?: string | null; +} + +const DEFAULTS: Required = { + expiresIn: null, +}; + +export class CacheExpirationPolicy { + private readonly settings: Required; + + constructor(settings?: CacheExpirationSettings) { + this.settings = { ...DEFAULTS, ...settings }; + } + + get defaultExpiresIn(): string | null { + return this.settings.expiresIn ?? null; + } + + resolveExpirationDate(expiresIn?: string | null): Date | null { + // A malformed client-supplied value is the caller's mistake; falling + // through to a malformed module-configured default is ours. + return getExpirationDate( + expiresIn ?? this.defaultExpiresIn, + expiresIn ? 'client' : 'usage', + ); + } +} diff --git a/packages/nestjs-cache/src/domain/repositories/cache-repository-resolver.interface.ts b/packages/nestjs-cache/src/domain/repositories/cache-repository-resolver.interface.ts new file mode 100644 index 000000000..ea447987f --- /dev/null +++ b/packages/nestjs-cache/src/domain/repositories/cache-repository-resolver.interface.ts @@ -0,0 +1,5 @@ +import { type CacheRepositoryInterface } from './cache-repository.interface.js'; + +export interface CacheRepositoryResolverInterface { + resolve(entityKey: string): CacheRepositoryInterface; +} diff --git a/packages/nestjs-cache/src/domain/repositories/cache-repository.interface.ts b/packages/nestjs-cache/src/domain/repositories/cache-repository.interface.ts new file mode 100644 index 000000000..9a7878828 --- /dev/null +++ b/packages/nestjs-cache/src/domain/repositories/cache-repository.interface.ts @@ -0,0 +1,30 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Cache } from '../aggregates/cache.js'; + +export interface CacheRepositoryInterface { + get(ctx: PlainLiteralObject, id: ReferenceId): Promise; + + findOne( + ctx: PlainLiteralObject, + options: { key: string; type: string; assigneeId: string }, + ): Promise; + + findAllByAssignee( + ctx: PlainLiteralObject, + assigneeId: string, + ): Promise; + + save(ctx: PlainLiteralObject, cache: Cache): Promise; + + remove(ctx: PlainLiteralObject, cache: Cache): Promise; + + removeAllByAssignee( + ctx: PlainLiteralObject, + assigneeId: string, + ): Promise; + + softRemove(ctx: PlainLiteralObject, cache: Cache): Promise; +} diff --git a/packages/nestjs-cache/src/domain/utils/__tests__/get-expiration-date.util.spec.ts b/packages/nestjs-cache/src/domain/utils/__tests__/get-expiration-date.util.spec.ts new file mode 100644 index 000000000..892febec0 --- /dev/null +++ b/packages/nestjs-cache/src/domain/utils/__tests__/get-expiration-date.util.spec.ts @@ -0,0 +1,59 @@ +import { type RuntimeException } from '@concepta/nestjs-core'; + +import { getExpirationDate } from '../get-expiration-date.util.js'; + +describe('getExpirationDate', () => { + it('should return null when expiresIn is null', () => { + expect(getExpirationDate(null)).toBeNull(); + }); + + it('should return null when expiresIn is undefined', () => { + expect(getExpirationDate(undefined)).toBeNull(); + }); + + it('should return null when expiresIn is empty string', () => { + expect(getExpirationDate('')).toBeNull(); + }); + + it('should return a future Date for valid duration string', () => { + const before = Date.now(); + const result = getExpirationDate('1h'); + const after = Date.now(); + + expect(result).toBeInstanceOf(Date); + + const oneHourMs = 60 * 60 * 1000; + expect(result!.getTime()).toBeGreaterThanOrEqual(before + oneHourMs); + expect(result!.getTime()).toBeLessThanOrEqual(after + oneHourMs); + }); + + it('should handle day duration', () => { + const before = Date.now(); + const result = getExpirationDate('2d'); + + const twoDaysMs = 2 * 24 * 60 * 60 * 1000; + expect(result!.getTime()).toBeGreaterThanOrEqual(before + twoDaysMs); + }); + + it('should throw for invalid format', () => { + expect(() => getExpirationDate('invalid')).toThrow(); + }); + + it('should default to a client fault for an unparseable value', () => { + try { + getExpirationDate('invalid'); + throw new Error('Expected a throw'); + } catch (e) { + expect((e as RuntimeException).fault).toBe('client'); + } + }); + + it('should use the fault passed by the caller', () => { + try { + getExpirationDate('invalid', 'usage'); + throw new Error('Expected a throw'); + } catch (e) { + expect((e as RuntimeException).fault).toBe('usage'); + } + }); +}); diff --git a/packages/nestjs-cache/src/domain/utils/get-expiration-date.util.ts b/packages/nestjs-cache/src/domain/utils/get-expiration-date.util.ts new file mode 100644 index 000000000..d25ee03f1 --- /dev/null +++ b/packages/nestjs-cache/src/domain/utils/get-expiration-date.util.ts @@ -0,0 +1,29 @@ +import { + toMilliseconds, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; + +import { CacheInvalidExpiredDateException } from '../exceptions/cache-invalid-expired-date.exception.js'; + +/** + * @param expiresIn - the value to parse; caller decides whether this is a + * client-supplied value or a module-configured default via `fault` + * @param fault - `'client'` if `expiresIn` came from the caller's request, + * `'usage'` if it's a module-configured default that turned out invalid + */ +const getExpirationDate = ( + expiresIn: string | null | undefined, + fault: RuntimeExceptionFault = 'client', +): Date | null => { + if (!expiresIn) return null; + + const now = new Date(); + const expires = toMilliseconds(expiresIn, undefined, fault); + + if (!expires) throw new CacheInvalidExpiredDateException({ fault }); + + // add expiration duration (in ms) to current time + return new Date(now.getTime() + expires); +}; + +export { getExpirationDate }; diff --git a/packages/nestjs-cache/src/dto/cache-create.dto.ts b/packages/nestjs-cache/src/dto/cache-create.dto.ts deleted file mode 100644 index adf59f680..000000000 --- a/packages/nestjs-cache/src/dto/cache-create.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { PickType } from '@nestjs/swagger'; - -import { CacheCreatableInterface } from '@concepta/nestjs-common'; - -import { CacheDto } from './cache.dto'; -/** - * Cache Create DTO - */ -@Exclude() -export class CacheCreateDto - extends PickType(CacheDto, [ - 'key', - 'data', - 'type', - 'expiresIn', - 'assigneeId', - ] as const) - implements CacheCreatableInterface {} diff --git a/packages/nestjs-cache/src/dto/cache-paginated.dto.ts b/packages/nestjs-cache/src/dto/cache-paginated.dto.ts deleted file mode 100644 index 4d168e727..000000000 --- a/packages/nestjs-cache/src/dto/cache-paginated.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CacheInterface } from '@concepta/nestjs-common'; -import { CrudResponsePaginatedDto } from '@concepta/nestjs-crud'; - -import { CacheDto } from './cache.dto'; - -/** - * Cache paginated DTO - */ -@Exclude() -export class CachePaginatedDto extends CrudResponsePaginatedDto { - @Expose() - @ApiProperty({ - type: CacheDto, - isArray: true, - description: 'Array of Caches', - }) - @Type(() => CacheDto) - data: CacheDto[] = []; -} diff --git a/packages/nestjs-cache/src/dto/cache-update.dto.ts b/packages/nestjs-cache/src/dto/cache-update.dto.ts deleted file mode 100644 index 1e1d6b8e9..000000000 --- a/packages/nestjs-cache/src/dto/cache-update.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { PickType } from '@nestjs/swagger'; - -import { CacheUpdatableInterface } from '@concepta/nestjs-common'; - -import { CacheDto } from './cache.dto'; - -/** - * Cache Create DTO - */ -@Exclude() -export class CacheUpdateDto - extends PickType(CacheDto, [ - 'key', - 'type', - 'assigneeId', - 'data', - 'expiresIn', - ] as const) - implements CacheUpdatableInterface {} diff --git a/packages/nestjs-cache/src/dto/cache.dto.ts b/packages/nestjs-cache/src/dto/cache.dto.ts deleted file mode 100644 index fb56cb333..000000000 --- a/packages/nestjs-cache/src/dto/cache.dto.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; -import { Allow, IsNotEmpty, IsOptional, IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CacheInterface, CommonEntityDto } from '@concepta/nestjs-common'; - -/** - * Cache Create DTO - */ -@Exclude() -export class CacheDto extends CommonEntityDto implements CacheInterface { - /** - * key - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'key', - }) - @IsString() - key = ''; - - /** - * data - */ - @Expose() - @IsString() - @ApiProperty({ - type: 'string', - description: 'data', - }) - @IsOptional() - data!: string | null; - - /** - * type - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'type', - }) - @IsString() - type = ''; - - /** - * Expressed in seconds or a string describing a time span [zeit/ms](https://github.com/zeit/ms.js). - * - * Eg: 60, "2 days", "10h", "7d" - */ - @Expose() - @IsString() - @ApiProperty({ - type: 'string', - description: 'type', - examples: ['60', '2 days', '10h', '7d'], - }) - @IsOptional() - expiresIn!: string | null; - - /** - * Assignee - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'assignee id', - }) - @IsString() - @IsNotEmpty() - assigneeId!: string; - - /** - * expirationDate - */ - @Allow() - @Type(() => Date) - @IsOptional() - expirationDate!: Date | null; -} diff --git a/packages/nestjs-cache/src/exceptions/cache-assignment-not-found.exception.spec.ts b/packages/nestjs-cache/src/exceptions/cache-assignment-not-found.exception.spec.ts deleted file mode 100644 index 570048ee9..000000000 --- a/packages/nestjs-cache/src/exceptions/cache-assignment-not-found.exception.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CacheAssignmentNotFoundException } from './cache-assignment-not-found.exception'; - -describe('AssignmentNotFoundException', () => { - it('should create an instance with default message', () => { - const assignmentName = 'testAssignment'; - const exception = new CacheAssignmentNotFoundException(assignmentName); - - expect(exception).toBeInstanceOf(Error); - expect(exception.message).toBe( - 'Assignment testAssignment was not registered to be used.', - ); - expect(exception.context).toEqual({ assignmentName: 'testAssignment' }); - expect(exception.errorCode).toBe('CACHE_ASSIGNMENT_NOT_FOUND_ERROR'); - }); - - it('should create an instance with custom message', () => { - const assignmentName = 'testAssignment'; - const customMessage = 'Custom message for %s'; - const exception = new CacheAssignmentNotFoundException( - assignmentName, - customMessage, - ); - - expect(exception.message).toBe('Custom message for testAssignment'); - }); -}); diff --git a/packages/nestjs-cache/src/exceptions/cache-assignment-not-found.exception.ts b/packages/nestjs-cache/src/exceptions/cache-assignment-not-found.exception.ts deleted file mode 100644 index 4bf0dab5b..000000000 --- a/packages/nestjs-cache/src/exceptions/cache-assignment-not-found.exception.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { RuntimeException } from '@concepta/nestjs-common'; - -import { CacheException } from './cache.exception'; - -export class CacheAssignmentNotFoundException extends CacheException { - context: RuntimeException['context'] & { - assignmentName: string; - }; - - constructor( - assignmentName: string, - message = 'Assignment %s was not registered to be used.', - ) { - super({ - message, - messageParams: [assignmentName], - }); - - this.errorCode = 'CACHE_ASSIGNMENT_NOT_FOUND_ERROR'; - - this.context = { - ...super.context, - assignmentName, - }; - } -} diff --git a/packages/nestjs-cache/src/exceptions/cache-entity-already-exists.exception.spec.ts b/packages/nestjs-cache/src/exceptions/cache-entity-already-exists.exception.spec.ts deleted file mode 100644 index 89abd3621..000000000 --- a/packages/nestjs-cache/src/exceptions/cache-entity-already-exists.exception.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { CacheEntityAlreadyExistsException } from './cache-entity-already-exists.exception'; - -describe(CacheEntityAlreadyExistsException.name, () => { - it('should create an instance of CacheEntityAlreadyExistsException', () => { - const exception = new CacheEntityAlreadyExistsException('TestEntity'); - expect(exception).toBeInstanceOf(CacheEntityAlreadyExistsException); - }); - - it('should have the correct error message', () => { - const exception = new CacheEntityAlreadyExistsException('TestEntity'); - expect(exception.message).toBe( - 'TestEntity already exists with the given key, type, and assignee ID.', - ); - }); - - it('should have the correct context', () => { - const exception = new CacheEntityAlreadyExistsException('TestEntity'); - expect(exception.context).toEqual({ entityName: 'TestEntity' }); - }); - - it('should have the correct error code', () => { - const exception = new CacheEntityAlreadyExistsException('TestEntity'); - expect(exception.errorCode).toBe('CACHE_ENTITY_ALREADY_EXISTS_ERROR'); - }); -}); diff --git a/packages/nestjs-cache/src/exceptions/cache-entity-already-exists.exception.ts b/packages/nestjs-cache/src/exceptions/cache-entity-already-exists.exception.ts deleted file mode 100644 index fc6ec3f6b..000000000 --- a/packages/nestjs-cache/src/exceptions/cache-entity-already-exists.exception.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeException } from '@concepta/nestjs-common'; - -import { CacheException } from './cache.exception'; - -export class CacheEntityAlreadyExistsException extends CacheException { - context: RuntimeException['context'] & { - entityName: string; - }; - - constructor( - entityName: string, - message = '%s already exists with the given key, type, and assignee ID.', - ) { - super({ - httpStatus: HttpStatus.BAD_REQUEST, - message, - messageParams: [entityName], - }); - - this.errorCode = 'CACHE_ENTITY_ALREADY_EXISTS_ERROR'; - - this.context = { - ...super.context, - entityName, - }; - } -} diff --git a/packages/nestjs-cache/src/exceptions/cache-entity-not-found.exception.ts b/packages/nestjs-cache/src/exceptions/cache-entity-not-found.exception.ts deleted file mode 100644 index 0d5e3ad43..000000000 --- a/packages/nestjs-cache/src/exceptions/cache-entity-not-found.exception.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { RuntimeException } from '@concepta/nestjs-common'; - -import { CacheException } from './cache.exception'; - -export class CacheEntityNotFoundException extends CacheException { - context: RuntimeException['context'] & { - entityName: string; - }; - - constructor( - entityName: string, - message = 'Entity %s was not registered to be used.', - ) { - super({ - message, - messageParams: [entityName], - }); - - this.errorCode = 'CACHE_ENTITY_NOT_FOUND_ERROR'; - - this.context = { - ...super.context, - entityName, - }; - } -} diff --git a/packages/nestjs-cache/src/exceptions/cache-invalid-expired-date.exception.ts b/packages/nestjs-cache/src/exceptions/cache-invalid-expired-date.exception.ts deleted file mode 100644 index 6c1354712..000000000 --- a/packages/nestjs-cache/src/exceptions/cache-invalid-expired-date.exception.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { CacheException } from './cache.exception'; - -export class CacheInvalidExpiredDateException extends CacheException { - constructor() { - super({ - message: 'Invalid expiresIn', - }); - this.errorCode = 'CACHE_INVALID_EXPIRES_IN'; - } -} diff --git a/packages/nestjs-cache/src/exceptions/cache-missing-entities-option.exception.ts b/packages/nestjs-cache/src/exceptions/cache-missing-entities-option.exception.ts deleted file mode 100644 index 1cc18d496..000000000 --- a/packages/nestjs-cache/src/exceptions/cache-missing-entities-option.exception.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { CacheException } from './cache.exception'; - -export class CacheMissingEntitiesOptionException extends CacheException { - constructor() { - super({ - message: 'You must provide the entities option', - }); - this.errorCode = 'CACHE_MISSING_ENTITIES_OPTION'; - } -} diff --git a/packages/nestjs-cache/src/gateways/cache-context.overlay.ts b/packages/nestjs-cache/src/gateways/cache-context.overlay.ts new file mode 100644 index 000000000..40df1fab4 --- /dev/null +++ b/packages/nestjs-cache/src/gateways/cache-context.overlay.ts @@ -0,0 +1,42 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { + ContextOverlayInterceptor, + getAppContext, + OverlayRef, +} from '@concepta/nestjs-core'; + +import { + CACHE_NAMESPACE_KEY, + CacheNamespaceOptions, +} from './decorators/cache-namespace.decorator.js'; +import { CacheContextInterface } from './interfaces/cache-context.interface.js'; + +export const CacheCtx = new OverlayRef<'withCache', CacheContextInterface>( + 'withCache', +); + +@Injectable() +export class CacheContextOverlay extends ContextOverlayInterceptor { + readonly ref = CacheCtx; + + constructor(private readonly reflector: Reflector) { + super(); + } + + attach(context: ExecutionContext): void { + const request = context.switchToHttp().getRequest(); + const ctx = getAppContext(request); + const resolved = this.resolve(context); + ctx.defineOverlay(CacheCtx, resolved); + } + + private resolve(context: ExecutionContext): CacheContextInterface { + const options = this.reflector.getAllAndOverride( + CACHE_NAMESPACE_KEY, + [context.getHandler(), context.getClass()], + ); + return { namespace: options?.name ?? '' }; + } +} diff --git a/packages/nestjs-cache/src/gateways/decorators/cache-namespace.decorator.ts b/packages/nestjs-cache/src/gateways/decorators/cache-namespace.decorator.ts new file mode 100644 index 000000000..a4515eb1c --- /dev/null +++ b/packages/nestjs-cache/src/gateways/decorators/cache-namespace.decorator.ts @@ -0,0 +1,10 @@ +import { SetMetadata } from '@nestjs/common'; + +export const CACHE_NAMESPACE_KEY = 'CACHE_NAMESPACE'; + +export interface CacheNamespaceOptions { + name: string; +} + +export const CacheNamespace = (options: CacheNamespaceOptions) => + SetMetadata(CACHE_NAMESPACE_KEY, options); diff --git a/packages/nestjs-cache/src/gateways/http/__tests__/cache-crud.controller.e2e-spec.ts b/packages/nestjs-cache/src/gateways/http/__tests__/cache-crud.controller.e2e-spec.ts new file mode 100644 index 000000000..5477354e8 --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/__tests__/cache-crud.controller.e2e-spec.ts @@ -0,0 +1,455 @@ +import assert from 'assert'; +import { randomUUID } from 'crypto'; + +import supertest from 'supertest'; +import { type MockInstance } from 'vitest'; + +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; + +import { TransactionScope } from '@concepta/nestjs-repository'; +import { SeedingSource } from '@concepta/typeorm-seeding'; + +import { CacheSeederFixture } from '../../../__tests__/fixtures/cache.seeder.fixture.js'; +import { UserCacheEntityFixture } from '../../../__tests__/fixtures/entities/user-cache-entity.fixture.js'; +import { type UserEntityFixture } from '../../../__tests__/fixtures/entities/user-entity.fixture.js'; +import { UserCacheFactoryFixture } from '../../../__tests__/fixtures/factories/user-cache.factory.fixture.js'; +import { UserFactoryFixture } from '../../../__tests__/fixtures/factories/user.factory.fixture.js'; +import { type CacheCreatableInterface } from '../../../domain/interfaces/cache-creatable.interface.js'; +import { CacheFactory } from '../../../infrastructure/persistence/cache.factory.js'; + +import { AppCrudModuleFixture } from './fixtures/app-crud.module.fixture.js'; + +describe('CacheAssignmentController (e2e)', () => { + let app: INestApplication; + let seedingSource: SeedingSource; + let userFactory: UserFactoryFixture; + let userCacheFactory: UserCacheFactoryFixture; + let user: UserEntityFixture; + let txSpy: MockInstance; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppCrudModuleFixture], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + + const txScope = app.get(TransactionScope); + txSpy = vi.spyOn(txScope, 'run'); + + seedingSource = new SeedingSource({ + dataSource: app.get(getDataSourceToken()), + }); + + await seedingSource.initialize(); + + userFactory = new UserFactoryFixture({ seedingSource }); + userCacheFactory = new UserCacheFactoryFixture({ seedingSource }); + + const cacheSeeder = new CacheSeederFixture({ + factories: [new CacheFactory({ entity: UserCacheEntityFixture })], + }); + + await seedingSource.run.one(cacheSeeder); + + user = await userFactory.create(); + }); + + afterEach(async () => { + vi.clearAllMocks(); + return app ? await app.close() : undefined; + }); + + it('GET /cache/user', async () => { + await userCacheFactory + .map((userCache) => { + userCache.assigneeId = user.id; + }) + .createMany(2); + + await supertest(app.getHttpServer()) + .get('/cache/user?limit=2') + .expect(200) + .then((res) => { + assert.strictEqual(res.body.data.length, 2); + }); + }); + + it('GET /cache/user/:id', async () => { + const userCache = await userCacheFactory + .map((userCache) => { + userCache.assigneeId = user.id; + }) + .create(); + + await supertest(app.getHttpServer()) + .get( + `/cache/user/${userCache.id}` + `?filter[0]=key||$eq||${userCache.key}`, + ) + .expect(200) + .then((res) => { + assert.strictEqual(res.body.assigneeId, user.id); + }); + }); + + it('GET /cache/user/ with key and type filters', async () => { + const userCache = await userCacheFactory + .map((userCache) => { + userCache.assigneeId = user.id; + userCache.key = 'specific-key'; + userCache.type = 'specific-type'; + userCache.data = JSON.stringify({ name: 'John Doe' }); + }) + .create(); + + const url = + `/cache/user/` + + `?filter[0]=key||$eq||${userCache.key}` + + `&filter[1]=type||$eq||${userCache.type}`; + // Assuming your endpoint can filter by key and type + await supertest(app.getHttpServer()) + .get(url) + .expect(200) + .then((res) => { + const response = res.body.data[0]; + assert.strictEqual(response.assigneeId, user.id); + assert.strictEqual(response.key, userCache.key); + assert.strictEqual(response.type, userCache.type); + assert.strictEqual(response.data, userCache.data); + }); + }); + + it('POST /cache/user creating user with success', async () => { + const payload: CacheCreatableInterface = { + key: 'dashboard-1', + type: 'filter', + data: '{}', + expiresIn: '1d', + assigneeId: user.id, + }; + + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(201) + .then((res) => { + expect(res.body.key).toBe(payload.key); + expect(res.body.assigneeId).toBe(user.id); + }); + }); + + it('POST /cache/user assignee id null', async () => { + const payload = { + key: 'dashboard-1', + type: 'filter', + data: '{}', + expiresIn: '1d', + assignee: { id: null }, + }; + + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(400); + }); + + it('POST /cache/user Duplicated', async () => { + const payload: CacheCreatableInterface = { + key: 'dashboard-1', + type: 'filter', + data: '{}', + expiresIn: '1d', + assigneeId: user.id, + }; + + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(201) + .then((res) => { + expect(res.body.key).toBe(payload.key); + expect(res.body.assigneeId).toBe(user.id); + }); + }); + + it('POST /cache/user null after create', async () => { + interface ExtendedCacheCreatableInterface extends Pick< + CacheCreatableInterface, + 'key' | 'expiresIn' | 'type' | 'data' + > { + assigneeId: string | null; + } + const payload: ExtendedCacheCreatableInterface = { + key: 'dashboard-1', + type: 'filter', + data: '{}', + expiresIn: '1d', + assigneeId: user.id, + }; + + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(201) + .then((res) => { + expect(res.body.key).toBe(payload.key); + expect(res.body.assigneeId).toBe(user.id); + }); + + payload.data = '{ "name": "John Doe" }'; + payload.expiresIn = null; + payload.assigneeId = null; + + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(400); + + payload.assigneeId = ''; + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(400); + + payload.assigneeId = null; + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(400); + }); + + it('PATCH /cache/user Update', async () => { + const payload: CacheCreatableInterface = { + key: 'dashboard-1', + type: 'filter', + data: '{}', + expiresIn: '1d', + assigneeId: user.id, + }; + + let cacheId = ''; + + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(201) + .then((res) => { + cacheId = res.body.id; + expect(typeof res.body.id).toEqual('string'); + expect(res.body.key).toBe(payload.key); + expect(res.body.assigneeId).toBe(user.id); + }); + + payload.data = '{ "name": "John Doe" }'; + payload.expiresIn = null; + + await supertest(app.getHttpServer()) + .patch(`/cache/user/${cacheId}`) + .send(payload) + .expect(200) + .then((res) => { + expect(res.body.key).toBe(payload.key); + expect(res.body.data).toBe(payload.data); + expect(res.body.assigneeId).toBe(user.id); + }); + + const url = + `/cache/user` + + `?filter[0]=key||$eq||${payload.key}` + + `&filter[1]=type||$eq||${payload.type}` + + `&filter[2]=assigneeId||$eq||${payload.assigneeId}`; + + await supertest(app.getHttpServer()) + .get(url) + .expect(200) + .then((res) => { + const response = res.body.data[0]; + assert.strictEqual(response.assigneeId, user.id); + assert.strictEqual(response.key, payload.key); + assert.strictEqual(response.type, payload.type); + assert.strictEqual(response.data, payload.data); + }); + }); + + it('PATCH /cache/user extending TTL without resending data leaves data unchanged', async () => { + const payload: CacheCreatableInterface = { + key: 'dashboard-1', + type: 'filter', + data: '{"original":true}', + expiresIn: '1d', + assigneeId: user.id, + }; + + let cacheId = ''; + + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(201) + .then((res) => { + cacheId = res.body.id; + }); + + // PATCH with only `expiresIn` — `data` and `key`/`type`/`assigneeId` + // are all omitted, matching a real partial-update request shape. + await supertest(app.getHttpServer()) + .patch(`/cache/user/${cacheId}`) + .send({ expiresIn: '2d' }) + .expect(200) + .then((res) => { + expect(res.body.data).toBe(payload.data); + }); + + await supertest(app.getHttpServer()) + .get(`/cache/user/${cacheId}`) + .expect(200) + .then((res) => { + expect(res.body.data).toBe(payload.data); + }); + }); + + it('PUT /cache/user', async () => { + const payload: CacheCreatableInterface = { + key: 'dashboard-1', + type: 'filter', + data: '{}', + expiresIn: '1d', + assigneeId: user.id, + }; + + const cacheId = randomUUID(); + + // create via PUT (id does not exist yet) + await supertest(app.getHttpServer()) + .put(`/cache/user/${cacheId}`) + .send(payload) + .expect(200) + .then((res) => { + expect(res.body.id).toBe(cacheId); + expect(res.body.key).toBe(payload.key); + expect(res.body.assigneeId).toBe(user.id); + }); + + // replace via PUT (same id, new data) + payload.data = '{ "name": "John Doe" }'; + payload.expiresIn = null; + + await supertest(app.getHttpServer()) + .put(`/cache/user/${cacheId}`) + .send(payload) + .expect(200) + .then((res) => { + expect(res.body.id).toBe(cacheId); + expect(res.body.key).toBe(payload.key); + expect(res.body.data).toBe(payload.data); + expect(res.body.assigneeId).toBe(user.id); + }); + + // verify via GET + const url = + `/cache/user/` + + `?filter[0]=key||$eq||${payload.key}` + + `&filter[1]=type||$eq||${payload.type}` + + `&filter[2]=assigneeId||$eq||${payload.assigneeId}`; + + await supertest(app.getHttpServer()) + .get(url) + .expect(200) + .then((res) => { + const response = res.body.data[0]; + assert.strictEqual(response.assigneeId, user.id); + assert.strictEqual(response.key, payload.key); + assert.strictEqual(response.type, payload.type); + assert.strictEqual(response.data, payload.data); + }); + }); + + it('DELETE /cache/user/:id', async () => { + const userCache = await userCacheFactory + .map((userCache) => { + userCache.assigneeId = user.id; + }) + .create(); + + await supertest(app.getHttpServer()) + .delete(`/cache/user/${userCache.id}`) + .expect(204); + }); + + describe('@Transactional', () => { + it('should use transaction for POST', async () => { + const payload: CacheCreatableInterface = { + key: 'tx-test', + type: 'filter', + data: '{}', + expiresIn: '1d', + assigneeId: user.id, + }; + + await supertest(app.getHttpServer()) + .post('/cache/user') + .send(payload) + .expect(201); + + expect(txSpy).toHaveBeenCalled(); + }); + + it('should use transaction for PATCH', async () => { + const userCache = await userCacheFactory + .map((uc) => { + uc.assigneeId = user.id; + }) + .create(); + + await supertest(app.getHttpServer()) + .patch(`/cache/user/${userCache.id}`) + .send({ + key: userCache.key, + type: userCache.type, + data: '{}', + assigneeId: user.id, + }) + .expect(200); + + expect(txSpy).toHaveBeenCalled(); + }); + + it('should use transaction for DELETE', async () => { + const userCache = await userCacheFactory + .map((uc) => { + uc.assigneeId = user.id; + }) + .create(); + + await supertest(app.getHttpServer()) + .delete(`/cache/user/${userCache.id}`) + .expect(204); + + expect(txSpy).toHaveBeenCalled(); + }); + + it('should NOT use transaction for GET (list)', async () => { + await supertest(app.getHttpServer()) + .get('/cache/user?limit=1') + .expect(200); + + expect(txSpy).not.toHaveBeenCalled(); + }); + + it('should NOT use transaction for GET (read)', async () => { + const userCache = await userCacheFactory + .map((uc) => { + uc.assigneeId = user.id; + }) + .create(); + + await supertest(app.getHttpServer()) + .get(`/cache/user/${userCache.id}`) + .expect(200); + + expect(txSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/nestjs-cache/src/gateways/http/__tests__/cache-crud.swagger.e2e-spec.ts b/packages/nestjs-cache/src/gateways/http/__tests__/cache-crud.swagger.e2e-spec.ts new file mode 100644 index 000000000..b081d57fc --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/__tests__/cache-crud.swagger.e2e-spec.ts @@ -0,0 +1,86 @@ +import { type INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { standardSchemaConverter } from '@concepta/nestjs-core'; + +import { AppCrudModuleFixture } from './fixtures/app-crud.module.fixture.js'; + +describe('CacheAssignmentController swagger (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppCrudModuleFixture], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + return app ? await app.close() : undefined; + }); + + it('registers Cache and CachePaginated as named, $ref-reused components', () => { + const config = new DocumentBuilder() + .setTitle('cache') + .setVersion('1.0') + .build(); + const document = SwaggerModule.createDocument(app, config, { + standardSchemaConverter, + }); + + expect(document.components?.schemas?.Cache).toBeDefined(); + expect(document.components?.schemas?.CachePaginated).toBeDefined(); + + const readResponse = + document.paths?.['/cache/user/{id}']?.get?.responses?.['200']; + const listResponse = + document.paths?.['/cache/user']?.get?.responses?.['200']; + + if (!readResponse || !('content' in readResponse)) { + throw new Error( + 'expected the read response to be a content-bearing response object', + ); + } + if (!listResponse || !('content' in listResponse)) { + throw new Error( + 'expected the list response to be a content-bearing response object', + ); + } + + expect(readResponse.content?.['application/json']?.schema).toEqual({ + $ref: '#/components/schemas/Cache', + }); + expect(listResponse.content?.['application/json']?.schema).toEqual({ + $ref: '#/components/schemas/CachePaginated', + }); + }); + + it('documents the schema-based POST request body inline, since cacheCreateSchema is not a named component (no withNamedComponent)', () => { + const config = new DocumentBuilder() + .setTitle('cache') + .setVersion('1.0') + .build(); + const document = SwaggerModule.createDocument(app, config, { + standardSchemaConverter, + }); + + const createBody = document.paths?.['/cache/user']?.post?.requestBody; + if (!createBody || !('content' in createBody)) { + throw new Error( + 'expected the create request body to be a content-bearing request body object', + ); + } + + const schema = createBody.content?.['application/json']?.schema; + if (!schema || !('type' in schema)) { + throw new Error('expected an inline object schema, not a $ref'); + } + + expect(schema.type).toBe('object'); + expect(schema.properties).toBeDefined(); + // cacheCreateSchema was never passed through withNamedComponent. + expect(document.components?.schemas?.CacheCreate).toBeUndefined(); + }); +}); diff --git a/packages/nestjs-cache/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts b/packages/nestjs-cache/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts new file mode 100644 index 000000000..5373a61b8 --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts @@ -0,0 +1,115 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { CoreModule, Operation } from '@concepta/nestjs-core'; +import { CrudCqrsResolver, CrudModule } from '@concepta/nestjs-crud'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { UserCacheEntityFixture } from '../../../../__tests__/fixtures/entities/user-cache-entity.fixture.js'; +import { UserEntityFixture } from '../../../../__tests__/fixtures/entities/user-entity.fixture.js'; +import { CACHE_MODULE_CACHE_ENTITY_KEY } from '../../../../cache.constants.js'; +import { CacheModule } from '../../../../cache.module.js'; +import { CacheInterface } from '../../../../domain/interfaces/cache.interface.js'; +import { CacheNamespace } from '../../../../gateways/decorators/cache-namespace.decorator.js'; +import { cacheCreateSchema } from '../../../../infrastructure/schemas/cache-create.schema.js'; +import { cachePaginatedSchema } from '../../../../infrastructure/schemas/cache-paginated.schema.js'; +import { cacheUpdateSchema } from '../../../../infrastructure/schemas/cache-update.schema.js'; +import { cacheSchema } from '../../../../infrastructure/schemas/cache.schema.js'; +import { CreateCacheRequestHandler } from '../../commands/handlers/create-cache-request.handler.js'; +import { DeleteCacheRequestHandler } from '../../commands/handlers/delete-cache-request.handler.js'; +import { ReplaceCacheRequestHandler } from '../../commands/handlers/replace-cache-request.handler.js'; +import { UpdateCacheRequestHandler } from '../../commands/handlers/update-cache-request.handler.js'; +import { CreateCacheRequest } from '../../commands/impl/create-cache.request.js'; +import { DeleteCacheRequest } from '../../commands/impl/delete-cache.request.js'; +import { ReplaceCacheRequest } from '../../commands/impl/replace-cache.request.js'; +import { UpdateCacheRequest } from '../../commands/impl/update-cache.request.js'; +import { ListCachesRequestHandler } from '../../queries/handlers/list-caches-request.handler.js'; +import { ReadCacheRequestHandler } from '../../queries/handlers/read-cache-request.handler.js'; +import { ListCachesRequest } from '../../queries/impl/list-caches.request.js'; +import { ReadCacheRequest } from '../../queries/impl/read-cache.request.js'; + +@Module({ + imports: [ + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [UserEntityFixture, UserCacheEntityFixture], + }), + CqrsModule.forRoot(), + RepositoryModule.forRoot({}), + CrudModule.forRoot({ + defaultResolver: CrudCqrsResolver, + }), + CoreModule.forRoot(), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: CACHE_MODULE_CACHE_ENTITY_KEY, entity: UserCacheEntityFixture }, + ], + }), + CacheModule.forRoot({ + settings: { + expiresIn: '1h', + }, + }), + CacheModule.forFeature([CACHE_MODULE_CACHE_ENTITY_KEY]), + CrudModule.forFeature({ + crud: { + controller: { + entity: CACHE_MODULE_CACHE_ENTITY_KEY, + path: 'cache/user', + resolver: CrudCqrsResolver, + transactional: true, + extraDecorators: [ + CacheNamespace({ name: CACHE_MODULE_CACHE_ENTITY_KEY }), + ], + request: { body: cacheCreateSchema }, + response: { + resource: cacheSchema, + paginated: cachePaginatedSchema, + }, + }, + operations: [ + { + operation: Operation.List, + query: ListCachesRequest, + queryHandler: ListCachesRequestHandler, + }, + { + operation: Operation.Read, + query: ReadCacheRequest, + queryHandler: ReadCacheRequestHandler, + }, + { + operation: Operation.Create, + request: { body: cacheCreateSchema }, + command: CreateCacheRequest, + commandHandler: CreateCacheRequestHandler, + }, + { + operation: Operation.Update, + request: { body: cacheUpdateSchema }, + command: UpdateCacheRequest, + commandHandler: UpdateCacheRequestHandler, + }, + { + operation: Operation.Replace, + request: { body: cacheCreateSchema }, + command: ReplaceCacheRequest, + commandHandler: ReplaceCacheRequestHandler, + }, + { + operation: Operation.Delete, + command: DeleteCacheRequest, + commandHandler: DeleteCacheRequestHandler, + }, + ], + }, + }), + ], + providers: [], +}) +export class AppCrudModuleFixture {} diff --git a/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/create-cache-request.handler.spec.ts b/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/create-cache-request.handler.spec.ts new file mode 100644 index 000000000..6d0589fc5 --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/create-cache-request.handler.spec.ts @@ -0,0 +1,50 @@ +import { CrudCreateCommand } from '@concepta/nestjs-crud'; + +import { + createMockCommandBus, + createMockCacheContext, + createMockCacheEntity, + toCacheDomain, +} from '../../../../../__tests__/helpers/mock.helpers.js'; +import { CreateCacheCommand } from '../../../../../application/commands/impl/create-cache.command.js'; +import { type CacheCreatableInterface } from '../../../../../domain/interfaces/cache-creatable.interface.js'; +import { type CacheInterface } from '../../../../../domain/interfaces/cache.interface.js'; +import { CreateCacheRequestHandler } from '../create-cache-request.handler.js'; + +describe(CreateCacheRequestHandler.name, () => { + let commandBus: ReturnType; + let handler: CreateCacheRequestHandler; + + beforeEach(() => { + commandBus = createMockCommandBus(); + handler = new CreateCacheRequestHandler(commandBus as never); + }); + + it('should return a plain object from toPlain()', async () => { + const entity = createMockCacheEntity(); + commandBus.execute.mockResolvedValue(toCacheDomain(entity)); + + const context = createMockCacheContext({ entity: 'UserCache' }) as never; + const dto: CacheCreatableInterface = { + key: 'test-key', + type: 'test-type', + data: 'test-data', + assigneeId: 'test-assignee', + expiresIn: '1h', + }; + + const result = await handler.execute( + new CrudCreateCommand( + context, + dto, + ), + ); + + expect(result.id).toBe('test-id'); + expect(result.key).toBe('test-key'); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(CreateCacheCommand), + ); + }); +}); diff --git a/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/delete-cache-request.handler.spec.ts b/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/delete-cache-request.handler.spec.ts new file mode 100644 index 000000000..b1627dbd6 --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/delete-cache-request.handler.spec.ts @@ -0,0 +1,90 @@ +import { Operation } from '@concepta/nestjs-core'; +import { CrudDeleteCommand } from '@concepta/nestjs-crud'; + +import { + createMockCommandBus, + createMockCacheContext, + createMockCacheEntity, + toCacheDomain, +} from '../../../../../__tests__/helpers/mock.helpers.js'; +import { type CacheInterface } from '../../../../../domain/interfaces/cache.interface.js'; +import { DeleteCacheRequestHandler } from '../delete-cache-request.handler.js'; + +describe(DeleteCacheRequestHandler.name, () => { + let commandBus: ReturnType; + let handler: DeleteCacheRequestHandler; + + beforeEach(() => { + commandBus = createMockCommandBus(); + handler = new DeleteCacheRequestHandler(commandBus as never); + }); + + it('should return null when returnDeleted is false', async () => { + commandBus.execute.mockResolvedValue( + toCacheDomain(createMockCacheEntity()), + ); + + const context = createMockCacheContext({ + entity: 'UserCache', + params: { id: 'test-id' }, + operation: Operation.Delete, + options: { route: { returnDeleted: false } }, + }) as never; + + const result = await handler.execute( + new CrudDeleteCommand(context), + ); + + expect(result).toBeNull(); + }); + + it('should return plain object when returnDeleted is true', async () => { + commandBus.execute.mockResolvedValue( + toCacheDomain(createMockCacheEntity()), + ); + + const context = createMockCacheContext({ + entity: 'UserCache', + params: { id: 'test-id' }, + operation: Operation.Delete, + options: { route: { returnDeleted: true } }, + }) as never; + + const result = await handler.execute( + new CrudDeleteCommand(context), + ); + + expect(result).not.toBeNull(); + expect(result!.id).toBe('test-id'); + }); + + it('should use soft delete when operation is SoftDelete', async () => { + commandBus.execute.mockResolvedValue( + toCacheDomain(createMockCacheEntity()), + ); + + const context = createMockCacheContext({ + entity: 'UserCache', + params: { id: 'test-id' }, + operation: Operation.SoftDelete, + options: { route: { returnDeleted: false } }, + }) as never; + + await handler.execute(new CrudDeleteCommand(context)); + + expect(commandBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw when id is not a string', async () => { + const context = createMockCacheContext({ + entity: 'UserCache', + params: { id: 42 }, + operation: Operation.Delete, + options: { route: { returnDeleted: false } }, + }) as never; + + await expect( + handler.execute(new CrudDeleteCommand(context)), + ).rejects.toThrow(); + }); +}); diff --git a/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/replace-cache-request.handler.spec.ts b/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/replace-cache-request.handler.spec.ts new file mode 100644 index 000000000..ceaea2f0f --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/replace-cache-request.handler.spec.ts @@ -0,0 +1,76 @@ +import { CrudReplaceCommand } from '@concepta/nestjs-crud'; + +import { + createMockCommandBus, + createMockCacheContext, + createMockCacheEntity, + toCacheDomain, +} from '../../../../../__tests__/helpers/mock.helpers.js'; +import { ReplaceCacheCommand } from '../../../../../application/commands/impl/replace-cache.command.js'; +import { type CacheCreatableInterface } from '../../../../../domain/interfaces/cache-creatable.interface.js'; +import { type CacheInterface } from '../../../../../domain/interfaces/cache.interface.js'; +import { ReplaceCacheRequestHandler } from '../replace-cache-request.handler.js'; + +describe(ReplaceCacheRequestHandler.name, () => { + let commandBus: ReturnType; + let handler: ReplaceCacheRequestHandler; + + beforeEach(() => { + commandBus = createMockCommandBus(); + handler = new ReplaceCacheRequestHandler(commandBus as never); + }); + + it('should return a plain object from toPlain()', async () => { + commandBus.execute.mockResolvedValue( + toCacheDomain(createMockCacheEntity({ data: 'replaced-data' })), + ); + + const context = createMockCacheContext({ + entity: 'UserCache', + params: { id: 'test-id' }, + }) as never; + const dto: CacheCreatableInterface = { + key: 'test-key', + type: 'test-type', + data: 'replaced-data', + assigneeId: 'test-assignee', + expiresIn: '1h', + }; + + const result = await handler.execute( + new CrudReplaceCommand( + context, + dto, + ), + ); + + expect(result.data).toBe('replaced-data'); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(ReplaceCacheCommand), + ); + }); + + it('should throw when id is not a string', async () => { + const context = createMockCacheContext({ + entity: 'UserCache', + params: { id: undefined }, + }) as never; + const dto: CacheCreatableInterface = { + key: 'k', + type: 't', + data: 'd', + assigneeId: 'a', + expiresIn: null, + }; + + await expect( + handler.execute( + new CrudReplaceCommand( + context, + dto, + ), + ), + ).rejects.toThrow(); + }); +}); diff --git a/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/update-cache-request.handler.spec.ts b/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/update-cache-request.handler.spec.ts new file mode 100644 index 000000000..ef2531ea0 --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/handlers/__tests__/update-cache-request.handler.spec.ts @@ -0,0 +1,70 @@ +import { CrudUpdateCommand } from '@concepta/nestjs-crud'; + +import { + createMockCommandBus, + createMockCacheContext, + createMockCacheEntity, + toCacheDomain, +} from '../../../../../__tests__/helpers/mock.helpers.js'; +import { UpdateCacheCommand } from '../../../../../application/commands/impl/update-cache.command.js'; +import { type CacheUpdatableInterface } from '../../../../../domain/interfaces/cache-updatable.interface.js'; +import { type CacheInterface } from '../../../../../domain/interfaces/cache.interface.js'; +import { UpdateCacheRequestHandler } from '../update-cache-request.handler.js'; + +describe(UpdateCacheRequestHandler.name, () => { + let commandBus: ReturnType; + let handler: UpdateCacheRequestHandler; + + beforeEach(() => { + commandBus = createMockCommandBus(); + handler = new UpdateCacheRequestHandler(commandBus as never); + }); + + it('should return a plain object from toPlain()', async () => { + commandBus.execute.mockResolvedValue( + toCacheDomain(createMockCacheEntity({ data: 'updated-data' })), + ); + + const context = createMockCacheContext({ + entity: 'UserCache', + params: { id: 'test-id' }, + }) as never; + const dto: CacheUpdatableInterface = { + data: 'updated-data', + expiresIn: null, + }; + + const result = await handler.execute( + new CrudUpdateCommand( + context, + dto, + ), + ); + + expect(result.data).toBe('updated-data'); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(UpdateCacheCommand), + ); + }); + + it('should throw when id is not a string', async () => { + const context = createMockCacheContext({ + entity: 'UserCache', + params: { id: 123 }, + }) as never; + const dto: CacheUpdatableInterface = { + data: 'd', + expiresIn: null, + }; + + await expect( + handler.execute( + new CrudUpdateCommand( + context, + dto, + ), + ), + ).rejects.toThrow(); + }); +}); diff --git a/packages/nestjs-cache/src/gateways/http/commands/handlers/create-cache-request.handler.ts b/packages/nestjs-cache/src/gateways/http/commands/handlers/create-cache-request.handler.ts new file mode 100644 index 000000000..4b3bc81c5 --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/handlers/create-cache-request.handler.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { CreateCacheCommand } from '../../../../application/commands/impl/create-cache.command.js'; +import { CreateCacheRequest } from '../impl/create-cache.request.js'; + +@Injectable() +export class CreateCacheRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: CreateCacheRequest) { + const { context, dto } = command; + const { namespace } = context.withCache(); + const cache = await this.commandBus.execute( + new CreateCacheCommand(context, namespace, dto), + ); + return cache.toPlain(); + } +} diff --git a/packages/nestjs-cache/src/gateways/http/commands/handlers/delete-cache-request.handler.ts b/packages/nestjs-cache/src/gateways/http/commands/handlers/delete-cache-request.handler.ts new file mode 100644 index 000000000..e836d0fab --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/handlers/delete-cache-request.handler.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { Operation } from '@concepta/nestjs-core'; + +import { ArchiveCacheCommand } from '../../../../application/commands/impl/archive-cache.command.js'; +import { RemoveCacheCommand } from '../../../../application/commands/impl/remove-cache.command.js'; +import { assertCacheId } from '../../../../application/utils/assert-cache-id.util.js'; +import { Cache } from '../../../../domain/aggregates/cache.js'; +import { DeleteCacheRequest } from '../impl/delete-cache.request.js'; + +@Injectable() +export class DeleteCacheRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: DeleteCacheRequest) { + const { context } = command; + const { id } = context.params; + const { returnDeleted = false } = context.options?.route ?? {}; + + assertCacheId(id); + + const { namespace } = context.withCache(); + let cache: Cache; + + if (context.operation === Operation.SoftDelete) { + cache = await this.commandBus.execute( + new ArchiveCacheCommand(context, namespace, id), + ); + } else { + cache = await this.commandBus.execute( + new RemoveCacheCommand(context, namespace, id), + ); + } + + return returnDeleted ? cache.toPlain() : null; + } +} diff --git a/packages/nestjs-cache/src/gateways/http/commands/handlers/replace-cache-request.handler.ts b/packages/nestjs-cache/src/gateways/http/commands/handlers/replace-cache-request.handler.ts new file mode 100644 index 000000000..5b3c69bfc --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/handlers/replace-cache-request.handler.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { ReplaceCacheCommand } from '../../../../application/commands/impl/replace-cache.command.js'; +import { assertCacheId } from '../../../../application/utils/assert-cache-id.util.js'; +import { ReplaceCacheRequest } from '../impl/replace-cache.request.js'; + +@Injectable() +export class ReplaceCacheRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: ReplaceCacheRequest) { + const { context, dto } = command; + const { id } = context.params; + + assertCacheId(id); + + const { namespace } = context.withCache(); + const cache = await this.commandBus.execute( + new ReplaceCacheCommand(context, namespace, id, dto), + ); + return cache.toPlain(); + } +} diff --git a/packages/nestjs-cache/src/gateways/http/commands/handlers/update-cache-request.handler.ts b/packages/nestjs-cache/src/gateways/http/commands/handlers/update-cache-request.handler.ts new file mode 100644 index 000000000..786f04d63 --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/handlers/update-cache-request.handler.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { UpdateCacheCommand } from '../../../../application/commands/impl/update-cache.command.js'; +import { assertCacheId } from '../../../../application/utils/assert-cache-id.util.js'; +import { UpdateCacheRequest } from '../impl/update-cache.request.js'; + +@Injectable() +export class UpdateCacheRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: UpdateCacheRequest) { + const { context, dto } = command; + const { id } = context.params; + + assertCacheId(id); + + const { namespace } = context.withCache(); + const cache = await this.commandBus.execute( + new UpdateCacheCommand(context, namespace, id, dto), + ); + return cache.toPlain(); + } +} diff --git a/packages/nestjs-cache/src/gateways/http/commands/impl/create-cache.request.ts b/packages/nestjs-cache/src/gateways/http/commands/impl/create-cache.request.ts new file mode 100644 index 000000000..eaf249e27 --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/impl/create-cache.request.ts @@ -0,0 +1,9 @@ +import { CrudCreateCommand } from '@concepta/nestjs-crud'; + +import { type CacheCreatableInterface } from '../../../../domain/interfaces/cache-creatable.interface.js'; +import { type CacheInterface } from '../../../../domain/interfaces/cache.interface.js'; + +export class CreateCacheRequest extends CrudCreateCommand< + CacheInterface, + CacheCreatableInterface +> {} diff --git a/packages/nestjs-cache/src/gateways/http/commands/impl/delete-cache.request.ts b/packages/nestjs-cache/src/gateways/http/commands/impl/delete-cache.request.ts new file mode 100644 index 000000000..394c8665a --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/impl/delete-cache.request.ts @@ -0,0 +1,5 @@ +import { CrudDeleteCommand } from '@concepta/nestjs-crud'; + +import { type CacheInterface } from '../../../../domain/interfaces/cache.interface.js'; + +export class DeleteCacheRequest extends CrudDeleteCommand {} diff --git a/packages/nestjs-cache/src/gateways/http/commands/impl/replace-cache.request.ts b/packages/nestjs-cache/src/gateways/http/commands/impl/replace-cache.request.ts new file mode 100644 index 000000000..9a760446e --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/impl/replace-cache.request.ts @@ -0,0 +1,9 @@ +import { CrudReplaceCommand } from '@concepta/nestjs-crud'; + +import { type CacheCreatableInterface } from '../../../../domain/interfaces/cache-creatable.interface.js'; +import { type CacheInterface } from '../../../../domain/interfaces/cache.interface.js'; + +export class ReplaceCacheRequest extends CrudReplaceCommand< + CacheInterface, + CacheCreatableInterface +> {} diff --git a/packages/nestjs-cache/src/gateways/http/commands/impl/update-cache.request.ts b/packages/nestjs-cache/src/gateways/http/commands/impl/update-cache.request.ts new file mode 100644 index 000000000..04d84afab --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/commands/impl/update-cache.request.ts @@ -0,0 +1,9 @@ +import { CrudUpdateCommand } from '@concepta/nestjs-crud'; + +import { type CacheUpdatableInterface } from '../../../../domain/interfaces/cache-updatable.interface.js'; +import { type CacheInterface } from '../../../../domain/interfaces/cache.interface.js'; + +export class UpdateCacheRequest extends CrudUpdateCommand< + CacheInterface, + CacheUpdatableInterface +> {} diff --git a/packages/nestjs-cache/src/gateways/http/queries/handlers/list-caches-request.handler.ts b/packages/nestjs-cache/src/gateways/http/queries/handlers/list-caches-request.handler.ts new file mode 100644 index 000000000..cd6b24c3e --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/queries/handlers/list-caches-request.handler.ts @@ -0,0 +1,5 @@ +import { CrudListHandler } from '@concepta/nestjs-crud'; + +import { type CacheInterface } from '../../../../domain/interfaces/cache.interface.js'; + +export class ListCachesRequestHandler extends CrudListHandler {} diff --git a/packages/nestjs-cache/src/gateways/http/queries/handlers/read-cache-request.handler.ts b/packages/nestjs-cache/src/gateways/http/queries/handlers/read-cache-request.handler.ts new file mode 100644 index 000000000..97236065b --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/queries/handlers/read-cache-request.handler.ts @@ -0,0 +1,5 @@ +import { CrudReadHandler } from '@concepta/nestjs-crud'; + +import { type CacheInterface } from '../../../../domain/interfaces/cache.interface.js'; + +export class ReadCacheRequestHandler extends CrudReadHandler {} diff --git a/packages/nestjs-cache/src/gateways/http/queries/impl/list-caches.request.ts b/packages/nestjs-cache/src/gateways/http/queries/impl/list-caches.request.ts new file mode 100644 index 000000000..b5d5e1876 --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/queries/impl/list-caches.request.ts @@ -0,0 +1,5 @@ +import { CrudListQuery } from '@concepta/nestjs-crud'; + +import { type CacheInterface } from '../../../../domain/interfaces/cache.interface.js'; + +export class ListCachesRequest extends CrudListQuery {} diff --git a/packages/nestjs-cache/src/gateways/http/queries/impl/read-cache.request.ts b/packages/nestjs-cache/src/gateways/http/queries/impl/read-cache.request.ts new file mode 100644 index 000000000..aef1f886b --- /dev/null +++ b/packages/nestjs-cache/src/gateways/http/queries/impl/read-cache.request.ts @@ -0,0 +1,5 @@ +import { CrudReadQuery } from '@concepta/nestjs-crud'; + +import { type CacheInterface } from '../../../../domain/interfaces/cache.interface.js'; + +export class ReadCacheRequest extends CrudReadQuery {} diff --git a/packages/nestjs-cache/src/gateways/interfaces/cache-context.interface.ts b/packages/nestjs-cache/src/gateways/interfaces/cache-context.interface.ts new file mode 100644 index 000000000..ba59be6bc --- /dev/null +++ b/packages/nestjs-cache/src/gateways/interfaces/cache-context.interface.ts @@ -0,0 +1,3 @@ +export interface CacheContextInterface { + namespace: string; +} diff --git a/packages/nestjs-cache/src/index.spec.ts b/packages/nestjs-cache/src/index.spec.ts deleted file mode 100644 index 53ddf8c44..000000000 --- a/packages/nestjs-cache/src/index.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { CacheModule, CacheService, CacheCreateDto } from './index'; - -describe('index', () => { - it('should be an instance of Function', () => { - expect(CacheModule).toBeInstanceOf(Function); - }); - - it('should be an instance of Function', () => { - expect(CacheService).toBeInstanceOf(Function); - }); - - it('should be an instance of Function', () => { - expect(CacheCreateDto).toBeInstanceOf(Function); - }); -}); diff --git a/packages/nestjs-cache/src/index.ts b/packages/nestjs-cache/src/index.ts index e44362f0a..f029ee6f3 100644 --- a/packages/nestjs-cache/src/index.ts +++ b/packages/nestjs-cache/src/index.ts @@ -1,14 +1,76 @@ -export { CacheModule } from './cache.module'; +export { CacheModule } from './cache.module.js'; -export { CacheService } from './services/cache.service'; -export { CacheCreateDto } from './dto/cache-create.dto'; -export { CacheUpdateDto } from './dto/cache-update.dto'; -export { CacheDto } from './dto/cache.dto'; +// domain interfaces +export { CacheInterface } from './domain/interfaces/cache.interface.js'; +export { CacheCreatableInterface } from './domain/interfaces/cache-creatable.interface.js'; +export { CacheUpdatableInterface } from './domain/interfaces/cache-updatable.interface.js'; + +// domain object +export { Cache } from './domain/aggregates/cache.js'; + +// policies +export { + CacheExpirationPolicy, + CacheExpirationSettings, +} from './domain/policies/cache-expiration.policy.js'; + +// repositories +export { CacheRepository } from './infrastructure/persistence/cache.repository.js'; +export { CacheRepositoryResolver } from './infrastructure/persistence/cache-repository.resolver.js'; +export { CacheRepositoryInterface } from './domain/repositories/cache-repository.interface.js'; +export { CacheRepositoryResolverInterface } from './domain/repositories/cache-repository-resolver.interface.js'; + +// interfaces +export { CacheExtrasInterface } from './infrastructure/config/interfaces/cache-extras.interface.js'; + +// schemas (Zod / Standard Schema) +export { cacheCreateSchema } from './infrastructure/schemas/cache-create.schema.js'; +export { cacheUpdateSchema } from './infrastructure/schemas/cache-update.schema.js'; +export { cacheSchema } from './infrastructure/schemas/cache.schema.js'; + +// domain commands +export { UpsertCacheCommand } from './application/commands/impl/upsert-cache.command.js'; +export { ClearCachesByAssigneeCommand } from './application/commands/impl/clear-caches-by-assignee.command.js'; +export { CreateCacheCommand } from './application/commands/impl/create-cache.command.js'; +export { UpdateCacheCommand } from './application/commands/impl/update-cache.command.js'; +export { RemoveCacheCommand } from './application/commands/impl/remove-cache.command.js'; +export { ReplaceCacheCommand } from './application/commands/impl/replace-cache.command.js'; +export { ArchiveCacheCommand } from './application/commands/impl/archive-cache.command.js'; + +// domain events +export { CacheCreatedEvent } from './domain/events/cache-created.event.js'; +export { CacheUpdatedEvent } from './domain/events/cache-updated.event.js'; +export { CacheReplacedEvent } from './domain/events/cache-replaced.event.js'; +export { CacheExtendedEvent } from './domain/events/cache-extended.event.js'; + +// domain queries +export { GetCacheQuery } from './application/queries/impl/get-cache.query.js'; +export { FindOneCacheQuery } from './application/queries/impl/find-one-cache.query.js'; +export { FindCachesByAssigneeQuery } from './application/queries/impl/find-caches-by-assignee.query.js'; + +// domain handlers +export { UpsertCacheHandler } from './application/commands/handlers/upsert-cache.handler.js'; +export { ClearCachesByAssigneeHandler } from './application/commands/handlers/clear-caches-by-assignee.handler.js'; +export { CreateCacheHandler } from './application/commands/handlers/create-cache.handler.js'; +export { UpdateCacheHandler } from './application/commands/handlers/update-cache.handler.js'; +export { RemoveCacheHandler } from './application/commands/handlers/remove-cache.handler.js'; +export { ReplaceCacheHandler } from './application/commands/handlers/replace-cache.handler.js'; +export { ArchiveCacheHandler } from './application/commands/handlers/archive-cache.handler.js'; +export { GetCacheHandler } from './application/queries/handlers/get-cache.handler.js'; +export { FindOneCacheHandler } from './application/queries/handlers/find-one-cache.handler.js'; +export { FindCachesByAssigneeHandler } from './application/queries/handlers/find-caches-by-assignee.handler.js'; + +// context overlay +export { + CacheContextOverlay, + CacheCtx, +} from './gateways/cache-context.overlay.js'; + +// decorators +export { CacheNamespace } from './gateways/decorators/cache-namespace.decorator.js'; // exceptions -export { CacheException } from './exceptions/cache.exception'; -export { CacheAssignmentNotFoundException } from './exceptions/cache-assignment-not-found.exception'; -export { CacheEntityAlreadyExistsException } from './exceptions/cache-entity-already-exists.exception'; -export { CacheEntityNotFoundException } from './exceptions/cache-entity-not-found.exception'; -export { CacheInvalidExpiredDateException } from './exceptions/cache-invalid-expired-date.exception'; -export { CacheMissingEntitiesOptionException } from './exceptions/cache-missing-entities-option.exception'; +export { CacheException } from './domain/exceptions/cache.exception.js'; +export { CacheEntityNotFoundException } from './infrastructure/exceptions/cache-entity-not-found.exception.js'; +export { CacheInvalidExpiredDateException } from './domain/exceptions/cache-invalid-expired-date.exception.js'; +export { CacheNotFoundException } from './application/exceptions/cache-not-found.exception.js'; diff --git a/packages/nestjs-cache/src/infrastructure/config/cache-default.config.ts b/packages/nestjs-cache/src/infrastructure/config/cache-default.config.ts new file mode 100644 index 000000000..721234f26 --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/config/cache-default.config.ts @@ -0,0 +1,15 @@ +import { registerAs } from '@nestjs/config'; + +import { CACHE_MODULE_DEFAULT_SETTINGS_TOKEN } from '../../cache.constants.js'; + +import { type CacheSettingsInterface } from './interfaces/cache-settings.interface.js'; + +/** + * Default configuration for Cache module. + */ +export const cacheDefaultConfig = registerAs( + CACHE_MODULE_DEFAULT_SETTINGS_TOKEN, + (): Partial => ({ + expiresIn: process.env.CACHE_EXPIRE_IN ?? null, + }), +); diff --git a/packages/nestjs-cache/src/infrastructure/config/interfaces/cache-extras.interface.ts b/packages/nestjs-cache/src/infrastructure/config/interfaces/cache-extras.interface.ts new file mode 100644 index 000000000..c55c533b2 --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/config/interfaces/cache-extras.interface.ts @@ -0,0 +1,10 @@ +import { type DynamicModule, type Provider, type Type } from '@nestjs/common'; + +import { type CacheRepositoryInterface } from '../../../domain/repositories/cache-repository.interface.js'; + +export interface CacheExtrasInterface extends Pick { + providers?: Provider[]; + repositories?: { + cache?: Type; + }; +} diff --git a/packages/nestjs-cache/src/infrastructure/config/interfaces/cache-options.interface.ts b/packages/nestjs-cache/src/infrastructure/config/interfaces/cache-options.interface.ts new file mode 100644 index 000000000..6f274fff8 --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/config/interfaces/cache-options.interface.ts @@ -0,0 +1,5 @@ +import { type CacheSettingsInterface } from './cache-settings.interface.js'; + +export interface CacheOptionsInterface { + settings?: CacheSettingsInterface; +} diff --git a/packages/nestjs-cache/src/infrastructure/config/interfaces/cache-settings.interface.ts b/packages/nestjs-cache/src/infrastructure/config/interfaces/cache-settings.interface.ts new file mode 100644 index 000000000..e5120935d --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/config/interfaces/cache-settings.interface.ts @@ -0,0 +1,3 @@ +export interface CacheSettingsInterface { + expiresIn?: string | undefined | null; +} diff --git a/packages/nestjs-cache/src/exceptions/cache-entity-not-found.exception.spec.ts b/packages/nestjs-cache/src/infrastructure/exceptions/__tests__/cache-entity-not-found.exception.spec.ts similarity index 91% rename from packages/nestjs-cache/src/exceptions/cache-entity-not-found.exception.spec.ts rename to packages/nestjs-cache/src/infrastructure/exceptions/__tests__/cache-entity-not-found.exception.spec.ts index 1ccc61042..5cf3af5d5 100644 --- a/packages/nestjs-cache/src/exceptions/cache-entity-not-found.exception.spec.ts +++ b/packages/nestjs-cache/src/infrastructure/exceptions/__tests__/cache-entity-not-found.exception.spec.ts @@ -1,4 +1,4 @@ -import { CacheEntityNotFoundException } from './cache-entity-not-found.exception'; +import { CacheEntityNotFoundException } from '../cache-entity-not-found.exception.js'; describe(CacheEntityNotFoundException.name, () => { it('should create an instance of EntityNotFoundException', () => { diff --git a/packages/nestjs-cache/src/infrastructure/exceptions/cache-entity-not-found.exception.ts b/packages/nestjs-cache/src/infrastructure/exceptions/cache-entity-not-found.exception.ts new file mode 100644 index 000000000..d7982132b --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/exceptions/cache-entity-not-found.exception.ts @@ -0,0 +1,28 @@ +import { + type RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +import { CacheException } from '../../domain/exceptions/cache.exception.js'; + +export class CacheEntityNotFoundException extends CacheException { + declare context: RuntimeException['context'] & { + entityName: string; + }; + + constructor(entityName: string, options?: RuntimeExceptionOptions) { + super({ + message: 'Entity %s was not registered to be used.', + messageParams: [entityName], + fault: 'usage', + ...options, + }); + + this.errorCode = 'CACHE_ENTITY_NOT_FOUND_ERROR'; + + this.context = { + ...this.context, + entityName, + }; + } +} diff --git a/packages/nestjs-cache/src/infrastructure/persistence/__tests__/cache-repository.resolver.spec.ts b/packages/nestjs-cache/src/infrastructure/persistence/__tests__/cache-repository.resolver.spec.ts new file mode 100644 index 000000000..4537e3f9a --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/persistence/__tests__/cache-repository.resolver.spec.ts @@ -0,0 +1,37 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { type ModuleRef } from '@nestjs/core'; + +import { CacheEntityNotFoundException } from '../../exceptions/cache-entity-not-found.exception.js'; +import { CacheRepositoryResolver } from '../cache-repository.resolver.js'; +import { type CacheRepository } from '../cache.repository.js'; + +describe(CacheRepositoryResolver.name, () => { + let resolver: CacheRepositoryResolver; + let mockModuleRef: DeepMockProxy; + + beforeEach(() => { + mockModuleRef = mockDeep(); + + resolver = new CacheRepositoryResolver(mockModuleRef); + }); + + it('should return a CacheRepository when found', () => { + const mockRepo = {} as CacheRepository; + mockModuleRef.get.mockReturnValue(mockRepo); + + const result = resolver.resolve('UserCache'); + + expect(result).toBe(mockRepo); + }); + + it('should throw CacheEntityNotFoundException when not found', () => { + mockModuleRef.get.mockImplementation(() => { + throw new Error('not found'); + }); + + expect(() => resolver.resolve('Missing')).toThrow( + CacheEntityNotFoundException, + ); + }); +}); diff --git a/packages/nestjs-cache/src/infrastructure/persistence/__tests__/cache.repository.spec.ts b/packages/nestjs-cache/src/infrastructure/persistence/__tests__/cache.repository.spec.ts new file mode 100644 index 000000000..96c44fcde --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/persistence/__tests__/cache.repository.spec.ts @@ -0,0 +1,197 @@ +import { AppContextHost } from '@concepta/nestjs-core'; +import { Where } from '@concepta/nestjs-repository'; +import { createMockRepository } from '@concepta/nestjs-repository/testing'; + +import { toCacheDomain } from '../../../__tests__/helpers/mock.helpers.js'; +import { Cache } from '../../../domain/aggregates/cache.js'; +import { CacheMapper } from '../cache.mapper.js'; +import { CacheRepository } from '../cache.repository.js'; +import { type CacheEntityInterface } from '../interfaces/cache-entity.interface.js'; + +const mapper = new CacheMapper(); + +const mockEntity: CacheEntityInterface = { + id: 'test-id', + key: 'test-key', + type: 'test-type', + assigneeId: 'test-assignee', + data: 'test-data', + expirationDate: new Date('2027-01-01'), + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, +}; + +describe(CacheRepository.name, () => { + let repo: CacheRepository; + let mockRepoInterface: ReturnType< + typeof createMockRepository + >; + const w = Where.for(); + const ctx = new AppContextHost(); + + beforeEach(() => { + mockRepoInterface = createMockRepository(); + repo = new CacheRepository(mockRepoInterface, new CacheMapper()); + }); + + describe('get', () => { + it('should query by id and return a Cache', async () => { + mockRepoInterface.findOne.mockResolvedValue(mockEntity); + + const result = await repo.get(ctx, 'test-id'); + + expect(result).toBeInstanceOf(Cache); + expect(result!.id).toBe('test-id'); + expect(mockRepoInterface.findOne).toHaveBeenCalledWith({ + where: w.eq('id', 'test-id'), + ctx, + }); + }); + + it('should return null when entity is not found', async () => { + mockRepoInterface.findOne.mockResolvedValue(null); + + const result = await repo.get(ctx, 'missing'); + + expect(result).toBeNull(); + }); + }); + + describe('findOne', () => { + it('should query by key, type, and assigneeId', async () => { + mockRepoInterface.findOne.mockResolvedValue(mockEntity); + + const result = await repo.findOne(ctx, { + key: 'test-key', + type: 'test-type', + assigneeId: 'test-assignee', + }); + + expect(result).toBeInstanceOf(Cache); + expect(result!.key).toBe('test-key'); + expect(mockRepoInterface.findOne).toHaveBeenCalledWith({ + where: w.and( + w.eq('key', 'test-key'), + w.eq('type', 'test-type'), + w.eq('assigneeId', 'test-assignee'), + ), + ctx, + }); + }); + + it('should return null when no entity matches', async () => { + mockRepoInterface.findOne.mockResolvedValue(null); + + const result = await repo.findOne(ctx, { + key: 'no-match', + type: 'no-match', + assigneeId: 'no-match', + }); + + expect(result).toBeNull(); + }); + }); + + describe('findAllByAssignee', () => { + it('should query by assigneeId', async () => { + mockRepoInterface.find.mockResolvedValue([mockEntity, mockEntity]); + + const result = await repo.findAllByAssignee(ctx, 'test-assignee'); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(Cache); + expect(mockRepoInterface.find).toHaveBeenCalledWith({ + where: w.eq('assigneeId', 'test-assignee'), + ctx, + }); + }); + + it('should return empty array when no matches', async () => { + mockRepoInterface.find.mockResolvedValue([]); + + const result = await repo.findAllByAssignee(ctx, 'no-match'); + + expect(result).toEqual([]); + }); + }); + + describe('save', () => { + it('should stamp and upsert the plain entity', async () => { + mockRepoInterface.upsert.mockResolvedValue(mockEntity); + + const cache = toCacheDomain(mockEntity); + const stampSpy = vi.spyOn(cache, 'stampUpdated'); + + await repo.save(ctx, cache); + + expect(stampSpy).toHaveBeenCalledTimes(1); + expect(mockRepoInterface.upsert).toHaveBeenCalledWith( + mapper.toPersistence(cache), + { ctx }, + ); + }); + }); + + describe('remove', () => { + it('should delete the plain entity', async () => { + mockRepoInterface.delete.mockResolvedValue(undefined as never); + + const cache = toCacheDomain(mockEntity); + await repo.remove(ctx, cache); + + expect(mockRepoInterface.delete).toHaveBeenCalledWith( + mapper.toPersistence(cache), + { ctx }, + ); + }); + }); + + describe('removeAllByAssignee', () => { + it('should find and delete all caches for assignee in a single batch', async () => { + mockRepoInterface.find.mockResolvedValue([mockEntity]); + mockRepoInterface.deleteMany.mockResolvedValue([mockEntity]); + + await repo.removeAllByAssignee(ctx, 'test-assignee'); + + expect(mockRepoInterface.find).toHaveBeenCalledWith({ + where: w.eq('assigneeId', 'test-assignee'), + ctx, + }); + const expectedPersistence = mapper.toPersistence( + toCacheDomain(mockEntity), + ); + expect(mockRepoInterface.deleteMany).toHaveBeenCalledWith( + [expectedPersistence], + { ctx }, + ); + }); + + it('should call deleteMany with empty array when no caches found', async () => { + mockRepoInterface.find.mockResolvedValue([]); + mockRepoInterface.deleteMany.mockResolvedValue([]); + + await repo.removeAllByAssignee(ctx, 'none'); + + expect(mockRepoInterface.deleteMany).toHaveBeenCalledWith([], { ctx }); + }); + }); + + describe('softRemove', () => { + it('should stamp deleted and soft delete the plain entity', async () => { + mockRepoInterface.softDelete.mockResolvedValue(undefined as never); + + const cache = toCacheDomain(mockEntity); + const stampSpy = vi.spyOn(cache, 'stampDeleted'); + + await repo.softRemove(ctx, cache); + + expect(stampSpy).toHaveBeenCalledTimes(1); + expect(mockRepoInterface.softDelete).toHaveBeenCalledWith( + mapper.toPersistence(cache), + { ctx }, + ); + }); + }); +}); diff --git a/packages/nestjs-cache/src/infrastructure/persistence/cache-repository.resolver.ts b/packages/nestjs-cache/src/infrastructure/persistence/cache-repository.resolver.ts new file mode 100644 index 000000000..43fd1ba81 --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/persistence/cache-repository.resolver.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; + +import { CacheRepositoryResolverInterface } from '../../domain/repositories/cache-repository-resolver.interface.js'; +import { CacheRepositoryInterface } from '../../domain/repositories/cache-repository.interface.js'; +import { CacheEntityNotFoundException } from '../exceptions/cache-entity-not-found.exception.js'; +import { getDynamicCacheRepositoryToken } from '../utils/create-cache-repository-provider.js'; + +@Injectable() +export class CacheRepositoryResolver implements CacheRepositoryResolverInterface { + constructor(private readonly moduleRef: ModuleRef) {} + + resolve(entityKey: string): CacheRepositoryInterface { + const token = getDynamicCacheRepositoryToken(entityKey); + + try { + return this.moduleRef.get(token, { + strict: false, + }); + } catch (error) { + throw new CacheEntityNotFoundException(entityKey, { + originalError: error, + }); + } + } +} diff --git a/packages/nestjs-cache/src/cache.factory.ts b/packages/nestjs-cache/src/infrastructure/persistence/cache.factory.ts similarity index 80% rename from packages/nestjs-cache/src/cache.factory.ts rename to packages/nestjs-cache/src/infrastructure/persistence/cache.factory.ts index 4b820082f..d4a76eb0a 100644 --- a/packages/nestjs-cache/src/cache.factory.ts +++ b/packages/nestjs-cache/src/infrastructure/persistence/cache.factory.ts @@ -2,9 +2,10 @@ import { randomUUID } from 'crypto'; import { faker } from '@faker-js/faker'; -import { CacheInterface } from '@concepta/nestjs-common'; import { Factory } from '@concepta/typeorm-seeding'; +import { type CacheInterface } from '../../domain/interfaces/cache.interface.js'; + /** * Cache factory */ @@ -37,10 +38,6 @@ export class CacheFactory extends Factory { * Get a random category. */ protected randomKey(): string { - // random index - const randomIdx = Math.floor(Math.random() * this.keys.length); - - // return it - return this.keys[randomIdx]; + return faker.helpers.arrayElement(this.keys); } } diff --git a/packages/nestjs-cache/src/infrastructure/persistence/cache.mapper.ts b/packages/nestjs-cache/src/infrastructure/persistence/cache.mapper.ts new file mode 100644 index 000000000..98a0644bf --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/persistence/cache.mapper.ts @@ -0,0 +1,23 @@ +import { DomainMapper } from '@concepta/nestjs-core/aggregate'; + +import { Cache } from '../../domain/aggregates/cache.js'; +import { type CacheInterface } from '../../domain/interfaces/cache.interface.js'; + +import { type CacheEntityInterface } from './interfaces/cache-entity.interface.js'; + +export class CacheMapper extends DomainMapper< + CacheEntityInterface, + CacheInterface, + Cache +> { + createAggregate(entity: CacheEntityInterface): Cache { + const { id, version, dateCreated, dateUpdated, dateDeleted, ...props } = + entity; + + return new Cache(id, props, version, { + dateCreated, + dateUpdated, + dateDeleted, + }); + } +} diff --git a/packages/nestjs-cache/src/infrastructure/persistence/cache.repository.ts b/packages/nestjs-cache/src/infrastructure/persistence/cache.repository.ts new file mode 100644 index 000000000..e231fc726 --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/persistence/cache.repository.ts @@ -0,0 +1,87 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; +import { type RepositoryInterface, Where } from '@concepta/nestjs-repository'; + +import { type Cache } from '../../domain/aggregates/cache.js'; +import { type CacheRepositoryInterface } from '../../domain/repositories/cache-repository.interface.js'; + +import { type CacheMapper } from './cache.mapper.js'; +import { type CacheEntityInterface } from './interfaces/cache-entity.interface.js'; + +export class CacheRepository implements CacheRepositoryInterface { + constructor( + protected readonly repository: RepositoryInterface, + private readonly mapper: CacheMapper, + ) {} + + async get(ctx: PlainLiteralObject, id: ReferenceId): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.eq('id', id), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findOne( + ctx: PlainLiteralObject, + options: { key: string; type: string; assigneeId: string }, + ): Promise { + const { key, type, assigneeId } = options; + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.and( + w.eq('key', key), + w.eq('type', type), + w.eq('assigneeId', assigneeId), + ), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findAllByAssignee( + ctx: PlainLiteralObject, + assigneeId: string, + ): Promise { + const w = Where.for(); + + const entities = await this.repository.find({ + where: w.eq('assigneeId', assigneeId), + ctx, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async save(ctx: PlainLiteralObject, cache: Cache): Promise { + cache.stampUpdated(); + await this.repository.upsert(this.mapper.toPersistence(cache), { ctx }); + } + + async remove(ctx: PlainLiteralObject, cache: Cache): Promise { + await this.repository.delete(this.mapper.toPersistence(cache), { ctx }); + } + + async removeAllByAssignee( + ctx: PlainLiteralObject, + assigneeId: string, + ): Promise { + const caches = await this.findAllByAssignee(ctx, assigneeId); + + await this.repository.deleteMany( + caches.map((cache) => this.mapper.toPersistence(cache)), + { ctx }, + ); + } + + async softRemove(ctx: PlainLiteralObject, cache: Cache): Promise { + cache.stampDeleted(); + await this.repository.softDelete(this.mapper.toPersistence(cache), { ctx }); + } +} diff --git a/packages/nestjs-cache/src/infrastructure/persistence/interfaces/cache-entity.interface.ts b/packages/nestjs-cache/src/infrastructure/persistence/interfaces/cache-entity.interface.ts new file mode 100644 index 000000000..69ad7ab7c --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/persistence/interfaces/cache-entity.interface.ts @@ -0,0 +1,14 @@ +import { + type AuditInterface, + type ReferenceIdInterface, + type ReferenceVersionInterface, +} from '@concepta/nestjs-core'; + +import { type CacheInterface } from '../../../domain/interfaces/cache.interface.js'; + +export interface CacheEntityInterface + extends + ReferenceIdInterface, + ReferenceVersionInterface, + CacheInterface, + AuditInterface {} diff --git a/packages/nestjs-cache/src/infrastructure/persistence/typeorm/cache-postgres.entity.ts b/packages/nestjs-cache/src/infrastructure/persistence/typeorm/cache-postgres.entity.ts new file mode 100644 index 000000000..da35d57af --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/persistence/typeorm/cache-postgres.entity.ts @@ -0,0 +1,30 @@ +import { Column, Unique } from 'typeorm'; + +import { ReferenceId } from '@concepta/nestjs-core'; +import { CommonPostgresEntity } from '@concepta/nestjs-repository-typeorm'; + +import { CacheInterface } from '../../../domain/interfaces/cache.interface.js'; + +/** + * Cache Postgres Entity + */ +@Unique(['key', 'type', 'assigneeId']) +export abstract class CachePostgresEntity + extends CommonPostgresEntity + implements CacheInterface +{ + @Column() + type!: string; + + @Column() + key!: string; + + @Column({ type: 'jsonb', nullable: true }) + data!: string | null; + + @Column({ type: 'timestamptz', nullable: true }) + expirationDate!: Date | null; + + @Column({ type: 'uuid' }) + assigneeId!: ReferenceId; +} diff --git a/packages/nestjs-cache/src/infrastructure/persistence/typeorm/cache-sqlite.entity.ts b/packages/nestjs-cache/src/infrastructure/persistence/typeorm/cache-sqlite.entity.ts new file mode 100644 index 000000000..aa0c2be21 --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/persistence/typeorm/cache-sqlite.entity.ts @@ -0,0 +1,31 @@ +import { Column, Unique } from 'typeorm'; + +import { ReferenceId } from '@concepta/nestjs-core'; +import { CommonSqliteEntity } from '@concepta/nestjs-repository-typeorm'; + +import { CacheInterface } from '../../../domain/interfaces/cache.interface.js'; + +/** + * Cache Sqlite Entity + */ + +@Unique(['key', 'type', 'assigneeId']) +export abstract class CacheSqliteEntity + extends CommonSqliteEntity + implements CacheInterface +{ + @Column() + key!: string; + + @Column() + type!: string; + + @Column({ type: 'text', nullable: true }) + data!: string; + + @Column({ type: 'datetime', nullable: true }) + expirationDate!: Date | null; + + @Column({ type: 'uuid' }) + assigneeId!: ReferenceId; +} diff --git a/packages/nestjs-cache/src/infrastructure/schemas/cache-create.schema.ts b/packages/nestjs-cache/src/infrastructure/schemas/cache-create.schema.ts new file mode 100644 index 000000000..d81db65ef --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/schemas/cache-create.schema.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type CacheCreatableInterface } from '../../domain/interfaces/cache-creatable.interface.js'; + +import { cacheSchema } from './cache.schema.js'; + +/** + * Used only as a request body — not a named OpenAPI component (no + * `withNamedComponent`), so `crud-init-api-body.decorator.ts` documents it + * inline. + * + * `data` and `expiresIn` are optional here — matching the legacy + * `CacheCreateDto` class's `IsOptional` decorator on both (picking `data` + * from `cacheSchema`, where it's required, would lose that). `expiresIn` is + * also request-only — see `cache.schema.ts` for why it isn't part of the + * response schema. + */ +export const cacheCreateSchema = withOpenApi( + conformsTo()( + cacheSchema.pick({ key: true, type: true, assigneeId: true }).extend({ + data: z.string().nullable().optional().meta({ description: 'data' }), + expiresIn: z + .string() + .nullable() + .optional() + .meta({ + description: 'Expiration duration expressed as a time span', + examples: ['60', '2 days', '10h', '7d'], + }), + }), + ), +); diff --git a/packages/nestjs-cache/src/infrastructure/schemas/cache-paginated.schema.ts b/packages/nestjs-cache/src/infrastructure/schemas/cache-paginated.schema.ts new file mode 100644 index 000000000..a3ea58079 --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/schemas/cache-paginated.schema.ts @@ -0,0 +1,9 @@ +import { withNamedComponent } from '@concepta/nestjs-core'; +import { paginatedSchema } from '@concepta/nestjs-crud'; + +import { cacheSchema } from './cache.schema.js'; + +export const cachePaginatedSchema = withNamedComponent( + paginatedSchema(cacheSchema), + 'CachePaginated', +); diff --git a/packages/nestjs-cache/src/infrastructure/schemas/cache-update.schema.ts b/packages/nestjs-cache/src/infrastructure/schemas/cache-update.schema.ts new file mode 100644 index 000000000..e05471a37 --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/schemas/cache-update.schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type CacheUpdatableInterface } from '../../domain/interfaces/cache-updatable.interface.js'; + +/** + * Used only as a request body — not a named OpenAPI component (no + * `withNamedComponent`), so `crud-init-api-body.decorator.ts` documents it + * inline. + * + * Both fields are optional — matching the legacy `CacheUpdateDto` class's + * `IsOptional` decorator on both (e.g. a PATCH extending TTL without + * resending `data`). + * `expiresIn` is also request-only — see `cache.schema.ts` for why it + * isn't part of the response schema. + */ +export const cacheUpdateSchema = withOpenApi( + conformsTo()( + z.object({ + data: z.string().nullable().optional().meta({ description: 'data' }), + expiresIn: z + .string() + .nullable() + .optional() + .meta({ + description: 'Expiration duration expressed as a time span', + examples: ['60', '2 days', '10h', '7d'], + }), + }), + ), +); diff --git a/packages/nestjs-cache/src/infrastructure/schemas/cache.schema.spec.ts b/packages/nestjs-cache/src/infrastructure/schemas/cache.schema.spec.ts new file mode 100644 index 000000000..a890618bd --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/schemas/cache.schema.spec.ts @@ -0,0 +1,135 @@ +import { cacheCreateSchema } from './cache-create.schema.js'; +import { cachePaginatedSchema } from './cache-paginated.schema.js'; +import { cacheUpdateSchema } from './cache-update.schema.js'; +import { cacheSchema } from './cache.schema.js'; + +const validCache = { + id: 'abc', + version: 1, + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-02'), + dateDeleted: null, + key: 'dashboard-1', + type: 'filter', + data: '{}', + assigneeId: 'user-1', + expirationDate: null, +}; + +describe('cacheSchema', () => { + it('accepts a valid cache entity', () => { + expect(cacheSchema.parse(validCache)).toEqual(validCache); + }); + + it('rejects a missing assigneeId', () => { + const { assigneeId: _assigneeId, ...rest } = validCache; + expect(cacheSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects an empty assigneeId (matching legacy @IsNotEmpty())', () => { + expect( + cacheSchema.safeParse({ ...validCache, assigneeId: '' }).success, + ).toBe(false); + }); + + it('does not require expiresIn (it is request-only, not part of the response shape)', () => { + const result = cacheSchema.parse(validCache); + expect(result).not.toHaveProperty('expiresIn'); + }); + + it('strips unknown keys', () => { + const result = cacheSchema.parse({ ...validCache, _internal: 'x' }); + expect(result).not.toHaveProperty('_internal'); + }); +}); + +describe('cacheCreateSchema', () => { + const validCreate = { + key: 'dashboard-1', + type: 'filter', + data: '{}', + expiresIn: '1d', + assigneeId: 'user-1', + }; + + it('accepts a valid create payload with expiresIn', () => { + expect(cacheCreateSchema.parse(validCreate)).toEqual(validCreate); + }); + + it('accepts expiresIn omitted (matching legacy @IsOptional())', () => { + const { expiresIn: _expiresIn, ...rest } = validCreate; + expect(cacheCreateSchema.parse(rest)).toEqual(rest); + }); + + it('accepts expiresIn explicitly null', () => { + const result = cacheCreateSchema.parse({ ...validCreate, expiresIn: null }); + expect(result.expiresIn).toBeNull(); + }); + + it('accepts data omitted (matching legacy @IsOptional())', () => { + const { data: _data, ...rest } = validCreate; + expect(cacheCreateSchema.parse(rest)).toEqual(rest); + }); + + it('rejects a missing assigneeId (e.g. sending { assignee: { id: null } } instead)', () => { + const { assigneeId: _assigneeId, ...rest } = validCreate; + expect(cacheCreateSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects a null assigneeId', () => { + expect( + cacheCreateSchema.safeParse({ ...validCreate, assigneeId: null }).success, + ).toBe(false); + }); + + it('rejects an empty-string assigneeId', () => { + expect( + cacheCreateSchema.safeParse({ ...validCreate, assigneeId: '' }).success, + ).toBe(false); + }); +}); + +describe('cacheUpdateSchema', () => { + it('accepts data + expiresIn', () => { + const payload = { data: '{ "name": "John Doe" }', expiresIn: null }; + expect(cacheUpdateSchema.parse(payload)).toEqual(payload); + }); + + it('accepts expiresIn omitted (partial update)', () => { + const payload = { data: '{}' }; + expect(cacheUpdateSchema.parse(payload)).toEqual(payload); + }); + + it('accepts data omitted (e.g. a PATCH that only extends TTL)', () => { + const payload = { expiresIn: '2d' }; + expect(cacheUpdateSchema.parse(payload)).toEqual(payload); + }); + + it('accepts an empty payload (both fields optional)', () => { + expect(cacheUpdateSchema.parse({})).toEqual({}); + }); + + it('strips fields outside data/expiresIn (e.g. key/type/assigneeId)', () => { + const result = cacheUpdateSchema.parse({ + data: '{}', + key: 'ignored', + type: 'ignored', + assigneeId: 'ignored', + }); + expect(result).toEqual({ data: '{}' }); + }); +}); + +describe('cachePaginatedSchema', () => { + it('accepts a paginated list of cache entities', () => { + const payload = { + data: [validCache], + limit: 10, + count: 1, + total: 1, + page: 1, + pageCount: 1, + }; + expect(cachePaginatedSchema.parse(payload)).toEqual(payload); + }); +}); diff --git a/packages/nestjs-cache/src/infrastructure/schemas/cache.schema.ts b/packages/nestjs-cache/src/infrastructure/schemas/cache.schema.ts new file mode 100644 index 000000000..ec6acfef5 --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/schemas/cache.schema.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { conformsTo, withNamedComponent } from '@concepta/nestjs-core'; +import { domainAggregateSchema } from '@concepta/nestjs-core/aggregate'; + +import { type CacheInterface } from '../../domain/interfaces/cache.interface.js'; + +/** + * `expiresIn` is intentionally NOT part of this (response) schema — it is + * request-only. The persisted/response entity only ever carries the + * computed `expirationDate`; the legacy `CacheDto` class declared `expiresIn` + * as `@Expose()`d too, but since response data never actually has it, + * class-transformer silently serialized it as `undefined` (dropped by + * JSON). Zod's `.parse()` is stricter and correctly rejects a declared + * required field that's actually absent, so `expiresIn` is added only on + * the request schemas (`cacheCreateSchema`/`cacheUpdateSchema`) where + * clients genuinely send it. + */ +export const cacheSchema = withNamedComponent( + conformsTo()( + domainAggregateSchema.extend({ + key: z.string().meta({ description: 'key' }), + type: z.string().meta({ description: 'type' }), + data: z.string().nullable().meta({ description: 'data' }), + assigneeId: z.string().min(1).meta({ description: 'assignee id' }), + expirationDate: z + .date() + .nullable() + .meta({ description: 'Expiration date of the cache entry' }), + }), + ), + 'Cache', +); diff --git a/packages/nestjs-cache/src/infrastructure/utils/__tests__/create-cache-repository-provider.spec.ts b/packages/nestjs-cache/src/infrastructure/utils/__tests__/create-cache-repository-provider.spec.ts new file mode 100644 index 000000000..661892b5d --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/utils/__tests__/create-cache-repository-provider.spec.ts @@ -0,0 +1,59 @@ +import { getDynamicRepositoryToken } from '@concepta/nestjs-repository'; + +import { CACHE_CUSTOM_REPOSITORY_TOKEN } from '../../../cache.constants.js'; +import { CacheMapper } from '../../persistence/cache.mapper.js'; +import { CacheRepository } from '../../persistence/cache.repository.js'; +import { + createCacheRepositoryProvider, + getDynamicCacheRepositoryToken, +} from '../create-cache-repository-provider.js'; + +describe('getDynamicCacheRepositoryToken', () => { + it('should return uppercase token with prefix', () => { + expect(getDynamicCacheRepositoryToken('user')).toBe( + 'CACHE_REPOSITORY_USER', + ); + }); + + it('should handle mixed case', () => { + expect(getDynamicCacheRepositoryToken('UserCache')).toBe( + 'CACHE_REPOSITORY_USERCACHE', + ); + }); +}); + +describe('createCacheRepositoryProvider', () => { + it('should create a provider with correct token', () => { + const provider = createCacheRepositoryProvider('user'); + + expect(provider).toEqual( + expect.objectContaining({ + provide: 'CACHE_REPOSITORY_USER', + }), + ); + }); + + it('should inject repository token, mapper, and custom repo token', () => { + const provider = createCacheRepositoryProvider('user') as { + inject: unknown[]; + }; + + expect(provider.inject).toEqual([ + getDynamicRepositoryToken('user'), + CacheMapper, + { token: CACHE_CUSTOM_REPOSITORY_TOKEN, optional: true }, + ]); + }); + + it('should have a useFactory that returns CacheRepository', () => { + const provider = createCacheRepositoryProvider('user') as { + useFactory: (...args: unknown[]) => unknown; + }; + + const mockRepo = {} as never; + const mockMapper = new CacheMapper(); + const result = provider.useFactory(mockRepo, mockMapper); + + expect(result).toBeInstanceOf(CacheRepository); + }); +}); diff --git a/packages/nestjs-cache/src/infrastructure/utils/create-cache-expiration-policy-provider.ts b/packages/nestjs-cache/src/infrastructure/utils/create-cache-expiration-policy-provider.ts new file mode 100644 index 000000000..cdad536e2 --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/utils/create-cache-expiration-policy-provider.ts @@ -0,0 +1,14 @@ +import { type Provider } from '@nestjs/common'; + +import { CACHE_MODULE_SETTINGS_TOKEN } from '../../cache.constants.js'; +import { CacheExpirationPolicy } from '../../domain/policies/cache-expiration.policy.js'; +import { type CacheSettingsInterface } from '../config/interfaces/cache-settings.interface.js'; + +export function createCacheExpirationPolicyProvider(): Provider { + return { + provide: CacheExpirationPolicy, + inject: [CACHE_MODULE_SETTINGS_TOKEN], + useFactory: (settings: CacheSettingsInterface) => + new CacheExpirationPolicy(settings), + }; +} diff --git a/packages/nestjs-cache/src/infrastructure/utils/create-cache-repository-provider.ts b/packages/nestjs-cache/src/infrastructure/utils/create-cache-repository-provider.ts new file mode 100644 index 000000000..31869302c --- /dev/null +++ b/packages/nestjs-cache/src/infrastructure/utils/create-cache-repository-provider.ts @@ -0,0 +1,35 @@ +import { type Provider, type Type } from '@nestjs/common'; + +import { + getDynamicRepositoryToken, + type RepositoryInterface, +} from '@concepta/nestjs-repository'; + +import { CACHE_CUSTOM_REPOSITORY_TOKEN } from '../../cache.constants.js'; +import { type CacheRepositoryInterface } from '../../domain/repositories/cache-repository.interface.js'; +import { CacheMapper } from '../persistence/cache.mapper.js'; +import { CacheRepository } from '../persistence/cache.repository.js'; +import { type CacheEntityInterface } from '../persistence/interfaces/cache-entity.interface.js'; + +export function getDynamicCacheRepositoryToken(entityKey: string): string { + return `CACHE_REPOSITORY_${entityKey.toUpperCase()}`; +} + +export function createCacheRepositoryProvider(entityKey: string): Provider { + return { + provide: getDynamicCacheRepositoryToken(entityKey), + inject: [ + getDynamicRepositoryToken(entityKey), + CacheMapper, + { token: CACHE_CUSTOM_REPOSITORY_TOKEN, optional: true }, + ], + useFactory: ( + repository: RepositoryInterface, + mapper: CacheMapper, + customRepo?: Type, + ) => { + const RepoClass = customRepo ?? CacheRepository; + return new RepoClass(repository, mapper); + }, + }; +} diff --git a/packages/nestjs-cache/src/interfaces/cache-entities-options.interface.ts b/packages/nestjs-cache/src/interfaces/cache-entities-options.interface.ts deleted file mode 100644 index a22507fd6..000000000 --- a/packages/nestjs-cache/src/interfaces/cache-entities-options.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { - CacheInterface, - RepositoryEntityOptionInterface, -} from '@concepta/nestjs-common'; - -export interface CacheEntitiesOptionsInterface - extends Record> {} diff --git a/packages/nestjs-cache/src/interfaces/cache-options-extras.interface.ts b/packages/nestjs-cache/src/interfaces/cache-options-extras.interface.ts deleted file mode 100644 index 1b88e30a2..000000000 --- a/packages/nestjs-cache/src/interfaces/cache-options-extras.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface CacheOptionsExtrasInterface - extends Pick { - entities?: string[]; -} diff --git a/packages/nestjs-cache/src/interfaces/cache-options.interface.ts b/packages/nestjs-cache/src/interfaces/cache-options.interface.ts deleted file mode 100644 index 236ef8e4a..000000000 --- a/packages/nestjs-cache/src/interfaces/cache-options.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CacheSettingsInterface } from './cache-settings.interface'; - -export interface CacheOptionsInterface { - settings?: CacheSettingsInterface; -} diff --git a/packages/nestjs-cache/src/interfaces/cache-service.interface.ts b/packages/nestjs-cache/src/interfaces/cache-service.interface.ts deleted file mode 100644 index 012d051d0..000000000 --- a/packages/nestjs-cache/src/interfaces/cache-service.interface.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - ReferenceAssignment, - CacheClearInterface, - CacheCreatableInterface, - CacheCreateInterface, - CacheDeleteInterface, - CacheGetOneInterface, - CacheInterface, - CacheUpdateInterface, -} from '@concepta/nestjs-common'; - -export interface CacheServiceInterface - extends CacheCreateInterface, - CacheDeleteInterface, - CacheUpdateInterface, - CacheGetOneInterface, - CacheClearInterface { - updateOrCreate( - assignment: ReferenceAssignment, - cache: CacheCreatableInterface, - ): Promise; - - getAssignedCaches( - assignment: ReferenceAssignment, - cache: Pick, - ): Promise; -} diff --git a/packages/nestjs-cache/src/interfaces/cache-settings.interface.ts b/packages/nestjs-cache/src/interfaces/cache-settings.interface.ts deleted file mode 100644 index 083b7ca0c..000000000 --- a/packages/nestjs-cache/src/interfaces/cache-settings.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { LiteralObject } from '@concepta/nestjs-common'; - -export interface CacheSettingsInterface { - assignments: LiteralObject<{ entityKey: string }>; - expiresIn?: string | undefined | null; -} diff --git a/packages/nestjs-cache/src/optional-crud.ts b/packages/nestjs-cache/src/optional-crud.ts new file mode 100644 index 000000000..5b07ea053 --- /dev/null +++ b/packages/nestjs-cache/src/optional-crud.ts @@ -0,0 +1,18 @@ +// schemas (Zod / Standard Schema) +export { cachePaginatedSchema } from './infrastructure/schemas/cache-paginated.schema.js'; + +// requests +export { CreateCacheRequest } from './gateways/http/commands/impl/create-cache.request.js'; +export { UpdateCacheRequest } from './gateways/http/commands/impl/update-cache.request.js'; +export { DeleteCacheRequest } from './gateways/http/commands/impl/delete-cache.request.js'; +export { ReplaceCacheRequest } from './gateways/http/commands/impl/replace-cache.request.js'; +export { ListCachesRequest } from './gateways/http/queries/impl/list-caches.request.js'; +export { ReadCacheRequest } from './gateways/http/queries/impl/read-cache.request.js'; + +// request handlers +export { CreateCacheRequestHandler } from './gateways/http/commands/handlers/create-cache-request.handler.js'; +export { UpdateCacheRequestHandler } from './gateways/http/commands/handlers/update-cache-request.handler.js'; +export { DeleteCacheRequestHandler } from './gateways/http/commands/handlers/delete-cache-request.handler.js'; +export { ReplaceCacheRequestHandler } from './gateways/http/commands/handlers/replace-cache-request.handler.js'; +export { ListCachesRequestHandler } from './gateways/http/queries/handlers/list-caches-request.handler.js'; +export { ReadCacheRequestHandler } from './gateways/http/queries/handlers/read-cache-request.handler.js'; diff --git a/packages/nestjs-cache/src/optional-seeding.ts b/packages/nestjs-cache/src/optional-seeding.ts new file mode 100644 index 000000000..d9802bc48 --- /dev/null +++ b/packages/nestjs-cache/src/optional-seeding.ts @@ -0,0 +1,6 @@ +/** + * These exports allow you to import seeding related classes + * and tools without loading the entire module which + * runs all of its decorators and meta data. + */ +export { CacheFactory } from './infrastructure/persistence/cache.factory.js'; diff --git a/packages/nestjs-cache/src/optional-typeorm.ts b/packages/nestjs-cache/src/optional-typeorm.ts new file mode 100644 index 000000000..1824ad58b --- /dev/null +++ b/packages/nestjs-cache/src/optional-typeorm.ts @@ -0,0 +1,2 @@ +export { CacheSqliteEntity } from './infrastructure/persistence/typeorm/cache-sqlite.entity.js'; +export { CachePostgresEntity } from './infrastructure/persistence/typeorm/cache-postgres.entity.js'; diff --git a/packages/nestjs-cache/src/seeding.ts b/packages/nestjs-cache/src/seeding.ts deleted file mode 100644 index 11d83537c..000000000 --- a/packages/nestjs-cache/src/seeding.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * These exports all you to import seeding related classes - * and tools without loading the entire module which - * runs all of it's decorators and meta data. - */ -export { CacheFactory } from './cache.factory'; -export { CacheSeeder } from './cache.seeder'; diff --git a/packages/nestjs-cache/src/services/cache.service.e2e-spec.ts b/packages/nestjs-cache/src/services/cache.service.e2e-spec.ts deleted file mode 100644 index e0ec5ee79..000000000 --- a/packages/nestjs-cache/src/services/cache.service.e2e-spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { - CacheCreatableInterface, - CacheInterface, -} from '@concepta/nestjs-common'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { CacheService } from './cache.service'; - -import { AppModuleFixture } from '../__fixtures__/app.module.fixture'; -import { UserEntityFixture } from '../__fixtures__/entities/user-entity.fixture'; -import { UserFactoryFixture } from '../__fixtures__/factories/user.factory.fixture'; - -const expirationDate = new Date(); -expirationDate.setHours(expirationDate.getHours() + 1); - -jest.mock('../utils/get-expiration-date.util', () => ({ - __esModule: true, - default: jest.fn(() => expirationDate), -})); - -describe(CacheService.name, () => { - let cacheService: CacheService; - let seedingSource: SeedingSource; - let userFactory: UserFactoryFixture; - let user: UserEntityFixture; - - const createTestCache = ( - overrides: Partial = {}, - ): CacheCreatableInterface => ({ - key: 'test-key', - type: 'test-type', - data: 'test-data', - assigneeId: user.id, - expiresIn: '1h', - ...overrides, - }); - - const createTestCacheQuery = ( - overrides: Partial< - Pick - > = {}, - ) => ({ - key: 'test-key', - type: 'test-type', - assigneeId: user.id, - ...overrides, - }); - - beforeEach(async () => { - const testModule: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - - cacheService = testModule.get(CacheService); - - seedingSource = new SeedingSource({ - dataSource: testModule.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - userFactory = new UserFactoryFixture({ seedingSource }); - user = await userFactory.create(); - }); - - describe(CacheService.prototype.create, () => { - it('should create a cache entry', async () => { - const cacheData = createTestCache(); - - const createdCache = await cacheService.create('user', cacheData); - expect(createdCache).toBeDefined(); - expect(createdCache.key).toBe(cacheData.key); - expect(createdCache.type).toBe(cacheData.type); - expect(createdCache.data).toBe(cacheData.data); - expect(createdCache.assigneeId).toBe(user.id); - }); - }); - - describe(CacheService.prototype.get, () => { - it('should find an existing cache entry', async () => { - const cacheData = createTestCache(); - await cacheService.create('user', cacheData); - - const foundCache = await cacheService.get('user', createTestCacheQuery()); - expect(foundCache).toBeDefined(); - expect(foundCache?.key).toBe(cacheData.key); - }); - - it('should return null for non-existent cache', async () => { - const nonExistentCache = await cacheService.get( - 'user', - createTestCacheQuery({ - key: 'non-existent-key', - type: 'non-existent-type', - }), - ); - expect(nonExistentCache).toBeNull(); - }); - }); - - describe(CacheService.prototype.update, () => { - it('should update an existing cache entry', async () => { - const initialCache = createTestCache(); - const createdCache = await cacheService.create('user', initialCache); - expect(createdCache.data).toBe('test-data'); - - const updatedData = 'updated-data'; - const updatedCache = await cacheService.update('user', { - ...createTestCacheQuery(), - data: updatedData, - expiresIn: '1h', - }); - - expect(updatedCache.data).toBe(updatedData); - expect(updatedCache.key).toBe(initialCache.key); - expect(updatedCache.type).toBe(initialCache.type); - expect(updatedCache.assigneeId).toBe(user.id); - }); - }); - - describe(CacheService.prototype.delete, () => { - it('should delete an existing cache entry', async () => { - const cacheData = createTestCache(); - await cacheService.create('user', cacheData); - - await cacheService.delete('user', createTestCacheQuery()); - - const deletedCache = await cacheService.get( - 'user', - createTestCacheQuery(), - ); - expect(deletedCache).toBeNull(); - }); - }); - - describe(CacheService.prototype.getAssignedCaches, () => { - it('should get all caches for an assignee', async () => { - const cache1 = createTestCache({ key: 'multi-test-key-1' }); - const cache2 = createTestCache({ key: 'multi-test-key-2' }); - - await cacheService.create('user', cache1); - await cacheService.create('user', cache2); - - const userCaches = await cacheService.getAssignedCaches('user', { - assigneeId: user.id, - }); - - expect(userCaches).toHaveLength(2); - expect(userCaches[0].assigneeId).toBe(user.id); - expect(userCaches[1].assigneeId).toBe(user.id); - expect(userCaches.map((cache) => cache.key)).toContain(cache1.key); - expect(userCaches.map((cache) => cache.key)).toContain(cache2.key); - }); - }); - - describe(CacheService.prototype.clear, () => { - it('should clear all caches for an assignee', async () => { - const cache1 = createTestCache({ key: 'clear-test-key-1' }); - const cache2 = createTestCache({ key: 'clear-test-key-2' }); - - await cacheService.create('user', cache1); - await cacheService.create('user', cache2); - - await cacheService.clear('user', { - assigneeId: user.id, - }); - - const userCaches = await cacheService.getAssignedCaches('user', { - assigneeId: user.id, - }); - expect(userCaches).toHaveLength(0); - }); - }); - - describe(CacheService.prototype.updateOrCreate, () => { - it('should create new cache if not exists and update if exists', async () => { - const cacheData = createTestCache({ key: 'update-or-create-key' }); - - // First call should create - const createdCache = await cacheService.updateOrCreate('user', cacheData); - expect(createdCache.data).toBe('test-data'); - - // Second call should update - const updatedData = 'updated-data'; - const updatedCache = await cacheService.updateOrCreate('user', { - ...cacheData, - data: updatedData, - }); - - expect(updatedCache.data).toBe(updatedData); - expect(updatedCache.key).toBe(cacheData.key); - expect(updatedCache.type).toBe(cacheData.type); - expect(updatedCache.assigneeId).toBe(user.id); - - const userCaches = await cacheService.getAssignedCaches('user', { - assigneeId: user.id, - }); - expect(userCaches).toHaveLength(1); - }); - }); -}); diff --git a/packages/nestjs-cache/src/services/cache.service.spec.ts b/packages/nestjs-cache/src/services/cache.service.spec.ts deleted file mode 100644 index 2ab0dbcd8..000000000 --- a/packages/nestjs-cache/src/services/cache.service.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { - ReferenceAssignment, - CacheCreatableInterface, - CacheInterface, - RepositoryInterface, - ModelMutateException, - ModelValidationException, -} from '@concepta/nestjs-common'; - -import { CacheCreateDto } from '../dto/cache-create.dto'; -import { CacheSettingsInterface } from '../interfaces/cache-settings.interface'; - -import { CacheService } from './cache.service'; - -const expirationDate = new Date(); -expirationDate.setHours(expirationDate.getHours() + 1); - -jest.mock('../utils/get-expiration-date.util', () => ({ - __esModule: true, - default: jest.fn(() => expirationDate), -})); - -describe('CacheService', () => { - let service: CacheService; - let repo: RepositoryInterface; - let settings: CacheSettingsInterface; - const cacheDto: CacheCreatableInterface = { - key: 'testKey', - type: 'testType', - data: 'testData', - assigneeId: 'testAssignee', - expiresIn: '1h', - }; - - const assignment: ReferenceAssignment = 'testAssignment'; - const cacheCreateDto = new CacheCreateDto(); - - beforeEach(() => { - repo = mock>(); - settings = mock(); - settings.assignments = { - testAssignment: { entityKey: 'testAssignment' }, - }; - settings.expiresIn = '1h'; - service = new CacheService({ testAssignment: repo }, settings); - }); - - describe(CacheService.prototype.create, () => { - it('should create a cache entry', async () => { - Object.assign(cacheCreateDto, cacheDto); - - // Mocking validateDto method - service['validateDto'] = jest.fn().mockResolvedValue(cacheCreateDto); - - await service.create(assignment, cacheDto); - - expect(repo.save).toHaveBeenCalledWith({ - key: cacheDto.key, - type: cacheDto.type, - data: cacheDto.data, - assigneeId: cacheDto.assigneeId, - expirationDate, - }); - }); - - it('should throw a ModelValidationException on error', async () => { - const assignment: ReferenceAssignment = 'testAssignment'; - - const error = new ModelValidationException('error', []); - service['validateDto'] = jest.fn().mockRejectedValue(error); - - await expect(service.create(assignment, cacheDto)).rejects.toThrow( - ModelValidationException, - ); - }); - }); - - describe(CacheService.prototype.update, () => { - it('should update a cache entry', async () => { - Object.assign(cacheCreateDto, cacheDto); - - service['validateDto'] = jest.fn().mockResolvedValueOnce(cacheDto); - const result = { - key: cacheDto.key, - type: cacheDto.type, - data: cacheDto.data, - assigneeId: cacheDto.assigneeId, - expirationDate, - }; - service['findCache'] = jest.fn().mockImplementationOnce(() => { - return { - ...result, - dateCreated: new Date(), - dateUpdated: new Date(), - id: 'testId', - version: 1, - } as CacheInterface; - }); - service['mergeEntity'] = jest.fn().mockResolvedValue(result); - - await service.update(assignment, cacheDto); - - expect(repo.save).toHaveBeenCalledWith(result); - }); - - it('should throw a ModelValidationException on error', async () => { - const assignment: ReferenceAssignment = 'testAssignment'; - - const error = new ModelValidationException('error', []); - service['validateDto'] = jest.fn().mockRejectedValue(error); - - await expect(service.update(assignment, cacheDto)).rejects.toThrow( - ModelValidationException, - ); - }); - - it('should throw a ModelMutateException on error', async () => { - const assignment: ReferenceAssignment = 'testAssignment'; - - const error = new Error('error'); - service['mergeEntity'] = jest.fn().mockResolvedValue(error); - - const t = () => service.update(assignment, cacheDto); - await expect(t).rejects.toThrow(ModelMutateException); - }); - }); -}); diff --git a/packages/nestjs-cache/src/services/cache.service.ts b/packages/nestjs-cache/src/services/cache.service.ts deleted file mode 100644 index 1ad951858..000000000 --- a/packages/nestjs-cache/src/services/cache.service.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { plainToInstance } from 'class-transformer'; -import { validate } from 'class-validator'; - -import { Inject, Injectable } from '@nestjs/common'; - -import { - CacheInterface, - CacheUpdatableInterface, - DeepPartial, - ReferenceAssignment, - Type, - RepositoryInterface, - ModelQueryException, - ModelMutateException, - ModelValidationException, -} from '@concepta/nestjs-common'; - -import { - CACHE_MODULE_REPOSITORIES_TOKEN, - CACHE_MODULE_SETTINGS_TOKEN, -} from '../cache.constants'; -import { CacheCreateDto } from '../dto/cache-create.dto'; -import { CacheUpdateDto } from '../dto/cache-update.dto'; -import { CacheAssignmentNotFoundException } from '../exceptions/cache-assignment-not-found.exception'; -import { CacheEntityNotFoundException } from '../exceptions/cache-entity-not-found.exception'; -import { CacheServiceInterface } from '../interfaces/cache-service.interface'; -import { CacheSettingsInterface } from '../interfaces/cache-settings.interface'; -import getExpirationDate from '../utils/get-expiration-date.util'; - -@Injectable() -export class CacheService implements CacheServiceInterface { - constructor( - @Inject(CACHE_MODULE_REPOSITORIES_TOKEN) - private allCacheRepos: Record>, - @Inject(CACHE_MODULE_SETTINGS_TOKEN) - protected readonly settings: CacheSettingsInterface, - ) {} - - /** - * Create a cache with a for the given assignee. - * - * @param assignment - The cache assignment - * @param cache - The data to create - */ - async create( - assignment: ReferenceAssignment, - cache: CacheCreateDto, - ): Promise { - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - // validate the data - const dto = await this.validateDto(CacheCreateDto, cache); - - // break out the vars - const { key, type, data, assigneeId, expiresIn } = dto; - - // try to find the relationship - try { - // generate the expiration date - const expirationDate = getExpirationDate( - expiresIn ?? this.settings.expiresIn, - ); - - // try to save the item - return assignmentRepo.save({ - key, - type, - data, - assigneeId, - expirationDate, - }); - } catch (e) { - throw new ModelMutateException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - async update( - assignment: ReferenceAssignment, - cache: CacheUpdatableInterface, - ): Promise { - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - // validate the data - const dto = await this.validateDto(CacheUpdateDto, cache); - - // generate the expiration date - const expirationDate = getExpirationDate( - dto.expiresIn ?? this.settings.expiresIn, - ); - - // try to update the item - try { - const assignedCache = await this.findCache(assignmentRepo, dto); - if (!assignedCache) - throw new CacheEntityNotFoundException(assignmentRepo.entityName()); - - const mergedEntity = await this.mergeEntity( - assignmentRepo, - assignedCache, - dto, - ); - - return assignmentRepo.save({ - ...mergedEntity, - expirationDate, - }); - } catch (e) { - throw new ModelMutateException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - /** - * Delete a cache based on params - * - * @param assignment - The cache assignment - * @param cache - The cache to delete - */ - async delete( - assignment: ReferenceAssignment, - cache: Pick, - ): Promise { - // get cache from an assigned user for a category - const assignedCache = await this.get(assignment, cache); - - if (assignedCache) { - return this.deleteCache(assignment, assignedCache); - } - } - - /** - * Get all CACHEs for assignee. - * - * @param assignment - The assignment of the check - * @param cache - The cache to get assignments - */ - async getAssignedCaches( - assignment: ReferenceAssignment, - cache: Pick, - ): Promise { - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - // break out the args - const { assigneeId } = cache; - - // try to find the relationships - try { - // make the query - const assignments = await assignmentRepo.find({ - where: { - assigneeId, - }, - }); - - // return the caches from assignee - return assignments; - } catch (e) { - throw new ModelQueryException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - async get( - assignment: ReferenceAssignment, - cache: Pick, - ): Promise { - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - return await this.findCache(assignmentRepo, cache); - } - - /** - * Clear all caches for a given assignee. - * - * @param assignment - The assignment of the repository - * @param cache - The cache to clear - */ - async clear( - assignment: ReferenceAssignment, - cache: Pick, - ): Promise { - // get all caches from an assigned user for a category - const assignedCaches = await this.getAssignedCaches(assignment, cache); - - if (assignedCaches.length > 0) - await this.deleteCache(assignment, assignedCaches); - } - - /** - * Delete CACHE based on assignment - * - * @internal - * @param assignment - The assignment to delete id from - * @param entity - The id or ids to delete - */ - protected async deleteCache( - assignment: ReferenceAssignment, - entity: CacheInterface | CacheInterface[], - ): Promise { - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - try { - await assignmentRepo.remove(Array.isArray(entity) ? entity : [entity]); - } catch (e) { - throw new ModelMutateException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - async updateOrCreate( - assignment: ReferenceAssignment, - cache: CacheCreateDto, - ): Promise { - const existingCache = await this.get(assignment, cache); - if (existingCache) { - return await this.update(assignment, cache); - } else { - return await this.create(assignment, cache); - } - } - - // Should this be on nestjs-common? - protected async validateDto>( - type: Type, - data: T, - ): Promise { - // convert to dto - const dto = plainToInstance(type, data); - - // validate the data - const validationErrors = await validate(dto); - - // any errors? - if (validationErrors.length) { - // yes, throw error - throw new ModelValidationException( - this.constructor.name, - validationErrors, - ); - } - - return dto; - } - - protected async findCache( - repo: RepositoryInterface, - cache: Pick, - ): Promise { - const { key, type, assigneeId } = cache; - - try { - if (!key || !type || !assigneeId) { - return null; - } - const cache = await repo.findOne({ - where: { - key, - type, - assigneeId, - }, - }); - return cache; - } catch (e) { - throw new ModelQueryException(repo.entityName(), { - originalError: e, - }); - } - } - - /** - * Get the assignment repo for the given assignment. - * - * @internal - * @param assignment - The cache assignment - */ - protected getAssignmentRepo( - assignment: ReferenceAssignment, - ): RepositoryInterface { - if (this.settings.assignments[assignment]) { - // get entity key based on assignment - const entityKey = this.settings.assignments[assignment].entityKey; - - // repo matching assignment was injected? - if (this.allCacheRepos[entityKey]) { - // yes, return it - return this.allCacheRepos[entityKey]; - } else { - // bad assignment - throw new CacheEntityNotFoundException(entityKey); - } - } else { - // bad assignment - throw new CacheAssignmentNotFoundException(assignment); - } - } - - private async mergeEntity( - repo: RepositoryInterface, - assignedCache: CacheInterface, - dto: CacheUpdateDto, - ): Promise { - return repo.merge(assignedCache, dto); - } -} diff --git a/packages/nestjs-cache/src/utils/get-expiration-date.util.ts b/packages/nestjs-cache/src/utils/get-expiration-date.util.ts deleted file mode 100644 index 899f54a07..000000000 --- a/packages/nestjs-cache/src/utils/get-expiration-date.util.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { toMilliseconds } from '@concepta/nestjs-common'; - -import { CacheInvalidExpiredDateException } from '../exceptions/cache-invalid-expired-date.exception'; - -const getExpirationDate = ( - expiresIn: string | null | undefined, -): Date | null => { - if (!expiresIn) return null; - - const now = new Date(); - const expires = toMilliseconds(expiresIn); - - // TODO: should be a custom exception - if (!expires) throw new CacheInvalidExpiredDateException(); - - // add time in seconds to now as string format - return new Date(now.getTime() + expires); -}; - -export default getExpirationDate; diff --git a/packages/nestjs-cache/tsconfig.json b/packages/nestjs-cache/tsconfig.json index ef9980950..edc11225e 100644 --- a/packages/nestjs-cache/tsconfig.json +++ b/packages/nestjs-cache/tsconfig.json @@ -4,10 +4,13 @@ "composite": true, "rootDir": "./src", "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", "typeRoots": [ "./node_modules/@types", - "../../node_modules/@types" - ] + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] }, "include": [ "src/**/*.ts" diff --git a/packages/nestjs-common/README.md b/packages/nestjs-common/README.md index 1eed9fe79..7398d8000 100644 --- a/packages/nestjs-common/README.md +++ b/packages/nestjs-common/README.md @@ -1,15 +1,545 @@ -# Rockets NestJS Common +# @concepta/nestjs-common -The common module contains commonly used utilities, DTOs, etc. +Core dependency for all Rockets modules. Provides the DDD aggregate +infrastructure, audit system, domain interfaces, context overlay system, +exception handling, and shared DTOs. ## Project [![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-common)](https://www.npmjs.com/package/@concepta/nestjs-common) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-common)](https://www.npmjs.com/package/@concepta/nestjs-common) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-common)](https://www.npmjs.com/package/@concepta/nestjs-common) [![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) [![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) [![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) +## Table of Contents + +- [Installation](#installation) +- [Entry Points](#entry-points) +- [Domain Aggregates](#domain-aggregates) +- [Domain Mapper](#domain-mapper) +- [Domain Factory](#domain-factory) +- [Audit System](#audit-system) +- [Reference Interfaces](#reference-interfaces) +- [Context Overlay System](#context-overlay-system) +- [Event Context](#event-context) +- [Exceptions](#exceptions) +- [Utilities](#utilities) +- [Module Configuration](#module-configuration) +- [DTOs](#dtos) +- [Model Exceptions and Interfaces](#model-exceptions-and-interfaces) +- [Domain Interfaces](#domain-interfaces) + ## Installation -`yarn add @concepta/nestjs-common` +```sh +yarn add @concepta/nestjs-common +``` + +## Entry Points + +| Path | Description | +| --- | --- | +| `@concepta/nestjs-common` | Main entry -- DTOs, interfaces, enums, utilities | +| `@concepta/nestjs-common/aggregate` | Aggregate infrastructure -- `DomainAggregate`, `DomainMapper`, `DomainAggregateDto`, `AggregateMetaInterface` | +| `@concepta/nestjs-common/testing` | `createMockEventPublisher`, `createMockCommandBus`, `createMockQueryBus` | + +## Domain Aggregates + +`DomainAggregate` is the base class for all domain entities. It extends +`AggregateRoot` from `@nestjs/cqrs` and adds versioning, audit metadata, +and a `toPlain()` serialization method. + +```ts +import { DomainAggregate, AggregateMetaInterface } from '@concepta/nestjs-common/aggregate'; + +export class Order extends DomainAggregate { + constructor( + id: string, + props: OrderInterface, + version?: number, + meta?: AggregateMetaInterface, + ) { + super(id, props, version, meta); + } + + get status() { + return this.props.status; + } + + cancel(eventContext): void { + this.props = { ...this.props, status: 'cancelled' }; + this.incrementVersion(); + this.apply(new OrderCancelledEvent(eventContext, this.toPlain())); + } +} +``` + +### Inherited API + +| Member | Description | +| --- | --- | +| `id` | Immutable unique identifier | +| `version` | Optimistic concurrency version (starts at 1) | +| `meta` | `AggregateMetaInterface` -- `dateCreated`, `dateUpdated`, `dateDeleted` | +| `props` | Domain properties (protected, update via spread) | +| `stampCreated()` | Set creation timestamp | +| `stampUpdated()` | Set update timestamp | +| `stampDeleted()` | Mark as soft-deleted | +| `incrementVersion()` | Bump version (protected) | +| `toPlain()` | Returns `{ id, version, ...props, ...meta }` | + +## Domain Mapper + +`DomainMapper` bridges persistence entities and +domain aggregates. Concrete mappers implement `createAggregate()` and are +registered as NestJS providers, injected into repositories. + +```ts +import { DomainMapper } from '@concepta/nestjs-common/aggregate'; + +export class OrderMapper extends DomainMapper< + OrderEntityInterface, + OrderInterface, + Order +> { + createAggregate(entity: OrderEntityInterface): Order { + const { id, version, dateCreated, dateUpdated, dateDeleted, ...props } = + entity; + return new Order(id, props, version, { + dateCreated, + dateUpdated, + dateDeleted, + }); + } +} +``` + +| Method | Description | +| --- | --- | +| `createAggregate(entity)` | Abstract -- hydrate an aggregate from a persistence entity | +| `toDomain(entity)` | Calls `createAggregate()` | +| `toPersistence(aggregate)` | Calls `aggregate.toPlain()` | + +## Domain Factory + +`DomainFactory` constrains aggregate classes to expose +static `create()` and `createWithId()` factory methods with event context: + +```ts +import { DomainFactory, EventContextInterface } from '@concepta/nestjs-common'; + +// Enforced via `satisfies` after the class declaration +Order satisfies DomainFactory; +``` + +## Audit System + +### AuditInterface + +Combines three date interfaces for persistence tracking: + +```ts +interface AuditInterface + extends AuditDateCreatedInterface, + AuditDateUpdatedInterface, + AuditDateDeletedInterface {} +``` + +| Interface | Type | +| --- | --- | +| `AuditDateCreatedInterface` | `{ dateCreated: Date }` | +| `AuditDateUpdatedInterface` | `{ dateUpdated: Date }` | +| `AuditDateDeletedInterface` | `{ dateDeleted: Date \| null }` | + +### AuditDto + +DTO with `@Expose()` decorators for `dateCreated`, `dateUpdated`, and +`dateDeleted`. Used as a base class for entity DTOs. + +### AggregateMetaInterface + +Extends `AuditInterface`. Carried by `DomainAggregate` instances for +audit tracking. + +## Reference Interfaces + +Small, composable interfaces for common entity fields: + +| Interface | Field | +| --- | --- | +| `ReferenceIdInterface` | `id: string` | +| `ReferenceVersionInterface` | `version: number` | +| `ReferenceEmailInterface` | `email: string` | +| `ReferenceUsernameInterface` | `username: string` | +| `ReferenceActiveInterface` | `active: boolean` | +| `ReferenceAssigneeInterface` | `assigneeId: string` | +| `ReferenceAssignmentInterface` | `assignment: string` | +| `ReferenceSubjectInterface` | `subject: string` | +| `ReferenceUserInterface` | `userId: string` | +| `ReferenceRoleInterface` | `role: string` | +| `ReferenceRolesInterface` | `roles: string[]` | + +## Context Overlay System + +`AppContextHost` is a per-request container that carries feature-specific +context through the NestJS request pipeline. Interceptors define **overlays** +early in the pipeline (e.g. namespace, transaction), and downstream +handlers consume them via typed `with*()` methods. A `Proxy` guard throws +`OverlayNotDefinedException` if you call an undefined `with*` method, +catching misconfiguration at runtime. + +### Defining an Overlay + +This walkthrough creates a `withFoo` overlay end-to-end. + +#### Step 1: Define the context interface and OverlayRef + +`OverlayRef` is a typed token that carries the method name and its resolved +type. It serves as the single source of truth for one overlay. + +```ts +// foo-context.interface.ts +import { PlainLiteralObject } from '@nestjs/common'; + +export interface FooContextInterface extends PlainLiteralObject { + namespace: string; +} +``` + +```ts +// foo-context.overlay.ts (partial -- ref only) +import { OverlayRef } from '@concepta/nestjs-common'; +import { FooContextInterface } from './foo-context.interface'; + +export const FooCtx = new OverlayRef<'withFoo', FooContextInterface>('withFoo'); +``` + +#### Step 2: Extend ContextOverlayInterceptor + +`ContextOverlayInterceptor` is an abstract base class. Subclasses provide: + +- **`ref`** -- the `OverlayRef` token +- **`attach(context)`** -- resolves overlay values, gets `AppContextHost`, + and calls `defineOverlay(ref, values)`. + +Since the overlay IS an interceptor, it can be registered directly as +`APP_INTERCEPTOR` or applied per-route via `@UseInterceptors()`. + +```ts +// foo-context.overlay.ts +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { + ContextOverlayInterceptor, + getAppContext, +} from '@concepta/nestjs-common'; +import { FooContextInterface } from './foo-context.interface'; +import { FooCtx } from './foo-context.overlay'; + +@Injectable() +export class FooContextOverlay extends ContextOverlayInterceptor { + readonly ref = FooCtx; + + constructor(private readonly reflector: Reflector) { + super(); + } + + attach(context: ExecutionContext): void { + const request = context.switchToHttp().getRequest(); + const ctx = getAppContext(request); + const resolved = this.resolve(context); + ctx.defineOverlay(FooCtx, resolved); + } + + private resolve(context: ExecutionContext): FooContextInterface { + const meta = this.reflector.getAllAndOverride<{ name: string }>( + 'FOO_NAMESPACE', + [context.getHandler(), context.getClass()], + ); + return { namespace: meta?.name ?? 'default' }; + } +} +``` + +#### Step 3: Register as a global interceptor + +Since the overlay extends `ContextOverlayInterceptor` (which implements +`NestInterceptor`), register it directly as `APP_INTERCEPTOR`: + +```ts +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { FooContextOverlay } from './foo-context.overlay'; + +@Module({ + providers: [ + { provide: APP_INTERCEPTOR, useClass: FooContextOverlay }, + ], +}) +export class FooModule {} +``` + +For per-route overlays, use `@UseInterceptors()`: + +```ts +import { UseInterceptors } from '@nestjs/common'; +import { FooContextOverlay } from './foo-context.overlay'; + +@Controller('foo') +@UseInterceptors(FooContextOverlay) +export class FooController { ... } +``` + +#### Step 4: Consume in a handler + +The `@Ctx()` decorator extracts the per-request `AppContextHost` from the +HTTP request. Pass an `OverlayRef` to unwrap the overlay directly. + +```ts +import { Controller, Get } from '@nestjs/common'; +import { Ctx } from '@concepta/nestjs-common'; +import { FooCtx } from './foo-context.overlay'; +import { FooContextInterface } from './foo-context.interface'; + +@Controller('foo') +export class FooController { + @Get() + handle(@Ctx(FooCtx) ctx: FooContextInterface) { + // ctx is the resolved overlay props -- use directly + const { namespace } = ctx; + } +} +``` + +When you need the full `AppContextHost` (e.g. to check multiple overlays), +omit the ref: + +```ts +import { Ctx, AppContextHost } from '@concepta/nestjs-common'; + +@Get() +handle(@Ctx() ctx: AppContextHost) { + // direct lookup by ref (throws if not defined) + const { namespace } = ctx.with(FooCtx); + + // optional (returns ctx unchanged if not defined) + const { namespace } = ctx.optional().withFoo(); +} +``` + +#### Step 5: Testing with defineOverlay() + +Use `defineOverlay(ref, values)` to set overlay values directly in tests, +bypassing the full `ContextOverlayInterface` and interceptor pipeline. + +```ts +import { AppContextHost } from '@concepta/nestjs-common'; +import { FooCtx } from './foo-context.overlay'; + +const ctx = new AppContextHost(); +ctx.defineOverlay(FooCtx, { namespace: 'test-ns' }); + +expect(ctx.with(FooCtx).namespace).toBe('test-ns'); +expect(ctx.supports(FooCtx)).toBe(true); +``` + +### AppContextHost API + +| Method | Description | +| --- | --- | +| `defineOverlay(ref, values)` | Register an overlay by `OverlayRef` and pre-resolved values. Installs a `with*()` method. Idempotent -- subsequent calls with the same name are no-ops. | +| `require(...refs)` | Type-level narrowing. Returns `this` cast to include the typed `with*()` methods for the given refs. No runtime validation. | +| `with(ref)` | Direct lookup by ref. Returns the resolved overlay props, or throws `OverlayNotDefinedException`. | +| `supports(ref)` | Returns `true` if the overlay is defined on this context. | +| `optional()` | Returns a proxy where any `with*()` call returns the resolved overlay if defined, or `this` unchanged if not. | +| `static from(value?)` | Normalizes an `AppContextLike` value to a guaranteed `AppContextHost`. Passes through existing instances; creates a new one for `undefined`, `null`, or `{}`. | + +### Proxy Guard + +`AppContextHost` wraps itself in a `Proxy` at construction time. Any +property access starting with `with` that is not defined throws +`OverlayNotDefinedException`, immediately surfacing misconfigured pipelines +rather than returning `undefined`. + +### Supporting Utilities + +| Export | Description | +| --- | --- | +| `getAppContext(request)` | Get or create the `AppContextHost` stored on a request object (keyed by a private `Symbol`). | +| `@Ctx(ref?)` | Parameter decorator. Without a ref, returns the `AppContextHost`. With an `OverlayRef`, calls `appCtx.with(ref)` and returns the unwrapped overlay props. | +| `ContextOverlayInterceptor` | Abstract base class for overlays. Subclass with `ref` and `attach()` to create a self-intercepting overlay. | +| `OverlayRef` | Typed token class carrying the overlay name and resolved props type. | +| `AppContextInterface` | Interface matching the full `AppContextHost` public API. | +| `AppContextLike` | Union type: `AppContextInterface \| PlainLiteralObject \| null \| undefined`. | +| `RefsToMethods` | Mapped type that converts `OverlayRef` tokens to their `with*()` method signatures. | + +## Event Context + +`EventContextHost` is an immutable container for event headers and metadata, +used when domain aggregates apply events. + +```ts +import { EventContextHost } from '@concepta/nestjs-common'; + +const eventContext = new EventContextHost( + { entityId: '123', operation: 'create' }, // headers + { source: 'api' }, // metadata +); + +eventContext.getHeader('entityId'); // '123' +eventContext.getMeta('source'); // 'api' +``` + +| Export | Description | +| --- | --- | +| `EventContextHost` | Immutable (frozen) event context. Constructor spreads and freezes headers/metadata. | +| `EventContextInterface` | Interface with `headers`, `metadata`, `getHeader(key)`, and `getMeta(key)`. | + +## Exceptions + +### RuntimeException + +Base exception class for all Rockets modules. Extends `Error` with +structured error codes, HTTP status hints, and user-safe messages. + +```ts +import { HttpStatus } from '@nestjs/common'; +import { RuntimeException } from '@concepta/nestjs-common'; + +throw new RuntimeException({ + message: 'Internal: record %s not found', + messageParams: ['abc-123'], + safeMessage: 'The requested resource was not found', + httpStatus: HttpStatus.NOT_FOUND, +}); +``` + +| Property | Default | Description | +| --- | --- | --- | +| `errorCode` | `'RUNTIME_EXCEPTION'` | Machine-readable error code. Subclasses override this. | +| `httpStatus` | `500` | HTTP status hint for the exception filter. | +| `message` | `'Runtime Exception'` | Internal message (may contain sensitive details). Supports `util.format` via `messageParams`. | +| `safeMessage` | -- | User-facing message. Returned by the filter for 4xx errors and as a fallback for 5xx. | +| `context.originalError` | -- | Wrapped original error, if any. | + +### ExceptionsFilter + +A global `@Catch()` filter that normalizes all exceptions into a consistent +JSON response: + +```json +{ + "statusCode": 404, + "errorCode": "MODEL_QUERY_ERROR", + "message": "The requested resource was not found", + "timestamp": "2025-01-15T12:00:00.000Z" +} +``` + +Behavior: + +- **HttpException** -- uses NestJS status and message +- **RuntimeException** -- uses `errorCode` and `httpStatus`; for 5xx errors, + hides the internal `message` and returns `safeMessage` or a generic fallback +- **All other exceptions** -- 500 with a generic fallback message + +### Exception Exports + +| Export | Description | +| --- | --- | +| `RuntimeException` | Base exception with error codes and safe messages | +| `RuntimeExceptionOptions` | Constructor options interface | +| `RuntimeExceptionInterface` | Interface: `httpStatus`, `safeMessage`, `context` | +| `RuntimeExceptionContext` | Type for the `context` property | +| `ExceptionInterface` | Minimal interface: `Error` + `errorCode` + `context` | +| `ExceptionsFilter` | Global catch-all filter | +| `NotAnErrorException` | Wraps non-Error throws | +| `mapNonErrorToException(error)` | Returns `error` if `Error`, else wraps in `NotAnErrorException` | +| `mapHttpStatus(statusCode)` | Maps an HTTP status code to a string error code | + +## Utilities + +| Export | Description | +| --- | --- | +| `toMilliseconds(value, fallback?)` | Converts time strings (`'1h'`, `'30m'`) to milliseconds via the `ms` library. Throws `RuntimeException` if unparseable. | +| `mapHttpStatus(statusCode)` | Maps an HTTP status code to a string error code (e.g. `404` -> `'NOT_FOUND'`). | +| `mapNonErrorToException(error)` | Wraps non-`Error` values in `NotAnErrorException`. Returns `Error` instances as-is. | +| `DeepPartial` | Recursive `Partial` utility type. | +| `LiteralObject` | Alias for `Record`. | + +## Module Configuration + +Rockets modules share a common settings/options pattern: + +| Export | Description | +| --- | --- | +| `createSettingsProvider(options)` | Factory that creates a NestJS provider for module settings. Injects default settings and module options, applies `settings` overrides, and runs `settingsTransform` if provided. | +| `ModuleOptionsSettingsInterface` | Interface for module options with optional `settings` override and `settingsTransform` function. | +| `ModuleOptionsControllerInterface` | Interface with `controller?: false \| Type \| Type[]` for enabling or disabling default module controllers. | + +```ts +import { createSettingsProvider } from '@concepta/nestjs-common'; + +createSettingsProvider({ + settingsToken: MY_SETTINGS_TOKEN, + optionsToken: RAW_OPTIONS_TOKEN, + settingsKey: myDefaultConfig.KEY, +}); +``` + +## DTOs + +| DTO | Extends | Adds | +| --- | --- | --- | +| `AuditDto` | -- | `dateCreated`, `dateUpdated`, `dateDeleted` | +| `CommonEntityDto` | `AuditDto` | `id` | +| `DomainAggregateDto` | `AuditDto` | `id`, `version` | +| `ReferenceIdDto` | -- | `id` | + +`DomainAggregateDto` is the standard base for API response DTOs when using +domain aggregates. + +## Model Exceptions and Interfaces + +### Model Exceptions + +| Exception | Error Code | Description | +| --- | --- | --- | +| `ModelQueryException` | `MODEL_QUERY_ERROR` | Error querying a model | +| `ModelMutateException` | `MODEL_MUTATE_ERROR` | Error mutating a model | +| `ModelValidationException` | `MODEL_VALIDATION_ERROR` | Model validation failure | +| `ModelIdNoMatchException` | `MODEL_ID_NO_MATCH_ERROR` | ID mismatch on update/replace | + +### Query Interfaces + +| Interface | Method | +| --- | --- | +| `ByIdInterface` | `byId(id)` | +| `ByEmailInterface` | `byEmail(email)` | +| `BySubjectInterface` | `bySubject(subject)` | +| `ByUsernameInterface` | `byUsername(username)` | + +### Mutation Interfaces + +| Interface | Method | +| --- | --- | +| `CreateOneInterface` | `createOne(...)` | +| `UpdateOneInterface` | `updateOne(...)` | +| `ReplaceOneInterface` | `replaceOne(...)` | +| `RemoveOneInterface` | `removeOne(...)` | + +## Domain Interfaces + +The module exports shared domain interfaces that are used across multiple +modules or that remain here to avoid circular dependencies. + +| Domain | Key Interfaces | +| --- | --- | +| Auth | `AuthenticatedUserInterface`, `AuthenticationAccessInterface`, `AuthenticationLoginInterface`, `AuthenticationRefreshInterface`, `AuthenticationResponseInterface`, `AuthenticationCodeInterface`, `AuthorizationPayloadInterface` | +| Password | `PasswordStorageInterface`, `PasswordPlainInterface`, `PasswordPlainCurrentInterface`, `PasswordUpdateInterface`, `isPasswordStorage` | +| Org | `OrgInterface`, `OrgCreatableInterface`, `OrgUpdatableInterface`, `OrgReplaceableInterface`, `OrgEntityInterface`, `OrgOwnableInterface`, `OrgMemberInterface`, `OrgOwnerInterface`, `OrgMemberEntityInterface` | +| Org Profile | `OrgProfileInterface`, `OrgProfileCreatableInterface`, `OrgProfileEntityInterface` | +| File | `FileInterface`, `FileCreatableInterface`, `FileUpdatableInterface`, `FileOwnableInterface`, `FileEntityInterface` | +| Report | `ReportInterface`, `ReportCreatableInterface`, `ReportUpdatableInterface`, `ReportEntityInterface`, `ReportStatusEnum` | +| Email | `EmailSendInterface`, `EmailSendOptionsInterface` | +| Assignee | `AssigneeRelationInterface` | diff --git a/packages/nestjs-common/package.json b/packages/nestjs-common/package.json index 8223cedfb..62dcad9a5 100644 --- a/packages/nestjs-common/package.json +++ b/packages/nestjs-common/package.json @@ -2,29 +2,47 @@ "name": "@concepta/nestjs-common", "version": "7.0.0-alpha.10", "description": "Rockets NestJS Common", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "./dist/cjs/index.js", + "types": "./dist/cjs/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + } + }, "license": "BSD-3-Clause", "publishConfig": { "access": "public" }, "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + "dist/cjs/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "dist/esm/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "dist/cjs/package.json", + "dist/esm/package.json", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" ], "dependencies": { "@nestjs/common": "^11.1.9", - "@nestjs/swagger": "^11.2.2", + "@nestjs/core": "^11.1.9", + "@nestjs/swagger": "11.2.2", "ms": "^2.1.3" }, "devDependencies": { - "@nestjs/core": "^11.1.9", "@nestjs/testing": "^11.1.9", "@types/supertest": "^6.0.3", - "jest-mock-extended": "^4.0.0", "supertest": "^6.3.4" }, "peerDependencies": { "class-transformer": "*", - "class-validator": "*" - } + "class-validator": "*", + "rxjs": "^7.0.0" + }, + "module": "./dist/esm/index.js" } diff --git a/packages/nestjs-common/src/audit/dto/audit.dto.ts b/packages/nestjs-common/src/audit/dto/audit.dto.ts index ec5b27c16..89f22d606 100644 --- a/packages/nestjs-common/src/audit/dto/audit.dto.ts +++ b/packages/nestjs-common/src/audit/dto/audit.dto.ts @@ -1,5 +1,5 @@ import { Exclude, Expose, Type } from 'class-transformer'; -import { IsDate, IsNumber, IsOptional } from 'class-validator'; +import { IsDate, IsOptional } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; @@ -55,15 +55,4 @@ export class AuditDto implements AuditInterface { @IsDate() @IsOptional() dateDeleted!: AuditDateDeleted; - - /** - * Version - */ - @Expose() - @ApiProperty({ - type: 'number', - description: 'Version of the data', - }) - @IsNumber() - version!: number; } diff --git a/packages/nestjs-common/src/audit/interfaces/audit-date-created.interface.ts b/packages/nestjs-common/src/audit/interfaces/audit-date-created.interface.ts index d93576eb1..e27493548 100644 --- a/packages/nestjs-common/src/audit/interfaces/audit-date-created.interface.ts +++ b/packages/nestjs-common/src/audit/interfaces/audit-date-created.interface.ts @@ -1,4 +1,4 @@ -import { AuditDateCreated } from './audit.types'; +import { type AuditDateCreated } from './audit.types'; /** * Date data was created. diff --git a/packages/nestjs-common/src/audit/interfaces/audit-date-deleted.interface.ts b/packages/nestjs-common/src/audit/interfaces/audit-date-deleted.interface.ts index c11daf628..68ba00b79 100644 --- a/packages/nestjs-common/src/audit/interfaces/audit-date-deleted.interface.ts +++ b/packages/nestjs-common/src/audit/interfaces/audit-date-deleted.interface.ts @@ -1,4 +1,4 @@ -import { AuditDateDeleted } from './audit.types'; +import { type AuditDateDeleted } from './audit.types'; /** * Date data was deleted. diff --git a/packages/nestjs-common/src/audit/interfaces/audit-date-updated.interface.ts b/packages/nestjs-common/src/audit/interfaces/audit-date-updated.interface.ts index d69895697..ea56d48e6 100644 --- a/packages/nestjs-common/src/audit/interfaces/audit-date-updated.interface.ts +++ b/packages/nestjs-common/src/audit/interfaces/audit-date-updated.interface.ts @@ -1,4 +1,4 @@ -import { AuditDateUpdated } from './audit.types'; +import { type AuditDateUpdated } from './audit.types'; /** * Date data was last updated. diff --git a/packages/nestjs-common/src/audit/interfaces/audit-version.interface.ts b/packages/nestjs-common/src/audit/interfaces/audit-version.interface.ts deleted file mode 100644 index e6cd97726..000000000 --- a/packages/nestjs-common/src/audit/interfaces/audit-version.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { AuditVersion } from './audit.types'; - -/** - * The latest version of the data. - */ -export interface AuditVersionInterface { - version: T; -} diff --git a/packages/nestjs-common/src/audit/interfaces/audit.interface.ts b/packages/nestjs-common/src/audit/interfaces/audit.interface.ts index 39f7bedef..b5eb3127f 100644 --- a/packages/nestjs-common/src/audit/interfaces/audit.interface.ts +++ b/packages/nestjs-common/src/audit/interfaces/audit.interface.ts @@ -1,10 +1,12 @@ -import { AuditDateCreatedInterface } from './audit-date-created.interface'; -import { AuditDateDeletedInterface } from './audit-date-deleted.interface'; -import { AuditDateUpdatedInterface } from './audit-date-updated.interface'; -import { AuditVersionInterface } from './audit-version.interface'; +import { type AuditDateCreatedInterface } from './audit-date-created.interface'; +import { type AuditDateDeletedInterface } from './audit-date-deleted.interface'; +import { type AuditDateUpdatedInterface } from './audit-date-updated.interface'; +/** + * Audit metadata for persistence tracking. + */ export interface AuditInterface - extends AuditDateCreatedInterface, + extends + AuditDateCreatedInterface, AuditDateUpdatedInterface, - AuditDateDeletedInterface, - AuditVersionInterface {} + AuditDateDeletedInterface {} diff --git a/packages/nestjs-common/src/common/dto/common-entity.dto.ts b/packages/nestjs-common/src/common/dto/common-entity.dto.ts index 675dcd4ea..183fc1a7c 100644 --- a/packages/nestjs-common/src/common/dto/common-entity.dto.ts +++ b/packages/nestjs-common/src/common/dto/common-entity.dto.ts @@ -8,7 +8,7 @@ import { AuditInterface } from '../../audit/interfaces/audit.interface'; import { ReferenceIdInterface } from '../../reference/interfaces/reference-id.interface'; /** - * User DTO + * Common Entity DTO */ @Exclude() export class CommonEntityDto diff --git a/packages/nestjs-common/src/core.types.ts b/packages/nestjs-common/src/core.types.ts index 904ae2899..3f66176bd 100644 --- a/packages/nestjs-common/src/core.types.ts +++ b/packages/nestjs-common/src/core.types.ts @@ -1,3 +1,24 @@ +import { + type MutateOperations, + type ReadOperations, + type WriteOperations, +} from './enums/operation.enum'; + export type ExceptionContext = Record & { originalError?: unknown; }; + +/** + * Type for read operations (List, Read). + */ +export type ReadOperation = (typeof ReadOperations)[number]; + +/** + * Type for write operations (Create, CreateBatch, Update, Replace). + */ +export type WriteOperation = (typeof WriteOperations)[number]; + +/** + * Type for modify operations (write + delete/restore). + */ +export type MutateOperation = (typeof MutateOperations)[number]; diff --git a/packages/nestjs-common/src/decorators/auth-user.decorator.spec.ts b/packages/nestjs-common/src/decorators/auth-user.decorator.spec.ts deleted file mode 100644 index c80f18fc2..000000000 --- a/packages/nestjs-common/src/decorators/auth-user.decorator.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { ExecutionContext } from '@nestjs/common'; -import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; -import { HttpArgumentsHost } from '@nestjs/common/interfaces'; - -import { AuthUser } from './auth-user.decorator'; - -interface UserInterface { - user: { username: string }; -} - -describe(AuthUser.name, () => { - interface ValueInterface {} - - const getParamDecoratorFactory = (decorator: () => ParameterDecorator) => { - class TestController { - public test(@decorator() value: ValueInterface) { - return value; - } - } - - const args = Reflect.getMetadata( - ROUTE_ARGS_METADATA, - TestController, - 'test', - ); - return args[Object.keys(args)[0]].factory; - }; - const factory = getParamDecoratorFactory(AuthUser); - const context = mock(); - const httpArgumentsHost = mock(); - const testUser = { username: 'my_username' }; - - jest.spyOn(httpArgumentsHost, 'getRequest').mockImplementation(() => { - return { user: testUser } as UserInterface; - }); - - jest.spyOn(context, 'switchToHttp').mockImplementation(() => { - return httpArgumentsHost; - }); - - it('should match username', async () => { - const result = factory(null, context); - expect(result.username).toBe(testUser.username); - }); - - it('should get property of the user', async () => { - const result = factory('username', context); - expect(result).toBe(testUser.username); - }); - - it('should get property undefined ', async () => { - jest.spyOn(httpArgumentsHost, 'getRequest').mockImplementation(() => { - return { user: undefined }; - }); - const result = factory('username', context); - expect(result).toBe(undefined); - }); - - it('should get property undefined ', async () => { - const result = factory('email', context); - expect(result).toBe(undefined); - }); -}); diff --git a/packages/nestjs-common/src/decorators/auth-user.decorator.ts b/packages/nestjs-common/src/decorators/auth-user.decorator.ts deleted file mode 100644 index 08f4bc955..000000000 --- a/packages/nestjs-common/src/decorators/auth-user.decorator.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; - -import { AuthenticatedUserInterface } from '../domain/authentication/interfaces/authenticated-user.interface'; - -/** - * Decorator that takes a property name as key, and returns the - * associated value if it exists (or undefined if it doesn't exist, - * or if the user object has not been created). - * - * @example - * ```ts - * @Get() - * async findOne(@AuthUser('firstName') firstName: string) { - * console.log(`Hello ${firstName}`); - * } - * ``` - */ -export const AuthUser = createParamDecorator( - (data: string, ctx: ExecutionContext): AuthenticatedUserInterface => { - const request = ctx.switchToHttp().getRequest(); - const user = request.user; - - return data ? user?.[data] : user; - }, -); diff --git a/packages/nestjs-common/src/domain/assignee/interfaces/assignee-relation.interface.ts b/packages/nestjs-common/src/domain/assignee/interfaces/assignee-relation.interface.ts deleted file mode 100644 index ab8776dfa..000000000 --- a/packages/nestjs-common/src/domain/assignee/interfaces/assignee-relation.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ReferenceId } from '../../../reference/interfaces/reference.types'; - -/** - * Assigned to assignee. - */ -export interface AssigneeRelationInterface< - T extends ReferenceId = ReferenceId, -> { - assigneeId: T; -} diff --git a/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user.interface.ts b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user.interface.ts deleted file mode 100644 index 921ddea50..000000000 --- a/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; - -export interface AuthenticatedUserInterface extends ReferenceIdInterface {} diff --git a/packages/nestjs-common/src/domain/authentication/interfaces/authentication-code.interface.ts b/packages/nestjs-common/src/domain/authentication/interfaces/authentication-code.interface.ts deleted file mode 100644 index 9855c952e..000000000 --- a/packages/nestjs-common/src/domain/authentication/interfaces/authentication-code.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface AuthenticationCodeInterface { - code: string; -} diff --git a/packages/nestjs-common/src/domain/authentication/interfaces/authentication-login.interface.ts b/packages/nestjs-common/src/domain/authentication/interfaces/authentication-login.interface.ts deleted file mode 100644 index a49d252c3..000000000 --- a/packages/nestjs-common/src/domain/authentication/interfaces/authentication-login.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ReferenceUsernameInterface } from '../../../reference/interfaces/reference-username.interface'; -import { PasswordPlainInterface } from '../../password/interfaces/password-plain.interface'; - -export interface AuthenticationLoginInterface - extends ReferenceUsernameInterface, - PasswordPlainInterface {} diff --git a/packages/nestjs-common/src/domain/authentication/interfaces/authentication-response.interface.ts b/packages/nestjs-common/src/domain/authentication/interfaces/authentication-response.interface.ts deleted file mode 100644 index 45c480f05..000000000 --- a/packages/nestjs-common/src/domain/authentication/interfaces/authentication-response.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { AuthenticationAccessInterface } from './authentication-access.interface'; -import { AuthenticationRefreshInterface } from './authentication-refresh.interface'; - -/** - * Authentication response interface - */ -export interface AuthenticationResponseInterface - extends AuthenticationAccessInterface, - AuthenticationRefreshInterface {} diff --git a/packages/nestjs-common/src/domain/authorization/interfaces/authorization-payload.interface.ts b/packages/nestjs-common/src/domain/authorization/interfaces/authorization-payload.interface.ts deleted file mode 100644 index 30b357fa2..000000000 --- a/packages/nestjs-common/src/domain/authorization/interfaces/authorization-payload.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { ReferenceSubjectInterface } from '../../../reference/interfaces/reference-subject.interface'; - -export interface AuthorizationPayloadInterface - extends ReferenceSubjectInterface {} diff --git a/packages/nestjs-common/src/domain/cache/interfaces/cache-clear.interface.ts b/packages/nestjs-common/src/domain/cache/interfaces/cache-clear.interface.ts deleted file mode 100644 index e7e9a22cd..000000000 --- a/packages/nestjs-common/src/domain/cache/interfaces/cache-clear.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ReferenceAssignment } from '../../../reference/interfaces/reference.types'; - -import { CacheInterface } from './cache.interface'; - -export interface CacheClearInterface { - /** - * Clear all caches for assign in given category. - * - * @param assignment - The assignment of the repository - * @param cache - The cache to clear - */ - clear( - assignment: ReferenceAssignment, - cache: Pick, - ): Promise; -} diff --git a/packages/nestjs-common/src/domain/cache/interfaces/cache-creatable.interface.ts b/packages/nestjs-common/src/domain/cache/interfaces/cache-creatable.interface.ts deleted file mode 100644 index 0b012125b..000000000 --- a/packages/nestjs-common/src/domain/cache/interfaces/cache-creatable.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { CacheInterface } from './cache.interface'; - -export interface CacheCreatableInterface - extends Pick { - expiresIn: string | null; -} diff --git a/packages/nestjs-common/src/domain/cache/interfaces/cache-create.interface.ts b/packages/nestjs-common/src/domain/cache/interfaces/cache-create.interface.ts deleted file mode 100644 index 04d191f50..000000000 --- a/packages/nestjs-common/src/domain/cache/interfaces/cache-create.interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ReferenceAssignment } from '../../../reference/interfaces/reference.types'; - -import { CacheCreatableInterface } from './cache-creatable.interface'; -import { CacheInterface } from './cache.interface'; - -export interface CacheCreateInterface { - /** - * Create a cache with a for the given assignee. - * - * @param assignment - The cache assignment - * @param cache - The CACHE to create - */ - create( - assignment: ReferenceAssignment, - cache: CacheCreatableInterface, - ): Promise; -} diff --git a/packages/nestjs-common/src/domain/cache/interfaces/cache-delete.interface.ts b/packages/nestjs-common/src/domain/cache/interfaces/cache-delete.interface.ts deleted file mode 100644 index 89d5bbdb5..000000000 --- a/packages/nestjs-common/src/domain/cache/interfaces/cache-delete.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ReferenceAssignment } from '../../../reference/interfaces/reference.types'; - -import { CacheInterface } from './cache.interface'; - -export interface CacheDeleteInterface { - /** - * Delete a cache based on params - * - * @param assignment - The cache assignment - * @param cache - The dto with unique keys to delete - */ - delete( - assignment: ReferenceAssignment, - cache: Pick, - ): Promise; -} diff --git a/packages/nestjs-common/src/domain/cache/interfaces/cache-get-one.interface.ts b/packages/nestjs-common/src/domain/cache/interfaces/cache-get-one.interface.ts deleted file mode 100644 index ab3a775a2..000000000 --- a/packages/nestjs-common/src/domain/cache/interfaces/cache-get-one.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ReferenceAssignment } from '../../../reference/interfaces/reference.types'; - -import { CacheInterface } from './cache.interface'; - -export interface CacheGetOneInterface { - /** - * Get One cache based on params - * - * @param assignment - The cache assignment - * @param cache - The dto with unique keys to delete - */ - get( - assignment: ReferenceAssignment, - cache: Pick, - ): Promise; -} diff --git a/packages/nestjs-common/src/domain/cache/interfaces/cache-updatable.interface.ts b/packages/nestjs-common/src/domain/cache/interfaces/cache-updatable.interface.ts deleted file mode 100644 index 486b75118..000000000 --- a/packages/nestjs-common/src/domain/cache/interfaces/cache-updatable.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { CacheInterface } from './cache.interface'; - -export interface CacheUpdatableInterface - extends Pick { - expiresIn: string | null; -} diff --git a/packages/nestjs-common/src/domain/cache/interfaces/cache-update.interface.ts b/packages/nestjs-common/src/domain/cache/interfaces/cache-update.interface.ts deleted file mode 100644 index a41d2b901..000000000 --- a/packages/nestjs-common/src/domain/cache/interfaces/cache-update.interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ReferenceAssignment } from '../../../reference/interfaces/reference.types'; - -import { CacheUpdatableInterface } from './cache-updatable.interface'; -import { CacheInterface } from './cache.interface'; - -export interface CacheUpdateInterface { - /** - * Update a cache based on params - * - * @param assignment - The cache assignment - * @param cache - The dto with unique keys to delete - */ - update( - assignment: ReferenceAssignment, - cache: CacheUpdatableInterface, - ): Promise; -} diff --git a/packages/nestjs-common/src/domain/cache/interfaces/cache.interface.ts b/packages/nestjs-common/src/domain/cache/interfaces/cache.interface.ts deleted file mode 100644 index 38984856f..000000000 --- a/packages/nestjs-common/src/domain/cache/interfaces/cache.interface.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { AssigneeRelationInterface } from '../../assignee/interfaces/assignee-relation.interface'; - -export interface CacheInterface - extends ReferenceIdInterface, - AssigneeRelationInterface, - AuditInterface { - /** - * key to be used as reference for the cache data - */ - key: string; - - /** - * Type of the passcode - */ - type: string; - - /** - * data of the cache - */ - data: string | null; - - /** - * Date it will expire - */ - expirationDate: Date | null; -} diff --git a/packages/nestjs-common/src/domain/email/interfaces/email-send-options.interface.ts b/packages/nestjs-common/src/domain/email/interfaces/email-send-options.interface.ts index 10d5d3661..8e294ae93 100644 --- a/packages/nestjs-common/src/domain/email/interfaces/email-send-options.interface.ts +++ b/packages/nestjs-common/src/domain/email/interfaces/email-send-options.interface.ts @@ -1,7 +1,7 @@ -import { Readable } from 'stream'; -import { Url } from 'url'; +import { type Readable } from 'stream'; +import { type Url } from 'url'; -import { LiteralObject } from '../../../utils/interfaces/literal-object.interface'; +import { type LiteralObject } from '../../../utils/interfaces/literal-object.interface'; interface Address { name: string; diff --git a/packages/nestjs-common/src/domain/email/interfaces/email-send.interface.ts b/packages/nestjs-common/src/domain/email/interfaces/email-send.interface.ts index e6abbfb62..a6e8ba55f 100644 --- a/packages/nestjs-common/src/domain/email/interfaces/email-send.interface.ts +++ b/packages/nestjs-common/src/domain/email/interfaces/email-send.interface.ts @@ -1,4 +1,4 @@ -import { EmailSendOptionsInterface } from './email-send-options.interface'; +import { type EmailSendOptionsInterface } from './email-send-options.interface'; export interface EmailSendInterface { sendMail(sendMailOptions: EmailSendOptionsInterface): Promise; diff --git a/packages/nestjs-common/src/domain/federated/interfaces/federated-creatable.interface.ts b/packages/nestjs-common/src/domain/federated/interfaces/federated-creatable.interface.ts deleted file mode 100644 index 2a80ae5cc..000000000 --- a/packages/nestjs-common/src/domain/federated/interfaces/federated-creatable.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { FederatedInterface } from './federated.interface'; - -export interface FederatedCreatableInterface - extends Pick {} diff --git a/packages/nestjs-common/src/domain/federated/interfaces/federated-entity.interface.ts b/packages/nestjs-common/src/domain/federated/interfaces/federated-entity.interface.ts deleted file mode 100644 index 9b9bfb337..000000000 --- a/packages/nestjs-common/src/domain/federated/interfaces/federated-entity.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { FederatedInterface } from './federated.interface'; - -export interface FederatedEntityInterface extends FederatedInterface {} diff --git a/packages/nestjs-common/src/domain/federated/interfaces/federated-updatable.interface.ts b/packages/nestjs-common/src/domain/federated/interfaces/federated-updatable.interface.ts deleted file mode 100644 index 028647e27..000000000 --- a/packages/nestjs-common/src/domain/federated/interfaces/federated-updatable.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { FederatedInterface } from './federated.interface'; - -export interface FederatedUpdatableInterface - extends Pick {} diff --git a/packages/nestjs-common/src/domain/federated/interfaces/federated.interface.ts b/packages/nestjs-common/src/domain/federated/interfaces/federated.interface.ts deleted file mode 100644 index 952cdf05e..000000000 --- a/packages/nestjs-common/src/domain/federated/interfaces/federated.interface.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { ReferenceUserInterface } from '../../../reference/interfaces/reference-user.interface'; - -export interface FederatedInterface - extends ReferenceIdInterface, - ReferenceUserInterface, - AuditInterface { - /** - * Provider name (github, facebook, etc) - */ - provider: string; - - /** - * The reference identification for provider - * - * TODO: rename to `sub` via ReferenceSubjectInterface - */ - subject: string; - - /** - * The user federated will be associated to - */ - user: ReferenceIdInterface; -} diff --git a/packages/nestjs-common/src/domain/file/interfaces/file-creatable.interface.ts b/packages/nestjs-common/src/domain/file/interfaces/file-creatable.interface.ts index 1419ca80c..1006bce90 100644 --- a/packages/nestjs-common/src/domain/file/interfaces/file-creatable.interface.ts +++ b/packages/nestjs-common/src/domain/file/interfaces/file-creatable.interface.ts @@ -1,4 +1,6 @@ -import { FileInterface } from './file.interface'; +import { type FileInterface } from './file.interface'; -export interface FileCreatableInterface - extends Pick {} +export interface FileCreatableInterface extends Pick< + FileInterface, + 'serviceKey' | 'fileName' | 'contentType' +> {} diff --git a/packages/nestjs-common/src/domain/file/interfaces/file-entity.interface.ts b/packages/nestjs-common/src/domain/file/interfaces/file-entity.interface.ts index 09655d7ef..55c5c7252 100644 --- a/packages/nestjs-common/src/domain/file/interfaces/file-entity.interface.ts +++ b/packages/nestjs-common/src/domain/file/interfaces/file-entity.interface.ts @@ -1,3 +1,3 @@ -import { FileInterface } from './file.interface'; +import { type FileInterface } from './file.interface'; export interface FileEntityInterface extends FileInterface {} diff --git a/packages/nestjs-common/src/domain/file/interfaces/file-ownable.interface.ts b/packages/nestjs-common/src/domain/file/interfaces/file-ownable.interface.ts index b0f52bcb8..93c09cbb3 100644 --- a/packages/nestjs-common/src/domain/file/interfaces/file-ownable.interface.ts +++ b/packages/nestjs-common/src/domain/file/interfaces/file-ownable.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceId } from '../../../reference/interfaces/reference.types'; +import { type ReferenceId } from '../../../reference/interfaces/reference.types'; export interface FileOwnableInterface { fileId: ReferenceId; diff --git a/packages/nestjs-common/src/domain/file/interfaces/file-updatable.interface.ts b/packages/nestjs-common/src/domain/file/interfaces/file-updatable.interface.ts index ff0e639da..4c526998f 100644 --- a/packages/nestjs-common/src/domain/file/interfaces/file-updatable.interface.ts +++ b/packages/nestjs-common/src/domain/file/interfaces/file-updatable.interface.ts @@ -1,6 +1,5 @@ -import { FileCreatableInterface } from './file-creatable.interface'; -import { FileInterface } from './file.interface'; +import { type FileCreatableInterface } from './file-creatable.interface'; +import { type FileInterface } from './file.interface'; export interface FileUpdatableInterface - extends Pick, - FileCreatableInterface {} + extends Pick, FileCreatableInterface {} diff --git a/packages/nestjs-common/src/domain/file/interfaces/file.interface.ts b/packages/nestjs-common/src/domain/file/interfaces/file.interface.ts index bdc5b973e..417206695 100644 --- a/packages/nestjs-common/src/domain/file/interfaces/file.interface.ts +++ b/packages/nestjs-common/src/domain/file/interfaces/file.interface.ts @@ -1,5 +1,5 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type AuditInterface } from '../../../audit/interfaces/audit.interface'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; /** * Interface representing a file entity diff --git a/packages/nestjs-common/src/domain/index.ts b/packages/nestjs-common/src/domain/index.ts index 98f61473a..27e413b50 100644 --- a/packages/nestjs-common/src/domain/index.ts +++ b/packages/nestjs-common/src/domain/index.ts @@ -1,22 +1,6 @@ export { EmailSendOptionsInterface } from './email/interfaces/email-send-options.interface'; export { EmailSendInterface } from './email/interfaces/email-send.interface'; -export { AssigneeRelationInterface } from './assignee/interfaces/assignee-relation.interface'; - -export { AuthenticatedUserInterface } from './authentication/interfaces/authenticated-user.interface'; -export { AuthenticationAccessInterface } from './authentication/interfaces/authentication-access.interface'; -export { AuthenticationCodeInterface } from './authentication/interfaces/authentication-code.interface'; -export { AuthenticationLoginInterface } from './authentication/interfaces/authentication-login.interface'; -export { AuthenticationRefreshInterface } from './authentication/interfaces/authentication-refresh.interface'; -export { AuthenticationResponseInterface } from './authentication/interfaces/authentication-response.interface'; - -export { AuthorizationPayloadInterface } from './authorization/interfaces/authorization-payload.interface'; - -export { PasswordStorageInterface } from './password/interfaces/password-storage.interface'; -export { PasswordPlainCurrentInterface } from './password/interfaces/password-plain-current.interface'; -export { PasswordPlainInterface } from './password/interfaces/password-plain.interface'; -export { isPasswordStorage } from './password/is-password-storage.typeguard'; - export { OrgCreatableInterface } from './org/interfaces/org-creatable.interface'; export { OrgOwnableInterface } from './org/interfaces/org-ownable.interface'; export { OrgMemberInterface } from './org/interfaces/org-member.interface'; @@ -31,62 +15,6 @@ export { OrgProfileInterface } from './org-profile/interfaces/org-profile.interf export { OrgProfileCreatableInterface } from './org-profile/interfaces/org-profile-creatable.interface'; export { OrgProfileEntityInterface } from './org-profile/interfaces/org-profile-entity.interface'; -// User interfaces -export { UserCreatableInterface } from './user/interfaces/user-creatable.interface'; -export { UserOwnableInterface } from './user/interfaces/user-ownable.interface'; -export { UserUpdatableInterface } from './user/interfaces/user-updatable.interface'; -export { UserReplaceableInterface } from './user/interfaces/user-replaceable.interface'; -export { UserRelationInterface } from './user/interfaces/user-relation.interface'; -export { UserInterface } from './user/interfaces/user.interface'; -export { UserEntityInterface } from './user/interfaces/user-entity.interface'; - -export { UserProfileInterface } from './user-profile/interfaces/user-profile.interface'; -export { UserProfileCreatableInterface } from './user-profile/interfaces/user-profile-creatable.interface'; -export { UserProfileEntityInterface } from './user-profile/interfaces/user-profile-entity.interface'; - -export { UserPasswordHistoryInterface } from './user-password-history/interfaces/user-password-history.interface'; -export { UserPasswordHistoryEntityInterface } from './user-password-history/interfaces/user-password-history-entity.interface'; -export { UserPasswordHistoryCreatableInterface } from './user-password-history/interfaces/user-password-history-creatable.interface'; - -export { FederatedCreatableInterface } from './federated/interfaces/federated-creatable.interface'; -export { FederatedUpdatableInterface } from './federated/interfaces/federated-updatable.interface'; -export { FederatedInterface } from './federated/interfaces/federated.interface'; -export { FederatedEntityInterface } from './federated/interfaces/federated-entity.interface'; - -export { RoleAssigneesInterface } from './role/interfaces/role-assignees.interface'; -export { RoleAssignmentCreatableInterface } from './role/interfaces/role-assignment-creatable.interface'; -export { RoleAssignmentInterface } from './role/interfaces/role-assignment.interface'; -export { RoleAssignmentEntityInterface } from './role/interfaces/role-assignment-entity.interface'; -export { RoleCreatableInterface } from './role/interfaces/role-creatable.interface'; -export { RoleUpdatableInterface } from './role/interfaces/role-updatable.interface'; -export { RoleRelationInterface } from './role/interfaces/role-relation.interface'; -export { RoleInterface } from './role/interfaces/role.interface'; -export { RoleEntityInterface } from './role/interfaces/role-entity.interface'; - -export { OtpClearInterface } from './otp/interfaces/otp-clear.interface'; -export { OtpParamsInterface } from './otp/interfaces/otp-params.interface'; -export { OtpCreateParamsInterface } from './otp/interfaces/otp-create-params.interface'; -export { OtpValidateLimitParamsInterface } from './otp/interfaces/otp-validate-limit-params.interface'; -export { OtpCreatableInterface } from './otp/interfaces/otp-creatable.interface'; -export { OtpCreateInterface } from './otp/interfaces/otp-create.interface'; -export { OtpDeleteInterface } from './otp/interfaces/otp-delete.interface'; -export { OtpValidateInterface } from './otp/interfaces/otp-validate.interface'; -export { OtpInterface } from './otp/interfaces/otp.interface'; - -export { CacheClearInterface } from './cache/interfaces/cache-clear.interface'; -export { CacheCreatableInterface } from './cache/interfaces/cache-creatable.interface'; -export { CacheCreateInterface } from './cache/interfaces/cache-create.interface'; -export { CacheDeleteInterface } from './cache/interfaces/cache-delete.interface'; -export { CacheGetOneInterface } from './cache/interfaces/cache-get-one.interface'; -export { CacheUpdatableInterface } from './cache/interfaces/cache-updatable.interface'; -export { CacheUpdateInterface } from './cache/interfaces/cache-update.interface'; -export { CacheInterface } from './cache/interfaces/cache.interface'; - -export { InvitationAcceptedEventPayloadInterface } from './invitation/interfaces/invitation-accepted-event-payload.interface'; -export { InvitationInterface } from './invitation/interfaces/invitation.interface'; -export { InvitationUserInterface } from './invitation/interfaces/invitation-user.interface'; -export { InvitationEntityInterface } from './invitation/invitation-entity.interface'; - export { FileCreatableInterface } from './file/interfaces/file-creatable.interface'; export { FileUpdatableInterface } from './file/interfaces/file-updatable.interface'; export { FileOwnableInterface } from './file/interfaces/file-ownable.interface'; @@ -98,8 +26,3 @@ export { ReportCreatableInterface } from './report/interfaces/report-creatable.i export { ReportUpdatableInterface } from './report/interfaces/report-updatable.interface'; export { ReportInterface } from './report/interfaces/report.interface'; export { ReportEntityInterface } from './report/interfaces/report-entity.interface'; - -export { - INVITATION_MODULE_CATEGORY_ORG_KEY, - INVITATION_MODULE_CATEGORY_USER_KEY, -} from './invitation/invitation.contants'; diff --git a/packages/nestjs-common/src/domain/invitation/interfaces/invitation-accepted-event-payload.interface.ts b/packages/nestjs-common/src/domain/invitation/interfaces/invitation-accepted-event-payload.interface.ts deleted file mode 100644 index 37688ac30..000000000 --- a/packages/nestjs-common/src/domain/invitation/interfaces/invitation-accepted-event-payload.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { LiteralObject } from '../../../utils/interfaces/literal-object.interface'; - -import { InvitationInterface } from './invitation.interface'; - -export interface InvitationAcceptedEventPayloadInterface { - invitation: InvitationInterface; - data?: LiteralObject; -} diff --git a/packages/nestjs-common/src/domain/invitation/interfaces/invitation-user.interface.ts b/packages/nestjs-common/src/domain/invitation/interfaces/invitation-user.interface.ts deleted file mode 100644 index 4d5f9c002..000000000 --- a/packages/nestjs-common/src/domain/invitation/interfaces/invitation-user.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { UserInterface } from '../../user/interfaces/user.interface'; - -export interface InvitationUserInterface - extends Pick {} diff --git a/packages/nestjs-common/src/domain/invitation/interfaces/invitation.interface.ts b/packages/nestjs-common/src/domain/invitation/interfaces/invitation.interface.ts deleted file mode 100644 index d7863b33b..000000000 --- a/packages/nestjs-common/src/domain/invitation/interfaces/invitation.interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceActiveInterface } from '../../../reference/interfaces/reference-active.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { LiteralObject } from '../../../utils/interfaces/literal-object.interface'; -import { UserRelationInterface } from '../../user/interfaces/user-relation.interface'; - -export interface InvitationInterface - extends ReferenceIdInterface, - ReferenceActiveInterface, - UserRelationInterface, - AuditInterface { - code: string; - category: string; - constraints: LiteralObject | undefined; -} diff --git a/packages/nestjs-common/src/domain/invitation/invitation-entity.interface.ts b/packages/nestjs-common/src/domain/invitation/invitation-entity.interface.ts deleted file mode 100644 index e42b59ef3..000000000 --- a/packages/nestjs-common/src/domain/invitation/invitation-entity.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { InvitationInterface } from './interfaces/invitation.interface'; - -export interface InvitationEntityInterface extends InvitationInterface {} diff --git a/packages/nestjs-common/src/domain/invitation/invitation.contants.ts b/packages/nestjs-common/src/domain/invitation/invitation.contants.ts deleted file mode 100644 index dafd7d34a..000000000 --- a/packages/nestjs-common/src/domain/invitation/invitation.contants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const INVITATION_MODULE_CATEGORY_USER_KEY = 'user'; -export const INVITATION_MODULE_CATEGORY_ORG_KEY = 'org'; diff --git a/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile-creatable.interface.ts b/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile-creatable.interface.ts index d84aa5e30..dcc795170 100644 --- a/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile-creatable.interface.ts +++ b/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile-creatable.interface.ts @@ -1,4 +1,6 @@ -import { OrgProfileInterface } from './org-profile.interface'; +import { type OrgProfileInterface } from './org-profile.interface'; -export interface OrgProfileCreatableInterface - extends Pick {} +export interface OrgProfileCreatableInterface extends Pick< + OrgProfileInterface, + 'orgId' +> {} diff --git a/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile-entity.interface.ts b/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile-entity.interface.ts index b1e47ba00..117784ca4 100644 --- a/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile-entity.interface.ts +++ b/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile-entity.interface.ts @@ -1,3 +1,3 @@ -import { OrgProfileInterface } from './org-profile.interface'; +import { type OrgProfileInterface } from './org-profile.interface'; export interface OrgProfileEntityInterface extends OrgProfileInterface {} diff --git a/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile.interface.ts b/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile.interface.ts index d35d2d3d2..c7e254e8d 100644 --- a/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile.interface.ts +++ b/packages/nestjs-common/src/domain/org-profile/interfaces/org-profile.interface.ts @@ -1,8 +1,6 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { OrgOwnableInterface } from '../../org/interfaces/org-ownable.interface'; +import { type AuditInterface } from '../../../audit/interfaces/audit.interface'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type OrgOwnableInterface } from '../../org/interfaces/org-ownable.interface'; export interface OrgProfileInterface - extends ReferenceIdInterface, - AuditInterface, - OrgOwnableInterface {} + extends ReferenceIdInterface, AuditInterface, OrgOwnableInterface {} diff --git a/packages/nestjs-common/src/domain/org/interfaces/org-creatable.interface.ts b/packages/nestjs-common/src/domain/org/interfaces/org-creatable.interface.ts index 331b72225..e74a35177 100644 --- a/packages/nestjs-common/src/domain/org/interfaces/org-creatable.interface.ts +++ b/packages/nestjs-common/src/domain/org/interfaces/org-creatable.interface.ts @@ -1,5 +1,6 @@ -import { OrgInterface } from './org.interface'; +import { type OrgInterface } from './org.interface'; export interface OrgCreatableInterface - extends Pick, + extends + Pick, Partial> {} diff --git a/packages/nestjs-common/src/domain/org/interfaces/org-entity.interface.ts b/packages/nestjs-common/src/domain/org/interfaces/org-entity.interface.ts index 4f0cbe024..d9b96c3fe 100644 --- a/packages/nestjs-common/src/domain/org/interfaces/org-entity.interface.ts +++ b/packages/nestjs-common/src/domain/org/interfaces/org-entity.interface.ts @@ -1,3 +1,3 @@ -import { OrgInterface } from './org.interface'; +import { type OrgInterface } from './org.interface'; export interface OrgEntityInterface extends OrgInterface {} diff --git a/packages/nestjs-common/src/domain/org/interfaces/org-member-entity.interface.ts b/packages/nestjs-common/src/domain/org/interfaces/org-member-entity.interface.ts index 036b24902..20aa16321 100644 --- a/packages/nestjs-common/src/domain/org/interfaces/org-member-entity.interface.ts +++ b/packages/nestjs-common/src/domain/org/interfaces/org-member-entity.interface.ts @@ -1,3 +1,3 @@ -import { OrgMemberInterface } from './org-member.interface'; +import { type OrgMemberInterface } from './org-member.interface'; export interface OrgMemberEntityInterface extends OrgMemberInterface {} diff --git a/packages/nestjs-common/src/domain/org/interfaces/org-member.interface.ts b/packages/nestjs-common/src/domain/org/interfaces/org-member.interface.ts index ff8336767..644e52ba4 100644 --- a/packages/nestjs-common/src/domain/org/interfaces/org-member.interface.ts +++ b/packages/nestjs-common/src/domain/org/interfaces/org-member.interface.ts @@ -1,13 +1,18 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceActiveInterface } from '../../../reference/interfaces/reference-active.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { UserOwnableInterface } from '../../user/interfaces/user-ownable.interface'; +import { type AuditInterface } from '../../../audit/interfaces/audit.interface'; +import { type ReferenceActiveInterface } from '../../../reference/interfaces/reference-active.interface'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type ReferenceId } from '../../../reference/interfaces/reference.types'; -import { OrgOwnableInterface } from './org-ownable.interface'; +import { type OrgOwnableInterface } from './org-ownable.interface'; + +export interface OrgMemberOwnableInterface { + userId: ReferenceId; +} export interface OrgMemberInterface - extends ReferenceIdInterface, + extends + ReferenceIdInterface, ReferenceActiveInterface, OrgOwnableInterface, - UserOwnableInterface, + OrgMemberOwnableInterface, AuditInterface {} diff --git a/packages/nestjs-common/src/domain/org/interfaces/org-ownable.interface.ts b/packages/nestjs-common/src/domain/org/interfaces/org-ownable.interface.ts index 43f83eeb4..d6cea0f73 100644 --- a/packages/nestjs-common/src/domain/org/interfaces/org-ownable.interface.ts +++ b/packages/nestjs-common/src/domain/org/interfaces/org-ownable.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceId } from '../../../reference/interfaces/reference.types'; +import { type ReferenceId } from '../../../reference/interfaces/reference.types'; export interface OrgOwnableInterface { orgId: ReferenceId; diff --git a/packages/nestjs-common/src/domain/org/interfaces/org-owner.interface.ts b/packages/nestjs-common/src/domain/org/interfaces/org-owner.interface.ts index b5ddf16a4..cb56597d1 100644 --- a/packages/nestjs-common/src/domain/org/interfaces/org-owner.interface.ts +++ b/packages/nestjs-common/src/domain/org/interfaces/org-owner.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceId } from '../../../reference/interfaces/reference.types'; +import { type ReferenceId } from '../../../reference/interfaces/reference.types'; export interface OrgOwnerInterface { /** diff --git a/packages/nestjs-common/src/domain/org/interfaces/org-replaceable.interface.ts b/packages/nestjs-common/src/domain/org/interfaces/org-replaceable.interface.ts index 5088131d2..fc850d213 100644 --- a/packages/nestjs-common/src/domain/org/interfaces/org-replaceable.interface.ts +++ b/packages/nestjs-common/src/domain/org/interfaces/org-replaceable.interface.ts @@ -1,6 +1,5 @@ -import { OrgCreatableInterface } from './org-creatable.interface'; -import { OrgInterface } from './org.interface'; +import { type OrgCreatableInterface } from './org-creatable.interface'; +import { type OrgInterface } from './org.interface'; export interface OrgReplaceableInterface - extends Pick, - OrgCreatableInterface {} + extends Pick, OrgCreatableInterface {} diff --git a/packages/nestjs-common/src/domain/org/interfaces/org-updatable.interface.ts b/packages/nestjs-common/src/domain/org/interfaces/org-updatable.interface.ts index 7331515c8..19c5821bb 100644 --- a/packages/nestjs-common/src/domain/org/interfaces/org-updatable.interface.ts +++ b/packages/nestjs-common/src/domain/org/interfaces/org-updatable.interface.ts @@ -1,5 +1,6 @@ -import { OrgInterface } from './org.interface'; +import { type OrgInterface } from './org.interface'; export interface OrgUpdatableInterface - extends Pick, + extends + Pick, Partial> {} diff --git a/packages/nestjs-common/src/domain/org/interfaces/org.interface.ts b/packages/nestjs-common/src/domain/org/interfaces/org.interface.ts index 0f58a65a5..8c9eb499e 100644 --- a/packages/nestjs-common/src/domain/org/interfaces/org.interface.ts +++ b/packages/nestjs-common/src/domain/org/interfaces/org.interface.ts @@ -1,12 +1,13 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceActiveInterface } from '../../../reference/interfaces/reference-active.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { OrgProfileInterface } from '../../org-profile/interfaces/org-profile.interface'; +import { type AuditInterface } from '../../../audit/interfaces/audit.interface'; +import { type ReferenceActiveInterface } from '../../../reference/interfaces/reference-active.interface'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type OrgProfileInterface } from '../../org-profile/interfaces/org-profile.interface'; -import { OrgOwnerInterface } from './org-owner.interface'; +import { type OrgOwnerInterface } from './org-owner.interface'; export interface OrgInterface - extends ReferenceIdInterface, + extends + ReferenceIdInterface, ReferenceActiveInterface, AuditInterface, OrgOwnerInterface { diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-clear.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp-clear.interface.ts deleted file mode 100644 index 036ba90da..000000000 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp-clear.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ReferenceAssignment } from '../../../reference/interfaces/reference.types'; - -import { OtpInterface } from './otp.interface'; - -export interface OtpClearInterface { - /** - * Clear all otps for assign in given category. - * - * @param assignment - The assignment of the repository - * @param otp - The otp to clear - */ - clear( - assignment: ReferenceAssignment, - otp: Pick, - ): Promise; -} diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-create-params.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp-create-params.interface.ts deleted file mode 100644 index e1ae2a9e9..000000000 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp-create-params.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { OtpCreatableInterface } from './otp-creatable.interface'; -import { OtpParamsInterface } from './otp-params.interface'; - -export interface OtpCreateParamsInterface - extends Pick, - Partial> { - clearOnCreate?: boolean; -} diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-create.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp-create.interface.ts deleted file mode 100644 index 0992d8442..000000000 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp-create.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { OtpCreateParamsInterface } from './otp-create-params.interface'; -import { OtpInterface } from './otp.interface'; - -export interface OtpCreateInterface { - /** - * Create a otp with a for the given assignee. - * - * @param params - The otp params - */ - create(params: OtpCreateParamsInterface): Promise; -} diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-delete.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp-delete.interface.ts deleted file mode 100644 index 308fe137a..000000000 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp-delete.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ReferenceAssignment } from '../../../reference/interfaces/reference.types'; - -import { OtpInterface } from './otp.interface'; - -export interface OtpDeleteInterface { - /** - * Delete a otp based on params - * - * @param assignment - The otp assignment - * @param otp - The otp to delete - */ - delete( - assignment: ReferenceAssignment, - otp: Pick, - ): Promise; -} diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-params.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp-params.interface.ts deleted file mode 100644 index 4c5b9145e..000000000 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp-params.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ReferenceAssignment } from '../../../reference/interfaces/reference.types'; - -import { OtpCreatableInterface } from './otp-creatable.interface'; - -export interface OtpParamsInterface { - assignment: ReferenceAssignment; - otp: OtpCreatableInterface; -} diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-validate-limit-params.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp-validate-limit-params.interface.ts deleted file mode 100644 index bd76cf9a8..000000000 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp-validate-limit-params.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AssigneeRelationInterface } from '../../assignee/interfaces/assignee-relation.interface'; - -import { OtpCreatableInterface } from './otp-creatable.interface'; -import { OtpParamsInterface } from './otp-params.interface'; - -export interface OtpValidateLimitParamsInterface - extends Pick, - Pick, - Partial>, - AssigneeRelationInterface {} diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-validate.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp-validate.interface.ts deleted file mode 100644 index b4202d151..000000000 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp-validate.interface.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ReferenceAssignment } from '../../../reference/interfaces/reference.types'; -import { AssigneeRelationInterface } from '../../assignee/interfaces/assignee-relation.interface'; - -import { OtpInterface } from './otp.interface'; - -export interface OtpValidateInterface { - /** - * Check if otp is valid - * - * @param assignment - The otp assignment - * @param otp - The otp to validate - * @param deleteIfValid - If true, delete the otp if it is valid - */ - validate( - assignment: ReferenceAssignment, - otp: Pick, - deleteIfValid: boolean, - ): Promise; -} diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp.interface.ts deleted file mode 100644 index 0bf9bcc26..000000000 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp.interface.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { AssigneeRelationInterface } from '../../assignee/interfaces/assignee-relation.interface'; - -export interface OtpInterface - extends ReferenceIdInterface, - AssigneeRelationInterface, - AuditInterface { - /** - * Name - */ - category: string; - - /** - * Type of the passcode - */ - type: string; - - /** - * Passcode - */ - passcode: string; - - /** - * Date it will expire - */ - expirationDate: Date; - - /** - * is active status - */ - active: boolean; -} diff --git a/packages/nestjs-common/src/domain/password/interfaces/password-storage.interface.ts b/packages/nestjs-common/src/domain/password/interfaces/password-storage.interface.ts deleted file mode 100644 index 1aeeaf4c4..000000000 --- a/packages/nestjs-common/src/domain/password/interfaces/password-storage.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Password storage interface - */ -export interface PasswordStorageInterface { - /** - * Hashed password - */ - passwordHash: string; - - /** - * Salt used to hash password - */ - passwordSalt: string; -} diff --git a/packages/nestjs-common/src/domain/password/is-password-storage.typeguard.ts b/packages/nestjs-common/src/domain/password/is-password-storage.typeguard.ts deleted file mode 100644 index c21910968..000000000 --- a/packages/nestjs-common/src/domain/password/is-password-storage.typeguard.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { PasswordStorageInterface } from './interfaces/password-storage.interface'; - -export function isPasswordStorage( - target: unknown, -): target is PasswordStorageInterface { - return ( - typeof (target as PasswordStorageInterface).passwordHash === 'string' && - typeof (target as PasswordStorageInterface).passwordSalt === 'string' - ); -} diff --git a/packages/nestjs-common/src/domain/report/interfaces/report-creatable.interface.ts b/packages/nestjs-common/src/domain/report/interfaces/report-creatable.interface.ts index 1aa9e926e..1c074d7b0 100644 --- a/packages/nestjs-common/src/domain/report/interfaces/report-creatable.interface.ts +++ b/packages/nestjs-common/src/domain/report/interfaces/report-creatable.interface.ts @@ -1,4 +1,6 @@ -import { ReportInterface } from './report.interface'; +import { type ReportInterface } from './report.interface'; -export interface ReportCreatableInterface - extends Pick {} +export interface ReportCreatableInterface extends Pick< + ReportInterface, + 'serviceKey' | 'name' | 'status' +> {} diff --git a/packages/nestjs-common/src/domain/report/interfaces/report-entity.interface.ts b/packages/nestjs-common/src/domain/report/interfaces/report-entity.interface.ts index dd862e455..9458164b8 100644 --- a/packages/nestjs-common/src/domain/report/interfaces/report-entity.interface.ts +++ b/packages/nestjs-common/src/domain/report/interfaces/report-entity.interface.ts @@ -1,3 +1,3 @@ -import { ReportInterface } from './report.interface'; +import { type ReportInterface } from './report.interface'; export interface ReportEntityInterface extends ReportInterface {} diff --git a/packages/nestjs-common/src/domain/report/interfaces/report-updatable.interface.ts b/packages/nestjs-common/src/domain/report/interfaces/report-updatable.interface.ts index 34ee979e2..ff72add5e 100644 --- a/packages/nestjs-common/src/domain/report/interfaces/report-updatable.interface.ts +++ b/packages/nestjs-common/src/domain/report/interfaces/report-updatable.interface.ts @@ -1,5 +1,6 @@ -import { ReportInterface } from './report.interface'; +import { type ReportInterface } from './report.interface'; export interface ReportUpdatableInterface - extends Pick, + extends + Pick, Partial> {} diff --git a/packages/nestjs-common/src/domain/report/interfaces/report.interface.ts b/packages/nestjs-common/src/domain/report/interfaces/report.interface.ts index 57a980ed2..dee5259aa 100644 --- a/packages/nestjs-common/src/domain/report/interfaces/report.interface.ts +++ b/packages/nestjs-common/src/domain/report/interfaces/report.interface.ts @@ -1,15 +1,13 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { FileOwnableInterface } from '../../file/interfaces/file-ownable.interface'; -import { ReportStatusEnum } from '../enum/report-status.enum'; +import { type AuditInterface } from '../../../audit/interfaces/audit.interface'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type FileOwnableInterface } from '../../file/interfaces/file-ownable.interface'; +import { type ReportStatusEnum } from '../enum/report-status.enum'; /** * Interface representing a report entity */ export interface ReportInterface - extends ReferenceIdInterface, - FileOwnableInterface, - AuditInterface { + extends ReferenceIdInterface, FileOwnableInterface, AuditInterface { /** * Service key associated with the report */ diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-assignees.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-assignees.interface.ts deleted file mode 100644 index 946363ed7..000000000 --- a/packages/nestjs-common/src/domain/role/interfaces/role-assignees.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; - -import { RoleRelationInterface } from './role-relation.interface'; - -export interface RoleAssigneesInterface< - T extends ReferenceIdInterface & - RoleRelationInterface = ReferenceIdInterface & RoleRelationInterface, -> { - assignees: T[]; -} diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-assignment-creatable.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-assignment-creatable.interface.ts deleted file mode 100644 index 7bbcb75a7..000000000 --- a/packages/nestjs-common/src/domain/role/interfaces/role-assignment-creatable.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AssigneeRelationInterface } from '../../assignee/interfaces/assignee-relation.interface'; - -import { RoleRelationInterface } from './role-relation.interface'; - -export interface RoleAssignmentCreatableInterface - extends RoleRelationInterface, - AssigneeRelationInterface {} diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-assignment-entity.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-assignment-entity.interface.ts deleted file mode 100644 index 0d286beb8..000000000 --- a/packages/nestjs-common/src/domain/role/interfaces/role-assignment-entity.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { AssigneeRelationInterface } from '../../assignee/interfaces/assignee-relation.interface'; - -import { RoleRelationInterface } from './role-relation.interface'; - -export interface RoleAssignmentEntityInterface - extends ReferenceIdInterface, - AuditInterface, - AssigneeRelationInterface, - RoleRelationInterface {} diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-assignment.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-assignment.interface.ts deleted file mode 100644 index 2e9997004..000000000 --- a/packages/nestjs-common/src/domain/role/interfaces/role-assignment.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { AssigneeRelationInterface } from '../../assignee/interfaces/assignee-relation.interface'; - -import { RoleRelationInterface } from './role-relation.interface'; - -export interface RoleAssignmentInterface - extends ReferenceIdInterface, - AuditInterface, - AssigneeRelationInterface, - RoleRelationInterface {} diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-creatable.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-creatable.interface.ts deleted file mode 100644 index 9ac62dfa8..000000000 --- a/packages/nestjs-common/src/domain/role/interfaces/role-creatable.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { RoleInterface } from './role.interface'; - -export interface RoleCreatableInterface - extends Pick {} diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-entity.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-entity.interface.ts deleted file mode 100644 index dd0e8cc19..000000000 --- a/packages/nestjs-common/src/domain/role/interfaces/role-entity.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { RoleInterface } from './role.interface'; - -export interface RoleEntityInterface extends RoleInterface {} diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-relation.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-relation.interface.ts deleted file mode 100644 index c1693c85c..000000000 --- a/packages/nestjs-common/src/domain/role/interfaces/role-relation.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ReferenceId } from '../../../reference/interfaces/reference.types'; - -/** - * Belongs to role. - */ -export interface RoleRelationInterface { - roleId: T; -} diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-updatable.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-updatable.interface.ts deleted file mode 100644 index 5906280aa..000000000 --- a/packages/nestjs-common/src/domain/role/interfaces/role-updatable.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { RoleInterface } from './role.interface'; - -export interface RoleUpdatableInterface - extends Pick {} diff --git a/packages/nestjs-common/src/domain/role/interfaces/role.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role.interface.ts deleted file mode 100644 index d06256ff5..000000000 --- a/packages/nestjs-common/src/domain/role/interfaces/role.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; - -export interface RoleInterface extends ReferenceIdInterface, AuditInterface { - /** - * Name - */ - name: string; - - /** - * Name - */ - description: string; -} diff --git a/packages/nestjs-common/src/domain/user-password-history/interfaces/user-password-history-creatable.interface.ts b/packages/nestjs-common/src/domain/user-password-history/interfaces/user-password-history-creatable.interface.ts deleted file mode 100644 index 84dddb23a..000000000 --- a/packages/nestjs-common/src/domain/user-password-history/interfaces/user-password-history-creatable.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { UserPasswordHistoryInterface } from './user-password-history.interface'; - -export interface UserPasswordHistoryCreatableInterface - extends Pick< - UserPasswordHistoryInterface, - 'passwordHash' | 'passwordSalt' | 'userId' - > {} diff --git a/packages/nestjs-common/src/domain/user-password-history/interfaces/user-password-history-entity.interface.ts b/packages/nestjs-common/src/domain/user-password-history/interfaces/user-password-history-entity.interface.ts deleted file mode 100644 index 47142be57..000000000 --- a/packages/nestjs-common/src/domain/user-password-history/interfaces/user-password-history-entity.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { UserPasswordHistoryInterface } from './user-password-history.interface'; - -export interface UserPasswordHistoryEntityInterface - extends UserPasswordHistoryInterface {} diff --git a/packages/nestjs-common/src/domain/user-password-history/interfaces/user-password-history.interface.ts b/packages/nestjs-common/src/domain/user-password-history/interfaces/user-password-history.interface.ts deleted file mode 100644 index 84b9539db..000000000 --- a/packages/nestjs-common/src/domain/user-password-history/interfaces/user-password-history.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { PasswordStorageInterface } from '../../password/interfaces/password-storage.interface'; -import { UserOwnableInterface } from '../../user/interfaces/user-ownable.interface'; - -export interface UserPasswordHistoryInterface - extends ReferenceIdInterface, - PasswordStorageInterface, - UserOwnableInterface, - AuditInterface {} diff --git a/packages/nestjs-common/src/domain/user-profile/interfaces/user-profile-creatable.interface.ts b/packages/nestjs-common/src/domain/user-profile/interfaces/user-profile-creatable.interface.ts deleted file mode 100644 index 50839834d..000000000 --- a/packages/nestjs-common/src/domain/user-profile/interfaces/user-profile-creatable.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { UserProfileInterface } from './user-profile.interface'; - -export interface UserProfileCreatableInterface - extends Pick {} diff --git a/packages/nestjs-common/src/domain/user-profile/interfaces/user-profile-entity.interface.ts b/packages/nestjs-common/src/domain/user-profile/interfaces/user-profile-entity.interface.ts deleted file mode 100644 index 5052999ac..000000000 --- a/packages/nestjs-common/src/domain/user-profile/interfaces/user-profile-entity.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { UserProfileInterface } from './user-profile.interface'; - -export interface UserProfileEntityInterface extends UserProfileInterface {} diff --git a/packages/nestjs-common/src/domain/user-profile/interfaces/user-profile.interface.ts b/packages/nestjs-common/src/domain/user-profile/interfaces/user-profile.interface.ts deleted file mode 100644 index fdd189c08..000000000 --- a/packages/nestjs-common/src/domain/user-profile/interfaces/user-profile.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { UserOwnableInterface } from '../../user/interfaces/user-ownable.interface'; - -export interface UserProfileInterface - extends ReferenceIdInterface, - AuditInterface, - UserOwnableInterface {} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts deleted file mode 100644 index b6e114043..000000000 --- a/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { PasswordStorageInterface } from '../../password/interfaces/password-storage.interface'; - -import { UserInterface } from './user.interface'; - -export interface UserCreatableInterface - extends Pick, - Partial>, - Partial {} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-entity.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-entity.interface.ts deleted file mode 100644 index d3d4f6a24..000000000 --- a/packages/nestjs-common/src/domain/user/interfaces/user-entity.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PasswordStorageInterface } from '../../password/interfaces/password-storage.interface'; - -import { UserInterface } from './user.interface'; - -export interface UserEntityInterface - extends UserInterface, - PasswordStorageInterface {} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-ownable.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-ownable.interface.ts deleted file mode 100644 index 1b07c2ca5..000000000 --- a/packages/nestjs-common/src/domain/user/interfaces/user-ownable.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ReferenceId } from '../../../reference/interfaces/reference.types'; - -import { UserInterface } from './user.interface'; - -export interface UserOwnableInterface { - userId: ReferenceId; - user?: UserInterface; -} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-relation.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-relation.interface.ts deleted file mode 100644 index d8ae22a51..000000000 --- a/packages/nestjs-common/src/domain/user/interfaces/user-relation.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ReferenceId } from '../../../reference/interfaces/reference.types'; - -/** - * Belongs to user. - */ -export interface UserRelationInterface { - userId: T; -} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-replaceable.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-replaceable.interface.ts deleted file mode 100644 index c56f0690c..000000000 --- a/packages/nestjs-common/src/domain/user/interfaces/user-replaceable.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { UserCreatableInterface } from './user-creatable.interface'; -import { UserInterface } from './user.interface'; - -export interface UserReplaceableInterface - extends Pick, - UserCreatableInterface {} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts deleted file mode 100644 index 9c66f404b..000000000 --- a/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { UserCreatableInterface } from './user-creatable.interface'; -import { UserInterface } from './user.interface'; - -export interface UserUpdatableInterface - extends Pick, - Partial< - Pick< - UserCreatableInterface, - 'email' | 'active' | 'passwordHash' | 'passwordSalt' - > - > {} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user.interface.ts deleted file mode 100644 index 25da27d3a..000000000 --- a/packages/nestjs-common/src/domain/user/interfaces/user.interface.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AuditInterface } from '../../../audit/interfaces/audit.interface'; -import { ReferenceActiveInterface } from '../../../reference/interfaces/reference-active.interface'; -import { ReferenceEmailInterface } from '../../../reference/interfaces/reference-email.interface'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { ReferenceUsernameInterface } from '../../../reference/interfaces/reference-username.interface'; - -export interface UserInterface - extends ReferenceIdInterface, - ReferenceEmailInterface, - ReferenceUsernameInterface, - ReferenceActiveInterface, - AuditInterface {} diff --git a/packages/nestjs-access-control/src/enums/action.enum.ts b/packages/nestjs-common/src/enums/action.enum.ts similarity index 100% rename from packages/nestjs-access-control/src/enums/action.enum.ts rename to packages/nestjs-common/src/enums/action.enum.ts diff --git a/packages/nestjs-common/src/enums/operation.enum.spec.ts b/packages/nestjs-common/src/enums/operation.enum.spec.ts new file mode 100644 index 000000000..60566ca6a --- /dev/null +++ b/packages/nestjs-common/src/enums/operation.enum.spec.ts @@ -0,0 +1,33 @@ +import { + MutateOperations, + Operation, + ReadOperations, + WriteOperations, +} from './operation.enum'; + +describe('Operation constants', () => { + it('QueryOperations should contain List and Read', () => { + expect(ReadOperations).toContain(Operation.List); + expect(ReadOperations).toContain(Operation.Read); + expect(ReadOperations).toHaveLength(2); + }); + + it('WriteOperations should contain Create, CreateBatch, Update, Replace', () => { + expect(WriteOperations).toContain(Operation.Create); + expect(WriteOperations).toContain(Operation.CreateBatch); + expect(WriteOperations).toContain(Operation.Update); + expect(WriteOperations).toContain(Operation.Replace); + expect(WriteOperations).toHaveLength(4); + }); + + it('MutateOperations should contain all write operations plus Delete, SoftDelete, and Restore', () => { + expect(MutateOperations).toContain(Operation.Create); + expect(MutateOperations).toContain(Operation.CreateBatch); + expect(MutateOperations).toContain(Operation.Update); + expect(MutateOperations).toContain(Operation.Replace); + expect(MutateOperations).toContain(Operation.Delete); + expect(MutateOperations).toContain(Operation.SoftDelete); + expect(MutateOperations).toContain(Operation.Restore); + expect(MutateOperations).toHaveLength(7); + }); +}); diff --git a/packages/nestjs-common/src/enums/operation.enum.ts b/packages/nestjs-common/src/enums/operation.enum.ts new file mode 100644 index 000000000..7d8b5f70b --- /dev/null +++ b/packages/nestjs-common/src/enums/operation.enum.ts @@ -0,0 +1,43 @@ +/** + * Base operations enum used across the Rockets ecosystem. + * + * This is the single source of truth for operation names. + * Module-specific enums (CrudOperations, HookOperation) should + * mirror these values for consistency. + */ +export enum Operation { + List = 'list', + Read = 'read', + Create = 'create', + CreateBatch = 'createBatch', + Update = 'update', + Replace = 'replace', + Delete = 'delete', + SoftDelete = 'softDelete', + Restore = 'restore', +} + +/** + * Operations that read data without modification. + */ +export const ReadOperations = [Operation.List, Operation.Read] as const; + +/** + * Operations that write data (create/update). + */ +export const WriteOperations = [ + Operation.Create, + Operation.CreateBatch, + Operation.Update, + Operation.Replace, +] as const; + +/** + * Operations that mutate data (write + delete/restore). + */ +export const MutateOperations = [ + ...WriteOperations, + Operation.Delete, + Operation.SoftDelete, + Operation.Restore, +] as const; diff --git a/packages/nestjs-common/src/exceptions/exception.types.ts b/packages/nestjs-common/src/exceptions/exception.types.ts index 2761ad0a9..4bef4d218 100644 --- a/packages/nestjs-common/src/exceptions/exception.types.ts +++ b/packages/nestjs-common/src/exceptions/exception.types.ts @@ -1,4 +1,4 @@ -import { ExceptionContext } from '../core.types'; +import { type ExceptionContext } from '../core.types'; export type RuntimeExceptionContext = ExceptionContext & { originalError?: Error; diff --git a/packages/nestjs-common/src/exceptions/interfaces/exception.interface.ts b/packages/nestjs-common/src/exceptions/interfaces/exception.interface.ts index 441e2e82c..64ef37f42 100644 --- a/packages/nestjs-common/src/exceptions/interfaces/exception.interface.ts +++ b/packages/nestjs-common/src/exceptions/interfaces/exception.interface.ts @@ -1,4 +1,4 @@ -import { ExceptionContext } from '../../core.types'; +import { type ExceptionContext } from '../../core.types'; export interface ExceptionInterface extends Error { /** diff --git a/packages/nestjs-common/src/exceptions/interfaces/runtime-exception-options.interface.ts b/packages/nestjs-common/src/exceptions/interfaces/runtime-exception-options.interface.ts index 153201996..0b6ac483d 100644 --- a/packages/nestjs-common/src/exceptions/interfaces/runtime-exception-options.interface.ts +++ b/packages/nestjs-common/src/exceptions/interfaces/runtime-exception-options.interface.ts @@ -1,4 +1,4 @@ -import { HttpStatus } from '@nestjs/common'; +import { type HttpStatus } from '@nestjs/common'; export interface RuntimeExceptionOptions { httpStatus?: HttpStatus; diff --git a/packages/nestjs-common/src/exceptions/interfaces/runtime-exception.interface.ts b/packages/nestjs-common/src/exceptions/interfaces/runtime-exception.interface.ts index 90f5d79ff..7a2679c87 100644 --- a/packages/nestjs-common/src/exceptions/interfaces/runtime-exception.interface.ts +++ b/packages/nestjs-common/src/exceptions/interfaces/runtime-exception.interface.ts @@ -1,7 +1,7 @@ -import { HttpStatus } from '@nestjs/common'; +import { type HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionContext } from '../exception.types'; -import { ExceptionInterface } from '../interfaces/exception.interface'; +import { type RuntimeExceptionContext } from '../exception.types'; +import { type ExceptionInterface } from '../interfaces/exception.interface'; export interface RuntimeExceptionInterface extends ExceptionInterface { /** diff --git a/packages/nestjs-common/src/exceptions/not-an-error.exception.ts b/packages/nestjs-common/src/exceptions/not-an-error.exception.ts index 469f99940..987975f6a 100644 --- a/packages/nestjs-common/src/exceptions/not-an-error.exception.ts +++ b/packages/nestjs-common/src/exceptions/not-an-error.exception.ts @@ -1,4 +1,4 @@ -import { ExceptionInterface } from './interfaces/exception.interface'; +import { type ExceptionInterface } from './interfaces/exception.interface'; export class NotAnErrorException extends Error implements ExceptionInterface { errorCode = 'NOT_AN_ERROR'; diff --git a/packages/nestjs-common/src/exceptions/runtime.exception.spec.ts b/packages/nestjs-common/src/exceptions/runtime.exception.spec.ts index b0f5e1f8c..f80b70505 100644 --- a/packages/nestjs-common/src/exceptions/runtime.exception.spec.ts +++ b/packages/nestjs-common/src/exceptions/runtime.exception.spec.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from './interfaces/runtime-exception-options.interface'; +import { type RuntimeExceptionOptions } from './interfaces/runtime-exception-options.interface'; import { RuntimeException } from './runtime.exception'; describe(RuntimeException.name, () => { diff --git a/packages/nestjs-common/src/exceptions/runtime.exception.ts b/packages/nestjs-common/src/exceptions/runtime.exception.ts index 6dc2c9e0d..454de7008 100644 --- a/packages/nestjs-common/src/exceptions/runtime.exception.ts +++ b/packages/nestjs-common/src/exceptions/runtime.exception.ts @@ -4,9 +4,9 @@ import { HttpStatus } from '@nestjs/common'; import { mapNonErrorToException } from '../utils/map-non-error-to-exception.util'; -import { RuntimeExceptionContext } from './exception.types'; -import { RuntimeExceptionOptions } from './interfaces/runtime-exception-options.interface'; -import { RuntimeExceptionInterface } from './interfaces/runtime-exception.interface'; +import { type RuntimeExceptionContext } from './exception.types'; +import { type RuntimeExceptionOptions } from './interfaces/runtime-exception-options.interface'; +import { type RuntimeExceptionInterface } from './interfaces/runtime-exception.interface'; export class RuntimeException extends Error diff --git a/packages/nestjs-common/src/filters/exceptions.filter.e2e-spec.ts b/packages/nestjs-common/src/filters/exceptions.filter.e2e-spec.ts index dbbc3e5da..ec50c35d2 100644 --- a/packages/nestjs-common/src/filters/exceptions.filter.e2e-spec.ts +++ b/packages/nestjs-common/src/filters/exceptions.filter.e2e-spec.ts @@ -1,8 +1,8 @@ import supertest from 'supertest'; -import { HttpStatus, INestApplication } from '@nestjs/common'; +import { HttpStatus, type INestApplication } from '@nestjs/common'; import { HttpAdapterHost } from '@nestjs/core'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { ExceptionsFilter } from './exceptions.filter'; diff --git a/packages/nestjs-common/src/filters/exceptions.filter.ts b/packages/nestjs-common/src/filters/exceptions.filter.ts index 7088bc98e..c2cf0a15b 100644 --- a/packages/nestjs-common/src/filters/exceptions.filter.ts +++ b/packages/nestjs-common/src/filters/exceptions.filter.ts @@ -1,4 +1,9 @@ -import { Catch, ArgumentsHost, HttpException } from '@nestjs/common'; +import { + Catch, + ArgumentsHost, + ExceptionFilter, + HttpException, +} from '@nestjs/common'; import { isObject } from '@nestjs/common/utils/shared.utils'; import { HttpAdapterHost } from '@nestjs/core'; @@ -11,7 +16,7 @@ import { RuntimeException } from '../exceptions/runtime.exception'; import { mapHttpStatus } from '../utils/map-http-status.util'; @Catch() -export class ExceptionsFilter implements ExceptionsFilter { +export class ExceptionsFilter implements ExceptionFilter { constructor(private readonly httpAdapterHost: HttpAdapterHost) {} catch(exception: ExceptionInterface, host: ArgumentsHost): void { @@ -45,20 +50,20 @@ export class ExceptionsFilter implements ExceptionsFilter { // its a runtime exception, set error code errorCode = exception.errorCode; // did they provide a status hint? - if (exception?.httpStatus) { + if (exception.httpStatus) { statusCode = exception.httpStatus; } // set the message if (statusCode >= 500) { // use safe message or internal sever error - message = exception?.safeMessage ?? ERROR_MESSAGE_FALLBACK; - } else if (exception?.safeMessage) { + message = exception.safeMessage ?? ERROR_MESSAGE_FALLBACK; + } else if (exception.safeMessage) { // use the safe message message = exception.safeMessage; } else { // use the error message with safe message as fallback message = - exception.message ?? exception?.safeMessage ?? ERROR_MESSAGE_FALLBACK; + exception.message ?? exception.safeMessage ?? ERROR_MESSAGE_FALLBACK; } } diff --git a/packages/nestjs-common/src/index.spec.ts b/packages/nestjs-common/src/index.spec.ts index 0aef420f7..d6065e52b 100644 --- a/packages/nestjs-common/src/index.spec.ts +++ b/packages/nestjs-common/src/index.spec.ts @@ -2,7 +2,6 @@ import { ReferenceIdDto, AuditDto, CommonEntityDto, - AuthUser, createSettingsProvider, } from './index'; @@ -19,11 +18,6 @@ describe('Module Exports', () => { expect(CommonEntityDto).toBeInstanceOf(Function); }); - // Decorators are functions - it('AuthUser decorator should be a function', () => { - expect(AuthUser).toBeInstanceOf(Function); - }); - // Utility functions it('createSettingsProvider should be a function', () => { expect(createSettingsProvider).toBeInstanceOf(Function); diff --git a/packages/nestjs-common/src/index.ts b/packages/nestjs-common/src/index.ts index 2d1b7efc1..23f96c6e4 100644 --- a/packages/nestjs-common/src/index.ts +++ b/packages/nestjs-common/src/index.ts @@ -1,11 +1,17 @@ +// Enums +export { ActionEnum } from './enums/action.enum'; +export { + Operation, + ReadOperations, + WriteOperations, + MutateOperations, +} from './enums/operation.enum'; + // DTOs export { AuditDto } from './audit/dto/audit.dto'; export { CommonEntityDto } from './common/dto/common-entity.dto'; export { ReferenceIdDto } from './reference/dto/reference-id.dto'; -// Decorators -export { AuthUser } from './decorators/auth-user.decorator'; - // Module utilities export { createSettingsProvider } from './modules/utils/create-settings-provider'; @@ -17,13 +23,17 @@ export { ModuleOptionsSettingsInterface } from './modules/interfaces/module-opti export * from './domain'; // Core types & exceptions -export { ExceptionContext } from './core.types'; +export { + ExceptionContext, + ReadOperation, + WriteOperation, + MutateOperation, +} from './core.types'; export { ExceptionInterface } from './exceptions/interfaces/exception.interface'; export { NotAnErrorException } from './exceptions/not-an-error.exception'; // Utility types and functions export { LiteralObject } from './utils/interfaces/literal-object.interface'; -export { Type } from './utils/interfaces/type.interface'; export { DeepPartial } from './utils/deep-partial'; export { mapNonErrorToException } from './utils/map-non-error-to-exception.util'; export { mapHttpStatus } from './utils/map-http-status.util'; @@ -50,6 +60,7 @@ export { ReferenceUsernameInterface } from './reference/interfaces/reference-use export { ReferenceUserInterface } from './reference/interfaces/reference-user.interface'; export { ReferenceRoleInterface } from './reference/interfaces/reference-role.interface'; export { ReferenceRolesInterface } from './reference/interfaces/reference-roles.interface'; +export { ReferenceVersionInterface } from './reference/interfaces/reference-version.interface'; // model exceptions export { ModelQueryException } from './model/exceptions/model-query.exception'; @@ -57,12 +68,7 @@ export { ModelMutateException } from './model/exceptions/model-mutate.exception' export { ModelValidationException } from './model/exceptions/model-validation.exception'; export { ModelIdNoMatchException } from './model/exceptions/model-id-no-match.exception'; -// model services -export { ModelService } from './model/model.service'; -export { ModelServiceInterface } from './model/interfaces/model-service.interface'; - // model query interfaces -export { FindInterface } from './model/interfaces/query/find.interface'; export { ByEmailInterface } from './model/interfaces/query/by-email.interface'; export { ByIdInterface } from './model/interfaces/query/by-id.interface'; export { BySubjectInterface } from './model/interfaces/query/by-subject.interface'; @@ -74,16 +80,6 @@ export { RemoveOneInterface } from './model/interfaces/mutate/remove-one.interfa export { ReplaceOneInterface } from './model/interfaces/mutate/replace-one.interface'; export { UpdateOneInterface } from './model/interfaces/mutate/update-one.interface'; -// Repository interfaces -export { RepositoryInterface } from './repository/interfaces/repository.interface'; -export { RepositoryEntityOptionInterface } from './repository/interfaces/repository-entity-option.interface'; - -// Repository utils -export { getDynamicRepositoryToken } from './repository/utils/get-dynamic-repository-token'; - -// Repository decorators -export { InjectDynamicRepository } from './repository/decorators/inject-dynamic-repository.decorator'; - // Audit types export { AuditDateCreated, @@ -96,7 +92,6 @@ export { export { AuditDateCreatedInterface } from './audit/interfaces/audit-date-created.interface'; export { AuditDateDeletedInterface } from './audit/interfaces/audit-date-deleted.interface'; export { AuditDateUpdatedInterface } from './audit/interfaces/audit-date-updated.interface'; -export { AuditVersionInterface } from './audit/interfaces/audit-version.interface'; export { AuditInterface } from './audit/interfaces/audit.interface'; // exception types @@ -111,6 +106,3 @@ export { RuntimeExceptionInterface } from './exceptions/interfaces/runtime-excep // exceptions export { RuntimeException } from './exceptions/runtime.exception'; - -// !!! THESE EXPORTS ARE TEMPORARY AND MAY BE REMOVED IN THE FUTURE !!! -export { RepositoryInternals } from './repository/interfaces/repository-internals'; diff --git a/packages/nestjs-common/src/model/exceptions/model-id-no-match.exception.ts b/packages/nestjs-common/src/model/exceptions/model-id-no-match.exception.ts index 23bf349f1..f72d131da 100644 --- a/packages/nestjs-common/src/model/exceptions/model-id-no-match.exception.ts +++ b/packages/nestjs-common/src/model/exceptions/model-id-no-match.exception.ts @@ -1,9 +1,9 @@ -import { RuntimeExceptionOptions } from '../../exceptions/interfaces/runtime-exception-options.interface'; +import { type RuntimeExceptionOptions } from '../../exceptions/interfaces/runtime-exception-options.interface'; import { RuntimeException } from '../../exceptions/runtime.exception'; -import { ReferenceId } from '../../reference/interfaces/reference.types'; +import { type ReferenceId } from '../../reference/interfaces/reference.types'; export class ModelIdNoMatchException extends RuntimeException { - context: RuntimeException['context'] & { + declare context: RuntimeException['context'] & { entityName: string; id: ReferenceId; }; @@ -22,7 +22,7 @@ export class ModelIdNoMatchException extends RuntimeException { this.errorCode = 'MODEL_ID_NO_MATCH'; this.context = { - ...super.context, + ...this.context, entityName, id, }; diff --git a/packages/nestjs-common/src/model/exceptions/model-mutate.exception.ts b/packages/nestjs-common/src/model/exceptions/model-mutate.exception.ts index b85b7765e..87664f90b 100644 --- a/packages/nestjs-common/src/model/exceptions/model-mutate.exception.ts +++ b/packages/nestjs-common/src/model/exceptions/model-mutate.exception.ts @@ -1,8 +1,8 @@ -import { RuntimeExceptionOptions } from '../../exceptions/interfaces/runtime-exception-options.interface'; +import { type RuntimeExceptionOptions } from '../../exceptions/interfaces/runtime-exception-options.interface'; import { RuntimeException } from '../../exceptions/runtime.exception'; export class ModelMutateException extends RuntimeException { - context: RuntimeException['context'] & { + declare context: RuntimeException['context'] & { entityName: string; }; @@ -14,7 +14,7 @@ export class ModelMutateException extends RuntimeException { }); this.context = { - ...super.context, + ...this.context, entityName, }; diff --git a/packages/nestjs-common/src/model/exceptions/model-query.exception.ts b/packages/nestjs-common/src/model/exceptions/model-query.exception.ts index 827c2abb2..ba052f3d7 100644 --- a/packages/nestjs-common/src/model/exceptions/model-query.exception.ts +++ b/packages/nestjs-common/src/model/exceptions/model-query.exception.ts @@ -1,8 +1,8 @@ -import { RuntimeExceptionOptions } from '../../exceptions/interfaces/runtime-exception-options.interface'; +import { type RuntimeExceptionOptions } from '../../exceptions/interfaces/runtime-exception-options.interface'; import { RuntimeException } from '../../exceptions/runtime.exception'; export class ModelQueryException extends RuntimeException { - context: RuntimeException['context'] & { + declare context: RuntimeException['context'] & { entityName: string; }; @@ -14,7 +14,7 @@ export class ModelQueryException extends RuntimeException { }); this.context = { - ...super.context, + ...this.context, entityName, }; diff --git a/packages/nestjs-common/src/model/exceptions/model-validation.exception.ts b/packages/nestjs-common/src/model/exceptions/model-validation.exception.ts index bf503450f..5d1c34208 100644 --- a/packages/nestjs-common/src/model/exceptions/model-validation.exception.ts +++ b/packages/nestjs-common/src/model/exceptions/model-validation.exception.ts @@ -1,10 +1,10 @@ -import { ValidationError } from 'class-validator'; +import { type ValidationError } from 'class-validator'; -import { RuntimeExceptionOptions } from '../../exceptions/interfaces/runtime-exception-options.interface'; +import { type RuntimeExceptionOptions } from '../../exceptions/interfaces/runtime-exception-options.interface'; import { RuntimeException } from '../../exceptions/runtime.exception'; export class ModelValidationException extends RuntimeException { - context: RuntimeException['context'] & { + declare context: RuntimeException['context'] & { entityName: string; validationErrors: ValidationError[]; }; @@ -21,7 +21,7 @@ export class ModelValidationException extends RuntimeException { }); this.context = { - ...super.context, + ...this.context, entityName, validationErrors, }; diff --git a/packages/nestjs-common/src/model/interfaces/model-service.interface.ts b/packages/nestjs-common/src/model/interfaces/model-service.interface.ts deleted file mode 100644 index 14774247b..000000000 --- a/packages/nestjs-common/src/model/interfaces/model-service.interface.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ReferenceIdInterface } from '../../reference/interfaces/reference-id.interface'; -import { DeepPartial } from '../../utils/deep-partial'; - -import { CreateOneInterface } from './mutate/create-one.interface'; -import { RemoveOneInterface } from './mutate/remove-one.interface'; -import { ReplaceOneInterface } from './mutate/replace-one.interface'; -import { UpdateOneInterface } from './mutate/update-one.interface'; -import { ByIdInterface } from './query/by-id.interface'; -import { FindInterface } from './query/find.interface'; - -export interface ModelServiceInterface< - Entity extends ReferenceIdInterface, - Creatable extends DeepPartial, - Updatable extends DeepPartial & ReferenceIdInterface, - Replaceable extends Creatable & Pick = Creatable & - Pick, - Removable extends Pick = Pick, -> extends FindInterface, - ByIdInterface, - CreateOneInterface, - UpdateOneInterface, - ReplaceOneInterface, - RemoveOneInterface { - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - gt(value: T): any; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - gte(value: T): any; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - lt(value: T): any; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - lte(value: T): any; -} diff --git a/packages/nestjs-common/src/model/interfaces/mutate/create-one.interface.ts b/packages/nestjs-common/src/model/interfaces/mutate/create-one.interface.ts index a857468a0..33470c46a 100644 --- a/packages/nestjs-common/src/model/interfaces/mutate/create-one.interface.ts +++ b/packages/nestjs-common/src/model/interfaces/mutate/create-one.interface.ts @@ -1,6 +1,6 @@ -import { PlainLiteralObject } from '@nestjs/common'; +import { type PlainLiteralObject } from '@nestjs/common'; -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; export interface CreateOneInterface< T extends PlainLiteralObject, diff --git a/packages/nestjs-common/src/model/interfaces/mutate/remove-one.interface.ts b/packages/nestjs-common/src/model/interfaces/mutate/remove-one.interface.ts index d015c7758..c15e06e7c 100644 --- a/packages/nestjs-common/src/model/interfaces/mutate/remove-one.interface.ts +++ b/packages/nestjs-common/src/model/interfaces/mutate/remove-one.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; export interface RemoveOneInterface< T extends ReferenceIdInterface, diff --git a/packages/nestjs-common/src/model/interfaces/mutate/replace-one.interface.ts b/packages/nestjs-common/src/model/interfaces/mutate/replace-one.interface.ts index 2ae74f07c..d4667cdea 100644 --- a/packages/nestjs-common/src/model/interfaces/mutate/replace-one.interface.ts +++ b/packages/nestjs-common/src/model/interfaces/mutate/replace-one.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; export interface ReplaceOneInterface< T extends ReferenceIdInterface, diff --git a/packages/nestjs-common/src/model/interfaces/mutate/update-one.interface.ts b/packages/nestjs-common/src/model/interfaces/mutate/update-one.interface.ts index 4005050e8..7b4e6320b 100644 --- a/packages/nestjs-common/src/model/interfaces/mutate/update-one.interface.ts +++ b/packages/nestjs-common/src/model/interfaces/mutate/update-one.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; export interface UpdateOneInterface< T extends ReferenceIdInterface, diff --git a/packages/nestjs-common/src/model/interfaces/query/by-email.interface.ts b/packages/nestjs-common/src/model/interfaces/query/by-email.interface.ts index f0361f733..3b5f740af 100644 --- a/packages/nestjs-common/src/model/interfaces/query/by-email.interface.ts +++ b/packages/nestjs-common/src/model/interfaces/query/by-email.interface.ts @@ -1,5 +1,5 @@ -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { ReferenceEmail } from '../../../reference/interfaces/reference.types'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type ReferenceEmail } from '../../../reference/interfaces/reference.types'; export interface ByEmailInterface< T = ReferenceEmail, diff --git a/packages/nestjs-common/src/model/interfaces/query/by-id.interface.ts b/packages/nestjs-common/src/model/interfaces/query/by-id.interface.ts index 11d3b8557..074f8134b 100644 --- a/packages/nestjs-common/src/model/interfaces/query/by-id.interface.ts +++ b/packages/nestjs-common/src/model/interfaces/query/by-id.interface.ts @@ -1,5 +1,5 @@ -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { ReferenceId } from '../../../reference/interfaces/reference.types'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type ReferenceId } from '../../../reference/interfaces/reference.types'; export interface ByIdInterface { byId: (id: T) => Promise; diff --git a/packages/nestjs-common/src/model/interfaces/query/by-subject.interface.ts b/packages/nestjs-common/src/model/interfaces/query/by-subject.interface.ts index 9bd744812..45a15c80d 100644 --- a/packages/nestjs-common/src/model/interfaces/query/by-subject.interface.ts +++ b/packages/nestjs-common/src/model/interfaces/query/by-subject.interface.ts @@ -1,5 +1,5 @@ -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { ReferenceSubject } from '../../../reference/interfaces/reference.types'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type ReferenceSubject } from '../../../reference/interfaces/reference.types'; export interface BySubjectInterface< T = ReferenceSubject, diff --git a/packages/nestjs-common/src/model/interfaces/query/by-username.interface.ts b/packages/nestjs-common/src/model/interfaces/query/by-username.interface.ts index 5eb3ee7b4..74a7236e8 100644 --- a/packages/nestjs-common/src/model/interfaces/query/by-username.interface.ts +++ b/packages/nestjs-common/src/model/interfaces/query/by-username.interface.ts @@ -1,5 +1,5 @@ -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { ReferenceUsername } from '../../../reference/interfaces/reference.types'; +import { type ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { type ReferenceUsername } from '../../../reference/interfaces/reference.types'; export interface ByUsernameInterface< T = ReferenceUsername, diff --git a/packages/nestjs-common/src/model/interfaces/query/find.interface.ts b/packages/nestjs-common/src/model/interfaces/query/find.interface.ts deleted file mode 100644 index 856ab31f0..000000000 --- a/packages/nestjs-common/src/model/interfaces/query/find.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; -import { ReferenceId } from '../../../reference/interfaces/reference.types'; -import { RepositoryInternals } from '../../../repository/interfaces/repository-internals'; - -export interface FindInterface { - find(options?: RepositoryInternals.FindManyOptions): Promise; -} diff --git a/packages/nestjs-common/src/model/model.service.ts b/packages/nestjs-common/src/model/model.service.ts deleted file mode 100644 index dcf3e3a2c..000000000 --- a/packages/nestjs-common/src/model/model.service.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { plainToInstance } from 'class-transformer'; -import { validate } from 'class-validator'; - -import { ReferenceIdInterface } from '../reference/interfaces/reference-id.interface'; -import { RepositoryInternals } from '../repository/interfaces/repository-internals'; -import { RepositoryInterface } from '../repository/interfaces/repository.interface'; -import { DeepPartial } from '../utils/deep-partial'; -import { Type } from '../utils/interfaces/type.interface'; - -import { ModelIdNoMatchException } from './exceptions/model-id-no-match.exception'; -import { ModelMutateException } from './exceptions/model-mutate.exception'; -import { ModelValidationException } from './exceptions/model-validation.exception'; -import { ModelServiceInterface } from './interfaces/model-service.interface'; - -/** - * Abstract mutate service - */ -export abstract class ModelService< - Entity extends ReferenceIdInterface, - Creatable extends DeepPartial, - Updatable extends DeepPartial & ReferenceIdInterface, - Replaceable extends Creatable & Pick = Creatable & - Pick, - Removable extends Pick = Pick, -> implements - ModelServiceInterface -{ - protected abstract createDto: Type; - protected abstract updateDto: Type; - - /** - * Constructor - * - * @param repo - instance of the repo - */ - constructor(protected repo: RepositoryInterface) {} - - /** - * Greater than - */ - gt(value: T) { - return this.repo.gt(value); - } - - /** - * Greater than or equal - */ - gte(value: T) { - return this.repo.gte(value); - } - - /** - * Less than or equal - */ - lt(value: T) { - return this.repo.lt(value); - } - - /** - * Less than - */ - lte(value: T) { - return this.repo.lte(value); - } - - /** - * Find - * - * @param options - Find many options - */ - async find( - options?: RepositoryInternals.FindManyOptions, - ): Promise { - return this.repo.find(options); - } - - /** - * Get entity for the given id. - * - * @param id - the id - */ - async byId(id: Entity['id']): Promise { - return this.repo.findOne({ - where: { id }, - } as RepositoryInternals.FindOneOptions); - } - - /** - * Create one - * - * @param data - the reference to create - * @returns the created reference - */ - async create(data: Creatable): Promise { - // validate the data - const dto = await this.validate(this.createDto, data); - // apply transformations - const transformed = await this.transform(dto); - // create new entity - const entity = this.repo.create(transformed); - // try to save the entity - return this.save(entity); - } - - /** - * Update one - * - * @param data - the reference data to update - * @returns the updated reference - */ - async update(data: Updatable): Promise { - // the entity we will update - const entity = await this.findByIdOrFail(data.id); - // yes, validate the data - const dto = await this.validate(this.updateDto, data); - // apply transformations - const transformed = await this.transform(dto); - // merge changes into the entity - const mergedEntity = this.repo.merge(entity, transformed); - // try to save it - return this.save(mergedEntity); - } - - /** - * Replace one - * - * @param data - the reference data to replace - * @returns the replaced reference - */ - async replace(data: Replaceable): Promise { - // the entity we will replace - const entity = await this.findByIdOrFail(data.id); - // yes, validate the data - const dto = await this.validate(this.createDto, data); - // apply transformations - const transformed = await this.transform(dto); - // merge changes into the entity - const mergedEntity = this.repo.merge(entity, transformed); - // try to save it - return this.save(mergedEntity); - } - - /** - * Remove one - * - * @param data - the reference data to remove - * @returns the removed reference - */ - async remove(data: Removable): Promise { - // try to find it - const entity = await this.findByIdOrFail(data.id); - // try to remove it - return this.delete(entity); - } - - /** - * @internal - */ - private async save(entity: Entity): Promise { - // try to save it - try { - return await this.repo.save(entity); - } catch (e) { - throw new ModelMutateException(this.repo.entityName(), { - originalError: e, - }); - } - } - - /** - * @internal - */ - private async delete(entity: Entity): Promise { - // try to save it - try { - return await this.repo.remove(entity); - } catch (e) { - throw new ModelMutateException(this.repo.entityName(), { - originalError: e, - }); - } - } - - /** - * @internal - */ - protected async validate>( - type: Type, - data: T, - ): Promise { - // convert to dto - const dto = plainToInstance(type, data); - - // validate the data - const validationErrors = await validate(dto); - - // any errors? - if (validationErrors?.length) { - // yes, throw error - throw new ModelValidationException( - this.repo.entityName(), - validationErrors, - ); - } - - return dto; - } - - /** - * @internal - */ - protected async transform( - data: DeepPartial, - ): Promise> { - return data; - } - - /** - * @internal - */ - protected async findByIdOrFail(id: Entity['id']): Promise { - // try to find the ref - const entity = await this.byId(id); - - // did we get one? - if (entity) { - return entity; - } else { - throw new ModelIdNoMatchException(this.repo.entityName(), id); - } - } -} diff --git a/packages/nestjs-common/src/modules/interfaces/module-options-controller.interface.ts b/packages/nestjs-common/src/modules/interfaces/module-options-controller.interface.ts index e5598dbb4..a4c9d8fd9 100644 --- a/packages/nestjs-common/src/modules/interfaces/module-options-controller.interface.ts +++ b/packages/nestjs-common/src/modules/interfaces/module-options-controller.interface.ts @@ -1,4 +1,4 @@ -import { Type } from '@nestjs/common'; +import { type Type } from '@nestjs/common'; export interface ModuleOptionsControllerInterface { controller?: false | Type | Type[]; diff --git a/packages/nestjs-common/src/modules/utils/create-settings-provider.spec.ts b/packages/nestjs-common/src/modules/utils/create-settings-provider.spec.ts index b3bb4b035..ed34028b5 100644 --- a/packages/nestjs-common/src/modules/utils/create-settings-provider.spec.ts +++ b/packages/nestjs-common/src/modules/utils/create-settings-provider.spec.ts @@ -1,4 +1,4 @@ -import { FactoryProvider } from '@nestjs/common'; +import { type FactoryProvider } from '@nestjs/common'; import { createSettingsProvider } from './create-settings-provider'; diff --git a/packages/nestjs-common/src/modules/utils/create-settings-provider.ts b/packages/nestjs-common/src/modules/utils/create-settings-provider.ts index 075785d99..99b670347 100644 --- a/packages/nestjs-common/src/modules/utils/create-settings-provider.ts +++ b/packages/nestjs-common/src/modules/utils/create-settings-provider.ts @@ -1,6 +1,6 @@ -import { InjectionToken, Provider } from '@nestjs/common'; +import { type InjectionToken, type Provider } from '@nestjs/common'; -import { ModuleOptionsSettingsInterface } from '../interfaces/module-options-settings.interface'; +import { type ModuleOptionsSettingsInterface } from '../interfaces/module-options-settings.interface'; export function createSettingsProvider< ModuleSettingsType, ModuleOptionsType extends ModuleOptionsSettingsInterface, diff --git a/packages/nestjs-common/src/reference/interfaces/reference-active.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-active.interface.ts index 470cf7877..e6ce31d83 100644 --- a/packages/nestjs-common/src/reference/interfaces/reference-active.interface.ts +++ b/packages/nestjs-common/src/reference/interfaces/reference-active.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceActive } from './reference.types'; +import { type ReferenceActive } from './reference.types'; /** * Identifiable by active. diff --git a/packages/nestjs-common/src/reference/interfaces/reference-assignee.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-assignee.interface.ts index 43c0092ea..50fbb26c5 100644 --- a/packages/nestjs-common/src/reference/interfaces/reference-assignee.interface.ts +++ b/packages/nestjs-common/src/reference/interfaces/reference-assignee.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceIdInterface } from './reference-id.interface'; +import { type ReferenceIdInterface } from './reference-id.interface'; /** * Identifiable by assignee. diff --git a/packages/nestjs-common/src/reference/interfaces/reference-assignment.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-assignment.interface.ts index 0002470d8..f821e9e61 100644 --- a/packages/nestjs-common/src/reference/interfaces/reference-assignment.interface.ts +++ b/packages/nestjs-common/src/reference/interfaces/reference-assignment.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceAssignment } from './reference.types'; +import { type ReferenceAssignment } from './reference.types'; /** * Identifiable by assignment. diff --git a/packages/nestjs-common/src/reference/interfaces/reference-email.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-email.interface.ts index ce35e9fce..5f4715b24 100644 --- a/packages/nestjs-common/src/reference/interfaces/reference-email.interface.ts +++ b/packages/nestjs-common/src/reference/interfaces/reference-email.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceEmail } from './reference.types'; +import { type ReferenceEmail } from './reference.types'; /** * Identifiable by email. diff --git a/packages/nestjs-common/src/reference/interfaces/reference-id.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-id.interface.ts index 63767c852..4b6c8ded8 100644 --- a/packages/nestjs-common/src/reference/interfaces/reference-id.interface.ts +++ b/packages/nestjs-common/src/reference/interfaces/reference-id.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceId } from './reference.types'; +import { type ReferenceId } from './reference.types'; /** * Identifiable by id. diff --git a/packages/nestjs-common/src/reference/interfaces/reference-role.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-role.interface.ts index cd12158ed..834f18aa3 100644 --- a/packages/nestjs-common/src/reference/interfaces/reference-role.interface.ts +++ b/packages/nestjs-common/src/reference/interfaces/reference-role.interface.ts @@ -1,5 +1,5 @@ -import { ReferenceIdInterface } from './reference-id.interface'; -import { ReferenceId } from './reference.types'; +import { type ReferenceIdInterface } from './reference-id.interface'; +import { type ReferenceId } from './reference.types'; /** * References a role. diff --git a/packages/nestjs-common/src/reference/interfaces/reference-roles.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-roles.interface.ts index 539c4448b..4d2ee8966 100644 --- a/packages/nestjs-common/src/reference/interfaces/reference-roles.interface.ts +++ b/packages/nestjs-common/src/reference/interfaces/reference-roles.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceIdInterface } from './reference-id.interface'; +import { type ReferenceIdInterface } from './reference-id.interface'; /** * References roles. diff --git a/packages/nestjs-common/src/reference/interfaces/reference-subject.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-subject.interface.ts index d240702e7..79703e8c7 100644 --- a/packages/nestjs-common/src/reference/interfaces/reference-subject.interface.ts +++ b/packages/nestjs-common/src/reference/interfaces/reference-subject.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceSubject } from './reference.types'; +import { type ReferenceSubject } from './reference.types'; /** * Identifiable by subject (JWT). diff --git a/packages/nestjs-common/src/reference/interfaces/reference-user.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-user.interface.ts index 72f0e6646..5abef1554 100644 --- a/packages/nestjs-common/src/reference/interfaces/reference-user.interface.ts +++ b/packages/nestjs-common/src/reference/interfaces/reference-user.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceIdInterface } from './reference-id.interface'; +import { type ReferenceIdInterface } from './reference-id.interface'; export interface ReferenceUserInterface { user: T; diff --git a/packages/nestjs-common/src/reference/interfaces/reference-username.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-username.interface.ts index eac984b77..8876b12a0 100644 --- a/packages/nestjs-common/src/reference/interfaces/reference-username.interface.ts +++ b/packages/nestjs-common/src/reference/interfaces/reference-username.interface.ts @@ -1,4 +1,4 @@ -import { ReferenceUsername } from './reference.types'; +import { type ReferenceUsername } from './reference.types'; /** * Identifiable by username. diff --git a/packages/nestjs-common/src/reference/interfaces/reference-version.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-version.interface.ts new file mode 100644 index 000000000..4356fb864 --- /dev/null +++ b/packages/nestjs-common/src/reference/interfaces/reference-version.interface.ts @@ -0,0 +1,8 @@ +/** + * Identifiable by version. + * + * Domain-level version for optimistic concurrency. + */ +export interface ReferenceVersionInterface { + version: number; +} diff --git a/packages/nestjs-common/src/repository/interfaces/repository-entity-option.interface.ts b/packages/nestjs-common/src/repository/interfaces/repository-entity-option.interface.ts deleted file mode 100644 index 6edd83557..000000000 --- a/packages/nestjs-common/src/repository/interfaces/repository-entity-option.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PlainLiteralObject, Type } from '@nestjs/common'; - -export interface RepositoryEntityOptionInterface< - T extends PlainLiteralObject = PlainLiteralObject, -> { - entity: Type; -} diff --git a/packages/nestjs-common/src/repository/interfaces/repository-internals.ts b/packages/nestjs-common/src/repository/interfaces/repository-internals.ts deleted file mode 100644 index e11649bd1..000000000 --- a/packages/nestjs-common/src/repository/interfaces/repository-internals.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable tsdoc/syntax */ -/* eslint-disable @typescript-eslint/no-namespace */ -/** - * !!! COPIED FROM TYPEORM !!! - * - * Some were not copied verbatim due to ridiculousness. - * - * These types need to be reduces to the smalles possible interface. - */ - -export namespace RepositoryInternals { - /** - * A single property handler for FindOptionsOrder. - */ - export type FindOptionsOrderProperty = - Property extends Promise - ? FindOptionsOrderProperty> - : Property extends Array - ? FindOptionsOrderProperty> - : Property extends (...args: unknown[]) => unknown - ? never - : Property extends string - ? FindOptionsOrderValue - : Property extends number - ? FindOptionsOrderValue - : Property extends boolean - ? FindOptionsOrderValue - : Property extends Date - ? FindOptionsOrderValue - : Property extends object - ? FindOptionsOrder | FindOptionsOrderValue - : FindOptionsOrderValue; - /** - * Order by find options. - */ - export type FindOptionsOrder = { - [P in keyof Entity]?: P extends 'toString' - ? unknown - : FindOptionsOrderProperty>; - }; - /** - * Value of order by in find options. - */ - export type FindOptionsOrderValue = - | 'ASC' - | 'DESC' - | 'asc' - | 'desc' - | 1 - | -1 - | { - direction?: 'asc' | 'desc' | 'ASC' | 'DESC'; - nulls?: 'first' | 'last' | 'FIRST' | 'LAST'; - }; - - /** - * Defines a special criteria to find specific entity. - */ - export interface FindOneOptions { - /** - * Simple condition that should be applied to match entities. - */ - where?: FindOptionsWhere[] | FindOptionsWhere; - /** - * Order, in which entities should be ordered. - */ - order?: FindOptionsOrder; - } - - /** - * A single property handler for FindOptionsWhere. - * - * The reason why we have both "PropertyToBeNarrowed" and "Property" is that Union is narrowed down when extends is used. - * It means the result of FindOptionsWhereProperty<1 | 2> doesn't include FindOperator<1 | 2> but FindOperator<1> | FindOperator<2>. - * So we keep the original Union as Original and pass it to the FindOperator too. Original remains Union as extends is not used for it. - */ - export type FindOptionsWhereProperty< - PropertyToBeNarrowed, - Property = PropertyToBeNarrowed, - > = - PropertyToBeNarrowed extends Promise - ? FindOptionsWhereProperty> - : PropertyToBeNarrowed extends Array - ? FindOptionsWhereProperty> - : PropertyToBeNarrowed extends (...args: unknown[]) => unknown - ? never - : Property; - - /** - * Used for find operations. - */ - export type FindOptionsWhere = { - [P in keyof Entity]?: P extends 'toString' - ? unknown - : FindOptionsWhereProperty>; - }; - - /** - * Result object returned by UpdateQueryBuilder execution. - */ - export declare class UpdateResult { - /** - * Number of affected rows/documents - * Not all drivers support this - */ - affected?: number; - } - - /** - * Special options passed to Repository#save, Repository#insert and Repository#update methods. - */ - export interface SaveOptions { - /** - * Additional data to be passed with persist method. - * This data can be used in subscribers then. - */ - data?: any; - /** - * By default chunk is not applied. To enable chunking, specify chunk size. - * For example, specifying { chunk: 50 } will execute 50 inserts per chunk. - */ - chunk?: number; - } - - /** - * Defines a special criteria to find specific entities. - */ - export interface FindManyOptions - extends FindOneOptions { - /** - * Offset (paginated) where from entities should be taken. - */ - skip?: number; - /** - * Limit (paginated) - max number of entities should be taken. - */ - take?: number; - } -} diff --git a/packages/nestjs-common/src/repository/interfaces/repository.interface.ts b/packages/nestjs-common/src/repository/interfaces/repository.interface.ts deleted file mode 100644 index 8771e63be..000000000 --- a/packages/nestjs-common/src/repository/interfaces/repository.interface.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { DeepPartial } from '../../utils/deep-partial'; - -import { RepositoryInternals } from './repository-internals'; - -export interface RepositoryInterface { - entityName(): string; - - find( - options?: RepositoryInternals.FindManyOptions, - ): Promise; - - findOne( - options: RepositoryInternals.FindOneOptions, - ): Promise; - - create(entityLike: DeepPartial): Entity; - - merge(mergeIntoEntity: Entity, ...entityLikes: DeepPartial[]): Entity; - - save>( - entities: T[], - options?: RepositoryInternals.SaveOptions, - ): Promise<(T & Entity)[]>; - save>( - entity: T, - options?: RepositoryInternals.SaveOptions, - ): Promise; - - remove(entities: Entity[]): Promise; - remove(entity: Entity): Promise; - - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - gt(value: T): any; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - gte(value: T): any; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - lt(value: T): any; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - lte(value: T): any; -} diff --git a/packages/nestjs-common/src/utils/interfaces/type.interface.ts b/packages/nestjs-common/src/utils/interfaces/type.interface.ts deleted file mode 100644 index d8ffdb12a..000000000 --- a/packages/nestjs-common/src/utils/interfaces/type.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface Type { - new (...args: unknown[]): T; -} diff --git a/packages/nestjs-common/src/utils/to-milliseconds.ts b/packages/nestjs-common/src/utils/to-milliseconds.ts index 2d3d186fc..db6c51fac 100644 --- a/packages/nestjs-common/src/utils/to-milliseconds.ts +++ b/packages/nestjs-common/src/utils/to-milliseconds.ts @@ -1,5 +1,7 @@ import ms from 'ms'; +import { HttpStatus } from '@nestjs/common'; + import { RuntimeException } from '../exceptions/runtime.exception'; /** @@ -16,13 +18,14 @@ export function toMilliseconds( value: unknown, fallback?: ms.StringValue | number, ): number { - const result = ms((value as ms.StringValue) ?? fallback); + const input = typeof value === 'string' ? value : fallback; + const result = ms(input as ms.StringValue); if (typeof result === 'number') { return result; } else { throw new RuntimeException({ message: 'Invalid ms string value', - httpStatus: 400, + httpStatus: HttpStatus.BAD_REQUEST, }); } } diff --git a/packages/nestjs-common/tsconfig.esm.json b/packages/nestjs-common/tsconfig.esm.json new file mode 100644 index 000000000..1f9f959a2 --- /dev/null +++ b/packages/nestjs-common/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "outDir": "./dist/esm", + "tsBuildInfoFile": "./dist/esm/.tsbuildinfo" + } +} diff --git a/packages/nestjs-common/tsconfig.json b/packages/nestjs-common/tsconfig.json index ef9980950..ae0781064 100644 --- a/packages/nestjs-common/tsconfig.json +++ b/packages/nestjs-common/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "composite": true, "rootDir": "./src", - "outDir": "./dist", + "outDir": "./dist/cjs", "typeRoots": [ "./node_modules/@types", "../../node_modules/@types" diff --git a/packages/nestjs-core/README.md b/packages/nestjs-core/README.md new file mode 100644 index 000000000..d58160dc5 --- /dev/null +++ b/packages/nestjs-core/README.md @@ -0,0 +1,806 @@ +# @concepta/nestjs-core + +Application-level module that wires Rockets framework primitives into a NestJS +application. Register it once at the root and all Rockets features it provides +become globally available without importing them in every module. + +Provides: **hook system** · **per-request context overlays** · **domain +exceptions** · **event context** · **aggregate base classes** · **testing +utilities**. + +## Project + +[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-core)](https://www.npmjs.com/package/@concepta/nestjs-core) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-core)](https://www.npmjs.com/package/@concepta/nestjs-core) +[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) +[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +## Table of Contents + +- [Installation](#installation) +- [Module Registration](#module-registration) +- [Hook Feature](#hook-feature) + - [Defining a Hook](#defining-a-hook) + - [Attaching Hooks to Controllers](#attaching-hooks-to-controllers) + - [Specification Guards](#specification-guards) + - [Consuming Hooks](#consuming-hooks) +- [Context System](#context-system) +- [Exceptions](#exceptions) +- [Schemas & OpenAPI](#schemas--openapi) +- [Event Context](#event-context) +- [Aggregate](#aggregate-conceptanestjs-coreaggregate-subpath) +- [Testing](#testing-conceptanestjs-coretesting-subpath) +- [API Reference](#api-reference) + +## Installation + +```sh +yarn add @concepta/nestjs-core @nestjs/common @nestjs/core @nestjs/swagger rxjs +``` + +### Requirements + +ESM-only — no CJS build is published. Requires Node `>= 22.12` and +NestJS 12. + +### Subpath exports + +| Import path | What it provides | +| --- | --- | +| `@concepta/nestjs-core` | Full public surface: hooks, context, exceptions, references, utilities, enums, schemas (`auditSchema`, `referenceIdSchema`, `conformsTo`, `withOpenApi`, `withNamedComponent`, `standardSchemaConverter`, `isStandardSchema`). | +| `@concepta/nestjs-core/aggregate` | `DomainAggregate`, `DomainMapper`, `domainAggregateSchema`, `AggregateMetaInterface`. | +| `@concepta/nestjs-core/testing` | `createMockEventPublisher`, `createMockCommandBus`, `createMockQueryBus`, `collectRuntimeExceptionClassNames`. | + +### Dependencies + +Direct dependencies: `ms`, `rxjs`, `zod` (^4.4.3). + +### Peer Dependencies + +| Package | Required | Notes | +| --- | --- | --- | +| `@nestjs/common` | Yes | NestJS 12 framework — install explicitly, no longer bundled | +| `@nestjs/core` | Yes | Module reference and reflection — install explicitly | +| `@nestjs/swagger` | Yes | Required by the schema/OpenAPI bridge utilities — install explicitly | +| `rxjs` | Yes | Observable support | +| `@nestjs/cqrs` | No | Optional peer — only if using CQRS patterns | + +## Module Registration + +Register `CoreModule` once at the application root. It defaults to +`global: true` so a global `APP_INTERCEPTOR` registered by the module +intercepts requests from every controller in the app without additional imports. + +### Synchronous + +```ts +import { CoreModule } from '@concepta/nestjs-core'; + +@Module({ + imports: [CoreModule.forRoot()], +}) +export class AppModule {} +``` + +### Asynchronous + +```ts +@Module({ + imports: [ + CoreModule.forRootAsync({ + useFactory: async () => ({}), + }), + ], +}) +export class AppModule {} +``` + +### Methods + +| Method | Description | +| --- | --- | +| `forRoot(options?)` | Synchronous global registration. `global: true` by default. | +| `forRootAsync(options)` | Asynchronous global registration. | +| `register(options)` | Synchronous non-global registration (manual scope control). `options` is required — only `forRoot` defaults it to `{}`. | +| `registerAsync(options)` | Asynchronous non-global registration. | + +## Hook Feature + +The hook system enables conditional execution of NestJS injectable classes +(hooks) attached to controllers or methods. A global `APP_INTERCEPTOR` +registered by `CoreModule` reads `@UseHooks(...)` metadata from the +current handler and attaches the resolved hook list to the request context via +`HooksCtx`. Downstream services consume the list via `HookResolverService`. + +### Defining a Hook + +Use `@Hook({ type })` to mark an injectable class as a hook. Methods are +decorated with subsystem-specific decorators (e.g. `@BeforeFind()`, +`@AfterCreate()` from `@concepta/nestjs-repository`). + +```ts +import { Hook } from '@concepta/nestjs-core'; +import { RepoHook, BeforeFind } from '@concepta/nestjs-repository'; + +@Hook({ type: RepoHook }) +export class TenantScopeHook { + @BeforeFind() + addTenantFilter(options: FindOptions, ctx: PlainLiteralObject): void { + options.where = { ...options.where, tenantId: ctx.tenantId }; + } +} +``` + +Forgetting `@Hook()` is a hard failure, not a silent no-op: a class registered +via `@UseHooks()` without the class-level `@Hook()` decorator throws +`HookNotDecoratedException` at resolution time, and a hook that can't be +resolved from the module's providers throws `HookProviderNotFoundException`. + +### Attaching Hooks to Controllers + +`@UseHooks(...hooks)` is applied to a controller class or a specific method. +Method-level decorators are merged with class-level decorators. + +```ts +import { UseHooks } from '@concepta/nestjs-core'; + +// Class-level — all methods get TenantScopeHook +@UseHooks(TenantScopeHook) +@Controller('users') +export class UserController { + @Get() + findAll() { ... } + + // Method-level addition — this method also gets AuditHook + @UseHooks(AuditHook) + @Delete(':id') + delete() { ... } +} +``` + +`@UseHooks` also accepts `{ hook, spec }` objects to add a per-registration +specification guard: + +```ts +@UseHooks( + { hook: TenantScopeHook, spec: Spec.always() }, + { hook: AuditHook, spec: Spec.and(adminSpec, mutationSpec) }, +) +@Controller('orders') +export class OrderController { ... } +``` + +### Specification Guards + +Specifications are evaluated at runtime to decide whether a hook (or a specific +hook method) executes. Use the `Spec` factory for common compositions: + +```ts +import { Spec } from '@concepta/nestjs-core'; + +Spec.always() // always executes +Spec.never() // never executes (useful to disable temporarily) +Spec.and(specA, specB) // both must be satisfied +Spec.or(specA, specB) // either must be satisfied +Spec.not(spec) // negates spec +``` + +Custom specifications implement `SpecificationInterface`: + +```ts +import { SpecificationInterface } from '@concepta/nestjs-core'; + +export class IsAdminSpec implements SpecificationInterface { + isSatisfiedBy(ctx: PlainLiteralObject): boolean { + return ctx.user?.role === 'admin'; + } +} +``` + +### Consuming Hooks + +`HookResolverService` is exported by `CoreModule` and available for +injection. It resolves and executes the matching hook methods from the request +context, passing the payload through each applicable hook in sequence. + +```ts +import { + HookResolverService, + getAppContext, +} from '@concepta/nestjs-core'; +import { RepoHook } from '@concepta/nestjs-repository'; + +@Injectable() +export class SomeService { + constructor(private readonly hookResolver: HookResolverService) {} + + async findAll(req: Request, options: FindOptions): Promise { + const ctx = getAppContext(req); + // hookType is the decorator object (has KEY property); payload is what flows + // through hooks; ctx is the full app context (resolver reads ctx.hooks internally). + return this.hookResolver.execute(RepoHook, 'beforeFind', options, ctx); + } +} +``` + +`execute(hookType, methodKey, payload, ctx)` returns the payload after all +applicable hooks have processed it. + +## Context System + +`AppContextHost` is a per-request container of typed overlays. Each overlay +adds a `with*()` method to the context, carrying a typed set of resolved +values for that request. + +`getAppContext(request)` returns the `AppContextHost` for a request, creating +one on first access. + +Most overlays live for the whole request. For one whose lifetime is +narrower — e.g. a single unit of work sharing a longer-lived context — +`ctx.removeOverlay(ref)` undoes `defineOverlay`, so the same context can +later host a fresh instance of that overlay via another `defineOverlay` +call. It only removes an overlay defined directly on that host — one +inherited from a parent context (e.g. via `with()`) is left untouched, and +the call returns `false` rather than removing anything. + +**Defining a custom overlay:** + +```ts +import { + ContextOverlayInterceptor, + OverlayRef, + getAppContext, +} from '@concepta/nestjs-core'; +import { Injectable, ExecutionContext } from '@nestjs/common'; + +// Typed token — export this for consumers +export const MyCtx = new OverlayRef<'withMy', { tenantId: string }>('withMy'); + +@Injectable() +export class MyContextOverlay extends ContextOverlayInterceptor { + readonly ref = MyCtx; + + attach(context: ExecutionContext): void { + const request = context.switchToHttp().getRequest(); + const ctx = getAppContext(request); + ctx.defineOverlay(MyCtx, { tenantId: request.headers['x-tenant-id'] }); + } +} + +// Register as a global interceptor in your module: +// { provide: APP_INTERCEPTOR, useClass: MyContextOverlay } +``` + +**Consuming an overlay in a controller:** + +```ts +import { Ctx } from '@concepta/nestjs-core'; + +@Controller('users') +export class UserController { + @Get() + // @Ctx(ref) injects the resolved overlay props directly + findAll(@Ctx(MyCtx) my: { tenantId: string }) { + return my.tenantId; + } +} +``` + +**Or read it from the raw context:** + +```ts +const ctx = getAppContext(request); +const { tenantId } = ctx.with(MyCtx); +``` + +`ContextOverlayInterceptor` is the abstract base class for custom overlays. +Subclasses implement `ref` (the `OverlayRef` token) and `attach()` (where +`defineOverlay` is called). Register them as global `APP_INTERCEPTOR` providers. + +### Correlation Context + +`CorrelationContextOverlay` seeds `correlationId`/`causationId` from the +inbound `x-correlation-id` request header, minting a fresh self-correlated +pair when the header is absent. Attaching it twice is idempotent — the +first-seen pair wins. This is the overlay `createEventContext` reads from +(see [Event Context](#event-context)). + +Unlike other overlays, which are registered by whichever feature module +owns them, `CorrelationContextOverlay` is registered by `CoreModule` itself +— correlation has no single natural owning feature module, since every +package needs it equally. **Importing `CoreModule` at the application root +is required** for any app using event-context-bearing packages; without it +the overlay never attaches, and every event context silently falls back to +a synthesized, unlinked correlation pair. + +```ts +import { CoreModule } from '@concepta/nestjs-core'; + +@Module({ + imports: [CoreModule.forRoot()], +}) +export class AppModule {} +``` + +Like all overlays, `CorrelationContextOverlay.attach()` reads the request +via `context.switchToHttp()` — on RPC/WS/GraphQL transports this yields a +bogus request object, the same limitation every overlay in this system has. + +## Exceptions + +### RuntimeException + +`RuntimeException` extends NestJS `HttpException` and adds a machine-readable +error code, safe message, and structured context. Subclass it to define +module-specific error codes. Because it is an `HttpException`, subclasses get +`getStatus()` and `getResponse()` for free, and the wire body is composed +lazily in an overridden `getResponse()` — so it always reflects the final +`errorCode` assigned by subclass constructors. + +```ts +import { RuntimeException } from '@concepta/nestjs-core'; +import { HttpStatus } from '@nestjs/common'; + +// Simple message +throw new RuntimeException('Something failed'); + +// With options +throw new RuntimeException({ + message: 'Entity %s not found', + messageParams: [id], + httpStatus: HttpStatus.NOT_FOUND, + safeMessage: 'Resource not found', +}); + +// Subclass with a fixed error code +export class MyNotFoundException extends RuntimeException { + constructor(id: string) { + super({ + httpStatus: HttpStatus.NOT_FOUND, + message: 'Entity %s not found', + messageParams: [id], + fault: 'client', + }); + this.errorCode = 'MY_NOT_FOUND_ERROR'; + } +} +``` + +Key options (`RuntimeExceptionOptions`): + +| Option | Type | Description | +| --- | --- | --- | +| `message` | `string` | Internal (developer-facing) message. Supports `%s` via `util.format`. | +| `messageParams` | `unknown[]` | Interpolation values for `message`. | +| `safeMessage` | `string` | User-facing message. When set, it is used in the HTTP response body at any status. | +| `safeMessageParams` | `unknown[]` | Interpolation values for `safeMessage`. | +| `httpStatus` | `HttpStatus` | HTTP status code. Defaults to `500`. | +| `originalError` | `unknown` | Original error cause (wrapped into context). | +| `fault` | `'client' \| 'usage' \| 'internal'` | Who is at fault, independent of `httpStatus` — triage classification for logging/observability. Defaults to `'internal'` (the fail-loud fallback for an unclassified exception), so subclasses should set it explicitly. Never rendered on the wire — see HTTP Responses below. | + +### HTTP Responses + +No filter registration is needed. `RuntimeException` subclasses are +`HttpException`s, so NestJS's built-in exception handling renders the body +returned by `getResponse()`: + +```json +{ + "statusCode": 404, + "message": "Resource not found", + "errorCode": "MY_NOT_FOUND_ERROR", + "error": "Not Found" +} +``` + +`error` is the HTTP status text (omitted for unknown status codes). Message +resolution: when `safeMessage` is set, it is always used; without one, +statuses `>= 500` fall back to `'Internal Server Error'` (never the internal +message), and 4xx statuses use `message`. `fault` is deliberately absent from +the response body — it never reaches the wire. + +## Schemas & OpenAPI + +Zod v4 / Standard Schema schemas replace the former DTO classes. Base +schemas are composed into concrete entity schemas via `.extend()`: + +| Export | Fields | +| --- | --- | +| `auditSchema` | `dateCreated`, `dateUpdated`, `dateDeleted` | +| `referenceIdSchema` | `id` | +| `domainAggregateSchema` (from `./aggregate`) | audit fields + `id` + `version` | + +### Interface Conformance + +`conformsTo()(schema)` is a compile-time assertion that a schema's +inferred output is assignable to a domain interface — replacing the old +`class Dto implements Interface` guarantee. Extra fields on the schema are +allowed; missing fields, wrong types, or optional-vs-nullable mismatches +fail to compile. + +```ts +import { conformsTo, domainAggregateSchema } from '@concepta/nestjs-core'; +import { z } from 'zod'; + +export const cacheSchema = conformsTo()( + domainAggregateSchema.extend({ + key: z.string(), + data: z.string().nullable(), + }), +); +``` + +### OpenAPI Wiring + +`withOpenApi(schema, id?)` attaches the `~standard.jsonSchema` extension so +`@nestjs/swagger` can render the schema as OpenAPI. It returns a **new** +schema instance (like Zod's other builder methods) — always use the return +value, never the schema passed in. + +`withNamedComponent(schema, id)` additionally registers the schema in a +process-wide named-component registry, so every endpoint referencing the +same schema instance `$ref`s a single `components.schemas` entry. Component +ids are bare entity names (e.g. `Cache`). It throws at module-load time if +the id is already registered. Same caveat: use the return value. + +`isStandardSchema(value)` is a type guard narrowing an `unknown` value to a +Zod (Standard Schema) schema. + +Wire the converter when creating the OpenAPI document: + +```ts +import { standardSchemaConverter } from '@concepta/nestjs-core'; + +const document = SwaggerModule.createDocument(app, config, { + standardSchemaConverter, +}); +``` + +Schemas registered via `withNamedComponent` render as named +`components.schemas` entries; any other schema falls through to the native +Standard Schema path and is inlined (this is how request body schemas are +documented automatically, with no decorator needed). + +## Event Context + +`EventContextHost` is a frozen container of headers and metadata, used +as the first argument to aggregate factory methods and domain events. It +ensures the event-issuing context is captured immutably at the point of +command execution. + +`headers` and `metadata` serve different roles. Headers are framework-owned +and auto-populated: every context carries `correlationId` (stable across a +whole causal chain), `causationId` (the inbound request/command that caused +this context to exist), and `recordedAt`, plus any per-package extension +such as `namespace`. Metadata is caller-supplied, per-event-type payload +extras — arbitrary typed data an event needs to carry that isn't part of the +uniform header set. + +Because the causal headers are required, `EventContextHost` is not built +directly — `createEventContext` derives `correlationId`/`causationId` from +the ambient context (`ctx`) rather than accepting them as arguments: + +```ts +import { createEventContext } from '@concepta/nestjs-core'; + +const eventContext = createEventContext(ctx, { namespace: 'my-module' }, {}); + +eventContext.getHeader('namespace'); // 'my-module' +eventContext.getHeader('correlationId'); // derived from ctx, or synthesized +eventContext.getHeader('recordedAt'); // Date +``` + +The correlation pair comes from a `CorrelationCtx` overlay attached to `ctx` +(see [Correlation Context](#correlation-context)) when one is present. If +not — a seed script, a bare unit test — `createEventContext` mints a fresh +self-correlated pair (`correlationId === causationId`), which is how such a +context is visibly distinguishable from a real request chain. + +Metadata is passed as the third argument and read back with `getMeta`: + +```ts +const eventContext = createEventContext(ctx, {}, { passcode, tokenExp }); + +eventContext.getMeta('passcode'); // typed +``` + +`EventContextHost` is frozen on construction — its `headers` and `metadata` +properties cannot be mutated. `H` is constrained to +`EventContextHeadersInterface`, so a context missing the causal headers +fails to compile. + +## Aggregate (`@concepta/nestjs-core/aggregate` subpath) + +```ts +import { + DomainAggregate, + DomainMapper, + domainAggregateSchema, + AggregateMetaInterface, +} from '@concepta/nestjs-core/aggregate'; +``` + +### DomainAggregate + +Abstract base class for all v8 domain aggregates. Extends +`@nestjs/cqrs` `AggregateRoot` (event sourcing support). + +```ts +export class MyAggregate extends DomainAggregate { + constructor( + id: string, + props: MyInterface, + version?: number, + meta?: AggregateMetaInterface, + ) { + super(id, props, version, meta); + } + + static create(eventContext: EventContextHost, dto: MyCreatable): MyAggregate { + const agg = new MyAggregate(randomUUID(), dto); + agg.apply(new MyCreatedEvent(eventContext, agg.toPlain())); + return agg; + } + + update(eventContext: EventContextHost, dto: Partial): void { + this.props = { ...this.props, ...dto }; + this.incrementVersion(); + this.apply(new MyUpdatedEvent(eventContext, this.toPlain())); + } +} +``` + +Inherited members: + +| Member | Description | +| --- | --- | +| `id` | Read-only string identifier. | +| `version` | Integer version counter. | +| `meta` | `AggregateMetaInterface` — `dateCreated`, `dateUpdated`, `dateDeleted`. | +| `props` | Protected domain properties object. | +| `stampCreated()` | Sets `dateCreated` and `dateUpdated` to now. Called by the repository. | +| `stampUpdated()` | Updates `dateUpdated`. Called by the repository before save. | +| `stampDeleted()` | Sets `dateDeleted`. Called by the repository before soft-delete. | +| `incrementVersion()` | Bumps the version. Call inside mutation methods. | +| `toPlain()` | Returns `{ id, version, ...props, ...meta }` — used as persistence payload. | + +### DomainMapper + +Abstract mapper that converts persistence entities to domain aggregates and +back. Implement `createAggregate(entity)` — `toDomain` and `toPersistence` +are inherited. + +```ts +export class MyMapper extends DomainMapper { + createAggregate(entity: MyEntityInterface): MyAggregate { + const { id, version, dateCreated, dateUpdated, dateDeleted, ...props } = entity; + return new MyAggregate(id, props, version, { dateCreated, dateUpdated, dateDeleted }); + } +} +``` + +The `nestjs-cache` package is the reference implementation for the full +aggregate + mapper + repository pattern. + +## Testing (`@concepta/nestjs-core/testing` subpath) + +```ts +import { + createMockEventPublisher, + createMockCommandBus, + createMockQueryBus, + collectRuntimeExceptionClassNames, + createTestEventContext, +} from '@concepta/nestjs-core/testing'; +``` + +`createMockEventPublisher`, `createMockCommandBus`, and `createMockQueryBus` +each return a `vitest-mock-extended` `DeepMockProxy` of the corresponding CQRS +class. `createMockEventPublisher` additionally pre-wires `mergeObjectContext` +to return its argument unchanged, matching real runtime behavior. + +```ts +import { Test } from '@nestjs/testing'; +import { EventPublisher } from '@nestjs/cqrs'; +import { createMockEventPublisher } from '@concepta/nestjs-core/testing'; + +const moduleRef = await Test.createTestingModule({ + providers: [MyHandler], +}) + .overrideProvider(EventPublisher) + .useValue(createMockEventPublisher()) + .compile(); +``` + +`collectRuntimeExceptionClassNames(srcDir, runtimeExceptionClass)` is a +different kind of helper — not a mock. It discovers every `RuntimeException` +subclass exported from a `*.exception.ts` file under `srcDir`, by dynamically +importing each file and walking its prototype chain. Packages use it in a +per-package `exception-fault.spec.ts` suite to assert every exception class +sets a `fault`, so a new exception can't silently ship unclassified. + +`createTestEventContext(extraHeaders, metadata)` builds an `EventContextHost` +with a fixed, deterministic `correlationId`/`causationId`/`recordedAt` — +for tests that need a valid context to satisfy the compile guard but don't +exercise correlation behavior directly. + +## API Reference + +### Module + +| Export | Description | +| --- | --- | +| `CoreModule` | The root module. Registers a global `APP_INTERCEPTOR` for hook context and exports `HookResolverService`. | + +### Hook Decorators + +| Export | Description | +| --- | --- | +| `@UseHooks(...hooks)` | Controller/method decorator. Attaches hook classes (or `{ hook, spec }` objects) to the handler. | +| `@Hook(options)` | Class decorator. Marks a class as a hook, applies `@Injectable()`, and pre-computes method mappings. | +| `@Specification(spec)` | Class/method decorator. Attaches a default specification to a hook class or method. | +| `createHookMethodDecorator(key)` | Factory for creating subsystem-specific hook method decorators (e.g. `@BeforeFind`). | + +### Hook Runtime + +| Export | Description | +| --- | --- | +| `HookResolverService` | Resolves and executes hook methods for a given hook type and method key. | +| `HooksCtx` | `OverlayRef` token for the hook context. Use with `ctx.with(HooksCtx)` or `@Ctx(HooksCtx)`. | +| `HookNotDecoratedException` | Thrown when a class passed to `@UseHooks()` is missing the class-level `@Hook()` decorator (error code `HOOK_NOT_DECORATED`). | +| `HookProviderNotFoundException` | Thrown when a hook registered via `@UseHooks()` cannot be resolved from the module's providers (error code `HOOK_PROVIDER_NOT_FOUND`). | + +### Hook Types and Interfaces + +| Export | Description | +| --- | --- | +| `SpecificationInterface` | Contract: `isSatisfiedBy(context): boolean`. | +| `HookOption` | Union: a bare hook class, or a `HookWithSpec` configuration object. | +| `HookWithSpec` | `{ hook, type?, spec? }` — a hook class paired with an optional spec guard. | +| `HookTypeInterface` | Interface for hook type constants. Requires `readonly KEY: string`. | +| `HookContextInterface` | Request-scoped context carrying the resolved `hooks: HookWithSpec[]` array. | +| `HookMethodKeyType` | String key that identifies a hook method slot (e.g. `'beforeFind'`). | + +### Specification Classes + +| Export | Description | +| --- | --- | +| `Spec` | Factory for common specifications: `always()`, `never()`, `and()`, `or()`, `not()`. | +| `CompositeSpecification` | Abstract base for custom composite specifications. | +| `AlwaysSpecification` | Always returns `true`. | +| `NeverSpecification` | Always returns `false`. | +| `AndSpecification` | Returns `true` if both left and right specs are satisfied. | +| `OrSpecification` | Returns `true` if either left or right spec is satisfied. | +| `NotSpecification` | Negates the wrapped specification. | + +### Context System Exports + +| Export | Description | +| --- | --- | +| `AppContextHost` | Per-request overlay container. Use `defineOverlay`, `removeOverlay`, `with`, `require`, `supports`, `optional`. Static `from(value?)` coerces `AppContextLike` to a host. | +| `getAppContext(request)` | Returns the `AppContextHost` for a request, creating one on first access. | +| `Ctx` | Parameter decorator. Without args: injects the raw `AppContextHost`. With an `OverlayRef`: unwraps the overlay via `appCtx.with(ref)`. | +| `OverlayRef` | Typed token for a named overlay. Construct with `new OverlayRef('withName')`. | +| `ContextOverlayInterceptor` | Abstract base for custom overlays. Subclasses implement `ref` and `attach()`. | +| `OverlayNotDefinedException` | Thrown when `with(ref)` is called for an overlay that was not defined on the context. | +| `AppContextInterface` | Interface implemented by `AppContextHost`. | +| `AppContextLike` | Type accepted by `AppContextHost.from()` — either an `AppContextHost` or a nullish/empty plain object. | +| `CorrelationCtx` | `OverlayRef` token for the correlation overlay. Use with `ctx.with(CorrelationCtx)` or `@Ctx(CorrelationCtx)`. | +| `CorrelationContextOverlay` | Seeds `correlationId`/`causationId` from the `x-correlation-id` request header. Registered by `CoreModule`. | +| `CorrelationContextInterface` | Shape of the resolved overlay: `{ correlationId, causationId }`. | + +### Exceptions Exports + +| Export | Description | +| --- | --- | +| `RuntimeException` | Base domain exception. Extends NestJS `HttpException`; composes the wire body `{ statusCode, message, errorCode, error? }` lazily in `getResponse()`. Accepts `httpStatus`, `safeMessage`, `messageParams`, `originalError`, `fault`. | +| `RuntimeExceptionInterface` | Interface for `RuntimeException`. | +| `RuntimeExceptionOptions` | Options bag for the `RuntimeException` constructor. | +| `RuntimeExceptionContext` | Type of the `context` property on `RuntimeException`. Defined as `ExceptionContext & { originalError?: Error }`. | +| `RuntimeExceptionFault` | String union `'client' \| 'usage' \| 'internal'` classifying who is at fault. | +| `ExceptionContext` | Base context shape: `Record & { originalError?: unknown }`. Extended by `RuntimeExceptionContext`. | +| `ExceptionInterface` | Minimal interface: `errorCode`, `context?`. Extends `Error`. | +| `NotAnErrorException` | Wraps a non-`Error` value (e.g. a string or object) into an `Error`. Used internally by `mapNonErrorToException`. | + +### Event Context Exports + +| Export | Description | +| --- | --- | +| `EventContextHost` | Frozen container of `headers: H` and `metadata: M`. Passed as first arg to aggregate factories and domain events. Provides `getHeader(key)` and `getMeta(key)`. | +| `EventContextInterface` | Interface implemented by `EventContextHost` — declares `headers`, `metadata`, `getHeader(key)`, `getMeta(key)`. | +| `EventContextHeadersInterface` | Required header shape: `correlationId`, `causationId`, `recordedAt`. Per-package headers extend this. | +| `createEventContext(ctx, extraHeaders, metadata)` | Builds an `EventContextHost`, deriving `correlationId`/`causationId` from `ctx`'s `CorrelationCtx` overlay (or synthesizing a self-correlated pair). Never throws, even on an unusual `ctx` shape. | +| `createCausalContext(resolver, extraHeaders, metadata)` | The framework-agnostic algorithm `createEventContext` delegates to — resolves or synthesizes a correlation pair via a `CausalContextResolver`, with no `AppContextHost` involved. | +| `CausalContextResolver` | Port abstracting over where the correlation pair lives — `resolve()` returns a pair or `undefined`; `memoize(pair)` records a synthesized one. | +| `CausalPairInterface` | `{ correlationId, causationId }` — the shape resolved and memoized by a `CausalContextResolver`. | +| `AppContextHostCausalResolver` | `CausalContextResolver` implementation backed by an `AppContextHost`'s `CorrelationCtx` overlay. Used internally by `createEventContext`. | + +### Reference Types + +| Export | Description | +| --- | --- | +| `ReferenceId` | Branded `string` type for entity IDs. | +| `ReferenceActive` | Branded `boolean` for active/inactive flag. | +| `ReferenceEmail` | Branded `string` for email addresses. | +| `ReferenceUsername` | Branded `string` for usernames. | +| `ReferenceSubject` | Branded `string` for JWT/auth subjects. | +| `ReferenceAssignment` | Branded `string` for role/scope assignment values. | +| `ReferenceIdInterface` | Interface with `id: ReferenceId`. | +| `ReferenceActiveInterface` | Interface with `active: ReferenceActive`. | +| `ReferenceEmailInterface` | Interface with `email: ReferenceEmail`. | +| `ReferenceUsernameInterface` | Interface with `username: ReferenceUsername`. | +| `ReferenceSubjectInterface` | Interface with `subject: ReferenceSubject`. | +| `ReferenceVersionInterface` | Interface with `version: number`. | + +### Audit Types + +| Export | Description | +| --- | --- | +| `AuditDateCreated` | Branded `Date \| null` for creation timestamp. | +| `AuditDateUpdated` | Branded `Date \| null` for last-update timestamp. | +| `AuditDateDeleted` | Branded `Date \| null` for soft-deletion timestamp. | +| `AuditVersion` | Branded `number` for optimistic-lock version. | +| `AuditInterface` | Interface with `dateCreated`, `dateUpdated`, `dateDeleted`. | +| `AuditDateCreatedInterface` | Interface with `dateCreated: AuditDateCreated`. | +| `AuditDateUpdatedInterface` | Interface with `dateUpdated: AuditDateUpdated`. | +| `AuditDateDeletedInterface` | Interface with `dateDeleted: AuditDateDeleted`. | +| `AuditVersionInterface` | Interface with `version: AuditVersion`. | + +### Enums and Operation Types + +| Export | Description | +| --- | --- | +| `ActionEnum` | Enum of CRUD action names (`CREATE`, `READ`, `UPDATE`, `DELETE`). | +| `Operation` | String-literal union of all operation names. | +| `ReadOperations` | String-literal union of read-only operation names. | +| `WriteOperations` | String-literal union of write operation names. | +| `MutateOperations` | String-literal union of mutating operation names. | +| `ReadOperation` | Branded string for a read operation value. | +| `WriteOperation` | Branded string for a write operation value. | +| `MutateOperation` | Branded string for a mutating operation value. | + +### Utilities and Module Helpers + +| Export | Description | +| --- | --- | +| `createSettingsProvider` | Factory that creates a NestJS `Provider` wiring module options to a settings token, with optional transformer support. | +| `mapNonErrorToException` | Converts any non-`Error` value to a `NotAnErrorException`; passes through real `Error` instances unchanged. | +| `toMilliseconds` | Converts a duration string (e.g. `'1h'`) or number to milliseconds via the `ms` library. Accepts an optional third `fault` argument classifying an unparseable value on the thrown `RuntimeException` (defaults to `'internal'`). | +| `isNil`, `isUndefined`, `isString`, `isNumber`, `isObject` | Narrowing type guards. Rockets-owned replacements for the unpublished `@nestjs/common/utils/shared.utils` internals. | +| `DeepPartial` | Recursive `Partial`. | +| `DomainFactory` | Interface enforcing `create` and `createWithId` static factory signatures on domain aggregate classes. | +| `AssigneeRelationInterface` | Interface for entities that hold an `assignee` relation (`{ assignee: ReferenceIdInterface }`). | +| `ModuleOptionsSettingsInterface` | Interface for module options that include a `settings` block and optional `settingsTransform`. | +| `ModuleOptionsControllerInterface` | Interface for module options that control whether HTTP endpoints are enabled. | + +### Schemas and OpenAPI Utilities + +| Export | Description | +| --- | --- | +| `auditSchema` | Zod schema for audit fields (`dateCreated`, `dateUpdated`, `dateDeleted`). Composed into entity schemas via `.extend()`. | +| `referenceIdSchema` | Zod schema exposing a single `id` field. Composed into entity schemas via `.extend()`. | +| `conformsTo()` | Compile-time assertion that a schema's inferred output conforms to a domain interface. | +| `withOpenApi(schema, id?)` | Attaches the `~standard.jsonSchema` extension for OpenAPI conversion. Returns a new schema instance — use the return value. | +| `withNamedComponent(schema, id)` | Registers a named, reusable `components.schemas` entry. Throws on duplicate id. Use the return value. | +| `standardSchemaConverter` | Document-level converter for `SwaggerModule.createDocument(app, config, { standardSchemaConverter })`. | +| `isStandardSchema(value)` | Type guard: `true` if `value` is a Standard Schema (Zod) schema. | + +### Subpath: `./aggregate` + +| Export | Description | +| --- | --- | +| `DomainAggregate` | Abstract aggregate base extending `@nestjs/cqrs` `AggregateRoot`. | +| `DomainMapper` | Abstract mapper base. Implement `createAggregate(entity)`. | +| `domainAggregateSchema` | Zod schema exposing `id`, `version`, and audit fields. Composed into entity schemas via `.extend()`. | +| `AggregateMetaInterface` | Interface for aggregate audit timestamps: `dateCreated`, `dateUpdated`, `dateDeleted`. | + +### Subpath: `./testing` + +| Export | Description | +| --- | --- | +| `createMockEventPublisher()` | Returns a `DeepMockProxy` with `mergeObjectContext` pre-wired to return its argument. | +| `createMockCommandBus()` | Returns a `DeepMockProxy`. | +| `createMockQueryBus()` | Returns a `DeepMockProxy`. | +| `collectRuntimeExceptionClassNames(srcDir, runtimeExceptionClass)` | Discovers every `RuntimeException` subclass under `srcDir` by dynamic import and prototype walk. | +| `createTestEventContext(extraHeaders, metadata)` | Builds an `EventContextHost` with a fixed, deterministic correlation pair for tests that don't exercise correlation directly. | diff --git a/packages/nestjs-core/package.json b/packages/nestjs-core/package.json new file mode 100644 index 000000000..e061f3fd9 --- /dev/null +++ b/packages/nestjs-core/package.json @@ -0,0 +1,58 @@ +{ + "name": "@concepta/nestjs-core", + "version": "8.0.0-alpha.10", + "description": "Rockets App - Core framework module for Rockets applications", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./aggregate": { + "types": "./dist/index-aggregate.d.ts", + "default": "./dist/index-aggregate.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "default": "./dist/testing.js" + } + }, + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" + ], + "dependencies": { + "ms": "^2.1.3", + "rxjs": "^7.8.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@nestjs/common": "^12.0.1", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/swagger": "^12.0.1", + "@nestjs/testing": "^12.0.1", + "@types/supertest": "^6.0.3", + "supertest": "^6.3.4", + "vitest-mock-extended": "^4.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.1", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/swagger": "^12.0.1", + "rxjs": "^7.8.1" + }, + "peerDependenciesMeta": { + "@nestjs/cqrs": { + "optional": true + } + } +} diff --git a/packages/nestjs-core/src/__tests__/exception-fault.spec.ts b/packages/nestjs-core/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..741ab7860 --- /dev/null +++ b/packages/nestjs-core/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,59 @@ +import { fileURLToPath } from 'url'; + +import { type RuntimeExceptionFault } from '../domain/exceptions/exception.types.js'; +import { RuntimeException } from '../domain/exceptions/runtime.exception.js'; +import { OverlayNotDefinedException } from '../infrastructure/context/exceptions/overlay-not-defined.exception.js'; +import { HookNotDecoratedException } from '../infrastructure/hook/exceptions/hook-not-decorated.exception.js'; +import { HookProviderNotFoundException } from '../infrastructure/hook/exceptions/hook-provider-not-found.exception.js'; +import { collectRuntimeExceptionClassNames } from '../testing/collect-runtime-exception-class-names.js'; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. A new exception class added without a row (or + * without updating an inherited default here) is a signal the classification + * sweep was skipped, not a signal to widen the table casually. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'RuntimeException (default)', + build: () => new RuntimeException(), + fault: 'internal', + }, + { + name: 'OverlayNotDefinedException', + build: () => new OverlayNotDefinedException('SomeOverlay'), + fault: 'usage', + }, + { + name: 'HookNotDecoratedException', + build: () => new HookNotDecoratedException('SomeHook'), + fault: 'usage', + }, + { + name: 'HookProviderNotFoundException', + build: () => new HookProviderNotFoundException('SomeHook'), + fault: 'usage', + }, +]; + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-core/src/core.module-definition.ts b/packages/nestjs-core/src/core.module-definition.ts new file mode 100644 index 000000000..67e77a776 --- /dev/null +++ b/packages/nestjs-core/src/core.module-definition.ts @@ -0,0 +1,76 @@ +import { + ConfigurableModuleBuilder, + type DynamicModule, + type Provider, +} from '@nestjs/common'; + +import { + createCorrelationFeatureExports, + createCorrelationFeatureProviders, +} from './infrastructure/context/utils/create-correlation-feature-providers.js'; +import { + createHookFeatureExports, + createHookFeatureProviders, +} from './infrastructure/hook/utils/create-hook-feature-providers.js'; + +const CORE_MODULE_RAW_OPTIONS_TOKEN = Symbol( + '__CORE_MODULE_RAW_OPTIONS_TOKEN__', +); + +export interface CoreOptionsInterface { + // Reserved for future feature options +} + +export interface CoreOptionsExtrasInterface { + global?: boolean; +} + +export const { + ConfigurableModuleClass: CoreModuleClass, + OPTIONS_TYPE: CORE_OPTIONS_TYPE, + ASYNC_OPTIONS_TYPE: CORE_ASYNC_OPTIONS_TYPE, +} = new ConfigurableModuleBuilder({ + moduleName: 'Core', + optionsInjectionToken: CORE_MODULE_RAW_OPTIONS_TOKEN, +}) + .setExtras({ global: true }, definitionTransform) + .build(); + +export type CoreOptions = Omit; +export type CoreAsyncOptions = Omit; + +function definitionTransform( + definition: DynamicModule, + extras: CoreOptionsExtrasInterface, +): DynamicModule { + const { providers = [] } = definition; + const { global = true } = extras; + + return { + ...definition, + global, + providers: createCoreProviders({ providers }), + exports: [CORE_MODULE_RAW_OPTIONS_TOKEN, ...createCoreExports()], + }; +} + +export function createCoreProviders(options: { + providers?: Provider[]; +}): Provider[] { + return [ + ...(options.providers ?? []), + // Hook feature + ...createHookFeatureProviders(), + // Correlation feature + ...createCorrelationFeatureProviders(), + ]; +} + +export function createCoreExports(): NonNullable { + return [ + // Hook feature + ...createHookFeatureExports(), + // Correlation feature + ...createCorrelationFeatureExports(), + ]; +} diff --git a/packages/nestjs-core/src/core.module.ts b/packages/nestjs-core/src/core.module.ts new file mode 100644 index 000000000..04472d007 --- /dev/null +++ b/packages/nestjs-core/src/core.module.ts @@ -0,0 +1,26 @@ +import { DynamicModule, Module } from '@nestjs/common'; + +import { + CoreAsyncOptions, + CoreModuleClass, + CoreOptions, +} from './core.module-definition.js'; + +@Module({}) +export class CoreModule extends CoreModuleClass { + static register(options: CoreOptions): DynamicModule { + return super.register(options); + } + + static registerAsync(options: CoreAsyncOptions): DynamicModule { + return super.registerAsync(options); + } + + static forRoot(options: CoreOptions = {}): DynamicModule { + return super.register({ ...options, global: true }); + } + + static forRootAsync(options: CoreAsyncOptions): DynamicModule { + return super.registerAsync({ ...options, global: true }); + } +} diff --git a/packages/nestjs-core/src/domain/aggregates/domain-aggregate.ts b/packages/nestjs-core/src/domain/aggregates/domain-aggregate.ts new file mode 100644 index 000000000..6e039aa27 --- /dev/null +++ b/packages/nestjs-core/src/domain/aggregates/domain-aggregate.ts @@ -0,0 +1,76 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { AggregateRoot } from '@nestjs/cqrs'; + +import { type AggregateMetaInterface } from './interfaces/aggregate-meta.interface.js'; + +export abstract class DomainAggregate< + T extends PlainLiteralObject, +> extends AggregateRoot { + readonly id: string; + protected props: T; + private _version: number; + private _meta: AggregateMetaInterface; + + constructor( + id: string, + props: T, + version: number = 1, + meta?: AggregateMetaInterface, + ) { + super(); + this.id = id; + this.props = { ...props }; + this._version = version; + this._meta = meta ?? { + dateCreated: new Date(), + dateUpdated: new Date(), + dateDeleted: null, + }; + } + + get version(): number { + return this._version; + } + + get meta(): AggregateMetaInterface { + return this._meta; + } + + stampCreated(): void { + const now = new Date(); + this._meta = { + dateCreated: now, + dateUpdated: now, + dateDeleted: null, + }; + } + + stampUpdated(): void { + if (!this._meta.dateCreated) { + this.stampCreated(); + } else { + this._meta = { ...this._meta, dateUpdated: new Date() }; + } + } + + stampDeleted(): void { + this._meta = { + ...this._meta, + dateDeleted: new Date(), + dateUpdated: new Date(), + }; + } + + protected incrementVersion(): void { + this._version = this.version + 1; + } + + toPlain() { + return { + id: this.id, + version: this.version, + ...this.props, + ...this.meta, + }; + } +} diff --git a/packages/nestjs-core/src/domain/aggregates/domain-mapper.ts b/packages/nestjs-core/src/domain/aggregates/domain-mapper.ts new file mode 100644 index 000000000..ed6f4a5e1 --- /dev/null +++ b/packages/nestjs-core/src/domain/aggregates/domain-mapper.ts @@ -0,0 +1,33 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceIdInterface } from '../reference/interfaces/reference-id.interface.js'; +import { type ReferenceVersionInterface } from '../reference/interfaces/reference-version.interface.js'; + +import { type DomainAggregate } from './domain-aggregate.js'; +import { type AggregateMetaInterface } from './interfaces/aggregate-meta.interface.js'; + +export abstract class DomainMapper< + Entity, + Props extends PlainLiteralObject, + A extends DomainAggregate, +> { + abstract createAggregate( + entity: Entity & + ReferenceIdInterface & + ReferenceVersionInterface & + AggregateMetaInterface, + ): A; + + toDomain( + entity: Entity & + ReferenceIdInterface & + ReferenceVersionInterface & + AggregateMetaInterface, + ): A { + return this.createAggregate(entity); + } + + toPersistence(aggregate: A) { + return aggregate.toPlain(); + } +} diff --git a/packages/nestjs-core/src/domain/aggregates/interfaces/aggregate-meta.interface.ts b/packages/nestjs-core/src/domain/aggregates/interfaces/aggregate-meta.interface.ts new file mode 100644 index 000000000..cf7d639dc --- /dev/null +++ b/packages/nestjs-core/src/domain/aggregates/interfaces/aggregate-meta.interface.ts @@ -0,0 +1,8 @@ +import { type AuditInterface } from '../../audit/interfaces/audit.interface.js'; + +/** + * Metadata tracked by domain aggregates. + * + * Contains audit timestamps for persistence tracking. + */ +export interface AggregateMetaInterface extends AuditInterface {} diff --git a/packages/nestjs-core/src/domain/assignee/interfaces/assignee-relation.interface.ts b/packages/nestjs-core/src/domain/assignee/interfaces/assignee-relation.interface.ts new file mode 100644 index 000000000..f28ab097e --- /dev/null +++ b/packages/nestjs-core/src/domain/assignee/interfaces/assignee-relation.interface.ts @@ -0,0 +1,10 @@ +import { type ReferenceId } from '../../reference/interfaces/reference.types.js'; + +/** + * Assigned to assignee. + */ +export interface AssigneeRelationInterface< + T extends ReferenceId = ReferenceId, +> { + assigneeId: T; +} diff --git a/packages/nestjs-core/src/domain/audit/interfaces/audit-date-created.interface.ts b/packages/nestjs-core/src/domain/audit/interfaces/audit-date-created.interface.ts new file mode 100644 index 000000000..c68e7ad82 --- /dev/null +++ b/packages/nestjs-core/src/domain/audit/interfaces/audit-date-created.interface.ts @@ -0,0 +1,8 @@ +import { type AuditDateCreated } from './audit.types.js'; + +/** + * Date data was created. + */ +export interface AuditDateCreatedInterface { + dateCreated: T; +} diff --git a/packages/nestjs-core/src/domain/audit/interfaces/audit-date-deleted.interface.ts b/packages/nestjs-core/src/domain/audit/interfaces/audit-date-deleted.interface.ts new file mode 100644 index 000000000..a7559803c --- /dev/null +++ b/packages/nestjs-core/src/domain/audit/interfaces/audit-date-deleted.interface.ts @@ -0,0 +1,8 @@ +import { type AuditDateDeleted } from './audit.types.js'; + +/** + * Date data was deleted. + */ +export interface AuditDateDeletedInterface { + dateDeleted: T; +} diff --git a/packages/nestjs-core/src/domain/audit/interfaces/audit-date-updated.interface.ts b/packages/nestjs-core/src/domain/audit/interfaces/audit-date-updated.interface.ts new file mode 100644 index 000000000..77a2e5221 --- /dev/null +++ b/packages/nestjs-core/src/domain/audit/interfaces/audit-date-updated.interface.ts @@ -0,0 +1,8 @@ +import { type AuditDateUpdated } from './audit.types.js'; + +/** + * Date data was last updated. + */ +export interface AuditDateUpdatedInterface { + dateUpdated: T; +} diff --git a/packages/nestjs-core/src/domain/audit/interfaces/audit-version.interface.ts b/packages/nestjs-core/src/domain/audit/interfaces/audit-version.interface.ts new file mode 100644 index 000000000..4e7c68b1f --- /dev/null +++ b/packages/nestjs-core/src/domain/audit/interfaces/audit-version.interface.ts @@ -0,0 +1,8 @@ +import { type AuditVersion } from './audit.types.js'; + +/** + * The latest version of the data. + */ +export interface AuditVersionInterface { + version: T; +} diff --git a/packages/nestjs-core/src/domain/audit/interfaces/audit.interface.ts b/packages/nestjs-core/src/domain/audit/interfaces/audit.interface.ts new file mode 100644 index 000000000..8eba0ba4c --- /dev/null +++ b/packages/nestjs-core/src/domain/audit/interfaces/audit.interface.ts @@ -0,0 +1,12 @@ +import { type AuditDateCreatedInterface } from './audit-date-created.interface.js'; +import { type AuditDateDeletedInterface } from './audit-date-deleted.interface.js'; +import { type AuditDateUpdatedInterface } from './audit-date-updated.interface.js'; + +/** + * Audit metadata for persistence tracking. + */ +export interface AuditInterface + extends + AuditDateCreatedInterface, + AuditDateUpdatedInterface, + AuditDateDeletedInterface {} diff --git a/packages/nestjs-core/src/domain/audit/interfaces/audit.types.ts b/packages/nestjs-core/src/domain/audit/interfaces/audit.types.ts new file mode 100644 index 000000000..8196c360c --- /dev/null +++ b/packages/nestjs-core/src/domain/audit/interfaces/audit.types.ts @@ -0,0 +1,4 @@ +export type AuditDateCreated = Date; +export type AuditDateUpdated = Date; +export type AuditDateDeleted = Date | null; +export type AuditVersion = number; diff --git a/packages/nestjs-core/src/domain/context/app-context-like.type.ts b/packages/nestjs-core/src/domain/context/app-context-like.type.ts new file mode 100644 index 000000000..d20d393c4 --- /dev/null +++ b/packages/nestjs-core/src/domain/context/app-context-like.type.ts @@ -0,0 +1,9 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type AppContextInterface } from './interfaces/app-context.interface.js'; + +export type AppContextLike = + | AppContextInterface + | PlainLiteralObject + | null + | undefined; diff --git a/packages/nestjs-core/src/domain/context/interfaces/app-context.interface.ts b/packages/nestjs-core/src/domain/context/interfaces/app-context.interface.ts new file mode 100644 index 000000000..04303c647 --- /dev/null +++ b/packages/nestjs-core/src/domain/context/interfaces/app-context.interface.ts @@ -0,0 +1,32 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type OverlayRef } from '../overlay-ref.js'; +import { type RefsToMethods } from '../refs-to-methods.type.js'; + +export interface AppContextInterface { + defineOverlay( + ref: OverlayRef, + values: Props, + ): void; + + removeOverlay( + ref: OverlayRef, + ): boolean; + + require[]>( + ...refs: R + ): this & RefsToMethods; + + with< + Name extends string, + Props extends PlainLiteralObject, + Args extends unknown[], + >( + ref: OverlayRef, + ...args: Args + ): Props; + + supports(ref: OverlayRef): boolean; + + optional(): Record this>; +} diff --git a/packages/nestjs-core/src/domain/context/overlay-ref.ts b/packages/nestjs-core/src/domain/context/overlay-ref.ts new file mode 100644 index 000000000..24523e66a --- /dev/null +++ b/packages/nestjs-core/src/domain/context/overlay-ref.ts @@ -0,0 +1,23 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +/** + * Typed token that serves as the single source of truth for an overlay's + * name and resolved type. + * + * Exported as a const alongside each overlay module and used as a lookup + * key for `get`, narrowing key for `require`, and type carrier. + * + * @example + * ```typescript + * export const WithFeature = new OverlayRef<'withFeature', FeatureContextInterface>('withFeature'); + * ``` + */ +export class OverlayRef< + Name extends string, + Props extends PlainLiteralObject, + Args extends unknown[] = [], +> { + declare readonly _props: Props; + declare readonly _args: Args; + constructor(readonly name: Name) {} +} diff --git a/packages/nestjs-core/src/domain/context/refs-to-methods.type.ts b/packages/nestjs-core/src/domain/context/refs-to-methods.type.ts new file mode 100644 index 000000000..744496ef4 --- /dev/null +++ b/packages/nestjs-core/src/domain/context/refs-to-methods.type.ts @@ -0,0 +1,11 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type OverlayRef } from './overlay-ref.js'; + +export type RefsToMethods< + R extends OverlayRef, +> = { + [O in R as O['name']]: O extends OverlayRef + ? (...args: A) => P + : never; +}; diff --git a/packages/nestjs-core/src/domain/enums/action.enum.ts b/packages/nestjs-core/src/domain/enums/action.enum.ts new file mode 100644 index 000000000..85b646950 --- /dev/null +++ b/packages/nestjs-core/src/domain/enums/action.enum.ts @@ -0,0 +1,6 @@ +export enum ActionEnum { + CREATE = 'create', + READ = 'read', + UPDATE = 'update', + DELETE = 'delete', +} diff --git a/packages/nestjs-core/src/domain/enums/operation.enum.ts b/packages/nestjs-core/src/domain/enums/operation.enum.ts new file mode 100644 index 000000000..7d8b5f70b --- /dev/null +++ b/packages/nestjs-core/src/domain/enums/operation.enum.ts @@ -0,0 +1,43 @@ +/** + * Base operations enum used across the Rockets ecosystem. + * + * This is the single source of truth for operation names. + * Module-specific enums (CrudOperations, HookOperation) should + * mirror these values for consistency. + */ +export enum Operation { + List = 'list', + Read = 'read', + Create = 'create', + CreateBatch = 'createBatch', + Update = 'update', + Replace = 'replace', + Delete = 'delete', + SoftDelete = 'softDelete', + Restore = 'restore', +} + +/** + * Operations that read data without modification. + */ +export const ReadOperations = [Operation.List, Operation.Read] as const; + +/** + * Operations that write data (create/update). + */ +export const WriteOperations = [ + Operation.Create, + Operation.CreateBatch, + Operation.Update, + Operation.Replace, +] as const; + +/** + * Operations that mutate data (write + delete/restore). + */ +export const MutateOperations = [ + ...WriteOperations, + Operation.Delete, + Operation.SoftDelete, + Operation.Restore, +] as const; diff --git a/packages/nestjs-core/src/domain/events/__tests__/app-context-host-causal-resolver.spec.ts b/packages/nestjs-core/src/domain/events/__tests__/app-context-host-causal-resolver.spec.ts new file mode 100644 index 000000000..1b01b1b53 --- /dev/null +++ b/packages/nestjs-core/src/domain/events/__tests__/app-context-host-causal-resolver.spec.ts @@ -0,0 +1,42 @@ +import { AppContextHost } from '../../../infrastructure/context/app-context.host.js'; +import { CorrelationCtx } from '../../../infrastructure/context/correlation-context.overlay.js'; +import { AppContextHostCausalResolver } from '../app-context-host-causal-resolver.js'; + +describe(AppContextHostCausalResolver.name, () => { + describe('resolve', () => { + it('returns undefined when no CorrelationCtx overlay is defined', () => { + const appCtx = new AppContextHost(); + const resolver = new AppContextHostCausalResolver(appCtx); + + expect(resolver.resolve()).toBeUndefined(); + }); + + it('returns the pair from a defined CorrelationCtx overlay', () => { + const appCtx = new AppContextHost(); + appCtx.defineOverlay(CorrelationCtx, { + correlationId: 'corr-1', + causationId: 'cause-1', + }); + const resolver = new AppContextHostCausalResolver(appCtx); + + expect(resolver.resolve()).toEqual({ + correlationId: 'corr-1', + causationId: 'cause-1', + }); + }); + }); + + describe('memoize', () => { + it('defines the CorrelationCtx overlay so a later resolve sees it', () => { + const appCtx = new AppContextHost(); + const resolver = new AppContextHostCausalResolver(appCtx); + + resolver.memoize({ correlationId: 'corr-1', causationId: 'cause-1' }); + + expect(resolver.resolve()).toEqual({ + correlationId: 'corr-1', + causationId: 'cause-1', + }); + }); + }); +}); diff --git a/packages/nestjs-core/src/domain/events/__tests__/create-event-context.spec.ts b/packages/nestjs-core/src/domain/events/__tests__/create-event-context.spec.ts new file mode 100644 index 000000000..5f4063124 --- /dev/null +++ b/packages/nestjs-core/src/domain/events/__tests__/create-event-context.spec.ts @@ -0,0 +1,63 @@ +import { AppContextHost } from '../../../infrastructure/context/app-context.host.js'; +import { CorrelationCtx } from '../../../infrastructure/context/correlation-context.overlay.js'; +import { createEventContext } from '../create-event-context.js'; + +describe(createEventContext.name, () => { + it('derives correlationId/causationId from an already-seeded CorrelationCtx overlay', () => { + const appCtx = new AppContextHost(); + appCtx.defineOverlay(CorrelationCtx, { + correlationId: 'corr-1', + causationId: 'cause-1', + }); + + const eventContext = createEventContext(appCtx, {}, {}); + + expect(eventContext.getHeader('correlationId')).toBe('corr-1'); + expect(eventContext.getHeader('causationId')).toBe('cause-1'); + }); + + it('mints a self-correlated pair when ctx has no CorrelationCtx overlay', () => { + const appCtx = new AppContextHost(); + + const eventContext = createEventContext(appCtx, {}, {}); + + const correlationId = eventContext.getHeader('correlationId'); + const causationId = eventContext.getHeader('causationId'); + expect(correlationId).toBe(causationId); + }); + + it('never throws on a plain empty object ctx', () => { + expect(() => createEventContext({}, {}, {})).not.toThrow(); + }); + + it('never throws on an unusual non-empty, non-AppContextHost ctx', () => { + expect(() => + createEventContext({ someUnrelatedField: 'x' }, {}, {}), + ).not.toThrow(); + }); + + it('degrades to the synthetic-fallback path when ctx is unusual', () => { + const eventContext = createEventContext( + { someUnrelatedField: 'x' }, + {}, + {}, + ); + + const correlationId = eventContext.getHeader('correlationId'); + const causationId = eventContext.getHeader('causationId'); + expect(correlationId).toBe(causationId); + }); + + it('carries caller-supplied extra headers and metadata', () => { + const appCtx = new AppContextHost(); + + const eventContext = createEventContext( + appCtx, + { namespace: 'my-namespace' }, + { passcode: 'abc123' }, + ); + + expect(eventContext.getHeader('namespace')).toBe('my-namespace'); + expect(eventContext.getMeta('passcode')).toBe('abc123'); + }); +}); diff --git a/packages/nestjs-core/src/domain/events/app-context-host-causal-resolver.ts b/packages/nestjs-core/src/domain/events/app-context-host-causal-resolver.ts new file mode 100644 index 000000000..48eb539af --- /dev/null +++ b/packages/nestjs-core/src/domain/events/app-context-host-causal-resolver.ts @@ -0,0 +1,26 @@ +import { type AppContextHost } from '../../infrastructure/context/app-context.host.js'; +import { CorrelationCtx } from '../../infrastructure/context/correlation-context.overlay.js'; + +import { + type CausalContextResolver, + type CausalPairInterface, +} from './causal-context/causal-context-resolver.interface.js'; + +/** + * Reads the correlation/causation pair from an {@link AppContextHost}'s + * `CorrelationCtx` overlay, and memoizes a synthesized pair back onto it + * when none is present yet. + */ +export class AppContextHostCausalResolver implements CausalContextResolver { + constructor(private readonly appCtx: AppContextHost) {} + + resolve(): CausalPairInterface | undefined { + if (!this.appCtx.supports(CorrelationCtx)) return undefined; + const { correlationId, causationId } = this.appCtx.with(CorrelationCtx); + return { correlationId, causationId }; + } + + memoize(pair: CausalPairInterface): void { + this.appCtx.defineOverlay(CorrelationCtx, pair); + } +} diff --git a/packages/nestjs-core/src/domain/events/causal-context/__tests__/create-causal-context.spec.ts b/packages/nestjs-core/src/domain/events/causal-context/__tests__/create-causal-context.spec.ts new file mode 100644 index 000000000..6e82921e3 --- /dev/null +++ b/packages/nestjs-core/src/domain/events/causal-context/__tests__/create-causal-context.spec.ts @@ -0,0 +1,96 @@ +import { + type CausalContextResolver, + type CausalPairInterface, +} from '../causal-context-resolver.interface.js'; +import { createCausalContext } from '../create-causal-context.js'; + +class MockCausalContextResolver implements CausalContextResolver { + private pair: CausalPairInterface | undefined; + memoizeCalls: CausalPairInterface[] = []; + + constructor(pair?: CausalPairInterface) { + this.pair = pair; + } + + resolve(): CausalPairInterface | undefined { + return this.pair; + } + + memoize(pair: CausalPairInterface): void { + this.memoizeCalls.push(pair); + this.pair = pair; + } +} + +describe(createCausalContext.name, () => { + it('uses the resolver pair when one is already established', () => { + const resolver = new MockCausalContextResolver({ + correlationId: 'corr-1', + causationId: 'cause-1', + }); + + const eventContext = createCausalContext(resolver, {}, {}); + + expect(eventContext.getHeader('correlationId')).toBe('corr-1'); + expect(eventContext.getHeader('causationId')).toBe('cause-1'); + expect(resolver.memoizeCalls).toHaveLength(0); + }); + + it('mints and memoizes a self-correlated pair when no pair is resolvable', () => { + const resolver = new MockCausalContextResolver(); + + const eventContext = createCausalContext(resolver, {}, {}); + + const correlationId = eventContext.getHeader('correlationId'); + const causationId = eventContext.getHeader('causationId'); + + expect(correlationId).toBe(causationId); + expect(resolver.memoizeCalls).toEqual([{ correlationId, causationId }]); + }); + + it('populates recordedAt at construction time', () => { + const resolver = new MockCausalContextResolver({ + correlationId: 'corr-1', + causationId: 'cause-1', + }); + + const before = new Date(); + const eventContext = createCausalContext(resolver, {}, {}); + const after = new Date(); + + const recordedAt = eventContext.getHeader('recordedAt'); + expect(recordedAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(recordedAt.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it('carries caller-supplied extra headers alongside the causal fields', () => { + const resolver = new MockCausalContextResolver({ + correlationId: 'corr-1', + causationId: 'cause-1', + }); + + const eventContext = createCausalContext( + resolver, + { namespace: 'my-namespace' }, + {}, + ); + + expect(eventContext.getHeader('namespace')).toBe('my-namespace'); + expect(eventContext.getHeader('correlationId')).toBe('corr-1'); + }); + + it('carries caller-supplied metadata', () => { + const resolver = new MockCausalContextResolver({ + correlationId: 'corr-1', + causationId: 'cause-1', + }); + + const eventContext = createCausalContext( + resolver, + {}, + { passcode: 'abc123' }, + ); + + expect(eventContext.getMeta('passcode')).toBe('abc123'); + }); +}); diff --git a/packages/nestjs-core/src/domain/events/causal-context/causal-context-headers.interface.ts b/packages/nestjs-core/src/domain/events/causal-context/causal-context-headers.interface.ts new file mode 100644 index 000000000..13abee1cc --- /dev/null +++ b/packages/nestjs-core/src/domain/events/causal-context/causal-context-headers.interface.ts @@ -0,0 +1,13 @@ +/** + * Base headers every event context carries, regardless of framework or + * package. `namespace` and other domain-specific fields are supplied via + * a per-call extension generic, not declared here. + */ +export interface EventContextHeadersInterface extends Record { + /** Stable across the whole causal chain. */ + correlationId: string; + /** Id of the inbound request/command that caused this context to exist. */ + causationId: string; + /** Auto-populated at context-construction time. */ + recordedAt: Date; +} diff --git a/packages/nestjs-core/src/domain/events/causal-context/causal-context-resolver.interface.ts b/packages/nestjs-core/src/domain/events/causal-context/causal-context-resolver.interface.ts new file mode 100644 index 000000000..f8600f06e --- /dev/null +++ b/packages/nestjs-core/src/domain/events/causal-context/causal-context-resolver.interface.ts @@ -0,0 +1,21 @@ +export interface CausalPairInterface { + correlationId: string; + causationId: string; +} + +/** + * Abstracts over *where* the correlation/causation pair for the current + * logical operation lives — an HTTP request context, a test fixture, a + * seed script. {@link createCausalContext} is written once against this + * interface and never touches the concrete source. + */ +export interface CausalContextResolver { + /** Returns the pair if one is already established, else `undefined`. */ + resolve(): CausalPairInterface | undefined; + /** + * Records a freshly synthesized pair so a later call through the same + * resolver instance is consistent. Not a substitute for seeding the + * pair at the true root of a request. + */ + memoize(pair: CausalPairInterface): void; +} diff --git a/packages/nestjs-core/src/domain/events/causal-context/create-causal-context.ts b/packages/nestjs-core/src/domain/events/causal-context/create-causal-context.ts new file mode 100644 index 000000000..20d546bbe --- /dev/null +++ b/packages/nestjs-core/src/domain/events/causal-context/create-causal-context.ts @@ -0,0 +1,40 @@ +import { type EventContextHeadersInterface } from './causal-context-headers.interface.js'; +import { type CausalContextResolver } from './causal-context-resolver.interface.js'; +import { EventContextHost } from './event-context.host.js'; + +/** + * Resolves the correlation/causation pair via `resolver`, or mints and + * memoizes a fresh self-correlated pair (`correlationId === causationId`) + * when no pair is resolvable — the degenerate case for an operation with + * no traceable origin (a seed script, a bare unit test), visibly + * distinguishable from a real chain because the two ids match. + */ +export function createCausalContext< + E extends Record = Record, + M extends Record = Record, +>( + resolver: CausalContextResolver, + extraHeaders: E, + metadata: M, +): EventContextHost { + const resolved = resolver.resolve(); + + let pair: { correlationId: string; causationId: string }; + + if (resolved) { + pair = resolved; + } else { + const correlationId = globalThis.crypto.randomUUID(); + pair = { correlationId, causationId: correlationId }; + resolver.memoize(pair); + } + + const headers: EventContextHeadersInterface & E = { + ...extraHeaders, + correlationId: pair.correlationId, + causationId: pair.causationId, + recordedAt: new Date(), + }; + + return new EventContextHost(headers, metadata); +} diff --git a/packages/nestjs-core/src/domain/events/causal-context/event-context.host.ts b/packages/nestjs-core/src/domain/events/causal-context/event-context.host.ts new file mode 100644 index 000000000..3cffde02e --- /dev/null +++ b/packages/nestjs-core/src/domain/events/causal-context/event-context.host.ts @@ -0,0 +1,35 @@ +import { type EventContextHeadersInterface } from './causal-context-headers.interface.js'; +import { type EventContextInterface } from './interfaces/event-context.interface.js'; + +/** + * Frozen `{headers, metadata}` container passed to domain aggregate + * factory/mutator methods and stored on every domain event. + * + * `H` is constrained to {@link EventContextHeadersInterface} so a caller + * cannot construct a context missing the required causal fields — the + * constraint is enforced at every call site, including explicit type + * arguments. The one gap this leaves open (a caller satisfying the type + * with garbage values, e.g. `correlationId: ''`) is accepted; closing it + * would require branded types and a cast, which is not worth it here. + */ +export class EventContextHost< + H extends EventContextHeadersInterface = EventContextHeadersInterface, + M extends Record = Record, +> implements EventContextInterface { + readonly headers: H; + readonly metadata: M; + + constructor(headers: H, metadata: M) { + this.headers = { ...headers }; + this.metadata = { ...metadata }; + Object.freeze(this); + } + + getHeader(key: K): H[K] { + return this.headers[key]; + } + + getMeta(key: K): M[K] { + return this.metadata[key]; + } +} diff --git a/packages/nestjs-core/src/domain/events/causal-context/interfaces/event-context.interface.ts b/packages/nestjs-core/src/domain/events/causal-context/interfaces/event-context.interface.ts new file mode 100644 index 000000000..d5716b3ba --- /dev/null +++ b/packages/nestjs-core/src/domain/events/causal-context/interfaces/event-context.interface.ts @@ -0,0 +1,12 @@ +import { type EventContextHeadersInterface } from '../causal-context-headers.interface.js'; + +export interface EventContextInterface< + H extends EventContextHeadersInterface = EventContextHeadersInterface, + M extends Record = Record, +> { + headers: H; + metadata: M; + + getHeader(key: K): H[K]; + getMeta(key: K): M[K]; +} diff --git a/packages/nestjs-core/src/domain/events/create-event-context.ts b/packages/nestjs-core/src/domain/events/create-event-context.ts new file mode 100644 index 000000000..99275cfd4 --- /dev/null +++ b/packages/nestjs-core/src/domain/events/create-event-context.ts @@ -0,0 +1,36 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { AppContextHost } from '../../infrastructure/context/app-context.host.js'; + +import { AppContextHostCausalResolver } from './app-context-host-causal-resolver.js'; +import { type EventContextHeadersInterface } from './causal-context/causal-context-headers.interface.js'; +import { createCausalContext } from './causal-context/create-causal-context.js'; +import { type EventContextHost } from './causal-context/event-context.host.js'; + +/** + * `AppContextHost.from` throws on a non-empty, non-`AppContextHost` value. + * Plain `new EventContextHost(...)` could never throw, so construction via + * `createEventContext` should not gain that new failure mode — any + * resolution problem just degrades to the synthetic-fallback path in + * {@link createCausalContext}, the same path a seed script hits today. + */ +function resolveAppContextDefensively(ctx: PlainLiteralObject): AppContextHost { + try { + return AppContextHost.from(ctx); + } catch { + return AppContextHost.from({}); + } +} + +export function createEventContext< + E extends PlainLiteralObject = PlainLiteralObject, + M extends PlainLiteralObject = PlainLiteralObject, +>( + ctx: PlainLiteralObject, + extraHeaders: E, + metadata: M, +): EventContextHost { + const appCtx = resolveAppContextDefensively(ctx); + const resolver = new AppContextHostCausalResolver(appCtx); + return createCausalContext(resolver, extraHeaders, metadata); +} diff --git a/packages/nestjs-core/src/domain/exceptions/exception.types.ts b/packages/nestjs-core/src/domain/exceptions/exception.types.ts new file mode 100644 index 000000000..1ec582e88 --- /dev/null +++ b/packages/nestjs-core/src/domain/exceptions/exception.types.ts @@ -0,0 +1,20 @@ +import { type ExceptionContext } from '../types/operation.types.js'; + +export type RuntimeExceptionContext = ExceptionContext & { + originalError?: Error; +}; + +/** + * Classifies *who* is at fault for a `RuntimeException`, independent of the + * HTTP status it renders with. Intended for a peer logging module to decide + * log level/severity without guessing from status codes — never rendered on + * the wire (see `RuntimeException.getResponse()`). + * + * Open to extension — e.g. a future `dependency` value to separate "an + * upstream/infra failure, possibly retryable" from "a bug, page someone", + * both of which fall under `internal` today. + */ +export type RuntimeExceptionFault = + | 'client' // the caller sent something invalid — expected in normal operation + | 'usage' // the integrating developer misused the library — wiring, config, bad state + | 'internal'; // a bug, or an unexpected infrastructure failure diff --git a/packages/nestjs-core/src/domain/exceptions/interfaces/exception.interface.ts b/packages/nestjs-core/src/domain/exceptions/interfaces/exception.interface.ts new file mode 100644 index 000000000..245953f1f --- /dev/null +++ b/packages/nestjs-core/src/domain/exceptions/interfaces/exception.interface.ts @@ -0,0 +1,13 @@ +import { type ExceptionContext } from '../../types/operation.types.js'; + +export interface ExceptionInterface extends Error { + /** + * The error code. + */ + errorCode: string; + + /** + * Additional context + */ + context?: ExceptionContext; +} diff --git a/packages/nestjs-core/src/domain/exceptions/interfaces/runtime-exception-options.interface.ts b/packages/nestjs-core/src/domain/exceptions/interfaces/runtime-exception-options.interface.ts new file mode 100644 index 000000000..1d24b4008 --- /dev/null +++ b/packages/nestjs-core/src/domain/exceptions/interfaces/runtime-exception-options.interface.ts @@ -0,0 +1,21 @@ +import { type HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionFault } from '../exception.types.js'; + +export interface RuntimeExceptionOptions { + httpStatus?: HttpStatus; + message?: string; + messageParams?: (string | number)[]; + safeMessage?: string; + safeMessageParams?: (string | number)[]; + /** + * The original error, if any. Mapped onto both the native `cause` (via + * `HttpException`'s `options.cause`) and `context.originalError`. + */ + originalError?: unknown; + /** + * Who is at fault for this exception. Defaults to `'internal'` — see + * {@link RuntimeExceptionFault}. + */ + fault?: RuntimeExceptionFault; +} diff --git a/packages/nestjs-core/src/domain/exceptions/interfaces/runtime-exception.interface.ts b/packages/nestjs-core/src/domain/exceptions/interfaces/runtime-exception.interface.ts new file mode 100644 index 000000000..3c525bb85 --- /dev/null +++ b/packages/nestjs-core/src/domain/exceptions/interfaces/runtime-exception.interface.ts @@ -0,0 +1,44 @@ +import { type HttpExceptionBody, type HttpStatus } from '@nestjs/common'; + +import { + type RuntimeExceptionContext, + type RuntimeExceptionFault, +} from '../exception.types.js'; +import { type ExceptionInterface } from '../interfaces/exception.interface.js'; + +export interface RuntimeExceptionInterface extends ExceptionInterface { + /** + * The HTTP status code this exception renders with. Always set (defaults + * to `HttpStatus.INTERNAL_SERVER_ERROR` — see `getStatus()`). + */ + httpStatus: HttpStatus; + + /** + * Who is at fault for this exception. Always set (defaults to + * `'internal'`). Never rendered on the wire — see + * {@link RuntimeExceptionFault}. + */ + fault: RuntimeExceptionFault; + + /** + * If set, this message will be used on responses instead of `message`. + * + * Use this when the main message might expose + */ + safeMessage?: string; + + /** + * Additional context + */ + context: RuntimeExceptionContext; + + /** + * The HTTP status code (native `HttpException` accessor). + */ + getStatus(): number; + + /** + * The response body rendered by Nest's exception layer. + */ + getResponse(): HttpExceptionBody; +} diff --git a/packages/nestjs-core/src/domain/exceptions/not-an-error.exception.ts b/packages/nestjs-core/src/domain/exceptions/not-an-error.exception.ts new file mode 100644 index 000000000..9caabee82 --- /dev/null +++ b/packages/nestjs-core/src/domain/exceptions/not-an-error.exception.ts @@ -0,0 +1,19 @@ +import { type ExceptionInterface } from './interfaces/exception.interface.js'; + +export class NotAnErrorException extends Error implements ExceptionInterface { + errorCode = 'NOT_AN_ERROR'; + + context: { + originalError: unknown; + }; + + constructor( + originalError: unknown, + message = 'An error was caught that is not an Error object', + ) { + super(message); + this.context = { + originalError, + }; + } +} diff --git a/packages/nestjs-core/src/domain/exceptions/runtime.exception.spec.ts b/packages/nestjs-core/src/domain/exceptions/runtime.exception.spec.ts new file mode 100644 index 000000000..101317890 --- /dev/null +++ b/packages/nestjs-core/src/domain/exceptions/runtime.exception.spec.ts @@ -0,0 +1,195 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from './interfaces/runtime-exception-options.interface.js'; +import { RuntimeException } from './runtime.exception.js'; + +describe(RuntimeException.name, () => { + const testError = new Error('my error'); + + it('should accept zero params', () => { + const exception = new RuntimeException(); + expect(exception).toBeInstanceOf(HttpException); + expect(exception).toBeInstanceOf(RuntimeException); + expect(exception.message).toEqual('Runtime Exception'); + expect(exception.errorCode).toEqual('RUNTIME_EXCEPTION'); + expect(exception.httpStatus).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(exception.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(exception.name).toEqual('RuntimeException'); + expect(exception.fault).toEqual('internal'); + }); + + it('should accept only message param', () => { + const exception = new RuntimeException('hello world'); + expect(exception).toBeInstanceOf(RuntimeException); + expect(exception.message).toEqual('hello world'); + }); + + it('should accept message and options params', () => { + const options: RuntimeExceptionOptions = { + messageParams: ['world'], + safeMessage: 'foo %s', + safeMessageParams: ['bar'], + httpStatus: HttpStatus.BAD_REQUEST, + originalError: testError, + }; + + const exception = new RuntimeException('hello %s', options); + expect(exception).toBeInstanceOf(RuntimeException); + expect(exception.message).toEqual('hello world'); + expect(exception.safeMessage).toEqual('foo bar'); + expect(exception.httpStatus).toEqual(HttpStatus.BAD_REQUEST); + expect(exception.context.originalError).toEqual(testError); + expect(exception.cause).toEqual(testError); + }); + + it('should accept only options param', () => { + const options: RuntimeExceptionOptions = { + message: 'hello %s', + messageParams: ['world'], + safeMessage: 'foo %s', + safeMessageParams: ['bar'], + httpStatus: HttpStatus.BAD_REQUEST, + originalError: testError, + }; + + const exception = new RuntimeException(options); + expect(exception).toBeInstanceOf(RuntimeException); + expect(exception.message).toEqual('hello world'); + expect(exception.safeMessage).toEqual('foo bar'); + expect(exception.httpStatus).toEqual(HttpStatus.BAD_REQUEST); + expect(exception.context.originalError).toEqual(testError); + expect(exception.cause).toEqual(testError); + }); + + it('should map a non-Error originalError to a NotAnErrorException, on both cause and context', () => { + const originalError = { some: 'value' }; + const exception = new RuntimeException({ originalError }); + + expect(exception.cause).toBeInstanceOf(Error); + expect(exception.context.originalError).toBe(exception.cause); + }); + + describe('fault', () => { + it('defaults to internal', () => { + const exception = new RuntimeException(); + expect(exception.fault).toEqual('internal'); + }); + + it('accepts an explicit fault via options', () => { + const exception = new RuntimeException({ fault: 'client' }); + expect(exception.fault).toEqual('client'); + }); + + it('lets a subclass set its own default fault, independent of httpStatus', () => { + class ConfigException extends RuntimeException { + constructor() { + super({ httpStatus: HttpStatus.BAD_REQUEST, fault: 'usage' }); + } + } + + const exception = new ConfigException(); + expect(exception.fault).toEqual('usage'); + expect(exception.httpStatus).toEqual(HttpStatus.BAD_REQUEST); + }); + + it('lets a caller override a subclass default fault, same as httpStatus', () => { + class ClientException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ fault: 'client', ...options }); + } + } + + const exception = new ClientException({ fault: 'internal' }); + expect(exception.fault).toEqual('internal'); + }); + + it('is never rendered on the wire', () => { + const exception = new RuntimeException({ fault: 'client' }); + expect(exception.getResponse()).not.toHaveProperty('fault'); + }); + }); + + it('should allow setting error code', () => { + class CustomRuntimeException extends RuntimeException { + constructor() { + super(); + this.errorCode = 'CUSTOM_CODE'; + } + } + + const exception = new CustomRuntimeException(); + expect(exception).toBeInstanceOf(CustomRuntimeException); + expect(exception.errorCode).toEqual('CUSTOM_CODE'); + }); + + describe('getResponse', () => { + it('composes a native HttpExceptionBody for the default (500) case', () => { + const exception = new RuntimeException(); + + expect(exception.getResponse()).toEqual({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Internal Server Error', + error: 'Internal Server Error', + errorCode: 'RUNTIME_EXCEPTION', + }); + }); + + it('suppresses the detailed message on 5xx when no safeMessage is set', () => { + const exception = new RuntimeException('sensitive detail', { + httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + }); + + expect(exception.message).toEqual('sensitive detail'); + expect(exception.getResponse().message).toEqual('Internal Server Error'); + }); + + it('prefers safeMessage over the fallback on 5xx', () => { + const exception = new RuntimeException('sensitive detail', { + httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + safeMessage: 'a safe message', + }); + + expect(exception.getResponse().message).toEqual('a safe message'); + }); + + it('prefers safeMessage over message on non-5xx', () => { + const exception = new RuntimeException('the real message', { + httpStatus: HttpStatus.BAD_REQUEST, + safeMessage: 'the safe message', + }); + + expect(exception.getResponse().message).toEqual('the safe message'); + }); + + it('uses message directly on non-5xx when no safeMessage is set', () => { + const exception = new RuntimeException('the real message', { + httpStatus: HttpStatus.NOT_FOUND, + }); + + expect(exception.getResponse()).toEqual({ + statusCode: HttpStatus.NOT_FOUND, + message: 'the real message', + error: 'Not Found', + errorCode: 'RUNTIME_EXCEPTION', + }); + }); + + it('reflects an errorCode assigned by a subclass AFTER super() returns', () => { + class ChildException extends RuntimeException { + constructor() { + super({ httpStatus: HttpStatus.NOT_FOUND }); + this.errorCode = 'CHILD_ERROR'; + } + } + + const exception = new ChildException(); + + expect(exception.getResponse()).toEqual({ + statusCode: HttpStatus.NOT_FOUND, + message: 'Runtime Exception', + error: 'Not Found', + errorCode: 'CHILD_ERROR', + }); + }); + }); +}); diff --git a/packages/nestjs-core/src/domain/exceptions/runtime.exception.ts b/packages/nestjs-core/src/domain/exceptions/runtime.exception.ts new file mode 100644 index 000000000..8dc5addd3 --- /dev/null +++ b/packages/nestjs-core/src/domain/exceptions/runtime.exception.ts @@ -0,0 +1,178 @@ +import { STATUS_CODES } from 'http'; +import { format } from 'util'; + +import { + HttpException, + HttpStatus, + type HttpExceptionBody, +} from '@nestjs/common'; + +import { mapNonErrorToException } from '../../infrastructure/utils/map-non-error-to-exception.util.js'; + +import { + type RuntimeExceptionContext, + type RuntimeExceptionFault, +} from './exception.types.js'; +import { type RuntimeExceptionOptions } from './interfaces/runtime-exception-options.interface.js'; +import { type RuntimeExceptionInterface } from './interfaces/runtime-exception.interface.js'; + +/** + * Public body message used for 5xx responses when no `safeMessage` was + * provided — mirrors Nest's own default so a suppressed message reads + * identically to a generic Nest 500. + */ +const SAFE_MESSAGE_FALLBACK = 'Internal Server Error'; + +/** + * Base runtime exception for the whole monorepo. Rebased onto Nest's native + * `HttpException` (rather than bare `Error` + a custom `ExceptionsFilter` + * translation layer) so subclasses get `getStatus()`/`getResponse()` and + * OpenAPI/error tooling that already understands `HttpException` for free. + * + * Every subclass assigns `this.errorCode` (and often augments `this.context`) + * AFTER calling `super()`, so the response body can't be composed eagerly in + * the constructor without baking in the wrong `errorCode`. Instead + * {@link getResponse} composes the body lazily, at render time — Nest's + * exception handling always calls `getResponse()` when it actually needs the + * body (e.g. `BaseExceptionFilter.catch`), by which point every subclass + * constructor has already run. + */ +export class RuntimeException + extends HttpException + implements RuntimeExceptionInterface +{ + /** + * Machine-readable error code. A plain public field — NOT an accessor + * pair — because Nest's `HttpException` declares `errorCode` as a native + * class field, which would otherwise shadow a subclass-defined accessor. + */ + public errorCode = 'RUNTIME_EXCEPTION'; + + /** + * The HTTP status this exception renders with (same value passed to the + * `HttpException` constructor — exposed here too since many consumers + * read `.httpStatus` directly instead of calling `getStatus()`). + */ + readonly httpStatus: HttpStatus; + + /** + * Who is at fault for this exception — `'client'`, `'usage'` (the + * integrating developer misused the library), or `'internal'` (a bug or + * unexpected infrastructure failure). Defaults to `'internal'` so an + * unclassified exception fails loud rather than silently under-logging. + * + * Independent of `httpStatus` (a 400 can be `'usage'`; a 500 can be + * `'client'`) and deliberately absent from {@link getResponse} — this is + * triage data for a peer logging/observability module, not part of the + * wire contract. + */ + readonly fault: RuntimeExceptionFault; + + /** + * If set, this message is used on responses instead of `message`. + * + * Use this when the main message might expose sensitive detail. + */ + readonly safeMessage?: string; + + /** + * Additional context. + */ + public context: RuntimeExceptionContext = {}; + + constructor( + message?: string, + options?: Omit, + ); + constructor(options?: RuntimeExceptionOptions); + + constructor( + messageOrOptions?: string | RuntimeExceptionOptions, + options?: Omit, + ) { + let message: string | undefined; + + let finalOptions: + | RuntimeExceptionOptions + | Omit = {}; + + if (typeof messageOrOptions === 'object') { + message = messageOrOptions?.message; + finalOptions = messageOrOptions; + } else if (options) { + message = messageOrOptions; + finalOptions = options; + } else { + message = messageOrOptions; + } + + if (typeof message !== 'string') { + message = 'Runtime Exception'; + } + + const { + messageParams = [], + safeMessage, + safeMessageParams = [], + originalError, + httpStatus = HttpStatus.INTERNAL_SERVER_ERROR, + fault = 'internal', + } = finalOptions; + + const formattedMessage = format(message ?? '', ...messageParams); + const formattedSafeMessage = format( + safeMessage ?? '', + ...safeMessageParams, + ); + + const cause = + originalError !== undefined + ? mapNonErrorToException(originalError) + : undefined; + + super( + formattedMessage.length ? formattedMessage : formattedSafeMessage, + httpStatus, + { cause }, + ); + + this.httpStatus = httpStatus; + this.fault = fault; + + if (formattedSafeMessage.length) { + this.safeMessage = formattedSafeMessage; + } + + if (cause) { + this.context.originalError = cause; + } + } + + /** + * Composes the wire body lazily so it always reflects the final + * `errorCode`/`context` state, which subclasses only finish assigning + * after `super()` returns. + */ + public override getResponse(): HttpExceptionBody { + const statusCode = this.getStatus(); + + const message = + this.safeMessage ?? + (statusCode >= HttpStatus.INTERNAL_SERVER_ERROR + ? SAFE_MESSAGE_FALLBACK + : this.message); + + const body: HttpExceptionBody = { + statusCode, + message, + errorCode: this.errorCode, + }; + + const error = STATUS_CODES[statusCode]; + if (error !== undefined) { + body.error = error; + } + + return body; + } +} diff --git a/packages/nestjs-core/src/domain/factories/domain-factory.interface.ts b/packages/nestjs-core/src/domain/factories/domain-factory.interface.ts new file mode 100644 index 000000000..932cc8f70 --- /dev/null +++ b/packages/nestjs-core/src/domain/factories/domain-factory.interface.ts @@ -0,0 +1,21 @@ +import { type EventContextInterface } from '../events/causal-context/interfaces/event-context.interface.js'; + +export interface DomainFactory { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (...args: any[]): Domain; + + create( + eventContext: EventContextInterface, + props: Creatable, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] + ): Domain; + + createWithId( + eventContext: EventContextInterface, + id: string, + props: Creatable, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] + ): Domain; +} diff --git a/packages/nestjs-core/src/domain/reference/interfaces/reference-active.interface.ts b/packages/nestjs-core/src/domain/reference/interfaces/reference-active.interface.ts new file mode 100644 index 000000000..9eb440550 --- /dev/null +++ b/packages/nestjs-core/src/domain/reference/interfaces/reference-active.interface.ts @@ -0,0 +1,8 @@ +import { type ReferenceActive } from './reference.types.js'; + +/** + * Identifiable by active. + */ +export interface ReferenceActiveInterface { + active: T; +} diff --git a/packages/nestjs-core/src/domain/reference/interfaces/reference-email.interface.ts b/packages/nestjs-core/src/domain/reference/interfaces/reference-email.interface.ts new file mode 100644 index 000000000..1c741341a --- /dev/null +++ b/packages/nestjs-core/src/domain/reference/interfaces/reference-email.interface.ts @@ -0,0 +1,8 @@ +import { type ReferenceEmail } from './reference.types.js'; + +/** + * Identifiable by email. + */ +export interface ReferenceEmailInterface { + email: T; +} diff --git a/packages/nestjs-core/src/domain/reference/interfaces/reference-id.interface.ts b/packages/nestjs-core/src/domain/reference/interfaces/reference-id.interface.ts new file mode 100644 index 000000000..95f421a46 --- /dev/null +++ b/packages/nestjs-core/src/domain/reference/interfaces/reference-id.interface.ts @@ -0,0 +1,10 @@ +import { type ReferenceId } from './reference.types.js'; + +/** + * Identifiable by id. + * + * @see https://en.wikipedia.org/wiki/Reference_(computer_science) + */ +export interface ReferenceIdInterface { + id: T; +} diff --git a/packages/nestjs-core/src/domain/reference/interfaces/reference-subject.interface.ts b/packages/nestjs-core/src/domain/reference/interfaces/reference-subject.interface.ts new file mode 100644 index 000000000..d79d6ea1a --- /dev/null +++ b/packages/nestjs-core/src/domain/reference/interfaces/reference-subject.interface.ts @@ -0,0 +1,8 @@ +import { type ReferenceSubject } from './reference.types.js'; + +/** + * Identifiable by subject (JWT). + */ +export interface ReferenceSubjectInterface { + sub: T; +} diff --git a/packages/nestjs-core/src/domain/reference/interfaces/reference-username.interface.ts b/packages/nestjs-core/src/domain/reference/interfaces/reference-username.interface.ts new file mode 100644 index 000000000..b098784b0 --- /dev/null +++ b/packages/nestjs-core/src/domain/reference/interfaces/reference-username.interface.ts @@ -0,0 +1,8 @@ +import { type ReferenceUsername } from './reference.types.js'; + +/** + * Identifiable by username. + */ +export interface ReferenceUsernameInterface { + username: T; +} diff --git a/packages/nestjs-core/src/domain/reference/interfaces/reference-version.interface.ts b/packages/nestjs-core/src/domain/reference/interfaces/reference-version.interface.ts new file mode 100644 index 000000000..4356fb864 --- /dev/null +++ b/packages/nestjs-core/src/domain/reference/interfaces/reference-version.interface.ts @@ -0,0 +1,8 @@ +/** + * Identifiable by version. + * + * Domain-level version for optimistic concurrency. + */ +export interface ReferenceVersionInterface { + version: number; +} diff --git a/packages/nestjs-core/src/domain/reference/interfaces/reference.types.ts b/packages/nestjs-core/src/domain/reference/interfaces/reference.types.ts new file mode 100644 index 000000000..dd99d2cc1 --- /dev/null +++ b/packages/nestjs-core/src/domain/reference/interfaces/reference.types.ts @@ -0,0 +1,6 @@ +export type ReferenceId = string; +export type ReferenceActive = boolean; +export type ReferenceEmail = string; +export type ReferenceUsername = string; +export type ReferenceSubject = string; +export type ReferenceAssignment = string; diff --git a/packages/nestjs-core/src/domain/types/operation.types.ts b/packages/nestjs-core/src/domain/types/operation.types.ts new file mode 100644 index 000000000..79845ea2f --- /dev/null +++ b/packages/nestjs-core/src/domain/types/operation.types.ts @@ -0,0 +1,24 @@ +import { + type MutateOperations, + type ReadOperations, + type WriteOperations, +} from '../enums/operation.enum.js'; + +export type ExceptionContext = Record & { + originalError?: unknown; +}; + +/** + * Type for read operations (List, Read). + */ +export type ReadOperation = (typeof ReadOperations)[number]; + +/** + * Type for write operations (Create, CreateBatch, Update, Replace). + */ +export type WriteOperation = (typeof WriteOperations)[number]; + +/** + * Type for modify operations (write + delete/restore). + */ +export type MutateOperation = (typeof MutateOperations)[number]; diff --git a/packages/nestjs-core/src/domain/utils/deep-partial.ts b/packages/nestjs-core/src/domain/utils/deep-partial.ts new file mode 100644 index 000000000..f886b3430 --- /dev/null +++ b/packages/nestjs-core/src/domain/utils/deep-partial.ts @@ -0,0 +1,18 @@ +/** + * Same as Partial but goes deeper and makes Partial all its properties and sub-properties. + * + * !!! COPIED FROM TYPEORM !!! + */ +export type DeepPartial = + | T + | (T extends Array + ? DeepPartial[] + : T extends Map + ? Map, DeepPartial> + : T extends Set + ? Set> + : T extends object + ? { + [K in keyof T]?: DeepPartial; + } + : T); diff --git a/packages/nestjs-core/src/index-aggregate.ts b/packages/nestjs-core/src/index-aggregate.ts new file mode 100644 index 000000000..67700a51e --- /dev/null +++ b/packages/nestjs-core/src/index-aggregate.ts @@ -0,0 +1,6 @@ +export { AggregateMetaInterface } from './domain/aggregates/interfaces/aggregate-meta.interface.js'; + +export { DomainAggregate } from './domain/aggregates/domain-aggregate.js'; +export { DomainMapper } from './domain/aggregates/domain-mapper.js'; + +export { domainAggregateSchema } from './infrastructure/schemas/domain-aggregate.schema.js'; diff --git a/packages/nestjs-core/src/index.ts b/packages/nestjs-core/src/index.ts new file mode 100644 index 000000000..1d6df7200 --- /dev/null +++ b/packages/nestjs-core/src/index.ts @@ -0,0 +1,162 @@ +// Enums +export { ActionEnum } from './domain/enums/action.enum.js'; +export { + Operation, + ReadOperations, + WriteOperations, + MutateOperations, +} from './domain/enums/operation.enum.js'; + +// Core +export { DomainFactory } from './domain/factories/domain-factory.interface.js'; + +// Schemas (Zod / Standard Schema) +export { auditSchema } from './infrastructure/schemas/audit.schema.js'; +export { referenceIdSchema } from './infrastructure/schemas/reference-id.schema.js'; +export { conformsTo } from './infrastructure/schemas/conforms-to.util.js'; +export { + withOpenApi, + withNamedComponent, + standardSchemaConverter, + isStandardSchema, +} from './infrastructure/schemas/open-api.util.js'; + +// Module utilities +export { createSettingsProvider } from './infrastructure/utils/create-settings-provider.js'; + +// Module interfaces +export { ModuleOptionsControllerInterface } from './infrastructure/config/interfaces/module-options-controller.interface.js'; +export { ModuleOptionsSettingsInterface } from './infrastructure/config/interfaces/module-options-settings.interface.js'; + +// Domain exports +export { AssigneeRelationInterface } from './domain/assignee/interfaces/assignee-relation.interface.js'; + +// Core types & exceptions +export { + ExceptionContext, + ReadOperation, + WriteOperation, + MutateOperation, +} from './domain/types/operation.types.js'; +export { ExceptionInterface } from './domain/exceptions/interfaces/exception.interface.js'; +export { NotAnErrorException } from './domain/exceptions/not-an-error.exception.js'; + +// Utility types and functions +export { DeepPartial } from './domain/utils/deep-partial.js'; +export { mapNonErrorToException } from './infrastructure/utils/map-non-error-to-exception.util.js'; +export { toMilliseconds } from './infrastructure/utils/to-milliseconds.js'; +export { + isNil, + isNumber, + isObject, + isString, + isUndefined, +} from './infrastructure/utils/type-guards.util.js'; + +// Reference types +export { + ReferenceActive, + ReferenceAssignment, + ReferenceEmail, + ReferenceId, + ReferenceSubject, + ReferenceUsername, +} from './domain/reference/interfaces/reference.types.js'; + +// Reference interfaces +export { ReferenceActiveInterface } from './domain/reference/interfaces/reference-active.interface.js'; +export { ReferenceEmailInterface } from './domain/reference/interfaces/reference-email.interface.js'; +export { ReferenceIdInterface } from './domain/reference/interfaces/reference-id.interface.js'; +export { ReferenceSubjectInterface } from './domain/reference/interfaces/reference-subject.interface.js'; +export { ReferenceUsernameInterface } from './domain/reference/interfaces/reference-username.interface.js'; +export { ReferenceVersionInterface } from './domain/reference/interfaces/reference-version.interface.js'; + +// Audit types +export { + AuditDateCreated, + AuditDateDeleted, + AuditDateUpdated, + AuditVersion, +} from './domain/audit/interfaces/audit.types.js'; + +// Audit interfaces +export { AuditDateCreatedInterface } from './domain/audit/interfaces/audit-date-created.interface.js'; +export { AuditDateDeletedInterface } from './domain/audit/interfaces/audit-date-deleted.interface.js'; +export { AuditDateUpdatedInterface } from './domain/audit/interfaces/audit-date-updated.interface.js'; +export { AuditVersionInterface } from './domain/audit/interfaces/audit-version.interface.js'; +export { AuditInterface } from './domain/audit/interfaces/audit.interface.js'; + +// exception types +export { + RuntimeExceptionContext, + RuntimeExceptionFault, +} from './domain/exceptions/exception.types.js'; + +// exception interfaces +export { RuntimeExceptionOptions } from './domain/exceptions/interfaces/runtime-exception-options.interface.js'; +export { RuntimeExceptionInterface } from './domain/exceptions/interfaces/runtime-exception.interface.js'; + +// exceptions +export { RuntimeException } from './domain/exceptions/runtime.exception.js'; + +// Hook interfaces and types +export { SpecificationInterface } from './infrastructure/hook/interfaces/specification.interface.js'; +export { HookOption, HookWithSpec } from './infrastructure/hook/hook.types.js'; + +// Context host and decorators +export { AppContextHost } from './infrastructure/context/app-context.host.js'; +export { getAppContext } from './infrastructure/context/get-app-context.util.js'; +export { Ctx } from './infrastructure/context/ctx.decorator.js'; + +// Context primitives +export { OverlayRef } from './domain/context/overlay-ref.js'; +export type { AppContextLike } from './domain/context/app-context-like.type.js'; +export { OverlayNotDefinedException } from './infrastructure/context/exceptions/overlay-not-defined.exception.js'; + +// Context overlay utilities +export { ContextOverlayInterceptor } from './infrastructure/context/context-overlay.interceptor.js'; + +// Context interfaces +export { AppContextInterface } from './domain/context/interfaces/app-context.interface.js'; +export { HookContextInterface } from './infrastructure/context/interfaces/hook-context.interface.js'; + +// Correlation context +export { + CorrelationCtx, + CorrelationContextOverlay, +} from './infrastructure/context/correlation-context.overlay.js'; +export { CorrelationContextInterface } from './infrastructure/context/interfaces/correlation-context.interface.js'; + +// Event context +export { EventContextHost } from './domain/events/causal-context/event-context.host.js'; +export { EventContextInterface } from './domain/events/causal-context/interfaces/event-context.interface.js'; +export { EventContextHeadersInterface } from './domain/events/causal-context/causal-context-headers.interface.js'; +export { + CausalContextResolver, + CausalPairInterface, +} from './domain/events/causal-context/causal-context-resolver.interface.js'; +export { createCausalContext } from './domain/events/causal-context/create-causal-context.js'; +export { AppContextHostCausalResolver } from './domain/events/app-context-host-causal-resolver.js'; +export { createEventContext } from './domain/events/create-event-context.js'; + +// Top-level module +export { CoreModule } from './core.module.js'; + +// Hook feature +export { HookTypeInterface } from './infrastructure/hook/hook.interfaces.js'; +export type { HookMethodKeyType } from './infrastructure/hook/decorators/hook-method.decorator.js'; +export { Spec } from './infrastructure/hook/specification/spec.factory.js'; +export { CompositeSpecification } from './infrastructure/hook/specification/composite-specification.js'; +export { AlwaysSpecification } from './infrastructure/hook/specification/specifications/always.specification.js'; +export { NeverSpecification } from './infrastructure/hook/specification/specifications/never.specification.js'; +export { AndSpecification } from './infrastructure/hook/specification/specifications/and.specification.js'; +export { OrSpecification } from './infrastructure/hook/specification/specifications/or.specification.js'; +export { NotSpecification } from './infrastructure/hook/specification/specifications/not.specification.js'; +export { HookResolverService } from './infrastructure/hook/hook.resolver.service.js'; +export { UseHooks } from './infrastructure/hook/decorators/use-hooks.decorator.js'; +export { Hook } from './infrastructure/hook/decorators/hook.decorator.js'; +export { Specification } from './infrastructure/hook/decorators/specification.decorator.js'; +export { createHookMethodDecorator } from './infrastructure/hook/decorators/hook-method.decorator.js'; +export { HooksCtx } from './infrastructure/hook/hook.context.overlay.js'; +export { HookNotDecoratedException } from './infrastructure/hook/exceptions/hook-not-decorated.exception.js'; +export { HookProviderNotFoundException } from './infrastructure/hook/exceptions/hook-provider-not-found.exception.js'; diff --git a/packages/nestjs-core/src/infrastructure/config/interfaces/module-options-controller.interface.ts b/packages/nestjs-core/src/infrastructure/config/interfaces/module-options-controller.interface.ts new file mode 100644 index 000000000..a4c9d8fd9 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/config/interfaces/module-options-controller.interface.ts @@ -0,0 +1,5 @@ +import { type Type } from '@nestjs/common'; + +export interface ModuleOptionsControllerInterface { + controller?: false | Type | Type[]; +} diff --git a/packages/nestjs-core/src/infrastructure/config/interfaces/module-options-settings.interface.ts b/packages/nestjs-core/src/infrastructure/config/interfaces/module-options-settings.interface.ts new file mode 100644 index 000000000..5aec8bacb --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/config/interfaces/module-options-settings.interface.ts @@ -0,0 +1,4 @@ +export interface ModuleOptionsSettingsInterface { + settings?: T; + settingsTransform?: (settings?: T, defaultSettings?: T) => T; +} diff --git a/packages/nestjs-core/src/infrastructure/context/__tests__/correlation-context.overlay.spec.ts b/packages/nestjs-core/src/infrastructure/context/__tests__/correlation-context.overlay.spec.ts new file mode 100644 index 000000000..36416943d --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/context/__tests__/correlation-context.overlay.spec.ts @@ -0,0 +1,58 @@ +import { mock } from 'vitest-mock-extended'; + +import { type ArgumentsHost, type ExecutionContext } from '@nestjs/common'; + +import { + CorrelationContextOverlay, + CorrelationCtx, +} from '../correlation-context.overlay.js'; +import { getAppContext } from '../get-app-context.util.js'; + +type HttpArgumentsHost = ReturnType; + +const makeCtx = (request: object): ExecutionContext => { + const httpArgsHost = mock(); + httpArgsHost.getRequest.mockReturnValue(request); + const ctx = mock(); + ctx.switchToHttp.mockReturnValue(httpArgsHost); + return ctx; +}; + +describe(CorrelationContextOverlay.name, () => { + let overlay: CorrelationContextOverlay; + + beforeEach(() => { + overlay = new CorrelationContextOverlay(); + }); + + it('should seed correlationId/causationId from the x-correlation-id header', () => { + const request = { headers: { 'x-correlation-id': 'req-corr-1' } }; + overlay.attach(makeCtx(request)); + + expect(getAppContext(request).with(CorrelationCtx)).toEqual({ + correlationId: 'req-corr-1', + causationId: 'req-corr-1', + }); + }); + + it('should mint a fresh self-correlated pair when no header is present', () => { + const request = { headers: {} }; + overlay.attach(makeCtx(request)); + + const { correlationId, causationId } = + getAppContext(request).with(CorrelationCtx); + expect(correlationId).toBe(causationId); + expect(correlationId).toEqual(expect.any(String)); + }); + + it('should be idempotent when attached twice', () => { + const request = { headers: { 'x-correlation-id': 'req-corr-1' } }; + overlay.attach(makeCtx(request)); + request.headers['x-correlation-id'] = 'req-corr-2'; + overlay.attach(makeCtx(request)); + + expect(getAppContext(request).with(CorrelationCtx).correlationId).toBe( + 'req-corr-1', + ); + }); +}); diff --git a/packages/nestjs-core/src/infrastructure/context/app-context.host.spec.ts b/packages/nestjs-core/src/infrastructure/context/app-context.host.spec.ts new file mode 100644 index 000000000..acb8b901e --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/context/app-context.host.spec.ts @@ -0,0 +1,59 @@ +import { OverlayRef } from '../../domain/context/overlay-ref.js'; + +import { AppContextHost } from './app-context.host.js'; + +interface FeatureProps { + value: string; +} + +const FeatureRef = new OverlayRef<'withFeature', FeatureProps>('withFeature'); + +describe(AppContextHost.name, () => { + describe('removeOverlay', () => { + it('removes a defined overlay and clears supports()', () => { + const ctx = new AppContextHost(); + ctx.defineOverlay(FeatureRef, { value: 'first' }); + + expect(ctx.supports(FeatureRef)).toBe(true); + + const removed = ctx.removeOverlay(FeatureRef); + + expect(removed).toBe(true); + expect(ctx.supports(FeatureRef)).toBe(false); + }); + + it('allows redefining an overlay after removal', () => { + const ctx = new AppContextHost(); + ctx.defineOverlay(FeatureRef, { value: 'first' }); + ctx.removeOverlay(FeatureRef); + + ctx.defineOverlay(FeatureRef, { value: 'second' }); + + expect(ctx.supports(FeatureRef)).toBe(true); + expect(ctx.with(FeatureRef)).toEqual( + expect.objectContaining({ value: 'second' }), + ); + }); + + it('does not remove an overlay inherited from a parent context', () => { + const parent = new AppContextHost(); + parent.defineOverlay(FeatureRef, { value: 'parent' }); + const child = AppContextHost.from(parent.with(FeatureRef)); + + const removed = child.removeOverlay(FeatureRef); + + expect(removed).toBe(false); + expect(parent.supports(FeatureRef)).toBe(true); + expect(child.supports(FeatureRef)).toBe(true); + }); + + it('is a no-op when the overlay was never defined', () => { + const ctx = new AppContextHost(); + + const removed = ctx.removeOverlay(FeatureRef); + + expect(removed).toBe(false); + expect(ctx.supports(FeatureRef)).toBe(false); + }); + }); +}); diff --git a/packages/nestjs-core/src/infrastructure/context/app-context.host.ts b/packages/nestjs-core/src/infrastructure/context/app-context.host.ts new file mode 100644 index 000000000..b6d523e3f --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/context/app-context.host.ts @@ -0,0 +1,180 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type AppContextLike } from '../../domain/context/app-context-like.type.js'; +import { type AppContextInterface } from '../../domain/context/interfaces/app-context.interface.js'; +import { type OverlayRef } from '../../domain/context/overlay-ref.js'; +import { type RefsToMethods } from '../../domain/context/refs-to-methods.type.js'; + +import { OverlayNotDefinedException } from './exceptions/overlay-not-defined.exception.js'; + +/** + * Symbol key used to store the context on the request object. + */ +export const APP_CONTEXT_KEY = Symbol('APP_CONTEXT_KEY'); + +// --------------------------------------------------------------------------- +// Proxy handler — intercepts undefined `with*` calls +// --------------------------------------------------------------------------- + +const proxyHandler: ProxyHandler = { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (value !== undefined) return value; + + if (typeof prop === 'string' && prop.startsWith('with')) { + throw new OverlayNotDefinedException(prop); + } + + return value; + }, +}; + +/** + * Per-request context container backed by typed overlays. + * + * Overlays are defined via {@link defineOverlay} and accessed through + * typed `with*()` methods. The proxy constructor intercepts calls to + * undefined `with*` methods and throws {@link OverlayNotDefinedException}. + * + * @example + * ```typescript + * // In an overlay's attach(): + * const ctx = getAppContext(request); + * ctx.defineOverlay(this.ref, resolvedValues); + * + * // In a handler: + * const typed = ctx.require(WithFeature); + * const feature = typed.withFeature(); + * ``` + */ +export class AppContextHost implements AppContextInterface { + constructor() { + return new Proxy(this, proxyHandler); + } + + /** + * Define an overlay on this context instance by ref and pre-resolved values. + * + * Installs a `with*()` method that returns the provided values wrapped + * in a prototype-chain child of this context. + * + * Idempotent — if the overlay name already exists on `this`, this is a no-op. + */ + defineOverlay( + ref: OverlayRef, + values: Props, + ): void { + const name = ref.name; + + if (Object.prototype.hasOwnProperty.call(this, name)) return; + + Object.defineProperty(this, name, { + value: function (this: AppContextHost) { + return Object.assign(Object.create(this), values); + }, + enumerable: false, + configurable: true, + writable: false, + }); + } + + /** + * Remove a previously defined overlay from this context instance. + * + * Only removes an overlay owned directly by this instance — an overlay + * inherited from a parent (e.g. a `with()` child's prototype) is left + * untouched. Returns whether an own overlay was removed. + */ + removeOverlay( + ref: OverlayRef, + ): boolean { + if (!Object.prototype.hasOwnProperty.call(this, ref.name)) return false; + return Reflect.deleteProperty(this, ref.name); + } + + /** + * Type-level narrowing gate. + * + * Returns `this` cast to include the typed `with*()` methods for the + * given refs. No runtime validation — the proxy handles undefined overlays. + */ + require[]>( + ..._refs: R + ): this & RefsToMethods { + return this as this & RefsToMethods; + } + + /** + * Direct lookup by ref. Returns the resolved overlay props. + */ + with< + Name extends string, + Props extends PlainLiteralObject, + Args extends unknown[], + >(ref: OverlayRef, ...args: Args): Props { + const fn = Reflect.get(this, ref.name); + + if (typeof fn !== 'function') { + throw new OverlayNotDefinedException(ref.name); + } + + // Cast required: runtime-assigned overlay method return type cannot be + // statically inferred from the dynamic Reflect.get lookup. + return Reflect.apply(fn, this, args) as Props; + } + + /** + * Check if an overlay is defined on this context. + */ + supports(ref: OverlayRef): boolean { + return ref.name in this; + } + + /** + * Returns a proxy where calling any overlay method returns the + * resolved overlay if defined, or `this` unchanged if not. + */ + optional(): Record this> { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + return new Proxy( + {}, + { + get(_target, prop: string) { + return (...args: unknown[]) => { + try { + const fn = Reflect.get(self, prop); + if (typeof fn === 'function') { + return Reflect.apply(fn, self, args); + } + } catch { + // proxy guard threw — overlay not defined, fall through + } + return self; + }; + }, + }, + ); + } + + /** + * Resolve an `AppContextLike` value to a guaranteed `AppContextHost`. + * + * - `AppContextHost` → returns as-is + * - `undefined`, `null`, or empty `{}` → returns a new `AppContextHost` + * - Non-empty non-AppContextHost object → throws + */ + static from(value?: AppContextLike): AppContextHost { + if (value instanceof AppContextHost) return value; + if ( + value === undefined || + value === null || + Object.keys(value).length === 0 + ) { + return new AppContextHost(); + } + throw new Error( + `Expected AppContextHost or nullish value, got ${typeof value}`, + ); + } +} diff --git a/packages/nestjs-core/src/infrastructure/context/context-overlay.interceptor.ts b/packages/nestjs-core/src/infrastructure/context/context-overlay.interceptor.ts new file mode 100644 index 000000000..31a5f1b1e --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/context/context-overlay.interceptor.ts @@ -0,0 +1,25 @@ +import { Observable } from 'rxjs'; + +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + PlainLiteralObject, +} from '@nestjs/common'; + +import { OverlayRef } from '../../domain/context/overlay-ref.js'; + +@Injectable() +export abstract class ContextOverlayInterceptor implements NestInterceptor { + abstract readonly ref: OverlayRef; + abstract attach(context: ExecutionContext): void | Promise; + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + await this.attach(context); + return next.handle(); + } +} diff --git a/packages/nestjs-core/src/infrastructure/context/correlation-context.overlay.ts b/packages/nestjs-core/src/infrastructure/context/correlation-context.overlay.ts new file mode 100644 index 000000000..d0097f821 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/context/correlation-context.overlay.ts @@ -0,0 +1,41 @@ +import { randomUUID } from 'node:crypto'; + +import { ExecutionContext, Injectable } from '@nestjs/common'; + +import { OverlayRef } from '../../domain/context/overlay-ref.js'; + +import { ContextOverlayInterceptor } from './context-overlay.interceptor.js'; +import { getAppContext } from './get-app-context.util.js'; +import { CorrelationContextInterface } from './interfaces/correlation-context.interface.js'; + +export const CorrelationCtx = new OverlayRef< + 'withCorrelation', + CorrelationContextInterface +>('withCorrelation'); + +/** + * Seeds the correlation/causation pair for the current request. A fresh + * request has no causation ancestor, so `causationId` starts equal to + * `correlationId` — the same self-correlated convention + * {@link createCausalContext} falls back to when no overlay is present. + */ +@Injectable() +export class CorrelationContextOverlay extends ContextOverlayInterceptor { + readonly ref = CorrelationCtx; + + attach(context: ExecutionContext): void { + const request = context + .switchToHttp() + .getRequest<{ headers: Record }>(); + const ctx = getAppContext(request); + + const header = request.headers['x-correlation-id']; + const correlationId = + (Array.isArray(header) ? header[0] : header) ?? randomUUID(); + + ctx.defineOverlay(CorrelationCtx, { + correlationId, + causationId: correlationId, + }); + } +} diff --git a/packages/nestjs-core/src/infrastructure/context/ctx.decorator.ts b/packages/nestjs-core/src/infrastructure/context/ctx.decorator.ts new file mode 100644 index 000000000..b4321af8c --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/context/ctx.decorator.ts @@ -0,0 +1,36 @@ +import { + createParamDecorator, + type ExecutionContext, + type PlainLiteralObject, +} from '@nestjs/common'; + +import { type OverlayRef } from '../../domain/context/overlay-ref.js'; + +import { getAppContext } from './get-app-context.util.js'; + +/** + * Parameter decorator to inject the per-request application context. + * + * When called without arguments, returns the raw `AppContextHost`. + * When called with an `OverlayRef`, unwraps the overlay via `appCtx.with(ref)`. + * + * @example + * ```typescript + * // Raw context + * @Get() + * handle(@Ctx() ctx: AppContextHost) { ... } + * + * // Unwrapped overlay + * @Get() + * handle(@Ctx(MyOverlayRef) overlay: MyOverlayInterface) { ... } + * ``` + */ +export const Ctx = createParamDecorator( + ( + ref: OverlayRef | undefined, + ctx: ExecutionContext, + ) => { + const appCtx = getAppContext(ctx.switchToHttp().getRequest()); + return ref ? appCtx.with(ref) : appCtx; + }, +); diff --git a/packages/nestjs-core/src/infrastructure/context/exceptions/overlay-not-defined.exception.ts b/packages/nestjs-core/src/infrastructure/context/exceptions/overlay-not-defined.exception.ts new file mode 100644 index 000000000..9bca2ed26 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/context/exceptions/overlay-not-defined.exception.ts @@ -0,0 +1,14 @@ +import { type RuntimeExceptionOptions } from '../../../domain/exceptions/interfaces/runtime-exception-options.interface.js'; +import { RuntimeException } from '../../../domain/exceptions/runtime.exception.js'; + +export class OverlayNotDefinedException extends RuntimeException { + constructor(name: string, options?: RuntimeExceptionOptions) { + super({ + message: `Overlay "${name}" is not defined on the context. Ensure the corresponding interceptor is applied to this route.`, + fault: 'usage', + ...options, + }); + + this.errorCode = 'OVERLAY_NOT_DEFINED'; + } +} diff --git a/packages/nestjs-core/src/infrastructure/context/get-app-context.util.ts b/packages/nestjs-core/src/infrastructure/context/get-app-context.util.ts new file mode 100644 index 000000000..3e21dcf49 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/context/get-app-context.util.ts @@ -0,0 +1,24 @@ +import { AppContextHost, APP_CONTEXT_KEY } from './app-context.host.js'; + +/** + * Get or create the application context for a request. + * + * Creates a new context on first access; subsequent calls return the same instance. + * Typically used by interceptors to define overlays on the context. + * + * @example + * ```typescript + * // In an overlay's attach() method + * const ctx = getAppContext(request); + * ctx.defineOverlay(this.ref, resolvedValues); + * ``` + */ +export function getAppContext( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: any, +): AppContextHost { + if (!request[APP_CONTEXT_KEY]) { + request[APP_CONTEXT_KEY] = new AppContextHost(); + } + return request[APP_CONTEXT_KEY]; +} diff --git a/packages/nestjs-core/src/infrastructure/context/interfaces/correlation-context.interface.ts b/packages/nestjs-core/src/infrastructure/context/interfaces/correlation-context.interface.ts new file mode 100644 index 000000000..d8f819d4c --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/context/interfaces/correlation-context.interface.ts @@ -0,0 +1,11 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +/** + * Correlation/causation pair seeded once per inbound request by + * {@link CorrelationContextOverlay} and read back by + * {@link AppContextHostCausalResolver} when constructing event contexts. + */ +export interface CorrelationContextInterface extends PlainLiteralObject { + correlationId: string; + causationId: string; +} diff --git a/packages/nestjs-core/src/infrastructure/context/interfaces/hook-context.interface.ts b/packages/nestjs-core/src/infrastructure/context/interfaces/hook-context.interface.ts new file mode 100644 index 000000000..b1ecebb6b --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/context/interfaces/hook-context.interface.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type HookWithSpec } from '../../hook/hook.types.js'; + +/** + * Context interface for hooks. + * + * Contains the hooks array that the hook system gathers from + * decorators and module registrations. + */ +export interface HookContextInterface extends PlainLiteralObject { + /** + * Normalized hook configurations to apply for this operation. + */ + hooks: HookWithSpec[]; +} diff --git a/packages/nestjs-core/src/infrastructure/context/utils/create-correlation-feature-providers.ts b/packages/nestjs-core/src/infrastructure/context/utils/create-correlation-feature-providers.ts new file mode 100644 index 000000000..850ad9985 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/context/utils/create-correlation-feature-providers.ts @@ -0,0 +1,15 @@ +import { type Provider, type Type } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; + +import { CorrelationContextOverlay } from '../correlation-context.overlay.js'; + +export function createCorrelationFeatureProviders(): Provider[] { + return [ + CorrelationContextOverlay, + { provide: APP_INTERCEPTOR, useClass: CorrelationContextOverlay }, + ]; +} + +export function createCorrelationFeatureExports(): Type[] { + return [CorrelationContextOverlay]; +} diff --git a/packages/nestjs-core/src/infrastructure/hook/__tests__/hook.resolver.service.spec.ts b/packages/nestjs-core/src/infrastructure/hook/__tests__/hook.resolver.service.spec.ts new file mode 100644 index 000000000..128b50336 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/__tests__/hook.resolver.service.spec.ts @@ -0,0 +1,74 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { type PlainLiteralObject } from '@nestjs/common'; +import { type ModuleRef, type Reflector } from '@nestjs/core'; + +import { HookNotDecoratedException } from '../exceptions/hook-not-decorated.exception.js'; +import { HookProviderNotFoundException } from '../exceptions/hook-provider-not-found.exception.js'; +import { HookResolverService } from '../hook.resolver.service.js'; +import { type HookWithSpec } from '../hook.types.js'; + +const TYPE_KEY = 'testHook'; + +class TestHook {} + +function ctxWith(config: HookWithSpec): PlainLiteralObject { + return { hooks: [config] }; +} + +describe(HookResolverService.name, () => { + let moduleRef: DeepMockProxy; + let reflector: DeepMockProxy; + let service: HookResolverService; + + beforeEach(() => { + moduleRef = mockDeep(); + reflector = mockDeep(); + service = new HookResolverService(moduleRef, reflector); + }); + + it('should throw HookProviderNotFoundException when the hook is not registered as a provider', async () => { + moduleRef.get.mockImplementation(() => { + throw new Error('Nest could not find TestHook element'); + }); + + await expect( + service.execute( + { KEY: TYPE_KEY }, + 'beforeFind', + {}, + ctxWith({ hook: TestHook, type: TYPE_KEY }), + ), + ).rejects.toThrow(HookProviderNotFoundException); + }); + + it('should throw HookNotDecoratedException when the hook is missing @Hook()', async () => { + moduleRef.get.mockReturnValue(new TestHook()); + reflector.get.mockReturnValue(undefined); + + await expect( + service.execute( + { KEY: TYPE_KEY }, + 'beforeFind', + {}, + ctxWith({ hook: TestHook, type: TYPE_KEY }), + ), + ).rejects.toThrow(HookNotDecoratedException); + }); + + it('should return the payload unchanged when the hook has no methods for this key', async () => { + moduleRef.get.mockReturnValue(new TestHook()); + reflector.get.mockReturnValue(new Map()); + + const payload = { value: 1 }; + + const result = await service.execute( + { KEY: TYPE_KEY }, + 'beforeFind', + payload, + ctxWith({ hook: TestHook, type: TYPE_KEY }), + ); + + expect(result).toBe(payload); + }); +}); diff --git a/packages/nestjs-core/src/infrastructure/hook/decorators/hook-method.decorator.ts b/packages/nestjs-core/src/infrastructure/hook/decorators/hook-method.decorator.ts new file mode 100644 index 000000000..cac4fdd20 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/decorators/hook-method.decorator.ts @@ -0,0 +1,72 @@ +import { HOOK_METHOD_METADATA_KEY } from '../hook.constants.js'; +import { type HookMethodMetadataInterface } from '../hook.interfaces.js'; +import { type SpecificationInterface } from '../interfaces/specification.interface.js'; + +/** + * Hook method key type. + * Subsystems define their own keys (e.g., RepoHookMethodKey.BEFORE_FIND). + */ +export type HookMethodKeyType = string; + +/** + * Creates a hook method decorator for a specific hook key. + * + * The returned decorator can be applied to methods in a `@Hook` class. + * When called with a specification, it overrides the class/method-level spec + * for this specific hook. + * + * Multiple hook decorators can be stacked on the same method. + * + * @param key - The hook method key (subsystems define their own keys) + * @returns A decorator factory that optionally accepts a specification + * + * @example + * ```typescript + * // Subsystems define their own keys and decorators + * export const BeforeFind = createHookMethodDecorator(RepoHookMethodKey.BEFORE_FIND); + * export const AfterCreate = createHookMethodDecorator(RepoHookMethodKey.AFTER_CREATE); + * + * // Use without spec (uses class/method-level spec) + * @BeforeFind() + * addFilter(options) { ... } + * + * // Use with hook-specific spec override + * @BeforeRemove(Spec.hasRole('admin')) + * restrictDelete(entity) { ... } + * + * // Multiple hooks on same method + * @BeforeFind() + * @BeforeFindOne() + * addTenantFilter(options) { ... } + * ``` + */ +export function createHookMethodDecorator( + key: HookMethodKeyType, +): (spec?: SpecificationInterface) => MethodDecorator { + return (spec?: SpecificationInterface): MethodDecorator => { + return ( + _target: object, + _propertyKey: string | symbol, + descriptor: PropertyDescriptor, + ): PropertyDescriptor => { + const method = descriptor.value; + if (!method) { + return descriptor; + } + + // Get existing metadata or initialize empty array + const existing: HookMethodMetadataInterface[] = + Reflect.getMetadata(HOOK_METHOD_METADATA_KEY, method) ?? []; + + // Add this hook's metadata + const metadata: HookMethodMetadataInterface = { key, spec }; + Reflect.defineMetadata( + HOOK_METHOD_METADATA_KEY, + [...existing, metadata], + method, + ); + + return descriptor; + }; + }; +} diff --git a/packages/nestjs-core/src/infrastructure/hook/decorators/hook.decorator.ts b/packages/nestjs-core/src/infrastructure/hook/decorators/hook.decorator.ts new file mode 100644 index 000000000..57ab2d0eb --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/decorators/hook.decorator.ts @@ -0,0 +1,147 @@ +import { applyDecorators, Injectable, SetMetadata } from '@nestjs/common'; +import { MetadataScanner } from '@nestjs/core'; + +import { + HOOK_METADATA_KEY, + HOOK_METHOD_METADATA_KEY, + HOOK_METHODS_CACHE_KEY, + SPECIFICATION_METADATA_KEY, +} from '../hook.constants.js'; +import { + type HookDecoratorOptions, + type HookMethodMapInterface, + type HookMethodMetadataInterface, + type HookMetadataInterface, +} from '../hook.interfaces.js'; +import { type SpecificationInterface } from '../interfaces/specification.interface.js'; +import { Spec } from '../specification/spec.factory.js'; + +import { type HookMethodKeyType } from './hook-method.decorator.js'; +import { Specification } from './specification.decorator.js'; + +const metadataScanner = new MetadataScanner(); + +/** + * Resolve hook type from string or object with KEY property. + */ +function resolveHookType(type: string | { KEY: string }): string { + if (typeof type === 'string') { + return type; + } + return type.KEY; +} + +/** + * Marks a class as a hook that can be registered and executed. + * + * Automatically applies `@Injectable()` so the class can be resolved via DI. + * Pre-computes method mappings at decoration time for O(1) runtime lookup. + * + * Hook classes should have methods decorated with hook method decorators + * like `@BeforeFind()`, `@AfterCreate()`, etc. + * + * @param options - Hook options including required type and optional spec. + * + * @example + * ```typescript + * // Repository hook using decorator reference + * @Hook({ type: RepoHook }) + * export class TenantHook { + * @BeforeFind() + * addTenantFilter(options, ctx) { ... } + * } + * + * // Hook with class-level spec + * @Hook({ type: RepoHook, spec: Spec.hasRole('admin') }) + * export class AdminHook { + * @AfterCreate() + * logAdminAction(result, ctx) { ... } + * } + * + * // Using subsystem-specific decorator (recommended) + * @RepoHook() + * export class AuditHook { + * @AfterCreate() + * logCreation(result, ctx) { ... } + * } + * ``` + */ +export function Hook(options: HookDecoratorOptions): ClassDecorator { + const hookType = resolveHookType(options.type); + + const metadata: HookMetadataInterface = { + type: hookType, + }; + + const decorators: (ClassDecorator | MethodDecorator)[] = [ + Injectable(), + SetMetadata(HOOK_METADATA_KEY, metadata), + ]; + + if (options.spec) { + decorators.push(Specification(options.spec)); + } + + // Apply base decorators first, then scan methods + const baseDecorator = applyDecorators(...decorators); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (target: Function) => { + // Apply Injectable, SetMetadata, etc. + baseDecorator(target); + + // Pre-compute method mappings at decoration time + const methodsCache = scanHookMethods(target); + Reflect.defineMetadata(HOOK_METHODS_CACHE_KEY, methodsCache, target); + }; +} + +/** + * Scan a hook class prototype for decorated methods. + * Called once at decoration time (app startup). + * Pre-computes resolved specifications for O(1) runtime lookup. + */ +function scanHookMethods( + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + target: Function, +): Map { + const methods = new Map(); + const prototype = target.prototype; + const methodNames = metadataScanner.getAllMethodNames(prototype); + + // Get class-level spec once (from @Specification or @Hook({ spec })) + const classSpec: SpecificationInterface | undefined = Reflect.getMetadata( + SPECIFICATION_METADATA_KEY, + target, + ); + + for (const methodName of methodNames) { + const method = prototype[methodName]; + if (typeof method !== 'function') continue; + + // Get hook method metadata + const hookMetadata: HookMethodMetadataInterface[] | undefined = + Reflect.getMetadata(HOOK_METHOD_METADATA_KEY, method); + + if (!hookMetadata || hookMetadata.length === 0) continue; + + // Get method-level spec once per method + const methodSpec: SpecificationInterface | undefined = Reflect.getMetadata( + SPECIFICATION_METADATA_KEY, + method, + ); + + // Register each hook key for this method + for (const meta of hookMetadata) { + // Resolve spec following priority: hook param > method > class > always + const resolvedSpec = + meta.spec ?? methodSpec ?? classSpec ?? Spec.always(); + + const existing = methods.get(meta.key) ?? []; + existing.push({ methodName, metadata: meta, resolvedSpec, method }); + methods.set(meta.key, existing); + } + } + + return methods; +} diff --git a/packages/nestjs-core/src/infrastructure/hook/decorators/specification.decorator.ts b/packages/nestjs-core/src/infrastructure/hook/decorators/specification.decorator.ts new file mode 100644 index 000000000..957a5d058 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/decorators/specification.decorator.ts @@ -0,0 +1,50 @@ +import { SetMetadata } from '@nestjs/common'; + +import { SPECIFICATION_METADATA_KEY } from '../hook.constants.js'; +import { type SpecificationInterface } from '../interfaces/specification.interface.js'; + +/** + * Sets a specification on a class or method. + * + * When applied to a class, it becomes the default specification for all methods. + * When applied to a method, it overrides any class-level specification. + * + * Specifications are plain objects (not NestJS providers) instantiated once + * at decoration time and reused for every request. + * + * Resolution order (most specific wins): + * 1. Hook decorator param: `@BeforeFind(spec)` - highest precedence + * 2. Method-level: `@Specification()` on the method + * 3. Class-level: `@Specification()` on the class (or via `@Hook(spec)`) + * 4. Default: `Spec.always()` + * + * @param spec - The specification instance that determines when the hook applies + * + * @example + * ```typescript + * // Class-level spec (applies to all methods) + * @Hook() + * @Specification(Spec.hasRole('admin')) + * class AdminHook { + * @AfterCreate() + * logAction() { ... } // Uses Spec.hasRole('admin') + * } + * + * // Method-level override + * @Hook() + * @Specification(Spec.always()) + * class MixedHook { + * @BeforeFind() + * findHook() { ... } // Uses Spec.always() + * + * @BeforeRemove() + * @Specification(Spec.hasRole('admin')) // Override for this method + * removeHook() { ... } // Uses Spec.hasRole('admin') + * } + * ``` + */ +export function Specification( + spec: SpecificationInterface, +): ClassDecorator & MethodDecorator { + return SetMetadata(SPECIFICATION_METADATA_KEY, spec); +} diff --git a/packages/nestjs-core/src/infrastructure/hook/decorators/use-hooks.decorator.ts b/packages/nestjs-core/src/infrastructure/hook/decorators/use-hooks.decorator.ts new file mode 100644 index 000000000..d19411647 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/decorators/use-hooks.decorator.ts @@ -0,0 +1,63 @@ +import { SetMetadata } from '@nestjs/common'; + +import { HOOKS_METADATA_KEY } from '../hook.constants.js'; +import { type HookOption } from '../hook.types.js'; + +/** + * Decorator to specify hooks for a controller class or method. + * + * The HookContextOverlay (registered globally via CoreModule) gathers hooks from + * this decorator and attaches them to the request context. + * + * When applied to a class, the hooks apply to all methods in that class. + * When applied to a method, the hooks apply only to that method. + * + * Method-level decorators are merged with class-level decorators. + * + * @param hooks - Hook configurations (class or `{ hook, spec }` objects) + * + * @example + * ```typescript + * // Class-level - applies to all methods + * @UseHooks(TenantHook, AuditHook) + * @Controller('users') + * class UserController { + * @Get() + * findAll() { ... } // TenantHook and AuditHook apply + * + * @Post() + * create() { ... } // TenantHook and AuditHook apply + * } + * ``` + * + * @example + * ```typescript + * // Method-level additions + * @UseHooks(TenantHook) + * @Controller('users') + * class UserController { + * @Get() + * findAll() { ... } // Only TenantHook (from class) + * + * @UseHooks(AdminHook) // Adds to class-level hooks + * @Delete(':id') + * delete() { ... } // TenantHook and AdminHook + * } + * ``` + * + * @example + * ```typescript + * // With specification objects + * @UseHooks( + * { hook: TenantHook, spec: Spec.isQuery() }, + * { hook: AuditHook, spec: Spec.isMutation() }, + * ) + * @Controller('orders') + * class OrderController { ... } + * ``` + */ +export function UseHooks( + ...hooks: HookOption[] +): ClassDecorator & MethodDecorator { + return SetMetadata(HOOKS_METADATA_KEY, hooks); +} diff --git a/packages/nestjs-core/src/infrastructure/hook/exceptions/hook-not-decorated.exception.ts b/packages/nestjs-core/src/infrastructure/hook/exceptions/hook-not-decorated.exception.ts new file mode 100644 index 000000000..a4cd643e7 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/exceptions/hook-not-decorated.exception.ts @@ -0,0 +1,15 @@ +import { type RuntimeExceptionOptions } from '../../../domain/exceptions/interfaces/runtime-exception-options.interface.js'; +import { RuntimeException } from '../../../domain/exceptions/runtime.exception.js'; + +export class HookNotDecoratedException extends RuntimeException { + constructor(hookName: string, options?: RuntimeExceptionOptions) { + super({ + message: `Hook class "%s" is registered via @UseHooks() but is missing the class-level @Hook() (or subsystem-specific, e.g. @RepoHook()) decorator, so its methods will never run.`, + messageParams: [hookName], + fault: 'usage', + ...options, + }); + + this.errorCode = 'HOOK_NOT_DECORATED'; + } +} diff --git a/packages/nestjs-core/src/infrastructure/hook/exceptions/hook-provider-not-found.exception.ts b/packages/nestjs-core/src/infrastructure/hook/exceptions/hook-provider-not-found.exception.ts new file mode 100644 index 000000000..4edcbc85b --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/exceptions/hook-provider-not-found.exception.ts @@ -0,0 +1,15 @@ +import { type RuntimeExceptionOptions } from '../../../domain/exceptions/interfaces/runtime-exception-options.interface.js'; +import { RuntimeException } from '../../../domain/exceptions/runtime.exception.js'; + +export class HookProviderNotFoundException extends RuntimeException { + constructor(hookName: string, options?: RuntimeExceptionOptions) { + super({ + message: `Hook class "%s" is registered via @UseHooks() but could not be resolved. Ensure it is registered in the module's providers.`, + messageParams: [hookName], + fault: 'usage', + ...options, + }); + + this.errorCode = 'HOOK_PROVIDER_NOT_FOUND'; + } +} diff --git a/packages/nestjs-core/src/infrastructure/hook/hook.constants.ts b/packages/nestjs-core/src/infrastructure/hook/hook.constants.ts new file mode 100644 index 000000000..41922a556 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/hook.constants.ts @@ -0,0 +1,26 @@ +/** + * Metadata key for `@UseHooks` decorator. + */ +export const HOOKS_METADATA_KEY = 'NESTJS_HOOK_HOOKS'; + +/** + * Metadata key for `@Hook` class decorator. + */ +export const HOOK_METADATA_KEY = Symbol('Hook'); + +/** + * Metadata key for hook method decorators. + * Stores array of HookMethodMetadataInterface on the decorated method. + */ +export const HOOK_METHOD_METADATA_KEY = Symbol('HookMethod'); + +/** + * Metadata key for pre-computed hook method mappings. + * Set by `@Hook()` decorator at class definition time for O(1) lookup. + */ +export const HOOK_METHODS_CACHE_KEY = Symbol('HookMethodsCache'); + +/** + * Metadata key for `@Specification` decorator (class and method level). + */ +export const SPECIFICATION_METADATA_KEY = Symbol('Specification'); diff --git a/packages/nestjs-core/src/infrastructure/hook/hook.context.overlay.ts b/packages/nestjs-core/src/infrastructure/hook/hook.context.overlay.ts new file mode 100644 index 000000000..34f4c7246 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/hook.context.overlay.ts @@ -0,0 +1,58 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { OverlayRef } from '../../domain/context/overlay-ref.js'; +import { ContextOverlayInterceptor } from '../context/context-overlay.interceptor.js'; +import { getAppContext } from '../context/get-app-context.util.js'; +import { HookContextInterface } from '../context/interfaces/hook-context.interface.js'; + +import { HOOK_METADATA_KEY, HOOKS_METADATA_KEY } from './hook.constants.js'; +import { HookMetadataInterface } from './hook.interfaces.js'; +import { HookOption, HookWithSpec } from './hook.types.js'; + +export const HooksCtx = new OverlayRef<'withHooks', HookContextInterface>( + 'withHooks', +); + +@Injectable() +export class HookContextOverlay extends ContextOverlayInterceptor { + readonly ref = HooksCtx; + + constructor(private readonly reflector: Reflector) { + super(); + } + + attach(context: ExecutionContext): void { + const request = context.switchToHttp().getRequest(); + const ctx = getAppContext(request); + const resolved = this.resolve(context); + ctx.defineOverlay(HooksCtx, resolved); + } + + private resolve(context: ExecutionContext): HookContextInterface { + const decoratorHooks = this.reflector.getAllAndMerge( + HOOKS_METADATA_KEY, + [context.getHandler(), context.getClass()], + ); + const hooks = (decoratorHooks ?? []).map((option) => + this.normalizeOption(option), + ); + return { hooks }; + } + + private normalizeOption(option: HookOption): HookWithSpec { + const hook = typeof option === 'function' ? option : option.hook; + const specOverride = typeof option === 'function' ? undefined : option.spec; + + const metadata = this.reflector.get( + HOOK_METADATA_KEY, + hook, + ); + + return { + hook, + type: metadata?.type, + spec: specOverride, + }; + } +} diff --git a/packages/nestjs-core/src/infrastructure/hook/hook.interfaces.ts b/packages/nestjs-core/src/infrastructure/hook/hook.interfaces.ts new file mode 100644 index 000000000..f0ea88365 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/hook.interfaces.ts @@ -0,0 +1,66 @@ +import { type HookMethodKeyType } from './decorators/hook-method.decorator.js'; +import { type SpecificationInterface } from './interfaces/specification.interface.js'; + +/** + * Hook type decorator with KEY property for subsystem filtering. + */ +export interface HookTypeInterface { + readonly KEY: string; +} + +/** + * Metadata stored on hook classes via `@Hook` decorator. + */ +export interface HookMetadataInterface { + type: string; +} + +/** + * Metadata stored for each hook method decorator on a method. + */ +export interface HookMethodMetadataInterface { + key: HookMethodKeyType; + spec?: SpecificationInterface; +} + +/** + * Cached method mapping for a hook. + * Pre-computed at decoration time for O(1) runtime lookup. + */ +export interface HookMethodMapInterface { + methodName: string; + metadata: HookMethodMetadataInterface; + /** + * Pre-resolved specification for this method. + * Computed at decoration time following priority: + * 1. Hook decorator param: `@BeforeFind(spec)` + * 2. Method-level: `@Specification()` on the method + * 3. Class-level: `@Specification()` on the class + * 4. Default: Spec.always() + */ + resolvedSpec: SpecificationInterface; + /** + * Pre-resolved method function from prototype. + * Stored at decoration time to avoid runtime property lookup. + */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + method: Function; +} + +/** + * A resolved hook instance with pre-computed method mappings. + * The spec here is from the hook config; per-method specs are in HookMethodMapInterface. + */ +export interface ResolvedHook { + hook: object; + spec?: SpecificationInterface; + methods?: Map; +} + +/** + * Options for the `@Hook` decorator. + */ +export interface HookDecoratorOptions { + type: string | { KEY: string }; + spec?: SpecificationInterface; +} diff --git a/packages/nestjs-core/src/infrastructure/hook/hook.resolver.service.ts b/packages/nestjs-core/src/infrastructure/hook/hook.resolver.service.ts new file mode 100644 index 000000000..344ce6b82 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/hook.resolver.service.ts @@ -0,0 +1,157 @@ +import { Injectable, PlainLiteralObject } from '@nestjs/common'; +import { ModuleRef, Reflector } from '@nestjs/core'; + +import { HookMethodKeyType } from './decorators/hook-method.decorator.js'; +import { HookNotDecoratedException } from './exceptions/hook-not-decorated.exception.js'; +import { HookProviderNotFoundException } from './exceptions/hook-provider-not-found.exception.js'; +import { HOOK_METHODS_CACHE_KEY } from './hook.constants.js'; +import { HookMethodMapInterface, ResolvedHook } from './hook.interfaces.js'; +import { HookWithSpec } from './hook.types.js'; +import { SpecificationInterface } from './interfaces/specification.interface.js'; + +/** + * Service for resolving hook configurations to instances + * and executing hook methods. + * + * Specifications are pre-computed at decoration time by `@Hook()`. + * This service evaluates those specs against the context at runtime. + * + * This service is stateless - hook configs and context are passed + * to each method call. + * + * Consuming modules (like nestjs-repository) use this service + * to resolve hooks and call the appropriate methods. + */ +@Injectable() +export class HookResolverService { + constructor( + private readonly moduleRef: ModuleRef, + private readonly reflector: Reflector, + ) {} + + /** + * Execute hooks for a specific subsystem and method key. + * + * Filters by hook type, resolves instances, evaluates specs, + * and calls hook methods in sequence. + * + * @param hookType - Hook type decorator with KEY property + * @param methodKey - The method key (e.g., 'beforeFind') + * @param payload - The payload to pass through hooks + * @param ctx - The hook context + * @returns The payload after processing by applicable hooks + */ + async execute( + hookType: { readonly KEY: string }, + methodKey: HookMethodKeyType, + payload: T, + ctx: PlainLiteralObject | undefined, + ): Promise { + if (!ctx?.hooks?.length) { + return payload; + } + + // Filter hooks by type + const typeHooks = ctx.hooks.filter( + (config: HookWithSpec) => config.type === hookType.KEY, + ); + + if (!typeHooks.length) { + return payload; + } + + // Resolve filtered hooks + const resolved = this.resolveHooks(typeHooks); + let result = payload; + + for (const resolvedHook of resolved) { + const methods = this.getMethods(resolvedHook, methodKey); + + for (const { method, spec } of methods) { + if (!spec.isSatisfiedBy(ctx)) { + continue; + } + + const hookResult = await method(result, ctx); + if (hookResult !== undefined) { + result = hookResult; + } + } + } + + return result; + } + + /** + * Get methods from a resolved hook for a specific hook method key. + * + * Returns an empty array if the hook doesn't have methods for this key, + * which is normal - not every hook handles every operation. + * + * Multiple methods can be registered for the same hook key. + * + * @param resolved - The resolved hook + * @param methodKey - The hook method key (e.g., 'beforeFind') + * @returns Array of objects containing bound method and pre-computed spec + */ + getMethods( + resolved: ResolvedHook, + methodKey: HookMethodKeyType, + ): Array<{ + method: (payload: T, ctx?: unknown) => Promise; + spec: SpecificationInterface; + }> { + const methodInfos = resolved.methods?.get(methodKey); + if (!methodInfos || methodInfos.length === 0) { + return []; + } + + const result: Array<{ + method: (payload: T, ctx?: unknown) => Promise; + spec: SpecificationInterface; + }> = []; + + for (const methodInfo of methodInfos) { + result.push({ + method: methodInfo.method.bind(resolved.hook), + spec: methodInfo.resolvedSpec, + }); + } + + return result; + } + + /** + * Resolve hook configurations to hook instances. + */ + private resolveHooks(configs: HookWithSpec[]): ResolvedHook[] { + return configs.map((config) => this.resolveConfig(config)); + } + + /** + * Resolve a single hook configuration to an instance. + * Uses pre-computed method mappings from `@Hook()` decorator for O(1) lookup. + */ + private resolveConfig(config: HookWithSpec): ResolvedHook { + let hook: object; + + try { + hook = this.moduleRef.get(config.hook, { strict: false }); + } catch (error) { + throw new HookProviderNotFoundException(config.hook.name, { + originalError: error, + }); + } + + // Get pre-computed method mappings from @Hook() decorator + const methods = this.reflector.get< + Map + >(HOOK_METHODS_CACHE_KEY, config.hook); + + if (!methods) { + throw new HookNotDecoratedException(config.hook.name); + } + + return { hook, spec: config.spec, methods }; + } +} diff --git a/packages/nestjs-core/src/infrastructure/hook/hook.types.ts b/packages/nestjs-core/src/infrastructure/hook/hook.types.ts new file mode 100644 index 000000000..ec8860ba9 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/hook.types.ts @@ -0,0 +1,23 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { type SpecificationInterface } from './interfaces/specification.interface.js'; + +/** + * Normalized hook configuration with hook class and optional specification. + * Stored on context and in registry. + */ +export interface HookWithSpec< + Ctx extends PlainLiteralObject = PlainLiteralObject, +> { + hook: Type; + type?: string; + spec?: SpecificationInterface; +} + +/** + * Configuration for a hook registration. + * Can be a hook class directly or a HookWithSpec object with spec override. + */ +export type HookOption = + | Type + | HookWithSpec; diff --git a/packages/nestjs-core/src/infrastructure/hook/interfaces/specification.interface.ts b/packages/nestjs-core/src/infrastructure/hook/interfaces/specification.interface.ts new file mode 100644 index 000000000..e89139f21 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/interfaces/specification.interface.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +/** + * Specification pattern interface. + * Determines if a context satisfies certain criteria. + */ +export interface SpecificationInterface< + Ctx extends PlainLiteralObject = PlainLiteralObject, +> { + /** + * Check if the context satisfies this specification. + * + * @param context - The context to check + * @returns true if satisfied, false otherwise + */ + isSatisfiedBy(context: Ctx): boolean; +} diff --git a/packages/nestjs-core/src/infrastructure/hook/specification/composite-specification.spec.ts b/packages/nestjs-core/src/infrastructure/hook/specification/composite-specification.spec.ts new file mode 100644 index 000000000..b9eb6e57d --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/specification/composite-specification.spec.ts @@ -0,0 +1,278 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type SpecificationInterface } from '../interfaces/specification.interface.js'; + +import { Spec } from './spec.factory.js'; +import { AndSpecification } from './specifications/and.specification.js'; +import { NotSpecification } from './specifications/not.specification.js'; +import { OrSpecification } from './specifications/or.specification.js'; + +/** + * Simple specification that checks if a value is greater than a threshold. + */ +class GreaterThanSpec implements SpecificationInterface<{ value: number }> { + constructor(private readonly threshold: number) {} + + isSatisfiedBy(context: { value: number }): boolean { + return context.value > this.threshold; + } +} + +/** + * Simple specification that checks if a value is even. + */ +class IsEvenSpec implements SpecificationInterface<{ value: number }> { + isSatisfiedBy(context: { value: number }): boolean { + return context.value % 2 === 0; + } +} + +/** + * Specification that always returns true. + */ +class AlwaysTrueSpec implements SpecificationInterface { + isSatisfiedBy(): boolean { + return true; + } +} + +/** + * Specification that always returns false. + */ +class AlwaysFalseSpec implements SpecificationInterface { + isSatisfiedBy(): boolean { + return false; + } +} + +describe('Spec factory', () => { + describe('Spec.always()', () => { + it('should always return true', () => { + const spec = Spec.always(); + expect(spec.isSatisfiedBy({})).toBe(true); + expect(spec.isSatisfiedBy({ any: 'value' })).toBe(true); + }); + }); + + describe('Spec.never()', () => { + it('should always return false', () => { + const spec = Spec.never(); + expect(spec.isSatisfiedBy({})).toBe(false); + expect(spec.isSatisfiedBy({ any: 'value' })).toBe(false); + }); + }); + + describe('Spec.and()', () => { + it('should combine specs with AND logic', () => { + const greaterThan5 = new GreaterThanSpec(5); + const isEven = new IsEvenSpec(); + const combined = Spec.and(greaterThan5, isEven); + + // 10 > 5 AND 10 is even = true + expect(combined.isSatisfiedBy({ value: 10 })).toBe(true); + + // 8 > 5 AND 8 is even = true + expect(combined.isSatisfiedBy({ value: 8 })).toBe(true); + + // 7 > 5 AND 7 is even = false (7 is odd) + expect(combined.isSatisfiedBy({ value: 7 })).toBe(false); + + // 4 > 5 AND 4 is even = false (4 is not > 5) + expect(combined.isSatisfiedBy({ value: 4 })).toBe(false); + + // 3 > 5 AND 3 is even = false (both fail) + expect(combined.isSatisfiedBy({ value: 3 })).toBe(false); + }); + + it('should return an AndSpecification instance', () => { + const spec1 = new AlwaysTrueSpec(); + const spec2 = new AlwaysFalseSpec(); + const combined = Spec.and(spec1, spec2); + + expect(combined).toBeInstanceOf(AndSpecification); + }); + + it('should short-circuit on first false', () => { + const alwaysFalse = new AlwaysFalseSpec(); + const alwaysTrue = new AlwaysTrueSpec(); + const combined = Spec.and(alwaysFalse, alwaysTrue); + + expect(combined.isSatisfiedBy({})).toBe(false); + }); + }); + + describe('Spec.or()', () => { + it('should combine specs with OR logic', () => { + const greaterThan5 = new GreaterThanSpec(5); + const isEven = new IsEvenSpec(); + const combined = Spec.or(greaterThan5, isEven); + + // 10 > 5 OR 10 is even = true (both true) + expect(combined.isSatisfiedBy({ value: 10 })).toBe(true); + + // 7 > 5 OR 7 is even = true (first true) + expect(combined.isSatisfiedBy({ value: 7 })).toBe(true); + + // 4 > 5 OR 4 is even = true (second true) + expect(combined.isSatisfiedBy({ value: 4 })).toBe(true); + + // 3 > 5 OR 3 is even = false (both false) + expect(combined.isSatisfiedBy({ value: 3 })).toBe(false); + }); + + it('should return an OrSpecification instance', () => { + const spec1 = new AlwaysTrueSpec(); + const spec2 = new AlwaysFalseSpec(); + const combined = Spec.or(spec1, spec2); + + expect(combined).toBeInstanceOf(OrSpecification); + }); + }); + + describe('Spec.not()', () => { + it('should negate a specification', () => { + const greaterThan5 = new GreaterThanSpec(5); + const notGreaterThan5 = Spec.not(greaterThan5); + + expect(notGreaterThan5.isSatisfiedBy({ value: 10 })).toBe(false); + expect(notGreaterThan5.isSatisfiedBy({ value: 5 })).toBe(true); + expect(notGreaterThan5.isSatisfiedBy({ value: 3 })).toBe(true); + }); + + it('should return a NotSpecification instance', () => { + const spec = new AlwaysTrueSpec(); + const negated = Spec.not(spec); + + expect(negated).toBeInstanceOf(NotSpecification); + }); + + it('should double negate back to original', () => { + const spec = new GreaterThanSpec(5); + const doubleNegated = Spec.not(Spec.not(spec)); + + // Double negation should give same results + expect(doubleNegated.isSatisfiedBy({ value: 10 })).toBe(true); + expect(doubleNegated.isSatisfiedBy({ value: 3 })).toBe(false); + }); + }); + + describe('complex compositions', () => { + it('should handle chained compositions', () => { + const greaterThan5 = new GreaterThanSpec(5); + const lessThan20 = Spec.not(new GreaterThanSpec(20)); // <= 20 + const isEven = new IsEvenSpec(); + + // (value > 5) AND (value <= 20) AND (value is even) + const combined = Spec.and(Spec.and(greaterThan5, lessThan20), isEven); + + expect(combined.isSatisfiedBy({ value: 10 })).toBe(true); // 10 > 5, 10 <= 20, even + expect(combined.isSatisfiedBy({ value: 8 })).toBe(true); // 8 > 5, 8 <= 20, even + expect(combined.isSatisfiedBy({ value: 7 })).toBe(false); // odd + expect(combined.isSatisfiedBy({ value: 4 })).toBe(false); // not > 5 + expect(combined.isSatisfiedBy({ value: 22 })).toBe(false); // > 20 + }); + + it('should handle mixed and/or compositions', () => { + const greaterThan10 = new GreaterThanSpec(10); + const isEven = new IsEvenSpec(); + + // (value > 10) OR (value is even) + const orCombined = Spec.or(greaterThan10, isEven); + + expect(orCombined.isSatisfiedBy({ value: 15 })).toBe(true); // > 10 + expect(orCombined.isSatisfiedBy({ value: 4 })).toBe(true); // even + expect(orCombined.isSatisfiedBy({ value: 5 })).toBe(false); // neither + + // NOT ((value > 10) OR (value is even)) + const notOrCombined = Spec.not(orCombined); + + expect(notOrCombined.isSatisfiedBy({ value: 15 })).toBe(false); + expect(notOrCombined.isSatisfiedBy({ value: 4 })).toBe(false); + expect(notOrCombined.isSatisfiedBy({ value: 5 })).toBe(true); + }); + }); +}); + +describe('AndSpecification', () => { + it('should return true only when both specs are satisfied', () => { + const left = new AlwaysTrueSpec(); + const right = new AlwaysTrueSpec(); + const and = new AndSpecification(left, right); + + expect(and.isSatisfiedBy({})).toBe(true); + }); + + it('should return false when left spec is not satisfied', () => { + const left = new AlwaysFalseSpec(); + const right = new AlwaysTrueSpec(); + const and = new AndSpecification(left, right); + + expect(and.isSatisfiedBy({})).toBe(false); + }); + + it('should return false when right spec is not satisfied', () => { + const left = new AlwaysTrueSpec(); + const right = new AlwaysFalseSpec(); + const and = new AndSpecification(left, right); + + expect(and.isSatisfiedBy({})).toBe(false); + }); + + it('should return false when both specs are not satisfied', () => { + const left = new AlwaysFalseSpec(); + const right = new AlwaysFalseSpec(); + const and = new AndSpecification(left, right); + + expect(and.isSatisfiedBy({})).toBe(false); + }); +}); + +describe('OrSpecification', () => { + it('should return true when both specs are satisfied', () => { + const left = new AlwaysTrueSpec(); + const right = new AlwaysTrueSpec(); + const or = new OrSpecification(left, right); + + expect(or.isSatisfiedBy({})).toBe(true); + }); + + it('should return true when left spec is satisfied', () => { + const left = new AlwaysTrueSpec(); + const right = new AlwaysFalseSpec(); + const or = new OrSpecification(left, right); + + expect(or.isSatisfiedBy({})).toBe(true); + }); + + it('should return true when right spec is satisfied', () => { + const left = new AlwaysFalseSpec(); + const right = new AlwaysTrueSpec(); + const or = new OrSpecification(left, right); + + expect(or.isSatisfiedBy({})).toBe(true); + }); + + it('should return false when neither spec is satisfied', () => { + const left = new AlwaysFalseSpec(); + const right = new AlwaysFalseSpec(); + const or = new OrSpecification(left, right); + + expect(or.isSatisfiedBy({})).toBe(false); + }); +}); + +describe('NotSpecification', () => { + it('should negate true to false', () => { + const spec = new AlwaysTrueSpec(); + const not = new NotSpecification(spec); + + expect(not.isSatisfiedBy({})).toBe(false); + }); + + it('should negate false to true', () => { + const spec = new AlwaysFalseSpec(); + const not = new NotSpecification(spec); + + expect(not.isSatisfiedBy({})).toBe(true); + }); +}); diff --git a/packages/nestjs-core/src/infrastructure/hook/specification/composite-specification.ts b/packages/nestjs-core/src/infrastructure/hook/specification/composite-specification.ts new file mode 100644 index 000000000..768b35019 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/specification/composite-specification.ts @@ -0,0 +1,47 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type SpecificationInterface } from '../interfaces/specification.interface.js'; + +/** + * Abstract base class for specifications. + * + * Implements the Specification pattern from Domain-Driven Design, + * allowing business rules to be encapsulated as reusable, composable objects. + * + * Use the Spec factory for composition: + * - Spec.and(spec1, spec2) + * - Spec.or(spec1, spec2) + * - Spec.not(spec) + * + * @example + * ```typescript + * class IsActiveSpec extends CompositeSpecification { + * isSatisfiedBy(user: User): boolean { + * return user.active === true; + * } + * } + * + * class IsAdminSpec extends CompositeSpecification { + * isSatisfiedBy(user: User): boolean { + * return user.role === 'admin'; + * } + * } + * + * // Compose specifications using the Spec factory + * const activeAdmin = Spec.and(new IsActiveSpec(), new IsAdminSpec()); + * const activeOrAdmin = Spec.or(new IsActiveSpec(), new IsAdminSpec()); + * const notActive = Spec.not(new IsActiveSpec()); + * ``` + */ +export abstract class CompositeSpecification< + Ctx extends PlainLiteralObject = PlainLiteralObject, +> implements SpecificationInterface { + /** + * Check if the context satisfies this specification. + * Must be implemented by subclasses. + * + * @param context - The context to check + * @returns true if satisfied, false otherwise + */ + abstract isSatisfiedBy(context: Ctx): boolean; +} diff --git a/packages/nestjs-core/src/infrastructure/hook/specification/spec.factory.ts b/packages/nestjs-core/src/infrastructure/hook/specification/spec.factory.ts new file mode 100644 index 000000000..91f849200 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/specification/spec.factory.ts @@ -0,0 +1,72 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type SpecificationInterface } from '../interfaces/specification.interface.js'; + +import { AlwaysSpecification } from './specifications/always.specification.js'; +import { AndSpecification } from './specifications/and.specification.js'; +import { NeverSpecification } from './specifications/never.specification.js'; +import { NotSpecification } from './specifications/not.specification.js'; +import { OrSpecification } from './specifications/or.specification.js'; + +/** + * Factory for creating common specifications. + * + * Provides a fluent API for creating and composing specifications. + * + * @example + * ```typescript + * // Simple specifications + * Spec.always() + * Spec.never() + * + * // Composed specifications + * Spec.and(spec1, spec2) + * Spec.or(spec1, spec2) + * Spec.not(spec1) + * + * // Nested composition + * Spec.and(Spec.or(spec1, spec2), Spec.not(spec3)) + * ``` + */ +export const Spec = { + /** + * Always matches - hook always applies. + */ + always: < + Ctx extends PlainLiteralObject = PlainLiteralObject, + >(): SpecificationInterface => new AlwaysSpecification(), + + /** + * Never matches - hook never applies. + * Useful for temporarily disabling hooks. + */ + never: < + Ctx extends PlainLiteralObject = PlainLiteralObject, + >(): SpecificationInterface => new NeverSpecification(), + + /** + * Combine two specifications with AND logic. + * Both specifications must be satisfied for the result to be true. + */ + and: ( + left: SpecificationInterface, + right: SpecificationInterface, + ): SpecificationInterface => new AndSpecification(left, right), + + /** + * Combine two specifications with OR logic. + * Either specification being satisfied will make the result true. + */ + or: ( + left: SpecificationInterface, + right: SpecificationInterface, + ): SpecificationInterface => new OrSpecification(left, right), + + /** + * Negate a specification. + * The result is true when the wrapped specification is not satisfied. + */ + not: ( + spec: SpecificationInterface, + ): SpecificationInterface => new NotSpecification(spec), +}; diff --git a/packages/nestjs-core/src/infrastructure/hook/specification/specifications/always.specification.ts b/packages/nestjs-core/src/infrastructure/hook/specification/specifications/always.specification.ts new file mode 100644 index 000000000..7bca7a68c --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/specification/specifications/always.specification.ts @@ -0,0 +1,23 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CompositeSpecification } from '../composite-specification.js'; + +/** + * Specification that always returns true. + * Use when a hook should always apply regardless of context. + * + * @example + * ```typescript + * // Hook always applies + * Spec.always() + * + * // Equivalent to not providing a specification at all + * ``` + */ +export class AlwaysSpecification< + Ctx extends PlainLiteralObject = PlainLiteralObject, +> extends CompositeSpecification { + isSatisfiedBy(): boolean { + return true; + } +} diff --git a/packages/nestjs-core/src/infrastructure/hook/specification/specifications/and.specification.ts b/packages/nestjs-core/src/infrastructure/hook/specification/specifications/and.specification.ts new file mode 100644 index 000000000..4eab9bf33 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/specification/specifications/and.specification.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type SpecificationInterface } from '../../interfaces/specification.interface.js'; +import { CompositeSpecification } from '../composite-specification.js'; + +/** + * Combines two specifications with AND logic. + * Both specifications must be satisfied for the result to be true. + */ +export class AndSpecification< + Ctx extends PlainLiteralObject = PlainLiteralObject, +> extends CompositeSpecification { + constructor( + private readonly left: SpecificationInterface, + private readonly right: SpecificationInterface, + ) { + super(); + } + + isSatisfiedBy(context: Ctx): boolean { + return ( + this.left.isSatisfiedBy(context) && this.right.isSatisfiedBy(context) + ); + } +} diff --git a/packages/nestjs-core/src/infrastructure/hook/specification/specifications/never.specification.ts b/packages/nestjs-core/src/infrastructure/hook/specification/specifications/never.specification.ts new file mode 100644 index 000000000..a6f75fbc1 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/specification/specifications/never.specification.ts @@ -0,0 +1,24 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CompositeSpecification } from '../composite-specification.js'; + +/** + * Specification that always returns false. + * Use when you want to temporarily disable a hook. + * + * @example + * ```typescript + * // Hook never applies + * Spec.never() + * + * // Useful for debugging or conditional disabling + * const spec = isDebug ? Spec.never() : Spec.always(); + * ``` + */ +export class NeverSpecification< + Ctx extends PlainLiteralObject = PlainLiteralObject, +> extends CompositeSpecification { + isSatisfiedBy(): boolean { + return false; + } +} diff --git a/packages/nestjs-core/src/infrastructure/hook/specification/specifications/not.specification.ts b/packages/nestjs-core/src/infrastructure/hook/specification/specifications/not.specification.ts new file mode 100644 index 000000000..7a255ddbc --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/specification/specifications/not.specification.ts @@ -0,0 +1,20 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type SpecificationInterface } from '../../interfaces/specification.interface.js'; +import { CompositeSpecification } from '../composite-specification.js'; + +/** + * Negates a specification. + * The result is true when the wrapped specification is not satisfied. + */ +export class NotSpecification< + Ctx extends PlainLiteralObject = PlainLiteralObject, +> extends CompositeSpecification { + constructor(private readonly spec: SpecificationInterface) { + super(); + } + + isSatisfiedBy(context: Ctx): boolean { + return !this.spec.isSatisfiedBy(context); + } +} diff --git a/packages/nestjs-core/src/infrastructure/hook/specification/specifications/or.specification.ts b/packages/nestjs-core/src/infrastructure/hook/specification/specifications/or.specification.ts new file mode 100644 index 000000000..9e2fb8594 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/specification/specifications/or.specification.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type SpecificationInterface } from '../../interfaces/specification.interface.js'; +import { CompositeSpecification } from '../composite-specification.js'; + +/** + * Combines two specifications with OR logic. + * Either specification being satisfied will make the result true. + */ +export class OrSpecification< + Ctx extends PlainLiteralObject = PlainLiteralObject, +> extends CompositeSpecification { + constructor( + private readonly left: SpecificationInterface, + private readonly right: SpecificationInterface, + ) { + super(); + } + + isSatisfiedBy(context: Ctx): boolean { + return ( + this.left.isSatisfiedBy(context) || this.right.isSatisfiedBy(context) + ); + } +} diff --git a/packages/nestjs-core/src/infrastructure/hook/utils/create-hook-feature-providers.ts b/packages/nestjs-core/src/infrastructure/hook/utils/create-hook-feature-providers.ts new file mode 100644 index 000000000..3604e965c --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/hook/utils/create-hook-feature-providers.ts @@ -0,0 +1,17 @@ +import { type Provider, type Type } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; + +import { HookContextOverlay } from '../hook.context.overlay.js'; +import { HookResolverService } from '../hook.resolver.service.js'; + +export function createHookFeatureProviders(): Provider[] { + return [ + HookResolverService, + HookContextOverlay, + { provide: APP_INTERCEPTOR, useClass: HookContextOverlay }, + ]; +} + +export function createHookFeatureExports(): Type[] { + return [HookResolverService, HookContextOverlay]; +} diff --git a/packages/nestjs-core/src/infrastructure/schemas/audit.schema.ts b/packages/nestjs-core/src/infrastructure/schemas/audit.schema.ts new file mode 100644 index 000000000..a8229a916 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/schemas/audit.schema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { type AuditInterface } from '../../domain/audit/interfaces/audit.interface.js'; + +import { conformsTo } from './conforms-to.util.js'; + +/** + * Audit schema. Composed into concrete entity schemas via `.extend()` — + * never used standalone as a request/response schema, so it is not + * wrapped with `withOpenApi`/`withNamedComponent` (only the final, + * concrete entity schema is). + */ +export const auditSchema = conformsTo()( + z.object({ + dateCreated: z.date(), + dateUpdated: z.date(), + dateDeleted: z.date().nullable(), + }), +); diff --git a/packages/nestjs-core/src/infrastructure/schemas/conforms-to.util.ts b/packages/nestjs-core/src/infrastructure/schemas/conforms-to.util.ts new file mode 100644 index 000000000..ebd73c129 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/schemas/conforms-to.util.ts @@ -0,0 +1,23 @@ +import { type z } from 'zod'; + +/** + * Compile-time assertion that a Zod schema's inferred output conforms to + * (is assignable to) a domain interface, replacing the old + * `class Dto implements Interface` guarantee. Extra fields on the schema + * are allowed; missing fields, wrong types, or optional-vs-nullable + * mismatches fail to compile. + * + * @example + * ```ts + * export const CacheSchema = conformsTo()( + * z.object({ id: z.string(), data: z.string().nullable() }), + * ); + * ``` + */ +export function conformsTo() { + return function >( + schema: Schema, + ): Schema { + return schema; + }; +} diff --git a/packages/nestjs-core/src/infrastructure/schemas/domain-aggregate.schema.spec.ts b/packages/nestjs-core/src/infrastructure/schemas/domain-aggregate.schema.spec.ts new file mode 100644 index 000000000..e1cfd307f --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/schemas/domain-aggregate.schema.spec.ts @@ -0,0 +1,71 @@ +import { auditSchema } from './audit.schema.js'; +import { domainAggregateSchema } from './domain-aggregate.schema.js'; +import { referenceIdSchema } from './reference-id.schema.js'; + +describe('auditSchema', () => { + it('accepts valid audit fields, including a null dateDeleted', () => { + const result = auditSchema.parse({ + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-02'), + dateDeleted: null, + }); + + expect(result).toEqual({ + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-02'), + dateDeleted: null, + }); + }); + + it('rejects a missing dateDeleted (nullable is required, not optional)', () => { + const result = auditSchema.safeParse({ + dateCreated: new Date(), + dateUpdated: new Date(), + }); + + expect(result.success).toBe(false); + }); +}); + +describe('referenceIdSchema', () => { + it('accepts a string id', () => { + expect(referenceIdSchema.parse({ id: 'abc' })).toEqual({ id: 'abc' }); + }); + + it('rejects a non-string id', () => { + expect(referenceIdSchema.safeParse({ id: 123 }).success).toBe(false); + }); +}); + +describe('domainAggregateSchema', () => { + it('merges audit + reference-id + version fields', () => { + const result = domainAggregateSchema.parse({ + id: 'abc', + version: 1, + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-02'), + dateDeleted: null, + }); + + expect(result).toEqual({ + id: 'abc', + version: 1, + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-02'), + dateDeleted: null, + }); + }); + + it('strips unknown keys (matching the legacy excludeAll/excludeExtraneousValues behavior)', () => { + const result = domainAggregateSchema.parse({ + id: 'abc', + version: 1, + dateCreated: new Date(), + dateUpdated: new Date(), + dateDeleted: null, + _internal: 'should be stripped', + }); + + expect(result).not.toHaveProperty('_internal'); + }); +}); diff --git a/packages/nestjs-core/src/infrastructure/schemas/domain-aggregate.schema.ts b/packages/nestjs-core/src/infrastructure/schemas/domain-aggregate.schema.ts new file mode 100644 index 000000000..77f1d5e87 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/schemas/domain-aggregate.schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { type AuditInterface } from '../../domain/audit/interfaces/audit.interface.js'; +import { type ReferenceIdInterface } from '../../domain/reference/interfaces/reference-id.interface.js'; +import { type ReferenceVersionInterface } from '../../domain/reference/interfaces/reference-version.interface.js'; + +import { auditSchema } from './audit.schema.js'; +import { conformsTo } from './conforms-to.util.js'; +import { referenceIdSchema } from './reference-id.schema.js'; + +/** + * Base schema for concrete entity schemas — merges audit + reference-id + + * version fields. Not wrapped with `withOpenApi`/`withNamedComponent` + * itself; see `audit.schema.ts`. + */ +export const domainAggregateSchema = conformsTo< + ReferenceIdInterface & ReferenceVersionInterface & AuditInterface +>()( + auditSchema.extend({ + ...referenceIdSchema.shape, + version: z.number(), + }), +); diff --git a/packages/nestjs-core/src/infrastructure/schemas/open-api.util.spec.ts b/packages/nestjs-core/src/infrastructure/schemas/open-api.util.spec.ts new file mode 100644 index 000000000..0726c3ab0 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/schemas/open-api.util.spec.ts @@ -0,0 +1,105 @@ +import { z } from 'zod'; + +import { + standardSchemaConverter, + withNamedComponent, + withOpenApi, +} from './open-api.util.js'; + +describe(withOpenApi, () => { + it('attaches a ~standard.jsonSchema extension that does not throw on Date fields', () => { + const schema = withOpenApi( + z.object({ createdAt: z.date(), deletedAt: z.date().nullable() }), + ); + + const jsonSchema = schema['~standard'].jsonSchema?.output?.({ + target: 'openapi-3.0', + }); + + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + createdAt: { type: 'string', format: 'date-time' }, + deletedAt: { type: 'string', format: 'date-time', nullable: true }, + }, + required: ['createdAt', 'deletedAt'], + additionalProperties: false, + }); + }); + + it('works without an id (no component registration)', () => { + const schema = withOpenApi(z.object({ name: z.string() })); + + expect(schema['~standard'].jsonSchema).toBeDefined(); + }); +}); + +describe(withNamedComponent, () => { + it('registers the schema under the given id, retrievable via .meta()', () => { + const schema = withNamedComponent(z.object({ name: z.string() }), 'Widget'); + + expect(schema.meta()).toEqual({ id: 'Widget' }); + }); + + it('throws when the id is already registered to a different schema instance', () => { + withNamedComponent(z.object({ name: z.string() }), 'DuplicateId'); + + expect(() => + withNamedComponent(z.object({ other: z.string() }), 'DuplicateId'), + ).toThrow(/already registered/); + }); +}); + +describe(standardSchemaConverter, () => { + it('returns a named $ref + components entry for a registered schema, reused across calls', () => { + const schema = withNamedComponent( + z.object({ id: z.string(), name: z.string() }), + 'WidgetResponse', + ); + + const first = standardSchemaConverter(schema, { schemaType: 'output' }); + const second = standardSchemaConverter(schema, { schemaType: 'output' }); + + expect(first?.schema).toEqual({ + $ref: '#/components/schemas/WidgetResponse', + }); + expect(second?.schema).toEqual({ + $ref: '#/components/schemas/WidgetResponse', + }); + expect(first?.components).toHaveProperty('WidgetResponse'); + }); + + it('hoists a nested named component into its own components entry, with the $ref rewritten', () => { + const categorySchema = withNamedComponent( + z.object({ id: z.string() }), + 'Category', + ); + const petSchema = withNamedComponent( + z.object({ id: z.string(), category: categorySchema }), + 'Pet', + ); + + const result = standardSchemaConverter(petSchema, { schemaType: 'output' }); + + // The Pet component body must NOT retain an embedded $defs/definitions + // block (invalid OpenAPI 3.0) — Category must be hoisted to its own + // top-level components entry instead. + expect(result?.components?.Pet).not.toHaveProperty('$defs'); + expect(result?.components?.Pet).not.toHaveProperty('definitions'); + expect(result?.components).toHaveProperty('Category'); + }); + + it('falls through (returns undefined) for an unregistered schema', () => { + const schema = withOpenApi(z.object({ name: z.string() })); + + expect( + standardSchemaConverter(schema, { schemaType: 'input' }), + ).toBeUndefined(); + }); + + it('returns undefined for a non-Standard-Schema value', () => { + expect( + standardSchemaConverter({ notASchema: true }, { schemaType: 'input' }), + ).toBeUndefined(); + }); +}); diff --git a/packages/nestjs-core/src/infrastructure/schemas/open-api.util.ts b/packages/nestjs-core/src/infrastructure/schemas/open-api.util.ts new file mode 100644 index 000000000..c6681ad08 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/schemas/open-api.util.ts @@ -0,0 +1,188 @@ +import { z } from 'zod'; +import { createStandardJSONSchemaMethod } from 'zod/v4/core'; + +import { type StandardSchemaConverter } from '@nestjs/swagger'; + +/** + * Shared JSON Schema conversion options for every schema built through + * `withOpenApi`. `unrepresentable: 'any'` avoids a hard throw for + * `z.date()` (Date has no native JSON Schema representation); the + * `override` then fills in the OpenAPI-3.0 shape `@ApiProperty` used to + * produce for date fields, so `z.date()`/`z.date().nullable()` can be + * used directly in schemas with no per-field wrapper. + */ +const jsonSchemaLibraryOptions = { + unrepresentable: 'any' as const, + override: (ctx: { + zodSchema: z.ZodType; + jsonSchema: Record; + }) => { + if (ctx.zodSchema instanceof z.ZodDate) { + ctx.jsonSchema.type = 'string'; + ctx.jsonSchema.format = 'date-time'; + } + }, +}; + +type JsonSchemaConversionParams = Parameters< + ReturnType +>[0]; +type JsonSchema = ReturnType>; + +/** + * Wraps `createStandardJSONSchemaMethod`'s returned function so + * `jsonSchemaLibraryOptions` (the date override, in particular) always + * applies — including when the CALLER never supplies `libraryOptions` + * itself. This matters because `@nestjs/swagger` calls + * `schema['~standard'].jsonSchema[type]({ target: 'openapi-3.0' })` + * directly with no `libraryOptions`, for any schema that falls through to + * this native path (i.e. every schema NOT registered via + * `withNamedComponent`, e.g. an inline request body schema). + * + * A caller-supplied `libraryOptions` fully replaces (not merges into) the + * date override below; no current caller passes one. + */ +function bridgedJsonSchemaMethod(schema: z.ZodType, io: 'input' | 'output') { + const generate = createStandardJSONSchemaMethod(schema, io); + return (params?: JsonSchemaConversionParams) => + generate({ + target: params?.target ?? 'draft-2020-12', + libraryOptions: { + ...jsonSchemaLibraryOptions, + ...params?.libraryOptions, + }, + }); +} + +/** + * Type guard narrowing an `unknown` value to a Zod (Standard Schema) schema + * — e.g. swagger's parameter/response converters, which receive arbitrary + * values and must confirm a schema before converting it. + */ +export function isStandardSchema(value: unknown): value is z.ZodType { + return !!value && typeof value === 'object' && '~standard' in value; +} + +/** + * Attaches the `~standard.jsonSchema` extension (per the `StandardJSONSchemaV1` + * spec) to a Zod schema, bridging it to Zod v4's `toJSONSchema`. This is + * what lets `@nestjs/swagger`'s native Standard Schema conversion path + * (and our own `standardSchemaConverter` below, for its fallback case) + * render a Zod schema as OpenAPI. + * + * IMPORTANT: `schema.meta(...)` clones and returns a NEW schema instance + * (like Zod's other builder methods) — it does not mutate `schema` in + * place. Always use the RETURN value of `withOpenApi`, never the schema + * passed in. + */ +export function withOpenApi(schema: T, id?: string): T { + const named = id ? schema.meta({ id }) : schema; + Object.assign(named['~standard'], { + jsonSchema: { + input: bridgedJsonSchemaMethod(named, 'input'), + output: bridgedJsonSchemaMethod(named, 'output'), + }, + }); + return named; +} + +/** + * Process-wide registry of schemas that should render as NAMED, reusable + * `components.schemas` entries (the equivalent of the legacy + * `ApiExtraModels`/`getSchemaPath` DTO-class dedup) rather than being + * inlined at every call site. A schema converted alone via the native + * `~standard.jsonSchema` path always inlines as the JSON Schema root, even + * with an `id` registered — `$defs`/`id` extraction only happens for + * schemas nested inside a single conversion call, never for that call's + * own top-level schema. Named, cross-endpoint `$ref` reuse therefore + * requires the document-level `standardSchemaConverter` below. + */ +const namedSchemaRegistry = new Map(); + +/** + * Marks a schema as a named, reusable OpenAPI component (e.g. `Cache`, + * `Pet`). Every endpoint referencing this exact schema instance will + * `$ref` the same `components.schemas` entry. Call this exactly once per + * id, at module scope, to build the exported schema constant. + * + * Throws if `id` is already registered — this registry is process-wide + * across all 13 packages, so a naming collision would otherwise silently + * degrade the FIRST schema registered under that id to an inline shape + * (see `standardSchemaConverter`'s identity check) instead of failing + * loudly at module-load time. + * + * Must be used on the RETURN value, same caveat as `withOpenApi`. + */ +export function withNamedComponent( + schema: T, + id: string, +): T { + if (namedSchemaRegistry.has(id)) { + throw new Error(`OpenAPI component id "${id}" is already registered.`); + } + const named = withOpenApi(schema, id); + namedSchemaRegistry.set(id, named); + return named; +} + +type JsonSchemaWithDefs = JsonSchema & { + $defs?: Record; + definitions?: Record; +}; + +/** + * Extracts a converted JSON Schema's own `$defs`/`definitions` block (the + * shapes of schemas NESTED inside this one) so they can be hoisted into + * the document's `components.schemas` as their own entries, rather than + * left embedded — which is both invalid OpenAPI 3.0 and would leave any + * `$ref` pointing at them dangling. `@nestjs/swagger` already rewrites the + * `$ref` POINTERS themselves (`#/$defs/X` → `#/components/schemas/X`); + * hoisting the definition BODIES to match is on us. + */ +function extractNestedComponents(rawSchema: JsonSchemaWithDefs): { + schema: JsonSchema; + nestedComponents: Record; +} { + const { $defs, definitions, ...schema } = rawSchema; + return { + schema, + nestedComponents: { ...$defs, ...definitions }, + }; +} + +/** + * Document-level Standard Schema → OpenAPI converter for + * `SwaggerModule.createDocument(app, config, { standardSchemaConverter })`. + * + * For schemas registered via `withNamedComponent`, returns a `$ref` to a + * named `components.schemas` entry (reused identically across every + * endpoint that references the same schema instance), with any nested + * named components it references hoisted alongside it. For any other + * schema, returns `undefined` so `@nestjs/swagger` falls through to the + * native `~standard.jsonSchema` path (inline, anonymous shape) — this + * fallback path is what documents request bodies passed via + * `@Body({ schema })` automatically, with no decorator needed. + */ +export const standardSchemaConverter: StandardSchemaConverter = ( + schema, + { schemaType }, +) => { + if (!isStandardSchema(schema)) { + return undefined; + } + const id = schema.meta()?.id; + if (!id || namedSchemaRegistry.get(id) !== schema) { + return undefined; + } + const convert = schema['~standard'].jsonSchema?.[schemaType]; + const raw = convert?.({ target: 'openapi-3.0' }); + if (!raw) { + return undefined; + } + const { schema: withoutDefs, nestedComponents } = + extractNestedComponents(raw); + return { + schema: { $ref: `#/components/schemas/${id}` }, + components: { ...nestedComponents, [id]: withoutDefs }, + }; +}; diff --git a/packages/nestjs-core/src/infrastructure/schemas/reference-id.schema.ts b/packages/nestjs-core/src/infrastructure/schemas/reference-id.schema.ts new file mode 100644 index 000000000..3a1017434 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/schemas/reference-id.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { type ReferenceIdInterface } from '../../domain/reference/interfaces/reference-id.interface.js'; + +import { conformsTo } from './conforms-to.util.js'; + +/** + * Reference-id schema. Composed into concrete entity schemas via + * `.extend()` — see `audit.schema.ts` for why it is not wrapped with + * `withOpenApi`/`withNamedComponent` itself. + */ +export const referenceIdSchema = conformsTo()( + z.object({ + id: z.string(), + }), +); diff --git a/packages/nestjs-core/src/infrastructure/utils/create-settings-provider.ts b/packages/nestjs-core/src/infrastructure/utils/create-settings-provider.ts new file mode 100644 index 000000000..47f80bec7 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/utils/create-settings-provider.ts @@ -0,0 +1,37 @@ +import { type InjectionToken, type Provider } from '@nestjs/common'; + +import { type ModuleOptionsSettingsInterface } from '../config/interfaces/module-options-settings.interface.js'; + +export function createSettingsProvider< + ModuleSettingsType, + ModuleOptionsType extends ModuleOptionsSettingsInterface, +>(options: { + settingsKey: string | symbol; + settingsToken: string; + optionsToken: InjectionToken; + optionsOverrides?: ModuleOptionsType; +}): Provider { + const { optionsOverrides, settingsToken, optionsToken, settingsKey } = + options; + + return { + provide: settingsToken, + inject: [optionsToken, settingsKey], + useFactory: async ( + moduleOptions: ModuleOptionsType, + defaultSettings: ModuleSettingsType, + ) => { + const effectiveSettings = + optionsOverrides?.settings ?? moduleOptions?.settings; + + if (optionsOverrides?.settingsTransform) { + return optionsOverrides.settingsTransform( + effectiveSettings, + defaultSettings, + ); + } else { + return effectiveSettings ?? defaultSettings; + } + }, + }; +} diff --git a/packages/nestjs-core/src/infrastructure/utils/map-non-error-to-exception.util.ts b/packages/nestjs-core/src/infrastructure/utils/map-non-error-to-exception.util.ts new file mode 100644 index 000000000..55cd427bd --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/utils/map-non-error-to-exception.util.ts @@ -0,0 +1,5 @@ +import { NotAnErrorException } from '../../domain/exceptions/not-an-error.exception.js'; + +export function mapNonErrorToException(error: unknown): Error { + return error instanceof Error ? error : new NotAnErrorException(error); +} diff --git a/packages/nestjs-core/src/infrastructure/utils/to-milliseconds.ts b/packages/nestjs-core/src/infrastructure/utils/to-milliseconds.ts new file mode 100644 index 000000000..17f984dbc --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/utils/to-milliseconds.ts @@ -0,0 +1,42 @@ +import ms from 'ms'; + +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionFault } from '../../domain/exceptions/exception.types.js'; +import { RuntimeException } from '../../domain/exceptions/runtime.exception.js'; + +/** + * Converts a time string value to milliseconds using the ms library. + * Uses the fallback value if the input is empty or nullish. + * Throws a RuntimeException if neither the value nor fallback can be parsed. + * + * Whether an unparseable value is the caller's mistake or a bad + * module-configured default depends on where `value`/`fallback` came from, + * which only the caller knows — pass `fault` explicitly when that's + * determinable. Defaults to `'internal'`, matching `RuntimeException`'s own + * fail-loud default for anything unclassified. + * + * @param value - The time string value to convert (e.g., '1h', '30m', '99y') + * @param fallback - The fallback value to use if value is empty/nullish + * @param fault - Who's at fault if neither value nor fallback parses + * @returns The number of milliseconds + * @internal + */ +export function toMilliseconds( + value: unknown, + fallback?: ms.StringValue | number, + fault: RuntimeExceptionFault = 'internal', +): number { + const input = typeof value === 'string' ? value : fallback; + + const result = ms(input as ms.StringValue); + if (typeof result === 'number') { + return result; + } else { + throw new RuntimeException({ + message: 'Invalid ms string value', + httpStatus: HttpStatus.BAD_REQUEST, + fault, + }); + } +} diff --git a/packages/nestjs-core/src/infrastructure/utils/type-guards.util.spec.ts b/packages/nestjs-core/src/infrastructure/utils/type-guards.util.spec.ts new file mode 100644 index 000000000..e39fa2c0f --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/utils/type-guards.util.spec.ts @@ -0,0 +1,57 @@ +import * as nestShared from '@nestjs/common/utils/shared.utils'; + +import { + isNil, + isNumber, + isObject, + isString, + isUndefined, +} from './type-guards.util.js'; + +const SAMPLE_VALUES: unknown[] = [ + undefined, + null, + 0, + 1, + -1, + NaN, + Infinity, + '', + 'a', + {}, + { a: 1 }, + [], + [1, 2], + true, + false, + () => undefined, + Symbol('s'), + new Date(), + /re/, +]; + +// These local copies exist only to avoid an undocumented deep import into +// `@nestjs/common/utils/shared.utils`. This spec pins their behavior against +// that same Nest internal so any future drift between the two fails loudly +// here instead of silently in consumers. +describe('type-guards.util drift against @nestjs/common/utils/shared.utils', () => { + it.each(SAMPLE_VALUES)('isUndefined matches for %s', (val) => { + expect(isUndefined(val)).toBe(nestShared.isUndefined(val)); + }); + + it.each(SAMPLE_VALUES)('isNil matches for %s', (val) => { + expect(isNil(val)).toBe(nestShared.isNil(val)); + }); + + it.each(SAMPLE_VALUES)('isObject matches for %s', (val) => { + expect(isObject(val)).toBe(nestShared.isObject(val)); + }); + + it.each(SAMPLE_VALUES)('isString matches for %s', (val) => { + expect(isString(val)).toBe(nestShared.isString(val)); + }); + + it.each(SAMPLE_VALUES)('isNumber matches for %s', (val) => { + expect(isNumber(val)).toBe(nestShared.isNumber(val)); + }); +}); diff --git a/packages/nestjs-core/src/infrastructure/utils/type-guards.util.ts b/packages/nestjs-core/src/infrastructure/utils/type-guards.util.ts new file mode 100644 index 000000000..380629b54 --- /dev/null +++ b/packages/nestjs-core/src/infrastructure/utils/type-guards.util.ts @@ -0,0 +1,14 @@ +export const isUndefined = (val: unknown): val is undefined => + typeof val === 'undefined'; + +export const isNil = (val: unknown): val is null | undefined => + isUndefined(val) || val === null; + +export const isObject = (val: unknown): val is object => + !isNil(val) && typeof val === 'object'; + +export const isString = (val: unknown): val is string => + typeof val === 'string'; + +export const isNumber = (val: unknown): val is number => + typeof val === 'number'; diff --git a/packages/nestjs-core/src/testing.ts b/packages/nestjs-core/src/testing.ts new file mode 100644 index 000000000..a9c03b0fa --- /dev/null +++ b/packages/nestjs-core/src/testing.ts @@ -0,0 +1,10 @@ +// CQRS testing utilities +export { createMockEventPublisher } from './testing/create-mock-event-publisher.js'; +export { createMockCommandBus } from './testing/create-mock-command-bus.js'; +export { createMockQueryBus } from './testing/create-mock-query-bus.js'; + +// Exception classification testing utilities +export { collectRuntimeExceptionClassNames } from './testing/collect-runtime-exception-class-names.js'; + +// Event context testing utilities +export { createTestEventContext } from './testing/create-test-event-context.js'; diff --git a/packages/nestjs-core/src/testing/collect-runtime-exception-class-names.ts b/packages/nestjs-core/src/testing/collect-runtime-exception-class-names.ts new file mode 100644 index 000000000..1c344d9b5 --- /dev/null +++ b/packages/nestjs-core/src/testing/collect-runtime-exception-class-names.ts @@ -0,0 +1,67 @@ +import { readdirSync } from 'fs'; +import { join } from 'path'; +import { pathToFileURL } from 'url'; + +/** + * Recursively finds every `*.exception.ts` file under `dir`, skipping + * `dist` and `node_modules`. + */ +function findExceptionFiles(dir: string): string[] { + const results: string[] = []; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'dist' || entry.name === 'node_modules') continue; + + const full = join(dir, entry.name); + + if (entry.isDirectory()) { + results.push(...findExceptionFiles(full)); + } else if (entry.isFile() && entry.name.endsWith('.exception.ts')) { + results.push(full); + } + } + + return results; +} + +/** + * Anti-drift helper for `exception-fault.spec.ts` files: discovers every + * class exported from a `*.exception.ts` file under `srcDir` that extends + * `runtimeExceptionClass`, by dynamically importing each file and walking + * its prototype chain — not by filename, so a file that happens to match + * `*.exception.ts` but extends `Error` directly (e.g. `NotAnErrorException`) + * is correctly excluded without special-casing. + * + * Uses `fs.readdirSync` + dynamic `import()` rather than a bundler glob + * (e.g. Vite's `import.meta.glob`) so this compiles under the repo's plain + * `tsc -b` type-check, which has no glob-import types configured. + * + * @param srcDir - absolute path to the package's `src` directory + * @param runtimeExceptionClass - the `RuntimeException` base class (or a + * subclass) to test discovered exports against via `instanceof` + * @returns the discovered classes' `.name` values + */ +export async function collectRuntimeExceptionClassNames( + srcDir: string, + runtimeExceptionClass: abstract new (...args: never[]) => unknown, +): Promise { + const files = findExceptionFiles(srcDir); + const names: string[] = []; + + for (const file of files) { + const jsSpecifier = pathToFileURL(file.replace(/\.ts$/, '.js')).href; + const mod: Record = await import(jsSpecifier); + + for (const exported of Object.values(mod)) { + if ( + typeof exported === 'function' && + exported !== runtimeExceptionClass && + exported.prototype instanceof runtimeExceptionClass + ) { + names.push(exported.name); + } + } + } + + return names; +} diff --git a/packages/nestjs-core/src/testing/create-mock-command-bus.ts b/packages/nestjs-core/src/testing/create-mock-command-bus.ts new file mode 100644 index 000000000..0388bcdd2 --- /dev/null +++ b/packages/nestjs-core/src/testing/create-mock-command-bus.ts @@ -0,0 +1,10 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { type CommandBus } from '@nestjs/cqrs'; + +/** + * Create a mock CommandBus for unit testing. + */ +export function createMockCommandBus(): DeepMockProxy { + return mockDeep(); +} diff --git a/packages/nestjs-core/src/testing/create-mock-event-publisher.ts b/packages/nestjs-core/src/testing/create-mock-event-publisher.ts new file mode 100644 index 000000000..9b10823dc --- /dev/null +++ b/packages/nestjs-core/src/testing/create-mock-event-publisher.ts @@ -0,0 +1,14 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { type EventPublisher } from '@nestjs/cqrs'; + +/** + * Create a mock EventPublisher for unit testing. + * + * `mergeObjectContext` returns the object unchanged, matching real behavior. + */ +export function createMockEventPublisher(): DeepMockProxy { + const publisher = mockDeep(); + publisher.mergeObjectContext.mockImplementation((obj) => obj); + return publisher; +} diff --git a/packages/nestjs-core/src/testing/create-mock-query-bus.ts b/packages/nestjs-core/src/testing/create-mock-query-bus.ts new file mode 100644 index 000000000..c3456a286 --- /dev/null +++ b/packages/nestjs-core/src/testing/create-mock-query-bus.ts @@ -0,0 +1,10 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { type QueryBus } from '@nestjs/cqrs'; + +/** + * Create a mock QueryBus for unit testing. + */ +export function createMockQueryBus(): DeepMockProxy { + return mockDeep(); +} diff --git a/packages/nestjs-core/src/testing/create-test-event-context.ts b/packages/nestjs-core/src/testing/create-test-event-context.ts new file mode 100644 index 000000000..27175fe5e --- /dev/null +++ b/packages/nestjs-core/src/testing/create-test-event-context.ts @@ -0,0 +1,30 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type EventContextHeadersInterface } from '../domain/events/causal-context/causal-context-headers.interface.js'; +import { EventContextHost } from '../domain/events/causal-context/event-context.host.js'; + +const TEST_CORRELATION_ID = 'test-correlation-id'; +const TEST_CAUSATION_ID = 'test-causation-id'; +const TEST_RECORDED_AT = new Date('2024-01-01T00:00:00.000Z'); + +/** + * Builds an `EventContextHost` with a fixed, deterministic correlation + * pair for unit tests that don't exercise correlation behavior directly + * but still need a valid context to satisfy the compile guard. + */ +export function createTestEventContext< + E extends PlainLiteralObject = PlainLiteralObject, + M extends PlainLiteralObject = PlainLiteralObject, +>( + extraHeaders: E, + metadata: M, +): EventContextHost { + const headers: EventContextHeadersInterface & E = { + ...extraHeaders, + correlationId: TEST_CORRELATION_ID, + causationId: TEST_CAUSATION_ID, + recordedAt: TEST_RECORDED_AT, + }; + + return new EventContextHost(headers, metadata); +} diff --git a/packages/nestjs-core/tsconfig.json b/packages/nestjs-core/tsconfig.json new file mode 100644 index 000000000..edc11225e --- /dev/null +++ b/packages/nestjs-core/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", + "typeRoots": [ + "./node_modules/@types", + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/nestjs-crud/README.md b/packages/nestjs-crud/README.md index bfd483ae1..3b594e053 100644 --- a/packages/nestjs-crud/README.md +++ b/packages/nestjs-crud/README.md @@ -1,24 +1,1219 @@ -# Rockets NestJS CRUD +# @concepta/nestjs-crud -Extremely powerful CRUD module that is an extension/wrapper of the -popular [@nestjsx/crud](https://github.com/nestjsx/crud) module. - -We love the original module, but in many cases it was not flexible enough. -We have retained all of the options and signatures, but pushed -the options from the controller level down to the method level. +Decorator-driven CRUD module for NestJS. Generates REST endpoints from +configuration, with per-method option customization and three controller +build modes: fully generated, pre-decorated, and hybrid. ## Project [![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-crud)](https://www.npmjs.com/package/@concepta/nestjs-crud) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-crud)](https://www.npmjs.com/package/@concepta/nestjs-crud) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-crud)](https://www.npmjs.com/package/@concepta/nestjs-crud) [![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) [![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-crud%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +## Table of Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Module Registration](#module-registration) +- [Architecture Overview](#architecture-overview) +- [Controller Build Modes](#controller-build-modes) +- [Operation Decorators](#operation-decorators) +- [Route Option Decorators](#route-option-decorators) +- [Query String Parameters](#query-string-parameters) +- [Paginated Response](#paginated-response) +- [Serialization and Validation](#serialization-and-validation) +- [OpenAPI Documents](#openapi-documents) +- [Resolvers](#resolvers) +- [CQRS Integration](#cqrs-integration) +- [Specifications and Hooks](#specifications-and-hooks) +- [Exceptions](#exceptions) +- [Entry Points](#entry-points) ## Installation -Prerequisites -`yarn add pg typeorm class-transformer class-validator` +```sh +yarn add @concepta/nestjs-crud +# required peers +yarn add @nestjs/common @nestjs/config @nestjs/core @nestjs/swagger rxjs +``` + +This package is **ESM-only** and targets **NestJS 12** on +**Node >= 22.12**. Request and response shapes are defined with **Zod v4** +schemas (Standard Schema) — `zod` is a direct dependency. + +### Dependencies + +| Package | Notes | +| --- | --- | +| `@concepta/nestjs-core` | Core interfaces, utilities, schema helpers, and hook system | +| `@concepta/nestjs-repository` | Repository abstraction layer | +| `@standard-schema/spec` | Standard Schema types | +| `deepmerge` | Option merging | +| `qs` | Query-string parsing | +| `zod` | Schema validation and serialization (Standard Schema) | + +### Peer Dependencies + +| Package | Required | Notes | +| --- | --- | --- | +| `@nestjs/common` | Yes | NestJS core — install explicitly, no longer bundled | +| `@nestjs/core` | Yes | Module reference and reflection — install explicitly | +| `@nestjs/config` | Yes | Module option registration via `registerAs` | +| `@nestjs/swagger` | Yes | OpenAPI decorator support — install explicitly | +| `rxjs` | Yes | Interceptor pipeline | +| `@concepta/nestjs-repository-typeorm` | No | TypeORM repository driver | +| `@nestjs/cqrs` | No | Optional peer — only when using `CrudCqrsResolver` | + +## Quick Start + +Define an entity, Zod schemas, and register a fully generated CRUD endpoint. + +### Entity + +```ts +import { Entity, PrimaryGeneratedColumn, Column, DeleteDateColumn } from 'typeorm'; + +@Entity() +export class PhotoEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column() + name!: string; + + @Column() + description!: string; + + @Column({ default: 0 }) + views!: number; + + @DeleteDateColumn({ nullable: true }) + deletedAt!: Date | null; +} +``` + +### Schemas + +Request and response shapes are Zod schemas. Helpers from +`@concepta/nestjs-core`: + +- `conformsTo()(schema)` — pins a schema to a TypeScript interface + at compile time (no runtime effect) +- `withNamedComponent(schema, id)` — registers the schema as a named OpenAPI + component (bare component id, e.g. `Photo`) +- `withOpenApi(schema)` — enables OpenAPI JSON schema output for schemas + documented inline (typically request bodies) + +```ts +import { z } from 'zod'; +import { + conformsTo, + referenceIdSchema, + withNamedComponent, + withOpenApi, +} from '@concepta/nestjs-core'; +import { paginatedSchema } from '@concepta/nestjs-crud'; + +export const photoSchema = withNamedComponent( + conformsTo()( + referenceIdSchema.extend({ + name: z.string(), + description: z.string(), + views: z.number(), + deletedAt: z.date().nullable(), + }), + ), + 'Photo', +); + +export const photoCreateSchema = withOpenApi( + photoSchema.pick({ + name: true, + description: true, + }), +); + +export const photoUpdateSchema = withOpenApi( + photoSchema.pick({ + name: true, + description: true, + views: true, + }), +); + +export const photoPaginatedSchema = withNamedComponent( + paginatedSchema(photoSchema), + 'PhotoPaginated', +); +``` + +### Feature Module + +```ts +import { Module } from '@nestjs/common'; +import { Operation } from '@concepta/nestjs-core'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; +import { CrudModule } from '@concepta/nestjs-crud'; + +@Module({ + imports: [ + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [{ key: 'photo', entity: PhotoEntity }], + }), + CrudModule.forFeature({ + crud: { + controller: { + path: 'photos', + entity: 'photo', + request: { body: photoSchema }, + response: { + resource: photoSchema, + paginated: photoPaginatedSchema, + }, + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + { operation: Operation.Create, request: { body: photoCreateSchema } }, + { operation: Operation.Update }, + { operation: Operation.Delete }, + ], + }, + }), + ], +}) +export class PhotoModule {} +``` + +### App Module + +```ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { CrudModule } from '@concepta/nestjs-crud'; + +@Module({ + imports: [ + TypeOrmModule.forRoot({ /* ... */ }), + RepositoryModule.forRoot({}), + CrudModule.forRoot({}), + PhotoModule, + ], +}) +export class AppModule {} +``` + +### Generated Endpoints + +| Method | Path | Operation | +| --- | --- | --- | +| GET | `/photos` | List (paginated) | +| GET | `/photos/:id` | Read | +| POST | `/photos` | Create | +| PATCH | `/photos/:id` | Update | +| DELETE | `/photos/:id` | Delete | + +## Module Registration + +### forRoot / forRootAsync + +Global registration. Required once per application. + +```ts +CrudModule.forRoot({}) + +// Async with factory +CrudModule.forRootAsync({ + useFactory: async () => ({}), +}) +``` + +### forFeature + +Per-entity registration. Generates a controller, adapter provider, and +(optionally) CQRS query/command handlers from the configuration object. + +```ts +CrudModule.forFeature({ + crud: { + controller: { + path: 'photos', + entity: 'photo', + request: { + body: photoSchema, + params: { + id: { field: 'id', type: 'uuid', primary: true }, + }, + }, + response: { resource: photoSchema, paginated: photoPaginatedSchema }, + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + { operation: Operation.Create, request: { body: photoCreateSchema } }, + { operation: Operation.Update, request: { body: photoUpdateSchema } }, + { operation: Operation.Delete }, + { operation: Operation.SoftDelete, path: 'soft/:id' }, + { operation: Operation.Restore, path: 'restore/:id' }, + ], + }, +}) +``` + +Per-operation `request.body` schemas override the controller-level schema — +for example, a stricter create schema is enforced only on the Create route +while other operations keep the controller default. + +### register / registerAsync + +Non-global variants of `forRoot`. Identical options, scoped to the importing +module. + +## Architecture Overview + +```text +HTTP Request + | +Controller (generated or hand-written) + | @CrudController + @CrudList / @CrudCreate / ... + | +CrudContextOverlay + | Parses params, query string into CrudContextInterface + | +CrudResolver (dispatches operation) + | + +-- CrudAdapterResolver (direct adapter call — default) + +-- CrudOperationResolver (handler call, no CQRS bus) + +-- CrudCqrsResolver (QueryBus / CommandBus) + | +CrudAdapter + | Wraps RepositoryInterface for CRUD semantics + | +RepositoryAdapter (@concepta/nestjs-repository) + | +Database Driver (TypeORM, etc.) +``` + +- **Controller** — Decorated class with operation methods. Can be fully + generated, hand-written, or a hybrid of both. +- **CrudContextOverlay** — Parses the HTTP request into a + `CrudContextInterface` (entity name, route params, query string, options) + and defines it as an overlay on the request context. +- **Resolver** — Dispatches the operation to the adapter directly, through + a handler, or through the CQRS bus. +- **CrudAdapter** — Wraps a `RepositoryInterface` and adds pagination, + field filtering, where-clause building, and entity preparation. + +### CRUD Context + +`CrudContextOverlay` defines the parsed CRUD context as an overlay on the +request context. In hand-written controller methods, unwrap it by passing +the `CrudCtx` overlay reference to the `@Ctx()` parameter decorator: + +```ts +@CrudList() +async list(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.resolver.list(ctx); +} +``` + +### Injecting the CRUD Adapter + +`InjectCrudAdapter(name)` injects the adapter registered for an entity key +by `CrudModule.forFeature()` — useful for reusing CRUD semantics from +services: + +```ts +import { Injectable } from '@nestjs/common'; +import { CrudAdapter, InjectCrudAdapter } from '@concepta/nestjs-crud'; + +@Injectable() +export class SomeService { + constructor( + @InjectCrudAdapter('photo') + protected readonly crudAdapter: CrudAdapter, + ) {} +} +``` + +### Operation-to-Repository Mapping + +| Operation | Adapter Method | Repository Method | +| --- | --- | --- | +| List | `list()` | `findAndCount()` | +| Read | `read()` | `findOne()` | +| Create | `create()` | `create()` | +| CreateBatch | `createBatch()` | `createMany()` | +| Update | `update()` | `update()` | +| Replace | `replace()` | `replace()` | +| Delete | `delete()` | `delete()` | +| SoftDelete | `softDelete()` | `softDelete()` | +| Restore | `restore()` | `restore()` | + +**Update / Replace and optimistic locking.** When the entity carries a +version column (`CommonPostgresEntity`/`CommonSqliteEntity`), `update`/ +`replace` are guarded by an atomic version compare-and-swap in the +repository layer. A concurrent write that lands between this request's read +and its write is rejected with `409 Conflict` (`OptimisticLockException`) +rather than silently clobbering. This requires `RepositoryModule.forRoot()` +to be imported (it provides `TransactionScope`) — see +[Optimistic Locking](../nestjs-repository-typeorm/README.md#optimistic-locking) +in `@concepta/nestjs-repository-typeorm`. + +## Controller Build Modes + +`ConfigurableCrudBuilder` supports three controller build paths. + +### Fully Generated + +Zero hand-written controller code. Pass controller options and an operations +array — the builder generates the controller class, methods, and providers. + +```ts +import { Operation } from '@concepta/nestjs-core'; +import { ConfigurableCrudBuilder } from '@concepta/nestjs-crud'; + +const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'photos', + entity: 'photo', + request: { body: photoSchema }, + response: { + resource: photoSchema, + paginated: photoPaginatedSchema, + }, + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + { + operation: Operation.CreateBatch, + request: { bodyBatch: photoCreateBatchSchema }, + response: { + serialization: { resource: photoCreateBatchResponseSchema }, + }, + }, + { operation: Operation.Create, request: { body: photoCreateSchema } }, + { operation: Operation.Update, request: { body: photoUpdateSchema } }, + { operation: Operation.Delete }, + ], +}); + +const { controllers, providers } = builder.build(); +``` + +Or use `CrudModule.forFeature()` which wraps the builder internally +(see [Module Registration](#module-registration)). The batch schemas are +defined in [Batch Create Schema](#batch-create-schema). + +Generated controllers derive `@CrudBody({ schema })` metadata automatically +from each operation's `request.body` / `request.bodyBatch`, so schema +validation is wired without any hand-written code. + +### Pre-Decorated + +Full control. You write the controller class with all decorators and method +implementations. The builder extracts handler metadata for provider registration. + +```ts +import { Inject } from '@nestjs/common'; +import { Ctx } from '@concepta/nestjs-core'; +import { + CrudController, + CrudList, + CrudRead, + CrudCreate, + CrudBody, + CrudCtx, + CrudAdapterResolver, + CrudResolverInterface, + CrudContextInterface, +} from '@concepta/nestjs-crud'; + +@CrudController({ + path: 'photos', + entity: 'photo', + request: { body: photoSchema }, + response: { resource: photoSchema, paginated: photoPaginatedSchema }, +}) +export class PhotoController { + constructor( + @Inject(CrudAdapterResolver) + private readonly resolver: CrudResolverInterface, + ) {} + + @CrudList() + async list(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.resolver.list(ctx); + } + + @CrudRead() + async read(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.resolver.read(ctx); + } + + @CrudCreate({ request: { body: photoCreateSchema } }) + async create( + @Ctx(CrudCtx) ctx: CrudContextInterface, + @CrudBody({ schema: photoCreateSchema }) dto: PhotoCreatable, + ) { + return this.resolver.create(ctx, dto); + } +} + +// Register: +CrudModule.forFeature({ + crud: { controller: { class: PhotoController } }, +}) +``` + +Two rules for hand-written controllers: + +- Pass the `CrudCtx` overlay reference to `@Ctx(...)` — a bare `@Ctx()` + yields the raw application context, not the `CrudContextInterface` + defined by `CrudContextOverlay`. +- Supply a body schema for validation: either explicitly via + `@CrudBody({ schema })`, or by setting `request.body` (or `bodyBatch`) on + the operation decorator or the controller. The validation pipe resolves + `@CrudBody({ schema })` first, then falls back to `request.body`/ + `bodyBatch` resolved through the metadata hierarchy (method → class) — so + a controller-level default is validated, not just a docs placeholder for + `@ApiBody` to render. + +#### Query-String Filtering Outside the Nine Operations + +A hand-written route only gets `@Ctx(CrudCtx)` — and with it `ctx.query`'s +parsed filter/sort/pagination — when it carries one of the nine +`@Crud` decorators (`@CrudList`, `@CrudRead`, etc.). A custom +search/aggregate/report endpoint that doesn't fit any of them still needs a +way to reuse CRUD's validated query-string contract. `@CrudQueryParams()` +does that independently of `@CrudController`, an entity, or an operation tag +— it works on any route: + +```ts +import { Controller, Get } from '@nestjs/common'; +import { + CrudParsedQueryInterface, + CrudQueryParams, + CrudQueryParamsApi, +} from '@concepta/nestjs-crud'; + +@Controller('photos/search') +export class PhotoSearchController { + @Get() + @CrudQueryParamsApi() + async search(@CrudQueryParams() query: CrudParsedQueryInterface) { + // query.filter, query.sort, query.limit, etc. — parsed and validated + // the same way a generated @CrudList route's ctx.query would be. + } +} +``` + +`@CrudQueryParamsApi()` documents the same standard filter/sort/pagination +`@ApiQuery` set generated List/Read routes get. Both decorators are optional +and independent — use either on its own if you don't need the other. Route +**path** params aren't covered here; Nest's native `@Param()` already handles +those with no friction. + +### Hybrid + +Provide a base class and an operations array. Existing methods are augmented +with decorator metadata; missing methods are generated. + +```ts +@CrudController({ + path: 'photos', + entity: 'photo', + request: { body: photoSchema }, + response: { resource: photoSchema, paginated: photoPaginatedSchema }, +}) +export class PhotoController { + constructor( + @Inject(CrudAdapterResolver) + private readonly resolver: CrudResolverInterface, + ) {} + + @CrudList() + async list(@Ctx(CrudCtx) ctx: CrudContextInterface) { + // Custom list logic + return this.resolver.list(ctx); + } +} + +// list is augmented; read and create are generated +CrudModule.forFeature({ + crud: { + controller: { class: PhotoController }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + { operation: Operation.Create, request: { body: photoCreateSchema } }, + ], + }, +}) +``` + +Methods generated from the `operations` array derive `@CrudBody({ schema })` +automatically from `request.body` / `request.bodyBatch` — only methods you +write yourself need the explicit parameter decorators. + +### Comparison + +| | Fully Generated | Pre-Decorated | Hybrid | +| --- | --- | --- | --- | +| Controller class | Auto-generated | You write it | You write base | +| Method implementations | Auto-generated | You write them | Mix of both | +| Decorator application | Automatic | Manual | Automatic for new | +| Best for | Standard CRUD | Full customization | Partial customization | + +## Operation Decorators + +Applied at method level. Each decorator sets the HTTP method, default path, +and operation metadata. + +| Decorator | HTTP | Default Path | Operation | +| --- | --- | --- | --- | +| `@CrudList()` | GET | `/` | `Operation.List` | +| `@CrudRead()` | GET | `/:id` | `Operation.Read` | +| `@CrudCreate()` | POST | `/` | `Operation.Create` | +| `@CrudCreateBatch()` | POST | `/bulk` | `Operation.CreateBatch` | +| `@CrudUpdate()` | PATCH | `/:id` | `Operation.Update` | +| `@CrudReplace()` | PUT | `/:id` | `Operation.Replace` | +| `@CrudDelete()` | DELETE | `/:id` | `Operation.Delete` | +| `@CrudSoftDelete()` | DELETE | `/:id` | `Operation.SoftDelete` | +| `@CrudRestore()` | PATCH | `/restore/:id` | `Operation.Restore` | + +### Operation Options + +All operation decorators (and `operations[]` config entries) accept a common +options object: + +```ts +{ + path?: string | string[]; + methodName?: string; // Target/created method name (config only) + request?: { + params?: CrudParamsOptionsInterface; // URL param config + body?: CrudSchema; // z.ZodType — single-entity body schema + bodyBatch?: CrudSchema; // z.ZodType — batch body schema (CreateBatch) + validation?: StandardSchemaValidationPipeOptions | false; + }; + response?: { + resource?: CrudSchema; // z.ZodType — single resource response + paginated?: CrudSchema; // z.ZodType — paginated response + serialization?: CrudSerializationOptionsInterface; + returnDeleted?: boolean; // Delete/SoftDelete only + returnRestored?: boolean; // Restore only + }; + transactional?: boolean | TransactionalOptions; + + // Query operations (List, Read): + query?: Type; + queryHandler?: Type; + + // Command operations (Create, Update, Replace, Delete, ...): + command?: Type; + commandHandler?: Type; + + extraDecorators?: ReturnType[]; + + api?: { + operation?: ApiOperationOptions; + query?: ApiQueryOptions[]; + params?: ApiParamOptions; + body?: ApiBodyOptions; // Create/CreateBatch/Update/Replace only + response?: ApiResponseOptions; + }; +} +``` + +- `CrudSchema` is `z.ZodType` — every request/response shape is a Zod + (Standard Schema) schema. +- `validation` merges into the `StandardSchemaValidationPipe` used for + `@CrudBody()` schemas. Available keys: `transform`, + `validateCustomDecorators`, `validateOptions`, `errorHttpStatusCode`, + `exceptionFactory`. Pass `false` to disable validation for the body + (it is still bound, just unvalidated). +- `api.body` is only read by the four write operations that accept a body + (Create, CreateBatch, Update, Replace); it's ignored on read/delete + operations. It merges (`description`, `examples`, `required`, ...) into + the `@ApiBody()` documenting the resolved request body schema — a + `schema`/`type` set here is superseded by the resolved schema and has no + effect. The one exception: when no schema resolves anywhere for the + operation, `api.body` is instead applied verbatim as a plain `@ApiBody()` + — that is the one case where a `schema`/`type` set here does take effect. +- `response.serialization` is `CrudSerializationOptionsInterface`: + `{ resource?: CrudSchema; paginated?: CrudSchema }` — schema + overrides for response serialization. +- `methodName` targets (or names) a specific controller method in + hybrid/generated mode, allowing multiple operations of the same type. + +### Delete/Restore Response Behavior + +By default, Delete, SoftDelete, and Restore return `204 No Content`. Set +`returnDeleted: true` or `returnRestored: true` to return `200 OK` with the +entity body: + +```ts +{ operation: Operation.Delete, response: { returnDeleted: true } } +{ operation: Operation.SoftDelete, response: { returnDeleted: true } } +{ operation: Operation.Restore, response: { returnRestored: true } } +``` + +## Route Option Decorators + +Route option decorators configure query behavior on a per-method basis. +Method-level settings override controller-level defaults. + +| Decorator | Description | +| --- | --- | +| `@CrudFilter(filter)` | Server-side default filter conditions | +| `@CrudSort(sort)` | Default sort order | +| `@CrudJoin(join)` | Relations to join | +| `@CrudLimit(n)` | Default page size | +| `@CrudMaxLimit(n)` | Maximum allowed page size | +| `@CrudAllow(columns)` | Whitelist query-accessible columns | +| `@CrudExclude(columns)` | Blacklist columns from queries | +| `@CrudPersist(columns)` | Always include these columns in select | +| `@CrudCache(seconds)` | Cache duration (pass `false` to disable) | +| `@CrudSerialize(options)` | Serialization schema overrides (`{ resource?, paginated? }`) | +| `@CrudValidate(options)` | `StandardSchemaValidationPipeOptions` or `false` to disable | +| `@CrudReturnDeleted(bool)` | Return entity body on delete | +| `@CrudReturnRestored(bool)` | Return entity body on restore | + +### Per-Method Example + +```ts +@CrudController({ + path: 'photos', + entity: 'photo', + request: { body: photoSchema }, + response: { resource: photoSchema, paginated: photoPaginatedSchema }, +}) +export class PhotoController { + @CrudList() + @CrudLimit(20) + @CrudMaxLimit(100) + @CrudSort([{ field: 'createdAt', order: 'DESC' }]) + @CrudAllow(['name', 'description', 'createdAt']) + async list(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.resolver.list(ctx); + } + + @CrudDelete() + @CrudReturnDeleted(true) + async delete(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.resolver.delete(ctx); + } +} +``` + +### CrudQueryOptionsInterface + +These decorators map to `CrudQueryOptionsInterface`: + +```ts +interface CrudQueryOptionsInterface { + allow?: EntityColumn[]; + exclude?: EntityColumn[]; + persist?: EntityColumn[]; + filter?: QueryFilterOption; + sort?: OrderSortKey[]; + limit?: number; + maxLimit?: number; + cache?: number | false; + join?: JoinClause[]; +} +``` + +## Query String Parameters + +The CRUD module parses HTTP query parameters into `CrudParsedQueryInterface` +via `CrudQueryParser`. + +### Parameters + +| Parameter | Format | Example | +| --- | --- | --- | +| `select` | `field1,field2` | `?select=name,description` | +| `filter` | `field\|\|$op\|\|value` | `?filter=status\|\|$eq\|\|active` | +| `or` | `field\|\|$op\|\|value` | `?or=status\|\|$eq\|\|archived` | +| `sort` | `field,ASC\|DESC` | `?sort=createdAt,DESC` | +| `limit` | number | `?limit=25` | +| `offset` | number | `?offset=50` | +| `page` | number (1-indexed) | `?page=3` | +| `cache` | number (seconds) | `?cache=0` | +| `includeDeleted` | `1` or `0` | `?includeDeleted=1` | +| `s` | JSON search object | `?s={"name":{"$contains":"sunset"}}` | + +### Comparison Operators + +| Operator | Description | +| --- | --- | +| `$eq` | Equal | +| `$ne` | Not equal | +| `$gt` | Greater than | +| `$gte` | Greater than or equal | +| `$lt` | Less than | +| `$lte` | Less than or equal | +| `$starts` | Starts with | +| `$nstarts` | Does not start with | +| `$ends` | Ends with | +| `$nends` | Does not end with | +| `$contains` | Contains substring | +| `$ncontains` | Does not contain | +| `$in` | In list (comma-separated) | +| `$nin` | Not in list | +| `$null` | Is null (no value needed) | +| `$nnull` | Not null (no value needed) | +| `$between` | Between two values (comma-separated) | + +### Filter Combination Rules + +- Multiple `filter` params are AND-combined +- Multiple `or` params provide an alternative set +- When both present: `(AND of filters) OR (AND of ors)` +- The `s` (search) parameter supersedes `filter` and `or` + +### Multiple Filters + +```text +GET /photos?filter[0]=status||$eq||active&filter[1]=views||$gt||100 +``` + +### Relation Filters + +Use dot notation to filter by related entity fields: + +```text +GET /photos?filter=author.name||$eq||Alice +``` + +## Paginated Response + +List operations return a paginated response: + +```ts +interface CrudResponsePaginatedInterface { + data: T[]; // Items on current page + limit: number; // Items per page + count: number; // Items on current page (data.length) + total: number; // Total items across all pages + page: number; // Current page (1-indexed) + pageCount: number; // Total number of pages + metrics?: CrudResponseMetrics; // Fetch metrics (federated responses only) +} + +interface CrudResponseMetrics { + totalFetched: number; // Rows fetched + totalValid: number; // Rows that passed post-fetch checks + fetchCalls: number; // Fetch calls made + duration: number; // Fetch duration in milliseconds +} +``` + +Both interfaces are exported from `@concepta/nestjs-crud`. `metrics` is only +present on federated responses. + +### Paginated Response Schema + +Wrap your resource schema with the `paginatedSchema` factory and register it +as a named OpenAPI component: + +```ts +import { withNamedComponent } from '@concepta/nestjs-core'; +import { paginatedSchema } from '@concepta/nestjs-crud'; + +export const photoPaginatedSchema = withNamedComponent( + paginatedSchema(photoSchema), + 'PhotoPaginated', +); +``` + +`paginatedSchema(itemSchema)` produces the `{ data, limit, count, total, +page, pageCount }` envelope with `data: z.array(itemSchema)`. It +intentionally omits `metrics`. + +### Batch Create Schema + +`createBatchSchema(itemSchema)` builds the request body schema for +`Operation.CreateBatch` — an object with a `bulk` array requiring at least +one item (`.min(1)`): + +```ts +import { withOpenApi } from '@concepta/nestjs-core'; +import { createBatchSchema } from '@concepta/nestjs-crud'; + +export const photoCreateBatchSchema = withOpenApi( + createBatchSchema(photoCreateSchema), +); + +// Response shape for CreateBatch — a bare array of created resources. +// Schema serialization validates the response verbatim, so the batch +// operation must supply an array schema via response.serialization.resource. +export const photoCreateBatchResponseSchema = z.array(photoSchema); +``` + +Pass them on the CreateBatch operation: + +```ts +{ + operation: Operation.CreateBatch, + request: { bodyBatch: photoCreateBatchSchema }, + response: { serialization: { resource: photoCreateBatchResponseSchema } }, +} +``` + +## Serialization and Validation + +### Serialization + +Responses are serialized by parsing them through the resolved Zod response +schema. `CrudSerializeInterceptor` picks the paginated schema +(`response.paginated` / `serialization.paginated`) for paginated +payloads and the resource schema (`response.resource` / +`serialization.resource`) otherwise, then runs the schema's `.parse()` on the +outgoing payload. + +Serialization is fail-closed: + +- Keys not declared in the schema are stripped — undeclared fields never + leak into responses. +- If the payload does not satisfy the schema, parsing throws instead of + returning a partially-valid response. +- If no response schema resolves at all, the interceptor throws a + `CrudException` rather than returning unserialized data. + +Operation-level `response.serialization` (`{ resource, paginated }`) +overrides the controller-level `response.resource` / `response.paginated` +schemas, per route. + +### Validation + +Request bodies declared with `@CrudBody({ schema })` are validated by a +per-parameter `StandardSchemaValidationPipe` running the Zod schema. The +default `exceptionFactory` prefixes each issue message with its field path, +producing field-identifying `400 Bad Request` messages such as: + +```text +name: Too big: expected string to have <=10 characters +``` + +Override pipe options per-route via `request.validation`: + +```ts +@CrudCreate({ + request: { + body: photoCreateSchema, + validation: { errorHttpStatusCode: 422 }, + }, +}) +``` + +Or with the route decorator: + +```ts +@CrudValidate({ errorHttpStatusCode: 422 }) +``` + +Available options (`StandardSchemaValidationPipeOptions` from +`@nestjs/common`): `transform`, `validateCustomDecorators`, +`validateOptions`, `errorHttpStatusCode`, `exceptionFactory`. The +class-validator era options (`whitelist`, `forbidNonWhitelisted`, etc.) no +longer exist — undeclared keys are handled by the schemas themselves. + +Pass `validation: false` (or `@CrudValidate(false)`) to disable validation +for that body — the parameter is still bound, just unvalidated. + +In hand-written controllers, the validation schema resolves from +`@CrudBody({ schema })` first, falling back to `request.body`/`bodyBatch` +resolved through the metadata hierarchy (method → class) — so a +controller-level default is validated too, not just a docs placeholder for +`@ApiBody` to render (see [Controller Build Modes](#controller-build-modes)). +Builder-generated and hybrid-generated methods derive `@CrudBody({ schema })` +automatically from `operations[].request.body`. + +## OpenAPI Documents + +Pass the `standardSchemaConverter` from `@concepta/nestjs-core` when +creating the swagger document so Zod schemas are converted to OpenAPI +component schemas: + +```ts +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { standardSchemaConverter } from '@concepta/nestjs-core'; + +const doc = SwaggerModule.createDocument( + app, + new DocumentBuilder().setTitle('API').setVersion('1.0').build(), + { standardSchemaConverter }, +); +``` + +Schemas wrapped with `withNamedComponent(schema, 'Photo')` register under +bare component ids (`Photo`, `PhotoPaginated`); schemas wrapped with +`withOpenApi(schema)` are documented inline (typical for request bodies). + +A parameter-level `@CrudBody({ schema })` takes precedence over the +per-operation `request.body`/`bodyBatch` schema, which in turn takes +precedence over the controller-level default — so PATCH/PUT document their +own narrower schemas, and a hand-written controller's explicit `@CrudBody` +schema always wins. + +## Resolvers + +Resolvers control how operations are dispatched from the controller to the +adapter. + +| Resolver | Dispatch | When to Use | +| --- | --- | --- | +| `CrudAdapterResolver` | Calls `CrudAdapter` directly | Default. Simple CRUD | +| `CrudOperationResolver` | Resolves handler via `ModuleRef` | Custom handler logic without CQRS | +| `CrudCqrsResolver` | Dispatches via `QueryBus` / `CommandBus` | Full CQRS with sagas and events | + +### Setting the Default Resolver + +Globally: + +```ts +CrudModule.forRoot({ + defaultResolver: CrudOperationResolver, +}) +``` + +Per-controller: + +```ts +@CrudController({ + path: 'photos', + entity: 'photo', + resolver: CrudCqrsResolver, + ... +}) +``` + +## CQRS Integration + +Optional integration with `@nestjs/cqrs` for saga, event, and cross-module +routing support. + +### Setup + +```sh +yarn add @nestjs/cqrs +``` + +```ts +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { CrudModule, CrudCqrsResolver } from '@concepta/nestjs-crud'; + +@Module({ + imports: [ + CqrsModule.forRoot(), + CrudModule.forRoot({ + defaultResolver: CrudCqrsResolver, + }), + ], +}) +export class AppModule {} +``` + +### Built-in Queries and Commands + +| Operation | Class | Handler | +| --- | --- | --- | +| List | `CrudListQuery` | `CrudListHandler` | +| Read | `CrudReadQuery` | `CrudReadHandler` | +| Create | `CrudCreateCommand` | `CrudCreateHandler` | +| CreateBatch | `CrudCreateBatchCommand` | `CrudCreateBatchHandler` | +| Update | `CrudUpdateCommand` | `CrudUpdateHandler` | +| Replace | `CrudReplaceCommand` | `CrudReplaceHandler` | +| Delete | `CrudDeleteCommand` | `CrudDeleteHandler` | +| SoftDelete | `CrudSoftDeleteCommand` | `CrudSoftDeleteHandler` | +| Restore | `CrudRestoreCommand` | `CrudRestoreHandler` | + +### Custom Handlers + +Override the handler for a specific operation: + +```ts +{ operation: Operation.Create, commandHandler: CustomCreateHandler } +``` + +Or with the decorator: + +```ts +@CrudCreate() +@CrudCommandHandler(CustomCreateHandler) +async create( + @Ctx(CrudCtx) ctx: CrudContextInterface, + @CrudBody({ schema: photoCreateSchema }) dto: PhotoCreatable, +) { ... } +``` + +## Specifications and Hooks + +`CrudSpec` provides factory methods for matching CRUD operations. Specifications +act as boolean gates — a hook method only runs when its spec is satisfied by the +current `CrudContextInterface`. + +### CrudSpec Methods + +| Method | Description | +| --- | --- | +| `CrudSpec.operation(op)` | Match a specific operation | +| `CrudSpec.action(action)` | Match an action category | +| `CrudSpec.isCreate()` | CREATE action | +| `CrudSpec.isRead()` | READ action | +| `CrudSpec.isUpdate()` | UPDATE action | +| `CrudSpec.isDelete()` | DELETE action | +| `CrudSpec.isQuery()` | List + Read operations | +| `CrudSpec.isWrite()` | Create + CreateBatch + Update + Replace | +| `CrudSpec.isMutation()` | All state-changing operations | +| `CrudSpec.and(...)` | All specifications must match | +| `CrudSpec.or(...)` | Any specification must match | +| `CrudSpec.not(spec)` | Negate a specification | +| `CrudSpec.always()` | Always matches (default) | +| `CrudSpec.never()` | Never matches | + +### Defining a Hook + +Use `@RepoHook()` from `@concepta/nestjs-repository` to mark a class as a +repository hook. Decorate methods with lifecycle decorators (`@BeforeCreate`, +`@AfterFind`, etc.) and optionally pass a `CrudSpec` to restrict when the +method runs: + +```ts +import { Injectable } from '@nestjs/common'; +import { + RepoHook, + BeforeFind, + AfterCreate, + AfterUpdate, +} from '@concepta/nestjs-repository'; +import { CrudSpec } from '@concepta/nestjs-crud'; + +@Injectable() +@RepoHook() +export class AuditHook { + // Runs on ALL find operations (no spec restriction) + @BeforeFind() + async addTenantFilter(options, ctx) { + const tenantId = ctx.locals?.tenantId; + if (tenantId) { + // add tenant filter to query options + } + return options; + } + + // Runs ONLY when the CRUD operation is a Create + @AfterCreate(CrudSpec.isCreate()) + async logCreation(entity, ctx) { + console.log(`Created ${ctx.operation}:`, entity.id); + return entity; + } + + // Runs ONLY on write operations (Create, Update, Replace) + @AfterUpdate(CrudSpec.isWrite()) + async logModification(entity, ctx) { + console.log(`Modified via ${ctx.operation}:`, entity.id); + return entity; + } +} +``` + +### Registering Hooks + +Attach hooks to a controller with `@UseHooks()` from `@concepta/nestjs-core`. +Hooks can be plain classes or `{ hook, spec }` objects: + +```ts +import { UseHooks } from '@concepta/nestjs-core'; +import { CrudSpec } from '@concepta/nestjs-crud'; + +// Simple: hook runs for all operations on this controller +@UseHooks(AuditHook) +@CrudController({ ... }) +export class PhotoController { ... } + +// With spec: hook only runs for mutations +@UseHooks({ hook: AuditHook, spec: CrudSpec.isMutation() }) +@CrudController({ ... }) +export class PhotoController { ... } + +// Method-level: adds to class-level hooks +@UseHooks(AuditHook) +@CrudController({ ... }) +export class PhotoController { + @CrudDelete() + @UseHooks({ hook: AdminAuditHook, spec: CrudSpec.isDelete() }) + async delete(@Ctx(CrudCtx) ctx) { ... } +} +``` + +### Spec Resolution Priority + +When multiple specs are defined, the most specific wins: + +1. Hook method parameter: `@BeforeCreate(spec)` — highest +2. Class-level: `@RepoHook(spec)` +3. `@UseHooks({ hook, spec })` registration +4. Default: `CrudSpec.always()` — lowest + +### Composing Specifications + +```ts +// Write operations that are NOT deletes +CrudSpec.and(CrudSpec.isWrite(), CrudSpec.not(CrudSpec.isDelete())) + +// List or Read +CrudSpec.or( + CrudSpec.operation(Operation.List), + CrudSpec.operation(Operation.Read), +) + +// Specific operation +CrudSpec.operation(Operation.Create) +``` + +### Available Hook Decorators + +Hook method decorators from `@concepta/nestjs-repository`: + +| Decorator | Fires on | +| --- | --- | +| `@BeforeRead` / `@AfterRead` | Any read (find, findOne, count, findAndCount) | +| `@BeforeWrite` / `@AfterWrite` | Any write (create, update, replace) | +| `@BeforeTransition` / `@AfterTransition` | Lifecycle changes (softDelete, restore) | +| `@BeforeDestroy` / `@AfterDestroy` | Hard delete | +| `@BeforeFind` / `@AfterFind` | `find()` | +| `@BeforeFindOne` / `@AfterFindOne` | `findOne()` | +| `@BeforeFindAndCount` / `@AfterFindAndCount` | `findAndCount()` | +| `@BeforeCreate` / `@AfterCreate` | `create()` | +| `@BeforeCreateMany` / `@AfterCreateMany` | `createMany()` | +| `@BeforeUpdate` / `@AfterUpdate` | `update()` | +| `@BeforeReplace` / `@AfterReplace` | `replace()` | +| `@BeforeDelete` / `@AfterDelete` | `delete()` | +| `@BeforeSoftDelete` / `@AfterSoftDelete` | `softDelete()` | +| `@BeforeRestore` / `@AfterRestore` | `restore()` | + +## Exceptions + +| Exception | Description | +| --- | --- | +| `CrudException` | Base CRUD exception | +| `CrudContextException` | Error during context building (interceptor) | +| `CrudDecoratorException` | Invalid decorator configuration | +| `CrudQueryException` | Error executing a query or command | + +## Entry Points -The crud module -`yarn add @concepta/nestjs-crud` +| Import Path | Contents | +| --- | --- | +| `@concepta/nestjs-crud` | Module, adapter, decorators, resolvers, CQRS queries/commands/handlers, schema factories (`paginatedSchema`, `createBatchSchema`), specifications, exceptions | diff --git a/packages/nestjs-crud/package.json b/packages/nestjs-crud/package.json index b4ed68d39..a2fe4d56f 100644 --- a/packages/nestjs-crud/package.json +++ b/packages/nestjs-crud/package.json @@ -1,40 +1,64 @@ { "name": "@concepta/nestjs-crud", - "version": "7.0.0-alpha.10", + "version": "8.0.0-alpha.10", "description": "Rockets NestJS CRUD", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "license": "BSD-3-Clause", "publishConfig": { "access": "public" }, "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" ], "dependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", - "@nestjs/swagger": "^11.2.2", - "@zmotivat0r/o0": "^1.0.2", + "@concepta/nestjs-core": "^8.0.0-alpha.10", + "@concepta/nestjs-repository": "8.0.0-alpha.10", + "@standard-schema/spec": "^1.0.0", "deepmerge": "^3.2.0", - "qs": "^6.14.0" + "qs": "^6.14.0", + "zod": "^4.4.3" }, "devDependencies": { + "@apidevtools/swagger-parser": "^12.1.0", + "@concepta/nestjs-repository-typeorm": "8.0.0-alpha.10", "@concepta/typeorm-seeding": "^4.0.0", "@faker-js/faker": "^8.4.1", - "@nestjs/testing": "^11.1.9", - "@nestjs/typeorm": "^11.0.0", - "jest-extended": "^7.0.0", - "jest-mock-extended": "^4.0.0", - "supertest": "^6.3.4" + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/swagger": "^12.0.1", + "@nestjs/testing": "^12.0.1", + "@nestjs/typeorm": "^12.0.1", + "supertest": "^6.3.4", + "typeorm": "^0.3.28", + "vitest-mock-extended": "^4.0.0" }, "peerDependencies": { - "@concepta/nestjs-typeorm-ext": "*", - "class-transformer": "*", - "class-validator": "*", - "rxjs": "^7.1.0", - "typeorm": "^0.3.0" + "@concepta/nestjs-repository-typeorm": "8.0.0-alpha.10", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/swagger": "^12.0.1", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@concepta/nestjs-repository-typeorm": { + "optional": true + }, + "@nestjs/cqrs": { + "optional": true + } + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } } } diff --git a/packages/nestjs-crud/src/__fixtures__/app-ccb-custom.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/app-ccb-custom.module.fixture.ts index 0d697c368..c58336e6d 100644 --- a/packages/nestjs-crud/src/__fixtures__/app-ccb-custom.module.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/app-ccb-custom.module.fixture.ts @@ -1,14 +1,14 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { CrudModule } from '../crud.module'; +import { RepositoryModule } from '@concepta/nestjs-repository'; -import { default as ormConfig } from './ormconfig.fixture'; -import { PhotoCcbCustomModuleFixture } from './photo-ccb-custom/photo-ccb-custom.module.fixture'; +import { CrudModule } from '../crud.module.js'; + +import { PhotoCcbCustomModuleFixture } from './photo-ccb-custom/photo-ccb-custom.module.fixture.js'; @Module({ imports: [ - TypeOrmModule.forRoot(ormConfig), + RepositoryModule.forRoot({}), CrudModule.forRoot({}), PhotoCcbCustomModuleFixture, ], diff --git a/packages/nestjs-crud/src/__fixtures__/app-ccb-sub.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/app-ccb-sub.module.fixture.ts index 701ba1e5e..973a841fc 100644 --- a/packages/nestjs-crud/src/__fixtures__/app-ccb-sub.module.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/app-ccb-sub.module.fixture.ts @@ -1,14 +1,14 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { CrudModule } from '../crud.module'; +import { RepositoryModule } from '@concepta/nestjs-repository'; -import { default as ormConfig } from './ormconfig.fixture'; -import { PhotoCcbSubModuleFixture } from './photo-ccb-sub/photo-ccb-sub.module.fixture'; +import { CrudModule } from '../crud.module.js'; + +import { PhotoCcbSubModuleFixture } from './photo-ccb-sub/photo-ccb-sub.module.fixture.js'; @Module({ imports: [ - TypeOrmModule.forRoot(ormConfig), + RepositoryModule.forRoot({}), CrudModule.forRoot({}), PhotoCcbSubModuleFixture, ], diff --git a/packages/nestjs-crud/src/__fixtures__/app-ccb-useclass.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/app-ccb-useclass.module.fixture.ts deleted file mode 100644 index d6fca7309..000000000 --- a/packages/nestjs-crud/src/__fixtures__/app-ccb-useclass.module.fixture.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { CrudModule } from '../crud.module'; - -import { default as ormConfig } from './ormconfig.fixture'; -import { PhotoCcbUseClassModuleFixture } from './photo-ccb-useclass/photo-ccb-useclass.module.fixture'; - -@Module({ - imports: [ - TypeOrmModule.forRoot(ormConfig), - CrudModule.forRoot({}), - PhotoCcbUseClassModuleFixture, - ], -}) -export class AppCcbUseClassModuleFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/app-ccb.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/app-ccb.module.fixture.ts index 03152aceb..e79a848ac 100644 --- a/packages/nestjs-crud/src/__fixtures__/app-ccb.module.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/app-ccb.module.fixture.ts @@ -1,14 +1,14 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { CrudModule } from '../crud.module'; +import { RepositoryModule } from '@concepta/nestjs-repository'; -import { default as ormConfig } from './ormconfig.fixture'; -import { PhotoCcbModuleFixture } from './photo-ccb/photo-ccb.module.fixture'; +import { CrudModule } from '../crud.module.js'; + +import { PhotoCcbModuleFixture } from './photo-ccb/photo-ccb.module.fixture.js'; @Module({ imports: [ - TypeOrmModule.forRoot(ormConfig), + RepositoryModule.forRoot({}), CrudModule.forRoot({}), PhotoCcbModuleFixture, ], diff --git a/packages/nestjs-crud/src/__fixtures__/app-photo-body-fallback.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/app-photo-body-fallback.module.fixture.ts new file mode 100644 index 000000000..1304df35b --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/app-photo-body-fallback.module.fixture.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { RepositoryModule } from '@concepta/nestjs-repository'; + +import { CrudModule } from '../crud.module.js'; + +import { PhotoBodyFallbackModuleFixture } from './photo-body-fallback/photo-body-fallback.module.fixture.js'; + +@Module({ + imports: [ + RepositoryModule.forRoot({}), + CrudModule.forRoot({}), + PhotoBodyFallbackModuleFixture, + ], +}) +export class AppPhotoBodyFallbackModuleFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/app-resolver-cqrs.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/app-resolver-cqrs.module.fixture.ts new file mode 100644 index 000000000..ce398856b --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/app-resolver-cqrs.module.fixture.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { RepositoryModule } from '@concepta/nestjs-repository'; + +import { CrudModule } from '../crud.module.js'; +import { CrudCqrsResolver } from '../infrastructure/resolvers/crud-cqrs.resolver.js'; + +import { PhotoCcbModuleFixture } from './photo-ccb/photo-ccb.module.fixture.js'; + +@Module({ + imports: [ + RepositoryModule.forRoot({}), + CqrsModule.forRoot(), + CrudModule.forRoot({ + defaultResolver: CrudCqrsResolver, + }), + PhotoCcbModuleFixture, + ], +}) +export class AppResolverCqrsModuleFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/app-resolver-operation.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/app-resolver-operation.module.fixture.ts new file mode 100644 index 000000000..7a11b410e --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/app-resolver-operation.module.fixture.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +import { RepositoryModule } from '@concepta/nestjs-repository'; + +import { CrudModule } from '../crud.module.js'; +import { CrudOperationResolver } from '../infrastructure/resolvers/crud-operation.resolver.js'; + +import { PhotoCcbModuleFixture } from './photo-ccb/photo-ccb.module.fixture.js'; + +@Module({ + imports: [ + RepositoryModule.forRoot({}), + CrudModule.forRoot({ + defaultResolver: CrudOperationResolver, + }), + PhotoCcbModuleFixture, + ], +}) +export class AppResolverOperationModuleFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/app.module.fixture.ts index d732e240f..a94dc04ea 100644 --- a/packages/nestjs-crud/src/__fixtures__/app.module.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/app.module.fixture.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { default as ormConfig } from './ormconfig.fixture'; -import { PhotoModuleFixture } from './photo/photo.module.fixture'; +import { RepositoryModule } from '@concepta/nestjs-repository'; + +import { PhotoModuleFixture } from './photo/photo.module.fixture.js'; @Module({ - imports: [TypeOrmModule.forRoot(ormConfig), PhotoModuleFixture.register()], + imports: [RepositoryModule.forRoot({}), PhotoModuleFixture.register()], }) export class AppModuleFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/crud-test.constants.ts b/packages/nestjs-crud/src/__fixtures__/crud-test.constants.ts index f787b28d3..7a635f35b 100644 --- a/packages/nestjs-crud/src/__fixtures__/crud-test.constants.ts +++ b/packages/nestjs-crud/src/__fixtures__/crud-test.constants.ts @@ -1,10 +1,14 @@ /** - * Entity keys for CRUD test fixtures + * Entity names for CRUD test fixtures */ -export const CRUD_TEST_COMPANY_ENTITY_KEY = 'crud-test-company'; -export const CRUD_TEST_USER_ENTITY_KEY = 'crud-test-user'; -export const CRUD_TEST_USER_PROFILE_ENTITY_KEY = 'crud-test-user-profile'; -export const CRUD_TEST_DEVICE_ENTITY_KEY = 'crud-test-device'; -export const CRUD_TEST_NOTE_ENTITY_KEY = 'crud-test-note'; -export const CRUD_TEST_PROJECT_ENTITY_KEY = 'crud-test-project'; -export const CRUD_TEST_PHOTO_ENTITY_KEY = 'crud-test-photo'; +export const CRUD_TEST_COMPANY_ENTITY_NAME = 'Company'; +export const CRUD_TEST_USER_ENTITY_NAME = 'User'; +export const CRUD_TEST_USER_PROFILE_ENTITY_NAME = 'UserProfile'; +export const CRUD_TEST_DEVICE_ENTITY_NAME = 'Device'; +export const CRUD_TEST_NOTE_ENTITY_NAME = 'Note'; +export const CRUD_TEST_PROJECT_ENTITY_NAME = 'Project'; +export const CRUD_TEST_PHOTO_ENTITY_NAME = 'Photo'; +export const CRUD_TEST_PHOTO_CCB_ENTITY_NAME = 'PhotoCcb'; +export const CRUD_TEST_PHOTO_CCB_SUB_ENTITY_NAME = 'PhotoCcbSub'; +export const CRUD_TEST_PHOTO_CCB_CUSTOM_ENTITY_NAME = 'PhotoCcbCustom'; +export const CRUD_TEST_PHOTO_BODY_FALLBACK_ENTITY_NAME = 'PhotoBodyFallback'; diff --git a/packages/nestjs-crud/src/__fixtures__/crud/adapters/test-crud.adapter.ts b/packages/nestjs-crud/src/__fixtures__/crud/adapters/test-crud.adapter.ts index db159c748..0860f63a4 100644 --- a/packages/nestjs-crud/src/__fixtures__/crud/adapters/test-crud.adapter.ts +++ b/packages/nestjs-crud/src/__fixtures__/crud/adapters/test-crud.adapter.ts @@ -1,65 +1,32 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { Injectable, PlainLiteralObject } from '@nestjs/common'; -import { CrudAdapter } from '../../../crud/adapters/crud.adapter'; -import { CrudCreateManyInterface } from '../../../crud/interfaces/crud-create-many.interface'; -import { CrudRequestOptionsInterface } from '../../../crud/interfaces/crud-request-options.interface'; -import { CrudRequestInterface } from '../../../crud/interfaces/crud-request.interface'; -import { CrudRequestParsedParamsInterface } from '../../../request/interfaces/crud-request-parsed-params.interface'; +import { RepositoryInterface, WhereClause } from '@concepta/nestjs-repository'; -class TestEntity {} +import { CrudAdapter } from '../../../infrastructure/adapters/crud.adapter.js'; +import { CrudContextOptionsInterface } from '../../../infrastructure/interceptors/interfaces/crud-context-options.interface.js'; +import { CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { CrudParsedQueryInterface } from '../../../infrastructure/request/interfaces/crud-parsed-query.interface.js'; @Injectable() export class TestCrudAdapter< T extends PlainLiteralObject, > extends CrudAdapter { - entityName(): string { - return 'TestEntity'; + constructor(repository: RepositoryInterface) { + super(repository); } - entityType(): Type { - return TestEntity as Type; - } - - async getMany(req: CrudRequestInterface): Promise { - return { req }; - } - - async getOne(req: CrudRequestInterface): Promise { - return { req }; - } - - async createOne(req: CrudRequestInterface, dto: T): Promise { - return { req, dto }; - } - - async createMany( - req: CrudRequestInterface, - dto: CrudCreateManyInterface, - ): Promise { - return { req, dto }; - } - - async updateOne(req: CrudRequestInterface, dto: T): Promise { - return { req, dto }; - } - - async replaceOne(req: CrudRequestInterface, dto: T): Promise { - return { req, dto }; - } - - async deleteOne(req: CrudRequestInterface): Promise { - return { req }; + decidePagination( + _query: CrudParsedQueryInterface, + _options: CrudContextOptionsInterface, + ): boolean { + return true; } - async recoverOne(req: CrudRequestInterface): Promise { - return { req }; + exposedBuildWhere(context: CrudContextInterface): WhereClause | undefined { + return this.buildWhere(context); } - decidePagination( - _parsed: CrudRequestParsedParamsInterface, - _options: CrudRequestOptionsInterface, - ): boolean { - return true; + exposedValidateWhereFields(clause: WhereClause | undefined): void { + return this.validateWhereFields(clause); } } diff --git a/packages/nestjs-crud/src/__fixtures__/crud/create-crud-operation-classes.fixture.ts b/packages/nestjs-crud/src/__fixtures__/crud/create-crud-operation-classes.fixture.ts new file mode 100644 index 000000000..06e059fbb --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/crud/create-crud-operation-classes.fixture.ts @@ -0,0 +1,43 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CrudCreateBatchCommand } from '../../application/commands/impl/crud-create-batch.command.js'; +import { CrudCreateCommand } from '../../application/commands/impl/crud-create.command.js'; +import { CrudDeleteCommand } from '../../application/commands/impl/crud-delete.command.js'; +import { CrudReplaceCommand } from '../../application/commands/impl/crud-replace.command.js'; +import { CrudRestoreCommand } from '../../application/commands/impl/crud-restore.command.js'; +import { CrudSoftDeleteCommand } from '../../application/commands/impl/crud-soft-delete.command.js'; +import { CrudUpdateCommand } from '../../application/commands/impl/crud-update.command.js'; +import { CrudListQuery } from '../../application/queries/impl/crud-list.query.js'; +import { CrudReadQuery } from '../../application/queries/impl/crud-read.query.js'; +import { + createCommand, + createQuery, +} from '../../application/utils/create-operation-classes.js'; + +/** + * Creates unique query and command classes for an entity. + * Each call creates new class definitions with prefixed names. + * + * @param name - Prefix for class names (e.g., 'User' creates UserCrudListQuery) + */ +export function createCrudOperationClasses( + name: string, +) { + return { + CrudListQuery: createQuery(name, CrudListQuery), + CrudReadQuery: createQuery(name, CrudReadQuery), + CrudCreateCommand: createCommand(name, CrudCreateCommand), + CrudCreateBatchCommand: createCommand( + name, + CrudCreateBatchCommand, + ), + CrudUpdateCommand: createCommand(name, CrudUpdateCommand), + CrudReplaceCommand: createCommand(name, CrudReplaceCommand), + CrudDeleteCommand: createCommand(name, CrudDeleteCommand), + CrudSoftDeleteCommand: createCommand( + name, + CrudSoftDeleteCommand, + ), + CrudRestoreCommand: createCommand(name, CrudRestoreCommand), + }; +} diff --git a/packages/nestjs-crud/src/__fixtures__/crud/dto/test-model-create-many.dto.ts b/packages/nestjs-crud/src/__fixtures__/crud/dto/test-model-create-many.dto.ts deleted file mode 100644 index c9abf917e..000000000 --- a/packages/nestjs-crud/src/__fixtures__/crud/dto/test-model-create-many.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Type } from 'class-transformer'; -import { IsArray, ArrayNotEmpty, ValidateNested } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CrudCreateManyInterface } from '../../../crud/interfaces/crud-create-many.interface'; - -import { TestModelCreateDto } from './test-model-create.dto'; - -export class TestModelCreateManyDto - implements CrudCreateManyInterface -{ - @ApiProperty({ type: TestModelCreateDto, isArray: true }) - @IsArray() - @ArrayNotEmpty() - @ValidateNested({ each: true }) - @Type(() => TestModelCreateDto) - bulk: TestModelCreateDto[] = []; -} diff --git a/packages/nestjs-crud/src/__fixtures__/crud/dto/test-model-create.dto.ts b/packages/nestjs-crud/src/__fixtures__/crud/dto/test-model-create.dto.ts deleted file mode 100644 index 2746438ff..000000000 --- a/packages/nestjs-crud/src/__fixtures__/crud/dto/test-model-create.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IsString, IsEmail, IsNumber, IsNotEmpty } from 'class-validator'; - -export class TestModelCreateDto { - @IsNotEmpty() - @IsString() - firstName!: string; - - @IsNotEmpty() - @IsString() - lastName!: string; - - @IsNotEmpty() - @IsEmail({ require_tld: false }) - email!: string; - - @IsNotEmpty() - @IsNumber({}) - age!: number; -} diff --git a/packages/nestjs-crud/src/__fixtures__/crud/dto/test-model-update.dto.ts b/packages/nestjs-crud/src/__fixtures__/crud/dto/test-model-update.dto.ts deleted file mode 100644 index c9ebc7e99..000000000 --- a/packages/nestjs-crud/src/__fixtures__/crud/dto/test-model-update.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IsString, IsEmail, IsNumber, IsOptional } from 'class-validator'; - -export class TestModelUpdateDto { - @IsNumber({}) - id!: number; - - @IsOptional() - @IsString() - firstName?: string; - - @IsOptional() - @IsString() - lastName?: string; - - @IsOptional() - @IsEmail({ require_tld: false }) - email?: string; - - @IsOptional() - @IsNumber({}) - age?: number; -} diff --git a/packages/nestjs-crud/src/__fixtures__/crud/mocks/crud-context.mock.ts b/packages/nestjs-crud/src/__fixtures__/crud/mocks/crud-context.mock.ts new file mode 100644 index 000000000..e18bc9ebe --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/crud/mocks/crud-context.mock.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { ActionEnum, AppContextHost, Operation } from '@concepta/nestjs-core'; + +import { CrudCtx } from '../../../infrastructure/interceptors/crud-context.overlay.js'; +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; + +import { mockCrudParsedQuery } from './crud-parsed-query.mock.js'; + +export function mockCrudContext( + overrides: Partial> = {}, +) { + const ctx = new AppContextHost(); + + ctx.defineOverlay(CrudCtx, { + entity: overrides.entity ?? 'TestEntity', + params: overrides.params ?? {}, + query: overrides.query ?? mockCrudParsedQuery(), + options: overrides.options ?? {}, + operation: overrides.operation ?? Operation.Read, + action: overrides.action ?? ActionEnum.READ, + }); + + return ctx.with(CrudCtx); +} diff --git a/packages/nestjs-crud/src/__fixtures__/crud/mocks/crud-paginated-response.mock.ts b/packages/nestjs-crud/src/__fixtures__/crud/mocks/crud-paginated-response.mock.ts new file mode 100644 index 000000000..d1d0dfcaa --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/crud/mocks/crud-paginated-response.mock.ts @@ -0,0 +1,28 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +/** + * Creates a standard paginated response matching the service's expected format. + * Uses sensible defaults - only specify limit when testing buffer behavior. + */ +export const createPaginatedResponse = ( + data: T[], + options: { + limit?: number; + page?: number; + total?: number; + } = {}, +) => { + const limit = options.limit ?? 10; + const page = options.page ?? 1; + const total = options.total ?? data.length; + const pageCount = Math.ceil(total / limit); + + return { + data, + count: data.length, + total, + limit, + page, + pageCount, + }; +}; diff --git a/packages/nestjs-crud/src/__fixtures__/crud/mocks/crud-parsed-query.mock.ts b/packages/nestjs-crud/src/__fixtures__/crud/mocks/crud-parsed-query.mock.ts new file mode 100644 index 000000000..28b59305d --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/crud/mocks/crud-parsed-query.mock.ts @@ -0,0 +1,21 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudParsedQueryInterface } from '../../../infrastructure/request/interfaces/crud-parsed-query.interface.js'; + +export function mockCrudParsedQuery( + overrides: Partial> = {}, +): CrudParsedQueryInterface { + return { + fields: [], + search: undefined, + filter: [], + or: [], + sort: [], + limit: undefined, + offset: undefined, + page: undefined, + cache: undefined, + includeDeleted: undefined, + ...overrides, + }; +} diff --git a/packages/nestjs-crud/src/__fixtures__/crud/models/test.model.ts b/packages/nestjs-crud/src/__fixtures__/crud/models/test.model.ts index f0e3a8583..2a38e51be 100644 --- a/packages/nestjs-crud/src/__fixtures__/crud/models/test.model.ts +++ b/packages/nestjs-crud/src/__fixtures__/crud/models/test.model.ts @@ -1,4 +1,4 @@ -export class TestModelDto { +export class TestModel { id?: number; firstName?: string; lastName?: string; diff --git a/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model-create-batch.schema.ts b/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model-create-batch.schema.ts new file mode 100644 index 000000000..f9602d46b --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model-create-batch.schema.ts @@ -0,0 +1,7 @@ +import { createBatchSchema } from '../../../infrastructure/schemas/crud-create-batch.schema.js'; + +import { testModelCreateSchema } from './test-model-create.schema.js'; + +export const testModelCreateBatchSchema = createBatchSchema( + testModelCreateSchema, +); diff --git a/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model-create.schema.ts b/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model-create.schema.ts new file mode 100644 index 000000000..95ebec92a --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model-create.schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +/** + * Zod equivalent of the legacy `TestModelCreateDto` — faithful + * reproduction, all four fields required. + */ +export const testModelCreateSchema = z.object({ + firstName: z.string(), + lastName: z.string(), + email: z.email(), + age: z.number(), +}); diff --git a/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model-update.schema.ts b/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model-update.schema.ts new file mode 100644 index 000000000..bae0426d7 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model-update.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +/** + * Zod equivalent of the legacy `TestModelUpdateDto` — faithful + * reproduction. `id` is required (the legacy DTO had no `@IsOptional()` + * on it, unlike every other field). + */ +export const testModelUpdateSchema = z.object({ + id: z.number(), + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.email().optional(), + age: z.number().optional(), +}); diff --git a/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model.schema.ts b/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model.schema.ts new file mode 100644 index 000000000..d5122a441 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/crud/schemas/test-model.schema.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +/** + * Zod equivalent of `TestModel` (`models/test.model.ts`) — a bare, + * undecorated class kept around only as a `Type` token for + * `RepositoryInterface.metadata.type` (a genuine class-reference contract, + * unrelated to CRUD validation/serialization — not converted). This + * schema is the counterpart used wherever `TestModel` was previously + * wired as `response: { resource: TestModel }`. + * + * `z.looseObject` (not a strict `z.object`) is required to faithfully + * reproduce `crud-context.interceptor.e2e-spec.ts`'s explicit + * `{ excludeExtraneousValues: false, strategy: 'exposeAll' }` legacy + * config — those tests intentionally return non-TestModel-shaped payloads + * (`{ query }`, `{ params }`, `{ page }`) through the same `resource` + * type to exercise generic passthrough behavior; a strict schema would + * strip/reject those extra keys instead of keeping them. + */ +export const testModelSchema = z.looseObject({ + id: z.number().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), + age: z.number().optional(), +}); diff --git a/packages/nestjs-crud/src/__fixtures__/ormconfig.fixture.ts b/packages/nestjs-crud/src/__fixtures__/ormconfig.fixture.ts index 6fa4f9865..cd2b0c016 100644 --- a/packages/nestjs-crud/src/__fixtures__/ormconfig.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/ormconfig.fixture.ts @@ -1,6 +1,6 @@ -import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { type TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { PhotoFixture } from './photo/photo.entity.fixture'; +import { PhotoFixture } from './photo/photo.entity.fixture.js'; const config: TypeOrmModuleOptions = { type: 'sqlite', diff --git a/packages/nestjs-crud/src/__fixtures__/photo-body-fallback/photo-body-fallback.controller.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo-body-fallback/photo-body-fallback.controller.fixture.ts new file mode 100644 index 000000000..1b078e8d4 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/photo-body-fallback/photo-body-fallback.controller.fixture.ts @@ -0,0 +1,33 @@ +import { Operation } from '@concepta/nestjs-core'; + +import { ConfigurableCrudBuilder } from '../../infrastructure/utils/configurable-crud.builder.js'; +import { CRUD_TEST_PHOTO_BODY_FALLBACK_ENTITY_NAME } from '../crud-test.constants.js'; +import { type PhotoEntityInterfaceFixture } from '../photo/interfaces/photo-entity.interface.fixture.js'; +import { photoPaginatedSchema } from '../photo/schemas/photo-paginated.schema.fixture.js'; +import { photoSchema } from '../photo/schemas/photo.schema.fixture.js'; + +/** + * Mirrors #467's reporter config exactly: `request.body` declared ONLY at + * controller level (`photoSchema`, a `withNamedComponent` schema), and the + * `Create` operation has NO op-level override — regression fixture for both + * the docs `$ref` fix and the docs/validation resolution convergence. + */ +const crudBuilder = new ConfigurableCrudBuilder({ + controller: { + path: 'photo-body-fallback', + entity: CRUD_TEST_PHOTO_BODY_FALLBACK_ENTITY_NAME, + request: { body: photoSchema }, + response: { + resource: photoSchema, + paginated: photoPaginatedSchema, + }, + }, + operations: [{ operation: Operation.Create }], +}); + +const { controllers, providers } = crudBuilder.build(); +const { PhotoBodyFallbackController } = controllers; + +export class PhotoBodyFallbackControllerFixture extends PhotoBodyFallbackController {} + +export { providers as PhotoBodyFallbackProviders }; diff --git a/packages/nestjs-crud/src/__fixtures__/photo-body-fallback/photo-body-fallback.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo-body-fallback/photo-body-fallback.module.fixture.ts new file mode 100644 index 000000000..68288f328 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/photo-body-fallback/photo-body-fallback.module.fixture.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; + +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { CRUD_TEST_PHOTO_BODY_FALLBACK_ENTITY_NAME } from '../crud-test.constants.js'; +import { PhotoFixture } from '../photo/photo.entity.fixture.js'; + +import { + PhotoBodyFallbackControllerFixture, + PhotoBodyFallbackProviders, +} from './photo-body-fallback.controller.fixture.js'; + +@Module({ + imports: [ + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: CRUD_TEST_PHOTO_BODY_FALLBACK_ENTITY_NAME, + entity: PhotoFixture, + }, + ], + }), + ], + providers: PhotoBodyFallbackProviders, + controllers: [PhotoBodyFallbackControllerFixture], +}) +export class PhotoBodyFallbackModuleFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/photo-ccb-custom/photo-ccb-custom.controller.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo-ccb-custom/photo-ccb-custom.controller.fixture.ts index a3e2941a1..c058fbf94 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo-ccb-custom/photo-ccb-custom.controller.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo-ccb-custom/photo-ccb-custom.controller.fixture.ts @@ -1,153 +1,143 @@ import { Inject } from '@nestjs/common'; -import { CrudBaseController } from '../../crud/controllers/crud-base.controller'; -import { CrudBody } from '../../crud/decorators/params/crud-body.decorator'; -import { CrudRequest } from '../../crud/decorators/params/crud-request.decorator'; -import { CrudSoftDelete } from '../../crud/decorators/routes/crud-soft-delete.decorator'; -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; -import { CrudService } from '../../services/crud.service'; -import { ConfigurableCrudBuilder } from '../../util/configurable-crud.builder'; -import { PhotoCreateManyDtoFixture } from '../photo/dto/photo-create-many.dto.fixture'; -import { PhotoCreateDtoFixture } from '../photo/dto/photo-create.dto.fixture'; -import { PhotoPaginatedDtoFixture } from '../photo/dto/photo-paginated.dto.fixture'; -import { PhotoUpdateDtoFixture } from '../photo/dto/photo-update.dto.fixture'; -import { PhotoDtoFixture } from '../photo/dto/photo.dto.fixture'; -import { PhotoCreatableInterfaceFixture } from '../photo/interfaces/photo-creatable.interface.fixture'; -import { PhotoEntityInterfaceFixture } from '../photo/interfaces/photo-entity.interface.fixture'; -import { PhotoUpdatableInterfaceFixture } from '../photo/interfaces/photo-updatable.interface.fixture'; -import { PhotoTypeOrmCrudAdapterFixture } from '../photo/photo-typeorm-crud.adapter.fixture'; +import { Ctx } from '@concepta/nestjs-core'; -export const PHOTO_CRUD_SERVICE_TOKEN = Symbol('__PHOTO_CRUD_SERVICE_TOKEN__'); +import { CrudController } from '../../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudCreateBatch } from '../../infrastructure/decorators/operations/crud-create-batch.decorator.js'; +import { CrudCreate } from '../../infrastructure/decorators/operations/crud-create.decorator.js'; +import { CrudDelete } from '../../infrastructure/decorators/operations/crud-delete.decorator.js'; +import { CrudList } from '../../infrastructure/decorators/operations/crud-list.decorator.js'; +import { CrudRead } from '../../infrastructure/decorators/operations/crud-read.decorator.js'; +import { CrudReplace } from '../../infrastructure/decorators/operations/crud-replace.decorator.js'; +import { CrudRestore } from '../../infrastructure/decorators/operations/crud-restore.decorator.js'; +import { CrudSoftDelete } from '../../infrastructure/decorators/operations/crud-soft-delete.decorator.js'; +import { CrudUpdate } from '../../infrastructure/decorators/operations/crud-update.decorator.js'; +import { CrudBody } from '../../infrastructure/decorators/params/crud-body.decorator.js'; +import { CrudCtx } from '../../infrastructure/interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { CrudCreateBatchInterface } from '../../infrastructure/interfaces/crud-create-batch.interface.js'; +import { CrudAdapterResolver } from '../../infrastructure/resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../../infrastructure/resolvers/interfaces/crud-resolver.interface.js'; +import { ConfigurableCrudBuilder } from '../../infrastructure/utils/configurable-crud.builder.js'; +import { CRUD_TEST_PHOTO_CCB_CUSTOM_ENTITY_NAME } from '../crud-test.constants.js'; +import { PhotoCreatableInterfaceFixture } from '../photo/interfaces/photo-creatable.interface.fixture.js'; +import { PhotoEntityInterfaceFixture } from '../photo/interfaces/photo-entity.interface.fixture.js'; +import { PhotoUpdatableInterfaceFixture } from '../photo/interfaces/photo-updatable.interface.fixture.js'; +import { + photoCreateBatchResponseSchema, + photoCreateBatchSchema, +} from '../photo/schemas/photo-create-batch.schema.fixture.js'; +import { photoCreateSchema } from '../photo/schemas/photo-create.schema.fixture.js'; +import { photoPaginatedSchema } from '../photo/schemas/photo-paginated.schema.fixture.js'; +import { photoUpdateSchema } from '../photo/schemas/photo-update.schema.fixture.js'; +import { photoSchema } from '../photo/schemas/photo.schema.fixture.js'; -const crudBuilder = new ConfigurableCrudBuilder< - PhotoEntityInterfaceFixture, - PhotoCreatableInterfaceFixture, - PhotoUpdatableInterfaceFixture ->({ - service: { - adapterToken: PhotoTypeOrmCrudAdapterFixture, - serviceToken: PHOTO_CRUD_SERVICE_TOKEN, - }, - controller: { - path: 'photo', - model: { - type: PhotoDtoFixture, - paginatedType: PhotoPaginatedDtoFixture, - }, - }, - getMany: {}, - getOne: {}, - createMany: { - dto: PhotoCreateManyDtoFixture, - }, - createOne: { - dto: PhotoCreateDtoFixture, - }, - updateOne: { - dto: PhotoUpdateDtoFixture, - }, - replaceOne: { - dto: PhotoUpdateDtoFixture, - }, - deleteOne: { - extraDecorators: [CrudSoftDelete(true)], - }, - recoverOne: { path: 'recover/:id' }, -}); - -const { - ConfigurableServiceClass, - CrudController, - CrudGetMany, - CrudGetOne, - CrudCreateMany, - CrudCreateOne, - CrudUpdateOne, - CrudReplaceOne, - CrudDeleteOne, - CrudRecoverOne, -} = crudBuilder.build(); - -export class PhotoCcbCustomCrudServiceFixture extends ConfigurableServiceClass {} - -@CrudController -export class PhotoCcbCustomControllerFixture extends CrudBaseController< - PhotoEntityInterfaceFixture, - PhotoCreatableInterfaceFixture, - PhotoUpdatableInterfaceFixture -> { +@CrudController({ + path: 'photo', + entity: CRUD_TEST_PHOTO_CCB_CUSTOM_ENTITY_NAME, + request: { body: photoSchema }, + response: { resource: photoSchema, paginated: photoPaginatedSchema }, +}) +export class PhotoCcbCustomControllerFixture { constructor( - @Inject(PHOTO_CRUD_SERVICE_TOKEN) - protected crudService: CrudService, + @Inject(CrudAdapterResolver) + protected readonly crudResolver: CrudResolverInterface, + ) {} + + @CrudList() + async list( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - super(crudService); + return this.crudResolver.list(ctx); } - @CrudGetMany - async getMany( - @CrudRequest() - crudRequest: CrudRequestInterface, + @CrudRead() + async read( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return this.crudService.getMany(crudRequest); + return this.crudResolver.read(ctx); } - @CrudGetOne - async getOne( - @CrudRequest() - crudRequest: CrudRequestInterface, + @CrudCreateBatch({ + request: { bodyBatch: photoCreateBatchSchema }, + response: { + serialization: { resource: photoCreateBatchResponseSchema }, + }, + }) + async createBatch( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + // Explicit schema — validation would also resolve from this operation's + // `request.body`/`bodyBatch` fallback; passing it here pins it on the + // parameter itself. + @CrudBody({ schema: photoCreateBatchSchema }) + dto: CrudCreateBatchInterface, ) { - return this.crudService.getOne(crudRequest); + return this.crudResolver.createBatch(ctx, dto); } - @CrudCreateMany - async createMany( - @CrudRequest() - crudRequest: CrudRequestInterface, - @CrudBody() dto: PhotoCreateManyDtoFixture, + @CrudCreate({ request: { body: photoCreateSchema } }) + async create( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + @CrudBody({ schema: photoCreateSchema }) + dto: PhotoCreatableInterfaceFixture, ) { - return this.crudService.createMany(crudRequest, dto); + return this.crudResolver.create(ctx, dto); } - @CrudCreateOne - async createOne( - @CrudRequest() - crudRequest: CrudRequestInterface, - @CrudBody() dto: PhotoCreateDtoFixture, + @CrudUpdate({ request: { body: photoUpdateSchema } }) + async update( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + @CrudBody({ schema: photoUpdateSchema }) + dto: PhotoUpdatableInterfaceFixture, ) { - return this.crudService.createOne(crudRequest, dto); + return this.crudResolver.update(ctx, dto); } - @CrudUpdateOne - async updateOne( - @CrudRequest() - crudRequest: CrudRequestInterface, - @CrudBody() dto: PhotoUpdateDtoFixture, + @CrudReplace({ request: { body: photoUpdateSchema } }) + async replace( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + @CrudBody({ schema: photoUpdateSchema }) + dto: PhotoUpdatableInterfaceFixture, ) { - return this.crudService.updateOne(crudRequest, dto); + return this.crudResolver.replace(ctx, dto); } - @CrudReplaceOne - async replaceOne( - @CrudRequest() - crudRequest: CrudRequestInterface, - @CrudBody() dto: PhotoUpdateDtoFixture, + @CrudDelete() + async delete( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return this.crudService.replaceOne(crudRequest, dto); + return this.crudResolver.delete(ctx); } - @CrudDeleteOne - async deleteOne( - @CrudRequest() - crudRequest: CrudRequestInterface, + @CrudSoftDelete({ path: 'soft/:id' }) + async softDelete( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return this.crudService.deleteOne(crudRequest); + return this.crudResolver.softDelete(ctx); } - @CrudRecoverOne - async recoverOne( - @CrudRequest() - crudRequest: CrudRequestInterface, + @CrudRestore({ path: 'restore/:id' }) + async restore( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return this.crudService.recoverOne(crudRequest); + return this.crudResolver.restore(ctx); } } + +// Use controller.class path to generate handlers from the decorated class +const crudBuilder = new ConfigurableCrudBuilder({ + controller: { + class: PhotoCcbCustomControllerFixture, + }, +}); + +export const PhotoCcbCustomProviders = crudBuilder.build().providers; diff --git a/packages/nestjs-crud/src/__fixtures__/photo-ccb-custom/photo-ccb-custom.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo-ccb-custom/photo-ccb-custom.module.fixture.ts index 11111ebde..b4bbf47dd 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo-ccb-custom/photo-ccb-custom.module.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo-ccb-custom/photo-ccb-custom.module.fixture.ts @@ -1,32 +1,26 @@ import { Module } from '@nestjs/common'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; -import { CRUD_TEST_PHOTO_ENTITY_KEY } from '../crud-test.constants'; -import { PhotoTypeOrmCrudAdapterFixture } from '../photo/photo-typeorm-crud.adapter.fixture'; -import { PhotoFixture } from '../photo/photo.entity.fixture'; +import { CRUD_TEST_PHOTO_CCB_CUSTOM_ENTITY_NAME } from '../crud-test.constants.js'; +import { PhotoFixture } from '../photo/photo.entity.fixture.js'; import { PhotoCcbCustomControllerFixture, - PhotoCcbCustomCrudServiceFixture, - PHOTO_CRUD_SERVICE_TOKEN, -} from './photo-ccb-custom.controller.fixture'; + PhotoCcbCustomProviders, +} from './photo-ccb-custom.controller.fixture.js'; @Module({ imports: [ - TypeOrmExtModule.forFeature({ - [CRUD_TEST_PHOTO_ENTITY_KEY]: { - entity: PhotoFixture, - }, + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: CRUD_TEST_PHOTO_CCB_CUSTOM_ENTITY_NAME, entity: PhotoFixture }, + ], }), ], - providers: [ - PhotoTypeOrmCrudAdapterFixture, - { - provide: PHOTO_CRUD_SERVICE_TOKEN, - useClass: PhotoCcbCustomCrudServiceFixture, - }, - ], + providers: PhotoCcbCustomProviders, controllers: [PhotoCcbCustomControllerFixture], }) export class PhotoCcbCustomModuleFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/photo-ccb-sub/photo-ccb-sub.controller.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo-ccb-sub/photo-ccb-sub.controller.fixture.ts index 12ba00c4a..55b3efbe9 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo-ccb-sub/photo-ccb-sub.controller.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo-ccb-sub/photo-ccb-sub.controller.fixture.ts @@ -1,128 +1,143 @@ -import { CrudSoftDelete } from '../../crud/decorators/routes/crud-soft-delete.decorator'; -import { CrudCreateManyInterface } from '../../crud/interfaces/crud-create-many.interface'; -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; -import { ConfigurableCrudBuilder } from '../../util/configurable-crud.builder'; -import { PhotoCreateManyDtoFixture } from '../photo/dto/photo-create-many.dto.fixture'; -import { PhotoCreateDtoFixture } from '../photo/dto/photo-create.dto.fixture'; -import { PhotoPaginatedDtoFixture } from '../photo/dto/photo-paginated.dto.fixture'; -import { PhotoUpdateDtoFixture } from '../photo/dto/photo-update.dto.fixture'; -import { PhotoDtoFixture } from '../photo/dto/photo.dto.fixture'; -import { PhotoCreatableInterfaceFixture } from '../photo/interfaces/photo-creatable.interface.fixture'; -import { PhotoEntityInterfaceFixture } from '../photo/interfaces/photo-entity.interface.fixture'; -import { PhotoUpdatableInterfaceFixture } from '../photo/interfaces/photo-updatable.interface.fixture'; -import { PhotoTypeOrmCrudAdapterFixture } from '../photo/photo-typeorm-crud.adapter.fixture'; +import { Inject } from '@nestjs/common'; -export const PHOTO_CRUD_SERVICE_TOKEN = Symbol('__PHOTO_CRUD_SERVICE_TOKEN__'); +import { Ctx } from '@concepta/nestjs-core'; -const crudBuilder = new ConfigurableCrudBuilder< - PhotoEntityInterfaceFixture, - PhotoCreatableInterfaceFixture, - PhotoUpdatableInterfaceFixture ->({ - service: { - adapterToken: PhotoTypeOrmCrudAdapterFixture, - serviceToken: PHOTO_CRUD_SERVICE_TOKEN, - }, - controller: { - path: 'photo', - model: { - type: PhotoDtoFixture, - paginatedType: PhotoPaginatedDtoFixture, - }, - }, - getMany: {}, - getOne: {}, - createMany: { - dto: PhotoCreateManyDtoFixture, - }, - createOne: { - dto: PhotoCreateDtoFixture, - }, - updateOne: { - dto: PhotoUpdateDtoFixture, - }, - replaceOne: { - dto: PhotoUpdateDtoFixture, - }, - deleteOne: { - extraDecorators: [CrudSoftDelete(true)], - }, - recoverOne: { path: 'recover/:id' }, -}); - -const { - ConfigurableServiceClass, - ConfigurableControllerClass, - CrudController, - CrudGetMany, - CrudGetOne, - CrudCreateMany, - CrudCreateOne, - CrudUpdateOne, - CrudReplaceOne, - CrudDeleteOne, - CrudRecoverOne, -} = crudBuilder.build(); +import { CrudController } from '../../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudCreateBatch } from '../../infrastructure/decorators/operations/crud-create-batch.decorator.js'; +import { CrudCreate } from '../../infrastructure/decorators/operations/crud-create.decorator.js'; +import { CrudDelete } from '../../infrastructure/decorators/operations/crud-delete.decorator.js'; +import { CrudList } from '../../infrastructure/decorators/operations/crud-list.decorator.js'; +import { CrudRead } from '../../infrastructure/decorators/operations/crud-read.decorator.js'; +import { CrudReplace } from '../../infrastructure/decorators/operations/crud-replace.decorator.js'; +import { CrudRestore } from '../../infrastructure/decorators/operations/crud-restore.decorator.js'; +import { CrudSoftDelete } from '../../infrastructure/decorators/operations/crud-soft-delete.decorator.js'; +import { CrudUpdate } from '../../infrastructure/decorators/operations/crud-update.decorator.js'; +import { CrudBody } from '../../infrastructure/decorators/params/crud-body.decorator.js'; +import { CrudCtx } from '../../infrastructure/interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { CrudCreateBatchInterface } from '../../infrastructure/interfaces/crud-create-batch.interface.js'; +import { CrudAdapterResolver } from '../../infrastructure/resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../../infrastructure/resolvers/interfaces/crud-resolver.interface.js'; +import { ConfigurableCrudBuilder } from '../../infrastructure/utils/configurable-crud.builder.js'; +import { CRUD_TEST_PHOTO_CCB_SUB_ENTITY_NAME } from '../crud-test.constants.js'; +import { PhotoCreatableInterfaceFixture } from '../photo/interfaces/photo-creatable.interface.fixture.js'; +import { PhotoEntityInterfaceFixture } from '../photo/interfaces/photo-entity.interface.fixture.js'; +import { PhotoUpdatableInterfaceFixture } from '../photo/interfaces/photo-updatable.interface.fixture.js'; +import { + photoCreateBatchResponseSchema, + photoCreateBatchSchema, +} from '../photo/schemas/photo-create-batch.schema.fixture.js'; +import { photoCreateSchema } from '../photo/schemas/photo-create.schema.fixture.js'; +import { photoPaginatedSchema } from '../photo/schemas/photo-paginated.schema.fixture.js'; +import { photoUpdateSchema } from '../photo/schemas/photo-update.schema.fixture.js'; +import { photoSchema } from '../photo/schemas/photo.schema.fixture.js'; -export class PhotoCcbSubCrudServiceFixture extends ConfigurableServiceClass {} +@CrudController({ + path: 'photo', + entity: CRUD_TEST_PHOTO_CCB_SUB_ENTITY_NAME, + request: { body: photoSchema }, + response: { resource: photoSchema, paginated: photoPaginatedSchema }, +}) +export class PhotoCcbSubControllerFixture { + constructor( + @Inject(CrudAdapterResolver) + protected readonly crudResolver: CrudResolverInterface, + ) {} -@CrudController -export class PhotoCcbSubControllerFixture extends ConfigurableControllerClass { - @CrudGetMany - async getMany( - crudRequest: CrudRequestInterface, + @CrudList() + async list( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return super.getMany(crudRequest); + return this.crudResolver.list(ctx); } - @CrudGetOne - async getOne(crudRequest: CrudRequestInterface) { - return super.getOne(crudRequest); + @CrudRead() + async read( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + ) { + return this.crudResolver.read(ctx); } - @CrudCreateMany - async createMany( - crudRequest: CrudRequestInterface, - dto: CrudCreateManyInterface, + @CrudCreateBatch({ + request: { bodyBatch: photoCreateBatchSchema }, + response: { + serialization: { resource: photoCreateBatchResponseSchema }, + }, + }) + async createBatch( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + // Explicit schema — validation would also resolve from this operation's + // `request.body`/`bodyBatch` fallback; passing it here pins it on the + // parameter itself. + @CrudBody({ schema: photoCreateBatchSchema }) + dto: CrudCreateBatchInterface, ) { - return super.createMany(crudRequest, dto); + return this.crudResolver.createBatch(ctx, dto); } - @CrudCreateOne - async createOne( - crudRequest: CrudRequestInterface, + @CrudCreate({ request: { body: photoCreateSchema } }) + async create( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + @CrudBody({ schema: photoCreateSchema }) dto: PhotoCreatableInterfaceFixture, ) { - return super.createOne(crudRequest, dto); + return this.crudResolver.create(ctx, dto); } - @CrudUpdateOne - async updateOne( - crudRequest: CrudRequestInterface, + @CrudUpdate({ request: { body: photoUpdateSchema } }) + async update( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + @CrudBody({ schema: photoUpdateSchema }) dto: PhotoUpdatableInterfaceFixture, ) { - return super.updateOne(crudRequest, dto); + return this.crudResolver.update(ctx, dto); } - @CrudReplaceOne - async replaceOne( - crudRequest: CrudRequestInterface, + @CrudReplace({ request: { body: photoUpdateSchema } }) + async replace( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + @CrudBody({ schema: photoUpdateSchema }) dto: PhotoUpdatableInterfaceFixture, ) { - return super.replaceOne(crudRequest, dto); + return this.crudResolver.replace(ctx, dto); + } + + @CrudDelete() + async delete( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + ) { + return this.crudResolver.delete(ctx); } - @CrudDeleteOne - async deleteOne( - crudRequest: CrudRequestInterface, + @CrudSoftDelete({ path: 'soft/:id' }) + async softDelete( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return super.deleteOne(crudRequest); + return this.crudResolver.softDelete(ctx); } - @CrudRecoverOne - async recoverOne( - crudRequest: CrudRequestInterface, + @CrudRestore({ path: 'restore/:id' }) + async restore( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return super.recoverOne(crudRequest); + return this.crudResolver.restore(ctx); } } + +// Use controller.class path to generate handlers from the decorated class +const crudBuilder = new ConfigurableCrudBuilder({ + controller: { + class: PhotoCcbSubControllerFixture, + }, +}); + +export const PhotoCcbSubProviders = crudBuilder.build().providers; diff --git a/packages/nestjs-crud/src/__fixtures__/photo-ccb-sub/photo-ccb-sub.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo-ccb-sub/photo-ccb-sub.module.fixture.ts index a4c7ab56f..9bf0f2717 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo-ccb-sub/photo-ccb-sub.module.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo-ccb-sub/photo-ccb-sub.module.fixture.ts @@ -1,32 +1,26 @@ import { Module } from '@nestjs/common'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; -import { CRUD_TEST_PHOTO_ENTITY_KEY } from '../crud-test.constants'; -import { PhotoTypeOrmCrudAdapterFixture } from '../photo/photo-typeorm-crud.adapter.fixture'; -import { PhotoFixture } from '../photo/photo.entity.fixture'; +import { CRUD_TEST_PHOTO_CCB_SUB_ENTITY_NAME } from '../crud-test.constants.js'; +import { PhotoFixture } from '../photo/photo.entity.fixture.js'; import { PhotoCcbSubControllerFixture, - PhotoCcbSubCrudServiceFixture, - PHOTO_CRUD_SERVICE_TOKEN, -} from './photo-ccb-sub.controller.fixture'; + PhotoCcbSubProviders, +} from './photo-ccb-sub.controller.fixture.js'; @Module({ imports: [ - TypeOrmExtModule.forFeature({ - [CRUD_TEST_PHOTO_ENTITY_KEY]: { - entity: PhotoFixture, - }, + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: CRUD_TEST_PHOTO_CCB_SUB_ENTITY_NAME, entity: PhotoFixture }, + ], }), ], - providers: [ - PhotoTypeOrmCrudAdapterFixture, - { - provide: PHOTO_CRUD_SERVICE_TOKEN, - useClass: PhotoCcbSubCrudServiceFixture, - }, - ], + providers: PhotoCcbSubProviders, controllers: [PhotoCcbSubControllerFixture], }) export class PhotoCcbSubModuleFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/photo-ccb-useclass/photo-ccb-useclass.controller.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo-ccb-useclass/photo-ccb-useclass.controller.fixture.ts deleted file mode 100644 index 85c587b4d..000000000 --- a/packages/nestjs-crud/src/__fixtures__/photo-ccb-useclass/photo-ccb-useclass.controller.fixture.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { CrudAdapter } from '../../crud/adapters/crud.adapter'; -import { CrudSoftDelete } from '../../crud/decorators/routes/crud-soft-delete.decorator'; -import { CrudService } from '../../services/crud.service'; -import { ConfigurableCrudBuilder } from '../../util/configurable-crud.builder'; -import { PhotoCreateManyDtoFixture } from '../photo/dto/photo-create-many.dto.fixture'; -import { PhotoCreateDtoFixture } from '../photo/dto/photo-create.dto.fixture'; -import { PhotoPaginatedDtoFixture } from '../photo/dto/photo-paginated.dto.fixture'; -import { PhotoUpdateDtoFixture } from '../photo/dto/photo-update.dto.fixture'; -import { PhotoDtoFixture } from '../photo/dto/photo.dto.fixture'; -import { PhotoCreatableInterfaceFixture } from '../photo/interfaces/photo-creatable.interface.fixture'; -import { PhotoEntityInterfaceFixture } from '../photo/interfaces/photo-entity.interface.fixture'; -import { PhotoUpdatableInterfaceFixture } from '../photo/interfaces/photo-updatable.interface.fixture'; -import { PhotoTypeOrmCrudAdapterFixture } from '../photo/photo-typeorm-crud.adapter.fixture'; - -export const PHOTO_USECLASS_SERVICE_TOKEN = Symbol( - '__PHOTO_USECLASS_SERVICE_TOKEN__', -); - -/** - * Custom service class that will be passed via useClass option - * Has a custom method to verify it's being used - */ -@Injectable() -export class PhotoUseClassCrudServiceFixture extends CrudService { - constructor( - @Inject(PhotoTypeOrmCrudAdapterFixture) - protected readonly crudAdapter: CrudAdapter, - ) { - super(crudAdapter); - } - - /** - * Custom method to verify this service is being used - */ - customServiceMethod(): string { - return 'custom-service-used'; - } -} - -const crudBuilder = new ConfigurableCrudBuilder< - PhotoEntityInterfaceFixture, - PhotoCreatableInterfaceFixture, - PhotoUpdatableInterfaceFixture ->({ - service: { - useClass: PhotoUseClassCrudServiceFixture, - serviceToken: PHOTO_USECLASS_SERVICE_TOKEN, - }, - controller: { - path: 'photo', - model: { - type: PhotoDtoFixture, - paginatedType: PhotoPaginatedDtoFixture, - }, - }, - getMany: {}, - getOne: {}, - createMany: { - dto: PhotoCreateManyDtoFixture, - }, - createOne: { - dto: PhotoCreateDtoFixture, - }, - updateOne: { - dto: PhotoUpdateDtoFixture, - }, - replaceOne: { - dto: PhotoUpdateDtoFixture, - }, - deleteOne: { - extraDecorators: [CrudSoftDelete(true)], - }, - recoverOne: { path: 'recover/:id' }, -}); - -const { - ConfigurableControllerClass, - ConfigurableServiceClass, - ConfigurableServiceProvider, -} = crudBuilder.build(); - -// Verify that ConfigurableServiceClass is the same as our custom class -export const serviceClassIsCustom = - ConfigurableServiceClass === PhotoUseClassCrudServiceFixture; - -// Export the provider for module registration -export { ConfigurableServiceProvider as PhotoUseClassServiceProvider }; - -export class PhotoCcbUseClassControllerFixture extends ConfigurableControllerClass {} diff --git a/packages/nestjs-crud/src/__fixtures__/photo-ccb-useclass/photo-ccb-useclass.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo-ccb-useclass/photo-ccb-useclass.module.fixture.ts deleted file mode 100644 index c0a89b6c0..000000000 --- a/packages/nestjs-crud/src/__fixtures__/photo-ccb-useclass/photo-ccb-useclass.module.fixture.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { CRUD_TEST_PHOTO_ENTITY_KEY } from '../crud-test.constants'; -import { PhotoTypeOrmCrudAdapterFixture } from '../photo/photo-typeorm-crud.adapter.fixture'; -import { PhotoFixture } from '../photo/photo.entity.fixture'; - -import { - PhotoCcbUseClassControllerFixture, - PhotoUseClassServiceProvider, -} from './photo-ccb-useclass.controller.fixture'; - -@Module({ - imports: [ - TypeOrmExtModule.forFeature({ - [CRUD_TEST_PHOTO_ENTITY_KEY]: { - entity: PhotoFixture, - }, - }), - ], - providers: [PhotoTypeOrmCrudAdapterFixture, PhotoUseClassServiceProvider], - controllers: [PhotoCcbUseClassControllerFixture], -}) -export class PhotoCcbUseClassModuleFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/photo-ccb/photo-ccb.controller.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo-ccb/photo-ccb.controller.fixture.ts index 7a7965acd..69286b1e1 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo-ccb/photo-ccb.controller.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo-ccb/photo-ccb.controller.fixture.ts @@ -1,55 +1,58 @@ -import { CrudSoftDelete } from '../../crud/decorators/routes/crud-soft-delete.decorator'; -import { ConfigurableCrudBuilder } from '../../util/configurable-crud.builder'; -import { PhotoCreateManyDtoFixture } from '../photo/dto/photo-create-many.dto.fixture'; -import { PhotoCreateDtoFixture } from '../photo/dto/photo-create.dto.fixture'; -import { PhotoPaginatedDtoFixture } from '../photo/dto/photo-paginated.dto.fixture'; -import { PhotoUpdateDtoFixture } from '../photo/dto/photo-update.dto.fixture'; -import { PhotoDtoFixture } from '../photo/dto/photo.dto.fixture'; -import { PhotoCreatableInterfaceFixture } from '../photo/interfaces/photo-creatable.interface.fixture'; -import { PhotoEntityInterfaceFixture } from '../photo/interfaces/photo-entity.interface.fixture'; -import { PhotoUpdatableInterfaceFixture } from '../photo/interfaces/photo-updatable.interface.fixture'; -import { PhotoTypeOrmCrudAdapterFixture } from '../photo/photo-typeorm-crud.adapter.fixture'; +import { Operation } from '@concepta/nestjs-core'; -export const PHOTO_CRUD_ADAPTER_TOKEN = Symbol('__PHOTO_CRUD_ADAPTER_TOKEN__'); +import { ConfigurableCrudBuilder } from '../../infrastructure/utils/configurable-crud.builder.js'; +import { CRUD_TEST_PHOTO_CCB_ENTITY_NAME } from '../crud-test.constants.js'; +import { type PhotoEntityInterfaceFixture } from '../photo/interfaces/photo-entity.interface.fixture.js'; +import { + photoCreateBatchResponseSchema, + photoCreateBatchSchema, +} from '../photo/schemas/photo-create-batch.schema.fixture.js'; +import { photoCreateSchema } from '../photo/schemas/photo-create.schema.fixture.js'; +import { photoPaginatedSchema } from '../photo/schemas/photo-paginated.schema.fixture.js'; +import { photoUpdateSchema } from '../photo/schemas/photo-update.schema.fixture.js'; +import { photoSchema } from '../photo/schemas/photo.schema.fixture.js'; -const crudBuilder = new ConfigurableCrudBuilder< - PhotoEntityInterfaceFixture, - PhotoCreatableInterfaceFixture, - PhotoUpdatableInterfaceFixture ->({ - service: { - adapterToken: PhotoTypeOrmCrudAdapterFixture, - serviceToken: PHOTO_CRUD_ADAPTER_TOKEN, - }, +const crudBuilder = new ConfigurableCrudBuilder({ controller: { path: 'photo', - model: { - type: PhotoDtoFixture, - paginatedType: PhotoPaginatedDtoFixture, + entity: CRUD_TEST_PHOTO_CCB_ENTITY_NAME, + request: { body: photoSchema }, + response: { + resource: photoSchema, + paginated: photoPaginatedSchema, }, }, - getMany: {}, - getOne: {}, - createMany: { - dto: PhotoCreateManyDtoFixture, - }, - createOne: { - dto: PhotoCreateDtoFixture, - }, - updateOne: { - dto: PhotoUpdateDtoFixture, - }, - replaceOne: { - dto: PhotoUpdateDtoFixture, - }, - deleteOne: { - extraDecorators: [CrudSoftDelete(true)], - }, - recoverOne: { path: 'recover/:id' }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + { + operation: Operation.CreateBatch, + request: { bodyBatch: photoCreateBatchSchema }, + response: { + serialization: { resource: photoCreateBatchResponseSchema }, + }, + }, + { + operation: Operation.Create, + request: { body: photoCreateSchema }, + }, + { + operation: Operation.Update, + request: { body: photoUpdateSchema }, + }, + { + operation: Operation.Replace, + request: { body: photoUpdateSchema }, + }, + { operation: Operation.Delete }, + { operation: Operation.SoftDelete, path: 'soft/:id' }, + { operation: Operation.Restore, path: 'restore/:id' }, + ], }); -const { ConfigurableControllerClass, ConfigurableServiceClass } = - crudBuilder.build(); +const { controllers, providers } = crudBuilder.build(); +const { PhotoCcbController } = controllers; + +export class PhotoCcbControllerFixture extends PhotoCcbController {} -export class PhotoCcbCrudServiceFixture extends ConfigurableServiceClass {} -export class PhotoCcbControllerFixture extends ConfigurableControllerClass {} +export { providers as PhotoCcbProviders }; diff --git a/packages/nestjs-crud/src/__fixtures__/photo-ccb/photo-ccb.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo-ccb/photo-ccb.module.fixture.ts index 49c3d7bac..8f12c6172 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo-ccb/photo-ccb.module.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo-ccb/photo-ccb.module.fixture.ts @@ -1,32 +1,26 @@ import { Module } from '@nestjs/common'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; -import { CRUD_TEST_PHOTO_ENTITY_KEY } from '../crud-test.constants'; -import { PhotoTypeOrmCrudAdapterFixture } from '../photo/photo-typeorm-crud.adapter.fixture'; -import { PhotoFixture } from '../photo/photo.entity.fixture'; +import { CRUD_TEST_PHOTO_CCB_ENTITY_NAME } from '../crud-test.constants.js'; +import { PhotoFixture } from '../photo/photo.entity.fixture.js'; import { PhotoCcbControllerFixture, - PhotoCcbCrudServiceFixture, - PHOTO_CRUD_ADAPTER_TOKEN, -} from './photo-ccb.controller.fixture'; + PhotoCcbProviders, +} from './photo-ccb.controller.fixture.js'; @Module({ imports: [ - TypeOrmExtModule.forFeature({ - [CRUD_TEST_PHOTO_ENTITY_KEY]: { - entity: PhotoFixture, - }, + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: CRUD_TEST_PHOTO_CCB_ENTITY_NAME, entity: PhotoFixture }, + ], }), ], - providers: [ - PhotoTypeOrmCrudAdapterFixture, - { - provide: PHOTO_CRUD_ADAPTER_TOKEN, - useClass: PhotoCcbCrudServiceFixture, - }, - ], + providers: PhotoCcbProviders, controllers: [PhotoCcbControllerFixture], }) export class PhotoCcbModuleFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-create-many.dto.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-create-many.dto.fixture.ts deleted file mode 100644 index 1c0adaf94..000000000 --- a/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-create-many.dto.fixture.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CrudCreateManyDto } from '../../../crud/dto/crud-create-many.dto'; - -import { PhotoCreateDtoFixture } from './photo-create.dto.fixture'; -import { PhotoDtoFixture } from './photo.dto.fixture'; - -@Exclude() -export class PhotoCreateManyDtoFixture extends CrudCreateManyDto { - @Expose() - @ApiProperty({ type: [PhotoDtoFixture], isArray: true }) - @Type(() => PhotoCreateDtoFixture) - bulk: PhotoCreateDtoFixture[] = []; -} diff --git a/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-create.dto.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-create.dto.fixture.ts deleted file mode 100644 index bce84866c..000000000 --- a/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-create.dto.fixture.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { PickType } from '@nestjs/swagger'; - -import { PhotoCreatableInterfaceFixture } from '../interfaces/photo-creatable.interface.fixture'; - -import { PhotoDtoFixture } from './photo.dto.fixture'; - -@Exclude() -export class PhotoCreateDtoFixture - extends PickType(PhotoDtoFixture, [ - 'name', - 'description', - 'filename', - 'isPublished', - ] as const) - implements PhotoCreatableInterfaceFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-paginated.dto.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-paginated.dto.fixture.ts deleted file mode 100644 index 78a374ae2..000000000 --- a/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-paginated.dto.fixture.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CrudResponsePaginatedDto } from '../../../crud/dto/crud-response-paginated.dto'; - -import { PhotoDtoFixture } from './photo.dto.fixture'; - -@Exclude() -export class PhotoPaginatedDtoFixture extends CrudResponsePaginatedDto { - @ApiProperty({ type: [PhotoDtoFixture], isArray: true }) - @Expose() - @Type(() => PhotoDtoFixture) - data: PhotoDtoFixture[] = []; -} diff --git a/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-update.dto.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-update.dto.fixture.ts deleted file mode 100644 index a5dee63c6..000000000 --- a/packages/nestjs-crud/src/__fixtures__/photo/dto/photo-update.dto.fixture.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { PickType } from '@nestjs/swagger'; - -import { PhotoUpdatableInterfaceFixture } from '../interfaces/photo-updatable.interface.fixture'; - -import { PhotoDtoFixture } from './photo.dto.fixture'; - -@Exclude() -export class PhotoUpdateDtoFixture - extends PickType(PhotoDtoFixture, [ - 'name', - 'description', - 'filename', - 'isPublished', - 'views', - ] as const) - implements PhotoUpdatableInterfaceFixture {} diff --git a/packages/nestjs-crud/src/__fixtures__/photo/dto/photo.dto.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/dto/photo.dto.fixture.ts deleted file mode 100644 index 6344a4ddf..000000000 --- a/packages/nestjs-crud/src/__fixtures__/photo/dto/photo.dto.fixture.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { - IsBoolean, - IsDate, - IsNumber, - IsOptional, - IsString, - IsUUID, -} from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { PhotoEntityInterfaceFixture } from '../interfaces/photo-entity.interface.fixture'; - -@Exclude() -export class PhotoDtoFixture implements PhotoEntityInterfaceFixture { - @ApiProperty() - @Expose() - @IsUUID() - id: string = ''; - - @ApiProperty() - @Expose() - @IsString() - name = ''; - - @ApiProperty() - @Expose() - @IsString() - description = ''; - - @ApiProperty() - @Expose() - @IsString() - filename = ''; - - @ApiProperty() - @Expose() - @IsNumber() - views = 0; - - @ApiProperty() - @Expose() - @IsBoolean() - isPublished = true; - - @ApiProperty({ nullable: true }) - @Expose() - @IsDate() - @IsOptional() - deletedAt: Date | null = null; -} diff --git a/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-creatable.interface.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-creatable.interface.fixture.ts index fbbaf97d6..f93e5187d 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-creatable.interface.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-creatable.interface.fixture.ts @@ -1,7 +1,6 @@ -import { PhotoEntityInterfaceFixture } from './photo-entity.interface.fixture'; +import { type PhotoEntityInterfaceFixture } from './photo-entity.interface.fixture.js'; -export interface PhotoCreatableInterfaceFixture - extends Pick< - PhotoEntityInterfaceFixture, - 'name' | 'description' | 'filename' | 'isPublished' - > {} +export interface PhotoCreatableInterfaceFixture extends Pick< + PhotoEntityInterfaceFixture, + 'name' | 'description' | 'filename' | 'isPublished' +> {} diff --git a/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-entity.interface.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-entity.interface.fixture.ts index 92a4d8457..8f6977027 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-entity.interface.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-entity.interface.fixture.ts @@ -1,4 +1,4 @@ -import { ReferenceIdInterface } from '@concepta/nestjs-common'; +import { type ReferenceIdInterface } from '@concepta/nestjs-core'; export interface PhotoEntityInterfaceFixture extends ReferenceIdInterface { name: string; diff --git a/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-updatable.interface.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-updatable.interface.fixture.ts index 99a04e51e..77b02b367 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-updatable.interface.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo/interfaces/photo-updatable.interface.fixture.ts @@ -1,7 +1,6 @@ -import { PhotoEntityInterfaceFixture } from './photo-entity.interface.fixture'; +import { type PhotoEntityInterfaceFixture } from './photo-entity.interface.fixture.js'; -export interface PhotoUpdatableInterfaceFixture - extends Pick< - PhotoEntityInterfaceFixture, - 'name' | 'description' | 'filename' | 'isPublished' | 'views' - > {} +export interface PhotoUpdatableInterfaceFixture extends Pick< + PhotoEntityInterfaceFixture, + 'name' | 'description' | 'filename' | 'isPublished' | 'views' +> {} diff --git a/packages/nestjs-crud/src/__fixtures__/photo/photo-typeorm-crud.adapter.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/photo-typeorm-crud.adapter.fixture.ts deleted file mode 100644 index 2ebbecf21..000000000 --- a/packages/nestjs-crud/src/__fixtures__/photo/photo-typeorm-crud.adapter.fixture.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { InjectDynamicRepository } from '@concepta/nestjs-common'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { TypeOrmCrudAdapter } from '../../crud/adapters/typeorm-crud.adapter'; -import { CRUD_TEST_PHOTO_ENTITY_KEY } from '../crud-test.constants'; - -import { PhotoEntityInterfaceFixture } from './interfaces/photo-entity.interface.fixture'; - -/** - * Photo CRUD Adapter Fixture - */ -@Injectable() -export class PhotoTypeOrmCrudAdapterFixture extends TypeOrmCrudAdapter { - constructor( - @InjectDynamicRepository(CRUD_TEST_PHOTO_ENTITY_KEY) - repoAdapter: TypeOrmRepositoryAdapter, - ) { - super(repoAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/photo/photo.controller.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/photo.controller.fixture.ts index f67e92115..a1252660f 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo/photo.controller.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo/photo.controller.fixture.ts @@ -1,176 +1,161 @@ +import { Inject } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { CrudCreateMany } from '../../crud/decorators/actions/crud-create-many.decorator'; -import { CrudCreateOne } from '../../crud/decorators/actions/crud-create-one.decorator'; -import { CrudDeleteOne } from '../../crud/decorators/actions/crud-delete-one.decorator'; -import { CrudReadAll } from '../../crud/decorators/actions/crud-read-all.decorator'; -import { CrudReadOne } from '../../crud/decorators/actions/crud-read-one.decorator'; -import { CrudRecoverOne } from '../../crud/decorators/actions/crud-recover-one.decorator'; -import { CrudReplaceOne } from '../../crud/decorators/actions/crud-replace-one.decorator'; -import { CrudUpdateOne } from '../../crud/decorators/actions/crud-update-one.decorator'; -import { CrudController } from '../../crud/decorators/controller/crud-controller.decorator'; -import { CrudBody } from '../../crud/decorators/params/crud-body.decorator'; -import { CrudRequest } from '../../crud/decorators/params/crud-request.decorator'; -import { CrudSoftDelete } from '../../crud/decorators/routes/crud-soft-delete.decorator'; -import { CrudControllerInterface } from '../../crud/interfaces/crud-controller.interface'; -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; +import { Ctx } from '@concepta/nestjs-core'; -import { PhotoCreateManyDtoFixture } from './dto/photo-create-many.dto.fixture'; -import { PhotoCreateDtoFixture } from './dto/photo-create.dto.fixture'; -import { PhotoPaginatedDtoFixture } from './dto/photo-paginated.dto.fixture'; -import { PhotoUpdateDtoFixture } from './dto/photo-update.dto.fixture'; -import { PhotoDtoFixture } from './dto/photo.dto.fixture'; -import { PhotoEntityInterfaceFixture } from './interfaces/photo-entity.interface.fixture'; -import { PhotoServiceFixture } from './photo.service.fixture'; +import { CrudCreateBatchCommand } from '../../application/commands/impl/crud-create-batch.command.js'; +import { CrudCreateCommand } from '../../application/commands/impl/crud-create.command.js'; +import { CrudDeleteCommand } from '../../application/commands/impl/crud-delete.command.js'; +import { CrudReplaceCommand } from '../../application/commands/impl/crud-replace.command.js'; +import { CrudRestoreCommand } from '../../application/commands/impl/crud-restore.command.js'; +import { CrudSoftDeleteCommand } from '../../application/commands/impl/crud-soft-delete.command.js'; +import { CrudUpdateCommand } from '../../application/commands/impl/crud-update.command.js'; +import { CrudListQuery } from '../../application/queries/impl/crud-list.query.js'; +import { CrudReadQuery } from '../../application/queries/impl/crud-read.query.js'; +import { CrudController } from '../../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudCreateBatch } from '../../infrastructure/decorators/operations/crud-create-batch.decorator.js'; +import { CrudCreate } from '../../infrastructure/decorators/operations/crud-create.decorator.js'; +import { CrudDelete } from '../../infrastructure/decorators/operations/crud-delete.decorator.js'; +import { CrudList } from '../../infrastructure/decorators/operations/crud-list.decorator.js'; +import { CrudRead } from '../../infrastructure/decorators/operations/crud-read.decorator.js'; +import { CrudReplace } from '../../infrastructure/decorators/operations/crud-replace.decorator.js'; +import { CrudRestore } from '../../infrastructure/decorators/operations/crud-restore.decorator.js'; +import { CrudSoftDelete } from '../../infrastructure/decorators/operations/crud-soft-delete.decorator.js'; +import { CrudUpdate } from '../../infrastructure/decorators/operations/crud-update.decorator.js'; +import { CrudBody } from '../../infrastructure/decorators/params/crud-body.decorator.js'; +import { CrudCtx } from '../../infrastructure/interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { type CrudCreateBatchInterface } from '../../infrastructure/interfaces/crud-create-batch.interface.js'; +import { CrudAdapterResolver } from '../../infrastructure/resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../../infrastructure/resolvers/interfaces/crud-resolver.interface.js'; + +import { type PhotoCreatableInterfaceFixture } from './interfaces/photo-creatable.interface.fixture.js'; +import { PhotoEntityInterfaceFixture } from './interfaces/photo-entity.interface.fixture.js'; +import { type PhotoUpdatableInterfaceFixture } from './interfaces/photo-updatable.interface.fixture.js'; +import { + photoCreateBatchResponseSchema, + photoCreateBatchSchema, +} from './schemas/photo-create-batch.schema.fixture.js'; +import { photoCreateSchema } from './schemas/photo-create.schema.fixture.js'; +import { photoPaginatedSchema } from './schemas/photo-paginated.schema.fixture.js'; +import { photoUpdateSchema } from './schemas/photo-update.schema.fixture.js'; +import { photoSchema } from './schemas/photo.schema.fixture.js'; /** * Photo controller. */ @CrudController({ path: 'photo', - model: { - type: PhotoDtoFixture, - paginatedType: PhotoPaginatedDtoFixture, - }, + entity: 'Photo', + request: { body: photoSchema }, + response: { resource: photoSchema, paginated: photoPaginatedSchema }, }) @ApiTags('photo') -export class PhotoControllerFixture - implements - CrudControllerInterface< - PhotoEntityInterfaceFixture, - PhotoCreateDtoFixture, - PhotoUpdateDtoFixture - > -{ - /** - * Constructor. - * - * @param photoService instance of the photo crud service - */ - constructor(private photoService: PhotoServiceFixture) {} +export class PhotoControllerFixture { + constructor( + @Inject(CrudAdapterResolver) + protected readonly crudResolver: CrudResolverInterface, + ) {} - /** - * Get many - * - * @param crudRequest the CRUD request object - */ - @CrudReadAll() - async getMany( - @CrudRequest() - crudRequest: CrudRequestInterface, + @CrudList({ query: CrudListQuery }) + async list( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return this.photoService.getMany(crudRequest); + return this.crudResolver.list(ctx); } - /** - * Get one - * - * @param crudRequest the CRUD request object - */ - @CrudReadOne() - async getOne( - @CrudRequest() - crudRequest: CrudRequestInterface, + @CrudRead({ query: CrudReadQuery }) + async read( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return this.photoService.getOne(crudRequest); + return this.crudResolver.read(ctx); } - /** - * Create many - * - * @param crudRequest the CRUD request object - * @param photoCreateManyDto photo create many dto - */ - @CrudCreateMany() - async createMany( - @CrudRequest() - crudRequest: CrudRequestInterface, - @CrudBody() photoCreateManyDto: PhotoCreateManyDtoFixture, + @CrudCreateBatch({ + command: CrudCreateBatchCommand, + request: { bodyBatch: photoCreateBatchSchema }, + response: { + serialization: { resource: photoCreateBatchResponseSchema }, + }, + }) + async createBatch( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + // Explicit schema — validation would also resolve from this operation's + // `request.body`/`bodyBatch` fallback; passing it here pins it on the + // parameter itself. + @CrudBody({ schema: photoCreateBatchSchema }) + photoCreateBatchDto: CrudCreateBatchInterface, ) { - return this.photoService.createMany(crudRequest, photoCreateManyDto); + return this.crudResolver.createBatch(ctx, photoCreateBatchDto); } - /** - * Create one - * - * @param crudRequest the CRUD request object - * @param photoCreateDto photo create dto - */ - @CrudCreateOne() - async createOne( - @CrudRequest() - crudRequest: CrudRequestInterface, - @CrudBody() photoCreateDto: PhotoCreateDtoFixture, + // `request.body` overrides the controller-level default for this + // operation's validation and docs — `photoCreateSchema`, not the full + // `photoSchema`, because the real Create payload never carries + // `id`/`deletedAt`. + @CrudCreate({ + command: CrudCreateCommand, + request: { body: photoCreateSchema }, + }) + async create( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + @CrudBody({ schema: photoCreateSchema }) + photoCreateDto: PhotoCreatableInterfaceFixture, ) { - return this.photoService.createOne(crudRequest, photoCreateDto); + return this.crudResolver.create(ctx, photoCreateDto); } - /** - * Update one - * - * @param crudRequest the CRUD request object - * @param photoUpdateDto photo update dto - */ - @CrudUpdateOne() - async updateOne( - @CrudRequest() - crudRequest: CrudRequestInterface, - @CrudBody() photoUpdateDto: PhotoUpdateDtoFixture, + @CrudUpdate({ + command: CrudUpdateCommand, + request: { body: photoUpdateSchema }, + }) + async update( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + @CrudBody({ schema: photoUpdateSchema }) + photoUpdateDto: PhotoUpdatableInterfaceFixture, ) { - return this.photoService.updateOne(crudRequest, photoUpdateDto); + return this.crudResolver.update(ctx, photoUpdateDto); } - /** - * Replace one - * - * @param crudRequest the CRUD request object - */ - @CrudReplaceOne() - async replaceOne( - @CrudRequest() - crudRequest: CrudRequestInterface, - @CrudBody() photoCreateDto: PhotoCreateDtoFixture, + @CrudReplace({ + command: CrudReplaceCommand, + request: { body: photoUpdateSchema }, + }) + async replace( + @Ctx(CrudCtx) + ctx: CrudContextInterface, + @CrudBody({ schema: photoUpdateSchema }) + photoCreateDto: PhotoCreatableInterfaceFixture, ) { - return this.photoService.replaceOne(crudRequest, photoCreateDto); + return this.crudResolver.replace(ctx, photoCreateDto); } - /** - * Delete one - * - * @param crudRequest the CRUD request object - */ - @CrudDeleteOne() - async deleteOne( - @CrudRequest() - crudRequest: CrudRequestInterface, + @CrudDelete({ command: CrudDeleteCommand }) + async delete( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return this.photoService.deleteOne(crudRequest); + return this.crudResolver.delete(ctx); } - /** - * Delete one (soft) - * - * @param crudRequest the CRUD request object - */ - @CrudDeleteOne({ path: 'soft/:id' }) - @CrudSoftDelete(true) - async deleteOneSoft( - @CrudRequest() - crudRequest: CrudRequestInterface, + @CrudSoftDelete({ path: 'soft/:id', command: CrudSoftDeleteCommand }) + async softDelete( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return this.photoService.deleteOne(crudRequest); + return this.crudResolver.softDelete(ctx); } - /** - * Recover one - * - * @param crudRequest the CRUD request object - */ - @CrudRecoverOne() - async recoverOne( - @CrudRequest() - crudRequest: CrudRequestInterface, + @CrudRestore({ command: CrudRestoreCommand }) + async restore( + @Ctx(CrudCtx) + ctx: CrudContextInterface, ) { - return this.photoService.recoverOne(crudRequest); + return this.crudResolver.restore(ctx); } } diff --git a/packages/nestjs-crud/src/__fixtures__/photo/photo.entity.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/photo.entity.fixture.ts index 8d9a3d558..73e59d32b 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo/photo.entity.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo/photo.entity.fixture.ts @@ -5,9 +5,9 @@ import { PrimaryGeneratedColumn, } from 'typeorm'; -import { ReferenceId } from '@concepta/nestjs-common'; +import { ReferenceId } from '@concepta/nestjs-core'; -import { PhotoEntityInterfaceFixture } from './interfaces/photo-entity.interface.fixture'; +import { PhotoEntityInterfaceFixture } from './interfaces/photo-entity.interface.fixture.js'; @Entity() export class PhotoFixture implements PhotoEntityInterfaceFixture { diff --git a/packages/nestjs-crud/src/__fixtures__/photo/photo.factory.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/photo.factory.fixture.ts index 5147a16f1..dccc5e385 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo/photo.factory.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo/photo.factory.fixture.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; import { Factory } from '@concepta/typeorm-seeding'; -import { PhotoFixture } from './photo.entity.fixture'; +import { PhotoFixture } from './photo.entity.fixture.js'; export class PhotoFactoryFixture extends Factory { protected options = { entity: PhotoFixture }; diff --git a/packages/nestjs-crud/src/__fixtures__/photo/photo.module.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/photo.module.fixture.ts index 5319b6e3c..6b3d3c3b4 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo/photo.module.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo/photo.module.fixture.ts @@ -1,30 +1,33 @@ import { DynamicModule, Module } from '@nestjs/common'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; -import { CrudModule } from '../../crud.module'; -import { CRUD_TEST_PHOTO_ENTITY_KEY } from '../crud-test.constants'; +import { CrudModule } from '../../crud.module.js'; +import { CRUD_TEST_PHOTO_ENTITY_NAME } from '../crud-test.constants.js'; -import { PhotoTypeOrmCrudAdapterFixture } from './photo-typeorm-crud.adapter.fixture'; -import { PhotoControllerFixture } from './photo.controller.fixture'; -import { PhotoFixture } from './photo.entity.fixture'; -import { PhotoServiceFixture } from './photo.service.fixture'; +import { PhotoEntityInterfaceFixture } from './interfaces/photo-entity.interface.fixture.js'; +import { PhotoControllerFixture } from './photo.controller.fixture.js'; +import { PhotoFixture } from './photo.entity.fixture.js'; -@Module({ - providers: [PhotoTypeOrmCrudAdapterFixture, PhotoServiceFixture], - controllers: [PhotoControllerFixture], -}) +@Module({}) export class PhotoModuleFixture { static register(): DynamicModule { return { module: PhotoModuleFixture, imports: [ CrudModule.forRoot({}), - TypeOrmExtModule.forFeature({ - [CRUD_TEST_PHOTO_ENTITY_KEY]: { - entity: PhotoFixture, + CrudModule.forFeature({ + crud: { + controller: { class: PhotoControllerFixture }, }, }), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: CRUD_TEST_PHOTO_ENTITY_NAME, entity: PhotoFixture }, + ], + }), ], }; } diff --git a/packages/nestjs-crud/src/__fixtures__/photo/photo.seeder.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/photo.seeder.fixture.ts index f022ec0d7..ff55f89ce 100644 --- a/packages/nestjs-crud/src/__fixtures__/photo/photo.seeder.fixture.ts +++ b/packages/nestjs-crud/src/__fixtures__/photo/photo.seeder.fixture.ts @@ -1,6 +1,6 @@ import { Seeder } from '@concepta/typeorm-seeding'; -import { PhotoFactoryFixture } from './photo.factory.fixture'; +import { PhotoFactoryFixture } from './photo.factory.fixture.js'; export class PhotoSeederFixture extends Seeder { public async run(): Promise { diff --git a/packages/nestjs-crud/src/__fixtures__/photo/photo.service.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/photo.service.fixture.ts deleted file mode 100644 index d6a30610d..000000000 --- a/packages/nestjs-crud/src/__fixtures__/photo/photo.service.fixture.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { CrudAdapter } from '../../crud/adapters/crud.adapter'; -import { CrudService } from '../../services/crud.service'; - -import { PhotoTypeOrmCrudAdapterFixture } from './photo-typeorm-crud.adapter.fixture'; -import { PhotoFixture } from './photo.entity.fixture'; - -/** - * Photo CRUD service - */ -@Injectable() -export class PhotoServiceFixture extends CrudService { - constructor( - @Inject(PhotoTypeOrmCrudAdapterFixture) - crudAdapter: CrudAdapter, - ) { - super(crudAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-create-batch.schema.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-create-batch.schema.fixture.ts new file mode 100644 index 000000000..f32fd099e --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-create-batch.schema.fixture.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +import { createBatchSchema } from '../../../infrastructure/schemas/crud-create-batch.schema.js'; + +import { photoCreateSchema } from './photo-create.schema.fixture.js'; +import { photoSchema } from './photo.schema.fixture.js'; + +export const photoCreateBatchSchema = withOpenApi( + createBatchSchema(photoCreateSchema), +); + +/** + * Response shape for the CreateBatch endpoint — a bare array of created + * photos. Needed because `crud-serialize.interceptor.ts` validates the + * response against `response.serialization.resource` verbatim; the + * controller-level `response.resource` schema is a single-item schema, so + * CreateBatch's operation-level `response` option must supply this array + * schema explicitly. + */ +export const photoCreateBatchResponseSchema = z.array(photoSchema); diff --git a/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-create.schema.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-create.schema.fixture.ts new file mode 100644 index 000000000..52117fdb8 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-create.schema.fixture.ts @@ -0,0 +1,16 @@ +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type PhotoCreatableInterfaceFixture } from '../interfaces/photo-creatable.interface.fixture.js'; + +import { photoSchema } from './photo.schema.fixture.js'; + +export const photoCreateSchema = withOpenApi( + conformsTo()( + photoSchema.pick({ + name: true, + description: true, + filename: true, + isPublished: true, + }), + ), +); diff --git a/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-paginated.schema.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-paginated.schema.fixture.ts new file mode 100644 index 000000000..d61a48dc7 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-paginated.schema.fixture.ts @@ -0,0 +1,10 @@ +import { withNamedComponent } from '@concepta/nestjs-core'; + +import { paginatedSchema } from '../../../infrastructure/schemas/crud-response-paginated.schema.js'; + +import { photoSchema } from './photo.schema.fixture.js'; + +export const photoPaginatedSchema = withNamedComponent( + paginatedSchema(photoSchema), + 'PhotoPaginated', +); diff --git a/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-update.schema.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-update.schema.fixture.ts new file mode 100644 index 000000000..36ae729cb --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo-update.schema.fixture.ts @@ -0,0 +1,22 @@ +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type PhotoUpdatableInterfaceFixture } from '../interfaces/photo-updatable.interface.fixture.js'; + +import { photoSchema } from './photo.schema.fixture.js'; + +/** + * All fields are REQUIRED here — the original `PhotoUpdateDtoFixture` used + * `PickType` (not `PartialType`) despite being an "update" DTO, so no field + * was actually optional. Reproduced faithfully. + */ +export const photoUpdateSchema = withOpenApi( + conformsTo()( + photoSchema.pick({ + name: true, + description: true, + filename: true, + isPublished: true, + views: true, + }), + ), +); diff --git a/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo.schema.fixture.ts b/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo.schema.fixture.ts new file mode 100644 index 000000000..b2721d3c2 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/photo/schemas/photo.schema.fixture.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { + conformsTo, + referenceIdSchema, + withNamedComponent, +} from '@concepta/nestjs-core'; + +import { type PhotoEntityInterfaceFixture } from '../interfaces/photo-entity.interface.fixture.js'; + +export const photoSchema = withNamedComponent( + conformsTo()( + referenceIdSchema.extend({ + name: z.string(), + description: z.string(), + filename: z.string(), + views: z.number(), + isPublished: z.boolean(), + deletedAt: z.date().nullable(), + }), + ), + 'Photo', +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/base-entity.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/base-entity.ts index c1c9b57b0..3a83448e1 100644 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/base-entity.ts +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/base-entity.ts @@ -1,4 +1,3 @@ -import { Expose } from 'class-transformer'; import { PrimaryGeneratedColumn, CreateDateColumn, @@ -7,7 +6,6 @@ import { export class BaseEntity { @PrimaryGeneratedColumn() - @Expose() id?: number; @CreateDateColumn({ nullable: true }) diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/company-crud.service.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/company-crud.service.ts deleted file mode 100644 index d1378b7f4..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/company/company-crud.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { forwardRef, Inject, Injectable, Optional } from '@nestjs/common'; - -import { CrudRelationRegistry } from '../../../services/crud-relation.registry'; -import { CrudService } from '../../../services/crud.service'; -import { UserEntity } from '../users/user.entity'; - -import { CompanyTypeOrmCrudAdapter } from './company-typeorm-crud.adapter'; -import { CompanyEntity } from './company.entity'; - -@Injectable() -export class CompanyCrudService extends CrudService< - CompanyEntity, - [UserEntity] -> { - constructor( - crudAdapter: CompanyTypeOrmCrudAdapter, - @Optional() - @Inject(forwardRef(() => 'COMPANY_RELATION_REGISTRY')) - relationRegistry?: CrudRelationRegistry, - ) { - super(crudAdapter, relationRegistry); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/company-typeorm-crud.adapter.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/company-typeorm-crud.adapter.ts deleted file mode 100644 index b6ee8d5fd..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/company/company-typeorm-crud.adapter.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { InjectDynamicRepository } from '@concepta/nestjs-common'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { TypeOrmCrudAdapter } from '../../../crud/adapters/typeorm-crud.adapter'; -import { CRUD_TEST_COMPANY_ENTITY_KEY } from '../../crud-test.constants'; - -import { CompanyEntity } from './company.entity'; - -@Injectable() -export class CompanyTypeOrmCrudAdapter extends TypeOrmCrudAdapter { - constructor( - @InjectDynamicRepository(CRUD_TEST_COMPANY_ENTITY_KEY) - repoAdapter: TypeOrmRepositoryAdapter, - ) { - super(repoAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/company.entity.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/company.entity.ts index e3dcf1ed9..b145bb656 100644 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/company/company.entity.ts +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/company/company.entity.ts @@ -3,10 +3,12 @@ import { Column, PrimaryGeneratedColumn, DeleteDateColumn, + OneToMany, } from 'typeorm'; -import { BaseEntity } from '../base-entity'; -import { UserEntity } from '../users/user.entity'; +import { BaseEntity } from '../base-entity.js'; +import { ProjectEntity } from '../project/project.entity.js'; +import { UserEntity } from '../users/user.entity.js'; @Entity('companies') export class CompanyEntity extends BaseEntity { @@ -25,5 +27,9 @@ export class CompanyEntity extends BaseEntity { @DeleteDateColumn({ nullable: true }) deletedAt?: Date; + @OneToMany(() => UserEntity, (user) => user.company) users?: UserEntity[]; + + @OneToMany(() => ProjectEntity, (project) => project.company) + projects?: ProjectEntity[]; } diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-create-many.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-create-many.dto.ts deleted file mode 100644 index 6ea343479..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-create-many.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Expose, Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray, ValidateNested } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CrudCreateManyInterface } from '../../../../crud/interfaces/crud-create-many.interface'; - -import { CompanyCreateDto } from './company-create.dto'; - -export class CompanyCreateManyDto - implements CrudCreateManyInterface -{ - @Expose() - @ApiProperty({ type: CompanyCreateDto, isArray: true }) - @IsArray() - @ArrayNotEmpty() - @ValidateNested({ each: true }) - @Type(() => CompanyCreateDto) - bulk: CompanyCreateDto[] = []; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-create.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-create.dto.ts deleted file mode 100644 index 32273b5ae..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-create.dto.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsOptional, IsString, MaxLength } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -export class CompanyCreateDto { - @Expose() - @ApiProperty({ type: 'string' }) - @IsString() - @MaxLength(100) - name!: string; - - @Expose() - @ApiProperty({ type: 'string' }) - @IsString() - @MaxLength(100) - @IsOptional() - domain!: string; - - @Expose() - @ApiProperty({ type: 'string' }) - @IsOptional() - @IsString() - @MaxLength(100) - @IsOptional() - description!: string; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-paginated.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-paginated.dto.ts deleted file mode 100644 index 6bd163b3d..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-paginated.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CrudResponsePaginatedDto } from '../../../../crud/dto/crud-response-paginated.dto'; - -import { CompanyDto } from './company.dto'; - -export class CompanyPaginatedDto extends CrudResponsePaginatedDto { - @ApiProperty({ - type: CompanyDto, - isArray: true, - }) - @Type(() => CompanyDto) - data!: CompanyDto[]; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-update.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-update.dto.ts deleted file mode 100644 index b4b158c83..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company-update.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsOptional, IsString, MaxLength } from 'class-validator'; - -import { ApiPropertyOptional } from '@nestjs/swagger'; - -export class CompanyUpdateDto { - @Expose() - @ApiPropertyOptional({ type: 'string' }) - @IsString() - @MaxLength(100) - @IsOptional() - name?: string; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company.dto.ts deleted file mode 100644 index 55f678a49..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/company/dto/company.dto.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { UserDto } from '../../users/dto/user.dto'; - -export class CompanyDto { - @Expose() - @ApiProperty({ type: 'number' }) - id!: string; - - @Expose() - @ApiProperty({ type: 'string' }) - name!: string; - - @Expose() - @ApiProperty({ type: 'string' }) - domain!: string; - - @Expose() - @ApiProperty({ type: 'string' }) - description!: string; - - @Exclude() - createdAt!: string; - - @Exclude() - updatedAt!: string; - - @Expose() - @Type(() => UserDto) - users?: UserDto[]; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-create-batch-response.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-create-batch-response.schema.ts new file mode 100644 index 000000000..919a08107 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-create-batch-response.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { companySchema } from './company.schema.js'; + +/** + * Response shape for the CreateBatch endpoint — a bare array of created + * companies. Needed because `crud-serialize.interceptor.ts` validates the + * response against `response.serialization.resource` verbatim; the + * controller-level `response.resource` schema is a single-item schema, so + * CreateBatch's operation-level `response` option must supply this array + * schema explicitly. Mirrors the identical pattern already established for + * the photo fixture (`photoCreateBatchResponseSchema`). + */ +export const companyCreateBatchResponseSchema = z.array(companySchema); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-create-batch.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-create-batch.schema.ts new file mode 100644 index 000000000..76b2fdb18 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-create-batch.schema.ts @@ -0,0 +1,9 @@ +import { withOpenApi } from '@concepta/nestjs-core'; + +import { createBatchSchema } from '../../../../infrastructure/schemas/crud-create-batch.schema.js'; + +import { companyCreateSchema } from './company-create.schema.js'; + +export const companyCreateBatchSchema = withOpenApi( + createBatchSchema(companyCreateSchema), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-create.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-create.schema.ts new file mode 100644 index 000000000..9ef5608e2 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-create.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `CompanyCreateDto`. Per the Phase 3 plan + * decision, this fixture's optionality is FIXED to match `CompanyEntity` + * rather than faithfully reproduced: `domain` was `@IsOptional()` in the + * legacy DTO despite `CompanyEntity.domain` being `nullable: false` and + * `unique: true` — a real DTO/entity mismatch with no external contract + * to preserve, so it is now required. `description` stays optional, + * matching both the legacy `@IsOptional()` and `CompanyEntity.description` + * (`nullable: true`) — no mismatch there. + */ +export const companyCreateSchema = withOpenApi( + z.object({ + name: z.string().max(100), + domain: z.string().max(100), + description: z.string().max(100).optional(), + }), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-paginated.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-paginated.schema.ts new file mode 100644 index 000000000..4543b6c3c --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-paginated.schema.ts @@ -0,0 +1,9 @@ +import { withOpenApi } from '@concepta/nestjs-core'; + +import { paginatedSchema } from '../../../../infrastructure/schemas/crud-response-paginated.schema.js'; + +import { companySchema } from './company.schema.js'; + +export const companyPaginatedSchema = withOpenApi( + paginatedSchema(companySchema), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-update.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-update.schema.ts new file mode 100644 index 000000000..999a7ed3a --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company-update.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `CompanyUpdateDto` — faithful reproduction, + * `name` stays optional (a genuine partial-update field, not a + * DTO/entity mismatch like `CompanyCreateDto.domain`). + */ +export const companyUpdateSchema = withOpenApi( + z.object({ + name: z.string().max(100).optional(), + }), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company.schema.ts new file mode 100644 index 000000000..48548fb58 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/company/schemas/company.schema.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +import { type UserType } from '../../users/schemas/user.schema.js'; +import { userSchema } from '../../users/schemas/user.schema.js'; + +/** + * Explicit shape, not `z.infer`-derived — `Company`/`User` are mutually + * nested (`company.users` ↔ `user.company`, a genuine circular reference + * between this file and `user.schema.ts`), so each side's schema needs an + * explicit type annotation to break TypeScript's circular inference + * (`z.lazy()` alone only defers the RUNTIME reference, not the type). + */ +export interface CompanyType { + id?: number; + name: string; + domain: string; + description: string | null; + users?: UserType[]; +} + +/** + * Zod equivalent of the legacy `CompanyDto` — no domain interface exists + * for these TypeORM test fixtures, so there is no `conformsTo` to apply. + * `users` is wrapped in `z.lazy()` because of the circular reference + * described above. `description` is `.nullable()` (not just typed + * `string` like the legacy DTO) because `CompanyEntity.description` is + * `nullable: true, default: null` and the seed fixtures never set it — + * the legacy class-transformer path never actually validated this on the + * way out, but the schema-based serializer does (fail-closed), so this + * must reflect real persisted data, not just the DTO's declared type. + */ +export const companySchema: z.ZodType = withOpenApi( + z.object({ + id: z.number().optional(), + name: z.string(), + domain: z.string(), + description: z.string().nullable(), + users: z.array(z.lazy(() => userSchema)).optional(), + }), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/device/device-crud.service.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/device/device-crud.service.ts deleted file mode 100644 index 03209ea14..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/device/device-crud.service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { CrudService } from '../../../services/crud.service'; - -import { DeviceTypeOrmCrudAdapter } from './device-typeorm-crud.adapter'; -import { DeviceEntity } from './device.entity'; - -@Injectable() -export class DeviceCrudService extends CrudService { - constructor(crudAdapter: DeviceTypeOrmCrudAdapter) { - super(crudAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/device/device-typeorm-crud.adapter.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/device/device-typeorm-crud.adapter.ts deleted file mode 100644 index f889d9da1..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/device/device-typeorm-crud.adapter.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { InjectDynamicRepository } from '@concepta/nestjs-common'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { TypeOrmCrudAdapter } from '../../../crud/adapters/typeorm-crud.adapter'; -import { CRUD_TEST_DEVICE_ENTITY_KEY } from '../../crud-test.constants'; - -import { DeviceEntity } from './device.entity'; - -@Injectable() -export class DeviceTypeOrmCrudAdapter extends TypeOrmCrudAdapter { - constructor( - @InjectDynamicRepository(CRUD_TEST_DEVICE_ENTITY_KEY) - repoAdapter: TypeOrmRepositoryAdapter, - ) { - super(repoAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/device/dto/device-create.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/device/dto/device-create.dto.ts deleted file mode 100644 index c47a48442..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/device/dto/device-create.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsOptional, IsString, IsUUID } from 'class-validator'; - -export class DeviceCreateDto { - @Expose() - @IsOptional() - @IsUUID('4') - deviceKey!: string; - - @Expose() - @IsOptional() - @IsString() - description?: string; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/device/dto/device.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/device/dto/device.dto.ts deleted file mode 100644 index 74d0d7189..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/device/dto/device.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Expose } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -export class DeviceDto { - @Expose() - @ApiProperty({ type: 'string' }) - deviceKey!: string; - - @Expose() - description?: string; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/device/schemas/device-create.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/device/schemas/device-create.schema.ts new file mode 100644 index 000000000..1ea7d7750 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/device/schemas/device-create.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `DeviceCreateDto` — `deviceKey` stays + * optional (faithful reproduction, and NOT a bug per the Phase 3 plan + * decision): `DeviceEntity.deviceKey` is a `@PrimaryGeneratedColumn('uuid')`, + * normally server-generated, so client-optional is correct. + */ +export const deviceCreateSchema = withOpenApi( + z.object({ + deviceKey: z.uuid().optional(), + description: z.string().optional(), + }), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/device/schemas/device.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/device/schemas/device.schema.ts new file mode 100644 index 000000000..13f00846a --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/device/schemas/device.schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `DeviceDto` — no domain interface exists + * for these TypeORM test fixtures, so there is no `conformsTo` to apply. + * + * `description` is `.nullable()`, not just `.optional()`: `DeviceEntity.description` + * is a `nullable: true` column with no default, so an unset value reads + * back from the DB as `null`, not `undefined` — matches the + * `CompanyEntity.description`/`UserEntity.deletedAt` fidelity fix elsewhere + * in these fixtures. + */ +export const deviceSchema = withOpenApi( + z.object({ + deviceKey: z.string(), + description: z.string().nullable().optional(), + }), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/note/dto/note-paginated.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/note/dto/note-paginated.dto.ts deleted file mode 100644 index 70a309194..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/note/dto/note-paginated.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CrudResponsePaginatedDto } from '../../../../crud/dto/crud-response-paginated.dto'; - -import { NoteDto } from './note.dto'; - -export class NotePaginatedDto extends CrudResponsePaginatedDto { - @ApiProperty({ - type: NoteDto, - isArray: true, - }) - @Type(() => NoteDto) - data!: NoteDto[]; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/note/dto/note.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/note/dto/note.dto.ts deleted file mode 100644 index 58c1230ec..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/note/dto/note.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsNumber } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -export class NoteDto { - @Expose() - @ApiProperty({ type: 'number' }) - @IsNumber() - id!: string; - - @Expose() - @ApiProperty({ type: 'number' }) - @IsNumber() - revisionId!: string; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/note/note-crud.service.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/note/note-crud.service.ts deleted file mode 100644 index 0e39fce12..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/note/note-crud.service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { CrudService } from '../../../services/crud.service'; - -import { NoteTypeOrmCrudAdapter } from './note-typeorm-crud.adapter'; -import { NoteEntity } from './note.entity'; - -@Injectable() -export class NoteCrudService extends CrudService { - constructor(crudAdapter: NoteTypeOrmCrudAdapter) { - super(crudAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/note/note-typeorm-crud.adapter.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/note/note-typeorm-crud.adapter.ts deleted file mode 100644 index 4f80eb3cc..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/note/note-typeorm-crud.adapter.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { InjectDynamicRepository } from '@concepta/nestjs-common'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { TypeOrmCrudAdapter } from '../../../crud/adapters/typeorm-crud.adapter'; -import { CRUD_TEST_NOTE_ENTITY_KEY } from '../../crud-test.constants'; - -import { NoteEntity } from './note.entity'; - -@Injectable() -export class NoteTypeOrmCrudAdapter extends TypeOrmCrudAdapter { - constructor( - @InjectDynamicRepository(CRUD_TEST_NOTE_ENTITY_KEY) - repoAdapter: TypeOrmRepositoryAdapter, - ) { - super(repoAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/note/schemas/note-paginated.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/note/schemas/note-paginated.schema.ts new file mode 100644 index 000000000..8ec6fb6a8 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/note/schemas/note-paginated.schema.ts @@ -0,0 +1,7 @@ +import { withOpenApi } from '@concepta/nestjs-core'; + +import { paginatedSchema } from '../../../../infrastructure/schemas/crud-response-paginated.schema.js'; + +import { noteSchema } from './note.schema.js'; + +export const notePaginatedSchema = withOpenApi(paginatedSchema(noteSchema)); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/note/schemas/note.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/note/schemas/note.schema.ts new file mode 100644 index 000000000..69d5745e3 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/note/schemas/note.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `NoteDto` — no domain interface exists for + * these TypeORM test fixtures, so there is no `conformsTo` to apply. + */ +export const noteSchema = withOpenApi( + z.object({ + id: z.number(), + revisionId: z.number(), + }), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/orm.sqlite.config.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/orm.sqlite.config.ts index bb653f616..67106c651 100644 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/orm.sqlite.config.ts +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/orm.sqlite.config.ts @@ -1,10 +1,22 @@ -import { join } from 'path'; +import { type TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { CompanyEntity } from './company/company.entity.js'; +import { DeviceEntity } from './device/device.entity.js'; +import { NoteEntity } from './note/note.entity.js'; +import { ProjectEntity } from './project/project.entity.js'; +import { UserProfileEntity } from './user-profile/user-profile.entity.js'; +import { UserEntity } from './users/user.entity.js'; export const ormSqliteConfig: TypeOrmModuleOptions = { type: 'sqlite', database: ':memory:', - entities: [join(__dirname, './**/*.entity{.ts,.js}')], + entities: [ + CompanyEntity, + DeviceEntity, + NoteEntity, + ProjectEntity, + UserProfileEntity, + UserEntity, + ], synchronize: true, }; diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/project/dto/project-create.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/project/dto/project-create.dto.ts deleted file mode 100644 index 473e42037..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/project/dto/project-create.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Expose } from 'class-transformer'; -import { - IsOptional, - IsString, - IsNumber, - MaxLength, - IsBoolean, -} from 'class-validator'; - -export class ProjectCreateDto { - @Expose() - @IsOptional() - @IsString() - @MaxLength(100) - name!: string; - - @Expose() - @IsOptional() - description?: string; - - @Expose() - @IsOptional() - @IsBoolean() - isActive?: boolean; - - @Expose() - @IsOptional() - @IsNumber() - companyId?: number; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/project/dto/project-paginated.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/project/dto/project-paginated.dto.ts deleted file mode 100644 index 08fbf4883..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/project/dto/project-paginated.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CrudResponsePaginatedDto } from '../../../../crud/dto/crud-response-paginated.dto'; - -import { ProjectDto } from './project.dto'; - -export class ProjectPaginatedDto extends CrudResponsePaginatedDto { - @ApiProperty({ - type: ProjectDto, - isArray: true, - }) - @Type(() => ProjectDto) - data!: ProjectDto[]; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/project/dto/project.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/project/dto/project.dto.ts deleted file mode 100644 index bb3a6ca5b..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/project/dto/project.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Expose } from 'class-transformer'; - -export class ProjectDto { - @Expose() - id!: string; - - @Expose() - name?: string; - - @Expose() - description?: string; - - @Expose() - isActive?: boolean; - - @Expose() - companyId?: number; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/project/project-crud.service.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/project/project-crud.service.ts deleted file mode 100644 index cd979d982..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/project/project-crud.service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { CrudService } from '../../../services/crud.service'; - -import { ProjectTypeOrmCrudAdapter } from './project-typeorm-crud.adapter'; -import { ProjectEntity } from './project.entity'; - -@Injectable() -export class ProjectCrudService extends CrudService { - constructor(crudAdapter: ProjectTypeOrmCrudAdapter) { - super(crudAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/project/project-typeorm-crud.adapter.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/project/project-typeorm-crud.adapter.ts deleted file mode 100644 index 4922fe7c3..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/project/project-typeorm-crud.adapter.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { InjectDynamicRepository } from '@concepta/nestjs-common'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { TypeOrmCrudAdapter } from '../../../crud/adapters/typeorm-crud.adapter'; -import { CRUD_TEST_PROJECT_ENTITY_KEY } from '../../crud-test.constants'; - -import { ProjectEntity } from './project.entity'; - -@Injectable() -export class ProjectTypeOrmCrudAdapter extends TypeOrmCrudAdapter { - constructor( - @InjectDynamicRepository(CRUD_TEST_PROJECT_ENTITY_KEY) - repoAdapter: TypeOrmRepositoryAdapter, - ) { - super(repoAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/project/project.entity.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/project/project.entity.ts index c0aed78f1..ffbd162d9 100644 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/project/project.entity.ts +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/project/project.entity.ts @@ -1,6 +1,7 @@ -import { Entity, Column } from 'typeorm'; +import { Entity, Column, JoinColumn, ManyToOne } from 'typeorm'; -import { BaseEntity } from '../base-entity'; +import { BaseEntity } from '../base-entity.js'; +import { CompanyEntity } from '../company/company.entity.js'; @Entity('projects') export class ProjectEntity extends BaseEntity { @@ -15,4 +16,10 @@ export class ProjectEntity extends BaseEntity { @Column({ nullable: false }) companyId?: number; + + @ManyToOne(() => CompanyEntity, (company) => company.projects, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'companyId' }) + company?: CompanyEntity; } diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/project/schemas/project-create.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/project/schemas/project-create.schema.ts new file mode 100644 index 000000000..5ef2a532e --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/project/schemas/project-create.schema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `ProjectCreateDto`. Per the Phase 3 plan + * decision, `name` is FIXED to required rather than faithfully + * reproduced: it was `@IsOptional()` in the legacy DTO despite + * `ProjectEntity.name` being `nullable: false, unique: true` — a real + * DTO/entity mismatch with no external contract to preserve. The other + * fields stay optional, matching both the legacy decorators and the + * entity (`description`/`isActive` are nullable/defaulted; + * `companyId` is set separately by the handler, not client-supplied here). + */ +export const projectCreateSchema = withOpenApi( + z.object({ + name: z.string().max(100), + description: z.string().optional(), + isActive: z.boolean().optional(), + companyId: z.number().optional(), + }), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/project/schemas/project-paginated.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/project/schemas/project-paginated.schema.ts new file mode 100644 index 000000000..d799d3ea0 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/project/schemas/project-paginated.schema.ts @@ -0,0 +1,9 @@ +import { withOpenApi } from '@concepta/nestjs-core'; + +import { paginatedSchema } from '../../../../infrastructure/schemas/crud-response-paginated.schema.js'; + +import { projectSchema } from './project.schema.js'; + +export const projectPaginatedSchema = withOpenApi( + paginatedSchema(projectSchema), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/project/schemas/project.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/project/schemas/project.schema.ts new file mode 100644 index 000000000..256be4409 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/project/schemas/project.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `ProjectDto` — no domain interface exists + * for these TypeORM test fixtures, so there is no `conformsTo` to apply. + */ +export const projectSchema = withOpenApi( + z.object({ + id: z.number().optional(), + name: z.string().optional(), + description: z.string().optional(), + isActive: z.boolean().optional(), + companyId: z.number().optional(), + }), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/seeds.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/seeds.ts index 4e5708fbc..1ebaae830 100644 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/seeds.ts +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/seeds.ts @@ -1,26 +1,24 @@ -import { plainToClass } from 'class-transformer'; -import { MigrationInterface, Repository, QueryRunner } from 'typeorm'; +import { + type DeepPartial, + type MigrationInterface, + type Repository, + type QueryRunner, +} from 'typeorm'; -import { PlainLiteralObject, Type } from '@nestjs/common'; +import { type PlainLiteralObject } from '@nestjs/common'; -import { CompanyEntity } from './company/company.entity'; -import { NoteEntity } from './note/note.entity'; -import { ProjectEntity } from './project/project.entity'; -import { UserProfileEntity } from './user-profile/user-profile.entity'; -import { NameEntity, UserEntity } from './users/user.entity'; +import { CompanyEntity } from './company/company.entity.js'; +import { NoteEntity } from './note/note.entity.js'; +import { ProjectEntity } from './project/project.entity.js'; +import { UserProfileEntity } from './user-profile/user-profile.entity.js'; +import { UserEntity } from './users/user.entity.js'; export class Seeds implements MigrationInterface { private save( repo: Repository, - data: Partial[], + data: DeepPartial[], ): Promise { - return repo.save( - data.map((partial: Partial) => - plainToClass(repo.target as Type, partial, { - ignoreDecorators: true, - }), - ), - ); + return repo.save(data.map((partial) => repo.create(partial))); } public async up(queryRunner: QueryRunner): Promise { @@ -171,134 +169,113 @@ export class Seeds implements MigrationInterface { ]); // users - const name: NameEntity = { first: '', last: '' }; - const name1: NameEntity = { first: 'firstname1', last: 'lastname1' }; await this.save(usersRepo, [ { email: '1@email.com', isActive: true, companyId: 1, - name: name1, + firstName: 'firstname1', + lastName: 'lastname1', }, { email: '2@email.com', isActive: true, companyId: 1, - name, }, { email: '3@email.com', isActive: true, companyId: 1, - name, }, { email: '4@email.com', isActive: true, companyId: 1, - name, }, { email: '5@email.com', isActive: true, companyId: 1, - name, }, { email: '6@email.com', isActive: true, companyId: 1, - name, }, { email: '7@email.com', isActive: false, companyId: 1, - name, }, { email: '8@email.com', isActive: false, companyId: 1, - name, }, { email: '9@email.com', isActive: false, companyId: 1, - name, }, { email: '10@email.com', isActive: true, companyId: 1, - name, }, { email: '11@email.com', isActive: true, companyId: 2, - name, }, { email: '12@email.com', isActive: true, companyId: 2, - name, }, { email: '13@email.com', isActive: true, companyId: 2, - name, }, { email: '14@email.com', isActive: true, companyId: 2, - name, }, { email: '15@email.com', isActive: true, companyId: 2, - name, }, { email: '16@email.com', isActive: true, companyId: 2, - name, }, { email: '17@email.com', isActive: false, companyId: 2, - name, }, { email: '18@email.com', isActive: false, companyId: 2, - name, }, { email: '19@email.com', isActive: false, companyId: 2, - name, }, { email: '20@email.com', isActive: false, companyId: 2, - name, }, { email: '21@email.com', isActive: false, companyId: 2, - name, }, ]); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/dto/user-profile-create.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/dto/user-profile-create.dto.ts deleted file mode 100644 index 2731b9793..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/dto/user-profile-create.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Expose } from 'class-transformer'; -import { IsOptional, IsString, IsNumber, MaxLength } from 'class-validator'; - -export class UserProfileCreateDto { - @Expose() - @IsNumber() - userId!: number; - - @Expose() - @IsOptional() - @IsString() - @MaxLength(100) - nickName?: string; - - @Expose() - @IsOptional() - @IsString() - @MaxLength(50) - favoriteColor?: string; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/dto/user-profile-paginated.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/dto/user-profile-paginated.dto.ts deleted file mode 100644 index 6eb484ec3..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/dto/user-profile-paginated.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CrudResponsePaginatedDto } from '../../../../crud/dto/crud-response-paginated.dto'; - -import { UserProfileDto } from './user-profile.dto'; - -export class UserProfilePaginatedDto extends CrudResponsePaginatedDto { - @ApiProperty({ - type: UserProfileDto, - isArray: true, - }) - @Type(() => UserProfileDto) - data!: UserProfileDto[]; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/dto/user-profile.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/dto/user-profile.dto.ts deleted file mode 100644 index 7ed176894..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/dto/user-profile.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Expose } from 'class-transformer'; - -export class UserProfileDto { - @Expose() - id!: string; - - @Expose() - userId!: number; - - @Expose() - nickName?: string; - - @Expose() - favoriteColor?: string; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/schemas/user-profile-create.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/schemas/user-profile-create.schema.ts new file mode 100644 index 000000000..f041c81ce --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/schemas/user-profile-create.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `UserProfileCreateDto` — faithful + * reproduction. Converted for API parity despite zero live consumers + * (see `user-profile.schema.ts`'s docstring). + */ +export const userProfileCreateSchema = withOpenApi( + z.object({ + userId: z.number(), + nickName: z.string().max(100).optional(), + favoriteColor: z.string().max(50).optional(), + }), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/schemas/user-profile-paginated.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/schemas/user-profile-paginated.schema.ts new file mode 100644 index 000000000..b9d1657df --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/schemas/user-profile-paginated.schema.ts @@ -0,0 +1,9 @@ +import { withOpenApi } from '@concepta/nestjs-core'; + +import { paginatedSchema } from '../../../../infrastructure/schemas/crud-response-paginated.schema.js'; + +import { userProfileSchema } from './user-profile.schema.js'; + +export const userProfilePaginatedSchema = withOpenApi( + paginatedSchema(userProfileSchema), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/schemas/user-profile.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/schemas/user-profile.schema.ts new file mode 100644 index 000000000..cb3e348fe --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/schemas/user-profile.schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `UserProfileDto` — no domain interface + * exists for these TypeORM test fixtures, so there is no `conformsTo` to + * apply. Converted for API parity even though this fixture directory has + * zero live consumers today (confirmed via grep, matching the invitation + * package's dead `RecoveryValidatePasscodeDto` precedent) — not silently + * dropped as a side effect of this migration. + */ +export const userProfileSchema = withOpenApi( + z.object({ + id: z.number().optional(), + userId: z.number(), + nickName: z.string().optional(), + favoriteColor: z.string().optional(), + }), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/user-profile-crud.service.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/user-profile-crud.service.ts deleted file mode 100644 index 821c6134a..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/user-profile-crud.service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { CrudService } from '../../../services/crud.service'; - -import { UserProfileTypeOrmCrudAdapter } from './user-profile-typeorm-crud.adapter'; -import { UserProfileEntity } from './user-profile.entity'; - -@Injectable() -export class UserProfileCrudService extends CrudService { - constructor(crudAdapter: UserProfileTypeOrmCrudAdapter) { - super(crudAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/user-profile-typeorm-crud.adapter.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/user-profile-typeorm-crud.adapter.ts deleted file mode 100644 index cb15bfe79..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/user-profile-typeorm-crud.adapter.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { InjectDynamicRepository } from '@concepta/nestjs-common'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { TypeOrmCrudAdapter } from '../../../crud/adapters/typeorm-crud.adapter'; -import { CRUD_TEST_USER_PROFILE_ENTITY_KEY } from '../../crud-test.constants'; - -import { UserProfileEntity } from './user-profile.entity'; - -@Injectable() -export class UserProfileTypeOrmCrudAdapter extends TypeOrmCrudAdapter { - constructor( - @InjectDynamicRepository(CRUD_TEST_USER_PROFILE_ENTITY_KEY) - repoAdapter: TypeOrmRepositoryAdapter, - ) { - super(repoAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/user-profile.entity.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/user-profile.entity.ts index 8f7d0fba0..8189655bc 100644 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/user-profile.entity.ts +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/user-profile/user-profile.entity.ts @@ -1,6 +1,6 @@ import { Entity, Column } from 'typeorm'; -import { BaseEntity } from '../base-entity'; +import { BaseEntity } from '../base-entity.js'; @Entity('user_profiles') export class UserProfileEntity extends BaseEntity { diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/users/dto/user-paginated.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/users/dto/user-paginated.dto.ts deleted file mode 100644 index 0fa7f974d..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/users/dto/user-paginated.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CrudResponsePaginatedDto } from '../../../../crud/dto/crud-response-paginated.dto'; - -import { UserDto } from './user.dto'; - -export class UserPaginatedDto extends CrudResponsePaginatedDto { - @ApiProperty({ - type: UserDto, - isArray: true, - }) - @Type(() => UserDto) - data!: UserDto[]; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/users/dto/user.dto.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/users/dto/user.dto.ts deleted file mode 100644 index 9cdc78c5c..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/users/dto/user.dto.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Expose, Type } from 'class-transformer'; - -import { CompanyDto } from '../../company/dto/company.dto'; -import { UserProfileDto } from '../../user-profile/dto/user-profile.dto'; - -export class NameDto { - @Expose() - first!: string | null; - - @Expose() - last!: string | null; -} - -export class UserDto { - @Expose() - id!: string; - - @Expose() - email!: string; - - @Expose() - isActive!: boolean; - - @Expose() - companyId?: number; - - @Expose() - deletedAt?: Date; - - @Expose() - @Type(() => NameDto) - name!: NameDto; - - @Expose() - @Type(() => CompanyDto) - company?: CompanyDto; - - @Expose() - @Type(() => UserProfileDto) - userProfile?: UserProfileDto; -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/users/schemas/user-paginated.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/users/schemas/user-paginated.schema.ts new file mode 100644 index 000000000..b9e141795 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/users/schemas/user-paginated.schema.ts @@ -0,0 +1,7 @@ +import { withOpenApi } from '@concepta/nestjs-core'; + +import { paginatedSchema } from '../../../../infrastructure/schemas/crud-response-paginated.schema.js'; + +import { userSchema } from './user.schema.js'; + +export const userPaginatedSchema = withOpenApi(paginatedSchema(userSchema)); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/users/schemas/user.schema.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/users/schemas/user.schema.ts new file mode 100644 index 000000000..4f0175b23 --- /dev/null +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/users/schemas/user.schema.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +import { type CompanyType } from '../../company/schemas/company.schema.js'; +import { companySchema } from '../../company/schemas/company.schema.js'; +import { userProfileSchema } from '../../user-profile/schemas/user-profile.schema.js'; + +/** + * Explicit shape, not `z.infer`-derived — `User`/`Company` are mutually + * nested (`user.company` ↔ `company.users`, a genuine circular reference + * with `company.schema.ts`), so each side's schema needs an explicit type + * annotation to break TypeScript's circular inference (`z.lazy()` alone + * only defers the RUNTIME reference, not the type). + */ +export interface UserType { + id?: number; + email: string; + isActive: boolean; + companyId?: number; + deletedAt?: Date | null; + firstName?: string | null; + lastName?: string | null; + company?: CompanyType; + userProfile?: z.infer; +} + +/** + * Zod equivalent of the legacy `UserDto` — no domain interface exists for + * these TypeORM test fixtures, so there is no `conformsTo` to apply. + * `company` is wrapped in `z.lazy()` because of the circular reference + * described above. `deletedAt` is `.nullable()` (not just `.optional()` + * like the legacy DTO's plain `@Expose() deletedAt?: Date`) because + * `@DeleteDateColumn` always returns the key as `null`, never omits it, + * when a row isn't soft-deleted — the legacy class-transformer path never + * validated this on the way out, but the schema-based serializer does + * (fail-closed), so this must reflect real persisted data. + */ +export const userSchema: z.ZodType = withOpenApi( + z.object({ + id: z.number().optional(), + email: z.string(), + isActive: z.boolean(), + companyId: z.number().optional(), + deletedAt: z.date().nullable().optional(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional(), + company: z.lazy(() => companySchema).optional(), + userProfile: userProfileSchema.optional(), + }), +); diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/users/user-crud.service.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/users/user-crud.service.ts deleted file mode 100644 index a1077bff7..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/users/user-crud.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { forwardRef, Inject, Injectable, Optional } from '@nestjs/common'; - -import { CrudRelationRegistry } from '../../../services/crud-relation.registry'; -import { CrudService } from '../../../services/crud.service'; -import { UserProfileEntity } from '../user-profile/user-profile.entity'; - -import { UserTypeOrmCrudAdapter } from './user-typeorm-crud.adapter'; -import { UserEntity } from './user.entity'; - -@Injectable() -export class UserCrudService extends CrudService< - UserEntity, - [UserProfileEntity] -> { - constructor( - crudAdapter: UserTypeOrmCrudAdapter, - @Optional() - @Inject(forwardRef(() => 'USER_RELATION_REGISTRY')) - relationRegistry?: CrudRelationRegistry, - ) { - super(crudAdapter, relationRegistry); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/users/user-typeorm-crud.adapter.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/users/user-typeorm-crud.adapter.ts deleted file mode 100644 index fa61c3574..000000000 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/users/user-typeorm-crud.adapter.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { InjectDynamicRepository } from '@concepta/nestjs-common'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { TypeOrmCrudAdapter } from '../../../crud/adapters/typeorm-crud.adapter'; -import { CRUD_TEST_USER_ENTITY_KEY } from '../../crud-test.constants'; - -import { UserEntity } from './user.entity'; - -@Injectable() -export class UserTypeOrmCrudAdapter extends TypeOrmCrudAdapter { - constructor( - @InjectDynamicRepository(CRUD_TEST_USER_ENTITY_KEY) - repoAdapter: TypeOrmRepositoryAdapter, - ) { - super(repoAdapter); - } -} diff --git a/packages/nestjs-crud/src/__fixtures__/typeorm/users/user.entity.ts b/packages/nestjs-crud/src/__fixtures__/typeorm/users/user.entity.ts index 05bf0a70e..9405c2484 100644 --- a/packages/nestjs-crud/src/__fixtures__/typeorm/users/user.entity.ts +++ b/packages/nestjs-crud/src/__fixtures__/typeorm/users/user.entity.ts @@ -1,16 +1,14 @@ -import { Entity, Column, DeleteDateColumn } from 'typeorm'; - -import { BaseEntity } from '../base-entity'; -import { CompanyEntity } from '../company/company.entity'; -import { UserProfileEntity } from '../user-profile/user-profile.entity'; - -export class NameEntity { - @Column({ type: 'varchar', nullable: true }) - first!: string | null; - - @Column({ type: 'varchar', nullable: true }) - last!: string | null; -} +import { + Entity, + Column, + DeleteDateColumn, + JoinColumn, + ManyToOne, +} from 'typeorm'; + +import { BaseEntity } from '../base-entity.js'; +import { CompanyEntity } from '../company/company.entity.js'; +import { UserProfileEntity } from '../user-profile/user-profile.entity.js'; @Entity('users') export class UserEntity extends BaseEntity { @@ -20,8 +18,11 @@ export class UserEntity extends BaseEntity { @Column({ type: 'boolean', default: true }) isActive!: boolean; - @Column(() => NameEntity) - name!: NameEntity; + @Column({ type: 'varchar', nullable: true }) + firstName!: string | null; + + @Column({ type: 'varchar', nullable: true }) + lastName!: string | null; @Column({ nullable: false }) companyId?: number; @@ -30,5 +31,10 @@ export class UserEntity extends BaseEntity { deletedAt?: Date; userProfile?: UserProfileEntity; - company?: CompanyEntity[]; + + @ManyToOne(() => CompanyEntity, (company) => company.users, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'companyId' }) + company?: CompanyEntity; } diff --git a/packages/nestjs-crud/src/__tests__/__artifacts__/.gitignore b/packages/nestjs-crud/src/__tests__/__artifacts__/.gitignore new file mode 100644 index 000000000..a6c57f5fb --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/__artifacts__/.gitignore @@ -0,0 +1 @@ +*.json diff --git a/packages/nestjs-crud/src/__tests__/action.specification.spec.ts b/packages/nestjs-crud/src/__tests__/action.specification.spec.ts new file mode 100644 index 000000000..9a09227c5 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/action.specification.spec.ts @@ -0,0 +1,35 @@ +import { ActionEnum, Operation } from '@concepta/nestjs-core'; + +import { ActionSpecification } from '../infrastructure/specifications/action.specification.js'; +import { type CrudSpecContextInterface } from '../infrastructure/specifications/interfaces/crud-spec-context.interface.js'; + +function createContext( + operation: Operation, + action: ActionEnum, +): CrudSpecContextInterface { + return { operation, action }; +} + +describe('ActionSpecification', () => { + it('should match when action is in the list', () => { + const spec = new ActionSpecification([ + ActionEnum.CREATE, + ActionEnum.UPDATE, + ]); + + expect( + spec.isSatisfiedBy(createContext(Operation.Create, ActionEnum.CREATE)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Update, ActionEnum.UPDATE)), + ).toBe(true); + }); + + it('should not match when action is not in the list', () => { + const spec = new ActionSpecification([ActionEnum.CREATE]); + + expect( + spec.isSatisfiedBy(createContext(Operation.Delete, ActionEnum.DELETE)), + ).toBe(false); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/b.query-params.spec.ts b/packages/nestjs-crud/src/__tests__/b.query-params.spec.ts new file mode 100644 index 000000000..0879c1955 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/b.query-params.spec.ts @@ -0,0 +1,1099 @@ +import request from 'supertest'; +import { DataSource } from 'typeorm'; +import { z } from 'zod'; + +import { Inject, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; + +import { Ctx } from '@concepta/nestjs-core'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { CrudUpdateHandler } from '../application/commands/handlers/crud-update.handler.js'; +import { CrudListHandler } from '../application/queries/handlers/crud-list.handler.js'; +import { CrudReadHandler } from '../application/queries/handlers/crud-read.handler.js'; +import { + createQueryHandler, + createCommandHandler, +} from '../application/utils/create-operation-handlers.js'; +import { CrudModule } from '../crud.module.js'; +import { CrudAdapter } from '../infrastructure/adapters/crud.adapter.js'; +import { CrudController } from '../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudList } from '../infrastructure/decorators/operations/crud-list.decorator.js'; +import { CrudRead } from '../infrastructure/decorators/operations/crud-read.decorator.js'; +import { CrudUpdate } from '../infrastructure/decorators/operations/crud-update.decorator.js'; +import { CrudBody } from '../infrastructure/decorators/params/crud-body.decorator.js'; +import { CrudAllow } from '../infrastructure/decorators/routes/crud-allow.decorator.js'; +import { CrudExclude } from '../infrastructure/decorators/routes/crud-exclude.decorator.js'; +import { CrudFilter } from '../infrastructure/decorators/routes/crud-filter.decorator.js'; +import { CrudLimit } from '../infrastructure/decorators/routes/crud-limit.decorator.js'; +import { CrudMaxLimit } from '../infrastructure/decorators/routes/crud-max-limit.decorator.js'; +import { CrudSort } from '../infrastructure/decorators/routes/crud-sort.decorator.js'; +import { CrudCtx } from '../infrastructure/interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { CrudQueryBuilder } from '../infrastructure/request/crud-query.builder.js'; +import { CrudAdapterResolver } from '../infrastructure/resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../infrastructure/resolvers/interfaces/crud-resolver.interface.js'; +import { paginatedSchema } from '../infrastructure/schemas/crud-response-paginated.schema.js'; +import { createCrudAdapterProvider } from '../infrastructure/utils/create-crud-adapter-provider.js'; + +import { createCrudOperationClasses } from '../__fixtures__/crud/create-crud-operation-classes.fixture.js'; +import { + CRUD_TEST_COMPANY_ENTITY_NAME, + CRUD_TEST_NOTE_ENTITY_NAME, + CRUD_TEST_PROJECT_ENTITY_NAME, + CRUD_TEST_USER_ENTITY_NAME, +} from '../__fixtures__/crud-test.constants.js'; +import { CompanyEntity } from '../__fixtures__/typeorm/company/company.entity.js'; +import { companyPaginatedSchema } from '../__fixtures__/typeorm/company/schemas/company-paginated.schema.js'; +import { companySchema } from '../__fixtures__/typeorm/company/schemas/company.schema.js'; +import { NoteEntity } from '../__fixtures__/typeorm/note/note.entity.js'; +import { notePaginatedSchema } from '../__fixtures__/typeorm/note/schemas/note-paginated.schema.js'; +import { noteSchema } from '../__fixtures__/typeorm/note/schemas/note.schema.js'; +import { ormSqliteConfig } from '../__fixtures__/typeorm/orm.sqlite.config.js'; +import { ProjectEntity } from '../__fixtures__/typeorm/project/project.entity.js'; +import { projectCreateSchema } from '../__fixtures__/typeorm/project/schemas/project-create.schema.js'; +import { projectPaginatedSchema } from '../__fixtures__/typeorm/project/schemas/project-paginated.schema.js'; +import { projectSchema } from '../__fixtures__/typeorm/project/schemas/project.schema.js'; +import { Seeds } from '../__fixtures__/typeorm/seeds.js'; +import { userSchema } from '../__fixtures__/typeorm/users/schemas/user.schema.js'; +import { UserEntity } from '../__fixtures__/typeorm/users/user.entity.js'; + +// Create entity-specific operation classes +const CompanyOps = createCrudOperationClasses( + CRUD_TEST_COMPANY_ENTITY_NAME, +); +const ProjectOps = createCrudOperationClasses( + CRUD_TEST_PROJECT_ENTITY_NAME, +); +const UserOps = createCrudOperationClasses( + CRUD_TEST_USER_ENTITY_NAME, +); +const NoteOps = createCrudOperationClasses( + CRUD_TEST_NOTE_ENTITY_NAME, +); + +// Create entity-specific handlers +const CompanyListHandler = createQueryHandler({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + baseClass: CrudListHandler, + queryClass: CompanyOps.CrudListQuery, +}); + +const ProjectListHandler = createQueryHandler({ + entity: CRUD_TEST_PROJECT_ENTITY_NAME, + baseClass: CrudListHandler, + queryClass: ProjectOps.CrudListQuery, +}); +const ProjectReadHandler = createQueryHandler({ + entity: CRUD_TEST_PROJECT_ENTITY_NAME, + baseClass: CrudReadHandler, + queryClass: ProjectOps.CrudReadQuery, +}); +const ProjectUpdateHandler = createCommandHandler({ + entity: CRUD_TEST_PROJECT_ENTITY_NAME, + baseClass: CrudUpdateHandler, + commandClass: ProjectOps.CrudUpdateCommand, +}); + +const UserListHandler = createQueryHandler({ + entity: CRUD_TEST_USER_ENTITY_NAME, + baseClass: CrudListHandler, + queryClass: UserOps.CrudListQuery, +}); + +const NoteListHandler = createQueryHandler({ + entity: CRUD_TEST_NOTE_ENTITY_NAME, + baseClass: CrudListHandler, + queryClass: NoteOps.CrudListQuery, +}); + +// tslint:disable:max-classes-per-file +describe('#crud-typeorm', () => { + describe('#query params', () => { + let app: INestApplication; + let server: ReturnType; + let qb: CrudQueryBuilder; + + @CrudController({ + path: 'companies', + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + request: { + body: companySchema, + }, + response: { + resource: companySchema, + paginated: companyPaginatedSchema, + }, + }) + @CrudExclude(['updatedAt']) + @CrudFilter({ id: { $ne: 1 } }) + @CrudAllow(['id', 'name', 'domain', 'description']) + @CrudMaxLimit(5) + class CompaniesController { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ query: CompanyOps.CrudListQuery }) + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + } + + @CrudController({ + path: 'projects', + entity: CRUD_TEST_PROJECT_ENTITY_NAME, + request: { + params: { + id: { + field: 'id', + type: 'number', + primary: true, + }, + }, + body: projectCreateSchema, + }, + response: { + resource: projectSchema, + paginated: projectPaginatedSchema, + }, + }) + @CrudSort([{ field: 'id', order: 'ASC' }]) + @CrudLimit(100) + class ProjectsController { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ query: ProjectOps.CrudListQuery }) + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + + @CrudRead({ query: ProjectOps.CrudReadQuery }) + read(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.read(context); + } + + // This fixture reuses `projectCreateSchema` for both create AND + // update (a pre-existing test-fixture quirk, not a real API + // pattern) — but update is a PATCH, and `projectCreateSchema.name` + // is required (per the Phase 3 decision to fix it to match + // `ProjectEntity`'s NOT NULL constraint). `.partial()` here keeps + // that create-time requirement intact while allowing this + // operation's genuinely partial payloads (e.g. `{ companyId }` + // alone) to validate. + @CrudUpdate({ + command: ProjectOps.CrudUpdateCommand, + request: { body: projectCreateSchema.partial() }, + }) + update( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: projectCreateSchema.partial() }) + project: z.infer, + ) { + return this.crudResolver.update(context, project); + } + } + + @CrudController({ + path: 'projects2', + entity: CRUD_TEST_PROJECT_ENTITY_NAME, + request: { + body: projectSchema, + }, + response: { + resource: projectSchema, + paginated: projectPaginatedSchema, + }, + }) + class ProjectsController2 { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ query: ProjectOps.CrudListQuery }) + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + } + + @CrudController({ + path: 'projects3', + entity: CRUD_TEST_PROJECT_ENTITY_NAME, + request: { + body: projectSchema, + }, + response: { + resource: projectSchema, + paginated: projectPaginatedSchema, + }, + }) + @CrudFilter({ isActive: false }) + class ProjectsController3 { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ query: ProjectOps.CrudListQuery }) + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + } + + @CrudController({ + path: 'projects4', + entity: CRUD_TEST_PROJECT_ENTITY_NAME, + request: { + body: projectSchema, + }, + response: { + resource: projectSchema, + paginated: projectPaginatedSchema, + }, + }) + @CrudFilter({ isActive: true }) + class ProjectsController4 { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ query: ProjectOps.CrudListQuery }) + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + } + + @CrudController({ + path: 'users', + entity: CRUD_TEST_USER_ENTITY_NAME, + request: { + body: userSchema, + }, + response: { + resource: userSchema, + // Field-selection (`select=...`) can legitimately narrow a List + // response down to a subset of `userSchema`'s fields (see the + // "#field selection" describe block below) — unlike the legacy + // class-transformer path (which never validated field presence + // at all), the schema-based serializer validates the response + // shape, so List needs a lenient (all-optional) paginated schema. + // Built inline rather than `userSchema.partial()` because + // `userSchema` is typed `z.ZodType` (an explicit + // annotation required to break its circular reference with + // `companySchema` — see user.schema.ts), which drops + // `ZodObject`-only methods like `.partial()`. This plain (no + // join) `/users` list never actually returns `company`/ + // `userProfile`, so they're omitted here rather than duplicated. + // Read/Create/Update stay strict via the full `userSchema` + // resource above. + paginated: paginatedSchema( + z.object({ + id: z.number().optional(), + email: z.string().optional(), + isActive: z.boolean().optional(), + companyId: z.number().optional(), + deletedAt: z.date().nullable().optional(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional(), + }), + ), + }, + }) + class UsersController { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ query: UserOps.CrudListQuery }) + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + } + + @CrudController({ + path: 'notes', + entity: CRUD_TEST_NOTE_ENTITY_NAME, + request: { + body: noteSchema, + }, + response: { + resource: noteSchema, + paginated: notePaginatedSchema, + }, + }) + class NotesController { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ query: NoteOps.CrudListQuery }) + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ ...ormSqliteConfig }), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: CRUD_TEST_COMPANY_ENTITY_NAME, entity: CompanyEntity }, + { key: CRUD_TEST_PROJECT_ENTITY_NAME, entity: ProjectEntity }, + { key: CRUD_TEST_USER_ENTITY_NAME, entity: UserEntity }, + { key: CRUD_TEST_NOTE_ENTITY_NAME, entity: NoteEntity }, + ], + }), + CrudModule.forRoot({}), + ], + controllers: [ + CompaniesController, + ProjectsController, + ProjectsController2, + ProjectsController3, + ProjectsController4, + UsersController, + NotesController, + ], + providers: [ + createCrudAdapterProvider({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + }), + CompanyListHandler, + createCrudAdapterProvider({ + entity: CRUD_TEST_USER_ENTITY_NAME, + adapter: CrudAdapter, + }), + UserListHandler, + createCrudAdapterProvider({ + entity: CRUD_TEST_PROJECT_ENTITY_NAME, + adapter: CrudAdapter, + }), + ProjectListHandler, + ProjectReadHandler, + ProjectUpdateHandler, + createCrudAdapterProvider({ + entity: CRUD_TEST_NOTE_ENTITY_NAME, + adapter: CrudAdapter, + }), + NoteListHandler, + ], + }).compile(); + + app = fixture.createNestApplication(); + + await app.init(); + + server = app.getHttpServer(); + + const datasource = app.get(getDataSourceToken()); + const seeds = new Seeds(); + await seeds.up(datasource.createQueryRunner()); + }); + + beforeEach(() => { + qb = CrudQueryBuilder.create(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('#select', () => { + it('should throw status 400', async () => { + qb.setFilter({ field: 'invalid', operator: 'null' }); + await request(server) + .get('/companies') + .query(qb.queryObject) + .expect(400); + }); + }); + + describe('#query filter', () => { + it('should return data with limit', async () => { + qb.setLimit(4); + const res = await request(server) + .get('/companies') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(4); + res.body.data.forEach((e: CompanyEntity) => { + expect(e.id).not.toBe(1); + }); + }); + it('should return with maxLimit', async () => { + qb.setLimit(7); + const res = await request(server) + .get('/companies') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(5); + }); + it('should return with filter and or, 1', async () => { + qb.setFilter({ + field: 'name', + operator: 'nin', + value: ['Name2', 'Name3'], + }).setOr({ field: 'domain', operator: 'contains', value: 5 }); + const res = await request(server) + .get('/companies') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(5); + }); + it('should return with filter and or, 2', async () => { + qb.setFilter({ field: 'name', operator: 'ends', value: 'foo' }) + .setOr({ field: 'name', operator: 'starts', value: 'P' }) + .setOr({ field: 'isActive', operator: 'eq', value: true }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(10); + }); + it('should return with filter and or, 3', async () => { + qb.setOr({ field: 'companyId', operator: 'gt', value: 22 }) + .setFilter({ field: 'companyId', operator: 'gte', value: 6 }) + .setFilter({ field: 'companyId', operator: 'lt', value: 10 }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(8); + }); + it('should return with filter and or, 4', async () => { + qb.setOr({ field: 'companyId', operator: 'in', value: [6, 10] }) + .setOr({ field: 'companyId', operator: 'lte', value: 10 }) + .setFilter({ field: 'isActive', operator: 'eq', value: false }) + .setFilter({ field: 'description', operator: 'nnull' }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(10); + }); + it('should return with filter and or, 5', async () => { + qb.setOr({ field: 'companyId', operator: 'null' }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(0); + }); + it('should return with filter and or, 6', async () => { + qb.setOr({ field: 'companyId', operator: 'between', value: [1, 5] }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(10); + }); + it('should return with filter, 1', async () => { + qb.setOr({ field: 'companyId', operator: 'eq', value: 1 }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(2); + }); + it('should return with $ncontains filter', async () => { + qb.setFilter({ + field: 'name', + operator: 'ncontains', + value: 'Project1', + }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + res.body.data.forEach((e: ProjectEntity) => { + expect(e.name).not.toBe('Project1'); + }); + }); + it('should apply default @CrudFilter and exclude company 1', async () => { + const res = await request(server).get('/companies').expect(200); + const ids = res.body.data.map((c: CompanyEntity) => c.id); + expect(ids).not.toContain(1); + }); + it('should apply default @CrudLimit when no limit param is set', async () => { + const res = await request(server).get('/projects').expect(200); + expect(res.body.data).toHaveLength(20); + }); + }); + + describe('#pagination', () => { + it('should return page 1 with correct metadata', async () => { + qb.setLimit(3).setPage(1); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body).toEqual({ + data: expect.any(Array), + count: 3, + total: 20, + page: 1, + pageCount: 7, + limit: 3, + }); + expect(res.body.data).toHaveLength(3); + }); + it('should return page 2 with correct offset', async () => { + qb.setLimit(3).setPage(2).sortBy({ field: 'id', order: 'ASC' }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body).toEqual({ + data: [ + expect.objectContaining({ id: 4 }), + expect.objectContaining({ id: 5 }), + expect.objectContaining({ id: 6 }), + ], + count: 3, + total: 20, + page: 2, + pageCount: 7, + limit: 3, + }); + }); + it('should return last page with fewer items', async () => { + qb.setLimit(3).setPage(7).sortBy({ field: 'id', order: 'ASC' }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body).toEqual({ + data: [ + expect.objectContaining({ id: 19 }), + expect.objectContaining({ id: 20 }), + ], + count: 2, + total: 20, + page: 7, + pageCount: 7, + limit: 3, + }); + }); + it('should return empty data for page beyond total', async () => { + qb.setLimit(3).setPage(100); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body).toEqual({ + data: [], + count: 0, + total: 20, + page: 100, + pageCount: 7, + limit: 3, + }); + }); + it('should return data with offset and limit', async () => { + qb.setOffset(5).setLimit(10).sortBy({ field: 'id', order: 'ASC' }); + const res = await request(server) + .get('/users') + .query(qb.queryObject) + .expect(200); + expect(res.body).toEqual({ + data: expect.any(Array), + count: 10, + total: 21, + page: 1, + pageCount: 3, + limit: 10, + }); + expect(res.body.data).toHaveLength(10); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 6 })); + }); + it('should respect maxLimit in pagination metadata', async () => { + qb.setPage(1); + const res = await request(server) + .get('/companies') + .query(qb.queryObject) + .expect(200); + expect(res.body).toEqual({ + data: expect.any(Array), + count: 5, + total: 8, + page: 1, + pageCount: 2, + limit: 5, + }); + expect(res.body.data).toHaveLength(5); + }); + it('should paginate filtered results', async () => { + qb.setFilter({ field: 'isActive', operator: 'eq', value: true }) + .setLimit(3) + .setPage(2) + .sortBy({ field: 'id', order: 'ASC' }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body).toEqual({ + data: [ + expect.objectContaining({ id: 4 }), + expect.objectContaining({ id: 5 }), + expect.objectContaining({ id: 6 }), + ], + count: 3, + total: 10, + page: 2, + pageCount: 4, + limit: 3, + }); + }); + }); + + describe('#sort', () => { + it('should sort by field', async () => { + qb.sortBy({ field: 'id', order: 'DESC' }); + const res = await request(server) + .get('/users') + .query(qb.queryObject) + .expect(200); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 21 })); + expect(res.body.data[1]).toEqual(expect.objectContaining({ id: 20 })); + }); + + it('should throw 400 if SQL injection has been detected', async () => { + qb.sortBy({ + field: ' ASC; SELECT CAST( version() AS INTEGER); --', + order: 'DESC', + }); + const res = await request(server) + .get('/companies') + .query(qb.queryObject); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it('should sort ASC by field', async () => { + qb.sortBy({ field: 'id', order: 'ASC' }); + const res = await request(server) + .get('/users') + .query(qb.queryObject) + .expect(200); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + expect(res.body.data[1]).toEqual(expect.objectContaining({ id: 2 })); + }); + + it('should sort by multiple fields', async () => { + qb.sortBy([ + { field: 'companyId', order: 'ASC' }, + { field: 'id', order: 'DESC' }, + ]); + const res = await request(server) + .get('/projects2') + .query(qb.queryObject) + .expect(200); + expect(res.body.data[0]).toEqual( + expect.objectContaining({ companyId: 1, id: 2 }), + ); + expect(res.body.data[1]).toEqual( + expect.objectContaining({ companyId: 1, id: 1 }), + ); + expect(res.body.data[2]).toEqual( + expect.objectContaining({ companyId: 2, id: 4 }), + ); + }); + + it('should sort combined with filter', async () => { + qb.setFilter({ + field: 'isActive', + operator: 'eq', + value: true, + }).sortBy({ field: 'id', order: 'DESC' }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(10); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 10 })); + expect(res.body.data[9]).toEqual(expect.objectContaining({ id: 1 })); + }); + + it('should apply default @CrudSort when no sort param is set', async () => { + qb.setLimit(5); + const res = await request(server) + .get('/projects') + .query(qb.queryObject) + .expect(200); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + expect(res.body.data[1]).toEqual(expect.objectContaining({ id: 2 })); + }); + }); + + describe('#search', () => { + const projects2 = () => request(server).get('/projects2'); + const projects3 = () => request(server).get('/projects3'); + const projects4 = () => request(server).get('/projects4'); + + it('should return with search, 1', async () => { + const query = qb.search({ id: 1 }).query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + }); + it('should return with search, 2', async () => { + const query = qb.search({ id: 1, name: 'Project1' }).query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + }); + it('should return with search, 3', async () => { + const query = qb.search({ id: 1, name: { $eq: 'Project1' } }).query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + }); + it('should return with search, 4', async () => { + const query = qb.search({ name: { $eq: 'Project1' } }).query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + }); + it('should return with search, 5', async () => { + const query = qb.search({ id: { $nnull: true, $eq: 1 } }).query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + }); + it('should return with search, 6', async () => { + const query = qb + .search({ id: { $or: { $null: true, $eq: 1 } } }) + .query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + }); + it('should return with search, 7', async () => { + const query = qb.search({ id: { $or: { $eq: 1 } } }).query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + }); + it('should return with search, 8', async () => { + const query = qb + .search({ id: { $nnull: true, $or: { $eq: 1, $in: [30, 31] } } }) + .query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + }); + it('should return with search, 9', async () => { + const query = qb + .search({ id: { $nnull: true, $or: { $eq: 1 } } }) + .query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + }); + it('should return with search, 10', async () => { + const query = qb.search({ id: null }).query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(0); + }); + it('should return with search, 11', async () => { + const query = qb + .search({ + $and: [{ id: { $nin: [5, 6, 7, 8, 9, 10] } }, { isActive: true }], + }) + .query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(4); + }); + it('should return with search, 12', async () => { + const query = qb + .search({ $and: [{ id: { $nin: [5, 6, 7, 8, 9, 10] } }] }) + .query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(14); + }); + it('should return with search, 13', async () => { + const query = qb.search({ $or: [{ id: 54 }] }).query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(0); + }); + it('should return with search, 14', async () => { + const query = qb + .search({ $or: [{ id: 54 }, { id: 33 }, { id: { $in: [1, 2] } }] }) + .query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 1 })); + expect(res.body.data[1]).toEqual(expect.objectContaining({ id: 2 })); + }); + it('should return with search, 15', async () => { + const query = qb + .search({ $or: [{ id: 54 }], name: 'Project1' }) + .query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(0); + }); + it('should return with search, 16', async () => { + const query = qb + .search({ $or: [{ isActive: false }, { id: 3 }], name: 'Project3' }) + .query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 3 })); + }); + it('should return with search, 17', async () => { + const query = qb + .search({ + $or: [{ isActive: false }, { id: { $eq: 3 } }], + name: 'Project3', + }) + .query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 3 })); + }); + it('should return with search, 18', async () => { + const query = qb + .search({ + $or: [{ isActive: false }, { id: { $eq: 3 } }], + name: { $eq: 'Project3' }, + }) + .query(); + const res = await projects2().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 3 })); + }); + it('should return with default filter, 1', async () => { + const query = qb.search({ name: 'Project11' }).query(); + const res = await projects3().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 11 })); + }); + it('should return with default filter, 2', async () => { + const query = qb.search({ name: 'Project1' }).query(); + const res = await projects3().query(query).expect(200); + expect(res.body.data).toHaveLength(0); + }); + it('should return with default filter, 3', async () => { + const query = qb.search({ name: 'Project2' }).query(); + const res = await projects4().query(query).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 2 })); + }); + it('should return with default filter, 4', async () => { + const query = qb.search({ name: 'Project11' }).query(); + const res = await projects4().query(query).expect(200); + expect(res.body.data).toHaveLength(0); + }); + it('should search by display column name, but use dbName in sql query', async () => { + const query = qb.search({ revisionId: 2 }).query(); + const res = await request(server) + .get('/notes') + .query(query) + .expect(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0]).toEqual( + expect.objectContaining({ revisionId: 2 }), + ); + expect(res.body.data[1]).toEqual( + expect.objectContaining({ revisionId: 2 }), + ); + }); + it('should paginate search results', async () => { + const query = qb + .search({ isActive: true }) + .setLimit(3) + .setPage(2) + .sortBy({ field: 'id', order: 'ASC' }) + .query(); + const res = await request(server) + .get('/projects2') + .query(query) + .expect(200); + expect(res.body).toEqual({ + data: [ + expect.objectContaining({ id: 4 }), + expect.objectContaining({ id: 5 }), + expect.objectContaining({ id: 6 }), + ], + count: 3, + total: 10, + page: 2, + pageCount: 4, + limit: 3, + }); + }); + }); + + describe('#field selection', () => { + it('should return only selected fields', async () => { + qb.select(['id', 'name']); + const res = await request(server) + .get('/projects2') + .query(qb.queryObject) + .expect(200); + expect(res.body.data.length).toBeGreaterThan(0); + expect(res.body.data[0]).toEqual({ + id: expect.any(Number), + name: expect.any(String), + }); + }); + it('should return selected fields combined with filter', async () => { + qb.select(['id', 'email']).setFilter({ + field: 'companyId', + operator: 'eq', + value: 1, + }); + const res = await request(server) + .get('/users') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(10); + expect(res.body.data[0]).toEqual({ + id: expect.any(Number), + email: expect.any(String), + }); + }); + }); + + describe('#exclude and allow', () => { + it('should exclude updatedAt from company list response', async () => { + qb.setLimit(1); + const res = await request(server) + .get('/companies') + .query(qb.queryObject) + .expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual({ + id: expect.any(Number), + name: expect.any(String), + domain: expect.any(String), + description: null, + }); + }); + it('should only return allowed fields in company list response', async () => { + qb.setLimit(1); + const res = await request(server) + .get('/companies') + .query(qb.queryObject) + .expect(200); + expect(res.body.data[0]).toEqual({ + id: expect.any(Number), + name: expect.any(String), + domain: expect.any(String), + description: null, + }); + }); + it('should have all response fields when no exclude or allow decorators', async () => { + qb.setLimit(1); + const res = await request(server) + .get('/projects2') + .query(qb.queryObject) + .expect(200); + expect(res.body.data[0]).toEqual({ + id: expect.any(Number), + name: expect.any(String), + description: expect.any(String), + isActive: expect.any(Boolean), + companyId: expect.any(Number), + }); + }); + }); + + describe('#includeDeleted', () => { + it('should return soft-deleted companies when includeDeleted is set', async () => { + qb.setIncludeDeleted(1); + const res = await request(server) + .get('/companies') + .query(qb.queryObject) + .expect(200); + expect(res.body).toEqual(expect.objectContaining({ total: 9 })); + }); + it('should not return soft-deleted companies by default', async () => { + const res = await request(server) + .get('/companies') + .query(qb.queryObject) + .expect(200); + expect(res.body).toEqual(expect.objectContaining({ total: 8 })); + const ids = res.body.data.map((c: { id: number }) => c.id); + expect(ids).not.toContain(9); + }); + }); + + describe('#error cases', () => { + it('should return error for invalid sort order', async () => { + const res = await request(server).get('/projects?sort=id,INVALID'); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + it('should return error for malformed search JSON', async () => { + const res = await request(server).get('/projects2?s=not-valid-json'); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + it('should return error for invalid filter field', async () => { + qb.setFilter({ + field: 'nonexistent', + operator: 'eq', + value: 'test', + }); + const res = await request(server) + .get('/projects') + .query(qb.queryObject); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + }); + + describe('#search and filter mutual exclusion', () => { + it('should ignore filter when search is set', async () => { + qb.setFilter({ field: 'isActive', operator: 'eq', value: true }); + qb.search({ id: 11 }); + const query = qb.query(); + const res = await request(server) + .get('/projects2') + .query(query) + .expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual( + expect.objectContaining({ id: 11, isActive: false }), + ); + }); + it('should ignore or-condition when search is set', async () => { + qb.setOr({ field: 'companyId', operator: 'eq', value: 1 }); + qb.search({ id: 20 }); + const query = qb.query(); + const res = await request(server) + .get('/projects2') + .query(query) + .expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toEqual(expect.objectContaining({ id: 20 })); + }); + }); + + describe('#update', () => { + it('should update company id of project', async () => { + await request(server) + .patch('/projects/18') + .send({ companyId: 10 }) + .expect(200); + + const modified = await request(server).get('/projects/18').expect(200); + + expect(modified.body).toEqual( + expect.objectContaining({ id: 18, companyId: 10 }), + ); + }); + }); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/c.basic-crud.spec.ts b/packages/nestjs-crud/src/__tests__/c.basic-crud.spec.ts new file mode 100644 index 000000000..232d38eb0 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/c.basic-crud.spec.ts @@ -0,0 +1,1103 @@ +import request from 'supertest'; +import { DataSource } from 'typeorm'; +import { z } from 'zod'; + +import { Inject, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; + +import { Ctx } from '@concepta/nestjs-core'; +import { RepositoryModule, Transactional } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { CrudCreateBatchHandler } from '../application/commands/handlers/crud-create-batch.handler.js'; +import { CrudCreateHandler } from '../application/commands/handlers/crud-create.handler.js'; +import { CrudDeleteHandler } from '../application/commands/handlers/crud-delete.handler.js'; +import { CrudReplaceHandler } from '../application/commands/handlers/crud-replace.handler.js'; +import { CrudRestoreHandler } from '../application/commands/handlers/crud-restore.handler.js'; +import { CrudSoftDeleteHandler } from '../application/commands/handlers/crud-soft-delete.handler.js'; +import { CrudUpdateHandler } from '../application/commands/handlers/crud-update.handler.js'; +import { CrudListHandler } from '../application/queries/handlers/crud-list.handler.js'; +import { CrudReadHandler } from '../application/queries/handlers/crud-read.handler.js'; +import { + createCommandHandler, + createQueryHandler, +} from '../application/utils/create-operation-handlers.js'; +import { CrudModule } from '../crud.module.js'; +import { CrudAdapter } from '../infrastructure/adapters/crud.adapter.js'; +import { CrudController } from '../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudCreateBatch } from '../infrastructure/decorators/operations/crud-create-batch.decorator.js'; +import { CrudCreate } from '../infrastructure/decorators/operations/crud-create.decorator.js'; +import { CrudDelete } from '../infrastructure/decorators/operations/crud-delete.decorator.js'; +import { CrudList } from '../infrastructure/decorators/operations/crud-list.decorator.js'; +import { CrudRead } from '../infrastructure/decorators/operations/crud-read.decorator.js'; +import { CrudReplace } from '../infrastructure/decorators/operations/crud-replace.decorator.js'; +import { CrudRestore } from '../infrastructure/decorators/operations/crud-restore.decorator.js'; +import { CrudSoftDelete } from '../infrastructure/decorators/operations/crud-soft-delete.decorator.js'; +import { CrudUpdate } from '../infrastructure/decorators/operations/crud-update.decorator.js'; +import { CrudBody } from '../infrastructure/decorators/params/crud-body.decorator.js'; +import { CrudLimit } from '../infrastructure/decorators/routes/crud-limit.decorator.js'; +import { CrudCtx } from '../infrastructure/interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { CrudQueryBuilder } from '../infrastructure/request/crud-query.builder.js'; +import { CrudAdapterResolver } from '../infrastructure/resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../infrastructure/resolvers/interfaces/crud-resolver.interface.js'; +import { createCrudAdapterProvider } from '../infrastructure/utils/create-crud-adapter-provider.js'; + +import { createCrudOperationClasses } from '../__fixtures__/crud/create-crud-operation-classes.fixture.js'; +import { + CRUD_TEST_COMPANY_ENTITY_NAME, + CRUD_TEST_DEVICE_ENTITY_NAME, +} from '../__fixtures__/crud-test.constants.js'; +import { CompanyEntity } from '../__fixtures__/typeorm/company/company.entity.js'; +import { companyCreateBatchResponseSchema } from '../__fixtures__/typeorm/company/schemas/company-create-batch-response.schema.js'; +import { companyCreateBatchSchema } from '../__fixtures__/typeorm/company/schemas/company-create-batch.schema.js'; +import { companyCreateSchema } from '../__fixtures__/typeorm/company/schemas/company-create.schema.js'; +import { companyPaginatedSchema } from '../__fixtures__/typeorm/company/schemas/company-paginated.schema.js'; +import { companyUpdateSchema } from '../__fixtures__/typeorm/company/schemas/company-update.schema.js'; +import { companySchema } from '../__fixtures__/typeorm/company/schemas/company.schema.js'; +import { DeviceEntity } from '../__fixtures__/typeorm/device/device.entity.js'; +import { deviceCreateSchema } from '../__fixtures__/typeorm/device/schemas/device-create.schema.js'; +import { deviceSchema } from '../__fixtures__/typeorm/device/schemas/device.schema.js'; +import { ormSqliteConfig } from '../__fixtures__/typeorm/orm.sqlite.config.js'; +import { ProjectEntity } from '../__fixtures__/typeorm/project/project.entity.js'; +import { Seeds } from '../__fixtures__/typeorm/seeds.js'; +import { UserEntity } from '../__fixtures__/typeorm/users/user.entity.js'; + +// Create entity-specific operation classes +const CompanyOps = createCrudOperationClasses( + CRUD_TEST_COMPANY_ENTITY_NAME, +); +const DeviceOps = createCrudOperationClasses( + CRUD_TEST_DEVICE_ENTITY_NAME, +); + +// Create entity-specific handlers with proper DI setup +const CompanyListHandler = createQueryHandler({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + baseClass: CrudListHandler, + queryClass: CompanyOps.CrudListQuery, +}); +const CompanyReadHandler = createQueryHandler({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + baseClass: CrudReadHandler, + queryClass: CompanyOps.CrudReadQuery, +}); +const CompanyCreateHandler = createCommandHandler({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + baseClass: CrudCreateHandler, + commandClass: CompanyOps.CrudCreateCommand, +}); +const CompanyCreateBatchHandler = createCommandHandler({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + baseClass: CrudCreateBatchHandler, + commandClass: CompanyOps.CrudCreateBatchCommand, +}); +const CompanyUpdateHandler = createCommandHandler({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + baseClass: CrudUpdateHandler, + commandClass: CompanyOps.CrudUpdateCommand, +}); +const CompanyReplaceHandler = createCommandHandler({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + baseClass: CrudReplaceHandler, + commandClass: CompanyOps.CrudReplaceCommand, +}); +const CompanyDeleteHandler = createCommandHandler({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + baseClass: CrudDeleteHandler, + commandClass: CompanyOps.CrudDeleteCommand, +}); +const CompanySoftDeleteHandler = createCommandHandler({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + baseClass: CrudSoftDeleteHandler, + commandClass: CompanyOps.CrudSoftDeleteCommand, +}); +const CompanyRestoreHandler = createCommandHandler({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + baseClass: CrudRestoreHandler, + commandClass: CompanyOps.CrudRestoreCommand, +}); + +const DeviceCreateHandler = createCommandHandler({ + entity: CRUD_TEST_DEVICE_ENTITY_NAME, + baseClass: CrudCreateHandler, + commandClass: DeviceOps.CrudCreateCommand, +}); + +const isMysql = process.env.TYPEORM_CONNECTION === 'mysql'; + +// tslint:disable:max-classes-per-file no-shadowed-variable +describe('#crud-typeorm', () => { + describe('#basic crud respects global limit', () => { + let app: INestApplication; + let server: ReturnType; + + @CrudController({ + path: 'companies0', + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + request: { body: companySchema }, + response: { resource: companySchema, paginated: companyPaginatedSchema }, + }) + @CrudLimit(3) + class CompaniesController0 { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ query: CompanyOps.CrudListQuery }) + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormSqliteConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: CRUD_TEST_COMPANY_ENTITY_NAME, entity: CompanyEntity }, + ], + }), + CrudModule.forRoot({}), + ], + controllers: [CompaniesController0], + providers: [ + createCrudAdapterProvider({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + }), + CompanyListHandler, + ], + }).compile(); + + app = fixture.createNestApplication(); + + await app.init(); + server = app.getHttpServer(); + + const datasource = app.get(getDataSourceToken()); + const seeds = new Seeds(); + await seeds.up(datasource.createQueryRunner()); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('#list', () => { + it('should return an array of all entities', async () => { + const res = await request(server).get('/companies0').expect(200); + expect(res.body).toEqual({ + data: expect.any(Array), + count: 3, + total: 9, + page: 1, + pageCount: 3, + limit: 3, + }); + expect(res.body.data).toHaveLength(3); + }); + }); + }); + + describe('#basic crud default', () => { + let app: INestApplication; + let server: ReturnType; + let qb: CrudQueryBuilder; + + @CrudController({ + path: 'companies', + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + request: { body: companySchema }, + response: { resource: companySchema, paginated: companyPaginatedSchema }, + }) + class CompaniesController { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ query: CompanyOps.CrudListQuery }) + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormSqliteConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: CRUD_TEST_COMPANY_ENTITY_NAME, entity: CompanyEntity }, + ], + }), + CrudModule.forRoot({}), + ], + controllers: [CompaniesController], + providers: [ + createCrudAdapterProvider({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + }), + CompanyListHandler, + ], + }).compile(); + + app = fixture.createNestApplication(); + + await app.init(); + server = app.getHttpServer(); + + const datasource = app.get(getDataSourceToken()); + const seeds = new Seeds(); + await seeds.up(datasource.createQueryRunner()); + }); + + beforeEach(() => { + qb = CrudQueryBuilder.create(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('#list', () => { + it('should return an array of all entities', async () => { + const res = await request(server).get('/companies').expect(200); + expect(res.body).toEqual({ + data: expect.any(Array), + count: 9, + total: 9, + page: 1, + pageCount: 1, + limit: 9, + }); + expect(res.body.data).toHaveLength(9); + }); + it('should return an entities with limit', async () => { + const res = await request(server) + .get('/companies') + .query(qb.setLimit(5).query()) + .expect(200); + expect(res.body).toEqual({ + data: expect.any(Array), + count: 5, + total: 9, + page: 1, + pageCount: 2, + limit: 5, + }); + expect(res.body.data).toHaveLength(5); + }); + it('should return an entities with limit and page', async () => { + const query = qb + .setLimit(3) + .setPage(1) + .sortBy({ field: 'id', order: 'DESC' }) + .query(); + const res = await request(server) + .get('/companies') + .query(query) + .expect(200); + expect(res.body).toEqual({ + data: expect.any(Array), + count: 3, + total: 9, + page: 1, + pageCount: 3, + limit: 3, + }); + expect(res.body.data).toHaveLength(3); + }); + }); + }); + + describe('#basic crud', () => { + let app: INestApplication; + let server: ReturnType; + let qb: CrudQueryBuilder; + + @CrudController({ + path: 'companies', + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + request: { + body: companySchema, + params: { + id: { + field: 'id', + type: 'number', + primary: true, + }, + }, + }, + response: { resource: companySchema, paginated: companyPaginatedSchema }, + }) + class CompaniesController { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ query: CompanyOps.CrudListQuery }) + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + + @CrudRead({ query: CompanyOps.CrudReadQuery }) + read(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.read(context); + } + + @CrudCreate({ command: CompanyOps.CrudCreateCommand }) + create( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: companyCreateSchema }) + dto: z.infer, + ) { + return this.crudResolver.create(context, dto); + } + + @CrudCreateBatch({ + path: 'bulk', + command: CompanyOps.CrudCreateBatchCommand, + response: { + serialization: { resource: companyCreateBatchResponseSchema }, + }, + }) + createBatch( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: companyCreateBatchSchema }) + dto: z.infer, + ) { + return this.crudResolver.createBatch(context, dto); + } + + @CrudUpdate({ command: CompanyOps.CrudUpdateCommand }) + update( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: companyUpdateSchema }) + dto: z.infer, + ) { + return this.crudResolver.update(context, dto); + } + + @CrudReplace({ command: CompanyOps.CrudReplaceCommand }) + replace( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: companyCreateSchema }) + dto: z.infer, + ) { + return this.crudResolver.replace(context, dto); + } + + @CrudDelete({ + response: { returnDeleted: true }, + command: CompanyOps.CrudDeleteCommand, + }) + delete(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.delete(context); + } + + @CrudSoftDelete({ + path: ':id/soft', + response: { returnDeleted: true }, + command: CompanyOps.CrudSoftDeleteCommand, + }) + softDelete(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.softDelete(context); + } + + @CrudRestore({ + path: ':id/restore', + command: CompanyOps.CrudRestoreCommand, + }) + restore(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.restore(context); + } + + @CrudRestore({ + path: ':id/restore-with-body', + response: { returnRestored: true }, + command: CompanyOps.CrudRestoreCommand, + }) + restoreWithBody( + @Ctx(CrudCtx) context: CrudContextInterface, + ) { + return this.crudResolver.restore(context); + } + + @CrudDelete({ + path: ':id/silent', + command: CompanyOps.CrudDeleteCommand, + }) + deleteSilent(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.delete(context); + } + } + + @CrudController({ + path: 'devices', + entity: CRUD_TEST_DEVICE_ENTITY_NAME, + adapter: CrudAdapter, + request: { + body: deviceSchema, + params: { + deviceKey: { + field: 'deviceKey', + type: 'uuid', + primary: true, + }, + }, + }, + response: { resource: deviceSchema }, + }) + class DevicesController { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudCreate({ + command: DeviceOps.CrudCreateCommand, + }) + create( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: deviceCreateSchema }) + dto: z.infer, + ) { + return this.crudResolver.create(context, dto); + } + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ ...ormSqliteConfig, logging: false }), + TypeOrmModule.forFeature([ProjectEntity, UserEntity]), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: CRUD_TEST_COMPANY_ENTITY_NAME, entity: CompanyEntity }, + { key: CRUD_TEST_DEVICE_ENTITY_NAME, entity: DeviceEntity }, + ], + }), + CrudModule.forRoot({}), + ], + controllers: [CompaniesController, DevicesController], + providers: [ + createCrudAdapterProvider({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + }), + createCrudAdapterProvider({ + entity: CRUD_TEST_DEVICE_ENTITY_NAME, + adapter: CrudAdapter, + }), + CompanyListHandler, + CompanyReadHandler, + CompanyCreateHandler, + CompanyCreateBatchHandler, + CompanyUpdateHandler, + CompanyReplaceHandler, + CompanyDeleteHandler, + CompanySoftDeleteHandler, + CompanyRestoreHandler, + DeviceCreateHandler, + ], + }).compile(); + + app = fixture.createNestApplication(); + + await app.init(); + server = app.getHttpServer(); + + const datasource = app.get(getDataSourceToken()); + const seeds = new Seeds(); + await seeds.up(datasource.createQueryRunner()); + }); + + beforeEach(() => { + qb = CrudQueryBuilder.create(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('#list', () => { + it('should return an array of all entities', async () => { + const res = await request(server) + .get('/companies?includeDeleted=1') + .expect(200); + expect(res.body).toEqual({ + data: expect.any(Array), + count: 10, + total: 10, + page: 1, + pageCount: 1, + limit: 10, + }); + expect(res.body.data).toHaveLength(10); + }); + it('should return an entities with limit', async () => { + const query = qb.setLimit(5).query(); + const res = await request(server) + .get('/companies') + .query(query) + .expect(200); + expect(res.body).toEqual({ + data: expect.any(Array), + count: 5, + total: 9, + page: 1, + pageCount: 2, + limit: 5, + }); + expect(res.body.data).toHaveLength(5); + }); + it('should return an entities with limit and page', async () => { + const query = qb + .setLimit(3) + .setPage(1) + .sortBy({ field: 'id', order: 'DESC' }) + .query(); + const res = await request(server) + .get('/companies') + .query(query) + .expect(200); + expect(res.body).toEqual({ + data: expect.any(Array), + count: 3, + total: 9, + page: 1, + pageCount: 3, + limit: 3, + }); + expect(res.body.data).toHaveLength(3); + }); + it('should return an entities with offset', async () => { + const queryObj = qb.setOffset(3); + if (isMysql) { + queryObj.setLimit(10); + } + const query = queryObj.query(); + const res = await request(server) + .get('/companies') + .query(query) + .expect(200); + if (isMysql) { + expect(res.body).toEqual({ + data: expect.any(Array), + count: 6, + total: 9, + page: 1, + pageCount: 1, + limit: 10, + }); + expect(res.body.data).toHaveLength(6); + } else { + expect(res.body).toEqual({ + data: expect.any(Array), + count: 6, + total: 9, + page: 1, + pageCount: 1, + limit: 9, + }); + expect(res.body.data).toHaveLength(6); + } + }); + }); + + describe('#read', () => { + it('should return status 404', () => { + return request(server).get('/companies/333').expect(404); + }); + it('should return status 404 for deleted entity', () => { + return request(server).get('/companies/9').expect(404); + }); + it('should return a deleted entity if includeDeleted query param is specified', () => { + return request(server) + .get('/companies/9?includeDeleted=1') + .expect(200) + .expect({ + id: 9, + name: 'Name9', + domain: 'Domain9', + description: null, + }); + }); + it('should return an entity, 1', () => { + return request(server).get('/companies/1').expect(200).expect({ + id: 1, + name: 'Name1', + domain: 'Domain1', + description: null, + }); + }); + it('should return an entity, 2', () => { + const query = qb.select(['domain']).query(); + return request(server) + .get('/companies/1') + .query(query) + .expect(200) + .expect({ + id: 1, + name: 'Name1', + domain: 'Domain1', + description: null, + }); + }); + }); + + describe('#create', () => { + it('should return status 400', () => { + return request(server).post('/companies').send('').expect(400); + }); + it('should return saved entity', async () => { + const dto = { + name: 'test0', + domain: 'test0', + }; + const res = await request(server) + .post('/companies') + .send(dto) + .expect(201); + expect(res.body).toEqual({ + id: expect.any(Number), + name: 'test0', + domain: 'test0', + description: null, + }); + }); + it('should return saved entity with description', async () => { + const dto = { + name: 'test_verify', + domain: 'test_verify', + description: 'test_desc', + }; + const res = await request(server) + .post('/companies') + .send(dto) + .expect(201); + expect(res.body).toEqual({ + id: expect.any(Number), + name: 'test_verify', + domain: 'test_verify', + description: 'test_desc', + }); + }); + }); + + describe('#createBatch', () => { + it('should return status 400', () => { + return request(server) + .post('/companies/bulk') + .send({ bulk: [] }) + .expect(400); + }); + it('should return created entities with matching fields', async () => { + const dto = { + bulk: [ + { name: 'test1', domain: 'test1' }, + { name: 'test2', domain: 'test2' }, + ], + }; + const res = await request(server) + .post('/companies/bulk') + .send(dto) + .expect(201); + expect(res.body).toEqual([ + { + id: expect.any(Number), + name: 'test1', + domain: 'test1', + description: null, + }, + { + id: expect.any(Number), + name: 'test2', + domain: 'test2', + description: null, + }, + ]); + }); + }); + + describe('#update', () => { + it('should return status 404', () => { + return request(server) + .patch('/companies/333') + .send({ name: 'updated0' }) + .expect(404); + }); + it('should return updated entity, 1', () => { + return request(server) + .patch('/companies/1') + .send({ name: 'updated0' }) + .expect(200) + .expect({ + id: 1, + name: 'updated0', + domain: 'Domain1', + description: null, + }); + }); + it('should preserve unmodified fields', () => { + return request(server) + .patch('/companies/2') + .send({ name: 'updated2' }) + .expect(200) + .expect({ + id: 2, + name: 'updated2', + domain: 'Domain2', + description: null, + }); + }); + }); + + describe('#replace', () => { + it('should return 404 for non-existent entity', () => { + return request(server) + .put('/companies/333') + .send({ name: 'updated0', domain: 'domain0' }) + .expect(404); + }); + it('should return updated entity, 1', () => { + return request(server) + .put('/companies/1') + .send({ name: 'replaced0', domain: 'ReplacedDomain' }) + .expect(200) + .expect({ + id: 1, + name: 'replaced0', + domain: 'ReplacedDomain', + description: null, + }); + }); + }); + + describe('#delete (hard delete)', () => { + it('should return status 404 for non-existent entity', () => { + return request(server).delete('/companies/3333').expect(404); + }); + it('should permanently delete entity and return it', () => { + return request(server).delete('/companies/8').expect(200).expect({ + name: 'Name8', + domain: 'Domain8', + description: null, + }); + }); + it('should not return permanently deleted entity', () => { + return request(server).get('/companies/8').expect(404); + }); + it('should not return permanently deleted entity even with includeDeleted', () => { + return request(server).get('/companies/8?includeDeleted=1').expect(404); + }); + it('should delete without body (returnDeleted=false)', () => { + return request(server) + .delete('/companies/7/silent') + .expect(204) + .expect(''); + }); + }); + + describe('#softDelete', () => { + it('should return status 404 for non-existent entity', () => { + return request(server).delete('/companies/3333/soft').expect(404); + }); + it('should softly delete entity and return it', () => { + return request(server).delete('/companies/5/soft').expect(200).expect({ + id: 5, + name: 'Name5', + domain: 'Domain5', + description: null, + }); + }); + it('should not return softly deleted entity', () => { + return request(server).get('/companies/5').expect(404); + }); + it('should restore softly deleted entity without body (returnRestored=false)', () => { + return request(server) + .patch('/companies/5/restore') + .expect(204) + .expect(''); + }); + it('should return restored entity via read', () => { + return request(server).get('/companies/5').expect(200).expect({ + id: 5, + name: 'Name5', + domain: 'Domain5', + description: null, + }); + }); + it('should restore and return entity (returnRestored=true)', async () => { + // Soft-delete company 6 first, then restore with body + await request(server).delete('/companies/6/soft').expect(200); + const res = await request(server) + .patch('/companies/6/restore-with-body') + .expect(200); + + expect(res.body).toEqual({ + id: 6, + name: 'Name6', + domain: 'Domain6', + description: null, + }); + + // Verify entity is actually restored (not stale soft-deleted data) + await request(server).get('/companies/6').expect(200).expect({ + id: 6, + name: 'Name6', + domain: 'Domain6', + description: null, + }); + }); + }); + + describe('#device create (UUID primary key)', () => { + it('should create device with auto-generated UUID', async () => { + const res = await request(server) + .post('/devices') + .send({ description: 'Test device' }) + .expect(201); + expect(res.body).toEqual({ + deviceKey: expect.any(String), + description: 'Test device', + }); + expect(z.uuid().safeParse(res.body.deviceKey).success).toBe(true); + }); + + // Regression test for #466: a create body that validates to `{}` was + // rejected with a bare 400 even though `deviceCreateSchema` declares + // every field optional — every column here is server-populated. + it('should create device with an empty body (issue #466)', async () => { + const res = await request(server).post('/devices').send({}).expect(201); + expect(res.body).toEqual({ + deviceKey: expect.any(String), + description: null, + }); + expect(z.uuid().safeParse(res.body.deviceKey).success).toBe(true); + }); + }); + }); + + describe('#transactions', () => { + let app: INestApplication; + let server: ReturnType; + let datasource: DataSource; + + // Track if context.trx was set + let trxWasSet = false; + + // Controller that verifies @Transactional sets context.trx + @CrudController({ + path: 'tx-companies', + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + request: { + body: companySchema, + params: { + id: { field: 'id', type: 'number', primary: true }, + }, + }, + response: { resource: companySchema }, + }) + class TxCompaniesController { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudCreate({ command: CompanyOps.CrudCreateCommand }) + @Transactional() + async create( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: companyCreateSchema }) + dto: z.infer, + ) { + // Record whether trx was set by the interceptor + trxWasSet = (context as unknown as { trx: unknown }).trx !== null; + return this.crudResolver.create(context, dto); + } + + @CrudCreate({ + path: 'with-error', + command: CompanyOps.CrudCreateCommand, + }) + @Transactional() + async createWithError( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: companyCreateSchema }) + dto: z.infer, + ) { + await this.crudResolver.create(context, dto); + // Throw error after create to trigger rollback + throw new Error('Intentional rollback'); + } + + @CrudCreate({ + path: 'multiple-with-error', + command: CompanyOps.CrudCreateCommand, + // This handler returns { first, second, third } (three whole + // companies), not a single companySchema-shaped resource, so the + // controller-level response.resource schema doesn't apply here — + // give this operation its own matching response schema, same + // reasoning as the CreateBatch array response above. + response: { + serialization: { + resource: z.object({ + first: companySchema, + second: companySchema, + third: companySchema, + }), + }, + }, + }) + @Transactional() + async createMultipleWithError( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: companyCreateSchema }) + dto: z.infer, + ) { + // Create first entity - get back the created entity with ID + const first = await this.crudResolver.create(context, { + ...dto, + name: `${dto.name}_first`, + domain: 'test1.com', + }); + + // Create second entity + const second = await this.crudResolver.create(context, { + ...dto, + name: `${dto.name}_second`, + domain: 'test2.com', + }); + + // Create third entity + const third = await this.crudResolver.create(context, { + ...dto, + name: `${dto.name}_third`, + domain: 'test3.com', + }); + + // Verify all entities were created (have IDs assigned by DB) + if (!first.id || !second.id || !third.id) { + throw new Error('Entities were not created properly'); + } + + // Return all - transaction should COMMIT + return { first, second, third }; + } + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ ...ormSqliteConfig, logging: false }), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: CRUD_TEST_COMPANY_ENTITY_NAME, entity: CompanyEntity }, + ], + }), + CrudModule.forRoot({}), + ], + controllers: [TxCompaniesController], + providers: [ + createCrudAdapterProvider({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + }), + CompanyCreateHandler, + ], + }).compile(); + + app = fixture.createNestApplication(); + await app.init(); + server = app.getHttpServer(); + datasource = app.get(getDataSourceToken()); + }); + + beforeEach(() => { + trxWasSet = false; + }); + + afterAll(async () => { + await app.close(); + }); + + it('should set context.trx when @Transactional is used', async () => { + const uniqueName = `TxTest_${Date.now()}`; + + const res = await request(server) + .post('/tx-companies') + .send({ name: uniqueName, domain: 'test.com', description: 'test' }); + + expect(res.status).toBe(201); + expect(trxWasSet).toBe(true); + }); + + it('should rollback on error when @Transactional is used', async () => { + const uniqueName = `TxRollbackTest_${Date.now()}`; + + // Attempt to create - should fail with our intentional error + const res = await request(server) + .post('/tx-companies/with-error') + .send({ name: uniqueName, domain: 'test.com', description: 'test' }); + + expect(res.status).toBe(500); + + // Verify the entity was NOT persisted (transaction rolled back) + const found = await datasource + .getRepository(CompanyEntity) + .findOne({ where: { name: uniqueName } }); + + expect(found).toBeNull(); + }); + + it('should commit ALL entities when transaction succeeds', async () => { + const uniquePrefix = `TxMultiCommit_${Date.now()}`; + + // Create multiple entities - all 3 should be committed + const res = await request(server) + .post('/tx-companies/multiple-with-error') + .send({ + name: uniquePrefix, + domain: 'test.com', + description: 'test', + }); + + expect(res.status).toBe(201); + + // Verify ALL entities were persisted (committed) + const first = await datasource + .getRepository(CompanyEntity) + .findOne({ where: { name: `${uniquePrefix}_first` } }); + const second = await datasource + .getRepository(CompanyEntity) + .findOne({ where: { name: `${uniquePrefix}_second` } }); + const third = await datasource + .getRepository(CompanyEntity) + .findOne({ where: { name: `${uniquePrefix}_third` } }); + + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(third).not.toBeNull(); + }); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/crud-spec.factory.spec.ts b/packages/nestjs-crud/src/__tests__/crud-spec.factory.spec.ts new file mode 100644 index 000000000..c7f00a594 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/crud-spec.factory.spec.ts @@ -0,0 +1,471 @@ +import { ActionEnum, Operation, Spec } from '@concepta/nestjs-core'; + +import { ActionSpecification } from '../infrastructure/specifications/action.specification.js'; +import { CrudSpec } from '../infrastructure/specifications/crud-spec.factory.js'; +import { type CrudSpecContextInterface } from '../infrastructure/specifications/interfaces/crud-spec-context.interface.js'; +import { OperationSpecification } from '../infrastructure/specifications/operation.specification.js'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Test Context Factory +// ═══════════════════════════════════════════════════════════════════════════ + +function createContext( + operation: Operation, + action: ActionEnum, +): CrudSpecContextInterface { + return { operation, action }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +describe('CrudSpec factory', () => { + describe('inherited base specifications', () => { + it('should expose Spec.always()', () => { + const spec = CrudSpec.always(); + const ctx = createContext(Operation.List, ActionEnum.READ); + + expect(spec.isSatisfiedBy(ctx)).toBe(true); + }); + + it('should expose Spec.never()', () => { + const spec = CrudSpec.never(); + const ctx = createContext(Operation.List, ActionEnum.READ); + + expect(spec.isSatisfiedBy(ctx)).toBe(false); + }); + + it('should expose Spec.and()', () => { + const spec = CrudSpec.and(CrudSpec.always(), CrudSpec.never()); + const ctx = createContext(Operation.List, ActionEnum.READ); + + expect(spec.isSatisfiedBy(ctx)).toBe(false); + }); + + it('should expose Spec.or()', () => { + const spec = CrudSpec.or(CrudSpec.always(), CrudSpec.never()); + const ctx = createContext(Operation.List, ActionEnum.READ); + + expect(spec.isSatisfiedBy(ctx)).toBe(true); + }); + + it('should expose Spec.not()', () => { + const spec = CrudSpec.not(CrudSpec.never()); + const ctx = createContext(Operation.List, ActionEnum.READ); + + expect(spec.isSatisfiedBy(ctx)).toBe(true); + }); + }); + + describe('CrudSpec.operation()', () => { + it('should match single operation', () => { + const spec = CrudSpec.operation(Operation.Create); + + expect( + spec.isSatisfiedBy(createContext(Operation.Create, ActionEnum.CREATE)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Update, ActionEnum.UPDATE)), + ).toBe(false); + }); + + it('should match multiple operations', () => { + const spec = CrudSpec.operation(Operation.Create, Operation.Update); + + expect( + spec.isSatisfiedBy(createContext(Operation.Create, ActionEnum.CREATE)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Update, ActionEnum.UPDATE)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Delete, ActionEnum.DELETE)), + ).toBe(false); + }); + + it('should return OperationSpecification instance', () => { + const spec = CrudSpec.operation(Operation.Create); + + expect(spec).toBeInstanceOf(OperationSpecification); + }); + }); + + describe('CrudSpec.action()', () => { + it('should match single action', () => { + const spec = CrudSpec.action(ActionEnum.CREATE); + + expect( + spec.isSatisfiedBy(createContext(Operation.Create, ActionEnum.CREATE)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.List, ActionEnum.READ)), + ).toBe(false); + }); + + it('should match multiple actions', () => { + const spec = CrudSpec.action(ActionEnum.CREATE, ActionEnum.UPDATE); + + expect( + spec.isSatisfiedBy(createContext(Operation.Create, ActionEnum.CREATE)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Update, ActionEnum.UPDATE)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Delete, ActionEnum.DELETE)), + ).toBe(false); + }); + + it('should return ActionSpecification instance', () => { + const spec = CrudSpec.action(ActionEnum.CREATE); + + expect(spec).toBeInstanceOf(ActionSpecification); + }); + }); + + describe('action shortcuts', () => { + describe('CrudSpec.isCreate()', () => { + it('should match CREATE action', () => { + const spec = CrudSpec.isCreate(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Create, ActionEnum.CREATE), + ), + ).toBe(true); + expect( + spec.isSatisfiedBy( + createContext(Operation.CreateBatch, ActionEnum.CREATE), + ), + ).toBe(true); + expect( + spec.isSatisfiedBy( + createContext(Operation.Update, ActionEnum.UPDATE), + ), + ).toBe(false); + }); + }); + + describe('CrudSpec.isRead()', () => { + it('should match READ action', () => { + const spec = CrudSpec.isRead(); + + expect( + spec.isSatisfiedBy(createContext(Operation.List, ActionEnum.READ)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Read, ActionEnum.READ)), + ).toBe(true); + expect( + spec.isSatisfiedBy( + createContext(Operation.Create, ActionEnum.CREATE), + ), + ).toBe(false); + }); + }); + + describe('CrudSpec.isUpdate()', () => { + it('should match UPDATE action', () => { + const spec = CrudSpec.isUpdate(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Update, ActionEnum.UPDATE), + ), + ).toBe(true); + expect( + spec.isSatisfiedBy( + createContext(Operation.Replace, ActionEnum.UPDATE), + ), + ).toBe(true); + expect( + spec.isSatisfiedBy( + createContext(Operation.Create, ActionEnum.CREATE), + ), + ).toBe(false); + }); + }); + + describe('CrudSpec.isDelete()', () => { + it('should match DELETE action for Delete operation', () => { + const spec = CrudSpec.isDelete(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Delete, ActionEnum.DELETE), + ), + ).toBe(true); + }); + + it('should match DELETE action for SoftDelete operation', () => { + const spec = CrudSpec.isDelete(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.SoftDelete, ActionEnum.DELETE), + ), + ).toBe(true); + }); + + it('should not match non-DELETE actions', () => { + const spec = CrudSpec.isDelete(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Create, ActionEnum.CREATE), + ), + ).toBe(false); + }); + }); + }); + + describe('operation group shortcuts', () => { + describe('CrudSpec.isQuery()', () => { + it('should match List operation', () => { + const spec = CrudSpec.isQuery(); + + expect( + spec.isSatisfiedBy(createContext(Operation.List, ActionEnum.READ)), + ).toBe(true); + }); + + it('should match Read operation', () => { + const spec = CrudSpec.isQuery(); + + expect( + spec.isSatisfiedBy(createContext(Operation.Read, ActionEnum.READ)), + ).toBe(true); + }); + + it('should not match write operations', () => { + const spec = CrudSpec.isQuery(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Create, ActionEnum.CREATE), + ), + ).toBe(false); + expect( + spec.isSatisfiedBy( + createContext(Operation.Update, ActionEnum.UPDATE), + ), + ).toBe(false); + expect( + spec.isSatisfiedBy( + createContext(Operation.Delete, ActionEnum.DELETE), + ), + ).toBe(false); + }); + }); + + describe('CrudSpec.isWrite()', () => { + it('should match Create operation', () => { + const spec = CrudSpec.isWrite(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Create, ActionEnum.CREATE), + ), + ).toBe(true); + }); + + it('should match CreateBatch operation', () => { + const spec = CrudSpec.isWrite(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.CreateBatch, ActionEnum.CREATE), + ), + ).toBe(true); + }); + + it('should match Update operation', () => { + const spec = CrudSpec.isWrite(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Update, ActionEnum.UPDATE), + ), + ).toBe(true); + }); + + it('should match Replace operation', () => { + const spec = CrudSpec.isWrite(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Replace, ActionEnum.UPDATE), + ), + ).toBe(true); + }); + + it('should not match query operations', () => { + const spec = CrudSpec.isWrite(); + + expect( + spec.isSatisfiedBy(createContext(Operation.List, ActionEnum.READ)), + ).toBe(false); + expect( + spec.isSatisfiedBy(createContext(Operation.Read, ActionEnum.READ)), + ).toBe(false); + }); + + it('should not match Delete operation', () => { + const spec = CrudSpec.isWrite(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Delete, ActionEnum.DELETE), + ), + ).toBe(false); + }); + }); + + describe('CrudSpec.isMutation()', () => { + it('should match all write operations', () => { + const spec = CrudSpec.isMutation(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Create, ActionEnum.CREATE), + ), + ).toBe(true); + expect( + spec.isSatisfiedBy( + createContext(Operation.CreateBatch, ActionEnum.CREATE), + ), + ).toBe(true); + expect( + spec.isSatisfiedBy( + createContext(Operation.Update, ActionEnum.UPDATE), + ), + ).toBe(true); + expect( + spec.isSatisfiedBy( + createContext(Operation.Replace, ActionEnum.UPDATE), + ), + ).toBe(true); + }); + + it('should match Delete operation', () => { + const spec = CrudSpec.isMutation(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Delete, ActionEnum.DELETE), + ), + ).toBe(true); + }); + + it('should match SoftDelete operation', () => { + const spec = CrudSpec.isMutation(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.SoftDelete, ActionEnum.DELETE), + ), + ).toBe(true); + }); + + it('should match Restore operation', () => { + const spec = CrudSpec.isMutation(); + + expect( + spec.isSatisfiedBy( + createContext(Operation.Restore, ActionEnum.UPDATE), + ), + ).toBe(true); + }); + + it('should not match query operations', () => { + const spec = CrudSpec.isMutation(); + + expect( + spec.isSatisfiedBy(createContext(Operation.List, ActionEnum.READ)), + ).toBe(false); + expect( + spec.isSatisfiedBy(createContext(Operation.Read, ActionEnum.READ)), + ).toBe(false); + }); + }); + }); + + describe('specification composition', () => { + it('should compose with Spec.and()', () => { + // Match CREATE action AND Create operation + const spec = Spec.and( + CrudSpec.isCreate(), + CrudSpec.operation(Operation.Create), + ); + + expect( + spec.isSatisfiedBy(createContext(Operation.Create, ActionEnum.CREATE)), + ).toBe(true); + expect( + spec.isSatisfiedBy( + createContext(Operation.CreateBatch, ActionEnum.CREATE), + ), + ).toBe(false); + }); + + it('should compose with Spec.or()', () => { + // Match List OR Read operation + const spec = Spec.or( + CrudSpec.operation(Operation.List), + CrudSpec.operation(Operation.Read), + ); + + expect( + spec.isSatisfiedBy(createContext(Operation.List, ActionEnum.READ)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Read, ActionEnum.READ)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Create, ActionEnum.CREATE)), + ).toBe(false); + }); + + it('should compose with Spec.not()', () => { + // Match anything except query operations + const spec = Spec.not(CrudSpec.isQuery()); + + expect( + spec.isSatisfiedBy(createContext(Operation.List, ActionEnum.READ)), + ).toBe(false); + expect( + spec.isSatisfiedBy(createContext(Operation.Create, ActionEnum.CREATE)), + ).toBe(true); + }); + + it('should handle complex nested compositions', () => { + // (isMutation AND NOT isDelete) OR isQuery + const spec = Spec.or( + Spec.and(CrudSpec.isMutation(), Spec.not(CrudSpec.isDelete())), + CrudSpec.isQuery(), + ); + + // Query operations match + expect( + spec.isSatisfiedBy(createContext(Operation.List, ActionEnum.READ)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Read, ActionEnum.READ)), + ).toBe(true); + + // Mutation but not delete matches + expect( + spec.isSatisfiedBy(createContext(Operation.Create, ActionEnum.CREATE)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Update, ActionEnum.UPDATE)), + ).toBe(true); + + // Delete does not match (mutation but excluded) + expect( + spec.isSatisfiedBy(createContext(Operation.Delete, ActionEnum.DELETE)), + ).toBe(false); + }); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/crud.module.forfeature.spec.ts b/packages/nestjs-crud/src/__tests__/crud.module.forfeature.spec.ts new file mode 100644 index 000000000..0f4590b71 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/crud.module.forfeature.spec.ts @@ -0,0 +1,941 @@ +import request from 'supertest'; +import { DataSource } from 'typeorm'; +import { z } from 'zod'; + +import { Get, Inject, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; + +import { Ctx, Operation } from '@concepta/nestjs-core'; +import { + getDynamicRepositoryToken, + RepositoryModule, +} from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { CrudUpdateHandler } from '../application/commands/handlers/crud-update.handler.js'; +import { CrudListHandler } from '../application/queries/handlers/crud-list.handler.js'; +import { CrudListQuery } from '../application/queries/impl/crud-list.query.js'; +import { CrudModule } from '../crud.module.js'; +import { CrudAdapter } from '../infrastructure/adapters/crud.adapter.js'; +import { CrudController } from '../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudCreate } from '../infrastructure/decorators/operations/crud-create.decorator.js'; +import { CrudList } from '../infrastructure/decorators/operations/crud-list.decorator.js'; +import { CrudRead } from '../infrastructure/decorators/operations/crud-read.decorator.js'; +import { CrudUpdate } from '../infrastructure/decorators/operations/crud-update.decorator.js'; +import { CrudCtx } from '../infrastructure/interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { CrudAdapterResolver } from '../infrastructure/resolvers/crud-adapter.resolver.js'; +import { CrudOperationResolver } from '../infrastructure/resolvers/crud-operation.resolver.js'; +import { CrudResolverInterface } from '../infrastructure/resolvers/interfaces/crud-resolver.interface.js'; +import { CrudMetaview } from '../infrastructure/services/crud-metaview.service.js'; +import { getDynamicAdapterToken } from '../infrastructure/utils/crud-infra.utils.js'; + +import { CRUD_TEST_COMPANY_ENTITY_NAME } from '../__fixtures__/crud-test.constants.js'; +import { CompanyEntity } from '../__fixtures__/typeorm/company/company.entity.js'; +import { companyPaginatedSchema } from '../__fixtures__/typeorm/company/schemas/company-paginated.schema.js'; +import { companySchema } from '../__fixtures__/typeorm/company/schemas/company.schema.js'; +import { ormSqliteConfig } from '../__fixtures__/typeorm/orm.sqlite.config.js'; +import { Seeds } from '../__fixtures__/typeorm/seeds.js'; + +describe('CrudModule.forFeature', () => { + /** + * Test 1: 100% Configuration + * + * This test verifies that forFeature can create a fully functional CRUD + * controller entirely from configuration - no custom controller class needed. + * The ConfigurableCrudBuilder generates the class, methods, and applies + * all decorators including `\@Ctx` parameter decorators. + */ + describe('100% configuration (generated controller)', () => { + let testModule: TestingModule; + let app: INestApplication; + let generatedController: unknown; + + beforeAll(async () => { + testModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormSqliteConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: CRUD_TEST_COMPANY_ENTITY_NAME, + entity: CompanyEntity, + }, + ], + }), + CrudModule.forRoot({}), + CrudModule.forFeature({ + crud: { + controller: { + path: 'companies', + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + request: { + body: companySchema, + params: { + id: { field: 'id', type: 'number', primary: true }, + }, + }, + response: { + resource: companySchema, + paginated: companyPaginatedSchema, + }, + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + ], + }, + }), + ], + }).compile(); + + app = testModule.createNestApplication(); + await app.init(); + + // Seed the database + const datasource = testModule.get(getDataSourceToken()); + const seeds = new Seeds(); + await seeds.up(datasource.createQueryRunner()); + + // Get the generated controller from the module container + const container = ( + testModule as unknown as { + container: { + modules: Map< + unknown, + { controllers: Map } + >; + }; + } + ).container; + for (const module of container.modules.values()) { + for (const [, wrapper] of module.controllers) { + if ( + wrapper.instance?.constructor?.name === + `${CRUD_TEST_COMPANY_ENTITY_NAME}Controller` + ) { + generatedController = wrapper.instance; + break; + } + } + } + }); + + afterAll(async () => { + await app?.close(); + }); + + it('should create adapter provider', () => { + const adapter = testModule.get( + getDynamicAdapterToken(CRUD_TEST_COMPANY_ENTITY_NAME), + ); + expect(adapter).toBeDefined(); + expect(adapter).toBeInstanceOf(CrudAdapter); + }); + + it('should generate a controller class', () => { + expect(generatedController).toBeDefined(); + expect(generatedController?.constructor?.name).toBe( + `${CRUD_TEST_COMPANY_ENTITY_NAME}Controller`, + ); + }); + + it('should have correct entity metadata on generated controller', () => { + const reflectionService = new CrudMetaview(); + const entity = reflectionService.getEntity( + generatedController!.constructor, + ); + expect(entity).toBe(CRUD_TEST_COMPANY_ENTITY_NAME); + }); + + it('should have correct adapter metadata on generated controller', () => { + const reflectionService = new CrudMetaview(); + const adapter = reflectionService.getAdapter( + generatedController!.constructor, + ); + // Generated controllers store adapter as a FactoryProvider + expect(adapter).toMatchObject({ + provide: getDynamicAdapterToken(CRUD_TEST_COMPANY_ENTITY_NAME), + inject: [getDynamicRepositoryToken(CRUD_TEST_COMPANY_ENTITY_NAME)], + }); + expect(adapter).toHaveProperty('useFactory'); + }); + + it('should have list method with List operation metadata', () => { + const reflectionService = new CrudMetaview(); + const proto = generatedController as Record; + const operation = reflectionService.getOperation(proto.list); + expect(operation).toBe(Operation.List); + }); + + it('should have read method with Read operation metadata', () => { + const reflectionService = new CrudMetaview(); + const proto = generatedController as Record; + const operation = reflectionService.getOperation(proto.read); + expect(operation).toBe(Operation.Read); + }); + + it('should respond to GET /companies', async () => { + const server = app.getHttpServer(); + const res = await request(server).get('/companies'); + expect(res.status).toBe(200); + expect(res.body.data).toBeInstanceOf(Array); + expect(res.body.data.length).toBeGreaterThan(0); + }); + + it('should respond to GET /companies/:id', async () => { + const server = app.getHttpServer(); + const res = await request(server).get('/companies/1'); + expect(res.status).toBe(200); + expect(res.body.id).toBe(1); + }); + }); + + /** + * Test 2: Pre-decorated Controller Class + * + * This test verifies that forFeature correctly handles a controller class + * that is already fully decorated with `\@CrudController`, operation decorators, + * and parameter decorators. The forFeature extracts handlers from the + * decorated methods and creates the adapter provider. + */ + describe('pre-decorated controller class', () => { + // Pre-decorated controller with all necessary decorators + @CrudController({ + path: 'company-b', + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + request: { + body: companySchema, + params: { id: { field: 'id', type: 'number', primary: true } }, + }, + response: { resource: companySchema, paginated: companyPaginatedSchema }, + }) + class CompanyControllerB { + constructor( + @Inject(CrudAdapterResolver) + private readonly crudResolver: CrudResolverInterface, + ) {} + + @CrudList() + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + + @CrudRead() + read(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.read(context); + } + + @CrudRead({ path: 'custom/:id' }) + customRead(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.read(context); + } + } + + let testModule: TestingModule; + let app: INestApplication; + + beforeAll(async () => { + testModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormSqliteConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: CRUD_TEST_COMPANY_ENTITY_NAME, + entity: CompanyEntity, + }, + ], + }), + CrudModule.forRoot({}), + CrudModule.forFeature({ + crud: { + controller: { class: CompanyControllerB }, + }, + }), + ], + }).compile(); + + app = testModule.createNestApplication(); + await app.init(); + + // Seed the database + const datasource = testModule.get(getDataSourceToken()); + const seeds = new Seeds(); + await seeds.up(datasource.createQueryRunner()); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('should register the controller', () => { + const controller = testModule.get(CompanyControllerB); + expect(controller).toBeDefined(); + expect(controller).toBeInstanceOf(CompanyControllerB); + }); + + it('should create adapter provider from controller metadata', () => { + const adapter = testModule.get( + getDynamicAdapterToken(CRUD_TEST_COMPANY_ENTITY_NAME), + ); + expect(adapter).toBeDefined(); + expect(adapter).toBeInstanceOf(CrudAdapter); + }); + + it('should preserve entity metadata from @CrudController', () => { + const reflectionService = new CrudMetaview(); + const entity = reflectionService.getEntity(CompanyControllerB); + expect(entity).toBe(CRUD_TEST_COMPANY_ENTITY_NAME); + }); + + it('should preserve adapter metadata from @CrudController', () => { + const reflectionService = new CrudMetaview(); + const adapter = reflectionService.getAdapter(CompanyControllerB); + expect(adapter).toBe(CrudAdapter); + }); + + it('should have list method with List operation metadata', () => { + const reflectionService = new CrudMetaview(); + const operation = reflectionService.getOperation( + CompanyControllerB.prototype.list, + ); + expect(operation).toBe(Operation.List); + }); + + it('should have read method with Read operation metadata', () => { + const reflectionService = new CrudMetaview(); + const operation = reflectionService.getOperation( + CompanyControllerB.prototype.read, + ); + expect(operation).toBe(Operation.Read); + }); + + it('should respond to GET /company-b', async () => { + const server = app.getHttpServer(); + const res = await request(server).get('/company-b'); + expect(res.status).toBe(200); + expect(res.body.data).toBeInstanceOf(Array); + }); + + it('should respond to GET /company-b/:id', async () => { + const server = app.getHttpServer(); + const res = await request(server).get('/company-b/1'); + expect(res.status).toBe(200); + expect(res.body.id).toBe(1); + }); + + it('should respond to non-standard method name GET /company-b/custom/:id', async () => { + const server = app.getHttpServer(); + const res = await request(server).get('/company-b/custom/1'); + expect(res.status).toBe(200); + expect(res.body.id).toBe(1); + }); + + it('should have customRead method with Read operation metadata', () => { + const reflectionService = new CrudMetaview(); + const operation = reflectionService.getOperation( + CompanyControllerB.prototype.customRead, + ); + expect(operation).toBe(Operation.Read); + }); + }); + + /** + * Test 3: Hybrid Controller Class with Operations + * + * This test verifies the hybrid pattern where: + * - User provides a minimal controller class with `\@CrudController` + * - Methods have operation decorators but NO parameter decorators + * - Operations augment existing methods and create missing ones + * + * Logic: + * - If method exists with matching action → augment/override its options + * - If method doesn't exist → create new method with implementation + decorators + */ + describe('hybrid controller class with operations', () => { + // Minimal controller - operation decorators but NO @Ctx/@CrudBody parameter decorators + @CrudController({ + path: 'company-c', + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + request: { + body: companySchema, + params: { id: { field: 'id', type: 'number', primary: true } }, + }, + response: { resource: companySchema, paginated: companyPaginatedSchema }, + }) + class CompanyControllerC { + constructor( + @Inject(CrudAdapterResolver) + private readonly crudResolver: CrudResolverInterface, + ) {} + + // Has @CrudList but NO @Ctx - forFeature will add parameter decorators + @CrudList() + list(context: CrudContextInterface) { + return this.crudResolver.list(context); + } + + // Custom method name with operation decorator for read + @CrudRead() + findById(context: CrudContextInterface) { + return this.crudResolver.read(context); + } + + // Mutation method - has @CrudCreate but NO @Ctx/@CrudBody + @CrudCreate() + create( + context: CrudContextInterface, + dto: Partial, + ) { + return this.crudResolver.create(context, dto); + } + } + + let testModule: TestingModule; + let app: INestApplication; + + beforeAll(async () => { + testModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormSqliteConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: CRUD_TEST_COMPANY_ENTITY_NAME, + entity: CompanyEntity, + }, + ], + }), + CrudModule.forRoot({}), + CrudModule.forFeature({ + crud: { + controller: { class: CompanyControllerC }, + operations: [ + // Augment existing list (operation matches) + { operation: Operation.List }, + // Augment existing findById (operation matches, explicit methodName) + { operation: Operation.Read, methodName: 'findById' }, + // Create new read (doesn't exist, uses default name) + { operation: Operation.Read }, + // Augment existing create (mutation, operation matches) + { operation: Operation.Create }, + ], + }, + }), + ], + }).compile(); + + app = testModule.createNestApplication(); + await app.init(); + + // Seed the database + const datasource = testModule.get(getDataSourceToken()); + const seeds = new Seeds(); + await seeds.up(datasource.createQueryRunner()); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('should register the controller', () => { + const controller = testModule.get(CompanyControllerC); + expect(controller).toBeDefined(); + expect(controller).toBeInstanceOf(CompanyControllerC); + }); + + it('should create adapter provider', () => { + const adapter = testModule.get( + getDynamicAdapterToken(CRUD_TEST_COMPANY_ENTITY_NAME), + ); + expect(adapter).toBeDefined(); + expect(adapter).toBeInstanceOf(CrudAdapter); + }); + + it('should have list method with List operation metadata', () => { + const reflectionService = new CrudMetaview(); + const action = reflectionService.getOperation( + CompanyControllerC.prototype.list, + ); + expect(action).toBe(Operation.List); + }); + + it('should have findById method with Read operation metadata', () => { + const reflectionService = new CrudMetaview(); + const action = reflectionService.getOperation( + CompanyControllerC.prototype.findById, + ); + expect(action).toBe(Operation.Read); + }); + + it('should have created read method with Read operation metadata', () => { + const reflectionService = new CrudMetaview(); + // read should be created by forFeature since it didn't exist + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proto = CompanyControllerC.prototype as any; + expect(proto.read).toBeDefined(); + const action = reflectionService.getOperation(proto.read); + expect(action).toBe(Operation.Read); + }); + + it('should have create method with Create operation metadata', () => { + const reflectionService = new CrudMetaview(); + const action = reflectionService.getOperation( + CompanyControllerC.prototype.create, + ); + expect(action).toBe(Operation.Create); + }); + + it('should respond to GET /company-c (augmented list)', async () => { + const server = app.getHttpServer(); + const res = await request(server).get('/company-c'); + expect(res.status).toBe(200); + expect(res.body.data).toBeInstanceOf(Array); + }); + + it('should respond to GET /company-c/:id (created read)', async () => { + const server = app.getHttpServer(); + const res = await request(server).get('/company-c/1'); + expect(res.status).toBe(200); + expect(res.body.id).toBe(1); + }); + + it('should respond to POST /company-c (augmented create)', async () => { + const server = app.getHttpServer(); + // This operation has no op-level request.body, so validation now + // resolves the controller-level companySchema (full entity) through + // the hierarchy, same as docs already did (#467) — description is + // required (nullable, not optional) by that schema. + const dto = { + name: 'New Company', + domain: 'new-company.com', + description: null, + }; + const res = await request(server).post('/company-c').send(dto); + expect(res.status).toBe(201); + expect(res.body.id).toBeDefined(); + expect(res.body.name).toBe('New Company'); + }); + }); + + /** + * Test 4: Custom Schema Override in Operations + * + * This test verifies that operations can override the controller-level + * request body schema with a custom schema that has different validation + * rules. The custom schema enforces stricter validation (e.g., max + * length) that the default schema doesn't have. + */ + describe('custom schema override in operations', () => { + // Custom schema with stricter validation - name max 10 chars + const strictCompanyCreateSchema = z.object({ + name: z.string().min(1).max(10), + domain: z.string().min(1), + }); + + // Custom schema for updates - name max 5 chars + const strictCompanyUpdateSchema = z.object({ + name: z.string().min(1).max(5), + domain: z.string().min(1), + }); + + // Custom command handler that prefixes name with "Updated: " + // No need for @Injectable or constructor - auto-injected if not provided + class CustomUpdateHandler extends CrudUpdateHandler { + async execute( + command: Parameters['execute']>[0], + ): Promise { + // Modify the DTO before passing to parent + const modifiedDto = { + ...command.dto, + name: `Updated: ${command.dto.name}`, + }; + return super.execute({ ...command, dto: modifiedDto }); + } + } + + // Custom query handler that transforms results by appending " (filtered)" to names + class CustomListHandler extends CrudListHandler { + async execute( + query: Parameters['execute']>[0], + ) { + const result = await super.execute(query); + return { + ...result, + data: result.data.map((company) => ({ + ...company, + name: `${company.name} FILTERED`, + })), + }; + } + } + + // Controller uses default companySchema at controller level + // Uses CrudOperationResolver to invoke handlers directly + @CrudController({ + path: 'company-d', + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + request: { + body: companySchema, // Default schema — no max length on name + params: { id: { field: 'id', type: 'number', primary: true } }, + }, + response: { resource: companySchema, paginated: companyPaginatedSchema }, + }) + class CompanyControllerD { + constructor( + @Inject(CrudOperationResolver) + private readonly crudResolver: CrudResolverInterface, + ) {} + + @CrudList() + list(context: CrudContextInterface) { + return this.crudResolver.list(context); + } + + @CrudCreate() + create( + context: CrudContextInterface, + dto: Partial, + ) { + return this.crudResolver.create(context, dto); + } + + @CrudUpdate() + update( + context: CrudContextInterface, + dto: Partial, + ) { + return this.crudResolver.update(context, dto); + } + } + + let testModule: TestingModule; + let app: INestApplication; + + beforeAll(async () => { + testModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormSqliteConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: CRUD_TEST_COMPANY_ENTITY_NAME, + entity: CompanyEntity, + }, + ], + }), + CrudModule.forRoot({}), + CrudModule.forFeature({ + crud: { + controller: { class: CompanyControllerD }, + operations: [ + { + operation: Operation.List, + queryHandler: CustomListHandler, + }, + { + operation: Operation.Create, + request: { body: strictCompanyCreateSchema }, + }, + { + operation: Operation.Update, + request: { body: strictCompanyUpdateSchema }, + commandHandler: CustomUpdateHandler, + }, + ], + }, + }), + ], + }).compile(); + + app = testModule.createNestApplication(); + await app.init(); + + // Seed the database + const datasource = testModule.get(getDataSourceToken()); + const seeds = new Seeds(); + await seeds.up(datasource.createQueryRunner()); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('should have custom schema in create body param metadata', () => { + const reflectionService = new CrudMetaview(); + const bodyParams = reflectionService.getBodyParamOptions( + CompanyControllerD.prototype.create, + ); + expect(bodyParams).toBeDefined(); + expect(bodyParams?.length).toBe(1); + expect(bodyParams?.[0]?.schema).toBe(strictCompanyCreateSchema); + }); + + it('should have custom schema in update body param metadata', () => { + const reflectionService = new CrudMetaview(); + const bodyParams = reflectionService.getBodyParamOptions( + CompanyControllerD.prototype.update, + ); + expect(bodyParams).toBeDefined(); + expect(bodyParams?.length).toBe(1); + expect(bodyParams?.[0]?.schema).toBe(strictCompanyUpdateSchema); + }); + + it('should have custom command handler in update metadata', () => { + const reflectionService = new CrudMetaview(); + const handlerOptions = reflectionService.getCommandHandler( + CompanyControllerD.prototype.update, + ); + expect(handlerOptions).toBeDefined(); + expect(handlerOptions?.resolved).toBe(CustomUpdateHandler); + }); + + it('should have custom query handler in list metadata', () => { + const reflectionService = new CrudMetaview(); + const handlerOptions = reflectionService.getQueryHandler( + CompanyControllerD.prototype.list, + ); + expect(handlerOptions).toBeDefined(); + expect(handlerOptions?.resolved).toBe(CustomListHandler); + }); + + it('should reject data exceeding custom schema constraints', async () => { + const server = app.getHttpServer(); + // Name is 20 chars - exceeds strictCompanyCreateSchema's max(10) + const dto = { name: 'This Name Is Too Long', domain: 'toolong.com' }; + const res = await request(server).post('/company-d').send(dto); + expect(res.status).toBe(400); + expect(res.body.message).toContain( + 'name: Too big: expected string to have <=10 characters', + ); + }); + + it('should accept valid data within custom schema constraints', async () => { + const server = app.getHttpServer(); + // Name is 8 chars - within MaxLength(10) limit for create + const dto = { name: 'ShortOne', domain: 'short.com' }; + const res = await request(server).post('/company-d').send(dto); + expect(res.status).toBe(201); + expect(res.body.id).toBeDefined(); + expect(res.body.name).toBe('ShortOne'); + }); + + it('should reject update data exceeding custom schema constraints', async () => { + const server = app.getHttpServer(); + // Name is 8 chars - exceeds strictCompanyUpdateSchema's max(5) + const dto = { name: 'TooLong1', domain: 'updated.com' }; + const res = await request(server).patch('/company-d/1').send(dto); + expect(res.status).toBe(400); + expect(res.body.message).toContain( + 'name: Too big: expected string to have <=5 characters', + ); + }); + + it('should use custom command handler for update', async () => { + const server = app.getHttpServer(); + // Name is 5 chars - within MaxLength(5) limit for update + // Custom handler prefixes with "Updated: " + const dto = { name: 'Short', domain: 'updated.com' }; + const res = await request(server).patch('/company-d/1').send(dto); + expect(res.status).toBe(200); + expect(res.body.id).toBe(1); + expect(res.body.name).toBe('Updated: Short'); + }); + + it('should use custom query handler for list', async () => { + const server = app.getHttpServer(); + const res = await request(server).get('/company-d'); + expect(res.status).toBe(200); + expect(res.body.data).toBeDefined(); + expect(res.body.data.length).toBeGreaterThan(0); + // Custom handler appends " (filtered)" to each name + res.body.data.forEach((company: CompanyEntity) => { + expect(company.name).toMatch(/(FILTERED)$/); + }); + }); + }); + + describe('module compilation with controller config', () => { + let testModule: TestingModule; + + beforeAll(async () => { + testModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormSqliteConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: CRUD_TEST_COMPANY_ENTITY_NAME, + entity: CompanyEntity, + }, + ], + }), + CrudModule.forRoot({}), + CrudModule.forFeature({ + crud: { + controller: { + path: 'companies', + entity: 'Company', + adapter: CrudAdapter, + request: { body: companySchema }, + response: { + resource: companySchema, + paginated: companyPaginatedSchema, + }, + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + ], + }, + }), + ], + }).compile(); + }); + + afterAll(async () => { + await testModule?.close(); + }); + + it('should create adapter provider', () => { + const adapter = testModule.get(getDynamicAdapterToken('Company')); + expect(adapter).toBeDefined(); + expect(adapter).toBeInstanceOf(CrudAdapter); + }); + }); + + describe('module compilation with custom query handler', () => { + class CustomListHandler extends CrudListHandler { + async execute(_query: CrudListQuery) { + const data: CompanyEntity[] = []; + return { data, count: 0, page: 1, pageCount: 0, total: 0, limit: 10 }; + } + } + + let testModule: TestingModule; + + beforeAll(async () => { + testModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormSqliteConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: CRUD_TEST_COMPANY_ENTITY_NAME, + entity: CompanyEntity, + }, + ], + }), + CrudModule.forRoot({}), + CrudModule.forFeature({ + crud: { + controller: { + path: 'companies', + entity: 'Company', + adapter: CrudAdapter, + request: { body: companySchema }, + response: { + resource: companySchema, + paginated: companyPaginatedSchema, + }, + }, + operations: [ + { + operation: Operation.List, + queryHandler: CustomListHandler, + }, + { operation: Operation.Read }, + ], + }, + }), + ], + }).compile(); + }); + + afterAll(async () => { + await testModule?.close(); + }); + + it('should create adapter provider', () => { + const adapter = testModule.get(getDynamicAdapterToken('Company')); + expect(adapter).toBeDefined(); + expect(adapter).toBeInstanceOf(CrudAdapter); + }); + + it('should register custom query handler', () => { + const adapter = testModule.get(getDynamicAdapterToken('Company')); + expect(adapter).toBeDefined(); + }); + }); + + describe('module compilation with custom controller class', () => { + @CrudController({ + path: 'companies', + entity: 'Company', + adapter: CrudAdapter, + request: { body: companySchema }, + response: { resource: companySchema }, + }) + class CustomCompanyController { + @Get('ping') + ping(): string { + return 'pong'; + } + } + + let testModule: TestingModule; + + beforeAll(async () => { + testModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormSqliteConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: CRUD_TEST_COMPANY_ENTITY_NAME, + entity: CompanyEntity, + }, + ], + }), + CrudModule.forRoot({}), + CrudModule.forFeature({ + crud: { + controller: { class: CustomCompanyController }, + }, + }), + ], + }).compile(); + }); + + afterAll(async () => { + await testModule?.close(); + }); + + it('should register custom controller', () => { + const controller = testModule.get(CustomCompanyController); + expect(controller).toBeDefined(); + expect(controller).toBeInstanceOf(CustomCompanyController); + expect(controller.ping()).toBe('pong'); + }); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/crud.module.spec.ts b/packages/nestjs-crud/src/__tests__/crud.module.spec.ts new file mode 100644 index 000000000..810940089 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/crud.module.spec.ts @@ -0,0 +1,72 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { CRUD_MODULE_SETTINGS_TOKEN } from '../crud.constants.js'; +import { CrudModule } from '../crud.module.js'; +import { type CrudModuleSettingsInterface } from '../infrastructure/config/interfaces/crud-module-settings.interface.js'; + +describe(CrudModule, () => { + let crudModule: CrudModule; + let crudSettings: CrudModuleSettingsInterface; + + describe(CrudModule.register, () => { + beforeAll(async () => { + const testModule = await Test.createTestingModule({ + imports: [CrudModule.register({})], + }).compile(); + + setProviderVars(testModule); + }); + + commonProviderTests(); + }); + + describe(CrudModule.forRoot, () => { + beforeAll(async () => { + const testModule = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + }).compile(); + + setProviderVars(testModule); + }); + + commonProviderTests(); + }); + + describe(CrudModule.registerAsync, () => { + beforeEach(async () => { + const testModule = await Test.createTestingModule({ + imports: [CrudModule.registerAsync({ useFactory: () => ({}) })], + }).compile(); + + setProviderVars(testModule); + }); + + commonProviderTests(); + }); + + describe(CrudModule.forRootAsync, () => { + beforeEach(async () => { + const testModule = await Test.createTestingModule({ + imports: [CrudModule.forRootAsync({ useFactory: () => ({}) })], + }).compile(); + + setProviderVars(testModule); + }); + + commonProviderTests(); + }); + + function setProviderVars(testModule: TestingModule) { + crudModule = testModule.get(CrudModule); + crudSettings = testModule.get( + CRUD_MODULE_SETTINGS_TOKEN, + ); + } + + function commonProviderTests() { + it('providers should be loaded', async () => { + expect(crudModule).toBeInstanceOf(CrudModule); + expect(crudSettings).toBeInstanceOf(Object); + }); + } +}); diff --git a/packages/nestjs-crud/src/__tests__/crud.operations.e2e-spec.ts b/packages/nestjs-crud/src/__tests__/crud.operations.e2e-spec.ts new file mode 100644 index 000000000..5e8ce34d2 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/crud.operations.e2e-spec.ts @@ -0,0 +1,273 @@ +import supertest from 'supertest'; + +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; + +import { SeedingSource } from '@concepta/typeorm-seeding'; + +import { AppCcbCustomModuleFixture } from '../__fixtures__/app-ccb-custom.module.fixture.js'; +import { AppCcbSubModuleFixture } from '../__fixtures__/app-ccb-sub.module.fixture.js'; +import { AppCcbModuleFixture } from '../__fixtures__/app-ccb.module.fixture.js'; +import { AppResolverCqrsModuleFixture } from '../__fixtures__/app-resolver-cqrs.module.fixture.js'; +import { AppResolverOperationModuleFixture } from '../__fixtures__/app-resolver-operation.module.fixture.js'; +import { AppModuleFixture } from '../__fixtures__/app.module.fixture.js'; +import { default as ormConfig } from '../__fixtures__/ormconfig.fixture.js'; +import { type PhotoFixture } from '../__fixtures__/photo/photo.entity.fixture.js'; +import { PhotoFactoryFixture } from '../__fixtures__/photo/photo.factory.fixture.js'; +import { PhotoSeederFixture } from '../__fixtures__/photo/photo.seeder.fixture.js'; + +// Picks exactly the fields `photoSchema` exposes — the schema-based +// replacement for the legacy `plainToInstance(PhotoDtoFixture, photo, { +// excludeExtraneousValues: true })` + `instanceToPlain(...)` round trip. +const toPhotoBody = (photo: PhotoFixture) => ({ + id: photo.id, + name: photo.name, + description: photo.description, + filename: photo.filename, + views: photo.views, + isPublished: photo.isPublished, + deletedAt: photo.deletedAt, +}); + +/** + * Consolidated CRUD Operations E2E Test + * + * Tests all 9 CRUD operations against all fixture variations: + * - List, Read, Create, CreateBatch, Update, Replace, Delete, SoftDelete, Restore + * + * Fixture variations: + * 1. AppModuleFixture - forFeature pattern with manual controller + * 2. AppCcbModuleFixture - ConfigurableCrudBuilder generated controller + * 3. AppCcbCustomModuleFixture - ConfigurableCrudBuilder pre-decorated controller + * 4. AppCcbSubModuleFixture - ConfigurableCrudBuilder subclass controller + * 5. AppResolverOperationModuleFixture - CrudOperationResolver + * 6. AppResolverCqrsModuleFixture - CrudCqrsResolver + */ +describe.each([ + { name: 'forFeature', testModule: AppModuleFixture }, + { name: 'CCB Generated', testModule: AppCcbModuleFixture }, + { name: 'CCB Pre-decorated', testModule: AppCcbCustomModuleFixture }, + { name: 'CCB Subclass', testModule: AppCcbSubModuleFixture }, + { name: 'Operation Resolver', testModule: AppResolverOperationModuleFixture }, + { name: 'CQRS Resolver', testModule: AppResolverCqrsModuleFixture }, +])('CRUD Operations ($name)', ({ testModule }) => { + let app: INestApplication; + let seedingSource: SeedingSource; + let photoFactory: PhotoFactoryFixture; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [TypeOrmModule.forRoot(ormConfig), testModule], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + + const dataSource = app.get(getDataSourceToken()); + seedingSource = new SeedingSource({ dataSource }); + await seedingSource.initialize(); + photoFactory = new PhotoFactoryFixture({ seedingSource }); + await seedingSource.run.one(PhotoSeederFixture); + }); + + afterEach(async () => { + vi.clearAllMocks(); + return app ? await app.close() : undefined; + }); + + const expectedPhotoShape = { + id: expect.any(String), + name: expect.any(String), + description: expect.any(String), + filename: expect.any(String), + views: expect.any(Number), + isPublished: expect.any(Boolean), + deletedAt: null, + }; + + describe('List', () => { + it('GET /photo?limit=10', async () => { + const response = await supertest(app.getHttpServer()) + .get('/photo?limit=10') + .expect(200); + + const { data, ...envelope } = response.body; + expect(envelope).toEqual({ + count: 10, + total: 15, + page: 1, + pageCount: 2, + limit: 10, + }); + expect(data).toHaveLength(10); + data.forEach((item: Record) => { + expect(item).toEqual(expectedPhotoShape); + }); + }); + + it('GET /photo?limit=10&page=1', async () => { + const response = await supertest(app.getHttpServer()) + .get('/photo?limit=10&page=1') + .expect(200); + + const { data, ...envelope } = response.body; + expect(envelope).toEqual({ + count: 10, + total: 15, + page: 1, + pageCount: 2, + limit: 10, + }); + expect(data).toHaveLength(10); + data.forEach((item: Record) => { + expect(item).toEqual(expectedPhotoShape); + }); + }); + }); + + describe('Read', () => { + it('GET /photo/:id', async () => { + const photo = await photoFactory.create(); + + const response = await supertest(app.getHttpServer()) + .get(`/photo/${photo.id}`) + .expect(200); + + expect(response.body).toEqual(toPhotoBody(photo)); + }); + + it('GET /photo/:id returns 404 for non-existent', async () => { + await supertest(app.getHttpServer()) + .get('/photo/00000000-0000-0000-0000-000000000000') + .expect(404); + }); + }); + + describe('Create', () => { + it('POST /photo', async () => { + const photo = await photoFactory.make(); + const { id: _id, deletedAt: _del, ...createBody } = toPhotoBody(photo); + + const response = await supertest(app.getHttpServer()) + .post('/photo') + .send(createBody) + .expect(201); + + expect(response.body).toEqual({ + ...createBody, + id: expect.any(String), + deletedAt: null, + }); + }); + }); + + describe('CreateBatch', () => { + it('POST /photo/bulk', async () => { + const photos = await photoFactory.createMany(5); + + const response = await supertest(app.getHttpServer()) + .post('/photo/bulk') + .send({ bulk: photos }) + .expect(201); + + expect(response.body).toEqual( + photos.map((photo) => ({ + ...toPhotoBody(photo), + id: expect.any(String), + })), + ); + }); + }); + + describe('Update', () => { + it('PATCH /photo/:id', async () => { + const photo = await photoFactory.create(); + photo.views = 37; + + const expected = toPhotoBody(photo); + const { id, deletedAt: _del, ...updateBody } = expected; + + const response = await supertest(app.getHttpServer()) + .patch(`/photo/${id}`) + .send(updateBody) + .expect(200); + + expect(response.body).toEqual(expected); + }); + }); + + describe('Replace', () => { + it('PUT /photo/:id', async () => { + const photo = await photoFactory.create(); + const expected = toPhotoBody(photo); + const { id, deletedAt: _del, ...replaceBody } = expected; + + const response = await supertest(app.getHttpServer()) + .put(`/photo/${id}`) + .send(replaceBody) + .expect(200); + + expect(response.body).toEqual(expected); + }); + }); + + describe('Delete (hard)', () => { + it('DELETE /photo/:id', async () => { + const photo = await photoFactory.create(); + + await supertest(app.getHttpServer()) + .delete(`/photo/${photo.id}`) + .expect(204) + .expect(''); + + await supertest(app.getHttpServer()) + .get(`/photo/${photo.id}`) + .expect(404); + }); + }); + + describe('SoftDelete', () => { + it('DELETE /photo/soft/:id', async () => { + const photo = await photoFactory.create(); + + await supertest(app.getHttpServer()) + .delete(`/photo/soft/${photo.id}`) + .expect(204) + .expect(''); + + await supertest(app.getHttpServer()) + .get(`/photo/${photo.id}`) + .expect(404); + }); + }); + + describe('Restore', () => { + it('PATCH /photo/restore/:id after soft delete', async () => { + const photo = await photoFactory.create(); + const expected = toPhotoBody(photo); + + // Soft delete + await supertest(app.getHttpServer()) + .delete(`/photo/soft/${photo.id}`) + .expect(204); + + // Verify not found + await supertest(app.getHttpServer()) + .get(`/photo/${photo.id}`) + .expect(404); + + // Restore (returnRestored defaults to false) + await supertest(app.getHttpServer()) + .patch(`/photo/restore/${photo.id}`) + .expect(204) + .expect(''); + + // Verify found again + const readResponse = await supertest(app.getHttpServer()) + .get(`/photo/${photo.id}`) + .expect(200); + + expect(readResponse.body).toEqual(expected); + }); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/d.federated-crud.spec.ts b/packages/nestjs-crud/src/__tests__/d.federated-crud.spec.ts new file mode 100644 index 000000000..3c3e1dd91 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/d.federated-crud.spec.ts @@ -0,0 +1,217 @@ +import request from 'supertest'; +import { DataSource } from 'typeorm'; + +import { Inject, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; + +import { Ctx } from '@concepta/nestjs-core'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { CrudListHandler } from '../application/queries/handlers/crud-list.handler.js'; +import { createQueryHandler } from '../application/utils/create-operation-handlers.js'; +import { CrudModule } from '../crud.module.js'; +import { CrudAdapter } from '../infrastructure/adapters/crud.adapter.js'; +import { CrudController } from '../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudList } from '../infrastructure/decorators/operations/crud-list.decorator.js'; +import { CrudJoin } from '../infrastructure/decorators/routes/crud-join.decorator.js'; +import { CrudLimit } from '../infrastructure/decorators/routes/crud-limit.decorator.js'; +import { CrudSort } from '../infrastructure/decorators/routes/crud-sort.decorator.js'; +import { CrudCtx } from '../infrastructure/interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { CrudAdapterResolver } from '../infrastructure/resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../infrastructure/resolvers/interfaces/crud-resolver.interface.js'; +import { createCrudAdapterProvider } from '../infrastructure/utils/create-crud-adapter-provider.js'; + +import { createCrudOperationClasses } from '../__fixtures__/crud/create-crud-operation-classes.fixture.js'; +import { + CRUD_TEST_COMPANY_ENTITY_NAME, + CRUD_TEST_USER_ENTITY_NAME, +} from '../__fixtures__/crud-test.constants.js'; +import { CompanyEntity } from '../__fixtures__/typeorm/company/company.entity.js'; +import { companyPaginatedSchema } from '../__fixtures__/typeorm/company/schemas/company-paginated.schema.js'; +import { companySchema } from '../__fixtures__/typeorm/company/schemas/company.schema.js'; +import { ormSqliteConfig } from '../__fixtures__/typeorm/orm.sqlite.config.js'; +import { Seeds } from '../__fixtures__/typeorm/seeds.js'; +import { UserEntity } from '../__fixtures__/typeorm/users/user.entity.js'; + +const CompanyOps = createCrudOperationClasses( + CRUD_TEST_COMPANY_ENTITY_NAME, +); +const UserOps = createCrudOperationClasses( + CRUD_TEST_USER_ENTITY_NAME, +); + +const CompanyListHandler = createQueryHandler({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + baseClass: CrudListHandler, + queryClass: CompanyOps.CrudListQuery, +}); +const UserListHandler = createQueryHandler({ + entity: CRUD_TEST_USER_ENTITY_NAME, + baseClass: CrudListHandler, + queryClass: UserOps.CrudListQuery, +}); + +/** + * E2E tests for federated CRUD queries through the controller layer. + * + * Company → Users is a one-to-many relationship. With `federated: true` + * in forFeature(), the orchestrator issues separate queries and hydrates + * the results — no DB-level JOIN. + */ +describe('#federated-crud', () => { + let app: INestApplication; + let server: ReturnType; + + @CrudController({ + path: 'companies', + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + response: { resource: companySchema, paginated: companyPaginatedSchema }, + }) + @CrudJoin([{ relation: 'users' }]) + @CrudSort([{ field: 'id', order: 'ASC' }]) + @CrudLimit(10) + class CompaniesController { + constructor( + @Inject(CrudAdapterResolver) + public crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ query: CompanyOps.CrudListQuery }) + list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormSqliteConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: CRUD_TEST_COMPANY_ENTITY_NAME, + entity: CompanyEntity, + relations: { + users: { federated: true }, + }, + }, + { key: CRUD_TEST_USER_ENTITY_NAME, entity: UserEntity }, + ], + }), + CrudModule.forRoot({}), + ], + controllers: [CompaniesController], + providers: [ + createCrudAdapterProvider({ + entity: CRUD_TEST_COMPANY_ENTITY_NAME, + adapter: CrudAdapter, + }), + createCrudAdapterProvider({ + entity: CRUD_TEST_USER_ENTITY_NAME, + adapter: CrudAdapter, + }), + CompanyListHandler, + UserListHandler, + ], + }).compile(); + + app = fixture.createNestApplication(); + await app.init(); + server = app.getHttpServer(); + + const datasource = app.get(getDataSourceToken()); + const seeds = new Seeds(); + await seeds.up(datasource.createQueryRunner()); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('#list with federated join', () => { + it('should hydrate users array on each company', async () => { + const res = await request(server).get('/companies?limit=10').expect(200); + + // Pagination metadata + expect(res.body.count).toBe(9); + expect(res.body.total).toBe(9); + expect(res.body.page).toBe(1); + expect(res.body.pageCount).toBe(1); + expect(res.body.limit).toBe(10); + expect(res.body.data).toHaveLength(9); + + // Company 1 has users 1-10 (10 users) + expect(res.body.data[0].id).toBe(1); + expect(res.body.data[0].name).toBe('Name1'); + expect(res.body.data[0].users).toHaveLength(10); + expect(res.body.data[0].users[0].email).toBe('1@email.com'); + expect(res.body.data[0].users[0].companyId).toBe(1); + + // Company 2 has users 11-21 (11 users) + expect(res.body.data[1].id).toBe(2); + expect(res.body.data[1].name).toBe('Name2'); + expect(res.body.data[1].users).toHaveLength(11); + expect(res.body.data[1].users[0].email).toBe('11@email.com'); + expect(res.body.data[1].users[0].companyId).toBe(2); + + // Companies 3-8, 10 have no users → empty array (LEFT JOIN) + for (let i = 2; i < 9; i++) { + expect(res.body.data[i].users).toEqual([]); + } + }); + + it('should return correct pagination with federation (page 1)', async () => { + const res = await request(server) + .get('/companies?limit=5&page=1') + .expect(200); + + expect(res.body.count).toBe(5); + expect(res.body.total).toBe(9); + expect(res.body.page).toBe(1); + expect(res.body.pageCount).toBe(2); + expect(res.body.limit).toBe(5); + expect(res.body.data).toHaveLength(5); + + // Page 1: companies 1-5 (sorted by id ASC) + expect(res.body.data[0].id).toBe(1); + expect(res.body.data[0].users).toHaveLength(10); + expect(res.body.data[1].id).toBe(2); + expect(res.body.data[1].users).toHaveLength(11); + expect(res.body.data[2].id).toBe(3); + expect(res.body.data[2].users).toEqual([]); + expect(res.body.data[3].id).toBe(4); + expect(res.body.data[3].users).toEqual([]); + expect(res.body.data[4].id).toBe(5); + expect(res.body.data[4].users).toEqual([]); + }); + + it('should return correct pagination with federation (page 2)', async () => { + const res = await request(server) + .get('/companies?limit=5&page=2') + .expect(200); + + expect(res.body.count).toBe(4); + expect(res.body.total).toBe(9); + expect(res.body.page).toBe(2); + expect(res.body.pageCount).toBe(2); + expect(res.body.limit).toBe(5); + expect(res.body.data).toHaveLength(4); + + // Page 2: companies 6-8, 10 (company 9 soft-deleted) + expect(res.body.data[0].id).toBe(6); + expect(res.body.data[0].users).toEqual([]); + expect(res.body.data[1].id).toBe(7); + expect(res.body.data[1].users).toEqual([]); + expect(res.body.data[2].id).toBe(8); + expect(res.body.data[2].users).toEqual([]); + expect(res.body.data[3].id).toBe(10); + expect(res.body.data[3].users).toEqual([]); + }); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/exception-fault.spec.ts b/packages/nestjs-crud/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..a70334933 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,75 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { CrudContextException } from '../infrastructure/exceptions/crud-context.exception.js'; +import { CrudDecoratorException } from '../infrastructure/exceptions/crud-decorator.exception.js'; +import { CrudQueryException } from '../infrastructure/exceptions/crud-query.exception.js'; +import { CrudException } from '../infrastructure/exceptions/crud.exception.js'; +import { CrudQueryParserException } from '../infrastructure/request/exceptions/crud-query-parser.exception.js'; +import { CrudQueryValidatorException } from '../infrastructure/request/exceptions/crud-query-validator.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. Does not cover call-site `fault` overrides on + * the generic `CrudException` (e.g. the decorator-time sanity checks) — + * those are functional/behavioral, not class-level, classifications. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'CrudException (default)', + build: () => new CrudException(), + fault: 'internal', + }, + { + name: 'CrudContextException', + build: () => new CrudContextException(), + fault: 'internal', + }, + { + name: 'CrudDecoratorException', + build: () => new CrudDecoratorException(), + fault: 'usage', + }, + { + name: 'CrudQueryException', + build: () => new CrudQueryException('SomeEntity'), + fault: 'internal', + }, + { + name: 'CrudQueryParserException', + build: () => new CrudQueryParserException(), + fault: 'client', + }, + { + name: 'CrudQueryValidatorException', + build: () => new CrudQueryValidatorException(), + fault: 'client', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/openapi-types.ts b/packages/nestjs-crud/src/__tests__/openapi-types.ts new file mode 100644 index 000000000..abb341da9 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/openapi-types.ts @@ -0,0 +1,20 @@ +export type ParameterObject = { + name: string; + in: string; + [k: string]: unknown; +}; +export type OperationObject = { + parameters?: (ParameterObject | { $ref: string })[]; + requestBody?: unknown; + responses?: Record; + operationId?: string; + tags?: string[]; +}; +export type SchemaObject = { + required?: string[]; + properties?: Record; + enum?: unknown[]; + type?: string; + items?: unknown; + format?: string; +}; diff --git a/packages/nestjs-crud/src/__tests__/operation.specification.spec.ts b/packages/nestjs-crud/src/__tests__/operation.specification.spec.ts new file mode 100644 index 000000000..602b290f6 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/operation.specification.spec.ts @@ -0,0 +1,35 @@ +import { ActionEnum, Operation } from '@concepta/nestjs-core'; + +import { type CrudSpecContextInterface } from '../infrastructure/specifications/interfaces/crud-spec-context.interface.js'; +import { OperationSpecification } from '../infrastructure/specifications/operation.specification.js'; + +function createContext( + operation: Operation, + action: ActionEnum, +): CrudSpecContextInterface { + return { operation, action }; +} + +describe('OperationSpecification', () => { + it('should match when operation is in the list', () => { + const spec = new OperationSpecification([ + Operation.Create, + Operation.Update, + ]); + + expect( + spec.isSatisfiedBy(createContext(Operation.Create, ActionEnum.CREATE)), + ).toBe(true); + expect( + spec.isSatisfiedBy(createContext(Operation.Update, ActionEnum.UPDATE)), + ).toBe(true); + }); + + it('should not match when operation is not in the list', () => { + const spec = new OperationSpecification([Operation.Create]); + + expect( + spec.isSatisfiedBy(createContext(Operation.Delete, ActionEnum.DELETE)), + ).toBe(false); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/petstore/controllers/pet.controller.ts b/packages/nestjs-crud/src/__tests__/petstore/controllers/pet.controller.ts new file mode 100644 index 000000000..14a226993 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/petstore/controllers/pet.controller.ts @@ -0,0 +1,88 @@ +import { type z } from 'zod'; + +import { Inject } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { Ctx } from '@concepta/nestjs-core'; + +import { CrudCreateCommand } from '../../../application/commands/impl/crud-create.command.js'; +import { CrudDeleteCommand } from '../../../application/commands/impl/crud-delete.command.js'; +import { CrudReplaceCommand } from '../../../application/commands/impl/crud-replace.command.js'; +import { CrudReadQuery } from '../../../application/queries/impl/crud-read.query.js'; +import { CrudController } from '../../../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudCreate } from '../../../infrastructure/decorators/operations/crud-create.decorator.js'; +import { CrudDelete } from '../../../infrastructure/decorators/operations/crud-delete.decorator.js'; +import { CrudRead } from '../../../infrastructure/decorators/operations/crud-read.decorator.js'; +import { CrudReplace } from '../../../infrastructure/decorators/operations/crud-replace.decorator.js'; +import { CrudBody } from '../../../infrastructure/decorators/params/crud-body.decorator.js'; +import { CrudCtx } from '../../../infrastructure/interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { CrudAdapterResolver } from '../../../infrastructure/resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../../../infrastructure/resolvers/interfaces/crud-resolver.interface.js'; +import { petSchema } from '../schemas/pet.schema.js'; + +type PetType = z.infer; + +@CrudController({ + path: 'pet', + entity: 'Pet', + request: { + body: petSchema, + params: { petId: { field: 'petId', type: 'number', primary: true } }, + }, + response: { resource: petSchema }, +}) +@ApiTags('pet') +export class PetController { + constructor( + @Inject(CrudAdapterResolver) + protected readonly crudResolver: CrudResolverInterface, + ) {} + + @CrudCreate({ + command: CrudCreateCommand, + api: { operation: { operationId: 'addPet' } }, + }) + async addPet( + @Ctx(CrudCtx) ctx: CrudContextInterface, + // `@CrudBody()` itself is required here (a handwritten controller's + // parameter decorator is what wires `StandardSchemaValidationPipe`; + // `ConfigurableCrudBuilder`-generated controllers do this + // automatically) — though since #467 a bare `@CrudBody()` would + // resolve `petSchema` from the controller's `request.body` too; + // pinning it here just makes the parameter's own schema explicit. + @CrudBody({ schema: petSchema }) dto: PetType, + ) { + return this.crudResolver.create(ctx, dto); + } + + @CrudRead({ + query: CrudReadQuery, + path: ':petId', + api: { operation: { operationId: 'getPetById' } }, + }) + async getPetById(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.crudResolver.read(ctx); + } + + @CrudReplace({ + command: CrudReplaceCommand, + path: ':petId', + api: { operation: { operationId: 'updatePetWithForm' } }, + }) + async updatePetWithForm( + @Ctx(CrudCtx) ctx: CrudContextInterface, + @CrudBody({ schema: petSchema }) dto: PetType, + ) { + return this.crudResolver.replace(ctx, dto); + } + + @CrudDelete({ + command: CrudDeleteCommand, + path: ':petId', + api: { operation: { operationId: 'deletePet' } }, + }) + async deletePet(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.crudResolver.delete(ctx); + } +} diff --git a/packages/nestjs-crud/src/__tests__/petstore/controllers/store.controller.ts b/packages/nestjs-crud/src/__tests__/petstore/controllers/store.controller.ts new file mode 100644 index 000000000..493354715 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/petstore/controllers/store.controller.ts @@ -0,0 +1,69 @@ +import { type z } from 'zod'; + +import { Inject } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { Ctx } from '@concepta/nestjs-core'; + +import { CrudCreateCommand } from '../../../application/commands/impl/crud-create.command.js'; +import { CrudDeleteCommand } from '../../../application/commands/impl/crud-delete.command.js'; +import { CrudReadQuery } from '../../../application/queries/impl/crud-read.query.js'; +import { CrudController } from '../../../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudCreate } from '../../../infrastructure/decorators/operations/crud-create.decorator.js'; +import { CrudDelete } from '../../../infrastructure/decorators/operations/crud-delete.decorator.js'; +import { CrudRead } from '../../../infrastructure/decorators/operations/crud-read.decorator.js'; +import { CrudBody } from '../../../infrastructure/decorators/params/crud-body.decorator.js'; +import { CrudCtx } from '../../../infrastructure/interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { CrudAdapterResolver } from '../../../infrastructure/resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../../../infrastructure/resolvers/interfaces/crud-resolver.interface.js'; +import { orderSchema } from '../schemas/order.schema.js'; + +type OrderType = z.infer; + +@CrudController({ + path: 'store/order', + entity: 'Order', + request: { + body: orderSchema, + params: { orderId: { field: 'orderId', type: 'number', primary: true } }, + }, + response: { resource: orderSchema }, +}) +@ApiTags('store') +export class StoreController { + constructor( + @Inject(CrudAdapterResolver) + protected readonly crudResolver: CrudResolverInterface, + ) {} + + @CrudCreate({ + command: CrudCreateCommand, + api: { operation: { operationId: 'placeOrder' } }, + }) + async placeOrder( + @Ctx(CrudCtx) ctx: CrudContextInterface, + // See pet.controller.ts's addPet for why `{ schema }` is required here. + @CrudBody({ schema: orderSchema }) dto: OrderType, + ) { + return this.crudResolver.create(ctx, dto); + } + + @CrudRead({ + query: CrudReadQuery, + path: ':orderId', + api: { operation: { operationId: 'getOrderById' } }, + }) + async getOrderById(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.crudResolver.read(ctx); + } + + @CrudDelete({ + command: CrudDeleteCommand, + path: ':orderId', + api: { operation: { operationId: 'deleteOrder' } }, + }) + async deleteOrder(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.crudResolver.delete(ctx); + } +} diff --git a/packages/nestjs-crud/src/__tests__/petstore/controllers/user.controller.ts b/packages/nestjs-crud/src/__tests__/petstore/controllers/user.controller.ts new file mode 100644 index 000000000..533a24f89 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/petstore/controllers/user.controller.ts @@ -0,0 +1,84 @@ +import { type z } from 'zod'; + +import { Inject } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { Ctx } from '@concepta/nestjs-core'; + +import { CrudCreateCommand } from '../../../application/commands/impl/crud-create.command.js'; +import { CrudDeleteCommand } from '../../../application/commands/impl/crud-delete.command.js'; +import { CrudReplaceCommand } from '../../../application/commands/impl/crud-replace.command.js'; +import { CrudReadQuery } from '../../../application/queries/impl/crud-read.query.js'; +import { CrudController } from '../../../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudCreate } from '../../../infrastructure/decorators/operations/crud-create.decorator.js'; +import { CrudDelete } from '../../../infrastructure/decorators/operations/crud-delete.decorator.js'; +import { CrudRead } from '../../../infrastructure/decorators/operations/crud-read.decorator.js'; +import { CrudReplace } from '../../../infrastructure/decorators/operations/crud-replace.decorator.js'; +import { CrudBody } from '../../../infrastructure/decorators/params/crud-body.decorator.js'; +import { CrudCtx } from '../../../infrastructure/interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { CrudAdapterResolver } from '../../../infrastructure/resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../../../infrastructure/resolvers/interfaces/crud-resolver.interface.js'; +import { userSchema } from '../schemas/user.schema.js'; + +type UserType = z.infer; + +@CrudController({ + path: 'user', + entity: 'User', + request: { + body: userSchema, + params: { username: { field: 'username', type: 'string', primary: true } }, + }, + response: { resource: userSchema }, +}) +@ApiTags('user') +export class UserController { + constructor( + @Inject(CrudAdapterResolver) + protected readonly crudResolver: CrudResolverInterface, + ) {} + + @CrudCreate({ + command: CrudCreateCommand, + api: { operation: { operationId: 'createUser' } }, + }) + async createUser( + @Ctx(CrudCtx) ctx: CrudContextInterface, + // See pet.controller.ts's addPet for why `{ schema }` is required here. + @CrudBody({ schema: userSchema }) dto: UserType, + ) { + return this.crudResolver.create(ctx, dto); + } + + @CrudRead({ + query: CrudReadQuery, + path: ':username', + api: { operation: { operationId: 'getUserByName' } }, + }) + async getUserByName(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.crudResolver.read(ctx); + } + + @CrudReplace({ + command: CrudReplaceCommand, + path: ':username', + api: { operation: { operationId: 'updateUser' } }, + }) + async updateUser( + @Ctx(CrudCtx) ctx: CrudContextInterface, + // See pet.controller.ts's addPet for why `{ schema }` is required here. + @CrudBody({ schema: userSchema }) dto: UserType, + ) { + return this.crudResolver.replace(ctx, dto); + } + + @CrudDelete({ + command: CrudDeleteCommand, + path: ':username', + api: { operation: { operationId: 'deleteUser' } }, + }) + async deleteUser(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.crudResolver.delete(ctx); + } +} diff --git a/packages/nestjs-crud/src/__tests__/petstore/petstore-upstream.json b/packages/nestjs-crud/src/__tests__/petstore/petstore-upstream.json new file mode 100644 index 000000000..0c72f1d28 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/petstore/petstore-upstream.json @@ -0,0 +1,1270 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "Swagger Petstore - OpenAPI 3.0", + "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)", + "termsOfService": "https://swagger.io/terms/", + "contact": { + "email": "apiteam@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.27" + }, + "externalDocs": { + "description": "Find out more about Swagger", + "url": "https://swagger.io" + }, + "servers": [ + { + "url": "/api/v3" + } + ], + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets", + "externalDocs": { + "description": "Find out more", + "url": "https://swagger.io" + } + }, + { + "name": "store", + "description": "Access to Petstore orders", + "externalDocs": { + "description": "Find out more about our store", + "url": "https://swagger.io" + } + }, + { + "name": "user", + "description": "Operations about user" + } + ], + "paths": { + "/pet": { + "put": { + "tags": [ + "pet" + ], + "summary": "Update an existing pet.", + "description": "Update an existing pet by Id.", + "operationId": "updatePet", + "requestBody": { + "description": "Update an existent pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + }, + "422": { + "description": "Validation exception" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Add a new pet to the store.", + "description": "Add a new pet to the store.", + "operationId": "addPet", + "requestBody": { + "description": "Create a new pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "422": { + "description": "Validation exception" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByStatus": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by status.", + "description": "Multiple status values can be provided with comma separated strings.", + "operationId": "findPetsByStatus", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Status values that need to be considered for filter", + "required": true, + "explode": true, + "schema": { + "type": "string", + "default": "available", + "enum": [ + "available", + "pending", + "sold" + ] + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid status value" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByTags": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by tags.", + "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", + "operationId": "findPetsByTags", + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "Tags to filter by", + "required": true, + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid tag value" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}": { + "get": { + "tags": [ + "pet" + ], + "summary": "Find pet by ID.", + "description": "Returns a single pet.", + "operationId": "getPetById", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to return", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "api_key": [] + }, + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Updates a pet in the store with form data.", + "description": "Updates a pet resource based on the form data.", + "operationId": "updatePetWithForm", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet that needs to be updated", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "name", + "in": "query", + "description": "Name of pet that needs to be updated", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "Status of pet that needs to be updated", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "delete": { + "tags": [ + "pet" + ], + "summary": "Deletes a pet.", + "description": "Delete a pet.", + "operationId": "deletePet", + "parameters": [ + { + "name": "api_key", + "in": "header", + "description": "", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "petId", + "in": "path", + "description": "Pet id to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Pet deleted" + }, + "400": { + "description": "Invalid pet value" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}/uploadImage": { + "post": { + "tags": [ + "pet" + ], + "summary": "Uploads an image.", + "description": "Upload image of the pet.", + "operationId": "uploadFile", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "additionalMetadata", + "in": "query", + "description": "Additional Metadata", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "400": { + "description": "No file uploaded" + }, + "404": { + "description": "Pet not found" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/store/inventory": { + "get": { + "tags": [ + "store" + ], + "summary": "Returns pet inventories by status.", + "description": "Returns a map of status codes to quantities.", + "operationId": "getInventory", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/store/order": { + "post": { + "tags": [ + "store" + ], + "summary": "Place an order for a pet.", + "description": "Place a new order in the store.", + "operationId": "placeOrder", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "422": { + "description": "Validation exception" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/store/order/{orderId}": { + "get": { + "tags": [ + "store" + ], + "summary": "Find purchase order by ID.", + "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.", + "operationId": "getOrderById", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of order that needs to be fetched", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + }, + "default": { + "description": "Unexpected error" + } + } + }, + "delete": { + "tags": [ + "store" + ], + "summary": "Delete purchase order by identifier.", + "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.", + "operationId": "deleteOrder", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of the order that needs to be deleted", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "order deleted" + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user": { + "post": { + "tags": [ + "user" + ], + "summary": "Create user.", + "description": "This can only be done by the logged in user.", + "operationId": "createUser", + "requestBody": { + "description": "Created user object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/createWithList": { + "post": { + "tags": [ + "user" + ], + "summary": "Creates list of users with given input array.", + "description": "Creates list of users with given input array.", + "operationId": "createUsersWithListInput", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/login": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs user into the system.", + "description": "Log into the system.", + "operationId": "loginUser", + "parameters": [ + { + "name": "username", + "in": "query", + "description": "The user name for login", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "The password for login in clear text", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "X-Rate-Limit": { + "description": "calls per hour allowed by the user", + "schema": { + "type": "integer", + "format": "int32" + } + }, + "X-Expires-After": { + "description": "date in UTC when token expires", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/xml": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid username/password supplied" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/logout": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs out current logged in user session.", + "description": "Log user out of the system.", + "operationId": "logoutUser", + "parameters": [], + "responses": { + "200": { + "description": "successful operation" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/{username}": { + "get": { + "tags": [ + "user" + ], + "summary": "Get user by user name.", + "description": "Get user detail based on username.", + "operationId": "getUserByName", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be fetched. Use user1 for testing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + }, + "default": { + "description": "Unexpected error" + } + } + }, + "put": { + "tags": [ + "user" + ], + "summary": "Update user resource.", + "description": "This can only be done by the logged in user.", + "operationId": "updateUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "name that need to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Update an existent user in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation" + }, + "400": { + "description": "bad request" + }, + "404": { + "description": "user not found" + }, + "default": { + "description": "Unexpected error" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Delete user resource.", + "description": "This can only be done by the logged in user.", + "operationId": "deleteUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User deleted" + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + }, + "default": { + "description": "Unexpected error" + } + } + } + } + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "petId": { + "type": "integer", + "format": "int64", + "example": 198772 + }, + "quantity": { + "type": "integer", + "format": "int32", + "example": 7 + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "description": "Order Status", + "example": "approved", + "enum": [ + "placed", + "approved", + "delivered" + ] + }, + "complete": { + "type": "boolean" + } + }, + "xml": { + "name": "order" + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1 + }, + "name": { + "type": "string", + "example": "Dogs" + } + }, + "xml": { + "name": "category" + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "username": { + "type": "string", + "example": "theUser" + }, + "firstName": { + "type": "string", + "example": "John" + }, + "lastName": { + "type": "string", + "example": "James" + }, + "email": { + "type": "string", + "example": "john@email.com" + }, + "password": { + "type": "string", + "example": "12345" + }, + "phone": { + "type": "string", + "example": "12345" + }, + "userStatus": { + "type": "integer", + "description": "User Status", + "format": "int32", + "example": 1 + } + }, + "xml": { + "name": "user" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "tag" + } + }, + "Pet": { + "required": [ + "name", + "photoUrls" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "name": { + "type": "string", + "example": "doggie" + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "type": "string", + "xml": { + "name": "photoUrl" + } + } + }, + "tags": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "description": "pet status in the store", + "enum": [ + "available", + "pending", + "sold" + ] + } + }, + "xml": { + "name": "pet" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "xml": { + "name": "##default" + } + } + }, + "requestBodies": { + "Pet": { + "description": "Pet object that needs to be added to the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "UserArray": { + "description": "List of user object", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + }, + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + } + } + } +} diff --git a/packages/nestjs-crud/src/__tests__/petstore/petstore.spec.ts b/packages/nestjs-crud/src/__tests__/petstore/petstore.spec.ts new file mode 100644 index 000000000..8d759da23 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/petstore/petstore.spec.ts @@ -0,0 +1,332 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import SwaggerParser from '@apidevtools/swagger-parser'; + +import { type INestApplication } from '@nestjs/common'; +import { + DocumentBuilder, + type OpenAPIObject, + SwaggerModule, +} from '@nestjs/swagger'; +import { Test } from '@nestjs/testing'; + +import { standardSchemaConverter } from '@concepta/nestjs-core'; + +import { CrudModule } from '../../crud.module.js'; +import { + type OperationObject, + type ParameterObject, + type SchemaObject, +} from '../openapi-types.js'; + +import { PetController } from './controllers/pet.controller.js'; +import { StoreController } from './controllers/store.controller.js'; +import { UserController } from './controllers/user.controller.js'; + +const ARTIFACT_DIR = join(__dirname, '../__artifacts__'); + +function getOp( + doc: OpenAPIObject, + path: string, + method: string, +): OperationObject | undefined { + const pathItem = doc.paths[path]; + if (!pathItem) return undefined; + return (pathItem as Record)[method]; +} + +function pathParamNames( + doc: OpenAPIObject, + path: string, + method: string, +): string[] { + return (getOp(doc, path, method)?.parameters ?? []) + .filter((p): p is ParameterObject => !('$ref' in p) && p.in === 'path') + .map((p) => p.name); +} + +describe('Petstore3 CRUD-fits replication', () => { + let app: INestApplication; + let doc: OpenAPIObject; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + controllers: [PetController, StoreController, UserController], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + doc = SwaggerModule.createDocument( + app, + new DocumentBuilder().setTitle('Petstore').setVersion('1.0.27').build(), + { standardSchemaConverter }, + ); + + mkdirSync(ARTIFACT_DIR, { recursive: true }); + writeFileSync( + join(ARTIFACT_DIR, 'petstore.json'), + JSON.stringify(doc, null, 2), + ); + }); + + afterAll(async () => { + await app?.close(); + }); + + // ── OpenAPI spec compliance ────────────────────────────────────────────────── + // Validates the ACTUAL JSON written to disk above (the real output of the + // crud controller api decorators), not the in-memory `doc` reference — + // against the real OpenAPI 3.0 spec, independent of whatever shape + // @nestjs/swagger's own TS types claim. Catches structural defects our + // hand-written assertions elsewhere in this file wouldn't, e.g. #467's + // dangling `#/definitions/` pointers or a malformed schema object. + it('produces valid, spec-compliant OpenAPI 3.0 JSON', async () => { + const generated = JSON.parse( + readFileSync(join(ARTIFACT_DIR, 'petstore.json'), 'utf8'), + ); + await expect(SwaggerParser.validate(generated)).resolves.toBeDefined(); + }); + + // ── Paths ────────────────────────────────────────────────────────────────── + describe('paths', () => { + it.each<[string, string, string]>([ + ['addPet', '/pet', 'post'], + ['getPetById', '/pet/{petId}', 'get'], + ['updatePetWithForm', '/pet/{petId}', 'put'], + ['deletePet', '/pet/{petId}', 'delete'], + ['placeOrder', '/store/order', 'post'], + ['getOrderById', '/store/order/{orderId}', 'get'], + ['deleteOrder', '/store/order/{orderId}', 'delete'], + ['createUser', '/user', 'post'], + ['getUserByName', '/user/{username}', 'get'], + ['updateUser', '/user/{username}', 'put'], + ['deleteUser', '/user/{username}', 'delete'], + ])('%s %s %s', (_op, path, method) => { + expect(getOp(doc, path, method)).toBeDefined(); + }); + }); + + // ── OperationIds ─────────────────────────────────────────────────────────── + describe('operationIds', () => { + it.each<[string, string, string, string]>([ + ['addPet', '/pet', 'post', 'addPet'], + ['getPetById', '/pet/{petId}', 'get', 'getPetById'], + ['updatePetWithForm', '/pet/{petId}', 'put', 'updatePetWithForm'], + ['deletePet', '/pet/{petId}', 'delete', 'deletePet'], + ['placeOrder', '/store/order', 'post', 'placeOrder'], + ['getOrderById', '/store/order/{orderId}', 'get', 'getOrderById'], + ['deleteOrder', '/store/order/{orderId}', 'delete', 'deleteOrder'], + ['createUser', '/user', 'post', 'createUser'], + ['getUserByName', '/user/{username}', 'get', 'getUserByName'], + ['updateUser', '/user/{username}', 'put', 'updateUser'], + ['deleteUser', '/user/{username}', 'delete', 'deleteUser'], + ])('%s', (_name, path, method, operationId) => { + expect(getOp(doc, path, method)?.operationId).toBe(operationId); + }); + }); + + // ── Tags ─────────────────────────────────────────────────────────────────── + describe('tags', () => { + it.each<[string, string, string, string]>([ + ['addPet', '/pet', 'post', 'pet'], + ['getPetById', '/pet/{petId}', 'get', 'pet'], + ['updatePetWithForm', '/pet/{petId}', 'put', 'pet'], + ['deletePet', '/pet/{petId}', 'delete', 'pet'], + ['placeOrder', '/store/order', 'post', 'store'], + ['getOrderById', '/store/order/{orderId}', 'get', 'store'], + ['deleteOrder', '/store/order/{orderId}', 'delete', 'store'], + ['createUser', '/user', 'post', 'user'], + ['getUserByName', '/user/{username}', 'get', 'user'], + ['updateUser', '/user/{username}', 'put', 'user'], + ['deleteUser', '/user/{username}', 'delete', 'user'], + ])('%s has tag %s', (_name, path, method, tag) => { + expect(getOp(doc, path, method)?.tags).toContain(tag); + }); + }); + + // ── Path param names ─────────────────────────────────────────────────────── + describe('path param names', () => { + it.each<[string, string, string]>([ + ['/pet/{petId}', 'get', 'petId'], + ['/pet/{petId}', 'put', 'petId'], + ['/pet/{petId}', 'delete', 'petId'], + ['/store/order/{orderId}', 'get', 'orderId'], + ['/store/order/{orderId}', 'delete', 'orderId'], + ['/user/{username}', 'get', 'username'], + ['/user/{username}', 'put', 'username'], + ['/user/{username}', 'delete', 'username'], + ])('%s %s has path param %s', (path, method, paramName) => { + expect(pathParamNames(doc, path, method)).toContain(paramName); + }); + }); + + // ── Component schemas ────────────────────────────────────────────────────── + describe('components.schemas', () => { + it.each(['Pet', 'Order', 'User', 'Category', 'Tag'])( + 'registers %s', + (name) => { + expect(doc.components?.schemas?.[name]).toBeDefined(); + }, + ); + }); + + // ── Duplicate $ref grouping ───────────────────────────────────────────────── + // Pet/Order/User are each $ref-ed from BOTH a request body (#467) and one or + // more responses, across multiple operations — proves the document converter + // groups every usage into a single named component rather than registering a + // duplicate (e.g. a renamed "PetCreate") per call site. + describe('duplicate $refs are grouped into a single component', () => { + it.each(['Pet', 'Order', 'User', 'Category', 'Tag'])( + 'registers exactly one %s component', + (name) => { + const matchingKeys = Object.keys(doc.components?.schemas ?? {}).filter( + (key) => key === name, + ); + expect(matchingKeys).toHaveLength(1); + }, + ); + + it.each<[string, number]>([ + ['Pet', 5], // addPet body+response, getPetById response, updatePetWithForm body+response + ['Order', 3], // placeOrder body+response, getOrderById response + ['User', 5], // createUser body+response, getUserByName response, updateUser body+response + ])( + '%s is $ref-ed %i times across body and response usages', + (name, count) => { + // trailing quote guards against a false prefix match (e.g. Pet vs PetPaginated) + const needle = `"$ref":"#/components/schemas/${name}"`; + const refCount = JSON.stringify(doc).split(needle).length - 1; + expect(refCount).toBe(count); + }, + ); + }); + + // ── Pet schema shapes ────────────────────────────────────────────────────── + describe('Pet schema', () => { + let petSchema: SchemaObject; + + beforeAll(() => { + petSchema = doc.components?.schemas?.['Pet'] as SchemaObject; + }); + + it('has required: name', () => { + expect(petSchema?.required).toContain('name'); + }); + + it('has required: photoUrls', () => { + expect(petSchema?.required).toContain('photoUrls'); + }); + + it('status has enum [available, pending, sold]', () => { + const statusProp = petSchema?.properties?.['status'] as SchemaObject; + expect(statusProp?.enum).toEqual(['available', 'pending', 'sold']); + }); + + it('category references Category schema', () => { + const categoryProp = petSchema?.properties?.['category']; + expect(JSON.stringify(categoryProp)).toContain('Category'); + }); + + it('tags is array referencing Tag schema', () => { + const tagsProp = petSchema?.properties?.['tags'] as SchemaObject; + expect(tagsProp?.type).toBe('array'); + expect(JSON.stringify(tagsProp?.items)).toContain('Tag'); + }); + }); + + // ── Order schema shapes ──────────────────────────────────────────────────── + describe('Order schema', () => { + let orderSchema: SchemaObject; + + beforeAll(() => { + orderSchema = doc.components?.schemas?.['Order'] as SchemaObject; + }); + + it('id has format int64', () => { + const idProp = orderSchema?.properties?.['id'] as SchemaObject; + expect(idProp?.format).toBe('int64'); + }); + + it('status has enum [placed, approved, delivered]', () => { + const statusProp = orderSchema?.properties?.['status'] as SchemaObject; + expect(statusProp?.enum).toEqual(['placed', 'approved', 'delivered']); + }); + }); + + // ── Request bodies ───────────────────────────────────────────────────────── + describe('request bodies', () => { + it.each<[string, string, string]>([ + ['addPet', '/pet', 'post'], + ['updatePetWithForm', '/pet/{petId}', 'put'], + ['placeOrder', '/store/order', 'post'], + ['updateUser', '/user/{username}', 'put'], + ['createUser', '/user', 'post'], + ])('%s has requestBody referencing its schema', (_name, path, method) => { + const rb = getOp(doc, path, method)?.requestBody; + expect(rb).toBeDefined(); + }); + }); + + // ── Upstream comparison ──────────────────────────────────────────────────── + // Compares the Rockets-generated doc against the canonical petstore3 spec. + // Only the 11 in-scope operations are compared; skipped ops and prose fields + // (summary, description, security, servers, xml) are intentionally omitted. + describe('upstream comparison', () => { + let upstream: OpenAPIObject; + + beforeAll(() => { + upstream = JSON.parse( + readFileSync(join(__dirname, 'petstore-upstream.json'), 'utf8'), + ) as OpenAPIObject; + }); + + it('upstream spec loaded', () => { + expect(upstream.openapi).toBeDefined(); + }); + + it.each<[string, string]>([ + ['/pet', 'post'], + ['/pet/{petId}', 'get'], + ['/pet/{petId}', 'put'], + ['/pet/{petId}', 'delete'], + ['/store/order', 'post'], + ['/store/order/{orderId}', 'get'], + ['/store/order/{orderId}', 'delete'], + ['/user', 'post'], + ['/user/{username}', 'get'], + ['/user/{username}', 'put'], + ['/user/{username}', 'delete'], + ])('%s %s operationId matches upstream', (path, method) => { + // Note: /pet/{petId} PUT is remapped from upstream POST; operationId is preserved. + const upstreamMethod = + path === '/pet/{petId}' && method === 'put' ? 'post' : method; + const upstreamOp = getOp(upstream, path, upstreamMethod); + const rocketsOp = getOp(doc, path, method); + expect(rocketsOp?.operationId).toBe(upstreamOp?.operationId); + }); + + it.each(['Pet', 'Order', 'User', 'Category', 'Tag'])( + '%s schema exists in upstream', + (name) => { + expect(upstream.components?.schemas?.[name]).toBeDefined(); + }, + ); + + it.each(['Pet', 'Order', 'User', 'Category', 'Tag'])( + '%s schema property keys match upstream', + (name) => { + const rocketsProps = Object.keys( + (doc.components?.schemas?.[name] as SchemaObject)?.properties ?? {}, + ).sort(); + const upstreamProps = Object.keys( + (upstream.components?.schemas?.[name] as SchemaObject)?.properties ?? + {}, + ).sort(); + expect(rocketsProps).toEqual(upstreamProps); + }, + ); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/petstore/schemas/category.schema.ts b/packages/nestjs-crud/src/__tests__/petstore/schemas/category.schema.ts new file mode 100644 index 000000000..ae6880ded --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/petstore/schemas/category.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { withNamedComponent } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `Category` class, shaped to match the + * canonical Swagger Petstore v3 spec (`petstore-upstream.json`) exactly — + * no domain interface exists for these free-standing petstore fixtures, so + * there is no `conformsTo` to apply. + */ +export const categorySchema = withNamedComponent( + z.object({ + id: z.number().int().meta({ format: 'int64' }).optional(), + name: z.string().optional(), + }), + 'Category', +); diff --git a/packages/nestjs-crud/src/__tests__/petstore/schemas/order.schema.ts b/packages/nestjs-crud/src/__tests__/petstore/schemas/order.schema.ts new file mode 100644 index 000000000..b02efd039 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/petstore/schemas/order.schema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { withNamedComponent } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `Order` class, shaped to match the + * canonical Swagger Petstore v3 spec (`petstore-upstream.json`) exactly — + * no domain interface exists for these free-standing petstore fixtures, so + * there is no `conformsTo` to apply. `shipDate` uses the shared `z.date()` + * override (`open-api.util.ts`'s `jsonSchemaLibraryOptions`) which already + * renders it as `{ type: 'string', format: 'date-time' }` — no extra + * wrapper needed. + */ +export const orderSchema = withNamedComponent( + z.object({ + id: z.number().int().meta({ format: 'int64' }).optional(), + petId: z.number().int().meta({ format: 'int64' }).optional(), + quantity: z.number().int().meta({ format: 'int32' }).optional(), + shipDate: z.date().optional(), + status: z.enum(['placed', 'approved', 'delivered']).optional(), + complete: z.boolean().optional(), + }), + 'Order', +); diff --git a/packages/nestjs-crud/src/__tests__/petstore/schemas/pet.schema.ts b/packages/nestjs-crud/src/__tests__/petstore/schemas/pet.schema.ts new file mode 100644 index 000000000..5042ac570 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/petstore/schemas/pet.schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { withNamedComponent } from '@concepta/nestjs-core'; + +import { categorySchema } from './category.schema.js'; +import { tagSchema } from './tag.schema.js'; + +/** + * Zod equivalent of the legacy `Pet` class, shaped to match the canonical + * Swagger Petstore v3 spec (`petstore-upstream.json`) exactly — no domain + * interface exists for these free-standing petstore fixtures, so there is + * no `conformsTo` to apply. `category`/`tags` nest the already-named + * `Category`/`Tag` components directly so the OpenAPI converter hoists + * them as `$ref`s instead of inlining a duplicate shape. + */ +export const petSchema = withNamedComponent( + z.object({ + id: z.number().int().meta({ format: 'int64' }).optional(), + name: z.string(), + category: categorySchema.optional(), + photoUrls: z.array(z.string()), + tags: z.array(tagSchema).optional(), + status: z.enum(['available', 'pending', 'sold']).optional(), + }), + 'Pet', +); diff --git a/packages/nestjs-crud/src/__tests__/petstore/schemas/tag.schema.ts b/packages/nestjs-crud/src/__tests__/petstore/schemas/tag.schema.ts new file mode 100644 index 000000000..32c09d339 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/petstore/schemas/tag.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { withNamedComponent } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `Tag` class, shaped to match the canonical + * Swagger Petstore v3 spec (`petstore-upstream.json`) exactly — no domain + * interface exists for these free-standing petstore fixtures, so there is + * no `conformsTo` to apply. + */ +export const tagSchema = withNamedComponent( + z.object({ + id: z.number().int().meta({ format: 'int64' }).optional(), + name: z.string().optional(), + }), + 'Tag', +); diff --git a/packages/nestjs-crud/src/__tests__/petstore/schemas/user.schema.ts b/packages/nestjs-crud/src/__tests__/petstore/schemas/user.schema.ts new file mode 100644 index 000000000..83ecc1f8d --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/petstore/schemas/user.schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { withNamedComponent } from '@concepta/nestjs-core'; + +/** + * Zod equivalent of the legacy `User` class, shaped to match the canonical + * Swagger Petstore v3 spec (`petstore-upstream.json`) exactly — no domain + * interface exists for these free-standing petstore fixtures, so there is + * no `conformsTo` to apply. + */ +export const userSchema = withNamedComponent( + z.object({ + id: z.number().int().meta({ format: 'int64' }).optional(), + username: z.string().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), + password: z.string().optional(), + phone: z.string().optional(), + userStatus: z.number().int().meta({ format: 'int32' }).optional(), + }), + 'User', +); diff --git a/packages/nestjs-crud/src/__tests__/request-body-hierarchy.e2e-spec.ts b/packages/nestjs-crud/src/__tests__/request-body-hierarchy.e2e-spec.ts new file mode 100644 index 000000000..6d04d3f1d --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/request-body-hierarchy.e2e-spec.ts @@ -0,0 +1,114 @@ +import request from 'supertest'; + +import { type INestApplication } from '@nestjs/common'; +import { + DocumentBuilder, + type OpenAPIObject, + SwaggerModule, +} from '@nestjs/swagger'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { standardSchemaConverter } from '@concepta/nestjs-core'; + +import { type OperationObject } from './openapi-types.js'; + +import { AppPhotoBodyFallbackModuleFixture } from '../__fixtures__/app-photo-body-fallback.module.fixture.js'; +import { default as ormConfig } from '../__fixtures__/ormconfig.fixture.js'; + +/** + * Regression coverage for #467 — `PhotoBodyFallbackControllerFixture` + * mirrors the reporter's config exactly: a `ConfigurableCrudBuilder` + * fully-generated controller (no `design:paramtypes`, closing gate A) whose + * Create operation declares NO op-level `request.body` (closing gate B via + * the docs/validation hierarchy convergence) — the body exists only at + * controller level, as `photoSchema` (a `withNamedComponent` schema). + */ +describe('CRUD request body — controller-level-only schema (#467)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + AppPhotoBodyFallbackModuleFixture, + ], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + return app ? await app.close() : undefined; + }); + + it('documents the create body as a $ref to Photo, not inlined', () => { + const doc: OpenAPIObject = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('photo-body-fallback') + .setVersion('1.0') + .build(), + { standardSchemaConverter }, + ); + + const op = doc.paths['/photo-body-fallback']?.post as + | OperationObject + | undefined; + const schema = ( + op?.requestBody as + | { content?: { 'application/json'?: { schema?: unknown } } } + | undefined + )?.content?.['application/json']?.schema; + + expect(schema).toEqual({ $ref: '#/components/schemas/Photo' }); + expect(doc.components?.schemas?.Photo).toBeDefined(); + }); + + it('groups the request body and response $refs into the same single Photo component', () => { + const doc: OpenAPIObject = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('photo-body-fallback') + .setVersion('1.0') + .build(), + { standardSchemaConverter }, + ); + + // exactly one component — no duplicate/renamed entry (e.g. "PhotoCreate") + const matchingKeys = Object.keys(doc.components?.schemas ?? {}).filter( + (key) => key === 'Photo', + ); + expect(matchingKeys).toHaveLength(1); + + // the Create request body (via crud-init-api-body.decorator.ts's fixed + // CrudInitApiBody path) AND the Create response (via the pre-existing + // ApiResponse path) both $ref it — this is what actually regresses if + // CrudInitApiBody reverts to raw pre-conversion, since the body ref + // would disappear while the response ref survives. + const refCount = + JSON.stringify(doc).split('"$ref":"#/components/schemas/Photo"').length - + 1; + expect(refCount).toBeGreaterThanOrEqual(2); + }); + + it('validates the create body against the controller-level schema, not just documents it', async () => { + const server = app.getHttpServer(); + + await request(server).post('/photo-body-fallback').send({}).expect(400); + + const validBody = { + id: '11111111-1111-1111-1111-111111111111', + name: 'test photo', + description: 'a photo', + filename: 'test.jpg', + views: 0, + isPublished: true, + deletedAt: null, + }; + await request(server) + .post('/photo-body-fallback') + .send(validBody) + .expect(201); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/swagger-request-body.spec.ts b/packages/nestjs-crud/src/__tests__/swagger-request-body.spec.ts new file mode 100644 index 000000000..9cb726254 --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/swagger-request-body.spec.ts @@ -0,0 +1,411 @@ +import { z } from 'zod'; + +import { type INestApplication } from '@nestjs/common'; +import { + DocumentBuilder, + type OpenAPIObject, + SwaggerModule, +} from '@nestjs/swagger'; +import { Test } from '@nestjs/testing'; + +import { + standardSchemaConverter, + withNamedComponent, + withOpenApi, +} from '@concepta/nestjs-core'; + +import { CrudModule } from '../crud.module.js'; +import { CrudController } from '../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudInit } from '../infrastructure/decorators/controller/crud-init.decorator.js'; +import { CrudCreate } from '../infrastructure/decorators/operations/crud-create.decorator.js'; +import { CrudBody } from '../infrastructure/decorators/params/crud-body.decorator.js'; + +import { type OperationObject } from './openapi-types.js'; + +/** + * Docs-only regression coverage for #467's mechanism — these all exercise + * handwritten controllers (no TypeORM needed; `SwaggerModule.createDocument` + * never invokes a route handler) via `crud-init-api-body.decorator.ts`'s + * `ApiBody({ standardSchema })`. `request-body-hierarchy.e2e-spec.ts` + * covers the fully-generated `ConfigurableCrudBuilder` + docs/validation + * hierarchy convergence case (the reporter's actual config) separately. + */ + +function requestBodySchema(doc: OpenAPIObject, path: string): unknown { + const op = doc.paths[path]?.post as OperationObject | undefined; + return ( + op?.requestBody as + | { content?: { 'application/json'?: { schema?: unknown } } } + | undefined + )?.content?.['application/json']?.schema; +} + +describe('CrudBody({ schema }) beats a differing controller-level default (#467)', () => { + const controllerLevelSchema = withNamedComponent( + z.object({ fromController: z.string() }), + 'PrecedenceControllerSchema', + ); + const paramLevelSchema = withNamedComponent( + z.object({ fromParam: z.string() }), + 'PrecedenceParamSchema', + ); + + @CrudController({ + path: 'precedence-probe', + entity: 'PrecedenceProbe', + request: { body: controllerLevelSchema }, + }) + class PrecedenceProbeControllerFixture { + @CrudCreate() + async create(@CrudBody({ schema: paramLevelSchema }) dto: unknown) { + return dto; + } + } + + let doc: OpenAPIObject; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + controllers: [PrecedenceProbeControllerFixture], + }).compile(); + + const app: INestApplication = moduleRef.createNestApplication(); + await app.init(); + + doc = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('precedence-probe') + .setVersion('1.0') + .build(), + { standardSchemaConverter }, + ); + + await app.close(); + }); + + it("documents the parameter's own schema, not the controller-level default", () => { + expect(requestBodySchema(doc, '/precedence-probe')).toEqual({ + $ref: '#/components/schemas/PrecedenceParamSchema', + }); + expect(doc.components?.schemas?.PrecedenceControllerSchema).toBeUndefined(); + }); +}); + +describe('nested named components in a request body (#467)', () => { + const nestedNoteSchema = withNamedComponent( + z.object({ note: z.string() }), + 'NestedNoteComponent', + ); + const nestedBodySchema = withNamedComponent( + z.object({ title: z.string(), note: nestedNoteSchema }), + 'NestedBodyComponent', + ); + + @CrudController({ path: 'nested-body-probe', entity: 'NestedBodyProbe' }) + class NestedBodyProbeControllerFixture { + @CrudCreate() + async create(@CrudBody({ schema: nestedBodySchema }) dto: unknown) { + return dto; + } + } + + let doc: OpenAPIObject; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + controllers: [NestedBodyProbeControllerFixture], + }).compile(); + + const app: INestApplication = moduleRef.createNestApplication(); + await app.init(); + + doc = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('nested-body-probe') + .setVersion('1.0') + .build(), + { standardSchemaConverter }, + ); + + await app.close(); + }); + + it('hoists the nested component and rewrites its $ref, with no dangling pointer', () => { + expect(requestBodySchema(doc, '/nested-body-probe')).toEqual({ + $ref: '#/components/schemas/NestedBodyComponent', + }); + expect(doc.components?.schemas?.NestedBodyComponent).toBeDefined(); + expect(doc.components?.schemas?.NestedNoteComponent).toBeDefined(); + + const docJson = JSON.stringify(doc); + expect(docJson).not.toContain('#/definitions/'); + expect(docJson).not.toContain('#/$defs/'); + }); +}); + +describe('allowEmpty: false still documents a named body as a $ref (#467)', () => { + const strictSchema = withNamedComponent( + z.object({ name: z.string() }), + 'StrictBodyComponent', + ); + + @CrudController({ path: 'strict-body-probe', entity: 'StrictBodyProbe' }) + class StrictBodyProbeControllerFixture { + @CrudCreate() + async create( + @CrudBody({ schema: strictSchema, validation: { allowEmpty: false } }) + dto: unknown, + ) { + return dto; + } + } + + let doc: OpenAPIObject; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + controllers: [StrictBodyProbeControllerFixture], + }).compile(); + + const app: INestApplication = moduleRef.createNestApplication(); + await app.init(); + + doc = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('strict-body-probe') + .setVersion('1.0') + .build(), + { standardSchemaConverter }, + ); + + await app.close(); + }); + + it("does not degrade to inline (guards withEmptyBodyGuard's .refine() from reaching the docs schema)", () => { + expect(requestBodySchema(doc, '/strict-body-probe')).toEqual({ + $ref: '#/components/schemas/StrictBodyComponent', + }); + }); +}); + +/** + * `api.body` (`ApiBodyOptions` — description, examples, required) previously never survived + * onto a schema-based request body: `CrudApiBody({...api?.body})` was only ever called when + * the operation had NO local schema, and even then `crud-init-api-body.decorator.ts` stripped + * that placeholder the moment a schema resolved from the metadata hierarchy. See the TODOs.md + * "Per-operation api.body options silently dropped" item. + */ +describe('CrudApiBody carries ApiBodyOptions through to the resolved body (#api.body)', () => { + describe('operation-level schema present', () => { + const opSchema = withOpenApi(z.object({ fromOperation: z.string() })); + + @CrudController({ path: 'op-schema-probe', entity: 'OpSchemaProbe' }) + class OpSchemaProbeControllerFixture { + @CrudCreate({ + request: { body: opSchema }, + api: { + body: { description: 'Custom body description', required: false }, + }, + }) + async create(@CrudBody({ schema: opSchema }) dto: unknown) { + return dto; + } + } + + let doc: OpenAPIObject; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + controllers: [OpSchemaProbeControllerFixture], + }).compile(); + + const app: INestApplication = moduleRef.createNestApplication(); + await app.init(); + + doc = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('op-schema-probe') + .setVersion('1.0') + .build(), + { standardSchemaConverter }, + ); + + await app.close(); + }); + + it('documents both the schema and the api.body overrides', () => { + const rbJson = JSON.stringify( + doc.paths['/op-schema-probe']?.post?.requestBody, + ); + expect(rbJson).toContain('"fromOperation"'); + expect(rbJson).toContain('Custom body description'); + expect(rbJson).toContain('"required":false'); + }); + }); + + describe('controller-level default schema, no operation-level schema', () => { + const controllerSchema = withOpenApi( + z.object({ fromController: z.string() }), + ); + + @CrudController({ + path: 'controller-schema-probe', + entity: 'ControllerSchemaProbe', + request: { body: controllerSchema }, + }) + class ControllerSchemaProbeControllerFixture { + @CrudCreate({ + api: { body: { description: 'Inherited body description' } }, + }) + async create(@CrudBody({ schema: controllerSchema }) dto: unknown) { + return dto; + } + } + + let doc: OpenAPIObject; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + controllers: [ControllerSchemaProbeControllerFixture], + }).compile(); + + const app: INestApplication = moduleRef.createNestApplication(); + await app.init(); + + doc = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('controller-schema-probe') + .setVersion('1.0') + .build(), + { standardSchemaConverter }, + ); + + await app.close(); + }); + + it('documents both the inherited schema and the api.body override', () => { + const rbJson = JSON.stringify( + doc.paths['/controller-schema-probe']?.post?.requestBody, + ); + expect(rbJson).toContain('"fromController"'); + expect(rbJson).toContain('Inherited body description'); + }); + }); + + describe('no schema anywhere', () => { + @CrudController({ path: 'schemaless-probe', entity: 'SchemalessProbe' }) + class SchemalessProbeControllerFixture { + @CrudCreate({ api: { body: { description: 'Schemaless body' } } }) + async create() { + return undefined; + } + } + + let doc: OpenAPIObject; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + controllers: [SchemalessProbeControllerFixture], + }).compile(); + + const app: INestApplication = moduleRef.createNestApplication(); + await app.init(); + + doc = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('schemaless-probe') + .setVersion('1.0') + .build(), + { standardSchemaConverter }, + ); + + await app.close(); + }); + + it('documents the api.body description alongside the default string body shape', () => { + const rbJson = JSON.stringify( + doc.paths['/schemaless-probe']?.post?.requestBody, + ); + expect(rbJson).toContain('Schemaless body'); + expect(rbJson).toContain('"schema":{"type":"string"}'); + }); + }); + + describe('re-running CrudInit() overrides a stale body entry instead of duplicating it', () => { + // Mirrors what configurable-crud.builder.ts's hybrid path does at :708 — @CrudController + // already ran CrudInit() once at decoration time; pinning a different schema via CrudBody + // and re-running CrudInit() must make the SECOND schema win, not silently keep the first + // (ApiBody's own metadata storage is append-only and Swagger's dedup is first-wins, so + // this only works if crud-init-api-body.decorator.ts's own entries stay idempotent). + const controllerSchema = withOpenApi( + z.object({ fromController: z.string() }), + ); + const overrideSchema = withOpenApi(z.object({ fromOverride: z.string() })); + + @CrudController({ + path: 'idempotent-probe', + entity: 'IdempotentProbe', + request: { body: controllerSchema }, + }) + class IdempotentProbeControllerFixture { + // No explicit @CrudBody here — mirrors crud.module.forfeature.spec.ts's + // CompanyControllerD, which relies on hierarchy fallback for the first + // CrudInit() run (from @CrudController), then gets a real @CrudBody + // applied exactly once by the hybrid builder for the second run. + @CrudCreate() + async create(dto: unknown) { + return dto; + } + } + + CrudBody({ schema: overrideSchema })( + IdempotentProbeControllerFixture.prototype, + 'create', + 1, + ); + CrudInit()(IdempotentProbeControllerFixture); + + let doc: OpenAPIObject; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + controllers: [IdempotentProbeControllerFixture], + }).compile(); + + const app: INestApplication = moduleRef.createNestApplication(); + await app.init(); + + doc = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('idempotent-probe') + .setVersion('1.0') + .build(), + { standardSchemaConverter }, + ); + + await app.close(); + }); + + it('documents the second (overriding) schema, not the first', () => { + const rbJson = JSON.stringify( + doc.paths['/idempotent-probe']?.post?.requestBody, + ); + expect(rbJson).toContain('"fromOverride"'); + expect(rbJson).not.toContain('"fromController"'); + }); + }); +}); diff --git a/packages/nestjs-crud/src/__tests__/swagger.spec.ts b/packages/nestjs-crud/src/__tests__/swagger.spec.ts new file mode 100644 index 000000000..d9090d3ad --- /dev/null +++ b/packages/nestjs-crud/src/__tests__/swagger.spec.ts @@ -0,0 +1,259 @@ +import { mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import { type INestApplication } from '@nestjs/common'; +import { + DocumentBuilder, + type OpenAPIObject, + SwaggerModule, +} from '@nestjs/swagger'; +import { Test } from '@nestjs/testing'; + +import { standardSchemaConverter } from '@concepta/nestjs-core'; + +import { CrudModule } from '../crud.module.js'; +import { CrudController } from '../infrastructure/decorators/controller/crud-controller.decorator.js'; +import { CrudCreate } from '../infrastructure/decorators/operations/crud-create.decorator.js'; + +import { type OperationObject, type ParameterObject } from './openapi-types.js'; + +import { PhotoControllerFixture } from '../__fixtures__/photo/photo.controller.fixture.js'; +import { photoCreateSchema } from '../__fixtures__/photo/schemas/photo-create.schema.fixture.js'; +import { photoPaginatedSchema } from '../__fixtures__/photo/schemas/photo-paginated.schema.fixture.js'; +import { photoSchema } from '../__fixtures__/photo/schemas/photo.schema.fixture.js'; + +const ARTIFACT_DIR = join(__dirname, '__artifacts__'); + +function getOp( + doc: OpenAPIObject, + path: string, + method: string, +): OperationObject | undefined { + const pathItem = doc.paths[path]; + if (!pathItem) return undefined; + return (pathItem as Record)[method]; +} + +function paramNames( + doc: OpenAPIObject, + path: string, + method: string, +): string[] { + return (getOp(doc, path, method)?.parameters ?? []) + .filter((p): p is ParameterObject => !('$ref' in p)) + .map((p) => p.name); +} + +describe('CrudModule swagger document', () => { + let app: INestApplication; + let doc: OpenAPIObject; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + controllers: [PhotoControllerFixture], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + doc = SwaggerModule.createDocument( + app, + new DocumentBuilder().setTitle('Crud Probe').setVersion('1.0').build(), + { standardSchemaConverter }, + ); + + mkdirSync(ARTIFACT_DIR, { recursive: true }); + writeFileSync( + join(ARTIFACT_DIR, 'swagger.json'), + JSON.stringify(doc, null, 2), + ); + }); + + afterAll(async () => { + await app?.close(); + }); + + // ── Paths ────────────────────────────────────────────────────────────── + describe('paths', () => { + it.each<[string, string, string]>([ + ['List', '/photo', 'get'], + ['Create', '/photo', 'post'], + ['CreateBatch', '/photo/bulk', 'post'], + ['Read', '/photo/{id}', 'get'], + ['Update', '/photo/{id}', 'patch'], + ['Replace', '/photo/{id}', 'put'], + ['Delete', '/photo/{id}', 'delete'], + ['SoftDelete', '/photo/soft/{id}', 'delete'], + ['Restore', '/photo/restore/{id}', 'patch'], + ])('%s %s %s', (_op, path, method) => { + expect(getOp(doc, path, method)).toBeDefined(); + }); + }); + + // ── operationIds ─────────────────────────────────────────────────────── + describe('operationIds', () => { + it.each<[string, string, string, string]>([ + ['list', '/photo', 'get', 'PhotoControllerFixture_list'], + ['create', '/photo', 'post', 'PhotoControllerFixture_create'], + [ + 'createBatch', + '/photo/bulk', + 'post', + 'PhotoControllerFixture_createBatch', + ], + ['read', '/photo/{id}', 'get', 'PhotoControllerFixture_read'], + ['update', '/photo/{id}', 'patch', 'PhotoControllerFixture_update'], + ['replace', '/photo/{id}', 'put', 'PhotoControllerFixture_replace'], + ['delete', '/photo/{id}', 'delete', 'PhotoControllerFixture_delete'], + [ + 'softDelete', + '/photo/soft/{id}', + 'delete', + 'PhotoControllerFixture_softDelete', + ], + [ + 'restore', + '/photo/restore/{id}', + 'patch', + 'PhotoControllerFixture_restore', + ], + ])('%s', (_method, path, httpMethod, expectedId) => { + expect(getOp(doc, path, httpMethod)?.operationId).toBe(expectedId); + }); + }); + + // ── List query parameters ────────────────────────────────────────────── + // Actual names come from CrudQueryBuilder.paramNamesMap: + // fields → 'select', search → 's', join → not in map (excluded) + describe('List query parameters', () => { + it.each([ + 'select', + 's', + 'filter', + 'or', + 'sort', + 'limit', + 'offset', + 'page', + 'cache', + 'includeDeleted', + ])('includes %s', (name) => { + expect(paramNames(doc, '/photo', 'get')).toContain(name); + }); + }); + + // ── Read query parameters ────────────────────────────────────────────── + describe('Read query parameters', () => { + it.each(['select', 'cache', 'includeDeleted'])('includes %s', (name) => { + expect(paramNames(doc, '/photo/{id}', 'get')).toContain(name); + }); + }); + + // ── Request bodies ───────────────────────────────────────────────────── + // `crud-init-api-body.decorator.ts` routes request bodies through the + // document-level `standardSchemaConverter`, same as responses — so a + // schema registered via `withNamedComponent` documents as a `$ref` (see + // `request bodies` in `petstore.spec.ts`). `photoCreateSchema`/ + // `photoUpdateSchema` (the method-level bodies these operations actually + // use) are plain `withOpenApi`, not named components, so they still + // inline — matching `cache`'s schema-based POST request body (see + // `cache-crud.swagger.e2e-spec.ts`). These assert the inline object shape + // (photoSchema's fields) rather than a "Photo" name/ref. + describe('request bodies', () => { + it.each<[string, string, string]>([ + ['Create', '/photo', 'post'], + ['Update', '/photo/{id}', 'patch'], + ['Replace', '/photo/{id}', 'put'], + ])( + '%s has an inline requestBody shaped like photoSchema', + (_op, path, method) => { + const rb = getOp(doc, path, method)?.requestBody; + const rbJson = JSON.stringify(rb); + expect(rbJson).toContain('"name"'); + expect(rbJson).toContain('"isPublished"'); + expect(rbJson).not.toContain('$ref'); + }, + ); + }); + + // ── Response schemas ─────────────────────────────────────────────────── + describe('response schemas', () => { + it('List 200 references PhotoPaginated', () => { + const resp = getOp(doc, '/photo', 'get')?.responses?.['200']; + expect(JSON.stringify(resp)).toContain('PhotoPaginated'); + }); + + it('Read 200 references Photo', () => { + const resp = getOp(doc, '/photo/{id}', 'get')?.responses?.['200']; + expect(JSON.stringify(resp)).toContain('Photo'); + }); + }); + + // ── Component schemas ────────────────────────────────────────────────── + // Registered via the response schemas (photoSchema, photoPaginatedSchema) + // — every write op here overrides the controller-level photoSchema body + // with an unnamed method-level schema, so none of them contribute a body + // component. + describe('components.schemas', () => { + it.each(['Photo', 'PhotoPaginated'])('registers %s', (name) => { + expect(doc.components?.schemas?.[name]).toBeDefined(); + }); + }); +}); + +// ── String-typed body regression ────────────────────────────────────────── +// When an operation has no local `request.body`, `CrudInitApiBody` resolves +// the controller-level default from the metadata hierarchy and documents +// that — not swagger's own `{ type: 'string' }` `ApiBody()` default, which +// only appears when no schema resolves anywhere at all (see +// `crud-init-api-body.decorator.ts`'s schemaless branch). +describe('CrudModule swagger request body resolution', () => { + @CrudController({ + path: 'probe', + entity: 'Probe', + request: { body: photoCreateSchema }, + response: { resource: photoSchema, paginated: photoPaginatedSchema }, + }) + class ProbeControllerFixture { + // deliberately NO local request.body — resolves the controller-level + // schema via the metadata hierarchy instead + @CrudCreate() + async create() { + return undefined; + } + } + + let app: INestApplication; + let doc: OpenAPIObject; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + controllers: [ProbeControllerFixture], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + doc = SwaggerModule.createDocument( + app, + new DocumentBuilder().setTitle('Probe').setVersion('1.0').build(), + { standardSchemaConverter }, + ); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('documents the resolved schema, not the string placeholder', () => { + const rb = getOp(doc, '/probe', 'post')?.requestBody; + const rbJson = JSON.stringify(rb); + // resolved photoCreateSchema shape is present + expect(rbJson).toContain('"name"'); + expect(rbJson).toContain('"isPublished"'); + // the bare string placeholder is gone + expect(rbJson).not.toContain('"schema":{"type":"string"}'); + }); +}); diff --git a/packages/nestjs-crud/src/application/commands/handlers/crud-command-base.handler.ts b/packages/nestjs-crud/src/application/commands/handlers/crud-command-base.handler.ts new file mode 100644 index 000000000..f181f7c7e --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/handlers/crud-command-base.handler.ts @@ -0,0 +1,23 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudAdapter } from '../../../infrastructure/adapters/crud.adapter.js'; +import { type CrudCommandHandlerInterface } from '../interfaces/crud-command-handler.interface.js'; +import { type CrudCommandInterface } from '../interfaces/crud-command.interface.js'; + +/** + * Base class for CRUD command handlers. + * + * This class does NOT implement ICommandHandler directly. The resolver + * applies the `@CommandHandler` decorator if CQRS is being used. + */ +export class CrudCommandBaseHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, +> implements CrudCommandHandlerInterface { + constructor(readonly crudAdapter: CrudAdapter) {} + + execute( + _command: CrudCommandInterface, + ): Promise { + throw new Error('Method not implemented'); + } +} diff --git a/packages/nestjs-crud/src/application/commands/handlers/crud-create-batch.handler.ts b/packages/nestjs-crud/src/application/commands/handlers/crud-create-batch.handler.ts new file mode 100644 index 000000000..db186c784 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/handlers/crud-create-batch.handler.ts @@ -0,0 +1,28 @@ +import { HttpException, type PlainLiteralObject } from '@nestjs/common'; + +import { CrudQueryException } from '../../../infrastructure/exceptions/crud-query.exception.js'; +import { type CrudCreateBatchCommand } from '../impl/crud-create-batch.command.js'; + +import { CrudWithBodyCommandHandler } from './crud-with-body-command.handler.js'; + +export class CrudCreateBatchHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, + Body extends Entity = Entity, +> extends CrudWithBodyCommandHandler { + async execute( + command: CrudCreateBatchCommand, + ): Promise { + const { context, dto } = command; + + try { + return await this.crudAdapter.createBatch(context, dto); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + throw new CrudQueryException(this.crudAdapter.entityName(), { + originalError: e, + }); + } + } +} diff --git a/packages/nestjs-crud/src/application/commands/handlers/crud-create.handler.ts b/packages/nestjs-crud/src/application/commands/handlers/crud-create.handler.ts new file mode 100644 index 000000000..8fec6e9f0 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/handlers/crud-create.handler.ts @@ -0,0 +1,28 @@ +import { HttpException, type PlainLiteralObject } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { CrudQueryException } from '../../../infrastructure/exceptions/crud-query.exception.js'; +import { type CrudCreateCommand } from '../impl/crud-create.command.js'; + +import { CrudWithBodyCommandHandler } from './crud-with-body-command.handler.js'; + +export class CrudCreateHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, + Body extends DeepPartial = DeepPartial, +> extends CrudWithBodyCommandHandler { + async execute(command: CrudCreateCommand): Promise { + const { context, dto } = command; + + try { + return await this.crudAdapter.create(context, dto); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + throw new CrudQueryException(this.crudAdapter.entityName(), { + originalError: e, + }); + } + } +} diff --git a/packages/nestjs-crud/src/application/commands/handlers/crud-delete.handler.ts b/packages/nestjs-crud/src/application/commands/handlers/crud-delete.handler.ts new file mode 100644 index 000000000..27f7679b3 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/handlers/crud-delete.handler.ts @@ -0,0 +1,25 @@ +import { HttpException, type PlainLiteralObject } from '@nestjs/common'; + +import { CrudQueryException } from '../../../infrastructure/exceptions/crud-query.exception.js'; +import { type CrudDeleteCommand } from '../impl/crud-delete.command.js'; + +import { CrudCommandBaseHandler } from './crud-command-base.handler.js'; + +export class CrudDeleteHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, +> extends CrudCommandBaseHandler { + async execute(command: CrudDeleteCommand): Promise { + const { context } = command; + + try { + return await this.crudAdapter.delete(context); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + throw new CrudQueryException(this.crudAdapter.entityName(), { + originalError: e, + }); + } + } +} diff --git a/packages/nestjs-crud/src/application/commands/handlers/crud-replace.handler.ts b/packages/nestjs-crud/src/application/commands/handlers/crud-replace.handler.ts new file mode 100644 index 000000000..d804334ef --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/handlers/crud-replace.handler.ts @@ -0,0 +1,28 @@ +import { HttpException, type PlainLiteralObject } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { CrudQueryException } from '../../../infrastructure/exceptions/crud-query.exception.js'; +import { type CrudReplaceCommand } from '../impl/crud-replace.command.js'; + +import { CrudWithBodyCommandHandler } from './crud-with-body-command.handler.js'; + +export class CrudReplaceHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, + Body extends DeepPartial = DeepPartial, +> extends CrudWithBodyCommandHandler { + async execute(command: CrudReplaceCommand): Promise { + const { context, dto } = command; + + try { + return await this.crudAdapter.replace(context, dto); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + throw new CrudQueryException(this.crudAdapter.entityName(), { + originalError: e, + }); + } + } +} diff --git a/packages/nestjs-crud/src/application/commands/handlers/crud-restore.handler.ts b/packages/nestjs-crud/src/application/commands/handlers/crud-restore.handler.ts new file mode 100644 index 000000000..209fc4765 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/handlers/crud-restore.handler.ts @@ -0,0 +1,25 @@ +import { HttpException, type PlainLiteralObject } from '@nestjs/common'; + +import { CrudQueryException } from '../../../infrastructure/exceptions/crud-query.exception.js'; +import { type CrudRestoreCommand } from '../impl/crud-restore.command.js'; + +import { CrudCommandBaseHandler } from './crud-command-base.handler.js'; + +export class CrudRestoreHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, +> extends CrudCommandBaseHandler { + async execute(command: CrudRestoreCommand): Promise { + const { context } = command; + + try { + return await this.crudAdapter.restore(context); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + throw new CrudQueryException(this.crudAdapter.entityName(), { + originalError: e, + }); + } + } +} diff --git a/packages/nestjs-crud/src/application/commands/handlers/crud-soft-delete.handler.ts b/packages/nestjs-crud/src/application/commands/handlers/crud-soft-delete.handler.ts new file mode 100644 index 000000000..20c145538 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/handlers/crud-soft-delete.handler.ts @@ -0,0 +1,27 @@ +import { HttpException, type PlainLiteralObject } from '@nestjs/common'; + +import { CrudQueryException } from '../../../infrastructure/exceptions/crud-query.exception.js'; +import { type CrudSoftDeleteCommand } from '../impl/crud-soft-delete.command.js'; + +import { CrudCommandBaseHandler } from './crud-command-base.handler.js'; + +export class CrudSoftDeleteHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, +> extends CrudCommandBaseHandler { + async execute( + command: CrudSoftDeleteCommand, + ): Promise { + const { context } = command; + + try { + return await this.crudAdapter.softDelete(context); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + throw new CrudQueryException(this.crudAdapter.entityName(), { + originalError: e, + }); + } + } +} diff --git a/packages/nestjs-crud/src/application/commands/handlers/crud-update.handler.ts b/packages/nestjs-crud/src/application/commands/handlers/crud-update.handler.ts new file mode 100644 index 000000000..c5d3624d9 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/handlers/crud-update.handler.ts @@ -0,0 +1,28 @@ +import { HttpException, type PlainLiteralObject } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { CrudQueryException } from '../../../infrastructure/exceptions/crud-query.exception.js'; +import { type CrudUpdateCommand } from '../impl/crud-update.command.js'; + +import { CrudWithBodyCommandHandler } from './crud-with-body-command.handler.js'; + +export class CrudUpdateHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, + Body extends DeepPartial = DeepPartial, +> extends CrudWithBodyCommandHandler { + async execute(command: CrudUpdateCommand): Promise { + const { context, dto } = command; + + try { + return await this.crudAdapter.update(context, dto); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + throw new CrudQueryException(this.crudAdapter.entityName(), { + originalError: e, + }); + } + } +} diff --git a/packages/nestjs-crud/src/application/commands/handlers/crud-with-body-command.handler.ts b/packages/nestjs-crud/src/application/commands/handlers/crud-with-body-command.handler.ts new file mode 100644 index 000000000..4de80fc5d --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/handlers/crud-with-body-command.handler.ts @@ -0,0 +1,23 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { type CrudAdapter } from '../../../infrastructure/adapters/crud.adapter.js'; +import { type CrudWithBodyCommand } from '../impl/crud-with-body.command.js'; + +import { CrudCommandBaseHandler } from './crud-command-base.handler.js'; + +export class CrudWithBodyCommandHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, + Body extends DeepPartial = DeepPartial, +> extends CrudCommandBaseHandler { + constructor(readonly crudAdapter: CrudAdapter) { + super(crudAdapter); + } + + execute( + _command: CrudWithBodyCommand, + ): Promise { + throw new Error('Method not implemented'); + } +} diff --git a/packages/nestjs-crud/src/application/commands/impl/crud-create-batch.command.ts b/packages/nestjs-crud/src/application/commands/impl/crud-create-batch.command.ts new file mode 100644 index 000000000..a95196f55 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/impl/crud-create-batch.command.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { type CrudCreateBatchInterface } from '../../../infrastructure/interfaces/crud-create-batch.interface.js'; +import { type CrudCommandInterface } from '../interfaces/crud-command.interface.js'; + +export class CrudCreateBatchCommand< + Entity extends PlainLiteralObject, + Creatable extends DeepPartial = DeepPartial, +> implements CrudCommandInterface { + constructor( + public readonly context: CrudContextInterface, + public readonly dto: CrudCreateBatchInterface, + ) {} +} diff --git a/packages/nestjs-crud/src/application/commands/impl/crud-create.command.ts b/packages/nestjs-crud/src/application/commands/impl/crud-create.command.ts new file mode 100644 index 000000000..5b8bfcfd6 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/impl/crud-create.command.ts @@ -0,0 +1,19 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; + +import { CrudWithBodyCommand } from './crud-with-body.command.js'; + +export class CrudCreateCommand< + Entity extends PlainLiteralObject, + Creatable extends DeepPartial = DeepPartial, +> extends CrudWithBodyCommand { + constructor( + public readonly context: CrudContextInterface, + public readonly dto: Creatable, + ) { + super(context, dto); + } +} diff --git a/packages/nestjs-crud/src/application/commands/impl/crud-delete.command.ts b/packages/nestjs-crud/src/application/commands/impl/crud-delete.command.ts new file mode 100644 index 000000000..e30eb032f --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/impl/crud-delete.command.ts @@ -0,0 +1,10 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { type CrudCommandInterface } from '../interfaces/crud-command.interface.js'; + +export class CrudDeleteCommand< + Entity extends PlainLiteralObject, +> implements CrudCommandInterface { + constructor(public readonly context: CrudContextInterface) {} +} diff --git a/packages/nestjs-crud/src/application/commands/impl/crud-replace.command.ts b/packages/nestjs-crud/src/application/commands/impl/crud-replace.command.ts new file mode 100644 index 000000000..8ed6ea922 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/impl/crud-replace.command.ts @@ -0,0 +1,19 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; + +import { CrudWithBodyCommand } from './crud-with-body.command.js'; + +export class CrudReplaceCommand< + Entity extends PlainLiteralObject, + Replaceable extends DeepPartial = DeepPartial, +> extends CrudWithBodyCommand { + constructor( + public readonly context: CrudContextInterface, + public readonly dto: Replaceable, + ) { + super(context, dto); + } +} diff --git a/packages/nestjs-crud/src/application/commands/impl/crud-restore.command.ts b/packages/nestjs-crud/src/application/commands/impl/crud-restore.command.ts new file mode 100644 index 000000000..4b0985ffa --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/impl/crud-restore.command.ts @@ -0,0 +1,10 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { type CrudCommandInterface } from '../interfaces/crud-command.interface.js'; + +export class CrudRestoreCommand< + Entity extends PlainLiteralObject, +> implements CrudCommandInterface { + constructor(public readonly context: CrudContextInterface) {} +} diff --git a/packages/nestjs-crud/src/application/commands/impl/crud-soft-delete.command.ts b/packages/nestjs-crud/src/application/commands/impl/crud-soft-delete.command.ts new file mode 100644 index 000000000..6c1c68d28 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/impl/crud-soft-delete.command.ts @@ -0,0 +1,10 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { type CrudCommandInterface } from '../interfaces/crud-command.interface.js'; + +export class CrudSoftDeleteCommand< + Entity extends PlainLiteralObject, +> implements CrudCommandInterface { + constructor(public readonly context: CrudContextInterface) {} +} diff --git a/packages/nestjs-crud/src/application/commands/impl/crud-update.command.ts b/packages/nestjs-crud/src/application/commands/impl/crud-update.command.ts new file mode 100644 index 000000000..7ac09dcd3 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/impl/crud-update.command.ts @@ -0,0 +1,19 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; + +import { CrudWithBodyCommand } from './crud-with-body.command.js'; + +export class CrudUpdateCommand< + Entity extends PlainLiteralObject, + Updatable extends DeepPartial = DeepPartial, +> extends CrudWithBodyCommand { + constructor( + public readonly context: CrudContextInterface, + public readonly dto: Updatable, + ) { + super(context, dto); + } +} diff --git a/packages/nestjs-crud/src/application/commands/impl/crud-with-body.command.ts b/packages/nestjs-crud/src/application/commands/impl/crud-with-body.command.ts new file mode 100644 index 000000000..e53b89883 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/impl/crud-with-body.command.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { type CrudCreateBatchInterface } from '../../../infrastructure/interfaces/crud-create-batch.interface.js'; +import { type CrudCommandInterface } from '../interfaces/crud-command.interface.js'; + +export class CrudWithBodyCommand< + Entity extends PlainLiteralObject, + Body extends DeepPartial = DeepPartial, +> implements CrudCommandInterface { + constructor( + public readonly context: CrudContextInterface, + public readonly dto: Body | CrudCreateBatchInterface, + ) {} +} diff --git a/packages/nestjs-crud/src/application/commands/interfaces/crud-command-handler.interface.ts b/packages/nestjs-crud/src/application/commands/interfaces/crud-command-handler.interface.ts new file mode 100644 index 000000000..0fc3319e1 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/interfaces/crud-command-handler.interface.ts @@ -0,0 +1,23 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudAdapter } from '../../../infrastructure/adapters/crud.adapter.js'; + +import { type CrudCommandInterface } from './crud-command.interface.js'; + +/** + * The CRUD command handler interface. + * + * This interface defines the contract for command handlers without + * coupling to `@nestjs/cqrs`. The resolver applies CQRS decorators + * if needed at decoration-time. + */ +export interface CrudCommandHandlerInterface< + Entity extends PlainLiteralObject = PlainLiteralObject, + _Relations extends PlainLiteralObject[] = PlainLiteralObject[], +> { + readonly crudAdapter?: CrudAdapter; + + execute( + command: CrudCommandInterface, + ): Promise; +} diff --git a/packages/nestjs-crud/src/application/commands/interfaces/crud-command.interface.ts b/packages/nestjs-crud/src/application/commands/interfaces/crud-command.interface.ts new file mode 100644 index 000000000..44fe10085 --- /dev/null +++ b/packages/nestjs-crud/src/application/commands/interfaces/crud-command.interface.ts @@ -0,0 +1,11 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; + +/** + * Interface for CRUD command class instances. + * Command classes take a context object and optional data, used for command operations. + */ +export interface CrudCommandInterface { + readonly context: CrudContextInterface; +} diff --git a/packages/nestjs-crud/src/application/queries/handlers/__tests__/crud-list.handler.spec.ts b/packages/nestjs-crud/src/application/queries/handlers/__tests__/crud-list.handler.spec.ts new file mode 100644 index 000000000..e6e92a23c --- /dev/null +++ b/packages/nestjs-crud/src/application/queries/handlers/__tests__/crud-list.handler.spec.ts @@ -0,0 +1,83 @@ +import { BadRequestException } from '@nestjs/common'; + +import { mockCrudContext } from '../../../../__fixtures__/crud/mocks/crud-context.mock.js'; +import { createPaginatedResponse } from '../../../../__fixtures__/crud/mocks/crud-paginated-response.mock.js'; +import { CrudQueryException } from '../../../../infrastructure/exceptions/crud-query.exception.js'; +import { CrudListQuery } from '../../impl/crud-list.query.js'; +import { CrudListHandler } from '../crud-list.handler.js'; + +import { + type TestCrudAdapter, + type TestEntity, + createTestAdapter, +} from './fixtures/query-handler-test.fixture.js'; + +describe('CrudListHandler', () => { + let adapter: TestCrudAdapter; + + beforeAll(() => { + adapter = createTestAdapter(); + }); + + describe('execute', () => { + it('should delegate to crudAdapter.list()', async () => { + const handler = new CrudListHandler(adapter); + const context = mockCrudContext(); + const paginatedResult = createPaginatedResponse([ + { id: '1', name: 'Alice' }, + ]); + + vi.spyOn(adapter, 'list').mockResolvedValueOnce(paginatedResult); + + const result = await handler.execute(new CrudListQuery(context)); + + expect(result).toEqual(paginatedResult); + expect(adapter.list).toHaveBeenCalledWith(context); + }); + + it('should re-throw HttpException as-is', async () => { + const handler = new CrudListHandler(adapter); + const context = mockCrudContext(); + const httpError = new BadRequestException('Invalid query'); + + vi.spyOn(adapter, 'list').mockRejectedValueOnce(httpError); + + await expect(handler.execute(new CrudListQuery(context))).rejects.toThrow( + httpError, + ); + }); + + it('should wrap non-Http errors in CrudQueryException', async () => { + const handler = new CrudListHandler(adapter); + const context = mockCrudContext(); + + vi.spyOn(adapter, 'list').mockRejectedValueOnce( + new Error('database timeout'), + ); + + await expect(handler.execute(new CrudListQuery(context))).rejects.toThrow( + CrudQueryException, + ); + }); + + it('should include entity name in CrudQueryException', async () => { + const handler = new CrudListHandler(adapter); + const context = mockCrudContext(); + + vi.spyOn(adapter, 'list').mockRejectedValueOnce( + new Error('connection lost'), + ); + vi.spyOn(adapter, 'entityName').mockReturnValue('TestEntity'); + + try { + await handler.execute(new CrudListQuery(context)); + throw new Error('Expected CrudQueryException to be thrown'); + } catch (e) { + expect(e).toBeInstanceOf(CrudQueryException); + expect((e as CrudQueryException).context.entityName).toEqual( + 'TestEntity', + ); + } + }); + }); +}); diff --git a/packages/nestjs-crud/src/application/queries/handlers/__tests__/crud-query-base.handler.spec.ts b/packages/nestjs-crud/src/application/queries/handlers/__tests__/crud-query-base.handler.spec.ts new file mode 100644 index 000000000..8e3c57f76 --- /dev/null +++ b/packages/nestjs-crud/src/application/queries/handlers/__tests__/crud-query-base.handler.spec.ts @@ -0,0 +1,22 @@ +import { CrudQueryException } from '../../../../infrastructure/exceptions/crud-query.exception.js'; +import { CrudQueryBaseHandler } from '../crud-query-base.handler.js'; + +import { + type TestCrudAdapter, + createTestAdapter, +} from './fixtures/query-handler-test.fixture.js'; + +describe('CrudQueryBaseHandler', () => { + let adapter: TestCrudAdapter; + + beforeAll(() => { + adapter = createTestAdapter(); + }); + + describe('execute', () => { + it('should throw CrudQueryException from base implementation', () => { + const handler = new CrudQueryBaseHandler(adapter); + expect(() => handler.execute({} as never)).toThrow(CrudQueryException); + }); + }); +}); diff --git a/packages/nestjs-crud/src/application/queries/handlers/__tests__/crud-read.handler.spec.ts b/packages/nestjs-crud/src/application/queries/handlers/__tests__/crud-read.handler.spec.ts new file mode 100644 index 000000000..def833d90 --- /dev/null +++ b/packages/nestjs-crud/src/application/queries/handlers/__tests__/crud-read.handler.spec.ts @@ -0,0 +1,80 @@ +import { BadRequestException } from '@nestjs/common'; + +import { mockCrudContext } from '../../../../__fixtures__/crud/mocks/crud-context.mock.js'; +import { CrudQueryException } from '../../../../infrastructure/exceptions/crud-query.exception.js'; +import { CrudReadQuery } from '../../impl/crud-read.query.js'; +import { CrudReadHandler } from '../crud-read.handler.js'; + +import { + type TestCrudAdapter, + type TestEntity, + createTestAdapter, +} from './fixtures/query-handler-test.fixture.js'; + +describe('CrudReadHandler', () => { + let adapter: TestCrudAdapter; + + beforeAll(() => { + adapter = createTestAdapter(); + }); + + describe('execute', () => { + it('should delegate to crudAdapter.read()', async () => { + const handler = new CrudReadHandler(adapter); + const context = mockCrudContext(); + const entity: TestEntity = { id: '1', name: 'Alice' }; + + vi.spyOn(adapter, 'read').mockResolvedValueOnce(entity); + + const result = await handler.execute(new CrudReadQuery(context)); + + expect(result).toEqual(entity); + expect(adapter.read).toHaveBeenCalledWith(context); + }); + + it('should re-throw HttpException as-is', async () => { + const handler = new CrudReadHandler(adapter); + const context = mockCrudContext(); + const httpError = new BadRequestException('Invalid request'); + + vi.spyOn(adapter, 'read').mockRejectedValueOnce(httpError); + + await expect(handler.execute(new CrudReadQuery(context))).rejects.toThrow( + httpError, + ); + }); + + it('should wrap non-Http errors in CrudQueryException', async () => { + const handler = new CrudReadHandler(adapter); + const context = mockCrudContext(); + + vi.spyOn(adapter, 'read').mockRejectedValueOnce( + new Error('database timeout'), + ); + + await expect(handler.execute(new CrudReadQuery(context))).rejects.toThrow( + CrudQueryException, + ); + }); + + it('should include entity name in CrudQueryException', async () => { + const handler = new CrudReadHandler(adapter); + const context = mockCrudContext(); + + vi.spyOn(adapter, 'read').mockRejectedValueOnce( + new Error('connection lost'), + ); + vi.spyOn(adapter, 'entityName').mockReturnValue('TestEntity'); + + try { + await handler.execute(new CrudReadQuery(context)); + throw new Error('Expected CrudQueryException to be thrown'); + } catch (e) { + expect(e).toBeInstanceOf(CrudQueryException); + expect((e as CrudQueryException).context.entityName).toEqual( + 'TestEntity', + ); + } + }); + }); +}); diff --git a/packages/nestjs-crud/src/application/queries/handlers/__tests__/fixtures/query-handler-test.fixture.ts b/packages/nestjs-crud/src/application/queries/handlers/__tests__/fixtures/query-handler-test.fixture.ts new file mode 100644 index 000000000..bce6b7657 --- /dev/null +++ b/packages/nestjs-crud/src/application/queries/handlers/__tests__/fixtures/query-handler-test.fixture.ts @@ -0,0 +1,44 @@ +import { createMockRepository } from '@concepta/nestjs-repository/testing'; + +import { CrudAdapter } from '../../../../../infrastructure/adapters/crud.adapter.js'; + +export interface TestEntity { + id: string; + name: string; +} + +export class TestCrudAdapter extends CrudAdapter { + decidePagination(): boolean { + return true; + } +} + +export const relationsWithPosts = { + rootKey: 'id' as const, + relations: [ + { + property: 'posts', + cardinality: 'many' as const, + entity: 'PostEntity', + primaryKey: 'id', + foreignKey: 'authorId', + }, + ] as never, +}; + +export function createTestAdapter(): TestCrudAdapter { + return new TestCrudAdapter( + createMockRepository({ + name: 'TestEntity', + columns: [ + { name: 'id', isPrimary: true, isRemoveDate: false, isVersion: false }, + { + name: 'name', + isPrimary: false, + isRemoveDate: false, + isVersion: false, + }, + ], + }), + ); +} diff --git a/packages/nestjs-crud/src/application/queries/handlers/crud-list.handler.ts b/packages/nestjs-crud/src/application/queries/handlers/crud-list.handler.ts new file mode 100644 index 000000000..2fb9529e5 --- /dev/null +++ b/packages/nestjs-crud/src/application/queries/handlers/crud-list.handler.ts @@ -0,0 +1,28 @@ +import { HttpException, type PlainLiteralObject } from '@nestjs/common'; + +import { CrudQueryException } from '../../../infrastructure/exceptions/crud-query.exception.js'; +import { type CrudResponsePaginatedInterface } from '../../../infrastructure/interfaces/crud-response-paginated.interface.js'; +import { type CrudListQuery } from '../impl/crud-list.query.js'; + +import { CrudQueryBaseHandler } from './crud-query-base.handler.js'; + +export class CrudListHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, +> extends CrudQueryBaseHandler { + async execute( + query: CrudListQuery, + ): Promise> { + const { context } = query; + + try { + return await this.crudAdapter.list(context); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + throw new CrudQueryException(this.crudAdapter.entityName(), { + originalError: e, + }); + } + } +} diff --git a/packages/nestjs-crud/src/application/queries/handlers/crud-query-base.handler.ts b/packages/nestjs-crud/src/application/queries/handlers/crud-query-base.handler.ts new file mode 100644 index 000000000..2a59f9545 --- /dev/null +++ b/packages/nestjs-crud/src/application/queries/handlers/crud-query-base.handler.ts @@ -0,0 +1,27 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudAdapter } from '../../../infrastructure/adapters/crud.adapter.js'; +import { CrudQueryException } from '../../../infrastructure/exceptions/crud-query.exception.js'; +import { type CrudResponsePaginatedInterface } from '../../../infrastructure/interfaces/crud-response-paginated.interface.js'; +import { type CrudQueryHandlerInterface } from '../interfaces/crud-query-handler.interface.js'; +import { type CrudQueryInterface } from '../interfaces/crud-query.interface.js'; + +/** + * Base class for CRUD query handlers. + * + * This class does NOT implement IQueryHandler directly. The resolver + * applies the `@QueryHandler` decorator if CQRS is being used. + */ +export class CrudQueryBaseHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, +> implements CrudQueryHandlerInterface { + constructor(readonly crudAdapter: CrudAdapter) {} + + execute( + _query: CrudQueryInterface, + ): Promise> { + throw new CrudQueryException(this.crudAdapter.entityName(), { + message: 'Subclass must implement execute()', + }); + } +} diff --git a/packages/nestjs-crud/src/application/queries/handlers/crud-read.handler.ts b/packages/nestjs-crud/src/application/queries/handlers/crud-read.handler.ts new file mode 100644 index 000000000..54647619b --- /dev/null +++ b/packages/nestjs-crud/src/application/queries/handlers/crud-read.handler.ts @@ -0,0 +1,25 @@ +import { HttpException, type PlainLiteralObject } from '@nestjs/common'; + +import { CrudQueryException } from '../../../infrastructure/exceptions/crud-query.exception.js'; +import { type CrudReadQuery } from '../impl/crud-read.query.js'; + +import { CrudQueryBaseHandler } from './crud-query-base.handler.js'; + +export class CrudReadHandler< + Entity extends PlainLiteralObject = PlainLiteralObject, +> extends CrudQueryBaseHandler { + async execute(query: CrudReadQuery): Promise { + const { context } = query; + + try { + return await this.crudAdapter.read(context); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + throw new CrudQueryException(this.crudAdapter.entityName(), { + originalError: e, + }); + } + } +} diff --git a/packages/nestjs-crud/src/application/queries/impl/crud-list.query.ts b/packages/nestjs-crud/src/application/queries/impl/crud-list.query.ts new file mode 100644 index 000000000..9b0c0c58f --- /dev/null +++ b/packages/nestjs-crud/src/application/queries/impl/crud-list.query.ts @@ -0,0 +1,10 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { type CrudQueryInterface } from '../interfaces/crud-query.interface.js'; + +export class CrudListQuery< + Entity extends PlainLiteralObject, +> implements CrudQueryInterface { + constructor(public readonly context: CrudContextInterface) {} +} diff --git a/packages/nestjs-crud/src/application/queries/impl/crud-read.query.ts b/packages/nestjs-crud/src/application/queries/impl/crud-read.query.ts new file mode 100644 index 000000000..cdba52bfd --- /dev/null +++ b/packages/nestjs-crud/src/application/queries/impl/crud-read.query.ts @@ -0,0 +1,10 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; +import { type CrudQueryInterface } from '../interfaces/crud-query.interface.js'; + +export class CrudReadQuery< + Entity extends PlainLiteralObject, +> implements CrudQueryInterface { + constructor(public readonly context: CrudContextInterface) {} +} diff --git a/packages/nestjs-crud/src/application/queries/interfaces/crud-query-handler.interface.ts b/packages/nestjs-crud/src/application/queries/interfaces/crud-query-handler.interface.ts new file mode 100644 index 000000000..0b1134b76 --- /dev/null +++ b/packages/nestjs-crud/src/application/queries/interfaces/crud-query-handler.interface.ts @@ -0,0 +1,24 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudAdapter } from '../../../infrastructure/adapters/crud.adapter.js'; +import { type CrudResponsePaginatedInterface } from '../../../infrastructure/interfaces/crud-response-paginated.interface.js'; + +import { type CrudQueryInterface } from './crud-query.interface.js'; + +/** + * The CRUD query handler interface. + * + * This interface defines the contract for query handlers without + * coupling to `@nestjs/cqrs`. The resolver applies CQRS decorators + * if needed at decoration-time. + */ +export interface CrudQueryHandlerInterface< + Entity extends PlainLiteralObject = PlainLiteralObject, + _Relations extends PlainLiteralObject[] = PlainLiteralObject[], +> { + crudAdapter: CrudAdapter; + + execute( + query: CrudQueryInterface, + ): Promise>; +} diff --git a/packages/nestjs-crud/src/application/queries/interfaces/crud-query.interface.ts b/packages/nestjs-crud/src/application/queries/interfaces/crud-query.interface.ts new file mode 100644 index 000000000..2f9d17326 --- /dev/null +++ b/packages/nestjs-crud/src/application/queries/interfaces/crud-query.interface.ts @@ -0,0 +1,11 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudContextInterface } from '../../../infrastructure/interceptors/interfaces/crud-context.interface.js'; + +/** + * Interface for CRUD query class instances. + * Query classes take a context object and are used for read operations. + */ +export interface CrudQueryInterface { + readonly context: CrudContextInterface; +} diff --git a/packages/nestjs-crud/src/application/utils/create-operation-classes.ts b/packages/nestjs-crud/src/application/utils/create-operation-classes.ts new file mode 100644 index 000000000..2b351d5d5 --- /dev/null +++ b/packages/nestjs-crud/src/application/utils/create-operation-classes.ts @@ -0,0 +1,48 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { type CrudCommandInterface } from '../commands/interfaces/crud-command.interface.js'; +import { type CrudQueryInterface } from '../queries/interfaces/crud-query.interface.js'; + +/** + * Creates a new class extending the base class with a prefixed name. + * + * @param prefix - Prefix for the class name (e.g., 'User') + * @param baseClass - The base class to extend + * @returns A new class with name `${prefix}${BaseClass.name}` + */ +function createNamedClass(prefix: string, baseClass: T): T { + const NewClass = class extends baseClass {}; + Object.defineProperty(NewClass, 'name', { + value: `${prefix}${baseClass.name}`, + }); + return NewClass; +} + +/** + * Creates an entity-specific query class. + * + * @param name - Entity name (e.g., 'Company') used for class naming + * @param baseQuery - The base query class to extend + */ +export function createQuery( + name: string, + baseQuery: Type>, +): Type> { + return createNamedClass>>(name, baseQuery); +} + +/** + * Creates an entity-specific command class. + * + * @param name - Entity name (e.g., 'Company') used for class naming + * @param baseCommand - The base command class to extend + */ +export function createCommand( + name: string, + baseCommand: Type>, +): Type> { + return createNamedClass>>( + name, + baseCommand, + ); +} diff --git a/packages/nestjs-crud/src/application/utils/create-operation-handlers.ts b/packages/nestjs-crud/src/application/utils/create-operation-handlers.ts new file mode 100644 index 000000000..854bf6f3f --- /dev/null +++ b/packages/nestjs-crud/src/application/utils/create-operation-handlers.ts @@ -0,0 +1,112 @@ +import { Inject, Injectable, PlainLiteralObject, Type } from '@nestjs/common'; + +import { CrudAdapter } from '../../infrastructure/adapters/crud.adapter.js'; +import { CrudControllerEntityInterface } from '../../infrastructure/interfaces/crud-controller-entity.interface.js'; +import { CrudAdapterResolver } from '../../infrastructure/resolvers/crud-adapter.resolver.js'; +import { + CrudResolverInterface, + CrudResolverStatic, +} from '../../infrastructure/resolvers/interfaces/crud-resolver.interface.js'; +import { + getControllerName, + getDynamicAdapterToken, +} from '../../infrastructure/utils/crud-infra.utils.js'; +import { CrudCommandHandlerInterface } from '../commands/interfaces/crud-command-handler.interface.js'; +import { CrudQueryBaseHandler } from '../queries/handlers/crud-query-base.handler.js'; + +interface CreateHandlerOptionsBase extends CrudControllerEntityInterface { + /** Method name for unique class naming */ + methodName?: string; + /** Resolver class that controls decoration (defaults to CrudAdapterResolver) */ + resolverClass?: Type & CrudResolverStatic; +} + +interface CreateQueryHandlerOptions< + Entity extends PlainLiteralObject, +> extends CreateHandlerOptionsBase { + /** Base handler class to extend */ + baseClass: Type>; + /** Query class for handler registration */ + queryClass: Type; +} + +interface CreateCommandHandlerOptions< + Entity extends PlainLiteralObject, +> extends CreateHandlerOptionsBase { + /** Base handler class to extend */ + baseClass: Type>; + /** Command class for handler registration */ + commandClass: Type; +} + +/** + * Creates an entity-specific query handler class. + * + * Uses the provided resolver class to apply appropriate decorators. + */ +export function createQueryHandler( + options: CreateQueryHandlerOptions, +): Type> { + const { + entity, + methodName, + baseClass, + queryClass, + resolverClass = CrudAdapterResolver, + } = options; + const adapterToken = getDynamicAdapterToken(entity); + const baseName = getControllerName(options); + const nameParts = [baseName, methodName, 'Handler'].filter(Boolean); + class HandlerClass extends baseClass { + constructor(@Inject(adapterToken) adapter: CrudAdapter) { + super(adapter); + } + } + Object.defineProperty(HandlerClass, 'name', { + value: nameParts.join('_'), + }); + + // Apply @Injectable() universally to all handlers + Injectable()(HandlerClass); + + // Let resolver add any additional decorators (e.g., @QueryHandler for CQRS) + resolverClass.decorateQueryHandler(HandlerClass, queryClass); + + return HandlerClass; +} + +/** + * Creates an entity-specific command handler class. + * + * Uses the provided resolver class to apply appropriate decorators. + */ +export function createCommandHandler( + options: CreateCommandHandlerOptions, +): Type> { + const { + entity, + methodName, + baseClass, + commandClass, + resolverClass = CrudAdapterResolver, + } = options; + const adapterToken = getDynamicAdapterToken(entity); + const baseName = getControllerName(options); + const nameParts = [baseName, methodName, 'Handler'].filter(Boolean); + class HandlerClass extends baseClass { + constructor(@Inject(adapterToken) adapter: CrudAdapter) { + super(adapter); + } + } + Object.defineProperty(HandlerClass, 'name', { + value: nameParts.join('_'), + }); + + // Apply @Injectable() universally to all handlers + Injectable()(HandlerClass); + + // Let resolver add any additional decorators (e.g., @CommandHandler for CQRS) + resolverClass.decorateCommandHandler(HandlerClass, commandClass); + + return HandlerClass; +} diff --git a/packages/nestjs-crud/src/config/crud-default.config.ts b/packages/nestjs-crud/src/config/crud-default.config.ts deleted file mode 100644 index bb6d24460..000000000 --- a/packages/nestjs-crud/src/config/crud-default.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { - CRUD_MODULE_DEFAULT_SETTINGS_TOKEN, - CRUD_MODULE_DEFAULT_TRANSFORM_OPTIONS, -} from '../crud.constants'; -import { CrudModuleSettingsInterface } from '../interfaces/crud-module-settings.interface'; - -/** - * Default configuration for crud. - */ -export const crudDefaultConfig = registerAs( - CRUD_MODULE_DEFAULT_SETTINGS_TOKEN, - (): CrudModuleSettingsInterface => ({ - serialization: { - toInstanceOptions: CRUD_MODULE_DEFAULT_TRANSFORM_OPTIONS, - toPlainOptions: CRUD_MODULE_DEFAULT_TRANSFORM_OPTIONS, - }, - }), -); diff --git a/packages/nestjs-crud/src/crud.constants.ts b/packages/nestjs-crud/src/crud.constants.ts index 28fc05013..bfdd15f91 100644 --- a/packages/nestjs-crud/src/crud.constants.ts +++ b/packages/nestjs-crud/src/crud.constants.ts @@ -1,18 +1,11 @@ -import { ClassTransformOptions } from 'class-transformer'; - -import { ValidationPipeOptions } from '@nestjs/common'; - -import { CrudParamsOptionsInterface } from './crud/interfaces/crud-params-options.interface'; +import { type CrudParamsOptionsInterface } from './infrastructure/interfaces/crud-params-options.interface.js'; export const CRUD_MODULE_SETTINGS_TOKEN = 'CRUD_MODULE_SETTINGS_TOKEN'; export const CRUD_MODULE_DEFAULT_SETTINGS_TOKEN = 'CRUD_MODULE_DEFAULT_SETTINGS_TOKEN'; -export const CRUD_MODULE_CRUD_REQUEST_KEY = 'CRUD_MODULE_CRUD_REQUEST_KEY'; - -export const CRUD_MODULE_ROUTE_MODEL_METADATA = - 'CRUD_MODULE_ROUTE_MODEL_METADATA'; +export const CRUD_MODULE_CRUD_CONTEXT_KEY = 'CRUD_MODULE_CRUD_CONTEXT_KEY'; export const CRUD_MODULE_ROUTE_VALIDATION_METADATA = 'CRUD_MODULE_ROUTE_VALIDATION_METADATA'; @@ -20,8 +13,8 @@ export const CRUD_MODULE_ROUTE_VALIDATION_METADATA = export const CRUD_MODULE_ROUTE_SERIALIZATION_METADATA = 'CRUD_MODULE_ROUTE_SERIALIZATION_METADATA'; -export const CRUD_MODULE_ROUTE_ACTION_METADATA = - 'CRUD_MODULE_ROUTE_ACTION_METADATA'; +export const CRUD_MODULE_ROUTE_OPERATION_METADATA = + 'CRUD_MODULE_ROUTE_OPERATION_METADATA'; export const CRUD_MODULE_ROUTE_PARAMS_METADATA = 'CRUD_MODULE_ROUTE_PARAMS_METADATA'; @@ -53,30 +46,46 @@ export const CRUD_MODULE_ROUTE_QUERY_CACHE_METADATA = export const CRUD_MODULE_ROUTE_QUERY_SOFT_DELETE_METADATA = 'CRUD_MODULE_ROUTE_QUERY_SOFT_DELETE_METADATA'; -export const CRUD_MODULE_ROUTE_RELATIONS_METADATA = - 'CRUD_MODULE_ROUTE_RELATIONS_METADATA'; +export const CRUD_MODULE_ROUTE_QUERY_JOIN_METADATA = + 'CRUD_MODULE_ROUTE_QUERY_JOIN_METADATA'; + +// Request metadata keys +export const CRUD_MODULE_REQUEST_BODY_METADATA = + 'CRUD_MODULE_REQUEST_BODY_METADATA'; -export const CRUD_MODULE_ROUTE_CREATE_ONE_METADATA = - 'CRUD_MODULE_ROUTE_CREATE_ONE_METADATA'; +export const CRUD_MODULE_REQUEST_BODY_BATCH_METADATA = + 'CRUD_MODULE_REQUEST_BODY_BATCH_METADATA'; -export const CRUD_MODULE_ROUTE_UPDATE_ONE_METADATA = - 'CRUD_MODULE_ROUTE_UPDATE_ONE_METADATA'; +// Controller metadata keys +export const CRUD_MODULE_CONTROLLER_ENTITY_METADATA = + 'CRUD_MODULE_CONTROLLER_ENTITY_METADATA'; -export const CRUD_MODULE_ROUTE_DELETE_ONE_METADATA = - 'CRUD_MODULE_ROUTE_DELETE_ONE_METADATA'; +export const CRUD_MODULE_CONTROLLER_NAME_METADATA = + 'CRUD_MODULE_CONTROLLER_NAME_METADATA'; -export const CRUD_MODULE_ROUTE_REPLACE_ONE_METADATA = - 'CRUD_MODULE_ROUTE_REPLACE_ONE_METADATA'; +export const CRUD_MODULE_CONTROLLER_ADAPTER_METADATA = + 'CRUD_MODULE_CONTROLLER_ADAPTER_METADATA'; -export const CRUD_MODULE_ROUTE_RECOVER_ONE_METADATA = - 'CRUD_MODULE_ROUTE_RECOVER_ONE_METADATA'; +// Response metadata keys +export const CRUD_MODULE_RESPONSE_RESOURCE_METADATA = + 'CRUD_MODULE_RESPONSE_RESOURCE_METADATA'; + +export const CRUD_MODULE_RESPONSE_PAGINATED_METADATA = + 'CRUD_MODULE_RESPONSE_PAGINATED_METADATA'; + +// Return behavior metadata keys +export const CRUD_MODULE_ROUTE_RETURN_DELETED_METADATA = + 'CRUD_MODULE_ROUTE_RETURN_DELETED_METADATA'; + +export const CRUD_MODULE_ROUTE_RETURN_RESTORED_METADATA = + 'CRUD_MODULE_ROUTE_RETURN_RESTORED_METADATA'; export const CRUD_MODULE_ROUTE_ID_DEFAULT_PATH = ':id'; export const CRUD_MODULE_ROUTE_CREATE_MANY_DEFAULT_PATH = '/bulk'; -export const CRUD_MODULE_ROUTE_RECOVER_ONE_DEFAULT_PATH = - '/recover/' + CRUD_MODULE_ROUTE_ID_DEFAULT_PATH; +export const CRUD_MODULE_ROUTE_RESTORE_DEFAULT_PATH = + '/restore/' + CRUD_MODULE_ROUTE_ID_DEFAULT_PATH; export const CRUD_MODULE_PARAM_BODY_METADATA = 'CRUD_MODULE_PARAM_BODY_METADATA'; @@ -89,28 +98,36 @@ export const CRUD_MODULE_API_PARAMS_METADATA = export const CRUD_MODULE_API_RESPONSE_METADATA = 'CRUD_MODULE_API_RESPONSE_METADATA'; +export const CRUD_MODULE_API_BODY_METADATA = 'CRUD_MODULE_API_BODY_METADATA'; + +// Query/Command metadata keys (classes to dispatch) +export const CRUD_MODULE_ROUTE_QUERY_METADATA = + 'CRUD_MODULE_ROUTE_QUERY_METADATA'; + +export const CRUD_MODULE_ROUTE_COMMAND_METADATA = + 'CRUD_MODULE_ROUTE_COMMAND_METADATA'; + +// Handler metadata keys (handler classes for queries/commands) +export const CRUD_MODULE_ROUTE_QUERY_HANDLER_METADATA = + 'CRUD_MODULE_ROUTE_QUERY_HANDLER_METADATA'; + +export const CRUD_MODULE_ROUTE_COMMAND_HANDLER_METADATA = + 'CRUD_MODULE_ROUTE_COMMAND_HANDLER_METADATA'; + +// Resolver metadata keys +export const CRUD_MODULE_RESOLVER_METADATA = 'CRUD_MODULE_RESOLVER_METADATA'; + +export const CRUD_DEFAULT_RESOLVER_TOKEN = Symbol( + 'CRUD_DEFAULT_RESOLVER_TOKEN', +); + export const CRUD_MODULE_DEFAULT_PARAMS_OPTIONS: CrudParamsOptionsInterface<{ id?: { field?: string }; }> = { id: { field: 'id', type: 'string', primary: true }, }; -export const CRUD_MODULE_DEFAULT_TRANSFORM_OPTIONS: ClassTransformOptions = { - strategy: 'excludeAll', - excludeExtraneousValues: true, - excludePrefixes: ['_', '__'], -}; - -export const CRUD_MODULE_DEFAULT_VALIDATION_PIPE_OPTIONS: ValidationPipeOptions = - { - transform: true, - transformOptions: CRUD_MODULE_DEFAULT_TRANSFORM_OPTIONS, - }; - -// Federation constants -export const CRUD_FEDERATION_DEFAULT_LIMIT = 10; -export const CRUD_FEDERATION_DEFAULT_PAGE = 1; -export const CRUD_RELATION_CARDINALITY_ONE = 'one'; -export const CRUD_RELATION_CARDINALITY_MANY = 'many'; -export const CRUD_FEDERATION_MAX_ITERATIONS = 10; -export const CRUD_FEDERATION_MAX_BUFFER_SIZE = 1000; +// CQRS tokens +export const CRUD_CONTEXT_PROVIDER_TOKEN = Symbol( + 'CRUD_CONTEXT_PROVIDER_TOKEN', +); diff --git a/packages/nestjs-crud/src/crud.module-definition.ts b/packages/nestjs-crud/src/crud.module-definition.ts index 29c527931..26108fb66 100644 --- a/packages/nestjs-crud/src/crud.module-definition.ts +++ b/packages/nestjs-crud/src/crud.module-definition.ts @@ -1,18 +1,25 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { APP_INTERCEPTOR } from '@nestjs/core'; -import { createSettingsProvider } from '@concepta/nestjs-common'; +import { createSettingsProvider } from '@concepta/nestjs-core'; -import { crudDefaultConfig } from './config/crud-default.config'; -import { CRUD_MODULE_SETTINGS_TOKEN } from './crud.constants'; -import { CrudModuleOptionsExtrasInterface } from './interfaces/crud-module-options-extras.interface'; -import { CrudModuleOptionsInterface } from './interfaces/crud-module-options.interface'; -import { CrudModuleSettingsInterface } from './interfaces/crud-module-settings.interface'; -import { CrudReflectionService } from './services/crud-reflection.service'; +import { + CRUD_DEFAULT_RESOLVER_TOKEN, + CRUD_MODULE_SETTINGS_TOKEN, +} from './crud.constants.js'; +import { crudDefaultConfig } from './infrastructure/config/crud-default.config.js'; +import { type CrudModuleOptionsExtrasInterface } from './infrastructure/config/interfaces/crud-module-options-extras.interface.js'; +import { type CrudModuleOptionsInterface } from './infrastructure/config/interfaces/crud-module-options.interface.js'; +import { type CrudModuleSettingsInterface } from './infrastructure/config/interfaces/crud-module-settings.interface.js'; +import { CrudContextOverlay } from './infrastructure/interceptors/crud-context.overlay.js'; +import { CrudAdapterResolver } from './infrastructure/resolvers/crud-adapter.resolver.js'; +import { CrudOperationResolver } from './infrastructure/resolvers/crud-operation.resolver.js'; +import { CrudMetaview } from './infrastructure/services/crud-metaview.service.js'; const RAW_OPTIONS_TOKEN = Symbol('__CRUD_MODULE_RAW_OPTIONS_TOKEN__'); @@ -38,14 +45,18 @@ function definitionTransform( extras: CrudModuleOptionsExtrasInterface, ): DynamicModule { const { providers = [] } = definition; - const { global = false, imports } = extras; + const { global = false, imports, defaultResolver } = extras; return { ...definition, global, imports: createCrudImports({ imports }), - providers: createCrudProviders({ providers }), - exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createCrudExports()], + providers: createCrudProviders({ providers, defaultResolver }), + exports: [ + ConfigModule, + RAW_OPTIONS_TOKEN, + ...createCrudExports({ defaultResolver }), + ], }; } @@ -61,18 +72,43 @@ export function createCrudImports( } } -export function createCrudExports() { - return [CRUD_MODULE_SETTINGS_TOKEN, CrudReflectionService]; +export function createCrudExports(options?: { + defaultResolver?: CrudModuleOptionsExtrasInterface['defaultResolver']; +}) { + const resolverClass = options?.defaultResolver ?? CrudAdapterResolver; + + return [ + CRUD_MODULE_SETTINGS_TOKEN, + CrudContextOverlay, + CrudMetaview, + CrudAdapterResolver, + CrudOperationResolver, + CRUD_DEFAULT_RESOLVER_TOKEN, + resolverClass, + ]; } export function createCrudProviders(options: { - overrides?: CrudOptions; providers?: Provider[]; + defaultResolver?: CrudModuleOptionsExtrasInterface['defaultResolver']; }): Provider[] { + const { providers = [], defaultResolver } = options; + + const resolverClass = defaultResolver ?? CrudAdapterResolver; + return [ - ...(options.providers ?? []), - CrudReflectionService, - createCrudSettingsProvider(options.overrides), + ...providers, + CrudContextOverlay, + CrudMetaview, + CrudAdapterResolver, + CrudOperationResolver, + resolverClass, + { + provide: CRUD_DEFAULT_RESOLVER_TOKEN, + useExisting: resolverClass, + }, + createCrudSettingsProvider(), + { provide: APP_INTERCEPTOR, useClass: CrudContextOverlay }, ]; } diff --git a/packages/nestjs-crud/src/crud.module.spec.ts b/packages/nestjs-crud/src/crud.module.spec.ts deleted file mode 100644 index 781cfebd6..000000000 --- a/packages/nestjs-crud/src/crud.module.spec.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { Controller, Get, Injectable, Module } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { CrudAdapter } from './crud/adapters/crud.adapter'; -import { TypeOrmCrudAdapter } from './crud/adapters/typeorm-crud.adapter'; -import { CRUD_MODULE_SETTINGS_TOKEN } from './crud.constants'; -import { CrudModule } from './crud.module'; -import { CrudModuleSettingsInterface } from './interfaces/crud-module-settings.interface'; -import { CrudService } from './services/crud.service'; -import { - getDynamicCrudAdapterToken, - InjectDynamicCrudAdapter, -} from './util/inject-dynamic-crud-adapter.decorator'; -import { getDynamicCrudServiceToken } from './util/inject-dynamic-crud-service.decorator'; - -import { CRUD_TEST_COMPANY_ENTITY_KEY } from './__fixtures__/crud-test.constants'; -import { CompanyEntity } from './__fixtures__/typeorm/company/company.entity'; -import { ormSqliteConfig } from './__fixtures__/typeorm/orm.sqlite.config'; - -describe(CrudModule, () => { - let crudModule: CrudModule; - let crudSettings: CrudModuleSettingsInterface; - - describe(CrudModule.register, () => { - beforeAll(async () => { - const testModule = await Test.createTestingModule({ - imports: [CrudModule.register({})], - }).compile(); - - setProviderVars(testModule); - }); - - commonProviderTests(); - }); - - describe(CrudModule.forRoot, () => { - beforeAll(async () => { - const testModule = await Test.createTestingModule({ - imports: [CrudModule.forRoot({})], - }).compile(); - - setProviderVars(testModule); - }); - - commonProviderTests(); - }); - - describe(CrudModule.registerAsync, () => { - beforeEach(async () => { - const testModule = await Test.createTestingModule({ - imports: [CrudModule.registerAsync({ useFactory: () => ({}) })], - }).compile(); - - setProviderVars(testModule); - }); - - commonProviderTests(); - }); - - describe(CrudModule.forRootAsync, () => { - beforeEach(async () => { - const testModule = await Test.createTestingModule({ - imports: [CrudModule.forRootAsync({ useFactory: () => ({}) })], - }).compile(); - - setProviderVars(testModule); - }); - - commonProviderTests(); - }); - - describe('forFeature with empty options', () => { - @Module({ - imports: [CrudModule.forRoot({})], - }) - class AppGlobalTest {} - - @Module({ - imports: [AppGlobalTest, CrudModule.forFeature({})], - }) - class AppFeatureTest {} - - beforeAll(async () => { - const testModule = await Test.createTestingModule({ - imports: [AppFeatureTest], - }).compile(); - - setProviderVars(testModule); - }); - - commonProviderTests(); - }); - - describe('forFeature with settings override', () => { - @Module({ - imports: [CrudModule.forRoot({})], - }) - class AppGlobalTest {} - - @Module({ - imports: [ - AppGlobalTest, - CrudModule.forFeature({ - settings: { - serialization: { toPlainOptions: { strategy: 'excludeAll' } }, - }, - }), - ], - }) - class AppFeatureTest {} - - beforeAll(async () => { - const testModule = await Test.createTestingModule({ - imports: [AppFeatureTest], - }).compile(); - - setProviderVars(testModule); - }); - - commonProviderTests(); - - it('settings should be overriden', async () => { - expect(crudSettings).toEqual({ - serialization: { toPlainOptions: { strategy: 'excludeAll' } }, - }); - }); - }); - - function setProviderVars(testModule: TestingModule) { - crudModule = testModule.get(CrudModule); - crudSettings = testModule.get( - CRUD_MODULE_SETTINGS_TOKEN, - ); - } - - function commonProviderTests() { - it('providers should be loaded', async () => { - expect(crudModule).toBeInstanceOf(CrudModule); - expect(crudSettings).toBeInstanceOf(Object); - }); - } - - describe('forFeature with controller config (uses ConfigurableCrudBuilder)', () => { - const TEST_ENTITY_KEY = CRUD_TEST_COMPANY_ENTITY_KEY; - - let testModule: TestingModule; - - beforeAll(async () => { - testModule = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot(ormSqliteConfig), - TypeOrmExtModule.forFeature({ - [TEST_ENTITY_KEY]: { - entity: CompanyEntity, - }, - }), - CrudModule.forRoot({}), - CrudModule.forFeature({ - cruds: { - [TEST_ENTITY_KEY]: { - entity: CompanyEntity, - adapter: TypeOrmCrudAdapter, - controller: { - path: 'companies', - model: { type: CompanyEntity }, - }, - }, - }, - }), - ], - }).compile(); - - setProviderVars(testModule); - }); - - afterAll(async () => { - await testModule?.close(); - }); - - commonProviderTests(); - - it('should create adapter provider', () => { - const adapter = testModule.get( - getDynamicCrudAdapterToken(TEST_ENTITY_KEY), - ); - expect(adapter).toBeDefined(); - expect(adapter).toBeInstanceOf(TypeOrmCrudAdapter); - }); - - it('should create service provider', () => { - const adapter = testModule.get( - getDynamicCrudAdapterToken(TEST_ENTITY_KEY), - ); - const service = testModule.get>( - getDynamicCrudServiceToken(TEST_ENTITY_KEY), - ); - expect(service).toBeDefined(); - expect(service).toBeInstanceOf(CrudService); - expect(service['crudAdapter']).toEqual(adapter); - }); - }); - - describe('forFeature with custom service class', () => { - const TEST_ENTITY_KEY = CRUD_TEST_COMPANY_ENTITY_KEY; - - @Injectable() - class CustomCompanyService extends CrudService { - constructor( - @InjectDynamicCrudAdapter(TEST_ENTITY_KEY) - crudAdapter: CrudAdapter, - ) { - super(crudAdapter); - } - - customMethod(): string { - return 'custom'; - } - } - - let testModule: TestingModule; - - beforeAll(async () => { - testModule = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot(ormSqliteConfig), - TypeOrmExtModule.forFeature({ - [TEST_ENTITY_KEY]: { - entity: CompanyEntity, - }, - }), - CrudModule.forRoot({}), - CrudModule.forFeature({ - cruds: { - [TEST_ENTITY_KEY]: { - entity: CompanyEntity, - adapter: TypeOrmCrudAdapter, - service: CustomCompanyService, - controller: { - path: 'companies', - model: { type: CompanyEntity }, - }, - }, - }, - }), - ], - }).compile(); - - setProviderVars(testModule); - }); - - afterAll(async () => { - await testModule?.close(); - }); - - commonProviderTests(); - - it('should create adapter provider', () => { - const adapter = testModule.get( - getDynamicCrudAdapterToken(TEST_ENTITY_KEY), - ); - expect(adapter).toBeDefined(); - expect(adapter).toBeInstanceOf(TypeOrmCrudAdapter); - }); - - it('should use custom service class', () => { - const service = testModule.get( - getDynamicCrudServiceToken(TEST_ENTITY_KEY), - ); - expect(service).toBeDefined(); - expect(service).toBeInstanceOf(CustomCompanyService); - expect(service.customMethod()).toBe('custom'); - }); - }); - - describe('forFeature with custom controller class', () => { - const TEST_ENTITY_KEY = CRUD_TEST_COMPANY_ENTITY_KEY; - - @Controller('companies') - class CustomCompanyController { - @Get('ping') - ping(): string { - return 'pong'; - } - } - - let testModule: TestingModule; - - beforeAll(async () => { - testModule = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot(ormSqliteConfig), - TypeOrmExtModule.forFeature({ - [TEST_ENTITY_KEY]: { - entity: CompanyEntity, - }, - }), - CrudModule.forRoot({}), - CrudModule.forFeature({ - cruds: { - [TEST_ENTITY_KEY]: { - entity: CompanyEntity, - adapter: TypeOrmCrudAdapter, - controller: CustomCompanyController, - }, - }, - }), - ], - }).compile(); - - setProviderVars(testModule); - }); - - afterAll(async () => { - await testModule?.close(); - }); - - commonProviderTests(); - - it('should register custom controller', () => { - const controller = testModule.get(CustomCompanyController); - expect(controller).toBeDefined(); - expect(controller).toBeInstanceOf(CustomCompanyController); - expect(controller.ping()).toBe('pong'); - }); - }); - - describe('forFeature with self-contained service (no adapter)', () => { - const TEST_ENTITY_KEY = 'SELF_CONTAINED_TEST'; - - @Injectable() - class SelfContainedService extends CrudService { - constructor() { - super(null as unknown as CrudAdapter); - } - - customMethod(): string { - return 'self-contained'; - } - } - - describe('service with controller config', () => { - let testModule: TestingModule; - - beforeAll(async () => { - testModule = await Test.createTestingModule({ - imports: [ - CrudModule.forRoot({}), - CrudModule.forFeature({ - cruds: { - [TEST_ENTITY_KEY]: { - entity: CompanyEntity, - service: SelfContainedService, - controller: { - path: 'self-contained', - model: { type: CompanyEntity }, - }, - }, - }, - }), - ], - }).compile(); - - setProviderVars(testModule); - }); - - afterAll(async () => { - await testModule?.close(); - }); - - commonProviderTests(); - - it('should use self-contained service', () => { - const service = testModule.get( - getDynamicCrudServiceToken(TEST_ENTITY_KEY), - ); - expect(service).toBeDefined(); - expect(service).toBeInstanceOf(SelfContainedService); - expect(service.customMethod()).toBe('self-contained'); - }); - }); - - describe('service with controller class', () => { - @Controller('self-contained') - class SelfContainedController { - @Get('ping') - ping(): string { - return 'pong'; - } - } - - let testModule: TestingModule; - - beforeAll(async () => { - testModule = await Test.createTestingModule({ - imports: [ - CrudModule.forRoot({}), - CrudModule.forFeature({ - cruds: { - [TEST_ENTITY_KEY]: { - entity: CompanyEntity, - service: SelfContainedService, - controller: SelfContainedController, - }, - }, - }), - ], - }).compile(); - - setProviderVars(testModule); - }); - - afterAll(async () => { - await testModule?.close(); - }); - - commonProviderTests(); - - it('should use self-contained service', () => { - const service = testModule.get( - getDynamicCrudServiceToken(TEST_ENTITY_KEY), - ); - expect(service).toBeDefined(); - expect(service).toBeInstanceOf(SelfContainedService); - }); - - it('should register controller', () => { - const controller = testModule.get(SelfContainedController); - expect(controller).toBeDefined(); - expect(controller.ping()).toBe('pong'); - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/crud.module.ts b/packages/nestjs-crud/src/crud.module.ts index f4f72b959..d46108ebd 100644 --- a/packages/nestjs-crud/src/crud.module.ts +++ b/packages/nestjs-crud/src/crud.module.ts @@ -1,22 +1,12 @@ -import { DynamicModule, Module, Provider, Type } from '@nestjs/common'; +import { DynamicModule, Module, PlainLiteralObject } from '@nestjs/common'; import { - createCrudExports, - createCrudImports, - createCrudProviders, CrudAsyncOptions, CrudModuleClass, CrudOptions, -} from './crud.module-definition'; -import { - CrudForFeatureCrudsOptionInterface, - CrudModuleForFeatureOptionsInterface, -} from './interfaces/crud-module-for-feature-options.interface'; -import { ConfigurableCrudBuilder } from './util/configurable-crud.builder'; -import { createCrudAdapterProvider } from './util/create-crud-adapter-provider'; -import { createCrudServiceProvider } from './util/create-crud-service-provider'; -import { getDynamicCrudAdapterToken } from './util/inject-dynamic-crud-adapter.decorator'; -import { getDynamicCrudServiceToken } from './util/inject-dynamic-crud-service.decorator'; +} from './crud.module-definition.js'; +import { CrudModuleForFeatureOptionsInterface } from './infrastructure/config/interfaces/crud-module-for-feature-options.interface.js'; +import { ConfigurableCrudBuilder } from './infrastructure/utils/configurable-crud.builder.js'; @Module({}) export class CrudModule extends CrudModuleClass { @@ -36,106 +26,17 @@ export class CrudModule extends CrudModuleClass { return super.registerAsync({ ...options, global: true }); } - static forFeature< - TCruds extends Record, - >(options: CrudModuleForFeatureOptionsInterface): DynamicModule { - const providers: Provider[] = []; - const controllers: Type[] = []; - - // Create adapter, service, and controller for each CRUD configuration - if (options.cruds) { - for (const [entityKey, config] of Object.entries(options.cruds)) { - const { adapter, service, controller } = config; - - // Handle controller configuration - if ('model' in controller) { - // Controller config object - use ConfigurableCrudBuilder for service AND controller - - // Create adapter provider (only if adapter provided) - if (adapter) { - providers.push( - createCrudAdapterProvider({ - entityKey, - adapter, - }), - ); - } - - const adapterToken = getDynamicCrudAdapterToken(entityKey); - const serviceToken = getDynamicCrudServiceToken(entityKey); - - const { - getMany, - getOne, - createMany, - createOne, - updateOne, - replaceOne, - deleteOne, - recoverOne, - } = config; - - // Build service config - use service if provided, otherwise adapter token - const serviceConfig = service - ? { serviceToken, useClass: service } - : { serviceToken, adapterToken }; - - const builder = new ConfigurableCrudBuilder({ - service: serviceConfig, - controller, - getMany, - getOne, - createMany, - createOne, - updateOne, - replaceOne, - deleteOne, - recoverOne, - }); - const { ConfigurableControllerClass, ConfigurableServiceProvider } = - builder.build(); - providers.push(ConfigurableServiceProvider); - controllers.push(ConfigurableControllerClass); - } else { - // Custom controller class - use createCrudServiceProvider - - // Create adapter provider (only if adapter provided) - if (adapter) { - providers.push( - createCrudAdapterProvider({ - entityKey, - adapter, - }), - ); - } - - // Create service provider - if (service) { - providers.push( - createCrudServiceProvider({ - entityKey, - useClass: service, - }), - ); - } else if (adapter) { - providers.push( - createCrudServiceProvider({ - entityKey, - }), - ); - } - - controllers.push(controller); - } - } - } + static forFeature( + options: CrudModuleForFeatureOptionsInterface, + ): DynamicModule { + const builder = new ConfigurableCrudBuilder(options.crud); + const { providers, controllers } = builder.build(); return { module: CrudModule, - imports: createCrudImports(options), - providers: [...providers, ...createCrudProviders({ overrides: options })], - controllers, - exports: [...providers, ...createCrudExports()], + providers, + exports: providers, + controllers: Object.values(controllers), }; } } diff --git a/packages/nestjs-crud/src/crud.types.ts b/packages/nestjs-crud/src/crud.types.ts index fbf69705b..4ad5a9745 100644 --- a/packages/nestjs-crud/src/crud.types.ts +++ b/packages/nestjs-crud/src/crud.types.ts @@ -1,22 +1,26 @@ -import { PlainLiteralObject, Type } from '@nestjs/common'; +import { type z } from 'zod'; -import { CrudOptionsInterface } from './crud/interfaces/crud-options.interface'; -import { ConfigurableCrudOptions } from './util/interfaces/configurable-crud-options.interface'; +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { type CrudOptionsInterface } from './infrastructure/request/interfaces/crud-options.interface.js'; +import { type ConfigurableCrudOptions } from './infrastructure/utils/interfaces/configurable-crud-options.interface.js'; export type CrudValidationOptions = CrudOptionsInterface['validation']; +/** + * A request/response Zod (Standard Schema) schema. + */ +export type CrudSchema = z.ZodType; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export type DecoratorTargetObject = Type | T; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ReflectionTargetOrHandler = CallableFunction | Type; - -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -export type AdditionalCrudMethodArgs = any[]; +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export type ControllerTarget = Function; -export type CrudEntityColumn = keyof Entity & - string; +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export type MethodHandler = Function; export type ConfigurableCrudOptionsTransformer< Entity extends PlainLiteralObject, diff --git a/packages/nestjs-crud/src/crud/adapters/crud.adapter.spec.ts b/packages/nestjs-crud/src/crud/adapters/crud.adapter.spec.ts deleted file mode 100644 index 58d3950ab..000000000 --- a/packages/nestjs-crud/src/crud/adapters/crud.adapter.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Repository } from 'typeorm'; - -import { BadRequestException } from '@nestjs/common'; - -import { TestCrudAdapter } from '../../__fixtures__/crud/adapters/test-crud.adapter'; - -describe('#crud', () => { - describe('#CrudAdapter', () => { - let service: TestCrudAdapter>; - - beforeAll(() => { - service = new TestCrudAdapter(); - }); - - describe('#throwBadRequestException', () => { - it('should throw BadRequestException', () => { - expect(service.throwBadRequestException.bind(service, '')).toThrow( - BadRequestException, - ); - }); - }); - - describe('#createPageInfo', () => { - it('should return an object', () => { - const expected = { - count: 0, - data: [], - page: 2, - pageCount: 10, - total: 100, - }; - expect(service.createPageInfo([], 100, 10, 10)).toMatchObject(expected); - }); - - it('should return an object when limit and offset undefined', () => { - const expected = { - count: 0, - data: [], - page: 1, - pageCount: 1, - total: 100, - }; - expect( - service.createPageInfo([], 100, undefined, undefined), - ).toMatchObject(expected); - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/crud/adapters/crud.adapter.ts b/packages/nestjs-crud/src/crud/adapters/crud.adapter.ts deleted file mode 100644 index fd8dd42a3..000000000 --- a/packages/nestjs-crud/src/crud/adapters/crud.adapter.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { plainToInstance } from 'class-transformer'; - -import { BadRequestException, PlainLiteralObject, Type } from '@nestjs/common'; -import { isObject } from '@nestjs/common/utils/shared.utils'; - -import { CrudEntityColumn } from '../../crud.types'; -import { CrudRequestParsedParamsInterface } from '../../request/interfaces/crud-request-parsed-params.interface'; -import { QueryFilter } from '../../request/types/crud-request-query.types'; -import { CrudCreateManyInterface } from '../interfaces/crud-create-many.interface'; -import { CrudParamsOptionsInterface } from '../interfaces/crud-params-options.interface'; -import { CrudQueryOptionsInterface } from '../interfaces/crud-query-options.interface'; -import { CrudRequestOptionsInterface } from '../interfaces/crud-request-options.interface'; -import { CrudRequestInterface } from '../interfaces/crud-request.interface'; -import { CrudResponsePaginatedInterface } from '../interfaces/crud-response-paginated.interface'; -import { queryFilterIsArray } from '../util'; - -export abstract class CrudAdapter { - throwBadRequestException(msg?: unknown): BadRequestException { - throw new BadRequestException(msg); - } - - /** - * Wrap page into page-info - * override this method to create custom page-info response - * or set custom `serialize.getMany` dto in the controller's CrudOption - * - * @param data - array of data to be paginated - * @param total - total number of items in the collection - * @param limit - number of items per page - * @param offset - number of items to skip - */ - createPageInfo( - data: Entity[], - total: number | undefined, - limit: number | undefined, - offset: number | undefined, - ): CrudResponsePaginatedInterface { - return { - data, - limit: limit ?? 1, - count: data.length, - total: total ?? 0, - page: limit ? Math.floor((offset ?? 0) / limit) + 1 : 1, - pageCount: limit && total ? Math.ceil(total / limit) : 1, - }; - } - - /** - * Get number of resources to be fetched - * - * @param query - parsed request params - * @param options - query options - */ - getTake( - query: CrudRequestParsedParamsInterface, - options: CrudQueryOptionsInterface, - ): number | null { - if (query.limit) { - return options.maxLimit - ? query.limit <= options.maxLimit - ? query.limit - : options.maxLimit - : query.limit; - } - - if (options.limit) { - return options.maxLimit - ? options.limit <= options.maxLimit - ? options.limit - : options.maxLimit - : options.limit; - } - - return options.maxLimit ? options.maxLimit : null; - } - - /** - * Get number of resources to be skipped - * - * @param query - parsed request params - * @param take - number of resources to be fetched - */ - getSkip( - query: CrudRequestParsedParamsInterface, - take: number | null, - ): number | null { - return query.page && take - ? take * (query.page - 1) - : query.offset - ? query.offset - : null; - } - - /** - * Get primary param name from CrudOptions - * - * @param options - crud request options - */ - getPrimaryParams( - options: CrudRequestOptionsInterface, - ): CrudEntityColumn[] { - const rawParams: CrudParamsOptionsInterface = options.params ?? {}; - - const params = Object.keys(rawParams).filter( - (n) => rawParams[n] && rawParams[n].primary, - ); - - return params - .map((p) => rawParams[p].field) - .filter((field): field is string => typeof field === 'string'); - } - - /** - * Get parameter filters from parsed request. - * - * @param parsed - The parsed request parameters. - * @returns An object containing parameter filters. - */ - public getParamFilters(parsed: CrudRequestParsedParamsInterface) { - const filters: Partial, unknown>> = {}; - - /* istanbul ignore else */ - if (parsed.paramsFilter.length) { - for (const filter of parsed.paramsFilter) { - filters[filter.field] = filter.value; - } - } - - return filters; - } - - getAllowedColumns( - columns: CrudEntityColumn[], - options: CrudQueryOptionsInterface, - ): CrudEntityColumn[] { - return (!options.exclude || !options.exclude.length) && - (!options.allow || !options.allow.length) - ? columns - : columns.filter( - (column) => - (options.exclude && options.exclude.length - ? !options.exclude.some((col) => col === column) - : true) && - (options.allow && options.allow.length - ? options.allow.some((col) => col === column) - : true), - ); - } - - checkFilterIsArray(cond: QueryFilter): boolean { - if (queryFilterIsArray(cond)) { - return true; - } - - throw new BadRequestException(`Invalid column '${cond.field}' value`); - } - - prepareEntityBeforeSave( - dto: Partial, - parsed: CrudRequestParsedParamsInterface, - ): Entity | undefined { - if (!isObject(dto)) { - return undefined; - } - - if (parsed.paramsFilter.length) { - for (const filter of parsed.paramsFilter) { - if (filter.field in dto) { - (dto as Record)[filter.field] = filter.value; - } - } - } - - if (!Object.keys(dto).length) { - return undefined; - } - - return dto instanceof this.entityType() - ? Object.assign(dto) - : plainToInstance( - this.entityType(), - { ...dto }, - parsed.classTransformOptions, - ); - } - - abstract entityType(): Type; - abstract entityName(): string; - - abstract getMany( - req: CrudRequestInterface, - ): Promise>; - - abstract getOne(req: CrudRequestInterface): Promise; - - abstract createOne( - req: CrudRequestInterface, - dto: Entity | Partial, - ): Promise; - - abstract createMany( - req: CrudRequestInterface, - dto: CrudCreateManyInterface, - ): Promise; - - abstract updateOne( - req: CrudRequestInterface, - dto: Entity | Partial, - ): Promise; - - abstract replaceOne( - req: CrudRequestInterface, - dto: Entity | Partial, - ): Promise; - - abstract deleteOne(req: CrudRequestInterface): Promise; - - abstract recoverOne( - req: CrudRequestInterface, - ): Promise; -} diff --git a/packages/nestjs-crud/src/crud/adapters/typeorm-crud.adapter.ts b/packages/nestjs-crud/src/crud/adapters/typeorm-crud.adapter.ts deleted file mode 100644 index bf28103e3..000000000 --- a/packages/nestjs-crud/src/crud/adapters/typeorm-crud.adapter.ts +++ /dev/null @@ -1,898 +0,0 @@ -import { oO } from '@zmotivat0r/o0'; -import { plainToInstance } from 'class-transformer'; -import { - Brackets, - SelectQueryBuilder, - DataSourceOptions, - OrderByCondition, - WhereExpressionBuilder, -} from 'typeorm'; - -import { - BadRequestException, - NotFoundException, - PlainLiteralObject, - Type, -} from '@nestjs/common'; -import { - isNil, - isObject, - isUndefined, -} from '@nestjs/common/utils/shared.utils'; - -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { CrudEntityColumn } from '../../crud.types'; -import { comparisonOperatorKeys } from '../../request/crud-request.utils'; -import { CrudRequestParsedParamsInterface } from '../../request/interfaces/crud-request-parsed-params.interface'; -import { - ComparisonOperator, - QueryFilter, - QuerySort, - SCondition, - SConditionKey, -} from '../../request/types/crud-request-query.types'; -import { CrudCreateManyInterface } from '../interfaces/crud-create-many.interface'; -import { CrudQueryOptionsInterface } from '../interfaces/crud-query-options.interface'; -import { CrudRequestOptionsInterface } from '../interfaces/crud-request-options.interface'; -import { CrudRequestInterface } from '../interfaces/crud-request.interface'; -import { CrudResponsePaginatedInterface } from '../interfaces/crud-response-paginated.interface'; - -import { CrudAdapter } from './crud.adapter'; - -export class TypeOrmCrudAdapter< - Entity extends PlainLiteralObject, -> extends CrudAdapter { - protected dbName: DataSourceOptions['type']; - - protected entityColumns: CrudEntityColumn[] = []; - - protected entityPrimaryColumns: CrudEntityColumn[] = []; - - protected entityHasDeleteColumn = false; - - protected entityColumnsHash: Record = {}; - - protected sqlInjectionRegEx: RegExp[] = [ - /(%27)|(')|(--)|(%23)|(#)/gi, - /((%3D)|(=))[^\n]*((%27)|(')|(--)|(%3B)|(;))/gi, - /w*((%27)|')((%6F)|o|(%4F))((%72)|r|(%52))/gi, - /((%27)|')union/gi, - ]; - - constructor(protected repoAdapter: TypeOrmRepositoryAdapter) { - super(); - - this.dbName = this.repoAdapter.repo.metadata.connection.options.type; - this.onInitMapEntityColumns(); - } - - public entityName(): string { - return this.repoAdapter.repo.metadata.name; - } - - public entityType(): Type { - return this.repoAdapter.repo.target as Type; - } - - protected get alias(): string { - return this.repoAdapter.repo.metadata.targetName; - } - - /** - * Get many - * - * @param req - The CRUD request interface. - */ - public async getMany( - req: CrudRequestInterface, - ): Promise> { - const { parsed, options } = req; - const builder = await this.createBuilder(parsed, options); - return this.doGetMany(builder); - } - - /** - * Get one - * - * @param req - The CRUD request interface. - */ - public async getOne(req: CrudRequestInterface): Promise { - return this.getOneOrFail(req); - } - - /** - * Create one - * - * @param req - The CRUD request interface. - * @param dto - The DTO containing the entity data to create. - */ - public async createOne( - req: CrudRequestInterface, - dto: Entity | Partial, - ): Promise { - const { returnShallow } = req.options.routes?.createOne ?? {}; - const entity = this.prepareEntityBeforeSave(dto, req.parsed); - - if (!entity) { - throw new BadRequestException(); - } - - // Use RepositoryInterface - const saved = await this.repoAdapter.save(entity); - - if (returnShallow) { - return saved; - } else { - const primaryParams = this.getPrimaryParams(req.options); - - if (!primaryParams.length && primaryParams.some((p) => isNil(saved[p]))) { - return saved; - } else { - req.parsed.search = primaryParams.reduce( - (acc, p) => ({ ...acc, [p]: saved[p] }), - {}, - ); - return this.getOneOrFail(req); - } - } - } - - /** - * Create many entities. - * - * @param req - The CRUD request interface. - * @param dto - The DTO containing the bulk array of entities to create. - * @returns A promise resolving to an array of created entities. - */ - public async createMany( - req: CrudRequestInterface, - dto: CrudCreateManyInterface>, - ): Promise { - if (!isObject(dto) || !Array.isArray(dto.bulk) || !dto.bulk.length) { - this.throwBadRequestException('Empty data. Nothing to save.'); - } - - const preparedBulk = dto.bulk.map((one) => - this.prepareEntityBeforeSave(one, req.parsed), - ); - - const bulk: Entity[] = preparedBulk.filter( - (d): d is Entity => !isUndefined(d), - ); - - if (!bulk.length) { - this.throwBadRequestException('Empty data. Nothing to save.'); - } - - return this.repoAdapter.save(bulk, { chunk: 50 }); - } - - /** - * Update one entity. - * - * @param req - The CRUD request interface. - * @param dto - The DTO containing the updated entity data. - * @returns A promise resolving to the updated entity. - */ - public async updateOne( - req: CrudRequestInterface, - dto: Entity | Partial, - ): Promise { - const { returnShallow } = req.options?.routes?.updateOne ?? {}; - const paramsFilters = this.getParamFilters(req.parsed); - const found = await this.getOneOrFail(req, returnShallow); - const toSave = { ...found, ...dto, ...paramsFilters }; - - const updated = await this.repoAdapter.save( - plainToInstance( - this.entityType(), - toSave, - req.parsed.classTransformOptions, - ), - ); - - if (returnShallow) { - return updated; - } else { - req.parsed.paramsFilter.forEach((filter) => { - filter.value = updated[filter.field]; - }); - - return this.getOneOrFail(req); - } - } - - /** - * Recover one soft-deleted entity. - * - * @param req - The CRUD request interface. - * @returns A promise resolving to the recovered entity. - */ - public async recoverOne(req: CrudRequestInterface): Promise { - const found = await this.getOneOrFail(req, false, true); - return this.repoAdapter.recover(found); - } - - /** - * Replace one entity. - * - * @param req - The CRUD request interface. - * @param dto - The DTO containing the replacement entity data. - * @returns A promise resolving to the replaced entity. - */ - public async replaceOne( - req: CrudRequestInterface, - dto: Entity | Partial, - ): Promise { - const { returnShallow } = req.options?.routes?.replaceOne ?? {}; - const paramsFilters = this.getParamFilters(req.parsed); - const [_, found] = await oO(this.getOneOrFail(req, returnShallow)); - const toSave = { - ...(found || {}), - ...dto, - ...paramsFilters, - }; - - const replaced = await this.repoAdapter.save( - plainToInstance( - this.entityType(), - toSave, - req.parsed.classTransformOptions, - ), - ); - - if (returnShallow) { - return replaced; - } else { - const primaryParams = this.getPrimaryParams(req.options); - - /* istanbul ignore if */ - if (!primaryParams.length) { - return replaced; - } - - req.parsed.search = primaryParams.reduce( - (acc, p) => ({ ...acc, [p]: replaced[p] }), - {}, - ); - return this.getOneOrFail(req); - } - } - - /** - * Delete one entity. - * - * @param req - The CRUD request interface. - * @returns A promise resolving to the deleted entity or void. - */ - public async deleteOne( - req: CrudRequestInterface, - ): Promise { - const { returnDeleted } = req.options?.routes?.deleteOne ?? {}; - const found = await this.getOneOrFail(req, returnDeleted); - const toReturn = returnDeleted - ? plainToInstance( - this.entityType(), - { ...found }, - req.parsed.classTransformOptions, - ) - : undefined; - - if (req.options?.query?.softDelete === true) { - await this.repoAdapter.softRemove(found); - } else { - await this.repoAdapter.remove(found); - } - - return toReturn; - } - - /** - * Create a TypeORM QueryBuilder for the entity. - * - * @param parsed - The parsed request parameters. - * @param options - CRUD request options. - * @param many - Whether to query for many entities (default: true). - * @param withDeleted - Whether to include soft-deleted entities (default: false). - * @returns A promise resolving to a SelectQueryBuilder instance. - */ - public async createBuilder( - parsed: CrudRequestParsedParamsInterface, - options: CrudRequestOptionsInterface, - many = true, - withDeleted = false, - ): Promise> { - // create query builder - const builder = this.repoAdapter.repo.createQueryBuilder(this.alias); - // get select fields - const select = this.getSelect(parsed, options.query ?? {}); - // select fields - builder.select(select); - - // search - this.setSearchCondition(builder, parsed.search ?? {}); - - // if soft deleted is enabled add where statement to filter deleted records - if (this.entityHasDeleteColumn && options?.query?.softDelete) { - if (parsed.includeDeleted === 1 || withDeleted) { - builder.withDeleted(); - } - } - - /* istanbul ignore else */ - if (many) { - // set sort (order by) - const sort = this.getSort(parsed, options.query ?? {}); - builder.orderBy(sort); - - // set take - const take = this.getTake(parsed, options.query ?? {}); - /* istanbul ignore else */ - if (take && isFinite(take)) { - builder.take(take); - } - - // set skip - const skip = this.getSkip(parsed, take); - /* istanbul ignore else */ - if (skip && isFinite(skip)) { - builder.skip(skip); - } - } - - // set cache - /* istanbul ignore else */ - if (options?.query?.cache && parsed.cache !== 0) { - builder.cache(builder.getQueryAndParameters(), options.query.cache); - } - - return builder; - } - - /** - * depends on paging call `SelectQueryBuilder#getMany` or `SelectQueryBuilder#getManyAndCount` - * helpful for overriding `CrudAdapter#getMany` - * - * @see getMany - * @see SelectQueryBuilder#getMany - * @see SelectQueryBuilder#getManyAndCount - * @param builder - Select Query Builder for the entity - */ - protected async doGetMany( - builder: SelectQueryBuilder, - ): Promise> { - const [data, total] = await builder.getManyAndCount(); - const limit = builder.expressionMap.take; - const offset = builder.expressionMap.skip; - - return this.createPageInfo(data, total, limit || total, offset || 0); - } - - protected onInitMapEntityColumns() { - this.entityColumns = this.repoAdapter.repo.metadata.columns.map((prop) => { - // In case column is an embedded, use the propertyPath to get complete path - if (prop.embeddedMetadata) { - this.entityColumnsHash[prop.propertyPath] = prop.databasePath; - return prop.propertyPath; - } - this.entityColumnsHash[prop.propertyName] = prop.databasePath; - return prop.propertyName; - }); - this.entityPrimaryColumns = this.repoAdapter.repo.metadata.columns - .filter((prop) => prop.isPrimary) - .map((prop) => prop.propertyName); - this.entityHasDeleteColumn = - this.repoAdapter.repo.metadata.columns.filter((prop) => prop.isDeleteDate) - .length > 0; - } - - protected async getOneOrFail( - req: CrudRequestInterface, - shallow = false, - withDeleted = false, - ): Promise { - const { parsed, options } = req; - const builder = shallow - ? this.repoAdapter.repo.createQueryBuilder(this.alias) - : await this.createBuilder(parsed, options, true, withDeleted); - - if (shallow) { - this.setSearchCondition(builder, parsed.search ?? {}); - } - - const found = withDeleted - ? await builder.withDeleted().getOne() - : await builder.getOne(); - - if (found) { - return found; - } else { - throw new NotFoundException(`${this.alias} not found`); - } - } - - protected setAndWhere( - cond: QueryFilter, - i: unknown, - builder: WhereExpressionBuilder, - ) { - const { str, params } = this.mapOperatorsToQuery(cond, `andWhere${i}`); - builder.andWhere(str, params); - } - - protected setOrWhere( - cond: QueryFilter, - i: unknown, - builder: WhereExpressionBuilder, - ) { - const { str, params } = this.mapOperatorsToQuery(cond, `orWhere${i}`); - builder.orWhere(str, params); - } - - protected setSearchCondition( - builder: WhereExpressionBuilder, - search: SCondition, - condition: SConditionKey = '$and', - ) { - /* istanbul ignore else */ - if (isObject(search)) { - const keys = Object.keys(search); - /* istanbul ignore else */ - if (keys.length) { - // search: {$and: [...], ...} - if (search?.$and && Array.isArray(search.$and) && search.$and.length) { - // search: {$and: [{}]} - if (search.$and.length === 1) { - this.setSearchCondition(builder, search.$and[0], condition); - } - // search: {$and: [{}, {}, ...]} - else { - this.builderAddBrackets( - builder, - condition, - new Brackets((qb) => { - search.$and?.forEach((item: SCondition) => { - this.setSearchCondition(qb, item, '$and'); - }); - }), - ); - } - } - // search: {$or: [...], ...} - else if (Array.isArray(search.$or) && search.$or.length) { - // search: {$or: [...]} - if (keys.length === 1) { - // search: {$or: [{}]} - if (search.$or?.length === 1) { - this.setSearchCondition(builder, search.$or[0], condition); - } - // search: {$or: [{}, {}, ...]} - else { - this.builderAddBrackets( - builder, - condition, - new Brackets((qb) => { - search.$or?.forEach((item: SCondition) => { - this.setSearchCondition(qb, item, '$or'); - }); - }), - ); - } - } - // search: {$or: [...], foo, ...} - else { - this.builderAddBrackets( - builder, - condition, - new Brackets((qb) => { - keys.forEach((field: string) => { - if (field !== '$or') { - const value = search[field]; - if (!isObject(value)) { - this.builderSetWhere(qb, '$and', field, value); - } else { - this.setSearchFieldObjectCondition( - qb, - '$and', - field, - value, - ); - } - } else { - if (search.$or?.length === 1) { - this.setSearchCondition(builder, search.$or[0], '$and'); - } else { - this.builderAddBrackets( - qb, - '$and', - new Brackets((qb2) => { - search.$or?.forEach((item: SCondition) => { - this.setSearchCondition(qb2, item, '$or'); - }); - }), - ); - } - } - }); - }), - ); - } - } - // search: {...} - else { - // search: {foo} - if (keys.length === 1) { - const field = keys[0]; - const value = search[field]; - if (!isObject(value)) { - this.builderSetWhere(builder, condition, field, value); - } else { - this.setSearchFieldObjectCondition( - builder, - condition, - field, - value, - ); - } - } - // search: {foo, ...} - else { - this.builderAddBrackets( - builder, - condition, - new Brackets((qb) => { - keys.forEach((field: string) => { - const value = search[field]; - if (!isObject(value)) { - this.builderSetWhere(qb, '$and', field, value); - } else { - this.setSearchFieldObjectCondition( - qb, - '$and', - field, - value, - ); - } - }); - }), - ); - } - } - } - } - } - - protected builderAddBrackets( - builder: WhereExpressionBuilder, - condition: SConditionKey, - brackets: Brackets, - ) { - if (condition === '$and') { - builder.andWhere(brackets); - } else { - builder.orWhere(brackets); - } - } - - protected builderSetWhere( - builder: WhereExpressionBuilder, - condition: SConditionKey, - field: string, - value: unknown, - operator: ComparisonOperator = '$eq', - ) { - const time = process.hrtime(); - const index = `${field}${time[0]}${time[1]}`; - const condFilter = { - field, - operator: value === null ? '$isnull' : operator, - value, - }; - - if (condition === '$and') { - this.setAndWhere(condFilter, index, builder); - } else { - this.setOrWhere(condFilter, index, builder); - } - } - - protected setSearchFieldObjectCondition( - builder: WhereExpressionBuilder, - condition: SConditionKey, - field: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - object: { [k: string]: any; $or?: any; $and?: any }, - ) { - /* istanbul ignore else */ - if (isObject(object)) { - const operators = comparisonOperatorKeys(object); - - if (operators.length === 1) { - const operator: ComparisonOperator = operators[0]; - const value = object[operator]; - - if (isObject(object.$or)) { - const orKeys = Object.keys(object.$or); - this.setSearchFieldObjectCondition( - builder, - orKeys.length === 1 ? condition : '$or', - field, - object.$or, - ); - } else { - this.builderSetWhere(builder, condition, field, value, operator); - } - } else { - /* istanbul ignore else */ - if (operators.length > 1) { - this.builderAddBrackets( - builder, - condition, - new Brackets((qb) => { - operators.forEach((operator: ComparisonOperator) => { - const value = object[operator]; - - if (operator !== '$or') { - this.builderSetWhere(qb, condition, field, value, operator); - } else { - const orKeys = Object.keys(object.$or); - - if (orKeys.length === 1) { - this.setSearchFieldObjectCondition( - qb, - condition, - field, - object.$or, - ); - } else { - this.builderAddBrackets( - qb, - condition, - new Brackets((qb2) => { - this.setSearchFieldObjectCondition( - qb2, - '$or', - field, - object.$or, - ); - }), - ); - } - } - }); - }), - ); - } - } - } - } - - protected getSelect( - query: CrudRequestParsedParamsInterface, - options: CrudQueryOptionsInterface, - ): CrudEntityColumn[] { - const allowed = this.getAllowedColumns(this.entityColumns, options); - - const columns = - query.fields && query.fields.length - ? query.fields.filter((field) => allowed.some((col) => field === col)) - : allowed; - - const selectArray: CrudEntityColumn[] = [ - ...(options.persist && options.persist.length ? options.persist : []), - ...columns, - ...this.entityPrimaryColumns, - ]; - - const selectMapped = selectArray.map( - (col) => `${this.alias}.${String(col)}`, - ); - - const select = new Set(selectMapped); - - return Array.from(select); - } - - protected getSort( - query: CrudRequestParsedParamsInterface, - options: CrudQueryOptionsInterface, - ): OrderByCondition { - return query.sort && query.sort.length - ? this.mapSort(query.sort) - : options.sort && options.sort.length - ? this.mapSort(options.sort) - : {}; - } - - protected getFieldWithAlias(field: string, sort = false) { - /* istanbul ignore next */ - const i = ['mysql', 'mariadb'].includes(this.dbName) ? '`' : '"'; - const cols = field.split('.'); - - switch (cols.length) { - case 1: { - if (sort) { - return `${this.alias}.${field}`; - } - - const dbColName = - this.entityColumnsHash[field] !== field - ? this.entityColumnsHash[field] - : field; - - return `${i}${this.alias}${i}.${i}${dbColName}${i}`; - } - case 2: - return field; - default: - return cols.slice(cols.length - 2, cols.length).join('.'); - } - } - - protected mapSort(sort: QuerySort[]): OrderByCondition { - const params: OrderByCondition = {}; - - for (let i = 0; i < sort.length; i++) { - const field = this.getFieldWithAlias(sort[i].field, true); - const checkedFiled = this.checkSqlInjection(field); - params[checkedFiled] = sort[i].order; - } - - return params; - } - - protected mapOperatorsToQuery( - cond: QueryFilter, - param: string, - ): { str: string; params: PlainLiteralObject } { - const field = this.getFieldWithAlias(cond.field); - - const likeOperator = this.dbName === 'postgres' ? 'ILIKE' : 'LIKE'; - - let str: string; - let params: PlainLiteralObject | undefined = undefined; - - switch (cond.operator) { - case '$eq': - str = `${field} = :${param}`; - break; - - case '$ne': - str = `${field} != :${param}`; - break; - - case '$gt': - str = `${field} > :${param}`; - break; - - case '$lt': - str = `${field} < :${param}`; - break; - - case '$gte': - str = `${field} >= :${param}`; - break; - - case '$lte': - str = `${field} <= :${param}`; - break; - - case '$starts': - str = `${field} LIKE :${param}`; - params = { [param]: `${cond.value}%` }; - break; - - case '$ends': - str = `${field} LIKE :${param}`; - params = { [param]: `%${cond.value}` }; - break; - - case '$cont': - str = `${field} LIKE :${param}`; - params = { [param]: `%${cond.value}%` }; - break; - - case '$excl': - str = `${field} NOT LIKE :${param}`; - params = { [param]: `%${cond.value}%` }; - break; - - case '$in': - this.checkFilterIsArray(cond); - str = `${field} IN (:...${param})`; - break; - - case '$notin': - this.checkFilterIsArray(cond); - str = `${field} NOT IN (:...${param})`; - break; - - case '$isnull': - str = `${field} IS NULL`; - params = {}; - break; - - case '$notnull': - str = `${field} IS NOT NULL`; - params = {}; - break; - - case '$between': - if (!Array.isArray(cond.value) || cond.value.length !== 2) { - throw new BadRequestException( - `Invalid column '${cond.field}' value for BETWEEN operator, must be an array with two elements`, - ); - } - - str = `${field} BETWEEN :${param}0 AND :${param}1`; - params = { - [`${param}0`]: cond.value[0], - [`${param}1`]: cond.value[1], - }; - break; - - // case insensitive - case '$eqL': - str = `LOWER(${field}) = :${param}`; - break; - - case '$neL': - str = `LOWER(${field}) != :${param}`; - break; - - case '$startsL': - str = `LOWER(${field}) ${likeOperator} :${param}`; - params = { [param]: `${cond.value}%` }; - break; - - case '$endsL': - str = `LOWER(${field}) ${likeOperator} :${param}`; - params = { [param]: `%${cond.value}` }; - break; - - case '$contL': - str = `LOWER(${field}) ${likeOperator} :${param}`; - params = { [param]: `%${cond.value}%` }; - break; - - case '$exclL': - str = `LOWER(${field}) NOT ${likeOperator} :${param}`; - params = { [param]: `%${cond.value}%` }; - break; - - case '$inL': - this.checkFilterIsArray(cond); - str = `LOWER(${field}) IN (:...${param})`; - break; - - case '$notinL': - this.checkFilterIsArray(cond); - str = `LOWER(${field}) NOT IN (:...${param})`; - break; - - /* istanbul ignore next */ - default: - str = `${field} = :${param}`; - break; - } - - if (typeof params === 'undefined') { - params = { [param]: cond.value }; - } - - return { str, params }; - } - - private checkSqlInjection(field: string): string { - if (this.sqlInjectionRegEx.length) { - for (let i = 0; i < this.sqlInjectionRegEx.length; i++) { - if (this.sqlInjectionRegEx[0].test(field)) { - this.throwBadRequestException(`SQL injection detected: "${field}"`); - } - } - } - - return field; - } -} diff --git a/packages/nestjs-crud/src/crud/controllers/crud-base.controller.ts b/packages/nestjs-crud/src/crud/controllers/crud-base.controller.ts deleted file mode 100644 index 3a23cf192..000000000 --- a/packages/nestjs-crud/src/crud/controllers/crud-base.controller.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { DeepPartial } from '@concepta/nestjs-common'; - -import { AdditionalCrudMethodArgs } from '../../crud.types'; -import { CrudMethodNotImplementedException } from '../../exceptions/crud-method-not-implemented.exception'; -import { CrudService } from '../../services/crud.service'; -import { CrudControllerInterface } from '../interfaces/crud-controller.interface'; -import { CrudCreateManyInterface } from '../interfaces/crud-create-many.interface'; -import { CrudRequestInterface } from '../interfaces/crud-request.interface'; -import { CrudResponsePaginatedInterface } from '../interfaces/crud-response-paginated.interface'; - -export class CrudBaseController< - Entity extends PlainLiteralObject, - Creatable extends DeepPartial, - Updatable extends DeepPartial, - Replaceable extends Creatable = Creatable, -> implements CrudControllerInterface -{ - constructor(protected crudService: CrudService) {} - - getMany( - _crudRequest: CrudRequestInterface, - ..._rest: AdditionalCrudMethodArgs - ): Promise> { - throw new CrudMethodNotImplementedException(this, this.getMany); - } - - getOne( - _crudRequest: CrudRequestInterface, - ..._rest: AdditionalCrudMethodArgs - ): Promise { - throw new CrudMethodNotImplementedException(this, this.getOne); - } - - createOne( - _crudRequest: CrudRequestInterface, - _dto: Creatable, - ..._rest: AdditionalCrudMethodArgs - ): Promise { - throw new CrudMethodNotImplementedException(this, this.createOne); - } - - createMany( - _crudRequest: CrudRequestInterface, - _dto: CrudCreateManyInterface, - ..._rest: AdditionalCrudMethodArgs - ): Promise { - throw new CrudMethodNotImplementedException(this, this.createMany); - } - - updateOne( - _crudRequest: CrudRequestInterface, - _dto: Updatable, - ..._rest: AdditionalCrudMethodArgs - ): Promise { - throw new CrudMethodNotImplementedException(this, this.updateOne); - } - - replaceOne( - _crudRequest: CrudRequestInterface, - _dto: Replaceable, - ..._rest: AdditionalCrudMethodArgs - ): Promise { - throw new CrudMethodNotImplementedException(this, this.replaceOne); - } - - deleteOne( - _crudRequest: CrudRequestInterface, - ..._rest: AdditionalCrudMethodArgs - ): Promise { - throw new CrudMethodNotImplementedException(this, this.deleteOne); - } - - recoverOne( - _crudRequest: CrudRequestInterface, - ..._rest: AdditionalCrudMethodArgs - ): Promise { - throw new CrudMethodNotImplementedException(this, this.recoverOne); - } -} diff --git a/packages/nestjs-crud/src/crud/decorators/actions/crud-create-many.decorator.ts b/packages/nestjs-crud/src/crud/decorators/actions/crud-create-many.decorator.ts deleted file mode 100644 index fa49c4564..000000000 --- a/packages/nestjs-crud/src/crud/decorators/actions/crud-create-many.decorator.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { applyDecorators, PlainLiteralObject, Post } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_CREATE_MANY_DEFAULT_PATH } from '../../../crud.constants'; -import { CrudValidationOptions } from '../../../crud.types'; -import { CrudActions } from '../../enums/crud-actions.enum'; -import { CrudCreateManyOptionsInterface } from '../../interfaces/crud-route-options.interface'; -import { CrudApiBody } from '../openapi/crud-api-body.decorator'; -import { CrudApiOperation } from '../openapi/crud-api-operation.decorator'; -import { CrudApiResponse } from '../openapi/crud-api-response.decorator'; -import { CrudAction } from '../routes/crud-action.decorator'; -import { CrudSerialize } from '../routes/crud-serialize.decorator'; -import { CrudValidate } from '../routes/crud-validate.decorator'; - -/** - * CRUD Create Many route decorator - */ -export const CrudCreateMany = < - T extends PlainLiteralObject = PlainLiteralObject, ->( - options: CrudCreateManyOptionsInterface = {}, -) => { - const { - path = CRUD_MODULE_ROUTE_CREATE_MANY_DEFAULT_PATH, - dto, - validation, - serialization, - api, - } = { ...options }; - - const validationMerged: CrudValidationOptions = dto - ? { expectedType: dto, ...validation } - : validation; - - return applyDecorators( - Post(path), - CrudAction(CrudActions.CreateMany), - CrudValidate(validationMerged), - CrudSerialize(serialization), - CrudApiOperation(api?.operation), - CrudApiBody({ - type: options?.dto, - ...options?.api?.body, - }), - CrudApiResponse(CrudActions.CreateMany, api?.response), - ); -}; diff --git a/packages/nestjs-crud/src/crud/decorators/actions/crud-create-one.decorator.ts b/packages/nestjs-crud/src/crud/decorators/actions/crud-create-one.decorator.ts deleted file mode 100644 index 57dc57db5..000000000 --- a/packages/nestjs-crud/src/crud/decorators/actions/crud-create-one.decorator.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - applyDecorators, - PlainLiteralObject, - Post, - SetMetadata, -} from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_CREATE_ONE_METADATA } from '../../../crud.constants'; -import { CrudActions } from '../../enums/crud-actions.enum'; -import { CrudCreateOneOptionsInterface } from '../../interfaces/crud-route-options.interface'; -import { CrudApiBody } from '../openapi/crud-api-body.decorator'; -import { CrudApiOperation } from '../openapi/crud-api-operation.decorator'; -import { CrudApiResponse } from '../openapi/crud-api-response.decorator'; -import { CrudAction } from '../routes/crud-action.decorator'; -import { CrudSerialize } from '../routes/crud-serialize.decorator'; -import { CrudValidate } from '../routes/crud-validate.decorator'; - -/** - * CRUD Create One route decorator - */ -export const CrudCreateOne = < - T extends PlainLiteralObject = PlainLiteralObject, ->( - options: CrudCreateOneOptionsInterface = {}, -) => { - const { path, validation, serialization, api, ...rest } = { ...options }; - - return applyDecorators( - Post(path), - CrudAction(CrudActions.CreateOne), - SetMetadata(CRUD_MODULE_ROUTE_CREATE_ONE_METADATA, rest), - CrudValidate(validation), - CrudSerialize(serialization), - CrudApiOperation(api?.operation), - CrudApiBody({ - type: options?.dto, - ...options?.api?.body, - }), - CrudApiResponse(CrudActions.CreateOne, api?.response), - ); -}; diff --git a/packages/nestjs-crud/src/crud/decorators/actions/crud-delete-one.decorator.ts b/packages/nestjs-crud/src/crud/decorators/actions/crud-delete-one.decorator.ts deleted file mode 100644 index 9356923bd..000000000 --- a/packages/nestjs-crud/src/crud/decorators/actions/crud-delete-one.decorator.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - applyDecorators, - Delete, - PlainLiteralObject, - SetMetadata, -} from '@nestjs/common'; - -import { - CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, - CRUD_MODULE_ROUTE_DELETE_ONE_METADATA, -} from '../../../crud.constants'; -import { CrudActions } from '../../enums/crud-actions.enum'; -import { CrudDeleteOneOptionsInterface } from '../../interfaces/crud-route-options.interface'; -import { CrudApiOperation } from '../openapi/crud-api-operation.decorator'; -import { CrudApiParam } from '../openapi/crud-api-param.decorator'; -import { CrudApiResponse } from '../openapi/crud-api-response.decorator'; -import { CrudAction } from '../routes/crud-action.decorator'; -import { CrudSerialize } from '../routes/crud-serialize.decorator'; -import { CrudValidate } from '../routes/crud-validate.decorator'; - -/** - * CRUD Delete One route decorator - */ -export const CrudDeleteOne = < - T extends PlainLiteralObject = PlainLiteralObject, ->( - options: CrudDeleteOneOptionsInterface = {}, -) => { - const { - path = CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, - validation, - serialization, - api, - ...rest - } = { ...options }; - - return applyDecorators( - Delete(path), - CrudAction(CrudActions.DeleteOne), - SetMetadata(CRUD_MODULE_ROUTE_DELETE_ONE_METADATA, rest), - CrudValidate(validation), - CrudSerialize(serialization), - CrudApiOperation(api?.operation), - CrudApiParam(api?.params), - CrudApiResponse(CrudActions.DeleteOne, api?.response), - ); -}; diff --git a/packages/nestjs-crud/src/crud/decorators/actions/crud-get-many.decorator.ts b/packages/nestjs-crud/src/crud/decorators/actions/crud-get-many.decorator.ts deleted file mode 100644 index 92f1abf8d..000000000 --- a/packages/nestjs-crud/src/crud/decorators/actions/crud-get-many.decorator.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CrudReadAll } from './crud-read-all.decorator'; - -/** - * CRUD Get Many route decorator (alias for Read All) - */ -export const CrudGetMany = (...args: Parameters) => - CrudReadAll(...args); diff --git a/packages/nestjs-crud/src/crud/decorators/actions/crud-get-one.decorator.ts b/packages/nestjs-crud/src/crud/decorators/actions/crud-get-one.decorator.ts deleted file mode 100644 index 434f032d1..000000000 --- a/packages/nestjs-crud/src/crud/decorators/actions/crud-get-one.decorator.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CrudReadOne } from './crud-read-one.decorator'; - -/** - * CRUD Get One route decorator (alias for Read One) - */ -export const CrudGetOne = (...args: Parameters) => - CrudReadOne(...args); diff --git a/packages/nestjs-crud/src/crud/decorators/actions/crud-read-all.decorator.ts b/packages/nestjs-crud/src/crud/decorators/actions/crud-read-all.decorator.ts deleted file mode 100644 index 48f4af6ae..000000000 --- a/packages/nestjs-crud/src/crud/decorators/actions/crud-read-all.decorator.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { applyDecorators, Get, PlainLiteralObject } from '@nestjs/common'; - -import { CrudActions } from '../../enums/crud-actions.enum'; -import { CrudReadAllOptionsInterface } from '../../interfaces/crud-route-options.interface'; -import { CrudApiOperation } from '../openapi/crud-api-operation.decorator'; -import { CrudApiQuery } from '../openapi/crud-api-query.decorator'; -import { CrudApiResponse } from '../openapi/crud-api-response.decorator'; -import { CrudAction } from '../routes/crud-action.decorator'; -import { CrudSerialize } from '../routes/crud-serialize.decorator'; -import { CrudValidate } from '../routes/crud-validate.decorator'; - -/** - * CRUD Read All route decorator - */ -export const CrudReadAll = ( - options: CrudReadAllOptionsInterface = {}, -) => { - const { path, validation, serialization, api } = options; - - return applyDecorators( - Get(path), - CrudAction(CrudActions.ReadAll), - CrudValidate(validation), - CrudSerialize(serialization), - CrudApiOperation(api?.operation), - CrudApiQuery(api?.query), - CrudApiResponse(CrudActions.ReadAll, api?.response), - ); -}; diff --git a/packages/nestjs-crud/src/crud/decorators/actions/crud-read-many.decorator.ts b/packages/nestjs-crud/src/crud/decorators/actions/crud-read-many.decorator.ts deleted file mode 100644 index 00223b3ec..000000000 --- a/packages/nestjs-crud/src/crud/decorators/actions/crud-read-many.decorator.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CrudReadAll } from './crud-read-all.decorator'; - -/** - * CRUD Read Many route decorator (alias for Read All) - */ -export const CrudReadMany = (...args: Parameters) => - CrudReadAll(...args); diff --git a/packages/nestjs-crud/src/crud/decorators/actions/crud-read-one.decorator.ts b/packages/nestjs-crud/src/crud/decorators/actions/crud-read-one.decorator.ts deleted file mode 100644 index 881a0bb58..000000000 --- a/packages/nestjs-crud/src/crud/decorators/actions/crud-read-one.decorator.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { applyDecorators, Get, PlainLiteralObject } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_ID_DEFAULT_PATH } from '../../../crud.constants'; -import { CrudActions } from '../../enums/crud-actions.enum'; -import { CrudReadOneOptionsInterface } from '../../interfaces/crud-route-options.interface'; -import { CrudApiOperation } from '../openapi/crud-api-operation.decorator'; -import { CrudApiParam } from '../openapi/crud-api-param.decorator'; -import { CrudApiQuery } from '../openapi/crud-api-query.decorator'; -import { CrudApiResponse } from '../openapi/crud-api-response.decorator'; -import { CrudAction } from '../routes/crud-action.decorator'; -import { CrudSerialize } from '../routes/crud-serialize.decorator'; -import { CrudValidate } from '../routes/crud-validate.decorator'; - -/** - * CRUD Read One route decorator - */ -export const CrudReadOne = ( - options: CrudReadOneOptionsInterface = {}, -) => { - const { - path = CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, - validation, - serialization, - api, - } = { ...options }; - - return applyDecorators( - Get(path), - CrudAction(CrudActions.ReadOne), - CrudValidate(validation), - CrudSerialize(serialization), - CrudApiOperation(api?.operation), - CrudApiQuery(api?.query), - CrudApiParam(api?.params), - CrudApiResponse(CrudActions.ReadOne, api?.response), - ); -}; diff --git a/packages/nestjs-crud/src/crud/decorators/actions/crud-recover-one.decorator.ts b/packages/nestjs-crud/src/crud/decorators/actions/crud-recover-one.decorator.ts deleted file mode 100644 index 82125de41..000000000 --- a/packages/nestjs-crud/src/crud/decorators/actions/crud-recover-one.decorator.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - applyDecorators, - Patch, - PlainLiteralObject, - SetMetadata, -} from '@nestjs/common'; - -import { - CRUD_MODULE_ROUTE_RECOVER_ONE_DEFAULT_PATH, - CRUD_MODULE_ROUTE_RECOVER_ONE_METADATA, -} from '../../../crud.constants'; -import { CrudActions } from '../../enums/crud-actions.enum'; -import { CrudRecoverOneOptionsInterface } from '../../interfaces/crud-route-options.interface'; -import { CrudApiOperation } from '../openapi/crud-api-operation.decorator'; -import { CrudApiParam } from '../openapi/crud-api-param.decorator'; -import { CrudApiResponse } from '../openapi/crud-api-response.decorator'; -import { CrudAction } from '../routes/crud-action.decorator'; -import { CrudSerialize } from '../routes/crud-serialize.decorator'; -import { CrudValidate } from '../routes/crud-validate.decorator'; - -/** - * CRUD Recover One route decorator - */ -export const CrudRecoverOne = < - T extends PlainLiteralObject = PlainLiteralObject, ->( - options: CrudRecoverOneOptionsInterface = {}, -) => { - const { - path = CRUD_MODULE_ROUTE_RECOVER_ONE_DEFAULT_PATH, - validation, - serialization, - api, - ...rest - } = { ...options }; - - return applyDecorators( - Patch(path), - CrudAction(CrudActions.RecoverOne), - SetMetadata(CRUD_MODULE_ROUTE_RECOVER_ONE_METADATA, rest), - CrudValidate(validation), - CrudSerialize(serialization), - CrudApiOperation(api?.operation), - CrudApiParam(api?.params), - CrudApiResponse(CrudActions.RecoverOne, api?.response), - ); -}; diff --git a/packages/nestjs-crud/src/crud/decorators/actions/crud-replace-one.decorator.ts b/packages/nestjs-crud/src/crud/decorators/actions/crud-replace-one.decorator.ts deleted file mode 100644 index d6a01eab4..000000000 --- a/packages/nestjs-crud/src/crud/decorators/actions/crud-replace-one.decorator.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - applyDecorators, - PlainLiteralObject, - Put, - SetMetadata, -} from '@nestjs/common'; - -import { - CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, - CRUD_MODULE_ROUTE_REPLACE_ONE_METADATA, -} from '../../../crud.constants'; -import { CrudValidationOptions } from '../../../crud.types'; -import { CrudActions } from '../../enums/crud-actions.enum'; -import { CrudReplaceOneOptionsInterface } from '../../interfaces/crud-route-options.interface'; -import { CrudApiBody } from '../openapi/crud-api-body.decorator'; -import { CrudApiOperation } from '../openapi/crud-api-operation.decorator'; -import { CrudApiParam } from '../openapi/crud-api-param.decorator'; -import { CrudApiResponse } from '../openapi/crud-api-response.decorator'; -import { CrudAction } from '../routes/crud-action.decorator'; -import { CrudSerialize } from '../routes/crud-serialize.decorator'; -import { CrudValidate } from '../routes/crud-validate.decorator'; - -/** - * CRUD Replace One route decorator - */ -export const CrudReplaceOne = < - T extends PlainLiteralObject = PlainLiteralObject, ->( - options: CrudReplaceOneOptionsInterface = {}, -) => { - const { - path = CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, - dto, - validation, - serialization, - api, - ...rest - } = { ...options }; - - const validationMerged: CrudValidationOptions = dto - ? { expectedType: dto, ...validation } - : validation; - - return applyDecorators( - Put(path), - CrudAction(CrudActions.ReplaceOne), - SetMetadata(CRUD_MODULE_ROUTE_REPLACE_ONE_METADATA, rest), - CrudValidate(validationMerged), - CrudSerialize(serialization), - CrudApiOperation(api?.operation), - CrudApiParam(api?.params), - CrudApiBody({ - type: options?.dto, - ...options?.api?.body, - }), - CrudApiResponse(CrudActions.ReplaceOne, api?.response), - ); -}; diff --git a/packages/nestjs-crud/src/crud/decorators/actions/crud-update-one.decorator.ts b/packages/nestjs-crud/src/crud/decorators/actions/crud-update-one.decorator.ts deleted file mode 100644 index d42ba9151..000000000 --- a/packages/nestjs-crud/src/crud/decorators/actions/crud-update-one.decorator.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - applyDecorators, - Patch, - PlainLiteralObject, - SetMetadata, -} from '@nestjs/common'; - -import { - CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, - CRUD_MODULE_ROUTE_UPDATE_ONE_METADATA, -} from '../../../crud.constants'; -import { CrudValidationOptions } from '../../../crud.types'; -import { CrudActions } from '../../enums/crud-actions.enum'; -import { CrudUpdateOneOptionsInterface } from '../../interfaces/crud-route-options.interface'; -import { CrudApiBody } from '../openapi/crud-api-body.decorator'; -import { CrudApiOperation } from '../openapi/crud-api-operation.decorator'; -import { CrudApiParam } from '../openapi/crud-api-param.decorator'; -import { CrudApiResponse } from '../openapi/crud-api-response.decorator'; -import { CrudAction } from '../routes/crud-action.decorator'; -import { CrudSerialize } from '../routes/crud-serialize.decorator'; -import { CrudValidate } from '../routes/crud-validate.decorator'; - -/** - * CRUD Update One route decorator - */ -export const CrudUpdateOne = < - T extends PlainLiteralObject = PlainLiteralObject, ->( - options: CrudUpdateOneOptionsInterface = {}, -) => { - const { - path = CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, - dto, - validation, - serialization, - api, - ...rest - } = { ...options }; - - const validationMerged: CrudValidationOptions = dto - ? { expectedType: dto, ...validation } - : validation; - - return applyDecorators( - Patch(path), - CrudAction(CrudActions.UpdateOne), - SetMetadata(CRUD_MODULE_ROUTE_UPDATE_ONE_METADATA, rest), - CrudValidate(validationMerged), - CrudSerialize(serialization), - CrudApiOperation(api?.operation), - CrudApiParam(api?.params), - CrudApiBody({ - type: options?.dto, - ...options?.api?.body, - }), - CrudApiResponse(CrudActions.UpdateOne, api?.response), - ); -}; diff --git a/packages/nestjs-crud/src/crud/decorators/controller/crud-controller.decorator.e2e-spec.ts b/packages/nestjs-crud/src/crud/decorators/controller/crud-controller.decorator.e2e-spec.ts deleted file mode 100644 index 98eb15783..000000000 --- a/packages/nestjs-crud/src/crud/decorators/controller/crud-controller.decorator.e2e-spec.ts +++ /dev/null @@ -1,322 +0,0 @@ -import request from 'supertest'; - -import { INestApplication } from '@nestjs/common'; -import { APP_FILTER } from '@nestjs/core'; -import { Test } from '@nestjs/testing'; - -import { ExceptionsFilter } from '@concepta/nestjs-common'; - -import { TestCrudAdapter } from '../../../__fixtures__/crud/adapters/test-crud.adapter'; -import { TestModelCreateManyDto } from '../../../__fixtures__/crud/dto/test-model-create-many.dto'; -import { TestModelCreateDto } from '../../../__fixtures__/crud/dto/test-model-create.dto'; -import { TestModelUpdateDto } from '../../../__fixtures__/crud/dto/test-model-update.dto'; -import { TestModelDto } from '../../../__fixtures__/crud/models/test.model'; -import { CrudModule } from '../../../crud.module'; -import { CrudRequestQueryBuilder } from '../../../request/crud-request-query.builder'; -import { CrudCreateManyInterface } from '../../interfaces/crud-create-many.interface'; -import { CrudRequestInterface } from '../../interfaces/crud-request.interface'; -import { CrudCreateMany } from '../actions/crud-create-many.decorator'; -import { CrudCreateOne } from '../actions/crud-create-one.decorator'; -import { CrudDeleteOne } from '../actions/crud-delete-one.decorator'; -import { CrudGetMany } from '../actions/crud-get-many.decorator'; -import { CrudGetOne } from '../actions/crud-get-one.decorator'; -import { CrudReplaceOne } from '../actions/crud-replace-one.decorator'; -import { CrudUpdateOne } from '../actions/crud-update-one.decorator'; -import { CrudBody } from '../params/crud-body.decorator'; -import { CrudRequest } from '../params/crud-request.decorator'; - -import { CrudController } from './crud-controller.decorator'; - -describe('#crud', () => { - describe('#base methods', () => { - let app: INestApplication; - let server: ReturnType; - let qb: CrudRequestQueryBuilder; - - @CrudController({ - path: 'test', - model: { type: TestModelDto }, - params: { - id: { field: 'id', type: 'number' }, - }, - validation: { - transformOptions: { - strategy: 'exposeAll', - }, - }, - }) - class TestController { - constructor(public service: TestCrudAdapter) {} - - @CrudGetMany() - async getMany(@CrudRequest() req: CrudRequestInterface) { - return this.service.getMany(req); - } - - @CrudGetOne() - async getOne(@CrudRequest() req: CrudRequestInterface) { - return this.service.getOne(req); - } - - @CrudCreateOne() - async createOne( - @CrudRequest() req: CrudRequestInterface, - @CrudBody() dto: TestModelCreateDto, - ) { - return this.service.createOne(req, dto); - } - - @CrudReplaceOne() - async replaceOne( - @CrudRequest() req: CrudRequestInterface, - @CrudBody() dto: TestModelCreateDto, - ) { - return this.service.replaceOne(req, dto); - } - - @CrudUpdateOne() - async updateOne( - @CrudRequest() req: CrudRequestInterface, - @CrudBody() dto: TestModelUpdateDto, - ) { - return this.service.updateOne(req, dto); - } - - @CrudCreateMany() - async createMany( - @CrudRequest() req: CrudRequestInterface, - @CrudBody() dto: TestModelCreateManyDto, - ) { - return this.service.createMany(req, dto); - } - - @CrudDeleteOne() - async deleteOne(@CrudRequest() req: CrudRequestInterface) { - return this.service.deleteOne(req); - } - } - - beforeAll(async () => { - const fixture = await Test.createTestingModule({ - imports: [CrudModule.forRoot({})], - controllers: [TestController], - providers: [ - { provide: APP_FILTER, useClass: ExceptionsFilter }, - TestCrudAdapter, - ], - }).compile(); - - app = fixture.createNestApplication(); - - await app.init(); - server = app.getHttpServer(); - }); - - beforeEach(() => { - qb = CrudRequestQueryBuilder.create(); - }); - - afterAll(async () => { - app.close(); - }); - - describe('#getMany', () => { - it('should return status 200', (done) => { - request(server) - .get('/test') - .end((_, res) => { - expect(res.status).toEqual(200); - done(); - }); - }); - it('should return status 400', (done) => { - const query = qb.setFilter({ field: 'foo', operator: '$gt' }).query(); - request(server) - .get('/test') - .query(query) - .end((_, res) => { - const expected = { - statusCode: 400, - errorCode: 'CRUD_REQUEST_ERROR', - }; - expect(res.status).toEqual(400); - expect(res.body).toMatchObject(expected); - done(); - }); - }); - }); - - describe('#getOne', () => { - it('should return status 200', (done) => { - request(server) - .get('/test/1') - .end((_, res) => { - expect(res.status).toEqual(200); - done(); - }); - }); - it('should return status 400', (done) => { - request(server) - .get('/test/invalid') - .end((_, res) => { - const expected = { - statusCode: 400, - errorCode: 'CRUD_REQUEST_ERROR', - }; - expect(res.status).toEqual(400); - expect(res.body).toMatchObject(expected); - done(); - }); - }); - }); - - describe('#createBase', () => { - it('should return status 201', (done) => { - const send: TestModelDto = { - firstName: 'firstName', - lastName: 'lastName', - email: 'test@test.com', - age: 15, - }; - request(server) - .post('/test') - .send(send) - .end((_, res) => { - expect(res.status).toEqual(201); - done(); - }); - }); - it('should return status 400', (done) => { - const send: TestModelDto = { - firstName: 'firstName', - lastName: 'lastName', - email: 'test@test.com', - }; - request(server) - .post('/test') - .send(send) - .end((_, res) => { - expect(res.status).toEqual(400); - done(); - }); - }); - }); - - describe('#createMany', () => { - it('should return status 201', (done) => { - const send: CrudCreateManyInterface = { - bulk: [ - { - firstName: 'firstName', - lastName: 'lastName', - email: 'test@test.com', - age: 15, - }, - { - firstName: 'firstName', - lastName: 'lastName', - email: 'test@test.com', - age: 15, - }, - ], - }; - request(server) - .post('/test/bulk') - .send(send) - .end((_, res) => { - expect(res.status).toEqual(201); - done(); - }); - }); - it('should return status 400', (done) => { - const send: CrudCreateManyInterface = { - bulk: [], - }; - request(server) - .post('/test/bulk') - .send(send) - .end((_, res) => { - expect(res.status).toEqual(400); - done(); - }); - }); - }); - - describe('#replaceOne', () => { - it('should return status 200', (done) => { - const send: TestModelDto = { - id: 1, - firstName: 'firstName', - lastName: 'lastName', - email: 'test@test.com', - age: 15, - }; - request(server) - .put('/test/1') - .send(send) - .end((_, res) => { - expect(res.status).toEqual(200); - done(); - }); - }); - it('should return status 400', (done) => { - const send: TestModelDto = { - firstName: 'firstName', - lastName: 'lastName', - email: 'test@test.com', - }; - request(server) - .put('/test/1') - .send(send) - .end((_, res) => { - expect(res.status).toEqual(400); - done(); - }); - }); - }); - - describe('#updateOne', () => { - it('should return status 200', (done) => { - const send: TestModelDto = { - id: 1, - firstName: 'firstName', - lastName: 'lastName', - email: 'test@test.com', - age: 15, - }; - request(server) - .patch('/test/1') - .send(send) - .end((_, res) => { - expect(res.status).toEqual(200); - done(); - }); - }); - it('should return status 400', (done) => { - const send: TestModelDto = { - firstName: 'firstName', - lastName: 'lastName', - email: 'test@test.com', - }; - request(server) - .patch('/test/1') - .send(send) - .end((_, res) => { - expect(res.status).toEqual(400); - done(); - }); - }); - }); - - describe('#deleteOne', () => { - it('should return status 200', (done) => { - request(server) - .delete('/test/1') - .end((_, res) => { - expect(res.status).toEqual(200); - done(); - }); - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/crud/decorators/controller/crud-controller.decorator.ts b/packages/nestjs-crud/src/crud/decorators/controller/crud-controller.decorator.ts deleted file mode 100644 index 2533381d9..000000000 --- a/packages/nestjs-crud/src/crud/decorators/controller/crud-controller.decorator.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - applyDecorators, - Controller, - PlainLiteralObject, -} from '@nestjs/common'; - -import { CRUD_MODULE_DEFAULT_PARAMS_OPTIONS } from '../../../crud.constants'; -import { CrudControllerOptionsInterface } from '../../interfaces/crud-controller-options.interface'; -import { CrudModel } from '../routes/crud-model.decorator'; -import { CrudParams } from '../routes/crud-params.decorator'; -import { CrudSerialize } from '../routes/crud-serialize.decorator'; -import { CrudValidate } from '../routes/crud-validate.decorator'; - -import { CrudInitApiParams } from './crud-init-api-params.decorator'; -import { CrudInitApiQuery } from './crud-init-api-query.decorator'; -import { CrudInitApiResponse } from './crud-init-api-response.decorator'; -import { CrudInitSerialization } from './crud-init-serialization.decorator'; -import { CrudInitValidation } from './crud-init-validation.decorator'; - -/** - * CRUD controller decorator - * - * This decorator is a helper for calling the most common controller level decorators. - */ -export function CrudController< - T extends PlainLiteralObject = PlainLiteralObject, ->(options: CrudControllerOptionsInterface) { - // break out options - const { path, host, ...moreOptions } = options; - - // apply all decorators - return applyDecorators( - Controller({ path, host }), - CrudModel(moreOptions.model), - CrudParams(moreOptions.params ?? CRUD_MODULE_DEFAULT_PARAMS_OPTIONS), - CrudValidate(moreOptions.validation), - CrudSerialize(moreOptions.serialization), - CrudInitValidation(), - CrudInitSerialization(), - CrudInitApiQuery(), - CrudInitApiParams(), - CrudInitApiResponse(), - ); -} diff --git a/packages/nestjs-crud/src/crud/decorators/controller/crud-init-api-params.decorator.ts b/packages/nestjs-crud/src/crud/decorators/controller/crud-init-api-params.decorator.ts deleted file mode 100644 index a2c791fb3..000000000 --- a/packages/nestjs-crud/src/crud/decorators/controller/crud-init-api-params.decorator.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; -import { ApiParam, ApiParamOptions } from '@nestjs/swagger'; - -import { CrudException } from '../../../exceptions/crud.exception'; -import { CrudReflectionService } from '../../../services/crud-reflection.service'; - -/** - * Crud initialize open api params decorator. - * - * Add an ApiParam to every method with a crud action. - */ -export const CrudInitApiParams = - (): ClassDecorator => - (...args: Parameters) => { - // get the args - const [classTarget] = args; - - const reflectionService = new CrudReflectionService(); - - // get the api param options - const apiParamsMetadata = - reflectionService.getApiParamsOptions(classTarget.prototype) ?? []; - - // yes, loop all metadatas - apiParamsMetadata.map((metadata) => { - // break out the args - const { propertyKey } = metadata; - - // need the descriptor - const descriptor = Object.getOwnPropertyDescriptor( - classTarget.prototype, - propertyKey, - ); - - // sanity check - if (!descriptor) { - throw new CrudException({ - message: 'Did not find property descriptor', - }); - } - - // get the route params options - const paramsOptions = reflectionService.getAllParamOptions( - classTarget, - classTarget.prototype[propertyKey], - ); - - // create the api param decorator - if (paramsOptions) { - // loop all params options - for (const p in paramsOptions) { - // options for this property - const propOpts = paramsOptions[p]; - - // merge the options - const apiOptions: ApiParamOptions = { - name: propOpts.field ?? '', - required: true, - type: propOpts.type === 'number' ? Number : String, - enum: propOpts?.enum ? Object.values(propOpts.enum) : undefined, - }; - - ApiParam(apiOptions)(classTarget.prototype, propertyKey, descriptor); - } - } - }); - }; diff --git a/packages/nestjs-crud/src/crud/decorators/controller/crud-init-api-query.decorator.ts b/packages/nestjs-crud/src/crud/decorators/controller/crud-init-api-query.decorator.ts deleted file mode 100644 index a62877913..000000000 --- a/packages/nestjs-crud/src/crud/decorators/controller/crud-init-api-query.decorator.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { ApiQuery } from '@nestjs/swagger'; - -import { CrudException } from '../../../exceptions/crud.exception'; -import { CrudReflectionService } from '../../../services/crud-reflection.service'; -import { CrudActions } from '../../enums/crud-actions.enum'; -import { Swagger } from '../../helpers/swagger.helper'; -import { CrudRouteName } from '../../types/crud-route-name.type'; - -/** - * \@CrudInit() api query decorator. - */ -export const CrudInitApiQuery = - (): ClassDecorator => - (...args: Parameters) => { - // get the args - const [classTarget] = args; - - const reflectionService = new CrudReflectionService(); - - // get the api query options - const apiQueryMetadata = - reflectionService.getApiQueryOptions(classTarget.prototype) ?? []; - - // loop metadatas - apiQueryMetadata.map((metadata) => { - // break out the args - const { propertyKey, options = [] } = metadata; - - // need the descriptor - const descriptor = Object.getOwnPropertyDescriptor( - classTarget.prototype, - propertyKey, - ); - - // sanity check - if (!descriptor) { - throw new CrudException({ - message: 'Did not find property descriptor', - }); - } - - // get the action - const action = reflectionService.getAction( - classTarget.prototype[propertyKey], - ); - - // get the base name - const basename = mapActionNameToQueryableBaseName(action); - - // get a base name? - if (basename) { - // get the request options - const requestOptions = reflectionService.getRequestOptions( - classTarget, - classTarget.prototype[propertyKey], - ); - - // use swagger helper to get the query - const queryParamsMeta = Swagger.createQueryParamsMeta( - basename, - requestOptions, - ); - - // the merged options - const appliedParamsMap = new Map(); - - // filter options to only include those with a name property (NestJS 11 compatibility) - const queryOptionsWithName = [...options, ...queryParamsMeta].filter( - (option): option is typeof option & { name: string } => - 'name' in option && typeof option.name === 'string', - ); - - // loop all of the options merged together, overrides first - for (const apiQueryOptions of queryOptionsWithName) { - // applied yet? - if (!appliedParamsMap.has(apiQueryOptions.name)) { - // apply the decorator - ApiQuery(apiQueryOptions)( - classTarget.prototype, - propertyKey, - descriptor, - ); - // consider it done - appliedParamsMap.set(apiQueryOptions.name, true); - } - } - } - }); - }; - -/** - * Map crud action name to queryable base name. - * - * @param action - The crud action we are mapping. - */ -function mapActionNameToQueryableBaseName( - action: CrudActions, -): CrudRouteName | undefined { - switch (action) { - case CrudActions.ReadAll: - return 'getMany'; - case CrudActions.ReadOne: - return 'getOne'; - default: - return undefined; - } -} diff --git a/packages/nestjs-crud/src/crud/decorators/controller/crud-init-api-response.decorator.ts b/packages/nestjs-crud/src/crud/decorators/controller/crud-init-api-response.decorator.ts deleted file mode 100644 index 3505c5f73..000000000 --- a/packages/nestjs-crud/src/crud/decorators/controller/crud-init-api-response.decorator.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { CrudException } from '../../../exceptions/crud.exception'; -import { CrudReflectionService } from '../../../services/crud-reflection.service'; -import { applyApiResponse } from '../util/apply-api-response.decorator'; - -/** - * CRUD init api response decorator. - */ -export const CrudInitApiResponse = - (): ClassDecorator => - (...args: Parameters) => { - // get the args - const [classTarget] = args; - - const reflectionService = new CrudReflectionService(); - - // get the api response options - const apiResponseMetadata = - reflectionService.getApiResponseOptions(classTarget.prototype) ?? []; - - // loop all metadatas - apiResponseMetadata.map((metadata) => { - // break out the args - const { propertyKey, action, options } = metadata; - - // need the descriptor - const descriptor = Object.getOwnPropertyDescriptor( - classTarget.prototype, - propertyKey, - ); - - if (!descriptor) { - throw new CrudException({ - message: 'Failed to get property descriptor', - }); - } - - applyApiResponse(action, options)(classTarget, propertyKey, descriptor); - }); - }; diff --git a/packages/nestjs-crud/src/crud/decorators/controller/crud-init-validation.decorator.ts b/packages/nestjs-crud/src/crud/decorators/controller/crud-init-validation.decorator.ts deleted file mode 100644 index 596246ddc..000000000 --- a/packages/nestjs-crud/src/crud/decorators/controller/crud-init-validation.decorator.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Body, ValidationPipe } from '@nestjs/common'; - -import { CRUD_MODULE_DEFAULT_VALIDATION_PIPE_OPTIONS } from '../../../crud.constants'; -import { CrudReflectionService } from '../../../services/crud-reflection.service'; - -/** - * Crud initialize validation decorator. - * - * Add a ValidationPipe to every parameter called with the `CrudBody` decorator. - */ -export const CrudInitValidation = - (): ClassDecorator => - (...args: Parameters) => { - // get the args - const [classTarget] = args; - - // reflection service - const reflectionService = new CrudReflectionService(); - - // get the param options - const bodyParamMetadata = reflectionService.getBodyParamOptions( - classTarget.prototype, - ); - - // get the fallback validation options - const fallbackOptions = reflectionService.getValidationOptions(classTarget); - - // do we have param validation metada? - if (Array.isArray(bodyParamMetadata)) { - // yes, loop all metadatas and set up the pipe - bodyParamMetadata.map((metadata) => { - // break out the args - let { pipes = [] } = metadata; - const { validation = fallbackOptions } = metadata; - - // are we injecting validation? - if (validation !== false) { - // yes, merge options - const finalOptions = { - ...CRUD_MODULE_DEFAULT_VALIDATION_PIPE_OPTIONS, - ...validation, - }; - - // create new pipe - const paramPipe = new ValidationPipe(finalOptions); - - // put our validation pipe first - pipes = [paramPipe, ...pipes]; - } - - // create the body decorator - Body(...pipes)( - classTarget.prototype, - metadata.propertyKey, - metadata.parameterIndex, - ); - }); - } - }; diff --git a/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-body.decorator.ts b/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-body.decorator.ts deleted file mode 100644 index d3c35aecb..000000000 --- a/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-body.decorator.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ApiBody, ApiBodyOptions } from '@nestjs/swagger'; - -import { DecoratorTargetObject } from '../../../crud.types'; -import { CrudException } from '../../../exceptions/crud.exception'; - -/** - * \@CrudApiBody() open api decorator - */ -export function CrudApiBody(options?: ApiBodyOptions): MethodDecorator { - return (classTarget: DecoratorTargetObject, ...rest) => { - const [propertyKey] = rest; - - if ('__proto__' in classTarget) { - // need the descriptor - const descriptor = Object.getOwnPropertyDescriptor( - classTarget, - propertyKey, - ); - - // sanity check - if (!descriptor) { - throw new CrudException({ - message: 'Did not find property descriptor', - }); - } - - ApiBody(options ?? {})(classTarget.prototype, propertyKey, descriptor); - } else { - throw new CrudException({ - message: 'Cannot decorate with api body, target must be a class', - }); - } - }; -} diff --git a/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-param.decorator.ts b/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-param.decorator.ts deleted file mode 100644 index d27acde8f..000000000 --- a/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-param.decorator.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; -import { ApiParamOptions } from '@nestjs/swagger'; - -import { CRUD_MODULE_API_PARAMS_METADATA } from '../../../crud.constants'; -import { DecoratorTargetObject } from '../../../crud.types'; -import { CrudException } from '../../../exceptions/crud.exception'; -import { CrudReflectionService } from '../../../services/crud-reflection.service'; -import { CrudApiParamMetadataInterface } from '../../interfaces/crud-api-param-metadata.interface'; - -/** - * \@CrudApiParam() open api decorator - */ -export function CrudApiParam(options?: ApiParamOptions): MethodDecorator { - return (target: DecoratorTargetObject, ...rest) => { - const [propertyKey] = rest; - - if (!('__proto__' in target)) { - throw new CrudException({ - message: 'Cannot decorate with api param, target must be a class', - }); - } - - const reflectionService = new CrudReflectionService(); - - const previousValues = reflectionService.getApiParamsOptions(target) || []; - - const value: CrudApiParamMetadataInterface = { - propertyKey, - options, - }; - - const values = [...previousValues, value]; - - SetMetadata(CRUD_MODULE_API_PARAMS_METADATA, values)(target); - }; -} diff --git a/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-query.decorator.ts b/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-query.decorator.ts deleted file mode 100644 index 313ae9dc7..000000000 --- a/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-query.decorator.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; -import { ApiQueryOptions } from '@nestjs/swagger'; - -import { CRUD_MODULE_API_QUERY_METADATA } from '../../../crud.constants'; -import { DecoratorTargetObject } from '../../../crud.types'; -import { CrudException } from '../../../exceptions/crud.exception'; -import { CrudReflectionService } from '../../../services/crud-reflection.service'; -import { CrudApiQueryMetadataInterface } from '../../interfaces/crud-api-query-metadata.interface'; - -/** - * \@CrudApiQuery() open api decorator - */ -export function CrudApiQuery(options?: ApiQueryOptions[]): MethodDecorator { - return (target: DecoratorTargetObject, ...rest) => { - const [propertyKey] = rest; - - if (typeof target === 'object') { - const reflectionService = new CrudReflectionService(); - - const previousValues = reflectionService.getApiQueryOptions(target) || []; - - const value: CrudApiQueryMetadataInterface = { - propertyKey, - options, - }; - - const values = [...previousValues, value]; - - SetMetadata(CRUD_MODULE_API_QUERY_METADATA, values)(target); - } else { - throw new CrudException({ - message: 'Cannot decorate with api query, target must be a class', - }); - } - }; -} diff --git a/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-response.decorator.ts b/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-response.decorator.ts deleted file mode 100644 index a5f5e0512..000000000 --- a/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-response.decorator.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; -import { ApiResponseOptions } from '@nestjs/swagger'; - -import { CRUD_MODULE_API_RESPONSE_METADATA } from '../../../crud.constants'; -import { DecoratorTargetObject } from '../../../crud.types'; -import { CrudException } from '../../../exceptions/crud.exception'; -import { CrudReflectionService } from '../../../services/crud-reflection.service'; -import { CrudActions } from '../../enums/crud-actions.enum'; -import { CrudApiResponseMetadataInterface } from '../../interfaces/crud-api-response-metadata.interface'; - -/** - * \@CrudApiResponse() open api decorator - */ -export function CrudApiResponse( - action: CrudActions, - options?: ApiResponseOptions, -): MethodDecorator { - return (target: DecoratorTargetObject, ...rest) => { - const [propertyKey] = rest; - - if (!('__proto__' in target)) { - throw new CrudException({ - message: 'Cannot decorate with api response, target must be a class', - }); - } - - const reflectionService = new CrudReflectionService(); - - const previousValues = - reflectionService.getApiResponseOptions(target) || []; - - const value: CrudApiResponseMetadataInterface = { - propertyKey, - action, - options, - }; - - const values = [...previousValues, value]; - - SetMetadata(CRUD_MODULE_API_RESPONSE_METADATA, values)(target); - }; -} diff --git a/packages/nestjs-crud/src/crud/decorators/params/crud-body.decorator.ts b/packages/nestjs-crud/src/crud/decorators/params/crud-body.decorator.ts deleted file mode 100644 index 08d5efe4b..000000000 --- a/packages/nestjs-crud/src/crud/decorators/params/crud-body.decorator.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_PARAM_BODY_METADATA } from '../../../crud.constants'; -import { DecoratorTargetObject } from '../../../crud.types'; -import { CrudException } from '../../../exceptions/crud.exception'; -import { CrudReflectionService } from '../../../services/crud-reflection.service'; -import { CrudBodyOptionsInterface } from '../../interfaces/crud-body-options.interface'; -import { CrudValidationMetadataInterface } from '../../interfaces/crud-validation-metadata.interface'; - -/** - * \@CrudBody() parameter decorator - */ -export function CrudBody( - options?: CrudBodyOptionsInterface, -): ParameterDecorator { - return (target: DecoratorTargetObject, ...rest) => { - const [propertyKey, parameterIndex] = rest; - - if (!('__proto__' in target)) { - throw new CrudException({ - message: 'Cannot decorate with body, target must be a class', - }); - } - - const reflectionService = new CrudReflectionService(); - - const previousValues = reflectionService.getBodyParamOptions(target) || []; - - const value: CrudValidationMetadataInterface = { - propertyKey, - parameterIndex, - validation: options?.validation, - pipes: options?.pipes ?? [], - }; - - const values = [...previousValues, value]; - - SetMetadata(CRUD_MODULE_PARAM_BODY_METADATA, values)(target); - }; -} diff --git a/packages/nestjs-crud/src/crud/decorators/params/crud-request.decorator.ts b/packages/nestjs-crud/src/crud/decorators/params/crud-request.decorator.ts deleted file mode 100644 index 03d3d5d6d..000000000 --- a/packages/nestjs-crud/src/crud/decorators/params/crud-request.decorator.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; - -import { CRUD_MODULE_CRUD_REQUEST_KEY } from '../../../crud.constants'; - -/** - * \@CrudRequest() parameter decorator - */ -export const CrudRequest = createParamDecorator( - (_data: unknown, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); - return request[CRUD_MODULE_CRUD_REQUEST_KEY]; - }, -); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-action.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-action.decorator.ts deleted file mode 100644 index 128c93a8e..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-action.decorator.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { applyDecorators, SetMetadata, UseInterceptors } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_ACTION_METADATA } from '../../../crud.constants'; -import { CrudActions } from '../../enums/crud-actions.enum'; -import { CrudRequestInterceptor } from '../../interceptors/crud-request.interceptor'; - -/** - * CRUD action route decorator - */ -export const CrudAction = (action: CrudActions) => - applyDecorators( - SetMetadata(CRUD_MODULE_ROUTE_ACTION_METADATA, action), - UseInterceptors(CrudRequestInterceptor), - ); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-allow.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-allow.decorator.ts deleted file mode 100644 index 25058b4b4..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-allow.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_QUERY_ALLOW_METADATA } from '../../../crud.constants'; -import { CrudServiceQueryOptionsInterface } from '../../interfaces/crud-service-query-options.interface'; - -/** - * CRUD allow route decorator. - * - * Set the CRUD allow query option. - */ -export const CrudAllow = < - Entity extends PlainLiteralObject = PlainLiteralObject, ->( - fields: CrudServiceQueryOptionsInterface['allow'], -) => SetMetadata(CRUD_MODULE_ROUTE_QUERY_ALLOW_METADATA, fields); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-cache.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-cache.decorator.ts deleted file mode 100644 index e3636b0b2..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-cache.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_QUERY_CACHE_METADATA } from '../../../crud.constants'; -import { CrudServiceQueryOptionsInterface } from '../../interfaces/crud-service-query-options.interface'; - -/** - * CRUD cache route decorator. - * - * Set the CRUD cache query option. - */ -export const CrudCache = < - Entity extends PlainLiteralObject = PlainLiteralObject, ->( - cache: CrudServiceQueryOptionsInterface['cache'], -) => SetMetadata(CRUD_MODULE_ROUTE_QUERY_CACHE_METADATA, cache); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-exclude.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-exclude.decorator.ts deleted file mode 100644 index 8a0dfae9a..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-exclude.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_QUERY_EXCLUDE_METADATA } from '../../../crud.constants'; -import { CrudServiceQueryOptionsInterface } from '../../interfaces/crud-service-query-options.interface'; - -/** - * CRUD exclude route decorator. - * - * Set the CRUD exclude query option. - */ -export const CrudExclude = < - Entity extends PlainLiteralObject = PlainLiteralObject, ->( - fields: CrudServiceQueryOptionsInterface['exclude'], -) => SetMetadata(CRUD_MODULE_ROUTE_QUERY_EXCLUDE_METADATA, fields); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-filter.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-filter.decorator.ts deleted file mode 100644 index 893fc3f9e..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-filter.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_QUERY_FILTER_METADATA } from '../../../crud.constants'; -import { CrudServiceQueryOptionsInterface } from '../../interfaces/crud-service-query-options.interface'; - -/** - * CRUD filter route decorator. - * - * Set the CRUD filter query option. - */ -export const CrudFilter = < - Entity extends PlainLiteralObject = PlainLiteralObject, ->( - filters: CrudServiceQueryOptionsInterface['filter'], -) => SetMetadata(CRUD_MODULE_ROUTE_QUERY_FILTER_METADATA, filters); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-limit.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-limit.decorator.ts deleted file mode 100644 index a7da2bc31..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-limit.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_QUERY_LIMIT_METADATA } from '../../../crud.constants'; -import { CrudServiceQueryOptionsInterface } from '../../interfaces/crud-service-query-options.interface'; - -/** - * CRUD limit route decorator. - * - * Set the CRUD limit query option. - */ -export const CrudLimit = < - Entity extends PlainLiteralObject = PlainLiteralObject, ->( - limit: CrudServiceQueryOptionsInterface['limit'], -) => SetMetadata(CRUD_MODULE_ROUTE_QUERY_LIMIT_METADATA, limit); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-max-limit.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-max-limit.decorator.ts deleted file mode 100644 index a3f4ea00d..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-max-limit.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_QUERY_MAX_LIMIT_METADATA } from '../../../crud.constants'; -import { CrudServiceQueryOptionsInterface } from '../../interfaces/crud-service-query-options.interface'; - -/** - * CRUD max limit route decorator. - * - * Set the CRUD max limit query option. - */ -export const CrudMaxLimit = < - Entity extends PlainLiteralObject = PlainLiteralObject, ->( - maxLimit: CrudServiceQueryOptionsInterface['maxLimit'], -) => SetMetadata(CRUD_MODULE_ROUTE_QUERY_MAX_LIMIT_METADATA, maxLimit); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-model.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-model.decorator.ts deleted file mode 100644 index cafa89857..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-model.decorator.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_MODEL_METADATA } from '../../../crud.constants'; -import { CrudModelOptionsInterface } from '../../interfaces/crud-model-options.interface'; - -/** - * CRUD Model route decorator. - * - * Set the CRUD model, or override the model set by the `@CrudController` decorator. - */ -export const CrudModel = (options: CrudModelOptionsInterface) => - SetMetadata(CRUD_MODULE_ROUTE_MODEL_METADATA, options); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-params.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-params.decorator.ts deleted file mode 100644 index af4920baa..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-params.decorator.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_PARAMS_METADATA } from '../../../crud.constants'; -import { CrudParamsOptionsInterface } from '../../interfaces/crud-params-options.interface'; - -/** - * CRUD Params route decorator. - * - * Set the CRUD params. - */ -export const CrudParams = ( - params: CrudParamsOptionsInterface, -) => SetMetadata(CRUD_MODULE_ROUTE_PARAMS_METADATA, params); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-persist.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-persist.decorator.ts deleted file mode 100644 index 1d0a5d8c7..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-persist.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_QUERY_PERSIST_METADATA } from '../../../crud.constants'; -import { CrudServiceQueryOptionsInterface } from '../../interfaces/crud-service-query-options.interface'; - -/** - * CRUD persist route decorator. - * - * Set the CRUD persist query option. - */ -export const CrudPersist = < - Entity extends PlainLiteralObject = PlainLiteralObject, ->( - persist: CrudServiceQueryOptionsInterface['persist'], -) => SetMetadata(CRUD_MODULE_ROUTE_QUERY_PERSIST_METADATA, persist); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-relations.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-relations.decorator.ts deleted file mode 100644 index ad7cd5db9..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-relations.decorator.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_RELATIONS_METADATA } from '../../../crud.constants'; -import { CrudRelationsInterface } from '../../interfaces/crud-relations.interface'; - -/** - * CRUD Relations route decorator. - * - * Configure relationship properties for hydrating sub-properties based on raw - * foreign keys. - */ -export const CrudRelations = < - Entity extends PlainLiteralObject = PlainLiteralObject, - Relations extends PlainLiteralObject[] = PlainLiteralObject[], ->( - relations: CrudRelationsInterface, -) => SetMetadata(CRUD_MODULE_ROUTE_RELATIONS_METADATA, relations); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-serialize.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-serialize.decorator.ts deleted file mode 100644 index 178521dcf..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-serialize.decorator.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_SERIALIZATION_METADATA } from '../../../crud.constants'; -import { CrudSerializationOptionsInterface } from '../../interfaces/crud-serialization-options.interface'; - -/** - * CRUD serialize route decorator - */ -export const CrudSerialize = (options?: CrudSerializationOptionsInterface) => - SetMetadata(CRUD_MODULE_ROUTE_SERIALIZATION_METADATA, options); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-soft-delete.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-soft-delete.decorator.ts deleted file mode 100644 index 872742394..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-soft-delete.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_QUERY_SOFT_DELETE_METADATA } from '../../../crud.constants'; -import { CrudServiceQueryOptionsInterface } from '../../interfaces/crud-service-query-options.interface'; - -/** - * CRUD soft delete route decorator. - * - * Set the CRUD soft delete query option. - */ -export const CrudSoftDelete = < - Entity extends PlainLiteralObject = PlainLiteralObject, ->( - softDelete: CrudServiceQueryOptionsInterface['softDelete'], -) => SetMetadata(CRUD_MODULE_ROUTE_QUERY_SOFT_DELETE_METADATA, softDelete); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-sort.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-sort.decorator.ts deleted file mode 100644 index 1566d8416..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-sort.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PlainLiteralObject, SetMetadata } from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_QUERY_SORT_METADATA } from '../../../crud.constants'; -import { CrudServiceQueryOptionsInterface } from '../../interfaces/crud-service-query-options.interface'; - -/** - * CRUD sort route decorator. - * - * Set the CRUD sort query option. - */ -export const CrudSort = < - Entity extends PlainLiteralObject = PlainLiteralObject, ->( - sort: CrudServiceQueryOptionsInterface['sort'], -) => SetMetadata(CRUD_MODULE_ROUTE_QUERY_SORT_METADATA, sort); diff --git a/packages/nestjs-crud/src/crud/decorators/routes/crud-validate.decorator.ts b/packages/nestjs-crud/src/crud/decorators/routes/crud-validate.decorator.ts deleted file mode 100644 index 87246379f..000000000 --- a/packages/nestjs-crud/src/crud/decorators/routes/crud-validate.decorator.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - applyDecorators, - PlainLiteralObject, - SetMetadata, -} from '@nestjs/common'; - -import { CRUD_MODULE_ROUTE_VALIDATION_METADATA } from '../../../crud.constants'; -import { CrudValidationOptions } from '../../../crud.types'; - -/** - * Crud validate options decorator. - * - * Set the fallback ValidationPipe options for all method - * parameters called with the `CrudBody` decorator. - * - * If this decorator is used on a controller, it will use the given options to - * every controller method's Crud param that does NOT have validations explicitly set. - * - * If this decorator is used on a method, it will use the given options for - * every Crud parameter on the method that does NOT have validations explicitly set. - * - * @param options - crud validation options - */ -export const CrudValidate = ( - options?: CrudValidationOptions, -) => - applyDecorators(SetMetadata(CRUD_MODULE_ROUTE_VALIDATION_METADATA, options)); diff --git a/packages/nestjs-crud/src/crud/decorators/util/apply-api-response.decorator.ts b/packages/nestjs-crud/src/crud/decorators/util/apply-api-response.decorator.ts deleted file mode 100644 index a72a8071d..000000000 --- a/packages/nestjs-crud/src/crud/decorators/util/apply-api-response.decorator.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { HttpStatus, Type } from '@nestjs/common'; -import { - ApiExtraModels, - ApiResponse, - ApiResponseMetadata, - ApiResponseOptions, - ApiResponseSchemaHost, - getSchemaPath, -} from '@nestjs/swagger'; - -import { DecoratorTargetObject } from '../../../crud.types'; -import { CrudException } from '../../../exceptions/crud.exception'; -import { CrudReflectionService } from '../../../services/crud-reflection.service'; -import { CrudInvalidResponseDto } from '../../dto/crud-invalid-response.dto'; -import { CrudResponsePaginatedDto } from '../../dto/crud-response-paginated.dto'; -import { CrudActions } from '../../enums/crud-actions.enum'; - -/** - * Utility decorator used to apply response - * options *from the controller context*. - * - * DO NOT USE THIS DIRECTLY ON METHODS!!! - */ -export function applyApiResponse( - action: CrudActions, - options: ApiResponseOptions = {}, -): MethodDecorator { - return (target: DecoratorTargetObject, ...rest) => { - // break out args - const [propertyKey] = rest; - - // reflection service - const reflectionService = new CrudReflectionService(); - - if (!('prototype' in target)) { - throw new CrudException({ - message: - 'Cannot decorate with apply api response, target must be a class', - }); - } - - // get the serialize options - const serializeOptions = reflectionService.getAllSerializationOptions( - target, - target.prototype[propertyKey], - ); - - // get the request options - const requestOptions = reflectionService.getRequestOptions( - target, - target.prototype[propertyKey], - ); - - // determine the dto type - const dto = - serializeOptions?.type ?? - requestOptions.model.type ?? - CrudInvalidResponseDto; - - // determine pagination dto - const paginatedDto = - serializeOptions?.paginatedType ?? - requestOptions.model.paginatedType ?? - CrudResponsePaginatedDto; - - // dto meta options - const dtoMetaOptions: ApiResponseMetadata = {}; - - // dto schema options - let dtoSchemaOptions: ApiResponseSchemaHost = { schema: {} }; - - // action is the discriminator - switch (action) { - // read all - case CrudActions.ReadAll: - dtoSchemaOptions = createReadAllResponse({ - action: CrudActions.ReadAll, - modelName: requestOptions.model.type.name, - dto, - paginatedDto, - }); - break; - - // create many - case CrudActions.CreateMany: - dtoSchemaOptions.schema = createArraySchema(dto); - break; - - // returns deleted item or empty - case CrudActions.DeleteOne: - dtoMetaOptions.type = - requestOptions.routes?.deleteOne?.returnDeleted === true - ? dto - : undefined; - break; - - // returns recovered item or empty - case CrudActions.RecoverOne: - dtoMetaOptions.type = - requestOptions.routes?.recoverOne?.returnRecovered === true - ? dto - : undefined; - break; - - // returns one item - case CrudActions.ReadOne: - case CrudActions.CreateOne: - case CrudActions.UpdateOne: - case CrudActions.ReplaceOne: - default: - dtoMetaOptions.type = dto; - break; - } - - // merge the options - const mergedOptions: ApiResponseOptions = { - status: HttpStatus.OK, - description: `${action} ${requestOptions.model.type.name}`, - ...dtoMetaOptions, - ...dtoSchemaOptions, - ...options, - }; - - ApiExtraModels(paginatedDto)(target, ...rest); - ApiResponse(mergedOptions)(target, ...rest); - }; -} - -// -// private routines -// - -function createArraySchema(dto: Type): ApiResponseSchemaHost['schema'] { - return { - type: 'array', - items: { - $ref: getSchemaPath(dto), - }, - }; -} - -function createPaginatedSchema( - paginatedDto: Type, -): ApiResponseSchemaHost['schema'] { - return { - $ref: getSchemaPath(paginatedDto), - }; -} - -function createPaginatedResponse(options: { - action: CrudActions; - modelName: string; - paginatedDto: Type; -}): ApiResponseSchemaHost { - return { - description: `${options.action} ${options.modelName} as paginated response.`, - schema: createPaginatedSchema(options.paginatedDto), - }; -} - -function createReadAllResponse(options: { - action: CrudActions; - modelName: string; - dto: Type; - paginatedDto: Type; -}): ApiResponseSchemaHost { - // always use paginated type - return createPaginatedResponse(options); -} diff --git a/packages/nestjs-crud/src/crud/dto/crud-create-many.dto.ts b/packages/nestjs-crud/src/crud/dto/crud-create-many.dto.ts deleted file mode 100644 index 0e6de42b8..000000000 --- a/packages/nestjs-crud/src/crud/dto/crud-create-many.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray, ValidateNested } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CrudCreateManyInterface } from '../interfaces/crud-create-many.interface'; - -import { CrudInvalidMutationDto } from './crud-invalid-mutation.dto'; - -@Exclude() -export class CrudCreateManyDto implements CrudCreateManyInterface { - @Expose() - @ApiProperty({ type: CrudInvalidMutationDto, isArray: true }) - @IsArray() - @ArrayNotEmpty() - @ValidateNested({ each: true }) - @Type(() => CrudInvalidMutationDto) - bulk: T[] = []; -} diff --git a/packages/nestjs-crud/src/crud/dto/crud-invalid-mutation.dto.ts b/packages/nestjs-crud/src/crud/dto/crud-invalid-mutation.dto.ts deleted file mode 100644 index 88bb36124..000000000 --- a/packages/nestjs-crud/src/crud/dto/crud-invalid-mutation.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { InternalServerErrorException } from '@nestjs/common'; - -export class CrudInvalidMutationDto { - constructor() { - throw new InternalServerErrorException( - 'Fell back to default mutation DTO, this is a security issue.', - ); - } -} diff --git a/packages/nestjs-crud/src/crud/dto/crud-invalid-response.dto.ts b/packages/nestjs-crud/src/crud/dto/crud-invalid-response.dto.ts deleted file mode 100644 index 17c6ae120..000000000 --- a/packages/nestjs-crud/src/crud/dto/crud-invalid-response.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { InternalServerErrorException } from '@nestjs/common'; - -@Exclude() -export class CrudInvalidResponseDto { - constructor() { - throw new InternalServerErrorException( - 'Fell back to default response DTO, this is a security issue.', - ); - } -} diff --git a/packages/nestjs-crud/src/crud/dto/crud-response-paginated.dto.ts b/packages/nestjs-crud/src/crud/dto/crud-response-paginated.dto.ts deleted file mode 100644 index b0f5c0be8..000000000 --- a/packages/nestjs-crud/src/crud/dto/crud-response-paginated.dto.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CrudResponsePaginatedInterface } from '../interfaces/crud-response-paginated.interface'; - -import { CrudInvalidResponseDto } from './crud-invalid-response.dto'; - -@Exclude() -export class CrudResponsePaginatedDto - implements CrudResponsePaginatedInterface -{ - @Expose() - @ApiProperty({ - type: CrudInvalidResponseDto, - isArray: true, - description: 'The list of records for current page', - }) - @Type(() => CrudInvalidResponseDto) - data: T[] = []; - - @Expose() - @ApiProperty({ type: 'number', description: 'Limit number of items' }) - limit = 0; - - @Expose() - @ApiProperty({ type: 'number', description: 'Count of all records' }) - count = 0; - - @Expose() - @ApiProperty({ - type: 'number', - description: 'Count of records on current page', - }) - total = 0; - - @Expose() - @ApiProperty({ type: 'number', description: 'Current page number' }) - page = 0; - - @Expose() - @ApiProperty({ type: 'number', description: 'Total number of pages' }) - pageCount = 0; -} diff --git a/packages/nestjs-crud/src/crud/enums/crud-actions.enum.ts b/packages/nestjs-crud/src/crud/enums/crud-actions.enum.ts deleted file mode 100644 index bb82f9898..000000000 --- a/packages/nestjs-crud/src/crud/enums/crud-actions.enum.ts +++ /dev/null @@ -1,11 +0,0 @@ -export enum CrudActions { - ReadAll = 'Read-All', - ReadOne = 'Read-One', - CreateOne = 'Create-One', - CreateMany = 'Create-Many', - UpdateOne = 'Update-One', - ReplaceOne = 'Replace-One', - DeleteOne = 'Delete-One', - DeleteAll = 'Delete-All', - RecoverOne = 'Recover-One', -} diff --git a/packages/nestjs-crud/src/crud/helpers/swagger.helper.ts b/packages/nestjs-crud/src/crud/helpers/swagger.helper.ts deleted file mode 100644 index 642f0c4d4..000000000 --- a/packages/nestjs-crud/src/crud/helpers/swagger.helper.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; -import { isString } from '@nestjs/common/utils/shared.utils'; - -import { CrudRequestQueryBuilder } from '../../request/crud-request-query.builder'; -import { CrudOptionsInterface } from '../interfaces/crud-options.interface'; -import { CrudRouteName } from '../types/crud-route-name.type'; -import { safeRequire } from '../util'; - -export const swagger = safeRequire('@nestjs/swagger', () => - // eslint-disable-next-line @typescript-eslint/no-require-imports - require('@nestjs/swagger'), -); -export const swaggerConst = safeRequire('@nestjs/swagger/dist/constants', () => - // eslint-disable-next-line @typescript-eslint/no-require-imports - require('@nestjs/swagger/dist/constants'), -); -export const swaggerPkgJson = safeRequire('@nestjs/swagger/package.json', () => - // eslint-disable-next-line @typescript-eslint/no-require-imports - require('@nestjs/swagger/package.json'), -); - -export class Swagger { - static createQueryParamsMeta( - name: CrudRouteName, - options: CrudOptionsInterface, - ) { - /* istanbul ignore if */ - if (!swaggerConst) { - return []; - } - - const { - fields, - search, - filter, - or, - join, - sort, - limit, - offset, - page, - cache, - includeDeleted, - } = Swagger.getQueryParamsNames(); - const oldVersion = Swagger.getSwaggerVersion() < 4; - const docsLink = (a: string) => - `Docs`; - - const fieldsMetaBase = { - name: fields, - description: `Selects resource fields. ${docsLink('select')}`, - required: false, - in: 'query', - }; - const fieldsMeta = oldVersion - ? /* istanbul ignore next */ { - ...fieldsMetaBase, - type: 'array', - items: { - type: 'string', - }, - collectionFormat: 'csv', - } - : { - ...fieldsMetaBase, - schema: { - type: 'array', - items: { - type: 'string', - }, - }, - style: 'form', - explode: false, - }; - - const searchMetaBase = { - name: search, - description: `Adds search condition. ${docsLink('search')}`, - required: false, - in: 'query', - }; - const searchMeta = oldVersion - ? /* istanbul ignore next */ { ...searchMetaBase, type: 'string' } - : { ...searchMetaBase, schema: { type: 'string' } }; - - const filterMetaBase = { - name: filter, - description: `Adds filter condition. ${docsLink('filter')}`, - required: false, - in: 'query', - }; - const filterMeta = oldVersion - ? /* istanbul ignore next */ { - ...filterMetaBase, - items: { - type: 'string', - }, - type: 'array', - collectionFormat: 'multi', - } - : { - ...filterMetaBase, - schema: { - type: 'array', - items: { - type: 'string', - }, - }, - style: 'form', - explode: true, - }; - - const orMetaBase = { - name: or, - description: `Adds OR condition. ${docsLink('or')}`, - required: false, - in: 'query', - }; - const orMeta = oldVersion - ? /* istanbul ignore next */ { - ...orMetaBase, - items: { - type: 'string', - }, - type: 'array', - collectionFormat: 'multi', - } - : { - ...orMetaBase, - schema: { - type: 'array', - items: { - type: 'string', - }, - }, - style: 'form', - explode: true, - }; - - const sortMetaBase = { - name: sort, - description: `Adds sort by field. ${docsLink('sort')}`, - required: false, - in: 'query', - }; - const sortMeta = oldVersion - ? /* istanbul ignore next */ { - ...sortMetaBase, - items: { - type: 'string', - }, - type: 'array', - collectionFormat: 'multi', - } - : { - ...sortMetaBase, - schema: { - type: 'array', - items: { - type: 'string', - }, - }, - style: 'form', - explode: true, - }; - - const joinMetaBase = { - name: join, - description: `Adds relational resources. ${docsLink('join')}`, - required: false, - in: 'query', - }; - const joinMeta = oldVersion - ? /* istanbul ignore next */ { - ...joinMetaBase, - items: { - type: 'string', - }, - type: 'array', - collectionFormat: 'multi', - } - : { - ...joinMetaBase, - schema: { - type: 'array', - items: { - type: 'string', - }, - }, - style: 'form', - explode: true, - }; - - const limitMetaBase = { - name: limit, - description: `Limit amount of resources. ${docsLink('limit')}`, - required: false, - in: 'query', - }; - const limitMeta = oldVersion - ? /* istanbul ignore next */ { ...limitMetaBase, type: 'integer' } - : { ...limitMetaBase, schema: { type: 'integer' } }; - - const offsetMetaBase = { - name: offset, - description: `Offset amount of resources. ${docsLink('offset')}`, - required: false, - in: 'query', - }; - const offsetMeta = oldVersion - ? /* istanbul ignore next */ { ...offsetMetaBase, type: 'integer' } - : { ...offsetMetaBase, schema: { type: 'integer' } }; - - const pageMetaBase = { - name: page, - description: `Page portion of resources. ${docsLink('page')}`, - required: false, - in: 'query', - }; - const pageMeta = oldVersion - ? /* istanbul ignore next */ { ...pageMetaBase, type: 'integer' } - : { ...pageMetaBase, schema: { type: 'integer' } }; - - const cacheMetaBase = { - name: cache, - description: `Reset cache (if was enabled). ${docsLink('cache')}`, - required: false, - in: 'query', - }; - const cacheMeta = oldVersion - ? /* istanbul ignore next */ { - ...cacheMetaBase, - type: 'integer', - minimum: 0, - maximum: 1, - } - : { - ...cacheMetaBase, - schema: { type: 'integer', minimum: 0, maximum: 1 }, - }; - - const includeDeletedMetaBase = { - name: includeDeleted, - description: `Include deleted. ${docsLink('includeDeleted')}`, - required: false, - in: 'query', - }; - const includeDeletedMeta = oldVersion - ? /* istanbul ignore next */ { - ...includeDeletedMetaBase, - type: 'integer', - minimum: 0, - maximum: 1, - } - : { - ...includeDeletedMetaBase, - schema: { type: 'integer', minimum: 0, maximum: 1 }, - }; - - switch (name) { - case 'getMany': - return options.query?.softDelete - ? [ - fieldsMeta, - searchMeta, - filterMeta, - orMeta, - sortMeta, - joinMeta, - limitMeta, - offsetMeta, - pageMeta, - cacheMeta, - includeDeletedMeta, - ] - : [ - fieldsMeta, - searchMeta, - filterMeta, - orMeta, - sortMeta, - joinMeta, - limitMeta, - offsetMeta, - pageMeta, - cacheMeta, - ]; - case 'getOne': - return options.query?.softDelete - ? [fieldsMeta, joinMeta, cacheMeta, includeDeletedMeta] - : [fieldsMeta, joinMeta, cacheMeta]; - default: - return []; - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static getQueryParamsNames(): any { - const qbOptions = CrudRequestQueryBuilder.getOptions(); - const name = (n: string) => { - if (qbOptions?.paramNamesMap) { - const selected = qbOptions?.paramNamesMap[n]; - return isString(selected) ? selected : selected[0]; - } else { - return; - } - }; - - return { - delim: qbOptions.delim, - delimStr: qbOptions.delimStr, - fields: name('fields'), - search: name('search'), - filter: name('filter'), - or: name('or'), - sort: name('sort'), - limit: name('limit'), - offset: name('offset'), - page: name('page'), - cache: name('cache'), - includeDeleted: name('includeDeleted'), - }; - } - - private static getSwaggerVersion(): number { - return swaggerPkgJson - ? parseInt(swaggerPkgJson.version[0], 10) - : /* istanbul ignore next */ 3; - } -} diff --git a/packages/nestjs-crud/src/crud/interceptors/crud-request.interceptor.e2e-spec.ts b/packages/nestjs-crud/src/crud/interceptors/crud-request.interceptor.e2e-spec.ts deleted file mode 100644 index 1d21a724f..000000000 --- a/packages/nestjs-crud/src/crud/interceptors/crud-request.interceptor.e2e-spec.ts +++ /dev/null @@ -1,209 +0,0 @@ -import supertest from 'supertest'; - -import { Param, ParseIntPipe, Query, UseInterceptors } from '@nestjs/common'; -import { NestApplication } from '@nestjs/core'; -import { Test } from '@nestjs/testing'; - -import { TestCrudAdapter } from '../../__fixtures__/crud/adapters/test-crud.adapter'; -import { TestModelDto } from '../../__fixtures__/crud/models/test.model'; -import { CrudModule } from '../../crud.module'; -import { CrudRequestQueryBuilder } from '../../request/crud-request-query.builder'; -import { - QueryFilterArr, - QuerySortArr, -} from '../../request/types/crud-request-query.types'; -import { CrudGetMany } from '../decorators/actions/crud-get-many.decorator'; -import { CrudGetOne } from '../decorators/actions/crud-get-one.decorator'; -import { CrudController } from '../decorators/controller/crud-controller.decorator'; -import { CrudRequest } from '../decorators/params/crud-request.decorator'; -import { CrudRequestInterface } from '../interfaces/crud-request.interface'; - -import { CrudRequestInterceptor } from './crud-request.interceptor'; - -// tslint:disable:max-classes-per-file -describe('#crud', () => { - @UseInterceptors(CrudRequestInterceptor) - @CrudController({ - path: 'test', - model: { type: TestModelDto }, - params: { - someParam: { field: 'age', type: 'number' }, - }, - serialization: { - toInstanceOptions: { - excludeExtraneousValues: false, - strategy: 'exposeAll', - }, - toPlainOptions: { - excludeExtraneousValues: false, - strategy: 'exposeAll', - }, - }, - }) - class TestController { - @CrudGetMany({ path: '/query' }) - async query(@CrudRequest() req: CrudRequestInterface) { - return req; - } - - @CrudGetMany({ path: '/other' }) - async other(@Query('page', ParseIntPipe) page: number) { - return { page }; - } - - @CrudGetOne({ path: '/other2/:someParam' }) - async routeWithParam(@Param('someParam', ParseIntPipe) p: number) { - return { p }; - } - } - - @CrudController({ - path: 'test2', - model: { type: TestModelDto }, - params: { - id: { field: 'id', type: 'number' }, - someParam: { field: 'age', type: 'number' }, - }, - serialization: { - toInstanceOptions: { - excludeExtraneousValues: false, - strategy: 'exposeAll', - }, - toPlainOptions: { - excludeExtraneousValues: false, - strategy: 'exposeAll', - }, - }, - }) - class Test2Controller { - constructor(public service: TestCrudAdapter) {} - - @UseInterceptors(CrudRequestInterceptor) - @CrudGetOne({ path: 'normal/:id' }) - async normal(@CrudRequest() req: CrudRequestInterface) { - return { filter: req.parsed.paramsFilter }; - } - - @UseInterceptors(CrudRequestInterceptor) - @CrudGetOne({ path: 'other2/:someParam' }) - async routeWithParam(@Param('someParam', ParseIntPipe) p: number) { - return { p }; - } - - @UseInterceptors(CrudRequestInterceptor) - @CrudGetOne({ path: 'other2/:id/twoParams/:someParam' }) - async twoParams( - @CrudRequest() req: CrudRequestInterface, - @Param('someParam', ParseIntPipe) _p: number, - ) { - return { filter: req.parsed.paramsFilter }; - } - } - - let $: ReturnType; - let app: NestApplication; - - beforeAll(async () => { - const module = await Test.createTestingModule({ - imports: [CrudModule.forRoot({})], - providers: [TestCrudAdapter], - controllers: [TestController, Test2Controller], - }).compile(); - app = module.createNestApplication(); - await app.init(); - - $ = supertest(app.getHttpServer()); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('#interceptor', () => { - let qb: CrudRequestQueryBuilder; - - beforeEach(() => { - qb = CrudRequestQueryBuilder.create(); - }); - - it('should working on non-crud controller', async () => { - const page = 2; - const limit = 10; - const fields = ['a', 'b', 'c']; - const sorts: QuerySortArr[] = [ - ['firstName', 'ASC'], - ['lastName', 'DESC'], - ]; - const filters: QueryFilterArr[] = [ - ['id', '$in', [1, 2, 3]], - ['firstName', '$eq', 'John'], - ['lastName', '$notnull'], - ]; - - qb.setPage(page).setLimit(limit); - qb.select(fields); - for (const s of sorts) { - qb.sortBy({ field: s[0], order: s[1] }); - } - for (const f of filters) { - qb.setFilter({ field: f[0], operator: f[1], value: f[2] }); - } - - const res = await $.get('/test/query').query(qb.query()).expect(200); - expect(res.body.parsed).toHaveProperty('page', page); - expect(res.body.parsed).toHaveProperty('limit', limit); - expect(res.body.parsed).toHaveProperty('fields', fields); - expect(res.body.parsed).toHaveProperty('sort'); - for (let i = 0; i < sorts.length; i++) { - expect(res.body.parsed.sort[i]).toHaveProperty('field', sorts[i][0]); - expect(res.body.parsed.sort[i]).toHaveProperty('order', sorts[i][1]); - } - expect(res.body.parsed).toHaveProperty('filter'); - for (let i = 0; i < filters.length; i++) { - expect(res.body.parsed.filter[i]).toHaveProperty( - 'field', - filters[i][0], - ); - expect(res.body.parsed.filter[i]).toHaveProperty( - 'operator', - filters[i][1], - ); - expect(res.body.parsed.filter[i]).toHaveProperty( - 'value', - filters[i][2] || '', - ); - } - }); - - it('should others working', async () => { - const res = await $.get('/test/other') - .query({ page: 2, per_page: 11 }) - .expect(200); - expect(res.body.page).toBe(2); - }); - - it('should parse param', async () => { - const res = await $.get('/test/other2/123').expect(200); - expect(res.body.p).toBe(123); - }); - - it('should parse custom param in crud', async () => { - const res = await $.get('/test2/other2/123').expect(200); - expect(res.body.p).toBe(123); - }); - - it('should parse crud param and custom param', async () => { - const res = await $.get('/test2/other2/1/twoParams/123').expect(200); - expect(res.body.filter).toHaveLength(2); - expect(res.body.filter[0].field).toBe('id'); - expect(res.body.filter[0].value).toBe(1); - }); - - it('should work like before', async () => { - const res = await $.get('/test2/normal/0').expect(200); - expect(res.body.filter).toHaveLength(1); - expect(res.body.filter[0].field).toBe('id'); - expect(res.body.filter[0].value).toBe(0); - }); - }); -}); diff --git a/packages/nestjs-crud/src/crud/interceptors/crud-request.interceptor.ts b/packages/nestjs-crud/src/crud/interceptors/crud-request.interceptor.ts deleted file mode 100644 index eeb3ec29c..000000000 --- a/packages/nestjs-crud/src/crud/interceptors/crud-request.interceptor.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - CallHandler, - ExecutionContext, - Injectable, - NestInterceptor, - PlainLiteralObject, -} from '@nestjs/common'; - -import { CRUD_MODULE_CRUD_REQUEST_KEY } from '../../crud.constants'; -import { CrudRequestException } from '../../exceptions/crud-request.exception'; -import { CrudRequestQueryParser } from '../../request/crud-request-query.parser'; -import { CrudReflectionService } from '../../services/crud-reflection.service'; -import { CrudOptionsInterface } from '../interfaces/crud-options.interface'; -import { CrudRequestInterface } from '../interfaces/crud-request.interface'; - -@Injectable() -export class CrudRequestInterceptor< - T extends PlainLiteralObject = PlainLiteralObject, -> implements NestInterceptor -{ - constructor(private reflectionService: CrudReflectionService) {} - - intercept(context: ExecutionContext, next: CallHandler) { - const req = context.switchToHttp().getRequest(); - - try { - if (!req[CRUD_MODULE_CRUD_REQUEST_KEY]) { - const options = this.reflectionService.getRequestOptions( - context.getClass(), - context.getHandler(), - ); - - const parser = CrudRequestQueryParser.create(); - - parser.parseQuery(req.query); - - // Parse route parameters if they exist and are configured - if (req.params) { - parser.parseParams(req.params, options.params ?? {}); - } - - req[CRUD_MODULE_CRUD_REQUEST_KEY] = this.getCrudRequest( - parser, - options, - ); - } - - return next.handle(); - } catch (error) { - throw new CrudRequestException({ - originalError: error, - }); - } - } - - getCrudRequest( - parser: CrudRequestQueryParser, - crudOptions: Partial>, - ): CrudRequestInterface { - const parsed = parser.getParsed(); - const { query, routes, params } = crudOptions; - - return { - parsed, - options: { - query, - routes, - params, - }, - }; - } -} diff --git a/packages/nestjs-crud/src/crud/interceptors/crud-serialize.interceptor.ts b/packages/nestjs-crud/src/crud/interceptors/crud-serialize.interceptor.ts deleted file mode 100644 index 905d91bee..000000000 --- a/packages/nestjs-crud/src/crud/interceptors/crud-serialize.interceptor.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - instanceToPlain, - plainToInstance, - ClassTransformOptions, -} from 'class-transformer'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; - -import { - CallHandler, - ExecutionContext, - Inject, - NestInterceptor, - PlainLiteralObject, - StreamableFile, - Type, -} from '@nestjs/common'; -import { isFunction, isObject } from '@nestjs/common/utils/shared.utils'; - -import { CRUD_MODULE_SETTINGS_TOKEN } from '../../crud.constants'; -import { CrudException } from '../../exceptions/crud.exception'; -import { CrudModuleSettingsInterface } from '../../interfaces/crud-module-settings.interface'; -import { CrudReflectionService } from '../../services/crud-reflection.service'; -import { crudIsPaginatedHelper } from '../../util/crud-is-paginated.helper'; -import { CrudInvalidResponseDto } from '../dto/crud-invalid-response.dto'; -import { CrudResponsePaginatedDto } from '../dto/crud-response-paginated.dto'; -import { CrudResponsePaginatedInterface } from '../interfaces/crud-response-paginated.interface'; -import { CrudSerializationOptionsInterface } from '../interfaces/crud-serialization-options.interface'; - -type ResponseType = - | (PlainLiteralObject & CrudResponsePaginatedInterface) - | Array; - -export class CrudSerializeInterceptor< - T extends PlainLiteralObject = PlainLiteralObject, -> implements NestInterceptor -{ - constructor( - @Inject(CRUD_MODULE_SETTINGS_TOKEN) - private settings: CrudModuleSettingsInterface, - private reflectionService: CrudReflectionService, - ) {} - - /** - * @internal - */ - intercept(context: ExecutionContext, next: CallHandler): Observable { - // get the options - const options = this.getOptions(context); - - // serialize the response - return next - .handle() - .pipe(map((response: ResponseType) => this.serialize(response, options))); - } - - /** - * @internal - */ - protected serialize( - response: ResponseType, - options: CrudSerializationOptionsInterface, - ) { - // reasons to bail - if (!isObject(response) || response instanceof StreamableFile) { - // return response untouched - return response; - } - - // determine the type to use - const type = - !Array.isArray(response) && crudIsPaginatedHelper(response) === true - ? options?.paginatedType - : options?.type; - - // must have a dto type - if (type !== undefined && isFunction(type)) { - // convert each object to DTO type, then convert back to plain object - return this.toPlain( - this.toInstance(type, response, options?.toInstanceOptions), - options?.toPlainOptions, - ); - } else { - // this should never happen, but needed just in - // case somebody removes the defaults - throw new CrudException({ - message: 'Impossible to serialize data without a DTO type.', - }); - } - } - - protected toInstance( - type: Type, - targetObject: ResponseType, - options?: ClassTransformOptions, - ): Type { - return plainToInstance(type, targetObject, options); - } - - protected toPlain( - instance: Type, - options?: ClassTransformOptions, - ): Record { - return instanceToPlain(instance, options); - } - - protected getOptions( - context: ExecutionContext, - ): CrudSerializationOptionsInterface { - // get serialization options - const options = - this.reflectionService.getAllSerializationOptions( - context.getClass(), - context.getHandler(), - ) ?? {}; - - // get model options - const modelOptions = this.reflectionService.getAllModelOptions( - context.getClass(), - context.getHandler(), - ); - - // is the type missing? - if (!options?.type) { - // yes, set it - options.type = modelOptions.type ?? CrudInvalidResponseDto; - } - - // is the many type missing? - if (!options?.paginatedType) { - // yes, set it - options.paginatedType = - modelOptions.paginatedType ?? CrudResponsePaginatedDto; - } - - return { - ...options, - toInstanceOptions: { - ...(this.settings?.serialization?.toInstanceOptions ?? {}), - ...(options.toInstanceOptions ?? {}), - }, - toPlainOptions: { - ...(this.settings?.serialization?.toPlainOptions ?? {}), - ...(options.toPlainOptions ?? {}), - }, - }; - } -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-api-param-metadata.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-api-param-metadata.interface.ts deleted file mode 100644 index 22dd120fc..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-api-param-metadata.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ApiParamOptions } from '@nestjs/swagger'; - -export interface CrudApiParamMetadataInterface { - propertyKey: string | symbol; - options: ApiParamOptions | undefined; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-api-query-metadata.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-api-query-metadata.interface.ts deleted file mode 100644 index e7436b7f7..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-api-query-metadata.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ApiQueryOptions } from '@nestjs/swagger'; - -export interface CrudApiQueryMetadataInterface { - propertyKey: string | symbol; - options: ApiQueryOptions[] | undefined; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-api-response-metadata.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-api-response-metadata.interface.ts deleted file mode 100644 index 0c4e79217..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-api-response-metadata.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiResponseOptions } from '@nestjs/swagger'; - -import { CrudActions } from '../enums/crud-actions.enum'; - -export interface CrudApiResponseMetadataInterface { - propertyKey: string | symbol; - action: CrudActions; - options: ApiResponseOptions | undefined; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-body-options.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-body-options.interface.ts deleted file mode 100644 index 8047bb089..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-body-options.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Body, PlainLiteralObject } from '@nestjs/common'; - -import { CrudValidationOptions } from '../../crud.types'; - -export interface CrudBodyOptionsInterface< - T extends PlainLiteralObject = PlainLiteralObject, -> { - validation?: CrudValidationOptions; - pipes?: Parameters[1][]; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-controller-options.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-controller-options.interface.ts deleted file mode 100644 index 8894de347..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-controller-options.interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ControllerOptions, PlainLiteralObject } from '@nestjs/common'; - -import { CrudValidationOptions } from '../../crud.types'; - -import { CrudModelOptionsInterface } from './crud-model-options.interface'; -import { CrudParamsOptionsInterface } from './crud-params-options.interface'; -import { CrudSerializationOptionsInterface } from './crud-serialization-options.interface'; - -export interface CrudControllerOptionsInterface - extends ControllerOptions { - model: CrudModelOptionsInterface; - params?: CrudParamsOptionsInterface; - validation?: CrudValidationOptions; - serialization?: CrudSerializationOptionsInterface; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-controller.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-controller.interface.ts deleted file mode 100644 index 358db2927..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-controller.interface.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { DeepPartial } from '@concepta/nestjs-common'; - -import { AdditionalCrudMethodArgs } from '../../crud.types'; - -import { CrudCreateManyInterface } from './crud-create-many.interface'; -import { CrudRequestInterface } from './crud-request.interface'; -import { CrudResponsePaginatedInterface } from './crud-response-paginated.interface'; - -export interface CrudControllerInterface< - Entity extends PlainLiteralObject, - Creatable extends DeepPartial = DeepPartial, - Updatable extends DeepPartial = DeepPartial, - Replaceable extends Creatable = Creatable, -> { - getMany?( - crudRequest: CrudRequestInterface, - ...rest: AdditionalCrudMethodArgs - ): Promise>; - - getOne?( - crudRequest: CrudRequestInterface, - ...rest: AdditionalCrudMethodArgs - ): Promise; - - createOne?( - crudRequest: CrudRequestInterface, - dto: Creatable, - ...rest: AdditionalCrudMethodArgs - ): Promise; - - createMany?( - crudRequest: CrudRequestInterface, - dto: CrudCreateManyInterface, - ...rest: AdditionalCrudMethodArgs - ): Promise; - - updateOne?( - crudRequest: CrudRequestInterface, - dto: Updatable, - ...rest: AdditionalCrudMethodArgs - ): Promise; - - replaceOne?( - crudRequest: CrudRequestInterface, - dto: Replaceable, - ...rest: AdditionalCrudMethodArgs - ): Promise; - - deleteOne?( - crudRequest: CrudRequestInterface, - ...rest: AdditionalCrudMethodArgs - ): Promise; - - recoverOne?( - crudRequest: CrudRequestInterface, - ...rest: AdditionalCrudMethodArgs - ): Promise; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-create-many.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-create-many.interface.ts deleted file mode 100644 index f78504761..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-create-many.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface CrudCreateManyInterface { - bulk: T[]; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-extra-decorators.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-extra-decorators.interface.ts deleted file mode 100644 index 3660485c6..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-extra-decorators.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { applyDecorators } from '@nestjs/common'; - -export interface CrudExtraDecoratorsInterface { - extraDecorators?: ReturnType[]; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-model-options.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-model-options.interface.ts deleted file mode 100644 index 00d26fb9f..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-model-options.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Type } from '@nestjs/common'; - -export interface CrudModelOptionsInterface { - type: Type; - paginatedType?: Type; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-options.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-options.interface.ts deleted file mode 100644 index ef58d0a08..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-options.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PlainLiteralObject, ValidationPipeOptions } from '@nestjs/common'; - -import { CrudModelOptionsInterface } from './crud-model-options.interface'; -import { CrudParamsOptionsInterface } from './crud-params-options.interface'; -import { CrudQueryOptionsInterface } from './crud-query-options.interface'; -import { CrudRoutesOptionsInterface } from './crud-routes-options.interface'; - -export interface CrudOptionsInterface { - model: CrudModelOptionsInterface; - query?: CrudQueryOptionsInterface; - routes?: CrudRoutesOptionsInterface; - params?: CrudParamsOptionsInterface; - validation?: ValidationPipeOptions | false; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-param-option.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-param-option.interface.ts deleted file mode 100644 index 012b081ed..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-param-option.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; -import { SwaggerEnumType } from '@nestjs/swagger/dist/types/swagger-enum.type'; - -import { CrudEntityColumn } from '../../crud.types'; -import { ParamOptionType } from '../../request/types/crud-request-param.types'; - -export interface CrudParamOptionInterface { - field?: CrudEntityColumn; - type?: ParamOptionType; - enum?: SwaggerEnumType; - primary?: boolean; - disabled?: boolean; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-params-options.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-params-options.interface.ts deleted file mode 100644 index 54f3cc431..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-params-options.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { CrudParamOptionInterface } from './crud-param-option.interface'; - -export interface CrudParamsOptionsInterface { - [key: string]: CrudParamOptionInterface; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-query-options.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-query-options.interface.ts deleted file mode 100644 index c593a8284..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-query-options.interface.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { - QueryFields, - QuerySort, -} from '../../request/types/crud-request-query.types'; -import { QueryFilterOption } from '../types/query-filter-option.type'; - -import { CrudRelationsInterface } from './crud-relations.interface'; - -export interface CrudQueryOptionsInterface< - T extends PlainLiteralObject, - Relations extends PlainLiteralObject[] = PlainLiteralObject[], -> { - allow?: QueryFields; - exclude?: QueryFields; - persist?: QueryFields; - filter?: QueryFilterOption; - sort?: QuerySort[]; - limit?: number; - maxLimit?: number; - cache?: number | false; - softDelete?: boolean; - relations?: CrudRelationsInterface; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-relations.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-relations.interface.ts deleted file mode 100644 index 8303bc69d..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-relations.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { CrudEntityColumn } from '../../crud.types'; -import { QueryRelation } from '../../request/types/crud-request-query.types'; - -export interface CrudRelationsInterface< - Entity extends PlainLiteralObject, - Relations extends PlainLiteralObject[], -> { - rootKey: CrudEntityColumn; - relations: { - [K in keyof Relations]: QueryRelation; - }; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-request-options.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-request-options.interface.ts deleted file mode 100644 index 2b44a8938..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-request-options.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { CrudParamsOptionsInterface } from './crud-params-options.interface'; -import { CrudQueryOptionsInterface } from './crud-query-options.interface'; -import { CrudRoutesOptionsInterface } from './crud-routes-options.interface'; - -export interface CrudRequestOptionsInterface { - query?: CrudQueryOptionsInterface; - routes?: CrudRoutesOptionsInterface; - params?: CrudParamsOptionsInterface; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-request.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-request.interface.ts deleted file mode 100644 index 6af56b571..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-request.interface.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { CrudRequestParsedParamsInterface } from '../../request/interfaces/crud-request-parsed-params.interface'; - -import { CrudRequestOptionsInterface } from './crud-request-options.interface'; - -export interface CrudRequestInterface< - T extends PlainLiteralObject = PlainLiteralObject, -> { - parsed: CrudRequestParsedParamsInterface; - options: CrudRequestOptionsInterface; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-response-paginated.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-response-paginated.interface.ts deleted file mode 100644 index fa4a19712..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-response-paginated.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CrudResponseMetrics } from './crud-response-metrics.interface'; - -export interface CrudResponsePaginatedInterface { - data: T[]; - limit: number; - count: number; - total: number; - page: number; - pageCount: number; - metrics?: CrudResponseMetrics; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-route-options.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-route-options.interface.ts deleted file mode 100644 index da3f7a68d..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-route-options.interface.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { PlainLiteralObject, Type } from '@nestjs/common'; -import { - ApiBodyOptions, - ApiOperationOptions, - ApiParamOptions, - ApiQueryOptions, - ApiResponseOptions, -} from '@nestjs/swagger'; - -import { CrudValidationOptions } from '../../crud.types'; - -import { - CrudCreateOneRouteOptionsInterface, - CrudDeleteOneRouteOptionsInterface, - CrudRecoverOneRouteOptionsInterface, - CrudReplaceOneRouteOptionsInterface, - CrudUpdateOneRouteOptionsInterface, -} from './crud-routes-options.interface'; -import { CrudSerializationOptionsInterface } from './crud-serialization-options.interface'; - -export interface CrudRouteOptionsInterface { - path?: string | string[]; - validation?: CrudValidationOptions; - serialization?: CrudSerializationOptionsInterface; - api?: { - operation?: ApiOperationOptions; - query?: ApiQueryOptions[]; - params?: ApiParamOptions; - body?: ApiBodyOptions; - response?: ApiResponseOptions; - }; -} - -export interface CrudRouteDtoOptionsInterface { - dto?: Type; -} - -export interface CrudCreateManyOptionsInterface - extends CrudRouteOptionsInterface, - CrudRouteDtoOptionsInterface {} - -export interface CrudCreateOneOptionsInterface - extends CrudRouteOptionsInterface, - CrudCreateOneRouteOptionsInterface, - CrudRouteDtoOptionsInterface {} - -export interface CrudReadAllOptionsInterface - extends CrudRouteOptionsInterface {} - -export interface CrudReadOneOptionsInterface - extends CrudRouteOptionsInterface {} - -export interface CrudUpdateOneOptionsInterface - extends CrudRouteOptionsInterface, - Pick, - CrudRouteDtoOptionsInterface {} - -export interface CrudReplaceOneOptionsInterface - extends CrudRouteOptionsInterface, - Pick, - CrudRouteDtoOptionsInterface {} - -export interface CrudDeleteOneOptionsInterface - extends CrudRouteOptionsInterface, - CrudDeleteOneRouteOptionsInterface {} - -export interface CrudRecoverOneOptionsInterface - extends CrudRouteOptionsInterface, - CrudRecoverOneRouteOptionsInterface {} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-routes-options.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-routes-options.interface.ts deleted file mode 100644 index 40670a2f0..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-routes-options.interface.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface CrudRoutesOptionsInterface { - createOne?: CrudCreateOneRouteOptionsInterface; - updateOne?: CrudUpdateOneRouteOptionsInterface; - replaceOne?: CrudReplaceOneRouteOptionsInterface; - deleteOne?: CrudDeleteOneRouteOptionsInterface; - recoverOne?: CrudRecoverOneRouteOptionsInterface; -} - -export interface CrudCreateOneRouteOptionsInterface { - returnShallow?: boolean; -} - -export interface CrudReplaceOneRouteOptionsInterface { - returnShallow?: boolean; -} - -export interface CrudUpdateOneRouteOptionsInterface { - returnShallow?: boolean; -} - -export interface CrudDeleteOneRouteOptionsInterface { - returnDeleted?: boolean; -} - -export interface CrudRecoverOneRouteOptionsInterface { - returnRecovered?: boolean; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-serialization-options.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-serialization-options.interface.ts deleted file mode 100644 index 815d727e9..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-serialization-options.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ClassTransformOptions } from 'class-transformer'; - -import { Type } from '@nestjs/common'; - -export interface CrudSerializationOptionsInterface { - type?: Type; - paginatedType?: Type; - toInstanceOptions?: ClassTransformOptions; - toPlainOptions?: ClassTransformOptions; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-service-query-options.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-service-query-options.interface.ts deleted file mode 100644 index 0988cf745..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-service-query-options.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { SCondition } from '../../request/types/crud-request-query.types'; - -import { CrudQueryOptionsInterface } from './crud-query-options.interface'; - -export interface CrudServiceQueryOptionsInterface - extends Omit, 'filter'> { - filter?: SCondition; -} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-validation-metadata.interface.ts b/packages/nestjs-crud/src/crud/interfaces/crud-validation-metadata.interface.ts deleted file mode 100644 index 4b2e503e3..000000000 --- a/packages/nestjs-crud/src/crud/interfaces/crud-validation-metadata.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Body, PlainLiteralObject } from '@nestjs/common'; - -import { CrudValidationOptions } from '../../crud.types'; - -export interface CrudValidationMetadataInterface { - propertyKey: string | symbol; - parameterIndex: number; - validation: CrudValidationOptions | undefined; - pipes: Parameters[1][] | []; -} diff --git a/packages/nestjs-crud/src/crud/types/crud-route-name.type.ts b/packages/nestjs-crud/src/crud/types/crud-route-name.type.ts deleted file mode 100644 index c56f96107..000000000 --- a/packages/nestjs-crud/src/crud/types/crud-route-name.type.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type CrudRouteName = - | 'getMany' - | 'getOne' - | 'createOne' - | 'createMany' - | 'updateOne' - | 'replaceOne' - | 'deleteOne' - | 'recoverOne'; diff --git a/packages/nestjs-crud/src/crud/types/query-filter-option.type.ts b/packages/nestjs-crud/src/crud/types/query-filter-option.type.ts deleted file mode 100644 index c801d7196..000000000 --- a/packages/nestjs-crud/src/crud/types/query-filter-option.type.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { - QueryFilter, - SCondition, -} from '../../request/types/crud-request-query.types'; - -export type QueryFilterOption = - | QueryFilter[] - | SCondition; diff --git a/packages/nestjs-crud/src/crud/util.ts b/packages/nestjs-crud/src/crud/util.ts deleted file mode 100644 index b604968fb..000000000 --- a/packages/nestjs-crud/src/crud/util.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { QueryFilter } from '../request/types/crud-request-query.types'; - -export function safeRequire( - path: string, - loader?: () => T, -): T | null { - try { - /* istanbul ignore next */ - // eslint-disable-next-line @typescript-eslint/no-require-imports - const pack = loader ? loader() : require(path); - return pack; - } catch (_) { - /* istanbul ignore next */ - return null; - } -} - -export function queryFilterIsArray( - cond: QueryFilter, -): boolean { - return Array.isArray(cond.value) && cond.value.length > 0; -} diff --git a/packages/nestjs-crud/src/exceptions/crud-federation.exception.ts b/packages/nestjs-crud/src/exceptions/crud-federation.exception.ts deleted file mode 100644 index 3725f7273..000000000 --- a/packages/nestjs-crud/src/exceptions/crud-federation.exception.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -/** - * Federation-specific crud exception. - */ -export class CrudFederationException extends RuntimeException { - constructor(options?: RuntimeExceptionOptions) { - super(options); - this.errorCode = 'CRUD_FEDERATION_ERROR'; - - this.context = { - ...super.context, - }; - } -} diff --git a/packages/nestjs-crud/src/exceptions/crud-method-not-implemented.exception.ts b/packages/nestjs-crud/src/exceptions/crud-method-not-implemented.exception.ts deleted file mode 100644 index c5d015757..000000000 --- a/packages/nestjs-crud/src/exceptions/crud-method-not-implemented.exception.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Type } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { CrudException } from './crud.exception'; - -/** - * Crud method not implemented exception. - */ -export class CrudMethodNotImplementedException< - T extends Type = Type, -> extends CrudException { - constructor( - instance: InstanceType, - method: CallableFunction, - options?: RuntimeExceptionOptions, - ) { - super({ - message: `CRUD controller "%s" method "%s" not implemented`, - messageParams: [instance.constructor.name, method.name], - ...options, - }); - this.errorCode = 'CRUD_METHOD_NOT_IMPLEMENTED_ERROR'; - } -} diff --git a/packages/nestjs-crud/src/exceptions/crud-query.exception.ts b/packages/nestjs-crud/src/exceptions/crud-query.exception.ts deleted file mode 100644 index 14776aa77..000000000 --- a/packages/nestjs-crud/src/exceptions/crud-query.exception.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { CrudException } from './crud.exception'; - -export class CrudQueryException extends CrudException { - context: RuntimeException['context'] & { - entityName: string; - }; - - constructor(entityName: string, options?: RuntimeExceptionOptions) { - super({ - message: 'Error while trying to query the %s entity', - messageParams: [entityName], - ...options, - }); - - this.context = { - ...super.context, - entityName, - }; - - this.errorCode = 'CRUD_QUERY_ERROR'; - } -} diff --git a/packages/nestjs-crud/src/exceptions/crud-request.exception.ts b/packages/nestjs-crud/src/exceptions/crud-request.exception.ts deleted file mode 100644 index df12abe24..000000000 --- a/packages/nestjs-crud/src/exceptions/crud-request.exception.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { CrudException } from './crud.exception'; -/** - * Generic crud exception. - */ -export class CrudRequestException extends CrudException { - constructor(options?: RuntimeExceptionOptions) { - super({ - safeMessage: 'Error on crud request', - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - this.errorCode = 'CRUD_REQUEST_ERROR'; - } -} diff --git a/packages/nestjs-crud/src/exceptions/crud.exception.ts b/packages/nestjs-crud/src/exceptions/crud.exception.ts deleted file mode 100644 index 110af4c14..000000000 --- a/packages/nestjs-crud/src/exceptions/crud.exception.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; -/** - * Generic crud exception. - */ -export class CrudException extends RuntimeException { - constructor(options?: RuntimeExceptionOptions) { - super(options); - this.errorCode = 'CRUD_ERROR'; - - this.context = { - ...super.context, - }; - } -} diff --git a/packages/nestjs-crud/src/index.ts b/packages/nestjs-crud/src/index.ts index b555b978f..f169df51c 100644 --- a/packages/nestjs-crud/src/index.ts +++ b/packages/nestjs-crud/src/index.ts @@ -1,95 +1,158 @@ // the module -export { CrudModule } from './crud.module'; +export { CrudModule } from './crud.module.js'; // interfaces -export { CrudControllerInterface } from './crud/interfaces/crud-controller.interface'; -export { CrudRequestInterface } from './crud/interfaces/crud-request.interface'; -export { CrudFederationFetchOptionsInterface } from './services/interfaces/crud-federation-fetch-options.interface'; -export { CrudFetchServiceInterface } from './services/interfaces/crud-fetch-service.interface'; -export { CrudRelationBindingInterface } from './services/interfaces/crud-relation-binding.interface'; -export { CrudResponsePaginatedInterface } from './crud/interfaces/crud-response-paginated.interface'; -export { CrudResponseMetrics } from './crud/interfaces/crud-response-metrics.interface'; -export { CrudCreateManyInterface } from './crud/interfaces/crud-create-many.interface'; -export { CrudModuleForFeatureOptionsInterface } from './interfaces/crud-module-for-feature-options.interface'; - -export { CrudAdapter } from './crud/adapters/crud.adapter'; -export { TypeOrmCrudAdapter } from './crud/adapters/typeorm-crud.adapter'; - -// utilities -export { createCrudAdapterProvider } from './util/create-crud-adapter-provider'; -export { createCrudServiceProvider } from './util/create-crud-service-provider'; +export { CrudContextInterface } from './infrastructure/interceptors/interfaces/crud-context.interface.js'; +export { CrudParsedQueryInterface } from './infrastructure/request/interfaces/crud-parsed-query.interface.js'; +export { CrudResponsePaginatedInterface } from './infrastructure/interfaces/crud-response-paginated.interface.js'; +export { CrudResponseMetrics } from './infrastructure/interfaces/crud-response-metrics.interface.js'; +export { CrudCreateBatchInterface } from './infrastructure/interfaces/crud-create-batch.interface.js'; +export { CrudModuleForFeatureOptionsInterface } from './infrastructure/config/interfaces/crud-module-for-feature-options.interface.js'; export { - InjectDynamicCrudAdapter, - getDynamicCrudAdapterToken, -} from './util/inject-dynamic-crud-adapter.decorator'; -export { - InjectDynamicCrudService, - getDynamicCrudServiceToken, -} from './util/inject-dynamic-crud-service.decorator'; + CrudControllerClassOptionsInterface, + CrudControllerOptionsInterface, +} from './infrastructure/interfaces/crud-controller-options.interface.js'; +export { CrudRequestConfig } from './infrastructure/request/interfaces/crud-request-config.interface.js'; +export { CrudResponseConfig } from './infrastructure/request/interfaces/crud-response-config.interface.js'; +export { CrudParamOptionInterface } from './infrastructure/interfaces/crud-param-option.interface.js'; +export { CrudParamsOptionsInterface } from './infrastructure/interfaces/crud-params-options.interface.js'; +export { CrudSerializationOptionsInterface } from './infrastructure/interfaces/crud-serialization-options.interface.js'; + +export { CrudAdapter } from './infrastructure/adapters/crud.adapter.js'; + +// types +export { CrudAdapterProvider } from './infrastructure/adapters/interfaces/crud-adapter.types.js'; + +// utility decorators +export { InjectCrudAdapter } from './infrastructure/decorators/util/inject-crud-adapter.decorator.js'; // controller decorators -export { CrudController } from './crud/decorators/controller/crud-controller.decorator'; +export { CrudController } from './infrastructure/decorators/controller/crud-controller.decorator.js'; // route decorators -export { CrudReadAll } from './crud/decorators/actions/crud-read-all.decorator'; -export { CrudReadMany } from './crud/decorators/actions/crud-read-many.decorator'; -export { CrudGetMany } from './crud/decorators/actions/crud-get-many.decorator'; -export { CrudReadOne } from './crud/decorators/actions/crud-read-one.decorator'; -export { CrudGetOne } from './crud/decorators/actions/crud-get-one.decorator'; -export { CrudCreateOne } from './crud/decorators/actions/crud-create-one.decorator'; -export { CrudCreateMany } from './crud/decorators/actions/crud-create-many.decorator'; -export { CrudUpdateOne } from './crud/decorators/actions/crud-update-one.decorator'; -export { CrudReplaceOne } from './crud/decorators/actions/crud-replace-one.decorator'; -export { CrudDeleteOne } from './crud/decorators/actions/crud-delete-one.decorator'; -export { CrudRecoverOne } from './crud/decorators/actions/crud-recover-one.decorator'; +export { CrudList } from './infrastructure/decorators/operations/crud-list.decorator.js'; +export { CrudRead } from './infrastructure/decorators/operations/crud-read.decorator.js'; +export { CrudCreate } from './infrastructure/decorators/operations/crud-create.decorator.js'; +export { CrudCreateBatch } from './infrastructure/decorators/operations/crud-create-batch.decorator.js'; +export { CrudUpdate } from './infrastructure/decorators/operations/crud-update.decorator.js'; +export { CrudReplace } from './infrastructure/decorators/operations/crud-replace.decorator.js'; +export { CrudDelete } from './infrastructure/decorators/operations/crud-delete.decorator.js'; +export { CrudSoftDelete } from './infrastructure/decorators/operations/crud-soft-delete.decorator.js'; +export { CrudRestore } from './infrastructure/decorators/operations/crud-restore.decorator.js'; // route option decorators -export { CrudAction } from './crud/decorators/routes/crud-action.decorator'; -export { CrudAllow } from './crud/decorators/routes/crud-allow.decorator'; -export { CrudCache } from './crud/decorators/routes/crud-cache.decorator'; -export { CrudExclude } from './crud/decorators/routes/crud-exclude.decorator'; -export { CrudFilter } from './crud/decorators/routes/crud-filter.decorator'; -export { CrudLimit } from './crud/decorators/routes/crud-limit.decorator'; -export { CrudMaxLimit } from './crud/decorators/routes/crud-max-limit.decorator'; -export { CrudModel } from './crud/decorators/routes/crud-model.decorator'; -export { CrudParams } from './crud/decorators/routes/crud-params.decorator'; -export { CrudPersist } from './crud/decorators/routes/crud-persist.decorator'; -export { CrudSerialize } from './crud/decorators/routes/crud-serialize.decorator'; -export { CrudSoftDelete } from './crud/decorators/routes/crud-soft-delete.decorator'; -export { CrudSort } from './crud/decorators/routes/crud-sort.decorator'; -export { CrudValidate } from './crud/decorators/routes/crud-validate.decorator'; +export { CrudAllow } from './infrastructure/decorators/routes/crud-allow.decorator.js'; +export { CrudCache } from './infrastructure/decorators/routes/crud-cache.decorator.js'; +export { CrudCommand } from './infrastructure/decorators/routes/crud-command.decorator.js'; +export { CrudCommandHandler } from './infrastructure/decorators/routes/crud-command-handler.decorator.js'; +export { CrudExclude } from './infrastructure/decorators/routes/crud-exclude.decorator.js'; +export { CrudFilter } from './infrastructure/decorators/routes/crud-filter.decorator.js'; +export { CrudJoin } from './infrastructure/decorators/routes/crud-join.decorator.js'; +export { CrudLimit } from './infrastructure/decorators/routes/crud-limit.decorator.js'; +export { CrudMaxLimit } from './infrastructure/decorators/routes/crud-max-limit.decorator.js'; +export { CrudEntity } from './infrastructure/decorators/routes/crud-entity.decorator.js'; +export { CrudName } from './infrastructure/decorators/routes/crud-name.decorator.js'; +export { CrudParams } from './infrastructure/decorators/routes/crud-params.decorator.js'; +export { CrudPersist } from './infrastructure/decorators/routes/crud-persist.decorator.js'; +export { CrudQuery } from './infrastructure/decorators/routes/crud-query.decorator.js'; +export { CrudQueryHandler } from './infrastructure/decorators/routes/crud-query-handler.decorator.js'; +export { CrudRequestBody } from './infrastructure/decorators/routes/crud-request-body.decorator.js'; +export { CrudRequestBodyBatch } from './infrastructure/decorators/routes/crud-request-body-batch.decorator.js'; +export { CrudResponseResource } from './infrastructure/decorators/routes/crud-response-resource.decorator.js'; +export { CrudResponsePaginated } from './infrastructure/decorators/routes/crud-response-paginated.decorator.js'; +export { CrudReturnDeleted } from './infrastructure/decorators/routes/crud-return-deleted.decorator.js'; +export { CrudReturnRestored } from './infrastructure/decorators/routes/crud-return-restored.decorator.js'; +export { CrudSerialize } from './infrastructure/decorators/routes/crud-serialize.decorator.js'; +export { CrudSort } from './infrastructure/decorators/routes/crud-sort.decorator.js'; +export { CrudValidate } from './infrastructure/decorators/routes/crud-validate.decorator.js'; +export { CrudQueryParamsApi } from './infrastructure/decorators/routes/crud-query-params-api.decorator.js'; +// interceptors +export { + CrudContextOverlay, + CrudCtx, +} from './infrastructure/interceptors/crud-context.overlay.js'; // param decorators -export { CrudRequest } from './crud/decorators/params/crud-request.decorator'; -export { CrudBody } from './crud/decorators/params/crud-body.decorator'; +export { CrudBody } from './infrastructure/decorators/params/crud-body.decorator.js'; +export { CrudQueryParams } from './infrastructure/decorators/params/crud-query-params.decorator.js'; // api decorators -export { CrudApiBody } from './crud/decorators/openapi/crud-api-body.decorator'; -export { CrudApiOperation } from './crud/decorators/openapi/crud-api-operation.decorator'; -export { CrudApiParam } from './crud/decorators/openapi/crud-api-param.decorator'; -export { CrudApiQuery } from './crud/decorators/openapi/crud-api-query.decorator'; -export { CrudApiResponse } from './crud/decorators/openapi/crud-api-response.decorator'; - -// classes -export { CrudQueryHelper } from './services/helpers/crud-query.helper'; -export { CrudService } from './services/crud.service'; -export { CrudFederationService } from './services/crud-federation.service'; -export { CrudRelationRegistry } from './services/crud-relation.registry'; -export { CrudBaseController } from './crud/controllers/crud-base.controller'; - -// dto -export { CrudResponsePaginatedDto } from './crud/dto/crud-response-paginated.dto'; -export { CrudCreateManyDto } from './crud/dto/crud-create-many.dto'; +export { CrudApiBody } from './infrastructure/decorators/openapi/crud-api-body.decorator.js'; +export { CrudApiOperation } from './infrastructure/decorators/openapi/crud-api-operation.decorator.js'; +export { CrudApiParam } from './infrastructure/decorators/openapi/crud-api-param.decorator.js'; +export { CrudApiQuery } from './infrastructure/decorators/openapi/crud-api-query.decorator.js'; +export { CrudApiResponse } from './infrastructure/decorators/openapi/crud-api-response.decorator.js'; + +// schemas (Zod / Standard Schema) +export { paginatedSchema } from './infrastructure/schemas/crud-response-paginated.schema.js'; +export { createBatchSchema } from './infrastructure/schemas/crud-create-batch.schema.js'; // exceptions -export { CrudException } from './exceptions/crud.exception'; -export { CrudFederationException } from './exceptions/crud-federation.exception'; -export { CrudMethodNotImplementedException } from './exceptions/crud-method-not-implemented.exception'; -export { CrudRequestException } from './exceptions/crud-request.exception'; -export { CrudQueryException } from './exceptions/crud-query.exception'; +export { CrudException } from './infrastructure/exceptions/crud.exception.js'; +export { CrudContextException } from './infrastructure/exceptions/crud-context.exception.js'; +export { CrudDecoratorException } from './infrastructure/exceptions/crud-decorator.exception.js'; +export { CrudQueryException } from './infrastructure/exceptions/crud-query.exception.js'; // configurable crud builder -export { ConfigurableCrudHost } from './util/interfaces/configurable-crud-host.interface'; -export { ConfigurableCrudOptions } from './util/interfaces/configurable-crud-options.interface'; -export { ConfigurableCrudBuilder } from './util/configurable-crud.builder'; -export { ConfigurableCrudOptionsTransformer } from './crud.types'; +export { + ConfigurableCrudClassesMap, + ConfigurableCrudHost, +} from './infrastructure/utils/interfaces/configurable-crud-host.interface.js'; +export { + ConfigurableCrudClassOptions, + ConfigurableCrudHybridOptions, + ConfigurableCrudGeneratedOptions, + ConfigurableCrudOptions, +} from './infrastructure/utils/interfaces/configurable-crud-options.interface.js'; +export { ConfigurableCrudBuilder } from './infrastructure/utils/configurable-crud.builder.js'; +export { + ConfigurableCrudOptionsTransformer, + CrudSchema, + CrudValidationOptions, +} from './crud.types.js'; + +// operation types +export { CrudOperationOptions } from './infrastructure/utils/crud-operation-options.type.js'; +export { Operation } from '@concepta/nestjs-core'; + +// specifications +export { CrudSpecContextInterface } from './infrastructure/specifications/interfaces/crud-spec-context.interface.js'; +export { CrudSpec } from './infrastructure/specifications/crud-spec.factory.js'; +export { OperationSpecification } from './infrastructure/specifications/operation.specification.js'; +export { ActionSpecification } from './infrastructure/specifications/action.specification.js'; + +// resolvers +export { CrudResolverInterface } from './infrastructure/resolvers/interfaces/crud-resolver.interface.js'; +export { CrudAdapterResolver } from './infrastructure/resolvers/crud-adapter.resolver.js'; +export { CrudOperationResolver } from './infrastructure/resolvers/crud-operation.resolver.js'; +export { CrudCqrsResolver } from './infrastructure/resolvers/crud-cqrs.resolver.js'; +export { CrudResolver } from './infrastructure/decorators/routes/crud-resolver.decorator.js'; + +// operations (queries/commands) +export { CrudListQuery } from './application/queries/impl/crud-list.query.js'; +export { CrudReadQuery } from './application/queries/impl/crud-read.query.js'; +export { CrudCreateCommand } from './application/commands/impl/crud-create.command.js'; +export { CrudCreateBatchCommand } from './application/commands/impl/crud-create-batch.command.js'; +export { CrudUpdateCommand } from './application/commands/impl/crud-update.command.js'; +export { CrudReplaceCommand } from './application/commands/impl/crud-replace.command.js'; +export { CrudDeleteCommand } from './application/commands/impl/crud-delete.command.js'; +export { CrudSoftDeleteCommand } from './application/commands/impl/crud-soft-delete.command.js'; +export { CrudRestoreCommand } from './application/commands/impl/crud-restore.command.js'; +export { CrudWithBodyCommand } from './application/commands/impl/crud-with-body.command.js'; + +// operations (handlers) +export { CrudListHandler } from './application/queries/handlers/crud-list.handler.js'; +export { CrudReadHandler } from './application/queries/handlers/crud-read.handler.js'; +export { CrudCreateHandler } from './application/commands/handlers/crud-create.handler.js'; +export { CrudCreateBatchHandler } from './application/commands/handlers/crud-create-batch.handler.js'; +export { CrudUpdateHandler } from './application/commands/handlers/crud-update.handler.js'; +export { CrudReplaceHandler } from './application/commands/handlers/crud-replace.handler.js'; +export { CrudDeleteHandler } from './application/commands/handlers/crud-delete.handler.js'; +export { CrudSoftDeleteHandler } from './application/commands/handlers/crud-soft-delete.handler.js'; +export { CrudRestoreHandler } from './application/commands/handlers/crud-restore.handler.js'; + +// Base handler classes for consumers to extend when writing custom handlers. +export { CrudCommandBaseHandler } from './application/commands/handlers/crud-command-base.handler.js'; +export { CrudQueryBaseHandler } from './application/queries/handlers/crud-query-base.handler.js'; +export { CrudCommandInterface } from './application/commands/interfaces/crud-command.interface.js'; +export { CrudQueryInterface } from './application/queries/interfaces/crud-query.interface.js'; diff --git a/packages/nestjs-crud/src/infrastructure/adapters/__tests__/crud.adapter.e2e-spec.ts b/packages/nestjs-crud/src/infrastructure/adapters/__tests__/crud.adapter.e2e-spec.ts new file mode 100644 index 000000000..e6444c381 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/adapters/__tests__/crud.adapter.e2e-spec.ts @@ -0,0 +1,1339 @@ +import { Column, Entity } from 'typeorm'; + +import { + BadRequestException, + NotFoundException, + PlainLiteralObject, +} from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ActionEnum, AppContextHost, Operation } from '@concepta/nestjs-core'; +import { + getDynamicRepositoryToken, + RepoCtx, + RepositoryModule, + Where, +} from '@concepta/nestjs-repository'; +import { + CommonSqliteEntity, + TypeOrmRepository, + TypeOrmRepositoryModule, +} from '@concepta/nestjs-repository-typeorm'; + +import { mockCrudParsedQuery } from '../../../__fixtures__/crud/mocks/crud-parsed-query.mock.js'; +import { CompanyEntity } from '../../../__fixtures__/typeorm/company/company.entity.js'; +import { ProjectEntity } from '../../../__fixtures__/typeorm/project/project.entity.js'; +import { UserEntity } from '../../../__fixtures__/typeorm/users/user.entity.js'; +import { CrudCtx } from '../../interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../../interceptors/interfaces/crud-context.interface.js'; +import { CrudAdapter } from '../crud.adapter.js'; + +// ─── Entity ───────────────────────────────────────────────────────────────── + +@Entity() +class TestEntityFixture extends CommonSqliteEntity { + @Column() + firstName!: string; + + @Column({ nullable: true }) + lastName!: string; +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +const ENTITY_TOKEN = 'test-adapter-entity'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function ctx(overrides?: Partial>) { + const host = new AppContextHost(); + host.defineOverlay(RepoCtx, { entity: ENTITY_TOKEN }); + host.defineOverlay(CrudCtx, { + entity: 'TestEntityFixture', + params: {}, + query: mockCrudParsedQuery(), + options: {}, + operation: Operation.List, + action: ActionEnum.READ, + ...overrides, + }); + return host.with(CrudCtx); +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('CrudAdapter (e2e)', () => { + let moduleFixture: TestingModule; + let repository: TypeOrmRepository; + let adapter: CrudAdapter; + + beforeEach(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [TestEntityFixture], + }), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [{ key: ENTITY_TOKEN, entity: TestEntityFixture }], + }), + ], + }).compile(); + + repository = moduleFixture.get>( + getDynamicRepositoryToken(ENTITY_TOKEN), + ); + + adapter = new CrudAdapter(repository); + }); + + afterEach(async () => { + await moduleFixture?.close(); + }); + + async function seed( + data: Partial, + ): Promise { + return repository.create(data); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Metadata accessors + // ═══════════════════════════════════════════════════════════════════════════ + + describe('entityName', () => { + it('should return repository metadata name', () => { + expect(adapter.entityName()).toBe('TestEntityFixture'); + }); + }); + + describe('entityType', () => { + it('should return repository metadata type', () => { + expect(adapter.entityType()).toBe(TestEntityFixture); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // list + // ═══════════════════════════════════════════════════════════════════════════ + + describe('list', () => { + it('should return empty page info when no data', async () => { + const result = await adapter.list(ctx()); + + expect(result).toEqual({ + data: [], + count: 0, + total: 0, + limit: 0, + page: 1, + pageCount: 1, + }); + }); + + it('should return all entities', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list(ctx()); + + expect(result.count).toBe(2); + expect(result.total).toBe(2); + expect(result.data).toHaveLength(2); + }); + + it('should apply limit and offset', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Bob' }); + await seed({ firstName: 'Charlie' }); + + const result = await adapter.list( + ctx({ + query: { ...mockCrudParsedQuery(), limit: 2, offset: 0 }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(3); + expect(result.limit).toBe(2); + }); + + it('should apply sort from query', async () => { + await seed({ firstName: 'Charlie' }); + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + sort: [{ field: 'firstName', order: 'ASC' }], + }, + options: { query: {} }, + }), + ); + + expect(result.data.map((e) => e.firstName)).toEqual([ + 'Alice', + 'Bob', + 'Charlie', + ]); + }); + + it('should fall back to options sort when query sort empty', async () => { + await seed({ firstName: 'Charlie' }); + await seed({ firstName: 'Alice' }); + + const result = await adapter.list( + ctx({ + options: { + query: { sort: [{ field: 'firstName', order: 'ASC' }] }, + }, + }), + ); + + expect(result.data[0].firstName).toBe('Alice'); + }); + + it('should filter by simple equality search', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('firstName', 'Alice')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Alice'); + }); + + it('should filter by $or search', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Bob' }); + await seed({ firstName: 'Charlie' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + or: [ + Where.eq('firstName', 'Alice'), + Where.eq('firstName', 'Charlie'), + ], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(2); + const names = result.data.map((e) => e.firstName).sort(); + expect(names).toEqual(['Alice', 'Charlie']); + }); + + it('should filter by $and search', async () => { + await seed({ firstName: 'Alice', lastName: 'Smith' }); + await seed({ firstName: 'Alice', lastName: 'Jones' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [ + Where.eq('firstName', 'Alice'), + Where.eq('lastName', 'Smith'), + ], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].lastName).toBe('Smith'); + }); + + it('should filter by $or with additional fields ANDed', async () => { + await seed({ firstName: 'Alice', lastName: 'Smith' }); + await seed({ firstName: 'Bob', lastName: 'Smith' }); + await seed({ firstName: 'Alice', lastName: 'Jones' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + search: { + lastName: 'Smith', + $or: [{ firstName: 'Alice' }, { firstName: 'Bob' }], + }, + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(2); + const names = result.data.map((e) => e.firstName).sort(); + expect(names).toEqual(['Alice', 'Bob']); + }); + + it('should filter by $contains operator', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Alicia' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.contains('firstName', 'Ali')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(2); + }); + + it('should filter by $starts operator', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.starts('firstName', 'Ali')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Alice'); + }); + + it('should filter by $ends operator', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Janice' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.ends('firstName', 'ice')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(2); + }); + + it('should filter by $in operator', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Bob' }); + await seed({ firstName: 'Charlie' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.in('firstName', ['Alice', 'Charlie'])], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(2); + }); + + it('should filter by $ne operator', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.ne('firstName', 'Alice')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Bob'); + }); + + it('should filter by $nin operator', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Bob' }); + await seed({ firstName: 'Charlie' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.notIn('firstName', ['Alice', 'Bob'])], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Charlie'); + }); + + it('should filter by $ncontains operator', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Alicia' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.notContains('firstName', 'Ali')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Bob'); + }); + + it('should filter by $null operator', async () => { + await seed({ firstName: 'Alice', lastName: 'Smith' }); + await seed({ firstName: 'Bob' }); // lastName is null + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.isNull('lastName')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Bob'); + }); + + it('should filter by $nnull operator', async () => { + await seed({ firstName: 'Alice', lastName: 'Smith' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.notNull('lastName')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Alice'); + }); + + it('should filter by null value (mapped to IS NULL)', async () => { + await seed({ firstName: 'Alice', lastName: 'Smith' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.isNull('lastName')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Bob'); + }); + + it('should filter by $gt and $lt operators combined', async () => { + await seed({ firstName: 'Alice' }); // version defaults to 1 + const e2 = await seed({ firstName: 'Bob' }); + await repository.update(e2, { firstName: 'Bob2' }); // bumps version to 2 + const e3 = await seed({ firstName: 'Charlie' }); + await repository.update(e3, { firstName: 'Charlie2' }); + await repository.update( + (await repository.findOne({ where: Where.eq('id', e3.id) }))!, + { firstName: 'Charlie3' }, + ); // bumps version to 3 + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.gt('version', 1), Where.lt('version', 3)], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Bob2'); + }); + + it('should filter by $gte operator', async () => { + await seed({ firstName: 'Alice' }); // version 1 + const e2 = await seed({ firstName: 'Bob' }); + await repository.update(e2, { firstName: 'Bob2' }); // version 2 + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.gte('version', 2)], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Bob2'); + }); + + it('should filter by $lte operator', async () => { + await seed({ firstName: 'Alice' }); // version 1 + const e2 = await seed({ firstName: 'Bob' }); + await repository.update(e2, { firstName: 'Bob2' }); // version 2 + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.lte('version', 1)], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Alice'); + }); + + it('should filter by $between operator', async () => { + await seed({ firstName: 'Alice' }); // version 1 + const e2 = await seed({ firstName: 'Bob' }); + await repository.update(e2, { firstName: 'Bob2' }); // version 2 + const e3 = await seed({ firstName: 'Charlie' }); + await repository.update(e3, { firstName: 'Charlie2' }); + await repository.update( + (await repository.findOne({ where: Where.eq('id', e3.id) }))!, + { firstName: 'Charlie3' }, + ); // version 3 + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.between('version', 1, 2)], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(2); + }); + + it('should handle $and with all-empty branches', async () => { + await seed({ firstName: 'Alice' }); + + const result = await adapter.list( + ctx({ + query: mockCrudParsedQuery(), + options: { query: {} }, + }), + ); + + // Empty filter/or arrays produce empty where -> returns all + expect(result.data).toHaveLength(1); + }); + + it('should handle $and with one non-empty branch', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('firstName', 'Alice')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Alice'); + }); + + it('should handle $and with overlapping fields (merge conflict)', async () => { + await seed({ firstName: 'Alice' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [ + Where.contains('firstName', 'A'), + Where.contains('firstName', 'lice'), + ], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Alice'); + }); + + it('should filter by $or inside field operator with single operator', async () => { + await seed({ firstName: 'Alice', lastName: 'Smith' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.isNull('lastName')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].firstName).toBe('Bob'); + }); + + it('should filter by $or inside field operator with multiple operators', async () => { + await seed({ firstName: 'Alice', lastName: 'Smith' }); + await seed({ firstName: 'Bob' }); + + const result = await adapter.list( + ctx({ + query: { + ...mockCrudParsedQuery(), + or: [Where.isNull('lastName'), Where.eq('lastName', 'Smith')], + }, + options: { query: {} }, + }), + ); + + expect(result.data).toHaveLength(2); + }); + + it('should exclude soft-deleted by default', async () => { + const entity = await seed({ firstName: 'Alice' }); + await repository.softDelete(entity); + + const result = await adapter.list(ctx()); + + expect(result.data).toHaveLength(0); + }); + + it('should include soft-deleted when includeDeleted=1', async () => { + const entity = await seed({ firstName: 'Alice' }); + await repository.softDelete(entity); + + const result = await adapter.list( + ctx({ query: { ...mockCrudParsedQuery(), includeDeleted: 1 } }), + ); + + expect(result.data).toHaveLength(1); + }); + + it('should respect exclude query option', async () => { + await seed({ firstName: 'Alice', lastName: 'Smith' }); + + const result = await adapter.list( + ctx({ options: { query: { exclude: ['lastName'] } } }), + ); + + expect(result.data[0].firstName).toBe('Alice'); + }); + + it('should respect allow query option', async () => { + await seed({ firstName: 'Alice' }); + + const result = await adapter.list( + ctx({ options: { query: { allow: ['firstName', 'id'] } } }), + ); + + expect(result.data[0].firstName).toBe('Alice'); + }); + + it('should include persist fields in select', async () => { + await seed({ firstName: 'Alice', lastName: 'Smith' }); + + const result = await adapter.list( + ctx({ + query: { ...mockCrudParsedQuery(), fields: ['firstName'] }, + options: { query: { persist: ['lastName'] } }, + }), + ); + + expect(result.data[0].firstName).toBe('Alice'); + expect(result.data[0].lastName).toBe('Smith'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // read + // ═══════════════════════════════════════════════════════════════════════════ + + describe('read', () => { + it('should return entity when found', async () => { + const entity = await seed({ firstName: 'Alice' }); + + const result = await adapter.read( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + }), + ); + + expect(result.firstName).toBe('Alice'); + }); + + it('should throw NotFoundException when not found', async () => { + await expect( + adapter.read( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', 'nonexistent-id')], + }, + }), + ), + ).rejects.toThrow(NotFoundException); + }); + + it('should include soft-deleted via includeDeleted=1', async () => { + const entity = await seed({ firstName: 'Alice' }); + await repository.softDelete(entity); + + const result = await adapter.read( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + includeDeleted: 1, + }, + }), + ); + + expect(result.firstName).toBe('Alice'); + expect(result.dateDeleted).not.toBeNull(); + }); + + it('should not find soft-deleted without includeDeleted', async () => { + const entity = await seed({ firstName: 'Alice' }); + await repository.softDelete(entity); + + await expect( + adapter.read( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + }), + ), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // create + // ═══════════════════════════════════════════════════════════════════════════ + + describe('create', () => { + it('should create and return entity', async () => { + const result = await adapter.create(ctx(), { firstName: 'Alice' }); + + expect(result.id).toBeDefined(); + expect(result.firstName).toBe('Alice'); + }); + + it('should throw BadRequestException for null dto', async () => { + await expect( + adapter.create(ctx(), null as unknown as PlainLiteralObject), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // createBatch + // ═══════════════════════════════════════════════════════════════════════════ + + describe('createBatch', () => { + it('should create multiple entities', async () => { + const result = await adapter.createBatch(ctx(), { + bulk: [{ firstName: 'Alice' }, { firstName: 'Bob' }], + }); + + expect(result).toHaveLength(2); + const names = result.map((e) => e.firstName).sort(); + expect(names).toEqual(['Alice', 'Bob']); + }); + + it('should throw BadRequestException for empty bulk', async () => { + await expect(adapter.createBatch(ctx(), { bulk: [] })).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException for null dto', async () => { + await expect( + adapter.createBatch( + ctx(), + null as unknown as { bulk: PlainLiteralObject[] }, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when all items are invalid', async () => { + await expect( + adapter.createBatch(ctx(), { + bulk: [null as unknown as PlainLiteralObject], + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // update + // ═══════════════════════════════════════════════════════════════════════════ + + describe('update', () => { + it('should find and update entity', async () => { + const entity = await seed({ firstName: 'Alice', lastName: 'Smith' }); + + const result = await adapter.update( + ctx({ + params: { id: entity.id }, + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + }), + { firstName: 'Bob' }, + ); + + expect(result.firstName).toBe('Bob'); + expect(result.lastName).toBe('Smith'); + }); + + it('should throw NotFoundException when entity not found', async () => { + await expect( + adapter.update( + ctx({ + params: { id: 'nonexistent' }, + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', 'nonexistent')], + }, + }), + { firstName: 'X' }, + ), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // replace + // ═══════════════════════════════════════════════════════════════════════════ + + describe('replace', () => { + it('should find and replace entity', async () => { + const entity = await seed({ firstName: 'Alice', lastName: 'Smith' }); + + const result = await adapter.replace( + ctx({ + params: { id: entity.id }, + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + }), + { firstName: 'Bob', lastName: 'Jones' }, + ); + + expect(result.firstName).toBe('Bob'); + expect(result.lastName).toBe('Jones'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // delete + // ═══════════════════════════════════════════════════════════════════════════ + + describe('delete', () => { + it('should return null when returnDeleted is false', async () => { + const entity = await seed({ firstName: 'Alice' }); + + const result = await adapter.delete( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + }), + ); + + expect(result).toBeNull(); + }); + + it('should return entity when returnDeleted is true', async () => { + const entity = await seed({ firstName: 'Alice' }); + + const result = await adapter.delete( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + options: { route: { returnDeleted: true } }, + }), + ); + + expect(result).not.toBeNull(); + expect(result!.firstName).toBe('Alice'); + }); + + it('should permanently remove entity from database', async () => { + const entity = await seed({ firstName: 'Alice' }); + + await adapter.delete( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + }), + ); + + const found = await repository.findOne({ + where: Where.eq('id', entity.id), + withDeleted: true, + }); + expect(found).toBeNull(); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // softDelete + // ═══════════════════════════════════════════════════════════════════════════ + + describe('softDelete', () => { + it('should return null when returnDeleted is false', async () => { + const entity = await seed({ firstName: 'Alice' }); + + const result = await adapter.softDelete( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + }), + ); + + expect(result).toBeNull(); + }); + + it('should return entity when returnDeleted is true', async () => { + const entity = await seed({ firstName: 'Alice' }); + + const result = await adapter.softDelete( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + options: { route: { returnDeleted: true } }, + }), + ); + + expect(result).not.toBeNull(); + expect(result!.dateDeleted).not.toBeNull(); + }); + + it('should keep entity with dateDeleted set', async () => { + const entity = await seed({ firstName: 'Alice' }); + + await adapter.softDelete( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + }), + ); + + const found = await repository.findOne({ + where: Where.eq('id', entity.id), + withDeleted: true, + }); + expect(found).not.toBeNull(); + expect(found!.dateDeleted).not.toBeNull(); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // restore + // ═══════════════════════════════════════════════════════════════════════════ + + describe('restore', () => { + it('should return null when returnRestored is false', async () => { + const entity = await seed({ firstName: 'Alice' }); + await repository.softDelete(entity); + + const result = await adapter.restore( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + }), + ); + + expect(result).toBeNull(); + }); + + it('should return entity when returnRestored is true', async () => { + const entity = await seed({ firstName: 'Alice' }); + await repository.softDelete(entity); + + const result = await adapter.restore( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + options: { route: { returnRestored: true } }, + }), + ); + + expect(result).not.toBeNull(); + expect(result!.dateDeleted).toBeNull(); + }); + + it('should clear dateDeleted in database', async () => { + const entity = await seed({ firstName: 'Alice' }); + await repository.softDelete(entity); + + await adapter.restore( + ctx({ + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', entity.id)], + }, + }), + ); + + const found = await repository.findOne({ + where: Where.eq('id', entity.id), + }); + expect(found).not.toBeNull(); + expect(found!.dateDeleted).toBeNull(); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// CrudAdapter relations (e2e) +// ═══════════════════════════════════════════════════════════════════════════ + +describe('CrudAdapter relations (e2e)', () => { + const COMPANY_TOKEN = 'relation-company'; + const USER_TOKEN = 'relation-user'; + const PROJECT_TOKEN = 'relation-project'; + + let moduleFixture: TestingModule; + let companyRepo: TypeOrmRepository; + let userRepo: TypeOrmRepository; + let projectRepo: TypeOrmRepository; + let adapter: CrudAdapter; + + function relCtx(overrides?: Partial>) { + const host = new AppContextHost(); + host.defineOverlay(CrudCtx, { + entity: 'CompanyEntity', + params: {}, + query: mockCrudParsedQuery(), + options: {}, + operation: Operation.List, + action: ActionEnum.READ, + ...overrides, + }); + return host.with(CrudCtx); + } + + beforeEach(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [CompanyEntity, UserEntity, ProjectEntity], + }), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: COMPANY_TOKEN, entity: CompanyEntity }, + { key: USER_TOKEN, entity: UserEntity }, + { key: PROJECT_TOKEN, entity: ProjectEntity }, + ], + }), + ], + }).compile(); + + companyRepo = moduleFixture.get>( + getDynamicRepositoryToken(COMPANY_TOKEN), + ); + userRepo = moduleFixture.get>( + getDynamicRepositoryToken(USER_TOKEN), + ); + projectRepo = moduleFixture.get>( + getDynamicRepositoryToken(PROJECT_TOKEN), + ); + + adapter = new CrudAdapter(companyRepo); + }); + + afterEach(async () => { + await moduleFixture?.close(); + }); + + async function seedCompany( + name: string, + domain: string, + ): Promise { + return companyRepo.create({ + name, + domain, + description: `${name} description`, + }); + } + + async function seedUser( + email: string, + companyId: number, + ): Promise { + return userRepo.create({ + email, + isActive: true, + firstName: email.split('@')[0], + lastName: null, + companyId, + }); + } + + async function seedProject( + projectName: string, + companyId: number, + ): Promise { + return projectRepo.create({ name: projectName, companyId }); + } + + function usersRelationCtx(): Partial> { + return { + options: { + query: { + join: [{ relation: 'users', joinType: 'LEFT' }], + }, + }, + }; + } + + function projectsRelationCtx(): Partial> { + return { + options: { + query: { + join: [{ relation: 'projects', joinType: 'LEFT' }], + }, + }, + }; + } + + function bothRelationsCtx(): Partial> { + return { + options: { + query: { + join: [ + { relation: 'users', joinType: 'LEFT' }, + { relation: 'projects', joinType: 'LEFT' }, + ], + }, + }, + }; + } + + describe('list', () => { + it('should populate users with single join', async () => { + const company = await seedCompany('Acme', 'acme.com'); + await seedUser('alice@acme.com', company.id!); + await seedUser('bob@acme.com', company.id!); + + const result = await adapter.list(relCtx(usersRelationCtx())); + + expect(result.data).toHaveLength(1); + expect(result.data[0].users).toHaveLength(2); + const emails = result.data[0].users!.map((u) => u.email).sort(); + expect(emails).toEqual(['alice@acme.com', 'bob@acme.com']); + }); + + it('should populate projects with single join', async () => { + const company = await seedCompany('Acme', 'acme.com'); + await seedProject('Alpha', company.id!); + await seedProject('Beta', company.id!); + + const result = await adapter.list(relCtx(projectsRelationCtx())); + + expect(result.data).toHaveLength(1); + expect(result.data[0].projects).toHaveLength(2); + const names = result.data[0].projects!.map((p) => p.name).sort(); + expect(names).toEqual(['Alpha', 'Beta']); + }); + + it('should populate both users and projects with two joins', async () => { + const company = await seedCompany('Acme', 'acme.com'); + await seedUser('alice@acme.com', company.id!); + await seedProject('Alpha', company.id!); + + const result = await adapter.list(relCtx(bothRelationsCtx())); + + expect(result.data).toHaveLength(1); + expect(result.data[0].users).toHaveLength(1); + expect(result.data[0].users![0].email).toBe('alice@acme.com'); + expect(result.data[0].projects).toHaveLength(1); + expect(result.data[0].projects![0].name).toBe('Alpha'); + }); + + it('should return companies without relations when no config', async () => { + const company = await seedCompany('Acme', 'acme.com'); + await seedUser('alice@acme.com', company.id!); + await seedProject('Alpha', company.id!); + + const result = await adapter.list(relCtx()); + + expect(result.data).toHaveLength(1); + expect(result.data[0].users).toBeUndefined(); + expect(result.data[0].projects).toBeUndefined(); + }); + }); + + describe('read', () => { + it('should return single entity with both relations', async () => { + const company = await seedCompany('Acme', 'acme.com'); + await seedUser('alice@acme.com', company.id!); + await seedProject('Alpha', company.id!); + + const result = await adapter.read( + relCtx({ + ...bothRelationsCtx(), + query: { + ...mockCrudParsedQuery(), + filter: [Where.eq('id', company.id)], + }, + }), + ); + + expect(result.name).toBe('Acme'); + expect(result.users).toHaveLength(1); + expect(result.users![0].email).toBe('alice@acme.com'); + expect(result.projects).toHaveLength(1); + expect(result.projects![0].name).toBe('Alpha'); + }); + }); + + describe('relation sort', () => { + it('should sort by relation field ASC', async () => { + const zebra = await seedCompany('Zebra Inc', 'zebra.com'); + const alpha = await seedCompany('Alpha Corp', 'alpha.com'); + await seedUser('zara@zebra.com', zebra.id!); + await seedUser('alice@alpha.com', alpha.id!); + + const result = await adapter.list( + relCtx({ + ...usersRelationCtx(), + query: mockCrudParsedQuery({ + sort: [{ field: 'email', order: 'ASC', relation: 'users' }], + }), + }), + ); + + expect(result.data).toHaveLength(2); + expect(result.data[0].name).toBe('Alpha Corp'); + expect(result.data[1].name).toBe('Zebra Inc'); + }); + + it('should sort by relation field DESC', async () => { + const zebra = await seedCompany('Zebra Inc', 'zebra.com'); + const alpha = await seedCompany('Alpha Corp', 'alpha.com'); + await seedUser('zara@zebra.com', zebra.id!); + await seedUser('alice@alpha.com', alpha.id!); + + const result = await adapter.list( + relCtx({ + ...usersRelationCtx(), + query: mockCrudParsedQuery({ + sort: [{ field: 'email', order: 'DESC', relation: 'users' }], + }), + }), + ); + + expect(result.data).toHaveLength(2); + expect(result.data[0].name).toBe('Zebra Inc'); + expect(result.data[1].name).toBe('Alpha Corp'); + }); + + it('should combine root sort with relation sort', async () => { + const zebra = await seedCompany('Zebra Inc', 'zebra.com'); + const alpha = await seedCompany('Alpha Corp', 'alpha.com'); + await seedUser('zara@zebra.com', zebra.id!); + await seedUser('alice@alpha.com', alpha.id!); + + const result = await adapter.list( + relCtx({ + ...usersRelationCtx(), + query: mockCrudParsedQuery({ + sort: [ + { field: 'name', order: 'ASC' }, + { field: 'email', order: 'DESC', relation: 'users' }, + ], + }), + }), + ); + + expect(result.data).toHaveLength(2); + expect(result.data[0].name).toBe('Alpha Corp'); + expect(result.data[1].name).toBe('Zebra Inc'); + }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/adapters/__tests__/crud.adapter.spec.ts b/packages/nestjs-crud/src/infrastructure/adapters/__tests__/crud.adapter.spec.ts new file mode 100644 index 000000000..7a039b7f7 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/adapters/__tests__/crud.adapter.spec.ts @@ -0,0 +1,530 @@ +import { BadRequestException } from '@nestjs/common'; + +import { Where, WhereOperator } from '@concepta/nestjs-repository'; +import { createMockRepository } from '@concepta/nestjs-repository/testing'; + +import { TestCrudAdapter } from '../../../__fixtures__/crud/adapters/test-crud.adapter.js'; +import { mockCrudContext } from '../../../__fixtures__/crud/mocks/crud-context.mock.js'; +import { mockCrudParsedQuery } from '../../../__fixtures__/crud/mocks/crud-parsed-query.mock.js'; +import { type CrudContextOptionsInterface } from '../../interceptors/interfaces/crud-context-options.interface.js'; + +interface TestEntity { + id: string; + name: string; + age: number; +} + +class TestEntityClass { + id!: string; + name!: string; + age!: number; +} + +describe('CrudAdapter', () => { + let adapter: TestCrudAdapter; + + beforeAll(() => { + const repo = createMockRepository({ + name: 'TestEntity', + type: TestEntityClass as never, + columns: [ + { name: 'id', isPrimary: true, isRemoveDate: false, isVersion: false }, + { + name: 'name', + isPrimary: false, + isRemoveDate: false, + isVersion: false, + }, + { + name: 'age', + isPrimary: false, + isRemoveDate: false, + isVersion: false, + }, + ], + }); + repo.prepare.mockImplementation((dto) => dto as TestEntity); + adapter = new TestCrudAdapter(repo); + }); + + describe('entityName', () => { + it('should return repository metadata name', () => { + expect(adapter.entityName()).toEqual('TestEntity'); + }); + }); + + describe('entityType', () => { + it('should return repository metadata type', () => { + expect(adapter.entityType()).toEqual(TestEntityClass); + }); + }); + + describe('createPageInfo', () => { + it('should calculate page and pageCount from limit and offset', () => { + const result = adapter.createPageInfo([], 100, 10, 10); + expect(result).toEqual({ + data: [], + limit: 10, + count: 0, + total: 100, + page: 2, + pageCount: 10, + }); + }); + + it('should default limit to 1 and total to 0 when undefined', () => { + const result = adapter.createPageInfo( + [], + undefined, + undefined, + undefined, + ); + expect(result).toEqual({ + data: [], + limit: 1, + count: 0, + total: 0, + page: 1, + pageCount: 1, + }); + }); + + it('should report count as data array length', () => { + const data = [{ id: '1' }, { id: '2' }] as TestEntity[]; + const result = adapter.createPageInfo(data, 50, 10, 0); + expect(result.count).toEqual(2); + expect(result.data).toEqual(data); + }); + + it('should calculate page 1 when offset is 0', () => { + const result = adapter.createPageInfo([], 50, 10, 0); + expect(result.page).toEqual(1); + }); + + it('should report pageCount 1 when total is falsy', () => { + expect(adapter.createPageInfo([], 0, 10, 0).pageCount).toEqual(1); + expect(adapter.createPageInfo([], undefined, 10, 0).pageCount).toEqual(1); + }); + }); + + describe('getTake', () => { + it('should return query.limit when no maxLimit', () => { + expect(adapter.getTake(mockCrudParsedQuery({ limit: 25 }), {})).toEqual( + 25, + ); + }); + + it('should cap query.limit to maxLimit', () => { + expect( + adapter.getTake(mockCrudParsedQuery({ limit: 100 }), { maxLimit: 50 }), + ).toEqual(50); + }); + + it('should return query.limit when under maxLimit', () => { + expect( + adapter.getTake(mockCrudParsedQuery({ limit: 10 }), { maxLimit: 50 }), + ).toEqual(10); + }); + + it('should fall back to options.limit when no query.limit', () => { + expect(adapter.getTake(mockCrudParsedQuery(), { limit: 20 })).toEqual(20); + }); + + it('should cap options.limit to maxLimit', () => { + expect( + adapter.getTake(mockCrudParsedQuery(), { limit: 100, maxLimit: 50 }), + ).toEqual(50); + }); + + it('should return options.limit when under maxLimit', () => { + expect( + adapter.getTake(mockCrudParsedQuery(), { limit: 10, maxLimit: 50 }), + ).toEqual(10); + }); + + it('should return maxLimit when no query.limit and no options.limit', () => { + expect(adapter.getTake(mockCrudParsedQuery(), { maxLimit: 50 })).toEqual( + 50, + ); + }); + + it('should return null when no limits set', () => { + expect(adapter.getTake(mockCrudParsedQuery(), {})).toBeNull(); + }); + }); + + describe('getSkip', () => { + it('should calculate skip from page and take', () => { + expect(adapter.getSkip(mockCrudParsedQuery({ page: 3 }), 10)).toEqual(20); + }); + + it('should return offset when no page', () => { + expect(adapter.getSkip(mockCrudParsedQuery({ offset: 15 }), 10)).toEqual( + 15, + ); + }); + + it('should return null when no page and no offset', () => { + expect(adapter.getSkip(mockCrudParsedQuery(), 10)).toBeNull(); + }); + + it('should return null when page is set but take is null', () => { + expect( + adapter.getSkip(mockCrudParsedQuery({ page: 2 }), null), + ).toBeNull(); + }); + }); + + describe('getPrimaryParams', () => { + it('should return primary param fields', () => { + const options: CrudContextOptionsInterface = { + params: { + id: { field: 'id', type: 'uuid', primary: true }, + }, + }; + expect(adapter.getPrimaryParams(options)).toEqual(['id']); + }); + + it('should exclude non-primary params', () => { + const options: CrudContextOptionsInterface = { + params: { + id: { field: 'id', type: 'uuid', primary: true }, + name: { field: 'name', type: 'string', primary: false }, + }, + }; + expect(adapter.getPrimaryParams(options)).toEqual(['id']); + }); + + it('should return empty array when no params defined', () => { + expect(adapter.getPrimaryParams({})).toEqual([]); + }); + + it('should filter out params with undefined field', () => { + const options: CrudContextOptionsInterface = { + params: { + id: { type: 'uuid', primary: true }, + }, + }; + expect(adapter.getPrimaryParams(options)).toEqual([]); + }); + }); + + describe('getAllowedColumns', () => { + const allColumns: (keyof TestEntity & string)[] = ['id', 'name', 'age']; + + it('should return all columns when no allow or exclude', () => { + expect(adapter.getAllowedColumns(allColumns, {})).toEqual(allColumns); + }); + + it('should return all columns when allow and exclude are empty', () => { + expect( + adapter.getAllowedColumns(allColumns, { allow: [], exclude: [] }), + ).toEqual(allColumns); + }); + + it('should filter to only allowed columns', () => { + expect( + adapter.getAllowedColumns(allColumns, { allow: ['id', 'name'] }), + ).toEqual(['id', 'name']); + }); + + it('should remove excluded columns', () => { + expect( + adapter.getAllowedColumns(allColumns, { exclude: ['age'] }), + ).toEqual(['id', 'name']); + }); + + it('should apply both allow and exclude', () => { + expect( + adapter.getAllowedColumns(allColumns, { + allow: ['id', 'name'], + exclude: ['name'], + }), + ).toEqual(['id']); + }); + }); + + describe('checkFilterIsArray', () => { + it('should return true for non-empty array value', () => { + const cond = { + field: 'id' as keyof TestEntity, + operator: WhereOperator.IN, + value: ['a', 'b'], + }; + expect(adapter.checkFilterIsArray(cond)).toEqual(true); + }); + + it('should throw BadRequestException for empty array', () => { + const cond = { + field: 'id' as keyof TestEntity, + operator: WhereOperator.IN, + value: [], + }; + expect(() => adapter.checkFilterIsArray(cond)).toThrow( + BadRequestException, + ); + }); + }); + + describe('prepareEntityBeforeSave', () => { + it('should return undefined for non-object input', () => { + const ctx = mockCrudContext(); + expect( + adapter.prepareEntityBeforeSave(null as never, ctx), + ).toBeUndefined(); + }); + + it('should return an entity for an empty object (#466)', () => { + const ctx = mockCrudContext(); + expect(adapter.prepareEntityBeforeSave({} as never, ctx)).toEqual({}); + }); + + it('should return entity with dto field values', () => { + const dto = { id: '1', name: 'Test' } as TestEntity; + const ctx = mockCrudContext(); + const result = adapter.prepareEntityBeforeSave(dto, ctx); + expect(result).toBeDefined(); + expect(result?.id).toEqual('1'); + expect(result?.name).toEqual('Test'); + }); + + it('should apply matching route params to entity', () => { + const dto = { id: '1', name: 'Test' } as TestEntity; + const context = mockCrudContext({ params: { id: 'overridden' } }); + const result = adapter.prepareEntityBeforeSave(dto, context); + expect(result).toBeDefined(); + expect(result?.id).toEqual('overridden'); + }); + + it('should apply route params not present in dto (matches update/replace)', () => { + const dto = { name: 'Test' } as TestEntity; + const context = mockCrudContext({ + params: { id: 'from-route' }, + }); + const result = adapter.prepareEntityBeforeSave(dto, context); + expect(result).toBeDefined(); + expect(result?.id).toEqual('from-route'); + expect(result?.name).toEqual('Test'); + }); + }); + + describe('buildWhere', () => { + it('should return undefined when no conditions exist', () => { + const ctx = mockCrudContext(); + expect(adapter.exposedBuildWhere(ctx)).toBeUndefined(); + }); + + it('should convert single param to eq condition', () => { + const ctx = mockCrudContext({ params: { id: '5' } }); + expect(adapter.exposedBuildWhere(ctx)).toEqual(Where.eq('id', '5')); + }); + + it('should combine multiple params with and', () => { + const ctx = mockCrudContext({ params: { id: '1', name: 'test' } }); + expect(adapter.exposedBuildWhere(ctx)).toEqual( + Where.and(Where.eq('id', '1'), Where.eq('name', 'test')), + ); + }); + + it('should combine params and query.filter with and', () => { + const ctx = mockCrudContext({ + params: { id: '5' }, + query: mockCrudParsedQuery({ + filter: [{ field: 'name', operator: WhereOperator.EQ, value: 'foo' }], + }), + }); + expect(adapter.exposedBuildWhere(ctx)).toEqual( + Where.and(Where.eq('id', '5'), Where.eq('name', 'foo')), + ); + }); + + describe('options.query.filter', () => { + it('should convert WhereCondition[] to where conditions', () => { + const ctx = mockCrudContext({ + options: { + query: { + filter: [ + { field: 'name', operator: WhereOperator.EQ, value: 'admin' }, + ], + }, + }, + }); + expect(adapter.exposedBuildWhere(ctx)).toEqual( + Where.eq('name', 'admin'), + ); + }); + + it('should treat non-array truthy filter as SCondition', () => { + const ctx = mockCrudContext({ + options: { + query: { + filter: { name: { $eq: 'admin' } }, + }, + }, + }); + expect(adapter.exposedBuildWhere(ctx)).toEqual( + Where.eq('name', 'admin'), + ); + }); + + it('should ignore empty WhereCondition[] options filter', () => { + const ctx = mockCrudContext({ + options: { query: { filter: [] } }, + }); + expect(adapter.exposedBuildWhere(ctx)).toBeUndefined(); + }); + }); + + describe('query.search', () => { + it('should convert query.search SCondition to where', () => { + const ctx = mockCrudContext({ + query: mockCrudParsedQuery({ + search: { name: { $contains: 'foo' } }, + }), + }); + expect(adapter.exposedBuildWhere(ctx)).toEqual( + Where.contains('name', 'foo'), + ); + }); + + it('should use query.search even when filters are present', () => { + const ctx = mockCrudContext({ + query: mockCrudParsedQuery({ + search: { name: { $contains: 'foo' } }, + filter: [{ field: 'id', operator: WhereOperator.EQ, value: 1 }], + }), + }); + // search takes precedence, filter is ignored + expect(adapter.exposedBuildWhere(ctx)).toEqual( + Where.contains('name', 'foo'), + ); + }); + }); + + describe('query.filter', () => { + it('should convert single filter to where', () => { + const ctx = mockCrudContext({ + query: mockCrudParsedQuery({ + filter: [ + { field: 'name', operator: WhereOperator.EQ, value: 'foo' }, + ], + }), + }); + expect(adapter.exposedBuildWhere(ctx)).toEqual(Where.eq('name', 'foo')); + }); + + it('should convert multiple filters to and where', () => { + const ctx = mockCrudContext({ + query: mockCrudParsedQuery({ + filter: [ + { field: 'name', operator: WhereOperator.EQ, value: 'foo' }, + { field: 'id', operator: WhereOperator.GT, value: 5 }, + ], + }), + }); + expect(adapter.exposedBuildWhere(ctx)).toEqual( + Where.and(Where.eq('name', 'foo'), Where.gt('id', 5)), + ); + }); + }); + + describe('query.or', () => { + it('should convert single or to where', () => { + const ctx = mockCrudContext({ + query: mockCrudParsedQuery({ + or: [{ field: 'name', operator: WhereOperator.EQ, value: 'bar' }], + }), + }); + expect(adapter.exposedBuildWhere(ctx)).toEqual(Where.eq('name', 'bar')); + }); + }); + + describe('filter + or combined', () => { + it('should create or with single filter and single or', () => { + const ctx = mockCrudContext({ + query: mockCrudParsedQuery({ + filter: [ + { field: 'name', operator: WhereOperator.EQ, value: 'foo' }, + ], + or: [{ field: 'name', operator: WhereOperator.EQ, value: 'bar' }], + }), + }); + expect(adapter.exposedBuildWhere(ctx)).toEqual( + Where.or(Where.eq('name', 'foo'), Where.eq('name', 'bar')), + ); + }); + + it('should create or with and groups for multiple filters and ors', () => { + const ctx = mockCrudContext({ + query: mockCrudParsedQuery({ + filter: [ + { field: 'name', operator: WhereOperator.EQ, value: 'foo' }, + { field: 'id', operator: WhereOperator.GT, value: 1 }, + ], + or: [ + { field: 'name', operator: WhereOperator.EQ, value: 'bar' }, + { field: 'id', operator: WhereOperator.LT, value: 10 }, + ], + }), + }); + expect(adapter.exposedBuildWhere(ctx)).toEqual( + Where.or( + Where.and(Where.eq('name', 'foo'), Where.gt('id', 1)), + Where.and(Where.eq('name', 'bar'), Where.lt('id', 10)), + ), + ); + }); + }); + }); + + describe('validateWhereFields', () => { + it('should not throw for undefined clause', () => { + expect(() => adapter.exposedValidateWhereFields(undefined)).not.toThrow(); + }); + + it('should not throw for valid root entity field', () => { + expect(() => + adapter.exposedValidateWhereFields(Where.eq('name', 'foo')), + ).not.toThrow(); + }); + + it('should throw for invalid root entity field', () => { + expect(() => + adapter.exposedValidateWhereFields(Where.eq('unknown', 'foo')), + ).toThrow(BadRequestException); + }); + + it('should skip validation for relation-tagged condition', () => { + const condition = Where.rel('posts', Where.eq('title', 'hello')); + expect(() => adapter.exposedValidateWhereFields(condition)).not.toThrow(); + }); + + it('should validate compound clause recursively', () => { + const clause = Where.and( + Where.eq('name', 'foo'), + Where.eq('invalid', 'bar'), + ); + expect(() => adapter.exposedValidateWhereFields(clause)).toThrow( + BadRequestException, + ); + }); + + it('should pass compound clause mixing relation and valid root conditions', () => { + const clause = Where.and( + Where.eq('name', 'foo'), + Where.rel('posts', Where.eq('title', 'hello')), + ); + expect(() => adapter.exposedValidateWhereFields(clause)).not.toThrow(); + }); + + it('should throw for invalid field inside compound with relation conditions', () => { + const clause = Where.and( + Where.eq('unknown', 'foo'), + Where.rel('posts', Where.eq('title', 'hello')), + ); + expect(() => adapter.exposedValidateWhereFields(clause)).toThrow( + BadRequestException, + ); + }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/adapters/crud.adapter.ts b/packages/nestjs-crud/src/infrastructure/adapters/crud.adapter.ts new file mode 100644 index 000000000..8dce82af2 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/adapters/crud.adapter.ts @@ -0,0 +1,564 @@ +import { + BadRequestException, + NotFoundException, + type PlainLiteralObject, + type Type, +} from '@nestjs/common'; + +import { type DeepPartial, isObject, isUndefined } from '@concepta/nestjs-core'; +import { + type EntityColumn, + isWhereCondition, + type RepositoryFindOneOptions, + type RepositoryFindOptions, + type RepositoryInterface, + Where, + type WhereClause, + type WhereCondition, +} from '@concepta/nestjs-repository'; + +import { type CrudContextOptionsInterface } from '../interceptors/interfaces/crud-context-options.interface.js'; +import { type CrudContextInterface } from '../interceptors/interfaces/crud-context.interface.js'; +import { type CrudCreateBatchInterface } from '../interfaces/crud-create-batch.interface.js'; +import { type CrudParamsOptionsInterface } from '../interfaces/crud-params-options.interface.js'; +import { type CrudResponsePaginatedInterface } from '../interfaces/crud-response-paginated.interface.js'; +import { SConditionConverter } from '../request/crud-scondition.converter.js'; +import { type CrudParsedQueryInterface } from '../request/interfaces/crud-parsed-query.interface.js'; +import { type CrudQueryOptionsInterface } from '../request/interfaces/crud-query-options.interface.js'; +import { queryFilterIsArray } from '../utils/crud-infra.utils.js'; +import { sanitizeForMessage } from '../utils/validation.js'; + +export class CrudAdapter { + protected entityColumns: EntityColumn[] = []; + + protected entityPrimaryColumns: EntityColumn[] = []; + + protected entityHasDeleteColumn = false; + + constructor(protected repository: RepositoryInterface) { + this.initColumnMetadata(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Metadata + // ═══════════════════════════════════════════════════════════════════════════ + + entityName(): string { + return this.repository.metadata.name; + } + + entityType(): Type { + return this.repository.metadata.type; + } + + protected initColumnMetadata(): void { + const { columns } = this.repository.metadata; + + this.entityColumns = columns.map((col) => col.name); + this.entityPrimaryColumns = columns + .filter((col) => col.isPrimary) + .map((col) => col.name); + this.entityHasDeleteColumn = columns.some((col) => col.isRemoveDate); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Pagination helpers + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Wrap page into page-info + * override this method to create custom page-info response + * or set custom `serialize.list` dto in the controller's CrudOption + * + * @param data - array of data to be paginated + * @param total - total number of items in the collection + * @param limit - number of items per page + * @param offset - number of items to skip + */ + createPageInfo( + data: Entity[], + total: number | undefined, + limit: number | undefined, + offset: number | undefined, + ): CrudResponsePaginatedInterface { + return { + data, + limit: limit ?? 1, + count: data.length, + total: total ?? 0, + page: limit ? Math.floor((offset ?? 0) / limit) + 1 : 1, + pageCount: limit && total ? Math.ceil(total / limit) : 1, + }; + } + + /** + * Get number of resources to be fetched + * + * @param query - parsed query parameters + * @param options - query options + */ + getTake( + query: CrudParsedQueryInterface, + options: CrudQueryOptionsInterface, + ): number | null { + if (query.limit) { + return options.maxLimit + ? Math.min(query.limit, options.maxLimit) + : query.limit; + } + + if (options.limit) { + return options.maxLimit + ? Math.min(options.limit, options.maxLimit) + : options.limit; + } + + return options.maxLimit ?? null; + } + + /** + * Get number of resources to be skipped + * + * @param query - parsed query parameters + * @param take - number of resources to be fetched + */ + getSkip( + query: CrudParsedQueryInterface, + take: number | null, + ): number | null { + return query.page && take + ? take * (query.page - 1) + : query.offset + ? query.offset + : null; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Column & param helpers + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Get primary param name from CrudOptions + * + * @param options - crud request options + */ + getPrimaryParams( + options: CrudContextOptionsInterface, + ): EntityColumn[] { + const rawParams: CrudParamsOptionsInterface = options.params ?? {}; + + const params = Object.keys(rawParams).filter( + (n) => rawParams[n] && rawParams[n].primary, + ); + + return params + .map((p) => rawParams[p].field) + .filter( + (field): field is EntityColumn => typeof field === 'string', + ); + } + + getAllowedColumns( + columns: EntityColumn[], + options: CrudQueryOptionsInterface, + ): EntityColumn[] { + const { exclude, allow } = options; + + if (!exclude?.length && !allow?.length) { + return columns; + } + + return columns.filter( + (column) => + (!exclude?.length || !exclude.some((col) => col === column)) && + (!allow?.length || allow.some((col) => col === column)), + ); + } + + /** + * Type guard to check if a string is a valid entity column name. + */ + protected isEntityColumn(key: string): key is keyof Entity & string { + return this.entityColumns.some((col) => col === key); + } + + checkFilterIsArray(cond: WhereCondition): boolean { + if (queryFilterIsArray(cond)) { + return true; + } + + throw new BadRequestException( + `Invalid column '${sanitizeForMessage(cond.field)}' value`, + ); + } + + /** + * Get select fields without alias prefix. + */ + protected getSelectFields( + query: CrudParsedQueryInterface, + options: CrudQueryOptionsInterface, + ): (keyof Entity)[] { + const allowed = this.getAllowedColumns(this.entityColumns, options); + + const columns = + query.fields && query.fields.length + ? query.fields.filter((field) => allowed.some((col) => field === col)) + : allowed; + + const selectArray = [ + ...(options.persist && options.persist.length ? options.persist : []), + ...columns, + ...this.entityPrimaryColumns, + ]; + + const uniqueFields = new Set(selectArray); + return Array.from(uniqueFields); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Entity preparation + // ═══════════════════════════════════════════════════════════════════════════ + + prepareEntityBeforeSave( + dto: DeepPartial, + context: CrudContextInterface, + ): Entity | undefined { + if (!isObject(dto)) { + return undefined; + } + + // Route params always win over the body (e.g. a nested route's FK) — + // matches update()/replace()'s merge below. + const merged = { ...dto, ...context.params }; + + return this.repository.prepare(merged); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // CRUD operations + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Create one entity. + * + * @param context - The CRUD context interface. + * @param dto - The request body containing the entity data to create. + */ + async create( + context: CrudContextInterface, + dto: DeepPartial, + ): Promise { + const entity = this.prepareEntityBeforeSave(dto, context); + + if (!entity) { + throw new BadRequestException( + 'Invalid request body. Expected an object.', + ); + } + + return this.repository.create(entity, { ctx: context }); + } + + /** + * Create many entities in batch. + * + * @param context - The CRUD context interface. + * @param dto - The request body containing the bulk array of entities to create. + * @returns A promise resolving to an array of created entities. + */ + async createBatch( + context: CrudContextInterface, + dto: CrudCreateBatchInterface>, + ): Promise { + if (!isObject(dto) || !Array.isArray(dto.bulk) || !dto.bulk.length) { + throw new BadRequestException('Empty data. Nothing to save.'); + } + + const preparedBulk = dto.bulk.map((one) => + this.prepareEntityBeforeSave(one, context), + ); + + const bulk: Entity[] = preparedBulk.filter( + (d): d is Entity => !isUndefined(d), + ); + + if (!bulk.length) { + throw new BadRequestException('Empty data. Nothing to save.'); + } + + return this.repository.createMany(bulk, { ctx: context }); + } + + /** + * Update one entity. + * + * @param context - The CRUD context interface. + * @param dto - The request body containing the updated entity data. + * @returns A promise resolving to the updated entity. + */ + async update( + context: CrudContextInterface, + dto: DeepPartial, + ): Promise { + const found = await this.getOneOrFail(context); + const data = { ...dto, ...context.params }; + + return this.repository.update(found, data, { ctx: context }); + } + + /** + * Replace one entity. + * + * @param context - The CRUD context interface. + * @param dto - The request body containing the replacement entity data. + * @returns A promise resolving to the replaced entity. + */ + async replace( + context: CrudContextInterface, + dto: DeepPartial, + ): Promise { + const found = await this.getOneOrFail(context); + const data = { ...dto, ...context.params }; + + return this.repository.replace(found, data, { ctx: context }); + } + + /** + * Permanently delete one entity (hard delete). + * + * @param context - The CRUD context interface. + * @returns A promise resolving to the deleted entity, or null if returnDeleted is false. + */ + async delete(context: CrudContextInterface): Promise { + const { returnDeleted = false } = context.options?.route ?? {}; + const found = await this.getOneOrFail(context); + const deleted = await this.repository.delete(found, { ctx: context }); + + return returnDeleted ? deleted : null; + } + + /** + * Soft delete one entity by setting its delete date. + * + * @param context - The CRUD context interface. + * @returns A promise resolving to the soft-deleted entity, or null if returnDeleted is false. + */ + async softDelete( + context: CrudContextInterface, + ): Promise { + const { returnDeleted = false } = context.options?.route ?? {}; + const found = await this.getOneOrFail(context); + const deleted = await this.repository.softDelete(found, { ctx: context }); + + return returnDeleted ? deleted : null; + } + + /** + * Restore one soft-deleted entity. + * + * @param context - The CRUD context interface. + * @returns A promise resolving to the restored entity, or null if returnRestored is false. + */ + async restore(context: CrudContextInterface): Promise { + const { returnRestored = false } = context.options?.route ?? {}; + const found = await this.getOneOrFail(context, true); + const restored = await this.repository.restore(found, { ctx: context }); + + return returnRestored ? restored : null; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Query operations + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * List many entities. + * + * @param context - The CRUD context interface. + */ + async list( + context: CrudContextInterface, + ): Promise> { + const options = this.buildFindOptions(context); + const [data, total] = await this.repository.findAndCount(options); + const limit = options.take ?? total; + const offset = options.skip ?? 0; + + return this.createPageInfo(data, total, limit, offset); + } + + /** + * Read one entity. + * + * @param context - The CRUD context interface. + */ + async read(context: CrudContextInterface): Promise { + return this.getOneOrFail(context); + } + + protected async getOneOrFail( + context: CrudContextInterface, + withDeleted = false, + ): Promise { + const { query } = context; + + // Build and validate where clause from all filter sources + const where = this.buildWhere(context); + this.validateWhereFields(where); + + // Handle soft-delete query inclusion + // includeDeleted=1 query param enables fetching soft-deleted entities + const includeDeleted = + withDeleted || (this.entityHasDeleteColumn && query.includeDeleted === 1); + + const findOptions: RepositoryFindOneOptions = { + ctx: context, + where, + join: context.options?.query?.join, + withDeleted: includeDeleted || undefined, + }; + + const found = await this.repository.findOne(findOptions); + + if (!found) { + throw new NotFoundException(`${this.entityName()} not found`); + } + + return found; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // FindOptions-based query methods + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Build FindManyOptions from CRUD context. + * + * @param context - The CRUD context interface. + * @returns RepositoryFindOptions for repository.findAndCount() + */ + protected buildFindOptions( + context: CrudContextInterface, + ): RepositoryFindOptions { + const { query, options } = context; + const queryOptions = options.query ?? {}; + + // Build and validate where clause from all filter sources + const where = this.buildWhere(context); + this.validateWhereFields(where); + + // Get select fields (without alias prefix) + const select = this.getSelectFields(query, queryOptions); + + // Build order clause + const order = + query.sort.length > 0 ? query.sort : (queryOptions.sort ?? []); + + // Calculate pagination + const take = this.getTake(query, queryOptions); + const skip = this.getSkip(query, take); + + // Handle soft-delete inclusion + // includeDeleted=1 query param enables fetching soft-deleted entities + const withDeleted = + this.entityHasDeleteColumn && query.includeDeleted === 1; + + return { + ctx: context, + where, + join: context.options?.query?.join, + select: select.length > 0 ? select : undefined, + order: order.length > 0 ? order : undefined, + take: take || undefined, + skip: skip || undefined, + withDeleted: withDeleted || undefined, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Where clause building & validation + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Build WhereClause from all filter sources in the context. + * + * Combines: route params, options.query.filter, query.search, query.filter/or + * into a single WhereClause for the repository layer. + */ + protected buildWhere( + context: CrudContextInterface, + ): WhereClause | undefined { + const { query, options, params } = context; + const clauses: WhereClause[] = []; + + // 1. Route params -> Where.eq(field, value) each + for (const [field, value] of Object.entries(params)) { + clauses.push(Where.eq(field, value)); + } + + // 2. options.query.filter -> WhereCondition[] or SCondition + const optionsFilter = options?.query?.filter; + if (optionsFilter) { + if (Array.isArray(optionsFilter)) { + clauses.push(...optionsFilter); + } else { + const clause = SConditionConverter.convert(optionsFilter); + if (clause) clauses.push(clause); + } + } + + // 3. query.search (mutually exclusive with filter/or per parser) + if (query.search) { + const clause = SConditionConverter.convert(query.search); + if (clause) clauses.push(clause); + } else { + // 4. query.filter[] + query.or[] -> combined WhereClause + const filters = query.filter || []; + const ors = query.or || []; + + if (filters.length && ors.length) { + if (filters.length === 1 && ors.length === 1) { + clauses.push(Where.or(filters[0], ors[0])); + } else { + clauses.push(Where.or(Where.and(...filters), Where.and(...ors))); + } + } else if (filters.length) { + clauses.push(...filters); + } else if (ors.length) { + if (ors.length === 1) { + clauses.push(ors[0]); + } else { + clauses.push(Where.or(...ors)); + } + } + } + + if (clauses.length === 0) return undefined; + if (clauses.length === 1) return clauses[0]; + return Where.and(...clauses); + } + + /** + * Validate all field names in a WhereClause tree against entity columns. + * Throws BadRequestException for any invalid field. + */ + protected validateWhereFields(clause: WhereClause | undefined): void { + if (!clause) return; + + if (isWhereCondition(clause)) { + // Skip relation-tagged conditions — they target joined entities + if (clause.relation) return; + + if (!this.isEntityColumn(clause.field)) { + throw new BadRequestException( + `Invalid filter field '${sanitizeForMessage(clause.field)}' for entity '${this.entityName()}'`, + ); + } + } else { + for (const child of clause.conditions) { + this.validateWhereFields(child); + } + } + } +} diff --git a/packages/nestjs-crud/src/infrastructure/adapters/interfaces/crud-adapter.types.ts b/packages/nestjs-crud/src/infrastructure/adapters/interfaces/crud-adapter.types.ts new file mode 100644 index 000000000..00beac2d7 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/adapters/interfaces/crud-adapter.types.ts @@ -0,0 +1,21 @@ +import { + type PlainLiteralObject, + type Provider, + type Type, +} from '@nestjs/common'; + +import { type CrudAdapter } from '../crud.adapter.js'; + +/** + * Type for providing a CRUD adapter via NestJS DI. + * + * Can be: + * - A class (Type) - the class itself becomes the injection token + * - A ClassProvider - `{ provide: token, useClass: AdapterClass }` + * - A FactoryProvider - `{ provide: token, useFactory: () => adapter }` + * - A ValueProvider - `{ provide: token, useValue: adapterInstance }` + * - An ExistingProvider - `{ provide: token, useExisting: otherToken }` + */ +export type CrudAdapterProvider = + | Type> + | Exclude>, Type>; diff --git a/packages/nestjs-crud/src/infrastructure/config/crud-default.config.ts b/packages/nestjs-crud/src/infrastructure/config/crud-default.config.ts new file mode 100644 index 000000000..1947ea473 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/config/crud-default.config.ts @@ -0,0 +1,13 @@ +import { registerAs } from '@nestjs/config'; + +import { CRUD_MODULE_DEFAULT_SETTINGS_TOKEN } from '../../crud.constants.js'; + +import { type CrudModuleSettingsInterface } from './interfaces/crud-module-settings.interface.js'; + +/** + * Default configuration for crud. + */ +export const crudDefaultConfig = registerAs( + CRUD_MODULE_DEFAULT_SETTINGS_TOKEN, + (): CrudModuleSettingsInterface => ({}), +); diff --git a/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-for-feature-options.interface.ts b/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-for-feature-options.interface.ts new file mode 100644 index 000000000..436ff86da --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-for-feature-options.interface.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ConfigurableCrudOptions } from '../../utils/interfaces/configurable-crud-options.interface.js'; + +import { type CrudModuleOptionsInterface } from './crud-module-options.interface.js'; + +/** + * Configuration options for a single CRUD feature registration. + */ +export type CrudForFeatureOptionsInterface< + Entity extends PlainLiteralObject = PlainLiteralObject, +> = ConfigurableCrudOptions; + +/** + * Options for CrudModule.forFeature. + * Configures a single CRUD endpoint with full type safety. + */ +export interface CrudModuleForFeatureOptionsInterface< + Entity extends PlainLiteralObject = PlainLiteralObject, +> extends CrudModuleOptionsInterface { + /** + * CRUD configuration for a single entity type. + */ + crud: CrudForFeatureOptionsInterface; +} diff --git a/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-options-extras.interface.ts b/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-options-extras.interface.ts new file mode 100644 index 000000000..d3c817225 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-options-extras.interface.ts @@ -0,0 +1,15 @@ +import { type DynamicModule, type Type } from '@nestjs/common'; + +import { type CrudResolverInterface } from '../../resolvers/interfaces/crud-resolver.interface.js'; + +export interface CrudModuleOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' | 'imports' +> { + /** + * Default resolver class for CRUD operations. + * Controllers without an explicit resolver will use this resolver. + * Defaults to CrudAdapterResolver. + */ + defaultResolver?: Type; +} diff --git a/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-options.interface.ts b/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-options.interface.ts new file mode 100644 index 000000000..60c890c3a --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-options.interface.ts @@ -0,0 +1,8 @@ +import { type CrudModuleSettingsInterface } from './crud-module-settings.interface.js'; + +export interface CrudModuleOptionsInterface { + /** + * Module settings. + */ + settings?: CrudModuleSettingsInterface; +} diff --git a/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-settings.interface.ts b/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-settings.interface.ts new file mode 100644 index 000000000..58a6dc1a6 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/config/interfaces/crud-module-settings.interface.ts @@ -0,0 +1,9 @@ +/** + * Module-wide crud settings. Currently empty — a per-route response + * type/paginated type (`CrudResponseConfig`/`CrudSerializationOptionsInterface`) + * is always resolved per-operation via decorators, never as a module-wide + * default, since a single fallback schema would never be meaningful across + * a module's distinct entities. Kept as an extension point for genuinely + * module-wide settings, should one ever be needed. + */ +export interface CrudModuleSettingsInterface {} diff --git a/packages/nestjs-crud/src/infrastructure/decorators/controller/__tests__/crud-controller.decorator.e2e-spec.ts b/packages/nestjs-crud/src/infrastructure/decorators/controller/__tests__/crud-controller.decorator.e2e-spec.ts new file mode 100644 index 000000000..767dc20fd --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/controller/__tests__/crud-controller.decorator.e2e-spec.ts @@ -0,0 +1,304 @@ +import request from 'supertest'; +import { z } from 'zod'; + +import { Inject, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; + +import { Ctx } from '@concepta/nestjs-core'; +import { WhereOperator } from '@concepta/nestjs-repository'; + +import { TestModel } from '../../../../__fixtures__/crud/models/test.model.js'; +import { testModelCreateBatchSchema } from '../../../../__fixtures__/crud/schemas/test-model-create-batch.schema.js'; +import { testModelCreateSchema } from '../../../../__fixtures__/crud/schemas/test-model-create.schema.js'; +import { testModelUpdateSchema } from '../../../../__fixtures__/crud/schemas/test-model-update.schema.js'; +import { testModelSchema } from '../../../../__fixtures__/crud/schemas/test-model.schema.js'; +import { CrudCreateBatchHandler } from '../../../../application/commands/handlers/crud-create-batch.handler.js'; +import { CrudCreateHandler } from '../../../../application/commands/handlers/crud-create.handler.js'; +import { CrudDeleteHandler } from '../../../../application/commands/handlers/crud-delete.handler.js'; +import { CrudReplaceHandler } from '../../../../application/commands/handlers/crud-replace.handler.js'; +import { CrudUpdateHandler } from '../../../../application/commands/handlers/crud-update.handler.js'; +import { CrudListHandler } from '../../../../application/queries/handlers/crud-list.handler.js'; +import { CrudReadHandler } from '../../../../application/queries/handlers/crud-read.handler.js'; +import { CrudModule } from '../../../../crud.module.js'; +import { CrudCtx } from '../../../interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../../../interceptors/interfaces/crud-context.interface.js'; +import { CrudCreateBatchInterface } from '../../../interfaces/crud-create-batch.interface.js'; +import { CrudQueryBuilder } from '../../../request/crud-query.builder.js'; +import { CrudAdapterResolver } from '../../../resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../../../resolvers/interfaces/crud-resolver.interface.js'; +import { paginatedSchema } from '../../../schemas/crud-response-paginated.schema.js'; +import { CrudCreateBatch } from '../../operations/crud-create-batch.decorator.js'; +import { CrudCreate } from '../../operations/crud-create.decorator.js'; +import { CrudDelete } from '../../operations/crud-delete.decorator.js'; +import { CrudList } from '../../operations/crud-list.decorator.js'; +import { CrudRead } from '../../operations/crud-read.decorator.js'; +import { CrudReplace } from '../../operations/crud-replace.decorator.js'; +import { CrudUpdate } from '../../operations/crud-update.decorator.js'; +import { CrudBody } from '../../params/crud-body.decorator.js'; +import { CrudController } from '../crud-controller.decorator.js'; + +describe('#crud', () => { + describe('#base methods', () => { + let app: INestApplication; + let server: ReturnType; + let qb: CrudQueryBuilder; + + // Mock CrudResolver for testing decorator behavior + const mockCrudResolver = { + list: vi.fn().mockResolvedValue({ + data: [], + count: 0, + total: 0, + page: 1, + pageCount: 0, + limit: 0, + }), + read: vi.fn().mockResolvedValue({ id: 1 }), + create: vi.fn().mockResolvedValue({ id: 1 }), + createBatch: vi.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]), + update: vi.fn().mockResolvedValue({ id: 1 }), + replace: vi.fn().mockResolvedValue({ id: 1 }), + delete: vi.fn().mockResolvedValue({ id: 1 }), + restore: vi.fn().mockResolvedValue({ id: 1 }), + }; + + @CrudController({ + path: 'test', + entity: 'Test', + request: { + params: { + id: { field: 'id', type: 'number' }, + }, + }, + response: { + resource: testModelSchema, + paginated: paginatedSchema(testModelSchema), + }, + }) + class TestController { + constructor( + @Inject(CrudAdapterResolver) + private readonly crudResolver: CrudResolverInterface, + ) {} + + @CrudList({ queryHandler: CrudListHandler }) + async list(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.list(context); + } + + @CrudRead({ queryHandler: CrudReadHandler }) + async read(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.read(context); + } + + @CrudCreate({ + commandHandler: CrudCreateHandler, + request: { body: testModelCreateSchema }, + }) + async create( + @Ctx(CrudCtx) context: CrudContextInterface, + // Explicit schema — validation would also resolve from this + // operation's `request.body` fallback; passing it here pins it on + // the parameter itself. + @CrudBody({ schema: testModelCreateSchema }) + dto: z.infer, + ) { + return this.crudResolver.create(context, dto); + } + + @CrudReplace({ + commandHandler: CrudReplaceHandler, + request: { body: testModelCreateSchema }, + }) + async replace( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: testModelCreateSchema }) + dto: z.infer, + ) { + return this.crudResolver.replace(context, dto); + } + + @CrudUpdate({ + commandHandler: CrudUpdateHandler, + request: { body: testModelUpdateSchema }, + }) + async update( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: testModelUpdateSchema }) + dto: z.infer, + ) { + return this.crudResolver.update(context, dto); + } + + @CrudCreateBatch({ + commandHandler: CrudCreateBatchHandler, + request: { body: testModelCreateBatchSchema }, + response: { serialization: { resource: z.array(testModelSchema) } }, + }) + async createBatch( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody({ schema: testModelCreateBatchSchema }) + dto: z.infer, + ) { + return this.crudResolver.createBatch(context, dto); + } + + @CrudDelete({ commandHandler: CrudDeleteHandler }) + async delete(@Ctx(CrudCtx) context: CrudContextInterface) { + return this.crudResolver.delete(context); + } + } + + beforeAll(async () => { + const fixture = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + controllers: [TestController], + providers: [ + { provide: CrudAdapterResolver, useValue: mockCrudResolver }, + ], + }).compile(); + + app = fixture.createNestApplication(); + + await app.init(); + server = app.getHttpServer(); + }); + + beforeEach(() => { + qb = CrudQueryBuilder.create(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('#list', () => { + it('should return status 200', async () => { + await request(server).get('/test').expect(200); + }); + it('should return status 400', async () => { + const query = qb.setFilter(['foo', WhereOperator.GT]).query(); + const expected = { + statusCode: 400, + message: 'Invalid filter value', + error: 'Bad Request', + errorCode: 'CRUD_QUERY_PARSER_ERROR', + }; + const res = await request(server).get('/test').query(query).expect(400); + expect(res.body).toEqual(expected); + }); + }); + + describe('#read', () => { + it('should return status 200', async () => { + await request(server).get('/test/1').expect(200); + }); + it('should return status 400', async () => { + const expected = { + statusCode: 400, + message: 'Invalid param id. Number expected', + error: 'Bad Request', + errorCode: 'CRUD_QUERY_VALIDATOR_ERROR', + }; + const res = await request(server).get('/test/invalid').expect(400); + expect(res.body).toEqual(expected); + }); + }); + + describe('#createBase', () => { + it('should return status 201', async () => { + const send: TestModel = { + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.com', + age: 15, + }; + await request(server).post('/test').send(send).expect(201); + }); + it('should return status 400', async () => { + const send: TestModel = { + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.com', + }; + await request(server).post('/test').send(send).expect(400); + }); + }); + + describe('#createBatch', () => { + it('should return status 201', async () => { + const send: CrudCreateBatchInterface = { + bulk: [ + { + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.com', + age: 15, + }, + { + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.com', + age: 15, + }, + ], + }; + await request(server).post('/test/bulk').send(send).expect(201); + }); + it('should return status 400', async () => { + const send: CrudCreateBatchInterface = { + bulk: [], + }; + await request(server).post('/test/bulk').send(send).expect(400); + }); + }); + + describe('#replace', () => { + it('should return status 200', async () => { + const send: TestModel = { + id: 1, + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.com', + age: 15, + }; + await request(server).put('/test/1').send(send).expect(200); + }); + it('should return status 400', async () => { + const send: TestModel = { + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.com', + }; + await request(server).put('/test/1').send(send).expect(400); + }); + }); + + describe('#update', () => { + it('should return status 200', async () => { + const send: TestModel = { + id: 1, + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.com', + age: 15, + }; + await request(server).patch('/test/1').send(send).expect(200); + }); + it('should return status 400', async () => { + const send: TestModel = { + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.com', + }; + await request(server).patch('/test/1').send(send).expect(400); + }); + }); + + describe('#delete', () => { + it('should return status 204', async () => { + await request(server).delete('/test/1').expect(204); + }); + }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-controller.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-controller.decorator.ts new file mode 100644 index 000000000..d4cafcd47 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-controller.decorator.ts @@ -0,0 +1,60 @@ +import { + applyDecorators, + Controller, + type PlainLiteralObject, +} from '@nestjs/common'; + +import { CRUD_MODULE_DEFAULT_PARAMS_OPTIONS } from '../../../crud.constants.js'; +import { CrudAdapter as CrudAdapterClass } from '../../adapters/crud.adapter.js'; +import { type CrudControllerOptionsInterface } from '../../interfaces/crud-controller-options.interface.js'; +import { CrudAdapter } from '../routes/crud-adapter.decorator.js'; +import { CrudEntity } from '../routes/crud-entity.decorator.js'; +import { CrudName } from '../routes/crud-name.decorator.js'; +import { CrudParams } from '../routes/crud-params.decorator.js'; +import { CrudRequestBodyBatch } from '../routes/crud-request-body-batch.decorator.js'; +import { CrudRequestBody } from '../routes/crud-request-body.decorator.js'; +import { CrudResolver } from '../routes/crud-resolver.decorator.js'; +import { CrudResponsePaginated } from '../routes/crud-response-paginated.decorator.js'; +import { CrudResponseResource } from '../routes/crud-response-resource.decorator.js'; +import { CrudSerialize } from '../routes/crud-serialize.decorator.js'; +import { CrudValidate } from '../routes/crud-validate.decorator.js'; + +import { CrudInit } from './crud-init.decorator.js'; + +/** + * CRUD controller decorator + * + * This decorator is a helper for calling the most common controller level decorators. + */ +export function CrudController< + T extends PlainLiteralObject = PlainLiteralObject, +>(options: CrudControllerOptionsInterface) { + // break out options + const { + path, + host, + entity, + name, + adapter = CrudAdapterClass, + resolver, + request, + response, + } = options; + + // apply all decorators (CrudInit must be last — it resolves query/command metadata) + return applyDecorators( + Controller({ path, host }), + CrudEntity(entity), + CrudName(name), + CrudAdapter(adapter), + CrudResolver(resolver), + CrudParams(request?.params ?? CRUD_MODULE_DEFAULT_PARAMS_OPTIONS), + CrudValidate(request?.validation), + CrudRequestBody(request?.body), + CrudRequestBodyBatch(request?.bodyBatch), + CrudResponseResource(response?.resource), + CrudResponsePaginated(response?.paginated), + CrudSerialize(response?.serialization), + CrudInit(), + ); +} diff --git a/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-body.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-body.decorator.ts new file mode 100644 index 000000000..cb6b411a9 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-body.decorator.ts @@ -0,0 +1,178 @@ +import { MetadataScanner } from '@nestjs/core'; +import { ApiBody, type ApiBodyOptions } from '@nestjs/swagger'; + +import { Operation } from '@concepta/nestjs-core'; + +import { type CrudSchema, type MethodHandler } from '../../../crud.types.js'; +import { CrudException } from '../../exceptions/crud.exception.js'; +import { CrudMetaview } from '../../services/crud-metaview.service.js'; +import { swagger } from '../../utils/swagger.helper.js'; + +/** + * `standardSchema` isn't declared on `@nestjs/swagger`'s `ApiBodyOptions` + * (12.0.0-alpha.2) the way it already is on `ApiResponseMetadata`, but + * `SchemaObjectFactory.getSchemaOverride` reads `param.standardSchema` at + * document-build time regardless — the same mechanism `apply-api-response.decorator.ts` + * already relies on for responses. This local intersection keeps the call + * cast-free; a `swagger.spec.ts`/`petstore.spec.ts` assertion guards against a + * future alpha silently dropping the key. + */ +type ApiBodyOptionsWithStandardSchema = ApiBodyOptions & { + standardSchema: CrudSchema; +}; + +/** + * Discovers the Reflect metadata key that `@ApiBody()` uses to store + * parameter descriptors on a route handler. Rather than depending on the + * private `DECORATORS` constant inside nestjs/swagger (which is a string + * constant, not a symbol), we apply `@ApiBody` to a probe function and read + * back which string key it wrote — guaranteeing we use the exact same key + * that the decorator itself uses, with no dependency on internal APIs. + * + * Returns `undefined` when swagger is not installed. + */ +function discoverApiParametersKey(): string | undefined { + if (!swagger) return undefined; + // Apply @ApiBody to a probe function and observe which Reflect key it adds. + const probe = function probe() {}; + const descriptor: PropertyDescriptor = { + value: probe, + writable: true, + enumerable: false, + configurable: true, + }; + // Object.create(null) → any, satisfies the MethodDecorator target: Object param + ApiBody({ type: String })(Object.create(null), 'method', descriptor); + const keys: unknown[] = Reflect.getMetadataKeys(probe) ?? []; + // @nestjs/swagger uses a string key (e.g. 'swagger/apiParameters'); find it. + return keys.find((k): k is string => typeof k === 'string'); +} + +const API_PARAMETERS_KEY: string | undefined = discoverApiParametersKey(); + +/** + * Removes any existing body parameter entry this decorator previously wrote + * to `handler` (an entry with `in` set to `'body'`). `ApiBody()`'s own + * metadata storage is append-only, and `@nestjs/swagger`'s + * document-build-time dedup keeps the *first* body entry among duplicates — + * so without this, a second `ApiBody()` call on the same handler would be + * silently discarded instead of overriding the first. `CrudInit()` (and + * therefore this decorator) is documented as re-runnable + * (`crud-init.decorator.ts`) and genuinely does run twice on the + * hybrid-builder path (`configurable-crud.builder.ts` re-runs `CrudInit()` + * on an already-`@CrudController`-decorated class after augmenting it), so + * this keeps a second run's resolved body — which can differ from the + * first, e.g. a `@CrudBody`-pinned override — winning + * instead of being discarded by swagger's first-wins dedup. + */ +function stripExistingBodyEntry(handler: MethodHandler): void { + if (!API_PARAMETERS_KEY) return; + + const existingParams: unknown[] = + Reflect.getMetadata(API_PARAMETERS_KEY, handler) ?? []; + const withoutBody = existingParams.filter( + (p) => + typeof p !== 'object' || p === null || Reflect.get(p, 'in') !== 'body', + ); + Reflect.defineMetadata(API_PARAMETERS_KEY, withoutBody, handler); +} + +/** + * \@CrudInit() api body decorator. + * + * The sole place `@ApiBody()` is ever applied for a CRUD operation — + * `CrudApiBody` (the operation decorators' `api.body` option) only stores + * `ApiBodyOptions` metadata, it never calls `@ApiBody()` itself. + * + * Resolves the request body schema — preferring a parameter-level + * `@CrudBody({ schema })` (so a caller pinning the schema on the parameter + * itself isn't silently overridden by a differing class/method default), + * then falling back to the metadata hierarchy (method → class) — and + * applies `@ApiBody({ ...apiBodyOptions, standardSchema })` for each write + * operation. Passing the schema itself (rather than a pre-converted JSON + * Schema blob) routes it through the SAME document-level + * `standardSchemaConverter` responses already use (see + * `apply-api-response.decorator.ts`), so a schema registered via + * `withNamedComponent` documents as a `$ref` instead of inlining — + * dynamically-generated crud controller methods have no `design:paramtypes` + * reflection metadata for swagger's OWN parameter explorer to pick this up + * automatically (see parameter-metadata-accessor.js), so it's applied + * manually here instead. + * + * When no schema resolves at all, still applies `api.body` (if set) as a + * plain `@ApiBody()` so a fully-schemaless body-bearing operation isn't left + * completely undocumented. + */ +export const CrudInitApiBody = (): ClassDecorator => (classTarget) => { + /* istanbul ignore if */ + if (!swagger) return; + + const reflectionService = new CrudMetaview(); + const scanner = new MetadataScanner(); + const prototype = classTarget.prototype; + + for (const methodName of scanner.getAllMethodNames(prototype)) { + const handler = Reflect.get(prototype, methodName); + const descriptor = Object.getOwnPropertyDescriptor(prototype, methodName); + + if (!descriptor) continue; + + const operation = reflectionService.getOperation(handler); + if (!operation) continue; + + let hierarchySchema: CrudSchema | undefined; + + switch (operation) { + case Operation.CreateBatch: + hierarchySchema = reflectionService.getRequestBodyBatch( + classTarget, + handler, + ); + break; + case Operation.Create: + case Operation.Update: + case Operation.Replace: + hierarchySchema = reflectionService.getRequestBody( + classTarget, + handler, + ); + break; + default: + continue; + } + + const apiBodyOptions = reflectionService.getApiBodyOptions(handler); + + const crudBodySchema = reflectionService + .getBodyParamOptions(handler) + ?.find((metadata) => metadata.schema)?.schema; + + const bodySchema = crudBodySchema ?? hierarchySchema; + + if (!bodySchema) { + stripExistingBodyEntry(handler); + ApiBody(apiBodyOptions ?? {})(prototype, methodName, descriptor); + continue; + } + + if (!bodySchema['~standard'].jsonSchema?.input) { + // A schema missing its ~standard.jsonSchema bridge (i.e. never + // passed through withOpenApi) would otherwise silently produce an + // undocumented request body — fail loudly instead. + throw new CrudException({ + message: `Request body schema for "${methodName}" is missing its OpenAPI bridge — wrap it with withOpenApi() before using it as a CRUD request body.`, + fault: 'usage', + }); + } + + stripExistingBodyEntry(handler); + + const options: ApiBodyOptionsWithStandardSchema = { + ...apiBodyOptions, + required: apiBodyOptions?.required ?? true, + standardSchema: bodySchema, + }; + + ApiBody(options)(prototype, methodName, descriptor); + } +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-params.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-params.decorator.ts new file mode 100644 index 000000000..8ea048c61 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-params.decorator.ts @@ -0,0 +1,67 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { MetadataScanner } from '@nestjs/core'; +import { ApiParam, type ApiParamOptions } from '@nestjs/swagger'; + +import { CrudMetaview } from '../../services/crud-metaview.service.js'; + +/** + * Crud initialize open api params decorator. + * + * Add an ApiParam to every method with a crud operation. + */ +export const CrudInitApiParams = + (): ClassDecorator => + (classTarget) => { + const reflectionService = new CrudMetaview(); + const scanner = new MetadataScanner(); + const prototype = classTarget.prototype; + + for (const methodName of scanner.getAllMethodNames(prototype)) { + const handler = Reflect.get(prototype, methodName); + const descriptor = Object.getOwnPropertyDescriptor(prototype, methodName); + + if (!descriptor) continue; + + const apiParamsOptions = reflectionService.getApiParamsOptions(handler); + if (!apiParamsOptions?.length) continue; + + const paramsOptions = reflectionService.getAllParamOptions( + classTarget, + handler, + ); + + for (const options of apiParamsOptions) { + // Use decorator's name option to look up matching route param config + const paramName = options?.name; + const routeParam = paramName + ? paramsOptions?.[paramName] + : Object.values(paramsOptions ?? {})[0]; + + // ApiParamOptions is a union: ApiParamMetadata | ApiParamSchemaHost + // - ApiParamMetadata has `type` and `enum` at top level (accepts Function like Number/String) + // - ApiParamSchemaHost has `schema.type` and `schema.enum` (OpenAPI string format) + const isSchemaHost = options && 'schema' in options; + const schemaType = isSchemaHost ? options.schema?.type : undefined; + const optType = isSchemaHost + ? Array.isArray(schemaType) + ? schemaType[0] + : schemaType + : options?.type; + const optEnum = isSchemaHost ? options.schema?.enum : options?.enum; + + // Build final options: spread decorator options first, then set defaults + // for any properties not explicitly provided + const apiOptions: ApiParamOptions = { + ...options, + name: options?.name ?? routeParam?.field ?? '', + type: optType ?? (routeParam?.type === 'number' ? Number : String), + enum: + optEnum ?? + (routeParam?.enum ? Object.values(routeParam.enum) : undefined), + required: options?.required ?? true, + }; + + ApiParam(apiOptions)(prototype, methodName, descriptor); + } + } + }; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-query.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-query.decorator.ts new file mode 100644 index 000000000..810c74a9d --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-query.decorator.ts @@ -0,0 +1,63 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { MetadataScanner } from '@nestjs/core'; +import { ApiQuery } from '@nestjs/swagger'; + +import { CrudMetaview } from '../../services/crud-metaview.service.js'; +import { isReadOperation } from '../../utils/crud-infra.utils.js'; +import { Swagger } from '../../utils/swagger.helper.js'; + +/** + * \@CrudInit() api query decorator. + */ +export const CrudInitApiQuery = + (): ClassDecorator => + (classTarget) => { + const reflectionService = new CrudMetaview(); + const scanner = new MetadataScanner(); + const prototype = classTarget.prototype; + + for (const methodName of scanner.getAllMethodNames(prototype)) { + const handler = Reflect.get(prototype, methodName); + const descriptor = Object.getOwnPropertyDescriptor(prototype, methodName); + + if (!descriptor) continue; + + // get the api query options for this method + const apiQueryOptions = reflectionService.getApiQueryOptions(handler); + if (!apiQueryOptions?.length) continue; + + // get the operation + const operation = reflectionService.getOperation(handler); + + // only apply query params for queryable operations (List and Read) + if (isReadOperation(operation)) { + // use swagger helper to get the query + const queryParamsMeta = Swagger.createQueryParamsMeta(operation); + + // the merged options + const appliedParamsMap = new Map(); + + // flatten and filter options to only include those with a name property (NestJS 11 compatibility) + const queryOptionsWithName = [ + ...apiQueryOptions.flat(), + ...queryParamsMeta, + ].filter( + (option): option is NonNullable & { name: string } => + option !== undefined && + 'name' in option && + typeof option.name === 'string', + ); + + // loop all of the options merged together, overrides first + for (const queryOption of queryOptionsWithName) { + // applied yet? + if (!appliedParamsMap.has(queryOption.name)) { + // apply the decorator + ApiQuery(queryOption)(prototype, methodName, descriptor); + // consider it done + appliedParamsMap.set(queryOption.name, true); + } + } + } + } + }; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-response.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-response.decorator.ts new file mode 100644 index 000000000..e8a788e98 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-api-response.decorator.ts @@ -0,0 +1,33 @@ +import { MetadataScanner } from '@nestjs/core'; + +import { CrudMetaview } from '../../services/crud-metaview.service.js'; +import { applyApiResponse } from '../util/apply-api-response.decorator.js'; + +/** + * CRUD init api response decorator. + */ +export const CrudInitApiResponse = (): ClassDecorator => (classTarget) => { + const reflectionService = new CrudMetaview(); + const scanner = new MetadataScanner(); + const prototype = classTarget.prototype; + + for (const methodName of scanner.getAllMethodNames(prototype)) { + const handler = Reflect.get(prototype, methodName); + const descriptor = Object.getOwnPropertyDescriptor(prototype, methodName); + + if (!descriptor) continue; + + // get the operation for this method + const operation = reflectionService.getOperation(handler); + if (!operation) continue; + + // get the api response options for this method + const apiResponseOptions = reflectionService.getApiResponseOptions(handler); + if (!apiResponseOptions?.length) continue; + + // apply response decorators for each option + for (const options of apiResponseOptions) { + applyApiResponse(operation, options)(classTarget, methodName, descriptor); + } + } +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-command.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-command.decorator.ts new file mode 100644 index 000000000..898d8d67d --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-command.decorator.ts @@ -0,0 +1,135 @@ +import { Injectable, type Type } from '@nestjs/common'; +import { MetadataScanner } from '@nestjs/core'; + +import { createCommand } from '../../../application/utils/create-operation-classes.js'; +import { createCommandHandler } from '../../../application/utils/create-operation-handlers.js'; +import { CrudAdapterResolver } from '../../resolvers/crud-adapter.resolver.js'; +import { CrudMetaview } from '../../services/crud-metaview.service.js'; +import { + hasExplicitConstructor, + getControllerName, +} from '../../utils/crud-infra.utils.js'; +import { CrudCommandHandler } from '../routes/crud-command-handler.decorator.js'; +import { CrudCommand } from '../routes/crud-command.decorator.js'; +import { applyConstructorInjection } from '../util/apply-constructor-injection.decorator.js'; + +/** + * Resolves command and command handler options for controller methods that have an operation. + * + * This decorator should be applied after operation decorators (CrudCreate, CrudUpdate, etc.) + * to resolve deferred options and generate command/handler classes with proper names. + * + * Always re-resolves to support re-running after metadata changes. + */ +export const CrudInitCommand = + (): ClassDecorator => + (...args: Parameters) => { + const [classTarget] = args; + const prototype = classTarget.prototype; + const reflectionService = new CrudMetaview(); + const scanner = new MetadataScanner(); + + for (const methodName of scanner.getAllMethodNames(prototype)) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, methodName); + if (!descriptor) continue; + + // Only process methods that have an operation set + const operation = reflectionService.getOperation(descriptor.value); + if (!operation) continue; + + // Get stored command options + const commandOptions = reflectionService.getCommand(descriptor.value); + + // Skip if no command options (operation uses queries instead) + if (!commandOptions) continue; + + // Get entity and name from controller metadata + const entity = reflectionService.getEntity(classTarget); + const name = reflectionService.getName(classTarget); + + if (!entity) { + throw new Error( + `CrudCommand on ${classTarget.name}.${methodName} requires controller entity (use @CrudEntity or @CrudController)`, + ); + } + + const controllerName = getControllerName({ entity, name }); + + // --- Resolve Command Class --- + let resolvedCommand: Type; + + if (commandOptions.command) { + resolvedCommand = commandOptions.command; + } else if (commandOptions.commandTemplate) { + resolvedCommand = createCommand( + controllerName, + commandOptions.commandTemplate, + ); + } else { + throw new Error( + `CrudCommand on ${classTarget.name}.${methodName} requires either command or commandTemplate`, + ); + } + + // Update command metadata with resolved + CrudCommand({ ...commandOptions, resolved: resolvedCommand })( + prototype, + methodName, + descriptor, + ); + + // --- Resolve Command Handler --- + const handlerOptions = reflectionService.getCommandHandler( + descriptor.value, + ); + + // Handler options should always exist for command actions + if (!handlerOptions) { + throw new Error( + `CrudCommandHandler on ${classTarget.name}.${methodName} requires handler options`, + ); + } + + let resolvedHandler: Type; + + // Get the resolver class for handler decoration (controller > default) + const resolverClass = + reflectionService.getResolver(classTarget, descriptor.value) ?? + CrudAdapterResolver; + + if (handlerOptions.handler) { + resolvedHandler = handlerOptions.handler; + + // Apply @Injectable() universally to all handlers + Injectable()(resolvedHandler); + + // Let resolver add any additional decorators (e.g., @CommandHandler for CQRS) + resolverClass.decorateCommandHandler(resolvedHandler, resolvedCommand); + + // If no explicit constructor, apply DI for adapter injection + if (!hasExplicitConstructor(resolvedHandler)) { + applyConstructorInjection(entity)(resolvedHandler); + } + } else if (handlerOptions.handlerTemplate) { + resolvedHandler = createCommandHandler({ + entity, + name, + methodName, + baseClass: handlerOptions.handlerTemplate, + commandClass: resolvedCommand, + resolverClass, + }); + } else { + throw new Error( + `CrudCommandHandler on ${classTarget.name}.${methodName} requires either handler or handlerTemplate`, + ); + } + + // Update handler metadata with resolved + CrudCommandHandler({ ...handlerOptions, resolved: resolvedHandler })( + prototype, + methodName, + descriptor, + ); + } + }; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-query.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-query.decorator.ts new file mode 100644 index 000000000..e4a77d805 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-query.decorator.ts @@ -0,0 +1,133 @@ +import { Injectable, type Type } from '@nestjs/common'; +import { MetadataScanner } from '@nestjs/core'; + +import { createQuery } from '../../../application/utils/create-operation-classes.js'; +import { createQueryHandler } from '../../../application/utils/create-operation-handlers.js'; +import { CrudDecoratorException } from '../../exceptions/crud-decorator.exception.js'; +import { CrudAdapterResolver } from '../../resolvers/crud-adapter.resolver.js'; +import { CrudMetaview } from '../../services/crud-metaview.service.js'; +import { + hasExplicitConstructor, + getControllerName, +} from '../../utils/crud-infra.utils.js'; +import { CrudQueryHandler } from '../routes/crud-query-handler.decorator.js'; +import { CrudQuery } from '../routes/crud-query.decorator.js'; +import { applyConstructorInjection } from '../util/apply-constructor-injection.decorator.js'; + +/** + * Resolves query and query handler options for controller methods that have an operation. + * + * This decorator should be applied after operation decorators (CrudList, CrudRead, etc.) + * to resolve deferred options and generate query/handler classes with proper names. + * + * Always re-resolves to support re-running after metadata changes. + */ +export const CrudInitQuery = + (): ClassDecorator => + (...args: Parameters) => { + const [classTarget] = args; + const prototype = classTarget.prototype; + const reflectionService = new CrudMetaview(); + const scanner = new MetadataScanner(); + + for (const methodName of scanner.getAllMethodNames(prototype)) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, methodName); + if (!descriptor) continue; + + // Only process methods that have an operation set + const operation = reflectionService.getOperation(descriptor.value); + if (!operation) continue; + + // Get stored query options + const queryOptions = reflectionService.getQuery(descriptor.value); + + // Skip if no query options (operation uses commands instead) + if (!queryOptions) continue; + + // Get entity and name from controller metadata + const entity = reflectionService.getEntity(classTarget); + const name = reflectionService.getName(classTarget); + + if (!entity) { + throw new CrudDecoratorException({ + message: `CrudQuery on ${classTarget.name}.${methodName} requires controller entity (use @CrudEntity or @CrudController)`, + }); + } + + const controllerName = getControllerName({ entity, name }); + + // --- Resolve Query Class --- + let resolvedQuery: Type; + + if (queryOptions.query) { + resolvedQuery = queryOptions.query; + } else if (queryOptions.queryTemplate) { + resolvedQuery = createQuery(controllerName, queryOptions.queryTemplate); + } else { + throw new Error( + `CrudQuery on ${classTarget.name}.${methodName} requires either query or queryTemplate`, + ); + } + + // Update query metadata with resolved + CrudQuery({ ...queryOptions, resolved: resolvedQuery })( + prototype, + methodName, + descriptor, + ); + + // --- Resolve Query Handler --- + const handlerOptions = reflectionService.getQueryHandler( + descriptor.value, + ); + + // Handler options should always exist for query actions + if (!handlerOptions) { + throw new Error( + `CrudQueryHandler on ${classTarget.name}.${methodName} requires handler options`, + ); + } + + let resolvedHandler: Type; + + // Get the resolver class for handler decoration (controller > default) + const resolverClass = + reflectionService.getResolver(classTarget, descriptor.value) ?? + CrudAdapterResolver; + + if (handlerOptions.handler) { + resolvedHandler = handlerOptions.handler; + + // Apply @Injectable() universally to all handlers + Injectable()(resolvedHandler); + + // Let resolver add any additional decorators (e.g., @QueryHandler for CQRS) + resolverClass.decorateQueryHandler(resolvedHandler, resolvedQuery); + + // If no explicit constructor, apply DI for adapter injection + if (!hasExplicitConstructor(resolvedHandler)) { + applyConstructorInjection(entity)(resolvedHandler); + } + } else if (handlerOptions.handlerTemplate) { + resolvedHandler = createQueryHandler({ + entity, + name, + methodName, + baseClass: handlerOptions.handlerTemplate, + queryClass: resolvedQuery, + resolverClass, + }); + } else { + throw new Error( + `CrudQueryHandler on ${classTarget.name}.${methodName} requires either query or queryTemplate`, + ); + } + + // Update handler metadata with resolved + CrudQueryHandler({ ...handlerOptions, resolved: resolvedHandler })( + prototype, + methodName, + descriptor, + ); + } + }; diff --git a/packages/nestjs-crud/src/crud/decorators/controller/crud-init-serialization.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-serialization.decorator.ts similarity index 91% rename from packages/nestjs-crud/src/crud/decorators/controller/crud-init-serialization.decorator.ts rename to packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-serialization.decorator.ts index f12ffc3fc..f5beb4afd 100644 --- a/packages/nestjs-crud/src/crud/decorators/controller/crud-init-serialization.decorator.ts +++ b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-serialization.decorator.ts @@ -1,6 +1,6 @@ import { UseInterceptors } from '@nestjs/common'; -import { CrudSerializeInterceptor } from '../../interceptors/crud-serialize.interceptor'; +import { CrudSerializeInterceptor } from '../../interceptors/crud-serialize.interceptor.js'; /** * Crud initialize serialization decorator. diff --git a/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-validation.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-validation.decorator.ts new file mode 100644 index 000000000..f8048ca86 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init-validation.decorator.ts @@ -0,0 +1,81 @@ +import { Body, StandardSchemaValidationPipe } from '@nestjs/common'; +import { MetadataScanner } from '@nestjs/core'; + +import { Operation } from '@concepta/nestjs-core'; + +import { CrudMetaview } from '../../services/crud-metaview.service.js'; +import { withEmptyBodyGuard } from '../../utils/crud-empty-body-guard.util.js'; + +/** + * Crud initialize validation decorator. + * + * Adds a `StandardSchemaValidationPipe` to every parameter called with the + * `CrudBody` decorator. The schema resolves from `@CrudBody({ schema })` + * first, falling back to `request.body`/`bodyBatch` resolved through the + * metadata hierarchy (method → class) — so a controller-level default is + * validated, not just a docs placeholder left for `@ApiBody` to render + * (#467). Pipe options come from + * `metadata.validation`, falling back to the operation-then-controller + * `@CrudValidate()` hierarchy — so callers can tune pipe behavior via plain + * options instead of subclassing. `validation: false` disables validation + * for that body (it is still bound, just unvalidated); a body with no + * resolvable schema is always bound unvalidated. `metadata.validation`'s + * own `allowEmpty` (default `true`, no `@CrudValidate()` fallback — see + * `CrudBodyValidationOptionsInterface`) controls whether an empty (`{}`) + * body is accepted — see `withEmptyBodyGuard`. + */ +export const CrudInitValidation = (): ClassDecorator => (classTarget) => { + const reflectionService = new CrudMetaview(); + const scanner = new MetadataScanner(); + const prototype = classTarget.prototype; + + for (const methodName of scanner.getAllMethodNames(prototype)) { + const handler = Reflect.get(prototype, methodName); + + // get the body param options for this method + const bodyParamOptions = reflectionService.getBodyParamOptions(handler); + if (!bodyParamOptions?.length) continue; + + // validation options resolve method-first, then class (per the + // @CrudValidate contract) + const fallbackOptions = reflectionService.getValidationOptions( + classTarget, + handler, + ); + + // without an explicit @CrudBody({ schema }), fall back to request.body/ + // bodyBatch resolved through the metadata hierarchy (method → class) — + // the same resolution docs use (see crud-init-api-body.decorator.ts), so + // a controller-level default is validated, not just documented (#467). + const operation = reflectionService.getOperation(handler); + const fallbackSchema = + operation === Operation.CreateBatch + ? reflectionService.getRequestBodyBatch(classTarget, handler) + : reflectionService.getRequestBody(classTarget, handler); + + // loop all metadatas and set up the pipe + for (const metadata of bodyParamOptions) { + const { pipes = [], validation = fallbackOptions } = metadata; + const schema = metadata.schema ?? fallbackSchema; + const allowEmpty = + metadata.validation && typeof metadata.validation === 'object' + ? metadata.validation.allowEmpty + : undefined; + + if (schema && validation !== false) { + Body({ + schema: withEmptyBodyGuard(schema, allowEmpty), + pipes: [ + new StandardSchemaValidationPipe({ ...validation }), + ...pipes, + ], + })(prototype, methodName, metadata.parameterIndex); + continue; + } + + // no schema configured, or validation explicitly disabled — still + // bind the body, just unvalidated. + Body(...pipes)(prototype, methodName, metadata.parameterIndex); + } + } +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init.decorator.ts new file mode 100644 index 000000000..0556b1caf --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/controller/crud-init.decorator.ts @@ -0,0 +1,28 @@ +import { applyDecorators } from '@nestjs/common'; + +import { CrudInitApiBody } from './crud-init-api-body.decorator.js'; +import { CrudInitApiParams } from './crud-init-api-params.decorator.js'; +import { CrudInitApiQuery } from './crud-init-api-query.decorator.js'; +import { CrudInitApiResponse } from './crud-init-api-response.decorator.js'; +import { CrudInitCommand } from './crud-init-command.decorator.js'; +import { CrudInitQuery } from './crud-init-query.decorator.js'; +import { CrudInitSerialization } from './crud-init-serialization.decorator.js'; +import { CrudInitValidation } from './crud-init-validation.decorator.js'; + +/** + * CRUD controller initialization decorator. + * + * Runs all init decorators that resolve metadata and apply NestJS decorators. + * Can be re-run safely after metadata changes (e.g., by ConfigurableCrudBuilder). + */ +export const CrudInit = () => + applyDecorators( + CrudInitValidation(), + CrudInitSerialization(), + CrudInitQuery(), + CrudInitCommand(), + CrudInitApiBody(), + CrudInitApiQuery(), + CrudInitApiParams(), + CrudInitApiResponse(), + ); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-body.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-body.decorator.ts new file mode 100644 index 000000000..8e35fa4f5 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-body.decorator.ts @@ -0,0 +1,24 @@ +import { type ApiBodyOptions } from '@nestjs/swagger'; + +import { CRUD_MODULE_API_BODY_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * \@CrudApiBody() open api decorator. + * + * Stores the operation's `api.body` `ApiBodyOptions` for + * `crud-init-api-body.decorator.ts` to read and merge into the `@ApiBody()` + * it builds from the resolved request body schema — the same + * store-then-apply split `CrudApiParam`/`CrudApiQuery`/`CrudApiResponse` + * already use. `standardSchema` always wins over a caller-supplied + * `schema`/`type` (`@nestjs/swagger`'s own `SchemaObjectFactory` omits both + * before spreading the converted schema back in last), so a caller-supplied + * `schema`/`type` here is accepted but has no effect. + */ +export const CrudApiBody = CrudMetadata.createDecorator({ + key: CRUD_MODULE_API_BODY_METADATA, + lookupTarget: CrudMetadataLookupTarget.Method, +}); diff --git a/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-operation.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-operation.decorator.ts similarity index 80% rename from packages/nestjs-crud/src/crud/decorators/openapi/crud-api-operation.decorator.ts rename to packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-operation.decorator.ts index 455396d0b..701134ed6 100644 --- a/packages/nestjs-crud/src/crud/decorators/openapi/crud-api-operation.decorator.ts +++ b/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-operation.decorator.ts @@ -1,7 +1,7 @@ -import { ApiOperation, ApiOperationOptions } from '@nestjs/swagger'; +import { ApiOperation, type ApiOperationOptions } from '@nestjs/swagger'; -import { DecoratorTargetObject } from '../../../crud.types'; -import { CrudException } from '../../../exceptions/crud.exception'; +import { type DecoratorTargetObject } from '../../../crud.types.js'; +import { CrudException } from '../../exceptions/crud.exception.js'; /** * \@CrudApiOperation() open api decorator @@ -31,6 +31,7 @@ export function CrudApiOperation( if (!descriptor) { throw new CrudException({ message: 'Did not find property descriptor', + fault: 'usage', }); } @@ -42,6 +43,7 @@ export function CrudApiOperation( } else { throw new CrudException({ message: 'Cannot decorate with api operation, target must be a class', + fault: 'usage', }); } }; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-param.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-param.decorator.ts new file mode 100644 index 000000000..07f840e67 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-param.decorator.ts @@ -0,0 +1,47 @@ +import { type ApiParamOptions } from '@nestjs/swagger'; + +import { CRUD_MODULE_API_PARAMS_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +export type ApiParamMetadata = (ApiParamOptions | undefined)[]; + +type ApiParamDecoratorFn = ( + options?: ApiParamOptions, +) => ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, +) => void; + +/** + * \@CrudApiParam() open api decorator. + * Can be applied multiple times to accumulate parameters. + */ +export const CrudApiParam = CrudMetadata.createWrappedDecorator< + ApiParamMetadata, + ApiParamDecoratorFn +>( + { + key: CRUD_MODULE_API_PARAMS_METADATA, + lookupTarget: CrudMetadataLookupTarget.Method, + }, + (decorator) => + (options?: ApiParamOptions) => + ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ) => { + const handler = descriptor.value; + + const existing = + typeof handler === 'function' + ? (CrudMetadata.get(CrudApiParam, handler) ?? []) + : []; + + decorator([...existing, options])(target, propertyKey, descriptor); + }, +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-query.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-query.decorator.ts new file mode 100644 index 000000000..519c150ef --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-query.decorator.ts @@ -0,0 +1,47 @@ +import { type ApiQueryOptions } from '@nestjs/swagger'; + +import { CRUD_MODULE_API_QUERY_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +export type ApiQueryMetadata = (ApiQueryOptions[] | undefined)[]; + +type ApiQueryDecoratorFn = ( + options?: ApiQueryOptions[], +) => ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, +) => void; + +/** + * \@CrudApiQuery() open api decorator. + * Can be applied multiple times to accumulate query parameters. + */ +export const CrudApiQuery = CrudMetadata.createWrappedDecorator< + ApiQueryMetadata, + ApiQueryDecoratorFn +>( + { + key: CRUD_MODULE_API_QUERY_METADATA, + lookupTarget: CrudMetadataLookupTarget.Method, + }, + (decorator) => + (options?: ApiQueryOptions[]) => + ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ) => { + const handler = descriptor.value; + + const existing = + typeof handler === 'function' + ? (CrudMetadata.get(CrudApiQuery, handler) ?? []) + : []; + + decorator([...existing, options])(target, propertyKey, descriptor); + }, +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-response.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-response.decorator.ts new file mode 100644 index 000000000..88bcd4944 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/openapi/crud-api-response.decorator.ts @@ -0,0 +1,48 @@ +import { type ApiResponseOptions } from '@nestjs/swagger'; + +import { CRUD_MODULE_API_RESPONSE_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +export type ApiResponseMetadata = (ApiResponseOptions | undefined)[]; + +type ApiResponseDecoratorFn = ( + options?: ApiResponseOptions, +) => ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, +) => void; + +/** + * \@CrudApiResponse() open api decorator. + * Can be applied multiple times to accumulate response options. + */ +export const CrudApiResponse = CrudMetadata.createWrappedDecorator< + ApiResponseMetadata, + ApiResponseDecoratorFn +>( + { + key: CRUD_MODULE_API_RESPONSE_METADATA, + lookupTarget: CrudMetadataLookupTarget.Method, + }, + (decorator) => + (options?: ApiResponseOptions) => + ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ) => { + const handler = descriptor.value; + + const existing = + typeof handler === 'function' + ? (CrudMetadata.get(CrudApiResponse, handler) ?? + []) + : []; + + decorator([...existing, options])(target, propertyKey, descriptor); + }, +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-create-batch.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-create-batch.decorator.ts new file mode 100644 index 000000000..065811b24 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-create-batch.decorator.ts @@ -0,0 +1,65 @@ +import { applyDecorators, type PlainLiteralObject, Post } from '@nestjs/common'; + +import { Operation } from '@concepta/nestjs-core'; + +import { CrudCreateBatchHandler } from '../../../application/commands/handlers/crud-create-batch.handler.js'; +import { CrudCreateBatchCommand } from '../../../application/commands/impl/crud-create-batch.command.js'; +import { CRUD_MODULE_ROUTE_CREATE_MANY_DEFAULT_PATH } from '../../../crud.constants.js'; +import { type CrudRouteCommandOptionsInterface } from '../../interfaces/crud-route-ctlr-options.interface.js'; +import { getTransactionalDecorators } from '../../utils/get-transactional-decorators.js'; +import { CrudApiBody } from '../openapi/crud-api-body.decorator.js'; +import { CrudApiOperation } from '../openapi/crud-api-operation.decorator.js'; +import { CrudApiResponse } from '../openapi/crud-api-response.decorator.js'; +import { CrudCommandHandler } from '../routes/crud-command-handler.decorator.js'; +import { CrudCommand } from '../routes/crud-command.decorator.js'; +import { CrudOperation } from '../routes/crud-operation.decorator.js'; +import { CrudRequestBodyBatch } from '../routes/crud-request-body-batch.decorator.js'; +import { CrudSerialize } from '../routes/crud-serialize.decorator.js'; +import { CrudValidate } from '../routes/crud-validate.decorator.js'; + +/** + * CRUD Create Batch route decorator + */ +export const CrudCreateBatch = < + T extends PlainLiteralObject = PlainLiteralObject, +>( + options: CrudRouteCommandOptionsInterface = {}, +) => { + const { + path = CRUD_MODULE_ROUTE_CREATE_MANY_DEFAULT_PATH, + command: command, + commandHandler: commandHandler, + request, + response, + api, + transactional, + } = { ...options }; + + const bodyBatchSchema = request?.bodyBatch; + + return applyDecorators( + Post(path), + CrudOperation(Operation.CreateBatch), + CrudCommand({ + command: command, + commandTemplate: CrudCreateBatchCommand, + }), + CrudCommandHandler({ + handler: commandHandler, + handlerTemplate: CrudCreateBatchHandler, + }), + // Store this operation's body schema at method level so it overrides the + // controller-level default for validation and docs resolution. + ...(bodyBatchSchema === undefined + ? [] + : [CrudRequestBodyBatch(bodyBatchSchema)]), + CrudValidate(request?.validation), + CrudSerialize(response?.serialization), + CrudApiOperation(api?.operation), + // Stores api.body for crud-init-api-body.decorator.ts to read and merge + // into the ApiBody() it builds from the resolved request body schema. + CrudApiBody(api?.body), + CrudApiResponse(api?.response), + ...getTransactionalDecorators(transactional), + ); +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-create.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-create.decorator.ts new file mode 100644 index 000000000..ad6f4c1ce --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-create.decorator.ts @@ -0,0 +1,59 @@ +import { applyDecorators, type PlainLiteralObject, Post } from '@nestjs/common'; + +import { Operation } from '@concepta/nestjs-core'; + +import { CrudCreateHandler } from '../../../application/commands/handlers/crud-create.handler.js'; +import { CrudCreateCommand } from '../../../application/commands/impl/crud-create.command.js'; +import { type CrudRouteCommandOptionsInterface } from '../../interfaces/crud-route-ctlr-options.interface.js'; +import { getTransactionalDecorators } from '../../utils/get-transactional-decorators.js'; +import { CrudApiBody } from '../openapi/crud-api-body.decorator.js'; +import { CrudApiOperation } from '../openapi/crud-api-operation.decorator.js'; +import { CrudApiResponse } from '../openapi/crud-api-response.decorator.js'; +import { CrudCommandHandler } from '../routes/crud-command-handler.decorator.js'; +import { CrudCommand } from '../routes/crud-command.decorator.js'; +import { CrudOperation } from '../routes/crud-operation.decorator.js'; +import { CrudRequestBody } from '../routes/crud-request-body.decorator.js'; +import { CrudSerialize } from '../routes/crud-serialize.decorator.js'; +import { CrudValidate } from '../routes/crud-validate.decorator.js'; + +/** + * CRUD Create route decorator + */ +export const CrudCreate = ( + options: CrudRouteCommandOptionsInterface = {}, +) => { + const { + path, + command, + commandHandler, + request, + response, + api, + transactional, + } = { + ...options, + }; + + const bodySchema = request?.body; + + return applyDecorators( + Post(path), + CrudOperation(Operation.Create), + CrudCommand({ command, commandTemplate: CrudCreateCommand }), + CrudCommandHandler({ + handler: commandHandler, + handlerTemplate: CrudCreateHandler, + }), + // Store this operation's body schema at method level so it overrides the + // controller-level default for validation and docs resolution. + ...(bodySchema === undefined ? [] : [CrudRequestBody(bodySchema)]), + CrudValidate(request?.validation), + CrudSerialize(response?.serialization), + CrudApiOperation(api?.operation), + // Stores api.body for crud-init-api-body.decorator.ts to read and merge + // into the ApiBody() it builds from the resolved request body schema. + CrudApiBody(api?.body), + CrudApiResponse(api?.response), + ...getTransactionalDecorators(transactional), + ); +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-delete.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-delete.decorator.ts new file mode 100644 index 000000000..a33250cad --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-delete.decorator.ts @@ -0,0 +1,62 @@ +import { + applyDecorators, + Delete, + HttpCode, + HttpStatus, + type PlainLiteralObject, +} from '@nestjs/common'; + +import { Operation } from '@concepta/nestjs-core'; + +import { CrudDeleteHandler } from '../../../application/commands/handlers/crud-delete.handler.js'; +import { CrudDeleteCommand } from '../../../application/commands/impl/crud-delete.command.js'; +import { CRUD_MODULE_ROUTE_ID_DEFAULT_PATH } from '../../../crud.constants.js'; +import { type CrudRouteCommandOptionsInterface } from '../../interfaces/crud-route-ctlr-options.interface.js'; +import { getTransactionalDecorators } from '../../utils/get-transactional-decorators.js'; +import { CrudApiOperation } from '../openapi/crud-api-operation.decorator.js'; +import { CrudApiParam } from '../openapi/crud-api-param.decorator.js'; +import { CrudApiResponse } from '../openapi/crud-api-response.decorator.js'; +import { CrudCommandHandler } from '../routes/crud-command-handler.decorator.js'; +import { CrudCommand } from '../routes/crud-command.decorator.js'; +import { CrudOperation } from '../routes/crud-operation.decorator.js'; +import { CrudReturnDeleted } from '../routes/crud-return-deleted.decorator.js'; +import { CrudSerialize } from '../routes/crud-serialize.decorator.js'; +import { CrudValidate } from '../routes/crud-validate.decorator.js'; + +/** + * CRUD Delete route decorator (hard delete) + */ +export const CrudDelete = ( + options: CrudRouteCommandOptionsInterface = {}, +) => { + const { + path = CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, + command, + commandHandler, + request, + response, + api, + transactional, + } = { ...options }; + + const status = + response?.returnDeleted === true ? HttpStatus.OK : HttpStatus.NO_CONTENT; + + return applyDecorators( + Delete(path), + HttpCode(status), + CrudOperation(Operation.Delete), + CrudCommand({ command: command, commandTemplate: CrudDeleteCommand }), + CrudCommandHandler({ + handler: commandHandler, + handlerTemplate: CrudDeleteHandler, + }), + CrudReturnDeleted(response?.returnDeleted), + CrudValidate(request?.validation), + CrudSerialize(response?.serialization), + CrudApiOperation(api?.operation), + CrudApiParam(api?.params), + CrudApiResponse(api?.response), + ...getTransactionalDecorators(transactional), + ); +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-list.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-list.decorator.ts new file mode 100755 index 000000000..7ade692a0 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-list.decorator.ts @@ -0,0 +1,43 @@ +import { applyDecorators, Get, type PlainLiteralObject } from '@nestjs/common'; + +import { Operation } from '@concepta/nestjs-core'; + +import { CrudListHandler } from '../../../application/queries/handlers/crud-list.handler.js'; +import { CrudListQuery } from '../../../application/queries/impl/crud-list.query.js'; +import { type CrudRouteQueryOptionsInterface } from '../../interfaces/crud-route-ctlr-options.interface.js'; +import { getTransactionalDecorators } from '../../utils/get-transactional-decorators.js'; +import { CrudApiOperation } from '../openapi/crud-api-operation.decorator.js'; +import { CrudApiQuery } from '../openapi/crud-api-query.decorator.js'; +import { CrudApiResponse } from '../openapi/crud-api-response.decorator.js'; +import { CrudOperation } from '../routes/crud-operation.decorator.js'; +import { CrudQueryHandler } from '../routes/crud-query-handler.decorator.js'; +import { CrudQuery } from '../routes/crud-query.decorator.js'; +import { CrudSerialize } from '../routes/crud-serialize.decorator.js'; +import { CrudValidate } from '../routes/crud-validate.decorator.js'; + +/** + * CRUD List route decorator + */ +export const CrudList = ( + options: CrudRouteQueryOptionsInterface = {}, +) => { + const { path, query, queryHandler, request, response, api, transactional } = { + ...options, + }; + + return applyDecorators( + Get(path), + CrudOperation(Operation.List), + CrudQuery({ query, queryTemplate: CrudListQuery }), + CrudQueryHandler({ + handler: queryHandler, + handlerTemplate: CrudListHandler, + }), + CrudValidate(request?.validation), + CrudSerialize(response?.serialization), + CrudApiOperation(api?.operation), + CrudApiQuery(api?.query), + CrudApiResponse(api?.response), + ...getTransactionalDecorators(transactional), + ); +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-read.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-read.decorator.ts new file mode 100755 index 000000000..2a8fc30d8 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-read.decorator.ts @@ -0,0 +1,52 @@ +import { applyDecorators, Get, type PlainLiteralObject } from '@nestjs/common'; + +import { Operation } from '@concepta/nestjs-core'; + +import { CrudReadHandler } from '../../../application/queries/handlers/crud-read.handler.js'; +import { CrudReadQuery } from '../../../application/queries/impl/crud-read.query.js'; +import { CRUD_MODULE_ROUTE_ID_DEFAULT_PATH } from '../../../crud.constants.js'; +import { type CrudRouteQueryOptionsInterface } from '../../interfaces/crud-route-ctlr-options.interface.js'; +import { getTransactionalDecorators } from '../../utils/get-transactional-decorators.js'; +import { CrudApiOperation } from '../openapi/crud-api-operation.decorator.js'; +import { CrudApiParam } from '../openapi/crud-api-param.decorator.js'; +import { CrudApiQuery } from '../openapi/crud-api-query.decorator.js'; +import { CrudApiResponse } from '../openapi/crud-api-response.decorator.js'; +import { CrudOperation } from '../routes/crud-operation.decorator.js'; +import { CrudQueryHandler } from '../routes/crud-query-handler.decorator.js'; +import { CrudQuery } from '../routes/crud-query.decorator.js'; +import { CrudSerialize } from '../routes/crud-serialize.decorator.js'; +import { CrudValidate } from '../routes/crud-validate.decorator.js'; + +/** + * CRUD Read route decorator + */ +export const CrudRead = ( + options: CrudRouteQueryOptionsInterface = {}, +) => { + const { + path = CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, + query, + queryHandler, + request, + response, + api, + transactional, + } = { ...options }; + + return applyDecorators( + Get(path), + CrudOperation(Operation.Read), + CrudQuery({ query, queryTemplate: CrudReadQuery }), + CrudQueryHandler({ + handler: queryHandler, + handlerTemplate: CrudReadHandler, + }), + CrudValidate(request?.validation), + CrudSerialize(response?.serialization), + CrudApiOperation(api?.operation), + CrudApiQuery(api?.query), + CrudApiParam(api?.params), + CrudApiResponse(api?.response), + ...getTransactionalDecorators(transactional), + ); +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-replace.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-replace.decorator.ts new file mode 100644 index 000000000..87ec0be48 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-replace.decorator.ts @@ -0,0 +1,60 @@ +import { applyDecorators, type PlainLiteralObject, Put } from '@nestjs/common'; + +import { Operation } from '@concepta/nestjs-core'; + +import { CrudReplaceHandler } from '../../../application/commands/handlers/crud-replace.handler.js'; +import { CrudReplaceCommand } from '../../../application/commands/impl/crud-replace.command.js'; +import { CRUD_MODULE_ROUTE_ID_DEFAULT_PATH } from '../../../crud.constants.js'; +import { type CrudRouteCommandOptionsInterface } from '../../interfaces/crud-route-ctlr-options.interface.js'; +import { getTransactionalDecorators } from '../../utils/get-transactional-decorators.js'; +import { CrudApiBody } from '../openapi/crud-api-body.decorator.js'; +import { CrudApiOperation } from '../openapi/crud-api-operation.decorator.js'; +import { CrudApiParam } from '../openapi/crud-api-param.decorator.js'; +import { CrudApiResponse } from '../openapi/crud-api-response.decorator.js'; +import { CrudCommandHandler } from '../routes/crud-command-handler.decorator.js'; +import { CrudCommand } from '../routes/crud-command.decorator.js'; +import { CrudOperation } from '../routes/crud-operation.decorator.js'; +import { CrudRequestBody } from '../routes/crud-request-body.decorator.js'; +import { CrudSerialize } from '../routes/crud-serialize.decorator.js'; +import { CrudValidate } from '../routes/crud-validate.decorator.js'; + +/** + * CRUD Replace route decorator + */ +export const CrudReplace = ( + options: CrudRouteCommandOptionsInterface = {}, +) => { + const { + path = CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, + command, + commandHandler, + request, + response, + api, + transactional, + } = { ...options }; + + const bodySchema = request?.body; + + return applyDecorators( + Put(path), + CrudOperation(Operation.Replace), + CrudCommand({ command, commandTemplate: CrudReplaceCommand }), + CrudCommandHandler({ + handler: commandHandler, + handlerTemplate: CrudReplaceHandler, + }), + // Store this operation's body schema at method level so it overrides the + // controller-level default for validation and docs resolution. + ...(bodySchema === undefined ? [] : [CrudRequestBody(bodySchema)]), + CrudValidate(request?.validation), + CrudSerialize(response?.serialization), + CrudApiOperation(api?.operation), + CrudApiParam(api?.params), + // Stores api.body for crud-init-api-body.decorator.ts to read and merge + // into the ApiBody() it builds from the resolved request body schema. + CrudApiBody(api?.body), + CrudApiResponse(api?.response), + ...getTransactionalDecorators(transactional), + ); +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-restore.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-restore.decorator.ts new file mode 100644 index 000000000..9d98dfbdb --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-restore.decorator.ts @@ -0,0 +1,62 @@ +import { + applyDecorators, + HttpCode, + HttpStatus, + Patch, + type PlainLiteralObject, +} from '@nestjs/common'; + +import { Operation } from '@concepta/nestjs-core'; + +import { CrudRestoreHandler } from '../../../application/commands/handlers/crud-restore.handler.js'; +import { CrudRestoreCommand } from '../../../application/commands/impl/crud-restore.command.js'; +import { CRUD_MODULE_ROUTE_RESTORE_DEFAULT_PATH } from '../../../crud.constants.js'; +import { type CrudRouteCommandOptionsInterface } from '../../interfaces/crud-route-ctlr-options.interface.js'; +import { getTransactionalDecorators } from '../../utils/get-transactional-decorators.js'; +import { CrudApiOperation } from '../openapi/crud-api-operation.decorator.js'; +import { CrudApiParam } from '../openapi/crud-api-param.decorator.js'; +import { CrudApiResponse } from '../openapi/crud-api-response.decorator.js'; +import { CrudCommandHandler } from '../routes/crud-command-handler.decorator.js'; +import { CrudCommand } from '../routes/crud-command.decorator.js'; +import { CrudOperation } from '../routes/crud-operation.decorator.js'; +import { CrudReturnRestored } from '../routes/crud-return-restored.decorator.js'; +import { CrudSerialize } from '../routes/crud-serialize.decorator.js'; +import { CrudValidate } from '../routes/crud-validate.decorator.js'; + +/** + * CRUD Restore route decorator + */ +export const CrudRestore = ( + options: CrudRouteCommandOptionsInterface = {}, +) => { + const { + path = CRUD_MODULE_ROUTE_RESTORE_DEFAULT_PATH, + command, + commandHandler, + request, + response, + api, + transactional, + } = { ...options }; + + const status = + response?.returnRestored === true ? HttpStatus.OK : HttpStatus.NO_CONTENT; + + return applyDecorators( + Patch(path), + HttpCode(status), + CrudOperation(Operation.Restore), + CrudCommand({ command, commandTemplate: CrudRestoreCommand }), + CrudCommandHandler({ + handler: commandHandler, + handlerTemplate: CrudRestoreHandler, + }), + CrudReturnRestored(response?.returnRestored), + CrudValidate(request?.validation), + CrudSerialize(response?.serialization), + CrudApiOperation(api?.operation), + CrudApiParam(api?.params), + CrudApiResponse(api?.response), + ...getTransactionalDecorators(transactional), + ); +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-soft-delete.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-soft-delete.decorator.ts new file mode 100644 index 000000000..6f0638215 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-soft-delete.decorator.ts @@ -0,0 +1,67 @@ +import { + applyDecorators, + Delete, + HttpCode, + HttpStatus, + type PlainLiteralObject, +} from '@nestjs/common'; + +import { Operation } from '@concepta/nestjs-core'; + +import { CrudSoftDeleteHandler } from '../../../application/commands/handlers/crud-soft-delete.handler.js'; +import { CrudSoftDeleteCommand } from '../../../application/commands/impl/crud-soft-delete.command.js'; +import { CRUD_MODULE_ROUTE_ID_DEFAULT_PATH } from '../../../crud.constants.js'; +import { type CrudRouteCommandOptionsInterface } from '../../interfaces/crud-route-ctlr-options.interface.js'; +import { getTransactionalDecorators } from '../../utils/get-transactional-decorators.js'; +import { CrudApiOperation } from '../openapi/crud-api-operation.decorator.js'; +import { CrudApiParam } from '../openapi/crud-api-param.decorator.js'; +import { CrudApiResponse } from '../openapi/crud-api-response.decorator.js'; +import { CrudCommandHandler } from '../routes/crud-command-handler.decorator.js'; +import { CrudCommand } from '../routes/crud-command.decorator.js'; +import { CrudOperation } from '../routes/crud-operation.decorator.js'; +import { CrudReturnDeleted } from '../routes/crud-return-deleted.decorator.js'; +import { CrudSerialize } from '../routes/crud-serialize.decorator.js'; +import { CrudValidate } from '../routes/crud-validate.decorator.js'; + +/** + * CRUD Soft Delete route decorator + */ +export const CrudSoftDelete = < + T extends PlainLiteralObject = PlainLiteralObject, +>( + options: CrudRouteCommandOptionsInterface = {}, +) => { + const { + path = CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, + command, + commandHandler, + request, + response, + api, + transactional, + } = { ...options }; + + const status = + response?.returnDeleted === true ? HttpStatus.OK : HttpStatus.NO_CONTENT; + + return applyDecorators( + Delete(path), + HttpCode(status), + CrudOperation(Operation.SoftDelete), + CrudCommand({ + command: command, + commandTemplate: CrudSoftDeleteCommand, + }), + CrudCommandHandler({ + handler: commandHandler, + handlerTemplate: CrudSoftDeleteHandler, + }), + CrudReturnDeleted(response?.returnDeleted), + CrudValidate(request?.validation), + CrudSerialize(response?.serialization), + CrudApiOperation(api?.operation), + CrudApiParam(api?.params), + CrudApiResponse(api?.response), + ...getTransactionalDecorators(transactional), + ); +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-update.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-update.decorator.ts new file mode 100644 index 000000000..5f672ad82 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/operations/crud-update.decorator.ts @@ -0,0 +1,64 @@ +import { + applyDecorators, + Patch, + type PlainLiteralObject, +} from '@nestjs/common'; + +import { Operation } from '@concepta/nestjs-core'; + +import { CrudUpdateHandler } from '../../../application/commands/handlers/crud-update.handler.js'; +import { CrudUpdateCommand } from '../../../application/commands/impl/crud-update.command.js'; +import { CRUD_MODULE_ROUTE_ID_DEFAULT_PATH } from '../../../crud.constants.js'; +import { type CrudRouteCommandOptionsInterface } from '../../interfaces/crud-route-ctlr-options.interface.js'; +import { getTransactionalDecorators } from '../../utils/get-transactional-decorators.js'; +import { CrudApiBody } from '../openapi/crud-api-body.decorator.js'; +import { CrudApiOperation } from '../openapi/crud-api-operation.decorator.js'; +import { CrudApiParam } from '../openapi/crud-api-param.decorator.js'; +import { CrudApiResponse } from '../openapi/crud-api-response.decorator.js'; +import { CrudCommandHandler } from '../routes/crud-command-handler.decorator.js'; +import { CrudCommand } from '../routes/crud-command.decorator.js'; +import { CrudOperation } from '../routes/crud-operation.decorator.js'; +import { CrudRequestBody } from '../routes/crud-request-body.decorator.js'; +import { CrudSerialize } from '../routes/crud-serialize.decorator.js'; +import { CrudValidate } from '../routes/crud-validate.decorator.js'; + +/** + * CRUD Update route decorator + */ +export const CrudUpdate = ( + options: CrudRouteCommandOptionsInterface = {}, +) => { + const { + path = CRUD_MODULE_ROUTE_ID_DEFAULT_PATH, + command, + commandHandler, + request, + response, + api, + transactional, + } = { ...options }; + + const bodySchema = request?.body; + + return applyDecorators( + Patch(path), + CrudOperation(Operation.Update), + CrudCommand({ command, commandTemplate: CrudUpdateCommand }), + CrudCommandHandler({ + handler: commandHandler, + handlerTemplate: CrudUpdateHandler, + }), + // Store this operation's body schema at method level so it overrides the + // controller-level default for validation and docs resolution. + ...(bodySchema === undefined ? [] : [CrudRequestBody(bodySchema)]), + CrudValidate(request?.validation), + CrudSerialize(response?.serialization), + CrudApiOperation(api?.operation), + CrudApiParam(api?.params), + // Stores api.body for crud-init-api-body.decorator.ts to read and merge + // into the ApiBody() it builds from the resolved request body schema. + CrudApiBody(api?.body), + CrudApiResponse(api?.response), + ...getTransactionalDecorators(transactional), + ); +}; diff --git a/packages/nestjs-crud/src/infrastructure/decorators/params/__tests__/crud-query-params.decorator.e2e-spec.ts b/packages/nestjs-crud/src/infrastructure/decorators/params/__tests__/crud-query-params.decorator.e2e-spec.ts new file mode 100644 index 000000000..0c8e5529b --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/params/__tests__/crud-query-params.decorator.e2e-spec.ts @@ -0,0 +1,55 @@ +import supertest from 'supertest'; + +import { Controller, Get } from '@nestjs/common'; +import { NestApplication } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; + +import { CrudQueryParams } from '../crud-query-params.decorator.js'; + +// Proves `@CrudQueryParams()` works on a genuinely hand-written route: no +// `@CrudController()`, no `@CrudEntity()`, no `@Crud` tag, and +// `CrudModule` isn't even imported — `CrudContextOverlay` (and `ctx.query`) +// never populates for a method like this, but this decorator doesn't need it. +describe('#crud CrudQueryParams (decoupled from CrudController)', () => { + @Controller('search') + class SearchController { + @Get() + search(@CrudQueryParams() query: unknown) { + return { query }; + } + } + + let $: ReturnType; + let app: NestApplication; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + controllers: [SearchController], + }).compile(); + app = module.createNestApplication(); + await app.init(); + + $ = supertest(app.getHttpServer()); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should parse a valid query string on a bare hand-written route', async () => { + const res = await $.get('/search') + .query({ filter: 'firstName||$eq||John', limit: '5' }) + .expect(200); + + expect(res.body.query).toHaveProperty('limit', 5); + expect(res.body.query.filter).toEqual([ + { field: 'firstName', operator: 'eq', value: 'John' }, + ]); + }); + + it('should reject a malformed query string with HTTP 400', async () => { + await $.get('/search') + .query({ filter: 'firstName||badop||John' }) + .expect(400); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/params/__tests__/crud-query-params.decorator.spec.ts b/packages/nestjs-crud/src/infrastructure/decorators/params/__tests__/crud-query-params.decorator.spec.ts new file mode 100644 index 000000000..19724d2f6 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/params/__tests__/crud-query-params.decorator.spec.ts @@ -0,0 +1,75 @@ +import { mock } from 'vitest-mock-extended'; + +import { type ArgumentsHost, type ExecutionContext } from '@nestjs/common'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; + +import { mockCrudParsedQuery } from '../../../../__fixtures__/crud/mocks/crud-parsed-query.mock.js'; +import { CrudQueryValidatorException } from '../../../request/exceptions/crud-query-validator.exception.js'; +import { CrudQueryParams } from '../crud-query-params.decorator.js'; + +type HttpArgumentsHost = ReturnType; +type ParamFactory = (data: unknown, ctx: ExecutionContext) => unknown; + +const getDecoratorFactory = (): ParamFactory => { + class Probe { + test(@CrudQueryParams() _query: unknown): void { + return; + } + } + + const metadata = Reflect.getMetadata( + ROUTE_ARGS_METADATA, + Probe, + 'test', + ) as Record; + const key = Object.keys(metadata)[0]; + return metadata[key].factory; +}; + +const buildExecutionContext = (query: object): ExecutionContext => { + const httpArgsHost = mock(); + httpArgsHost.getRequest.mockReturnValue({ query }); + const ctx = mock(); + ctx.switchToHttp.mockReturnValue(httpArgsHost); + return ctx; +}; + +describe('CrudQueryParams', () => { + it('should return an empty parsed query when the request has no query string', () => { + const factory = getDecoratorFactory(); + + const result = factory(undefined, buildExecutionContext({})); + + expect(result).toEqual(mockCrudParsedQuery()); + }); + + it('should parse filter, sort, and pagination from the raw query string', () => { + const factory = getDecoratorFactory(); + const query = { + filter: 'firstName||$eq||John', + sort: 'lastName,DESC', + limit: '10', + page: '2', + }; + + const result = factory(undefined, buildExecutionContext(query)); + + expect(result).toEqual( + mockCrudParsedQuery({ + filter: [{ field: 'firstName', operator: 'eq', value: 'John' }], + sort: [{ field: 'lastName', order: 'DESC' }], + limit: 10, + page: 2, + }), + ); + }); + + it('should throw CrudQueryValidatorException for a malformed filter operator', () => { + const factory = getDecoratorFactory(); + const query = { filter: 'firstName||badop||John' }; + + expect(() => factory(undefined, buildExecutionContext(query))).toThrow( + CrudQueryValidatorException, + ); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/params/crud-body.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/params/crud-body.decorator.ts new file mode 100644 index 000000000..ee42a531a --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/params/crud-body.decorator.ts @@ -0,0 +1,44 @@ +import { CRUD_MODULE_PARAM_BODY_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; +import { getMethodHandler } from '../../utils/crud-infra.utils.js'; + +import { type CrudBodyMetadataInterface } from './interfaces/crud-body-metadata.interface.js'; +import { type CrudBodyOptionsInterface } from './interfaces/crud-body-options.interface.js'; + +type CrudBodyDecoratorFn = ( + options?: CrudBodyOptionsInterface, +) => ParameterDecorator; + +/** + * \@CrudBody() parameter decorator + */ +export const CrudBody = CrudMetadata.createWrappedDecorator< + CrudBodyMetadataInterface[], + CrudBodyDecoratorFn +>( + { + key: CRUD_MODULE_PARAM_BODY_METADATA, + lookupTarget: CrudMetadataLookupTarget.Parameter, + }, + (decorator) => + (options?: CrudBodyOptionsInterface): ParameterDecorator => + (target, propertyKey, parameterIndex) => { + if (propertyKey === undefined) return; + const handler = getMethodHandler(target, propertyKey); + const previousValues = + CrudMetadata.get(CrudBody, handler) ?? []; + + const value: CrudBodyMetadataInterface = { + parameterIndex, + validation: options?.validation, + pipes: options?.pipes ?? [], + schema: options?.schema, + }; + + // Store metadata on the method handler (not the parameter) + decorator([...previousValues, value])(handler); + }, +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/params/crud-query-params.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/params/crud-query-params.decorator.ts new file mode 100644 index 000000000..5df1b7a66 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/params/crud-query-params.decorator.ts @@ -0,0 +1,37 @@ +import { + createParamDecorator, + type ExecutionContext, + type PlainLiteralObject, +} from '@nestjs/common'; + +import { CrudQueryParser } from '../../request/crud-query.parser.js'; +import { type CrudParsedQueryInterface } from '../../request/interfaces/crud-parsed-query.interface.js'; + +/** + * \@CrudQueryParams() parameter decorator + * + * Parses and validates CRUD's standard query-string contract (filter, or, + * sort, fields, limit, offset, page, cache, includeDeleted) via + * `CrudQueryParser` — the same parser generated `@CrudList`/`@CrudRead` + * routes use — without depending on `@CrudController`, an entity, or a + * `@Crud` tag. `CrudContextOverlay` (and, with it, `@Ctx(CrudCtx)` + * and `ctx.query`) only populates for methods carrying one of the nine + * canonical CRUD operations; a hand-written endpoint that doesn't fit one of + * those — a custom search/aggregate/report route — has no other way to reuse + * this validated contract. + * + * Malformed input throws `CrudQueryParserException`, which surfaces as a + * plain HTTP 400 the same way a pipe's rejection would. + * + * Route **path** params are out of scope here — Nest's native `@Param()` + * already covers those with no friction. + */ +export const CrudQueryParams = createParamDecorator( + ( + _data: unknown, + ctx: ExecutionContext, + ): CrudParsedQueryInterface => { + const request = ctx.switchToHttp().getRequest(); + return CrudQueryParser.create().parseQuery(request.query).getParsedQuery(); + }, +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/params/interfaces/crud-body-metadata.interface.ts b/packages/nestjs-crud/src/infrastructure/decorators/params/interfaces/crud-body-metadata.interface.ts new file mode 100644 index 000000000..cd40b28e5 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/params/interfaces/crud-body-metadata.interface.ts @@ -0,0 +1,8 @@ +import { type CrudBodyOptionsInterface } from './crud-body-options.interface.js'; + +export interface CrudBodyMetadataInterface { + parameterIndex: number; + validation: CrudBodyOptionsInterface['validation']; + pipes: CrudBodyOptionsInterface['pipes']; + schema: CrudBodyOptionsInterface['schema']; +} diff --git a/packages/nestjs-crud/src/infrastructure/decorators/params/interfaces/crud-body-options.interface.ts b/packages/nestjs-crud/src/infrastructure/decorators/params/interfaces/crud-body-options.interface.ts new file mode 100644 index 000000000..7d50f8df1 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/params/interfaces/crud-body-options.interface.ts @@ -0,0 +1,23 @@ +import { type z } from 'zod'; + +import { type PipeTransform, type Type } from '@nestjs/common'; + +import { type CrudBodyValidationOptionsInterface } from './crud-body-validation-options.interface.js'; + +export interface CrudBodyOptionsInterface { + /** + * Options merged into the `StandardSchemaValidationPipe` used to + * validate `schema`. Overrides any controller/operation-level default. + * `false` disables validation for the body (it is still bound, just + * unvalidated). + */ + validation?: CrudBodyValidationOptionsInterface | false; + pipes?: (Type | PipeTransform)[]; + /** + * The schema to validate the body against — consumed by + * `crud-init-validation.decorator.ts` via + * `Body({ schema, pipes: [new StandardSchemaValidationPipe(...)] })`. + * When omitted, the body is still bound but never validated. + */ + schema?: z.ZodType; +} diff --git a/packages/nestjs-crud/src/infrastructure/decorators/params/interfaces/crud-body-validation-options.interface.ts b/packages/nestjs-crud/src/infrastructure/decorators/params/interfaces/crud-body-validation-options.interface.ts new file mode 100644 index 000000000..426184005 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/params/interfaces/crud-body-validation-options.interface.ts @@ -0,0 +1,21 @@ +import { type StandardSchemaValidationPipeOptions } from '@nestjs/common'; + +/** + * `@CrudBody()`'s own `validation` type — a superset of Nest's + * `StandardSchemaValidationPipeOptions`, decoupled from + * `CrudOptionsInterface.validation`/`@CrudValidate()` so crud-specific + * concerns like `allowEmpty` don't leak into that shared, pipe-forwarded + * type. + */ +export interface CrudBodyValidationOptionsInterface extends StandardSchemaValidationPipeOptions { + /** + * Whether an empty (`{}`) body is accepted, when the body's schema + * validates it. Default `true` — the schema is the contract, so if + * every field is optional, `{}` is already a valid body (see #466: + * resources whose columns are all server-populated legitimately post + * `{}`). Set `false` to reject `{}` even though the schema would + * otherwise allow it. Consumed by `CrudInitValidation` itself — not + * forwarded to `StandardSchemaValidationPipe`. + */ + allowEmpty?: boolean; +} diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/__tests__/crud-query-params-api.decorator.spec.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/__tests__/crud-query-params-api.decorator.spec.ts new file mode 100644 index 000000000..55287473e --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/__tests__/crud-query-params-api.decorator.spec.ts @@ -0,0 +1,57 @@ +import { Controller, Get, type INestApplication } from '@nestjs/common'; +import { + DocumentBuilder, + type OpenAPIObject, + SwaggerModule, +} from '@nestjs/swagger'; +import { Test } from '@nestjs/testing'; + +import { Operation } from '@concepta/nestjs-core'; + +import { Swagger } from '../../../utils/swagger.helper.js'; +import { CrudQueryParamsApi } from '../crud-query-params-api.decorator.js'; + +describe('CrudQueryParamsApi', () => { + let app: INestApplication; + let doc: OpenAPIObject; + + beforeAll(async () => { + @Controller('search') + class SearchController { + @Get() + @CrudQueryParamsApi() + search() { + return {}; + } + } + + const module = await Test.createTestingModule({ + controllers: [SearchController], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + + doc = SwaggerModule.createDocument(app, new DocumentBuilder().build()); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should apply the same @ApiQuery set generated List routes get', () => { + const parameters: unknown[] = doc.paths['/search']?.get?.parameters ?? []; + const names = parameters + .map((p) => + typeof p === 'object' && p !== null && 'name' in p ? p.name : undefined, + ) + .filter((name): name is string => typeof name === 'string'); + + const expectedNames = Swagger.createQueryParamsMeta(Operation.List).map( + (meta) => meta.name, + ); + + expect(expectedNames.length).toBeGreaterThan(0); + expect(names).toEqual(expect.arrayContaining(expectedNames)); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-adapter.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-adapter.decorator.ts new file mode 100644 index 000000000..db2e7962c --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-adapter.decorator.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_CONTROLLER_ADAPTER_METADATA } from '../../../crud.constants.js'; +import { type CrudAdapterProvider } from '../../adapters/interfaces/crud-adapter.types.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Set the adapter type used for the controller. + * + * Applied at controller level. + */ +export const CrudAdapter = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_CONTROLLER_ADAPTER_METADATA, + lookupTarget: CrudMetadataLookupTarget.Class, + }, + (decorator) => + ( + adapter?: CrudAdapterProvider, + ) => + decorator(adapter), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-allow.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-allow.decorator.ts new file mode 100644 index 000000000..8a574e43c --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-allow.decorator.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_ROUTE_QUERY_ALLOW_METADATA } from '../../../crud.constants.js'; +import { type CrudQueryOptionsInterface } from '../../request/interfaces/crud-query-options.interface.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD allow route decorator. + * + * Set the CRUD allow query option. + */ +export const CrudAllow = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_QUERY_ALLOW_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + }, + (decorator) => + ( + fields: CrudQueryOptionsInterface['allow'], + ) => + decorator(fields), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-cache.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-cache.decorator.ts new file mode 100644 index 000000000..6286e2af0 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-cache.decorator.ts @@ -0,0 +1,26 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_ROUTE_QUERY_CACHE_METADATA } from '../../../crud.constants.js'; +import { type CrudQueryOptionsInterface } from '../../request/interfaces/crud-query-options.interface.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD cache route decorator. + * + * Set the CRUD cache query option. Relies on repository adapter support + * for caching (e.g., TypeORM query caching). + */ +export const CrudCache = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_QUERY_CACHE_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + }, + (decorator) => + ( + cache: CrudQueryOptionsInterface['cache'], + ) => + decorator(cache), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-command-handler.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-command-handler.decorator.ts new file mode 100644 index 000000000..b016097ec --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-command-handler.decorator.ts @@ -0,0 +1,40 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { type CrudCommandHandlerInterface } from '../../../application/commands/interfaces/crud-command-handler.interface.js'; +import { CRUD_MODULE_ROUTE_COMMAND_HANDLER_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Options for CrudCommandHandler decorator. + */ +export interface CrudCommandHandlerOptionsInterface< + Entity extends PlainLiteralObject = PlainLiteralObject, +> { + /** Custom handler class to use directly */ + handler?: Type>; + /** Base handler class for generating default command handler class */ + handlerTemplate?: Type>; + /** Resolved handler class (set by CrudInitCommand) */ + resolved?: Type>; +} + +/** + * CRUD Command Handler route decorator. + * + * Stores command handler options as metadata. The actual handler class is resolved + * later by CrudInitCommandHandler, which applies defaults and generates classes. + */ +export const CrudCommandHandler = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_COMMAND_HANDLER_METADATA, + lookupTarget: CrudMetadataLookupTarget.Method, + }, + (decorator) => + ( + options: CrudCommandHandlerOptionsInterface = {}, + ) => + decorator(options), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-command.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-command.decorator.ts new file mode 100644 index 000000000..5a6204e9e --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-command.decorator.ts @@ -0,0 +1,40 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { type CrudCommandInterface } from '../../../application/commands/interfaces/crud-command.interface.js'; +import { CRUD_MODULE_ROUTE_COMMAND_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Options for CrudCommand decorator. + */ +export interface CrudCommandOptionsInterface< + Entity extends PlainLiteralObject = PlainLiteralObject, +> { + /** Custom command (command) class to use directly */ + command?: Type>; + /** Base command class for generating default command class */ + commandTemplate?: Type>; + /** Resolved command class (set by CrudInitCommand) */ + resolved?: Type>; +} + +/** + * CRUD Commmand route decorator. + * + * Stores command options as metadata. The actual command class is resolved + * later by CrudInitCommand, which applies defaults and generates classes. + */ +export const CrudCommand = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_COMMAND_METADATA, + lookupTarget: CrudMetadataLookupTarget.Method, + }, + (decorator) => + ( + options: CrudCommandOptionsInterface, + ) => + decorator(options), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-entity.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-entity.decorator.ts new file mode 100644 index 000000000..f43dc5536 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-entity.decorator.ts @@ -0,0 +1,15 @@ +import { CRUD_MODULE_CONTROLLER_ENTITY_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Set the entity key used for repository injection tokens. + * + * Applied at controller level. + */ +export const CrudEntity = CrudMetadata.createDecorator({ + key: CRUD_MODULE_CONTROLLER_ENTITY_METADATA, + lookupTarget: CrudMetadataLookupTarget.Class, +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-exclude.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-exclude.decorator.ts new file mode 100644 index 000000000..9e38699c1 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-exclude.decorator.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_ROUTE_QUERY_EXCLUDE_METADATA } from '../../../crud.constants.js'; +import { type CrudQueryOptionsInterface } from '../../request/interfaces/crud-query-options.interface.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD exclude route decorator. + * + * Set the CRUD exclude query option. + */ +export const CrudExclude = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_QUERY_EXCLUDE_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + }, + (decorator) => + ( + exclude: CrudQueryOptionsInterface['exclude'], + ) => + decorator(exclude), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-filter.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-filter.decorator.ts new file mode 100644 index 000000000..d7c22c988 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-filter.decorator.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_ROUTE_QUERY_FILTER_METADATA } from '../../../crud.constants.js'; +import { type CrudQueryOptionsInterface } from '../../request/interfaces/crud-query-options.interface.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD filter route decorator. + * + * Set the CRUD filter query option. + */ +export const CrudFilter = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_QUERY_FILTER_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + }, + (decorator) => + ( + filters: CrudQueryOptionsInterface['filter'], + ) => + decorator(filters), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-join.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-join.decorator.ts new file mode 100644 index 000000000..6e8c53521 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-join.decorator.ts @@ -0,0 +1,26 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_ROUTE_QUERY_JOIN_METADATA } from '../../../crud.constants.js'; +import { type CrudQueryOptionsInterface } from '../../request/interfaces/crud-query-options.interface.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD join route decorator. + * + * Set the CRUD join query option. + */ +export const CrudJoin = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_QUERY_JOIN_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + dedupeBy: 'relation', + }, + (decorator) => + ( + join: CrudQueryOptionsInterface['join'], + ) => + decorator(join), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-limit.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-limit.decorator.ts new file mode 100644 index 000000000..67c0c1536 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-limit.decorator.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_ROUTE_QUERY_LIMIT_METADATA } from '../../../crud.constants.js'; +import { type CrudQueryOptionsInterface } from '../../request/interfaces/crud-query-options.interface.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD limit route decorator. + * + * Set the CRUD limit query option. + */ +export const CrudLimit = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_QUERY_LIMIT_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + }, + (decorator) => + ( + limit: CrudQueryOptionsInterface['limit'], + ) => + decorator(limit), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-max-limit.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-max-limit.decorator.ts new file mode 100644 index 000000000..4480f28be --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-max-limit.decorator.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_ROUTE_QUERY_MAX_LIMIT_METADATA } from '../../../crud.constants.js'; +import { type CrudQueryOptionsInterface } from '../../request/interfaces/crud-query-options.interface.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD max limit route decorator. + * + * Set the CRUD max limit query option. + */ +export const CrudMaxLimit = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_QUERY_MAX_LIMIT_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + }, + (decorator) => + ( + maxLimit: CrudQueryOptionsInterface['maxLimit'], + ) => + decorator(maxLimit), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-name.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-name.decorator.ts new file mode 100644 index 000000000..89454f82c --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-name.decorator.ts @@ -0,0 +1,15 @@ +import { CRUD_MODULE_CONTROLLER_NAME_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Set the controller name used for CQRS class naming. + * + * Applied at controller level. + */ +export const CrudName = CrudMetadata.createDecorator({ + key: CRUD_MODULE_CONTROLLER_NAME_METADATA, + lookupTarget: CrudMetadataLookupTarget.Class, +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-operation.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-operation.decorator.ts new file mode 100644 index 000000000..3a910a7b2 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-operation.decorator.ts @@ -0,0 +1,15 @@ +import { type Operation } from '@concepta/nestjs-core'; + +import { CRUD_MODULE_ROUTE_OPERATION_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD operation route decorator. + */ +export const CrudOperation = CrudMetadata.createDecorator({ + key: CRUD_MODULE_ROUTE_OPERATION_METADATA, + lookupTarget: CrudMetadataLookupTarget.Method, +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-params.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-params.decorator.ts new file mode 100644 index 000000000..9b4a262e2 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-params.decorator.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_ROUTE_PARAMS_METADATA } from '../../../crud.constants.js'; +import { type CrudParamsOptionsInterface } from '../../interfaces/crud-params-options.interface.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD Params route decorator. + * + * Set the CRUD params. + */ +export const CrudParams = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_PARAMS_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + }, + (decorator) => + ( + params: CrudParamsOptionsInterface, + ) => + decorator(params), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-persist.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-persist.decorator.ts new file mode 100644 index 000000000..789a61822 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-persist.decorator.ts @@ -0,0 +1,25 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_ROUTE_QUERY_PERSIST_METADATA } from '../../../crud.constants.js'; +import { type CrudQueryOptionsInterface } from '../../request/interfaces/crud-query-options.interface.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD persist route decorator. + * + * Set the CRUD persist query option. + */ +export const CrudPersist = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_QUERY_PERSIST_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + }, + (decorator) => + ( + persist: CrudQueryOptionsInterface['persist'], + ) => + decorator(persist), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-query-handler.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-query-handler.decorator.ts new file mode 100644 index 000000000..a5d242b41 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-query-handler.decorator.ts @@ -0,0 +1,44 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { type CrudQueryHandlerInterface } from '../../../application/queries/interfaces/crud-query-handler.interface.js'; +import { CRUD_MODULE_ROUTE_QUERY_HANDLER_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Options for CrudQueryHandler decorator. + */ +export interface CrudQueryHandlerOptionsInterface< + Entity extends PlainLiteralObject = PlainLiteralObject, + Relations extends PlainLiteralObject[] = PlainLiteralObject[], +> { + /** Custom query (query handler) class to use directly */ + handler?: Type>; + /** Base query class for generating default query handler class */ + handlerTemplate?: Type>; + /** Resolved query handler class (set by CrudInitQuery) */ + resolved?: Type>; +} + +/** + * CRUD Query Handler route decorator. + * + * Stores query handler options as metadata. The actual handler class is resolved + * later by CrudInitQueryHandler, which applies defaults and generates classes. + */ +export const CrudQueryHandler = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_QUERY_HANDLER_METADATA, + lookupTarget: CrudMetadataLookupTarget.Method, + }, + (decorator) => + < + Entity extends PlainLiteralObject = PlainLiteralObject, + Relations extends PlainLiteralObject[] = PlainLiteralObject[], + >( + options: CrudQueryHandlerOptionsInterface = {}, + ) => + decorator(options), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-query-params-api.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-query-params-api.decorator.ts new file mode 100644 index 000000000..b7addfe69 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-query-params-api.decorator.ts @@ -0,0 +1,25 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiQuery } from '@nestjs/swagger'; + +import { Operation } from '@concepta/nestjs-core'; + +import { Swagger } from '../../utils/swagger.helper.js'; + +/** + * \@CrudQueryParamsApi() method decorator + * + * Documents CRUD's standard query-string contract (filter, or, sort, + * fields, limit, offset, page, cache, includeDeleted) via `@ApiQuery` — the + * exact same set `Swagger.createQueryParamsMeta` already generates for + * `@CrudList`/`@CrudRead` routes — applied immediately at decoration time. + * No class-scanning, no dependency on `CrudInit()`. + * + * Pair with `@CrudQueryParams()` on the same hand-written method to fully + * document the query it validates. + */ +export const CrudQueryParamsApi = (): MethodDecorator => + applyDecorators( + ...Swagger.createQueryParamsMeta(Operation.List).map((meta) => + ApiQuery(meta), + ), + ); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-query.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-query.decorator.ts new file mode 100644 index 000000000..515f254ad --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-query.decorator.ts @@ -0,0 +1,40 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { type CrudQueryInterface } from '../../../application/queries/interfaces/crud-query.interface.js'; +import { CRUD_MODULE_ROUTE_QUERY_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Options for CrudQuery decorator. + */ +export type CrudQueryDecoratorOptionsInterface< + Entity extends PlainLiteralObject = PlainLiteralObject, +> = { + /** Custom query (query) class to use directly */ + query?: Type>; + /** Base query class for generating default query class */ + queryTemplate?: Type>; + /** Resolved query class (set by CrudInitQuery) */ + resolved?: Type>; +}; + +/** + * CRUD Query route decorator. + * + * Stores query options as metadata. The actual query class is resolved + * later by CrudInitQuery, which applies defaults and generates classes. + */ +export const CrudQuery = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_QUERY_METADATA, + lookupTarget: CrudMetadataLookupTarget.Method, + }, + (decorator) => + ( + options: CrudQueryDecoratorOptionsInterface, + ) => + decorator(options), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-request-body-batch.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-request-body-batch.decorator.ts new file mode 100644 index 000000000..09e28d1cf --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-request-body-batch.decorator.ts @@ -0,0 +1,16 @@ +import { CRUD_MODULE_REQUEST_BODY_BATCH_METADATA } from '../../../crud.constants.js'; +import { type CrudSchema } from '../../../crud.types.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Set the expected body schema for batch mutations (createBatch). + * + * Can be applied at controller level (default) or method level (override). + */ +export const CrudRequestBodyBatch = CrudMetadata.createDecorator({ + key: CRUD_MODULE_REQUEST_BODY_BATCH_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-request-body.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-request-body.decorator.ts new file mode 100644 index 000000000..af8aebbb4 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-request-body.decorator.ts @@ -0,0 +1,17 @@ +import { CRUD_MODULE_REQUEST_BODY_METADATA } from '../../../crud.constants.js'; +import { type CrudSchema } from '../../../crud.types.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Set the expected body schema for single-item mutations + * (create, update, replace). + * + * Can be applied at controller level (default) or method level (override). + */ +export const CrudRequestBody = CrudMetadata.createDecorator({ + key: CRUD_MODULE_REQUEST_BODY_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-resolver.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-resolver.decorator.ts new file mode 100644 index 000000000..487a5833b --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-resolver.decorator.ts @@ -0,0 +1,25 @@ +import { CRUD_MODULE_RESOLVER_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadata, + CrudMetadataLookupTarget, +} from '../../services/crud-metadata.service.js'; + +/** + * Set the resolver for a controller. + * + * The resolver controls how operations are dispatched at runtime and + * how handlers are decorated at build time. Applied at the class level only. + * + * @example + * ```typescript + * @Controller('products') + * @CrudResolver(CrudCqrsResolver) + * class ProductController { + * // ... + * } + * ``` + */ +export const CrudResolver = CrudMetadata.createDecorator({ + key: CRUD_MODULE_RESOLVER_METADATA, + lookupTarget: CrudMetadataLookupTarget.Class, +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-response-paginated.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-response-paginated.decorator.ts new file mode 100644 index 000000000..174620dfc --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-response-paginated.decorator.ts @@ -0,0 +1,16 @@ +import { CRUD_MODULE_RESPONSE_PAGINATED_METADATA } from '../../../crud.constants.js'; +import { type CrudSchema } from '../../../crud.types.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Set the response schema for paginated responses. + * + * Can be applied at controller level (default) or method level (override). + */ +export const CrudResponsePaginated = CrudMetadata.createDecorator({ + key: CRUD_MODULE_RESPONSE_PAGINATED_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-response-resource.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-response-resource.decorator.ts new file mode 100644 index 000000000..c72ce69b1 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-response-resource.decorator.ts @@ -0,0 +1,16 @@ +import { CRUD_MODULE_RESPONSE_RESOURCE_METADATA } from '../../../crud.constants.js'; +import { type CrudSchema } from '../../../crud.types.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Set the response schema for single-item responses. + * + * Can be applied at controller level (default) or method level (override). + */ +export const CrudResponseResource = CrudMetadata.createDecorator({ + key: CRUD_MODULE_RESPONSE_RESOURCE_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-return-deleted.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-return-deleted.decorator.ts new file mode 100644 index 000000000..f9edb68ca --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-return-deleted.decorator.ts @@ -0,0 +1,16 @@ +import { CRUD_MODULE_ROUTE_RETURN_DELETED_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD return deleted route decorator. + * + * When set to true, the deleted entity will be returned in the response. + * Applies to Delete and SoftDelete operations. + */ +export const CrudReturnDeleted = CrudMetadata.createDecorator({ + key: CRUD_MODULE_ROUTE_RETURN_DELETED_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-return-restored.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-return-restored.decorator.ts new file mode 100644 index 000000000..04d82a683 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-return-restored.decorator.ts @@ -0,0 +1,16 @@ +import { CRUD_MODULE_ROUTE_RETURN_RESTORED_METADATA } from '../../../crud.constants.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD return restored route decorator. + * + * When set to true, the restored entity will be returned in the response. + * Applies to Restore operation. + */ +export const CrudReturnRestored = CrudMetadata.createDecorator({ + key: CRUD_MODULE_ROUTE_RETURN_RESTORED_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, +}); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-serialize.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-serialize.decorator.ts new file mode 100644 index 000000000..8381823d5 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-serialize.decorator.ts @@ -0,0 +1,15 @@ +import { CRUD_MODULE_ROUTE_SERIALIZATION_METADATA } from '../../../crud.constants.js'; +import { type CrudSerializationOptionsInterface } from '../../interfaces/crud-serialization-options.interface.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD serialize route decorator + */ +export const CrudSerialize = + CrudMetadata.createDecorator({ + key: CRUD_MODULE_ROUTE_SERIALIZATION_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + }); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-sort.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-sort.decorator.ts new file mode 100644 index 000000000..55c0fe68a --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-sort.decorator.ts @@ -0,0 +1,26 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_ROUTE_QUERY_SORT_METADATA } from '../../../crud.constants.js'; +import { type CrudQueryOptionsInterface } from '../../request/interfaces/crud-query-options.interface.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * CRUD sort route decorator. + * + * Set the CRUD sort query option. + */ +export const CrudSort = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_QUERY_SORT_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + dedupeBy: 'field', + }, + (decorator) => + ( + sort: CrudQueryOptionsInterface['sort'], + ) => + decorator(sort), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-validate.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-validate.decorator.ts new file mode 100644 index 000000000..f082cbfe2 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/routes/crud-validate.decorator.ts @@ -0,0 +1,34 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { CRUD_MODULE_ROUTE_VALIDATION_METADATA } from '../../../crud.constants.js'; +import { type CrudValidationOptions } from '../../../crud.types.js'; +import { + CrudMetadataLookupTarget, + CrudMetadata, +} from '../../services/crud-metadata.service.js'; + +/** + * Crud validate options decorator. + * + * Set the fallback ValidationPipe options for all method + * parameters called with the `CrudBody` decorator. + * + * If this decorator is used on a controller, it will use the given options for + * every controller method's Crud param that does NOT have validations explicitly set. + * + * If this decorator is used on a method, it will use the given options for + * every Crud parameter on the method that does NOT have validations explicitly set. + * + * @param options - crud validation options + */ +export const CrudValidate = CrudMetadata.createWrappedDecorator( + { + key: CRUD_MODULE_ROUTE_VALIDATION_METADATA, + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + }, + (decorator) => + ( + options?: CrudValidationOptions, + ) => + decorator(options), +); diff --git a/packages/nestjs-crud/src/infrastructure/decorators/util/apply-api-response.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/util/apply-api-response.decorator.ts new file mode 100644 index 000000000..507fe3704 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/util/apply-api-response.decorator.ts @@ -0,0 +1,173 @@ +import { type z } from 'zod'; + +import { HttpStatus } from '@nestjs/common'; +import { + ApiResponse, + type ApiResponseMetadata, + type ApiResponseOptions, +} from '@nestjs/swagger'; + +import { Operation, withOpenApi } from '@concepta/nestjs-core'; + +import { + type CrudSchema, + type DecoratorTargetObject, +} from '../../../crud.types.js'; +import { CrudException } from '../../exceptions/crud.exception.js'; +import { CrudMetaview } from '../../services/crud-metaview.service.js'; + +/** + * Utility decorator used to apply response + * options *from the controller context*. + * + * DO NOT USE THIS DIRECTLY ON METHODS!!! + */ +export function applyApiResponse( + operation: Operation, + options: ApiResponseOptions = {}, +): MethodDecorator { + return (target: DecoratorTargetObject, ...rest) => { + // break out args + const [propertyKey] = rest; + + // reflection service + const reflectionService = new CrudMetaview(); + + if (!('prototype' in target)) { + throw new CrudException({ + message: + 'Cannot decorate with apply api response, target must be a class', + fault: 'usage', + }); + } + + const handler = target.prototype[propertyKey]; + + // get the serialize options + const serializeOptions = reflectionService.getAllSerializationOptions( + target, + handler, + ); + + // determine the response schema + const schema = + serializeOptions?.resource ?? + reflectionService.getResponseResource(target, handler); + + // determine the paginated response schema + const paginatedSchema = + serializeOptions?.paginated ?? + reflectionService.getResponsePaginated(target, handler); + + // response meta options + const responseMetaOptions: ApiResponseMetadata = {}; + + // the schema actually documented by this operation — used only for the + // human-readable `description` string below + let displaySchema: CrudSchema | undefined; + + // operation is the discriminator + switch (operation) { + // list (paginated) + case Operation.List: + displaySchema = paginatedSchema; + setSingleResponse(responseMetaOptions, paginatedSchema); + break; + + // create batch (array response) + case Operation.CreateBatch: + displaySchema = schema; + if (schema !== undefined) { + assertBridged(schema, 'response schema'); + // withOpenApi (no id) bridges an inline array wrapper so Nest's + // native path converts it — the named item schema nested inside + // is hoisted into components.schemas automatically. + responseMetaOptions.standardSchema = withOpenApi(schema.array()); + } + break; + + // returns deleted item or empty + case Operation.Delete: + case Operation.SoftDelete: + displaySchema = reflectionService.getReturnDeleted( + target, + target.prototype[propertyKey], + ) + ? schema + : undefined; + setSingleResponse(responseMetaOptions, displaySchema); + break; + + // returns restored item or empty + case Operation.Restore: + displaySchema = reflectionService.getReturnRestored( + target, + target.prototype[propertyKey], + ) + ? schema + : undefined; + setSingleResponse(responseMetaOptions, displaySchema); + break; + + // returns one item + case Operation.Read: + case Operation.Create: + case Operation.Update: + case Operation.Replace: + default: + displaySchema = schema; + setSingleResponse(responseMetaOptions, schema); + break; + } + + // merge the options + const mergedOptions: ApiResponseOptions = { + status: HttpStatus.OK, + description: `${operation} ${displayName(displaySchema)}`, + ...responseMetaOptions, + ...options, + }; + + ApiResponse(mergedOptions)(target, ...rest); + }; +} + +// +// private routines +// + +/** + * Sets `responseMetaOptions.standardSchema` for a single-resource response; + * a no-op when there is no response schema to document (e.g. Delete/Restore + * configured not to return the entity). + */ +function setSingleResponse( + responseMetaOptions: ApiResponseMetadata, + schema: CrudSchema | undefined, +): void { + if (schema === undefined) return; + assertBridged(schema, 'response schema'); + responseMetaOptions.standardSchema = schema; +} + +/** + * A schema missing its `~standard.jsonSchema` bridge (i.e. never passed + * through `withOpenApi`) would otherwise silently produce an undocumented + * response (no schema in the OpenAPI output) — fail loudly instead. + */ +function assertBridged(schema: z.ZodType, context: string): void { + if (!schema['~standard'].jsonSchema?.output) { + throw new CrudException({ + message: `CRUD ${context} is missing its OpenAPI bridge — wrap it with withOpenApi() before using it as a CRUD response.`, + fault: 'usage', + }); + } +} + +/** + * Display name for a response schema — used only for the human-readable + * `description` string. + */ +function displayName(schema: CrudSchema | undefined): string { + return schema?.meta()?.id ?? 'Resource'; +} diff --git a/packages/nestjs-crud/src/infrastructure/decorators/util/apply-assert-target.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/util/apply-assert-target.decorator.ts new file mode 100644 index 000000000..c2b175353 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/util/apply-assert-target.decorator.ts @@ -0,0 +1,76 @@ +import { CrudDecoratorException } from '../../exceptions/crud-decorator.exception.js'; +import { CrudMetadataLookupTarget } from '../../services/crud-metadata.service.js'; + +/** + * A decorator function that can be applied to methods, classes, or parameters. + */ +export type UniversalDecorator = ( + target: object, + propertyKey?: string | symbol, + descriptorOrIndex?: PropertyDescriptor | number, +) => void; + +/** + * Decorator that asserts the target type matches the expected lookup target. + */ +export function applyAssertTarget( + lookupTarget: CrudMetadataLookupTarget, +): UniversalDecorator { + return ( + target: object, + propertyKey?: string | symbol, + descriptorOrIndex?: PropertyDescriptor | number, + ) => { + const targetName = + typeof target === 'function' ? target.name : target.constructor.name; + const location = propertyKey + ? `${targetName}.${String(propertyKey)}` + : targetName; + + switch (lookupTarget) { + case CrudMetadataLookupTarget.Class: + if (!(typeof target === 'function' && propertyKey === undefined)) { + throw new CrudDecoratorException({ + message: `Decorator can only be applied to classes, but was applied at ${location}`, + }); + } + break; + + case CrudMetadataLookupTarget.Method: + if (typeof target === 'function' && propertyKey === undefined) { + throw new CrudDecoratorException({ + message: `Decorator can only be applied to methods, but was applied to class ${targetName}`, + }); + } + if (typeof descriptorOrIndex === 'number') { + throw new CrudDecoratorException({ + message: `Decorator can only be applied to methods, but was applied to parameter ${descriptorOrIndex} at ${location}`, + }); + } + break; + + case CrudMetadataLookupTarget.Parameter: { + // Parameter decorators can be applied in two contexts: + // 1. By user: (target, propertyKey, parameterIndex) - parameterIndex is a number + // 2. Internally for metadata storage: (handler) - target is function, no propertyKey + const isParameterContext = typeof descriptorOrIndex === 'number'; + const isInternalStorage = + typeof target === 'function' && propertyKey === undefined; + if (!isParameterContext && !isInternalStorage) { + throw new CrudDecoratorException({ + message: `Decorator can only be applied to parameters, but was applied at ${location}`, + }); + } + break; + } + + case CrudMetadataLookupTarget.MethodAndClass: + if (typeof descriptorOrIndex === 'number') { + throw new CrudDecoratorException({ + message: `Decorator can only be applied to methods or classes, but was applied to parameter ${descriptorOrIndex} at ${location}`, + }); + } + break; + } + }; +} diff --git a/packages/nestjs-crud/src/infrastructure/decorators/util/apply-constructor-injection.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/util/apply-constructor-injection.decorator.ts new file mode 100644 index 000000000..49376175a --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/util/apply-constructor-injection.decorator.ts @@ -0,0 +1,17 @@ +import { CrudAdapter } from '../../adapters/crud.adapter.js'; + +import { InjectCrudAdapter } from './inject-crud-adapter.decorator.js'; + +/** + * Creates a decorator that applies DI metadata to a handler class + * that doesn't have an explicit constructor. + * Sets up `@InjectCrudAdapter` for the adapter parameter so NestJS can inject it. + */ +export function applyConstructorInjection(entity: string): ClassDecorator { + return (target) => { + // For constructor parameters, propertyKey is undefined + // Cast to bypass TypeScript's strict ParameterDecorator signature + InjectCrudAdapter(entity)(target, undefined!, 0); + Reflect.defineMetadata('design:paramtypes', [CrudAdapter], target); + }; +} diff --git a/packages/nestjs-crud/src/infrastructure/decorators/util/inject-crud-adapter.decorator.ts b/packages/nestjs-crud/src/infrastructure/decorators/util/inject-crud-adapter.decorator.ts new file mode 100644 index 000000000..b50fdd43f --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/decorators/util/inject-crud-adapter.decorator.ts @@ -0,0 +1,24 @@ +import { Inject } from '@nestjs/common'; + +import { getDynamicAdapterToken } from '../../utils/crud-infra.utils.js'; + +/** + * Decorator to inject a CRUD adapter by entity name + * + * @example + * ```typescript + * @Injectable() + * export class SomeService { + * constructor( + * @InjectCrudAdapter('User') + * protected readonly crudAdapter: CrudAdapter, + * ) {} + * } + * ``` + * + * @param name - The entity name used in the model configuration + * @returns A parameter decorator for dependency injection + */ +export function InjectCrudAdapter(name: string) { + return Inject(getDynamicAdapterToken(name)); +} diff --git a/packages/nestjs-crud/src/infrastructure/exceptions/crud-context.exception.ts b/packages/nestjs-crud/src/infrastructure/exceptions/crud-context.exception.ts new file mode 100644 index 000000000..c8a708d5f --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/exceptions/crud-context.exception.ts @@ -0,0 +1,17 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { CrudException } from './crud.exception.js'; + +/** + * Crud context exception. + */ +export class CrudContextException extends CrudException { + constructor(options?: RuntimeExceptionOptions) { + super({ + safeMessage: 'Error on crud context processing', + fault: 'internal', + ...options, + }); + this.errorCode = 'CRUD_CONTEXT_ERROR'; + } +} diff --git a/packages/nestjs-crud/src/infrastructure/exceptions/crud-decorator.exception.ts b/packages/nestjs-crud/src/infrastructure/exceptions/crud-decorator.exception.ts new file mode 100644 index 000000000..4d66bed7f --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/exceptions/crud-decorator.exception.ts @@ -0,0 +1,10 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { CrudException } from './crud.exception.js'; + +export class CrudDecoratorException extends CrudException { + constructor(options?: RuntimeExceptionOptions) { + super({ fault: 'usage', ...options }); + this.errorCode = 'CRUD_DECORATOR_ERROR'; + } +} diff --git a/packages/nestjs-crud/src/infrastructure/exceptions/crud-query.exception.ts b/packages/nestjs-crud/src/infrastructure/exceptions/crud-query.exception.ts new file mode 100644 index 000000000..19fa2daff --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/exceptions/crud-query.exception.ts @@ -0,0 +1,28 @@ +import { + type RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +import { CrudException } from './crud.exception.js'; + +export class CrudQueryException extends CrudException { + declare context: RuntimeException['context'] & { + entityName: string; + }; + + constructor(entityName: string, options?: RuntimeExceptionOptions) { + super({ + message: 'Error while trying to query the %s entity', + messageParams: [entityName], + fault: 'internal', + ...options, + }); + + this.context = { + ...this.context, + entityName, + }; + + this.errorCode = 'CRUD_QUERY_ERROR'; + } +} diff --git a/packages/nestjs-crud/src/infrastructure/exceptions/crud.exception.ts b/packages/nestjs-crud/src/infrastructure/exceptions/crud.exception.ts new file mode 100644 index 000000000..75ed935ed --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/exceptions/crud.exception.ts @@ -0,0 +1,17 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; +/** + * Generic crud exception. + */ +export class CrudException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super(options); + this.errorCode = 'CRUD_ERROR'; + + this.context = { + ...this.context, + }; + } +} diff --git a/packages/nestjs-crud/src/infrastructure/interceptors/__tests__/crud-context.interceptor.e2e-spec.ts b/packages/nestjs-crud/src/infrastructure/interceptors/__tests__/crud-context.interceptor.e2e-spec.ts new file mode 100644 index 000000000..75da87dbd --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interceptors/__tests__/crud-context.interceptor.e2e-spec.ts @@ -0,0 +1,251 @@ +import supertest from 'supertest'; + +import { Param, ParseIntPipe, Query } from '@nestjs/common'; +import { NestApplication } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; + +import { Ctx } from '@concepta/nestjs-core'; +import { + OrderSortKeyArr, + RepositoryInterface, + WhereConditionArr, +} from '@concepta/nestjs-repository'; + +import { TestCrudAdapter } from '../../../__fixtures__/crud/adapters/test-crud.adapter.js'; +import { TestModel } from '../../../__fixtures__/crud/models/test.model.js'; +import { testModelSchema } from '../../../__fixtures__/crud/schemas/test-model.schema.js'; +import { CrudModule } from '../../../crud.module.js'; +import { CrudController } from '../../decorators/controller/crud-controller.decorator.js'; +import { CrudList } from '../../decorators/operations/crud-list.decorator.js'; +import { CrudRead } from '../../decorators/operations/crud-read.decorator.js'; +import { CrudQueryBuilder } from '../../request/crud-query.builder.js'; +import { paginatedSchema } from '../../schemas/crud-response-paginated.schema.js'; +import { CrudCtx } from '../crud-context.overlay.js'; +import { CrudContextInterface } from '../interfaces/crud-context.interface.js'; + +// tslint:disable:max-classes-per-file +describe('#crud', () => { + @CrudController({ + path: 'test', + entity: 'Test', + adapter: TestCrudAdapter, + request: { + params: { + someParam: { field: 'age', type: 'number' }, + }, + }, + response: { + resource: testModelSchema, + // TestController has two @CrudList operations, so + // apply-api-response.decorator.ts requires a matching (both-Zod) + // paginated type even though these routes never actually return a + // paginated shape (see below) — the mismatch check fires at + // decoration time, unconditional on real response content. + paginated: paginatedSchema(testModelSchema), + }, + }) + class TestController { + @CrudList({ path: '/query' }) + async query(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return { query: ctx.withCrud().query }; + } + + @CrudList({ path: '/other' }) + async other(@Query('page', ParseIntPipe) page: number) { + return { page }; + } + + @CrudRead({ path: '/other2/:someParam' }) + async routeWithParam(@Param('someParam', ParseIntPipe) p: number) { + return { p }; + } + } + + @CrudController({ + path: 'test2', + entity: 'Test2', + adapter: TestCrudAdapter, + request: { + params: { + id: { field: 'id', type: 'number' }, + someParam: { field: 'age', type: 'number' }, + }, + }, + response: { + resource: testModelSchema, + }, + }) + class Test2Controller { + @CrudRead({ path: 'normal/:id' }) + async normal(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return { params: ctx.withCrud().params }; + } + + @CrudRead({ path: 'other2/:someParam' }) + async routeWithParam(@Param('someParam', ParseIntPipe) p: number) { + return { p }; + } + + @CrudRead({ + path: 'other2/:id/twoParams/:someParam', + }) + async twoParams( + @Ctx(CrudCtx) ctx: CrudContextInterface, + @Param('someParam', ParseIntPipe) _p: number, + ) { + return { params: ctx.withCrud().params }; + } + } + + let $: ReturnType; + let app: NestApplication; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [CrudModule.forRoot({})], + providers: [ + { + provide: TestCrudAdapter, + useFactory: () => { + const mockRepo: RepositoryInterface = { + metadata: { + name: 'TestModel', + type: TestModel, + columns: [ + { + name: 'id', + isPrimary: true, + isRemoveDate: false, + isVersion: false, + }, + { + name: 'firstName', + isPrimary: false, + isRemoveDate: false, + isVersion: false, + }, + { + name: 'lastName', + isPrimary: false, + isRemoveDate: false, + isVersion: false, + }, + ], + }, + find: vi.fn(), + findOne: vi.fn(), + count: vi.fn(), + findAndCount: vi.fn(), + create: vi.fn(), + createMany: vi.fn(), + update: vi.fn(), + upsert: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + deleteMany: vi.fn(), + softDelete: vi.fn(), + restore: vi.fn(), + transform: vi.fn(), + merge: vi.fn(), + prepare: vi.fn(), + }; + return new TestCrudAdapter(mockRepo); + }, + }, + ], + controllers: [TestController, Test2Controller], + }).compile(); + app = module.createNestApplication(); + await app.init(); + + $ = supertest(app.getHttpServer()); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('#interceptor', () => { + let qb: CrudQueryBuilder; + + beforeEach(() => { + qb = CrudQueryBuilder.create(); + }); + + it('should working on non-crud controller', async () => { + const page = 2; + const limit = 10; + const fields = ['a', 'b', 'c']; + const sorts: OrderSortKeyArr[] = [ + ['firstName', 'ASC'], + ['lastName', 'DESC'], + ]; + const filters: WhereConditionArr[] = [ + ['id', 'in', [1, 2, 3]], + ['firstName', 'eq', 'John'], + ['lastName', 'nnull'], + ]; + + qb.setPage(page).setLimit(limit); + qb.select(fields); + for (const s of sorts) { + qb.sortBy({ field: s[0], order: s[1] }); + } + for (const f of filters) { + qb.setFilter(f); + } + + const res = await $.get('/test/query').query(qb.query()).expect(200); + expect(res.body.query).toHaveProperty('page', page); + expect(res.body.query).toHaveProperty('limit', limit); + expect(res.body.query).toHaveProperty('fields', fields); + expect(res.body.query).toHaveProperty('sort'); + for (let i = 0; i < sorts.length; i++) { + expect(res.body.query.sort[i]).toHaveProperty('field', sorts[i][0]); + expect(res.body.query.sort[i]).toHaveProperty('order', sorts[i][1]); + } + expect(res.body.query).toHaveProperty('filter'); + for (let i = 0; i < filters.length; i++) { + expect(res.body.query.filter[i]).toHaveProperty('field', filters[i][0]); + expect(res.body.query.filter[i]).toHaveProperty( + 'operator', + filters[i][1], + ); + if (filters[i][2] !== undefined) { + expect(res.body.query.filter[i]).toHaveProperty( + 'value', + filters[i][2], + ); + } + } + }); + + it('should others working', async () => { + const res = await $.get('/test/other') + .query({ page: 2, limit: 11 }) + .expect(200); + expect(res.body.page).toBe(2); + }); + + it('should parse param', async () => { + const res = await $.get('/test/other2/123').expect(200); + expect(res.body.p).toBe(123); + }); + + it('should parse custom param in crud', async () => { + const res = await $.get('/test2/other2/123').expect(200); + expect(res.body.p).toBe(123); + }); + + it('should parse crud param and custom param', async () => { + const res = await $.get('/test2/other2/1/twoParams/123').expect(200); + expect(res.body.params).toHaveProperty('id', 1); + expect(res.body.params).toHaveProperty('age', 123); + }); + + it('should work like before', async () => { + const res = await $.get('/test2/normal/0').expect(200); + expect(res.body.params).toHaveProperty('id', 0); + }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/interceptors/crud-context.overlay.ts b/packages/nestjs-crud/src/infrastructure/interceptors/crud-context.overlay.ts new file mode 100644 index 000000000..535007db6 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interceptors/crud-context.overlay.ts @@ -0,0 +1,170 @@ +import { + ExecutionContext, + forwardRef, + HttpException, + HttpStatus, + Inject, + Injectable, + PlainLiteralObject, +} from '@nestjs/common'; + +import { + ContextOverlayInterceptor, + getAppContext, + Operation, + OverlayRef, +} from '@concepta/nestjs-core'; + +import { ControllerTarget, MethodHandler } from '../../crud.types.js'; +import { CrudContextException } from '../exceptions/crud-context.exception.js'; +import { CrudQueryParser } from '../request/crud-query.parser.js'; +import { CrudMetaview } from '../services/crud-metaview.service.js'; +import { operationToAction } from '../utils/crud-infra.utils.js'; + +import { CrudContextInterface } from './interfaces/crud-context.interface.js'; +import { CrudRouteOptionsInterface } from './interfaces/crud-route-options.interface.js'; + +export const CrudCtx = new OverlayRef<'withCrud', CrudContextInterface>( + 'withCrud', +); + +@Injectable() +export class CrudContextOverlay< + T extends PlainLiteralObject = PlainLiteralObject, +> extends ContextOverlayInterceptor { + readonly ref = CrudCtx; + + constructor( + @Inject(forwardRef(() => CrudMetaview)) + private reflectionService: CrudMetaview, + ) { + super(); + } + + private resolve( + context: ExecutionContext | undefined, + ): CrudContextInterface { + if (!context) { + throw new CrudContextException({ + message: 'CrudContextOverlay requires an ExecutionContext', + fault: 'usage', + }); + } + + try { + const req = context.switchToHttp().getRequest(); + const target = context.getClass(); + const handler = context.getHandler(); + + const ctxOptions = this.reflectionService.getContextOptions( + target, + handler, + ); + + const parser = CrudQueryParser.create(); + parser.parseQuery(req.query); + + if (req.params) { + parser.parseParams(req.params, ctxOptions.params ?? {}); + } + + const entity = this.reflectionService.getEntity(target); + + if (!entity) { + throw new CrudContextException({ + message: `No entity defined for ${target.name} (use @CrudEntity or @CrudController)`, + fault: 'usage', + }); + } + + const operation = this.reflectionService.getOperation(handler); + + if (!operation) { + throw new CrudContextException({ + message: `No CRUD operation defined for ${target.name}.${handler.name}`, + fault: 'usage', + }); + } + + const route = this.getRouteOptions(target, handler, operation); + + const result: CrudContextInterface = { + entity, + operation, + action: operationToAction(operation), + params: parser.getRouteParams(), + query: parser.getParsedQuery(), + options: { + query: ctxOptions.query, + params: ctxOptions.params, + route, + }, + }; + + return result; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + // Genuinely unexpected: query/param parsing errors are HttpExceptions + // and already rethrown above, so anything reaching here is a bug or + // an infrastructure failure, not something the caller did. + throw new CrudContextException({ + httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + originalError: error, + }); + } + } + + attach(context: ExecutionContext): void { + const target = context.getClass(); + const handler = context.getHandler(); + + if ( + !this.reflectionService.getEntity(target) || + !this.reflectionService.getOperation(handler) + ) { + return; + } + + const request = context.switchToHttp().getRequest(); + const ctx = getAppContext(request); + const resolved = this.resolve(context); + ctx.defineOverlay(CrudCtx, resolved); + } + + private getRouteOptions( + target: ControllerTarget, + handler: MethodHandler, + operation: Operation, + ): CrudRouteOptionsInterface { + const queryOptions = this.reflectionService.getQuery(handler); + const commandOptions = this.reflectionService.getCommand(handler); + + const routeOptions: CrudRouteOptionsInterface = { + query: queryOptions?.resolved, + queryHandler: this.reflectionService.getQueryHandler(handler), + command: commandOptions?.resolved, + commandHandler: this.reflectionService.getCommandHandler(handler), + }; + + switch (operation) { + case Operation.Delete: + case Operation.SoftDelete: + routeOptions.returnDeleted = this.reflectionService.getReturnDeleted( + target, + handler, + ); + break; + case Operation.Restore: + routeOptions.returnRestored = this.reflectionService.getReturnRestored( + target, + handler, + ); + break; + } + + return routeOptions; + } +} diff --git a/packages/nestjs-crud/src/infrastructure/interceptors/crud-serialize.interceptor.e2e-spec.ts b/packages/nestjs-crud/src/infrastructure/interceptors/crud-serialize.interceptor.e2e-spec.ts new file mode 100644 index 000000000..3b7a16beb --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interceptors/crud-serialize.interceptor.e2e-spec.ts @@ -0,0 +1,111 @@ +import supertest from 'supertest'; +import { z } from 'zod'; + +import { + Controller, + Get, + type INestApplication, + Module, + UseInterceptors, +} from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { withNamedComponent } from '@concepta/nestjs-core'; + +import { CrudModule } from '../../crud.module.js'; +import { CrudResponseResource } from '../decorators/routes/crud-response-resource.decorator.js'; +import { CrudSerialize } from '../decorators/routes/crud-serialize.decorator.js'; + +import { CrudSerializeInterceptor } from './crud-serialize.interceptor.js'; + +const widgetSchema = withNamedComponent( + z.object({ id: z.string(), name: z.string() }), + 'SerializerSpecWidget', +); + +const gadgetSchema = withNamedComponent( + z.object({ id: z.string(), title: z.string() }), + 'SerializerSpecGadget', +); + +@Controller('widgets') +@CrudResponseResource(widgetSchema) +@CrudSerialize({}) +@UseInterceptors(CrudSerializeInterceptor) +class WidgetsController { + @Get('valid') + valid() { + return { id: '1', name: 'a', secret: 'strip-me' }; + } + + @Get('invalid') + invalid() { + // missing `name` — does not match widgetSchema + return { id: '1' }; + } + + // shares the class-level `@CrudSerialize({})` metadata object with every + // other handler above — only its response resource is overridden + @Get('gadget') + @CrudResponseResource(gadgetSchema) + gadget() { + return { id: '1', title: 'g' }; + } +} + +@Module({ + imports: [CrudModule.forRoot({})], + controllers: [WidgetsController], + providers: [], +}) +class WidgetsModuleFixture {} + +describe('CrudSerializeInterceptor schema path (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [WidgetsModuleFixture], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + return app ? await app.close() : undefined; + }); + + it('shapes a valid response, stripping fields not in the schema', async () => { + const res = await supertest(app.getHttpServer()).get('/widgets/valid'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: '1', name: 'a' }); + }); + + it('fails closed with a normalized 500 (not a raw Error) when the response does not match its schema', async () => { + const res = await supertest(app.getHttpServer()).get('/widgets/invalid'); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ + statusCode: 500, + message: 'Internal Server Error', + error: 'Internal Server Error', + errorCode: 'CRUD_ERROR', + }); + }); + + it('does not leak a resolved response resource across handlers sharing one class-level @CrudSerialize object', async () => { + // resolves and (pre-fix) would mutate the shared class-level metadata + // object with `resource: widgetSchema` + const first = await supertest(app.getHttpServer()).get('/widgets/valid'); + expect(first.status).toBe(200); + + // this handler overrides the response resource at method level; if the + // previous request's resolution had leaked into the shared object, this + // would incorrectly serialize against widgetSchema instead of + // gadgetSchema and fail closed with a 500 + const second = await supertest(app.getHttpServer()).get('/widgets/gadget'); + expect(second.status).toBe(200); + expect(second.body).toEqual({ id: '1', title: 'g' }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/interceptors/crud-serialize.interceptor.ts b/packages/nestjs-crud/src/infrastructure/interceptors/crud-serialize.interceptor.ts new file mode 100644 index 000000000..fa3bbfa3e --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interceptors/crud-serialize.interceptor.ts @@ -0,0 +1,130 @@ +import { from, Observable } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; +import { type z } from 'zod'; + +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + PlainLiteralObject, + StreamableFile, +} from '@nestjs/common'; + +import { isObject, isStandardSchema } from '@concepta/nestjs-core'; + +import { CrudException } from '../exceptions/crud.exception.js'; +import { CrudResponsePaginatedInterface } from '../interfaces/crud-response-paginated.interface.js'; +import { CrudSerializationOptionsInterface } from '../interfaces/crud-serialization-options.interface.js'; +import { CrudMetaview } from '../services/crud-metaview.service.js'; +import { crudIsPaginatedHelper } from '../utils/crud-is-paginated.helper.js'; + +type ResponseType = + | (PlainLiteralObject & CrudResponsePaginatedInterface) + | Array; + +@Injectable() +export class CrudSerializeInterceptor< + T extends PlainLiteralObject = PlainLiteralObject, +> implements NestInterceptor { + constructor(private reflectionService: CrudMetaview) {} + + /** + * @internal + */ + intercept(context: ExecutionContext, next: CallHandler): Observable { + // get the options + const options = this.getOptions(context); + + // serialize the response — schema-based serialization + // (this.toSchema) can be async, so resolve uniformly via mergeMap + // rather than map. + return next + .handle() + .pipe( + mergeMap((response: ResponseType) => + from(Promise.resolve(this.serialize(response, options))), + ), + ); + } + + /** + * @internal + */ + protected serialize( + response: ResponseType, + options: CrudSerializationOptionsInterface, + ): unknown | Promise { + // reasons to bail + if (!isObject(response) || response instanceof StreamableFile) { + // return response untouched + return response; + } + + // determine the schema to use + const schema = + !Array.isArray(response) && crudIsPaginatedHelper(response) === true + ? options?.paginated + : options?.resource; + + // this should never happen, but needed just in case somebody + // removes the response resource/paginated schema configuration + if (schema === undefined || !isStandardSchema(schema)) { + throw new CrudException({ + message: 'Impossible to serialize data without a response schema.', + fault: 'usage', + }); + } + + return this.toSchema(schema, response); + } + + /** + * Shapes a response through a Zod (Standard Schema) schema — the schema + * parse strips unknown/underscore-prefixed keys. Fail-closed: our own + * code returning a shape that doesn't match its DECLARED response schema + * is a server bug, not a client error, so this throws a `CrudException` + * (a `RuntimeException`, 500 by default) — never a raw, unnormalized + * `Error` — matching what `StandardSchemaSerializerInterceptor` would + * otherwise throw directly. + */ + protected async toSchema( + schema: z.ZodType, + response: ResponseType, + ): Promise { + const result = await schema['~standard'].validate(response); + if (result.issues) { + throw new CrudException({ + message: 'Response failed schema validation: %s', + messageParams: [result.issues.map((issue) => issue.message).join('; ')], + originalError: new Error(JSON.stringify(result.issues)), + fault: 'internal', + }); + } + return result.value; + } + + protected getOptions( + context: ExecutionContext, + ): CrudSerializationOptionsInterface { + const target = context.getClass(); + const handler = context.getHandler(); + + // get serialization options — this is the actual stored decorator + // metadata object (returned by reference, not a copy), so it must + // never be mutated here; build and return a fresh object instead + const options = this.reflectionService.getAllSerializationOptions( + target, + handler, + ); + + return { + resource: + options?.resource ?? + this.reflectionService.getResponseResource(target, handler), + paginated: + options?.paginated ?? + this.reflectionService.getResponsePaginated(target, handler), + }; + } +} diff --git a/packages/nestjs-crud/src/infrastructure/interceptors/interfaces/crud-context-options.interface.ts b/packages/nestjs-crud/src/infrastructure/interceptors/interfaces/crud-context-options.interface.ts new file mode 100644 index 000000000..d97f5c048 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interceptors/interfaces/crud-context-options.interface.ts @@ -0,0 +1,12 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudParamsOptionsInterface } from '../../interfaces/crud-params-options.interface.js'; +import { type CrudQueryOptionsInterface } from '../../request/interfaces/crud-query-options.interface.js'; + +import { type CrudRouteOptionsInterface } from './crud-route-options.interface.js'; + +export interface CrudContextOptionsInterface { + query?: CrudQueryOptionsInterface; + route?: CrudRouteOptionsInterface; + params?: CrudParamsOptionsInterface; +} diff --git a/packages/nestjs-crud/src/infrastructure/interceptors/interfaces/crud-context.interface.ts b/packages/nestjs-crud/src/infrastructure/interceptors/interfaces/crud-context.interface.ts new file mode 100644 index 000000000..4fec1a2e9 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interceptors/interfaces/crud-context.interface.ts @@ -0,0 +1,39 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ActionEnum, type Operation } from '@concepta/nestjs-core'; + +import { type CrudParsedQueryInterface } from '../../request/interfaces/crud-parsed-query.interface.js'; +import { type CrudSpecContextInterface } from '../../specifications/interfaces/crud-spec-context.interface.js'; + +import { type CrudContextOptionsInterface } from './crud-context-options.interface.js'; + +export interface CrudContextInterface< + T extends PlainLiteralObject = PlainLiteralObject, +> + extends PlainLiteralObject, CrudSpecContextInterface { + /** + * The entity name for this CRUD context (used for adapter resolution). + */ + entity: string; + /** + * Route parameter values from URL path (e.g., `\{ id: 5, userId: 'abc' \}`). + * Simple key-value object, not WhereCondition[]. + */ + params: Record; + /** + * Parsed query string parameters (filter, sort, pagination, etc.). + */ + query: CrudParsedQueryInterface; + /** + * Options for the current request including query and route configuration. + */ + options: CrudContextOptionsInterface; + /** + * The CRUD operation being performed (List, Read, Create, etc.). + */ + operation: Operation; + /** + * The action category (CREATE, READ, UPDATE, DELETE). + */ + action: ActionEnum; +} diff --git a/packages/nestjs-crud/src/infrastructure/interceptors/interfaces/crud-route-options.interface.ts b/packages/nestjs-crud/src/infrastructure/interceptors/interfaces/crud-route-options.interface.ts new file mode 100644 index 000000000..82616fee9 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interceptors/interfaces/crud-route-options.interface.ts @@ -0,0 +1,30 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { type CrudCommandInterface } from '../../../application/commands/interfaces/crud-command.interface.js'; +import { type CrudQueryInterface } from '../../../application/queries/interfaces/crud-query.interface.js'; + +/** + * Resolved handler options containing the handler class. + */ +interface CrudResolvedHandlerOptions { + resolved?: Type; +} + +/** + * Runtime route options available in CrudContext. + * Contains query/command types, handlers, and return behavior flags. + */ +export interface CrudRouteOptionsInterface { + /** Resolved query class */ + query?: Type>; + /** Resolved query handler options */ + queryHandler?: CrudResolvedHandlerOptions; + /** Resolved command class */ + command?: Type>; + /** Resolved command handler options */ + commandHandler?: CrudResolvedHandlerOptions; + /** Return deleted entity on delete or soft delete operation */ + returnDeleted?: boolean; + /** Return restored entity on restore operation */ + returnRestored?: boolean; +} diff --git a/packages/nestjs-crud/src/infrastructure/interfaces/crud-controller-entity.interface.ts b/packages/nestjs-crud/src/infrastructure/interfaces/crud-controller-entity.interface.ts new file mode 100644 index 000000000..9eb2cc07f --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interfaces/crud-controller-entity.interface.ts @@ -0,0 +1,17 @@ +/** + * Interface for controller entity and name configuration. + * + * Used by controller options and CQRS handler factories. + */ +export interface CrudControllerEntityInterface { + /** + * Entity key used for repository/adapter injection tokens. + */ + entity: string; + + /** + * Name used for CQRS class naming and operationIds. + * Falls back to entity if not provided. + */ + name?: string; +} diff --git a/packages/nestjs-crud/src/infrastructure/interfaces/crud-controller-options.interface.ts b/packages/nestjs-crud/src/infrastructure/interfaces/crud-controller-options.interface.ts new file mode 100644 index 000000000..cbb8af9b7 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interfaces/crud-controller-options.interface.ts @@ -0,0 +1,62 @@ +import { + type ControllerOptions, + type PlainLiteralObject, + type Type, +} from '@nestjs/common'; + +import { type CrudAdapterProvider } from '../adapters/interfaces/crud-adapter.types.js'; +import { type CrudRequestConfig } from '../request/interfaces/crud-request-config.interface.js'; +import { type CrudResponseConfig } from '../request/interfaces/crud-response-config.interface.js'; +import { type CrudResolverInterface } from '../resolvers/interfaces/crud-resolver.interface.js'; + +import { type CrudControllerEntityInterface } from './crud-controller-entity.interface.js'; +import { type CrudTransactionalInterface } from './crud-transactional.interface.js'; + +/** + * Controller options for pre-decorated class path (build() with class). + * + * Use this when you have an already-decorated controller class. + * The builder will only add missing CQRS metadata. + * + * Adapter is read from class metadata (`@CrudController` decorator). + */ +export interface CrudControllerClassOptionsInterface { + /** + * Pre-decorated controller class. + * Must have `@CrudController` and operation decorators already applied. + */ + class: Type; +} + +/** + * Controller options for builder-generated controllers. + * + * Use this when you want the builder to generate a controller class. + */ +export interface CrudControllerOptionsInterface + extends + ControllerOptions, + CrudControllerEntityInterface, + CrudTransactionalInterface { + /** + * Adapter provider for CRUD operations. + * Defaults to CrudAdapter. + */ + adapter?: CrudAdapterProvider; + + /** + * Resolver class for dispatching operations. + * Defaults to CrudAdapterResolver (calls adapter directly). + */ + resolver?: Type; + + /** + * Request configuration (params, body schemas, validation). + */ + request?: CrudRequestConfig; + + /** + * Response configuration (resource schemas, serialization). + */ + response?: CrudResponseConfig; +} diff --git a/packages/nestjs-crud/src/infrastructure/interfaces/crud-create-batch.interface.ts b/packages/nestjs-crud/src/infrastructure/interfaces/crud-create-batch.interface.ts new file mode 100644 index 000000000..555509c1a --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interfaces/crud-create-batch.interface.ts @@ -0,0 +1,3 @@ +export interface CrudCreateBatchInterface { + bulk: T[]; +} diff --git a/packages/nestjs-crud/src/infrastructure/interfaces/crud-param-option.interface.ts b/packages/nestjs-crud/src/infrastructure/interfaces/crud-param-option.interface.ts new file mode 100644 index 000000000..4971a688f --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interfaces/crud-param-option.interface.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type EntityColumn } from '@concepta/nestjs-repository'; + +type SwaggerEnumType = + | (string | number | boolean)[] + | Record; + +export interface CrudParamOptionInterface { + field?: EntityColumn; + type?: 'number' | 'string' | 'uuid'; + enum?: SwaggerEnumType; + primary?: boolean; + disabled?: boolean; +} diff --git a/packages/nestjs-crud/src/infrastructure/interfaces/crud-params-options.interface.ts b/packages/nestjs-crud/src/infrastructure/interfaces/crud-params-options.interface.ts new file mode 100644 index 000000000..e2f414f6e --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interfaces/crud-params-options.interface.ts @@ -0,0 +1,7 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type CrudParamOptionInterface } from './crud-param-option.interface.js'; + +export interface CrudParamsOptionsInterface { + [key: string]: CrudParamOptionInterface; +} diff --git a/packages/nestjs-crud/src/crud/interfaces/crud-response-metrics.interface.ts b/packages/nestjs-crud/src/infrastructure/interfaces/crud-response-metrics.interface.ts similarity index 100% rename from packages/nestjs-crud/src/crud/interfaces/crud-response-metrics.interface.ts rename to packages/nestjs-crud/src/infrastructure/interfaces/crud-response-metrics.interface.ts diff --git a/packages/nestjs-crud/src/infrastructure/interfaces/crud-response-paginated.interface.ts b/packages/nestjs-crud/src/infrastructure/interfaces/crud-response-paginated.interface.ts new file mode 100644 index 000000000..2059693c0 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interfaces/crud-response-paginated.interface.ts @@ -0,0 +1,11 @@ +import { type CrudResponseMetrics } from './crud-response-metrics.interface.js'; + +export interface CrudResponsePaginatedInterface { + data: T[]; + limit: number; + count: number; + total: number; + page: number; + pageCount: number; + metrics?: CrudResponseMetrics; +} diff --git a/packages/nestjs-crud/src/infrastructure/interfaces/crud-route-ctlr-options.interface.ts b/packages/nestjs-crud/src/infrastructure/interfaces/crud-route-ctlr-options.interface.ts new file mode 100644 index 000000000..b0b377266 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interfaces/crud-route-ctlr-options.interface.ts @@ -0,0 +1,59 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; +import { + type ApiBodyOptions, + type ApiOperationOptions, + type ApiParamOptions, + type ApiQueryOptions, + type ApiResponseOptions, +} from '@nestjs/swagger'; + +import { type CrudCommandHandlerInterface } from '../../application/commands/interfaces/crud-command-handler.interface.js'; +import { type CrudCommandInterface } from '../../application/commands/interfaces/crud-command.interface.js'; +import { type CrudQueryHandlerInterface } from '../../application/queries/interfaces/crud-query-handler.interface.js'; +import { type CrudQueryInterface } from '../../application/queries/interfaces/crud-query.interface.js'; +import { type CrudRequestConfig } from '../request/interfaces/crud-request-config.interface.js'; +import { type CrudResponseConfig } from '../request/interfaces/crud-response-config.interface.js'; + +import { type CrudTransactionalInterface } from './crud-transactional.interface.js'; + +interface CrudRouteCtlrOptionsInterface { + path?: string | string[]; + /** + * Request configuration overrides for this route. + */ + request?: CrudRequestConfig; + + /** + * Response configuration overrides for this route. + */ + response?: CrudResponseConfig; + + api?: { + operation?: ApiOperationOptions; + query?: ApiQueryOptions[]; + params?: ApiParamOptions; + /** + * Only read by the write operations that accept a body — Create, + * CreateBatch, Update, Replace. Setting this on a read/delete operation + * type-checks but has no effect. + */ + body?: ApiBodyOptions; + response?: ApiResponseOptions; + }; +} + +export interface CrudRouteQueryOptionsInterface< + T extends PlainLiteralObject = PlainLiteralObject, +> + extends CrudRouteCtlrOptionsInterface, CrudTransactionalInterface { + query?: Type>; + queryHandler?: Type>; +} + +export interface CrudRouteCommandOptionsInterface< + T extends PlainLiteralObject = PlainLiteralObject, +> + extends CrudRouteCtlrOptionsInterface, CrudTransactionalInterface { + command?: Type>; + commandHandler?: Type>; +} diff --git a/packages/nestjs-crud/src/infrastructure/interfaces/crud-serialization-options.interface.ts b/packages/nestjs-crud/src/infrastructure/interfaces/crud-serialization-options.interface.ts new file mode 100644 index 000000000..71cc6d58a --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interfaces/crud-serialization-options.interface.ts @@ -0,0 +1,6 @@ +import { type CrudSchema } from '../../crud.types.js'; + +export interface CrudSerializationOptionsInterface { + resource?: CrudSchema; + paginated?: CrudSchema; +} diff --git a/packages/nestjs-crud/src/infrastructure/interfaces/crud-transactional.interface.ts b/packages/nestjs-crud/src/infrastructure/interfaces/crud-transactional.interface.ts new file mode 100644 index 000000000..8737d193b --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/interfaces/crud-transactional.interface.ts @@ -0,0 +1,15 @@ +import { type TransactionalOptions } from '@concepta/nestjs-repository'; + +export interface CrudTransactionalInterface { + /** + * Enable transactions for write operations. + * + * When `true`, applies `@Transactional()` with default options. + * When an options object, applies `@Transactional(options)`. + * When `false` or omitted, no transaction decorator is applied. + * + * On controller options: applies to all write operations (Create, Update, Replace, Delete, etc.) + * On operation options: overrides the controller-level setting for that operation. + */ + transactional?: boolean | TransactionalOptions; +} diff --git a/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-query.builder.spec.ts b/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-query.builder.spec.ts new file mode 100644 index 000000000..5493ba666 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-query.builder.spec.ts @@ -0,0 +1,405 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { CrudQueryBuilder } from '../crud-query.builder.js'; +import { CrudQueryValidatorException } from '../exceptions/crud-query-validator.exception.js'; +import { type CrudQueryBuilderOptionsInterface } from '../interfaces/crud-query-builder-options.interface.js'; + +const defaultOptions = { ...(CrudQueryBuilder as any)._options }; + +describe('#query', () => { + describe('#QueryBuilder', () => { + let qb: CrudQueryBuilder; + + beforeEach(() => { + qb = CrudQueryBuilder.create(); + }); + + afterEach(() => { + (CrudQueryBuilder as any)._options = defaultOptions; + }); + + it('should be a function', () => { + expect(typeof CrudQueryBuilder).toEqual('function'); + }); + + describe('#static setOptions', () => { + it('should merge options, 1', () => { + const options: CrudQueryBuilderOptionsInterface = { + paramNamesMap: { fields: ['override'] }, + }; + CrudQueryBuilder.setOptions(options); + expect((CrudQueryBuilder as any)._options).toEqual({ + delim: '||', + delimStr: ',', + paramNamesMap: { + fields: ['override'], + search: ['s'], + filter: ['filter'], + or: ['or'], + sort: ['sort'], + limit: ['limit'], + offset: ['offset'], + page: ['page'], + cache: ['cache'], + includeDeleted: ['includeDeleted'], + }, + }); + }); + it('should merge options, 2', () => { + CrudQueryBuilder.setOptions({ delim: 'override' }); + expect((CrudQueryBuilder as any)._options).toEqual({ + delim: 'override', + delimStr: ',', + paramNamesMap: { + fields: ['select'], + search: ['s'], + filter: ['filter'], + or: ['or'], + sort: ['sort'], + limit: ['limit'], + offset: ['offset'], + page: ['page'], + cache: ['cache'], + includeDeleted: ['includeDeleted'], + }, + }); + }); + }); + + describe('#select', () => { + it('should not throw', () => { + (qb as any).select(); + expect(qb.queryObject).toEqual({}); + }); + it('should throw an error', () => { + expect((qb.select as any).bind(qb, [false])).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set fields', () => { + qb.select(['foo', 'bar']); + expect(qb.queryObject).toEqual({ select: 'foo,bar' }); + }); + }); + + describe('#setFilter', () => { + it('should not throw', () => { + (qb as any).setFilter(); + expect(qb.queryObject).toEqual({}); + }); + it('should throw an error, 1', () => { + expect((qb.setFilter as any).bind(qb, { field: 1 })).toThrow( + CrudQueryValidatorException, + ); + }); + it('should throw an error, 2', () => { + expect( + (qb.setFilter as any).bind(qb, { field: 'foo', operator: 'bar' }), + ).toThrow(CrudQueryValidatorException); + }); + it('should throw an error, 3', () => { + expect((qb.setFilter as any).bind(qb, [{}])).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set filter, 1', () => { + qb.setFilter({ field: 'foo', operator: 'eq', value: 'bar' }); + expect(qb.queryObject).toEqual({ filter: ['foo||$eq||bar'] }); + }); + it('should set filter, 2', () => { + qb.setFilter([ + { field: 'foo', operator: 'eq', value: 'bar' }, + { field: 'baz', operator: 'ne', value: 'zoo' }, + ]); + expect(qb.queryObject).toEqual({ + filter: ['foo||$eq||bar', 'baz||$ne||zoo'], + }); + }); + it('should set filter, 3', () => { + qb.setFilter([ + ['foo', 'eq', 'bar'], + { field: 'baz', operator: 'ne', value: 'zoo' }, + ]); + expect(qb.queryObject).toEqual({ + filter: ['foo||$eq||bar', 'baz||$ne||zoo'], + }); + }); + it('should set filter, 4', () => { + qb.setFilter([ + ['foo', 'eq', 'bar'], + ['baz', 'ne', 'zoo'], + ]); + expect(qb.queryObject).toEqual({ + filter: ['foo||$eq||bar', 'baz||$ne||zoo'], + }); + }); + it('should set filter, 5', () => { + qb.setFilter(['foo', 'eq', 'bar']); + expect(qb.queryObject).toEqual({ filter: ['foo||$eq||bar'] }); + }); + }); + + describe('#setOr', () => { + it('should not throw', () => { + (qb as any).setOr(); + expect(qb.queryObject).toEqual({}); + }); + it('should throw an error, 1', () => { + expect((qb.setOr as any).bind(qb, { field: 1 })).toThrow( + CrudQueryValidatorException, + ); + }); + it('should throw an error, 2', () => { + expect( + (qb.setOr as any).bind(qb, { field: 'foo', operator: 'bar' }), + ).toThrow(CrudQueryValidatorException); + }); + it('should throw an error, 3', () => { + expect((qb.setOr as any).bind(qb, [{}])).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set or, 1', () => { + qb.setOr({ field: 'foo', operator: 'eq', value: 'bar' }); + expect(qb.queryObject).toEqual({ or: ['foo||$eq||bar'] }); + }); + it('should set or, 2', () => { + qb.setOr([ + { field: 'foo', operator: 'eq', value: 'bar' }, + { field: 'baz', operator: 'ne', value: 'zoo' }, + ]); + expect(qb.queryObject).toEqual({ + or: ['foo||$eq||bar', 'baz||$ne||zoo'], + }); + }); + }); + + describe('#sortBy', () => { + it('should not throw', () => { + (qb as any).sortBy(); + expect(qb.queryObject).toEqual({}); + }); + it('should throw an error, 1', () => { + expect((qb.sortBy as any).bind(qb, { field: 1 })).toThrow( + CrudQueryValidatorException, + ); + }); + it('should throw an error, 2', () => { + expect( + (qb.sortBy as any).bind(qb, { field: 'foo', order: 'bar' }), + ).toThrow(CrudQueryValidatorException); + }); + it('should throw an error, 3', () => { + expect((qb.sortBy as any).bind(qb, [{}])).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set sort, 1', () => { + qb.sortBy({ field: 'foo', order: 'ASC' }); + expect(qb.queryObject).toEqual({ sort: ['foo,ASC'] }); + }); + it('should set sort, 2', () => { + qb.sortBy([ + { field: 'foo', order: 'ASC' }, + { field: 'bar', order: 'DESC' }, + ]); + expect(qb.queryObject).toEqual({ sort: ['foo,ASC', 'bar,DESC'] }); + }); + it('should set sort, 3', () => { + qb.sortBy(['foo', 'ASC']); + expect(qb.queryObject).toEqual({ sort: ['foo,ASC'] }); + }); + it('should set sort, 4', () => { + qb.sortBy([['foo', 'ASC']]); + expect(qb.queryObject).toEqual({ sort: ['foo,ASC'] }); + }); + it('should set sort, 5', () => { + qb.sortBy([{ field: 'bar', order: 'DESC' }, ['foo', 'ASC']]); + expect(qb.queryObject).toEqual({ sort: ['bar,DESC', 'foo,ASC'] }); + }); + }); + + describe('#setLimit', () => { + it('should not throw', () => { + (qb as any).setLimit(); + expect(qb.queryObject).toEqual({}); + }); + it('should throw an error', () => { + expect((qb.setLimit as any).bind(qb, {})).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set limit', () => { + qb.setLimit(10); + expect(qb.queryObject).toEqual({ limit: 10 }); + }); + }); + + describe('#setOffset', () => { + it('should not throw', () => { + (qb as any).setOffset(); + expect(qb.queryObject).toEqual({}); + }); + it('should throw an error', () => { + expect((qb.setOffset as any).bind(qb, {})).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set offset', () => { + qb.setOffset(10); + expect(qb.queryObject).toEqual({ offset: 10 }); + }); + }); + + describe('#setPage', () => { + it('should not throw', () => { + (qb as any).setPage(); + expect(qb.queryObject).toEqual({}); + }); + it('should throw an error', () => { + expect((qb.setPage as any).bind(qb, {})).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set page', () => { + qb.setPage(10); + expect(qb.queryObject).toEqual({ page: 10 }); + }); + }); + + describe('#resetCache', () => { + it('should set cache', () => { + expect(qb.queryObject).toEqual({}); + qb.resetCache(); + expect(qb.queryObject).toEqual({ cache: 0 }); + }); + }); + + describe('#cond', () => { + it('should throw an error, 1', () => { + expect((qb as any).cond).toThrow(CrudQueryValidatorException); + }); + it('should throw an error, 2', () => { + expect((qb as any).cond.bind(qb, {})).toThrow( + CrudQueryValidatorException, + ); + }); + it('should return a filter string from an object', () => { + const test = (qb as any).cond( + { field: 'foo', operator: 'eq', value: 'bar' }, + 'filter', + ); + expect(test).toEqual('foo||$eq||bar'); + }); + it('should return a filter string from an array', () => { + const test = (qb as any).cond(['foo', 'eq', 'bar'], 'filter'); + expect(test).toEqual('foo||$eq||bar'); + }); + }); + + describe('#query', () => { + it('should return an empty string', () => { + expect(qb.query()).toEqual(''); + }); + it('should return query with overrided fields name', () => { + CrudQueryBuilder.setOptions({ + paramNamesMap: { fields: ['override'] }, + }); + qb.setParamNames(); + expect(qb.select(['foo', 'bar']).query()).toEqual('override=foo%2Cbar'); + expect(qb.select(['foo', 'bar']).query(false)).toEqual( + 'override=foo,bar', + ); + }); + it('should return valid query string with filters', () => { + const test = qb + .select(['foo', 'bar']) + .setFilter([ + { field: 'is', operator: 'nnull' }, + { field: 'foo', operator: 'lt', value: 10 }, + ]) + .query(false); + expect(test).toEqual( + 'select=foo,bar&filter[0]=is||$nnull&filter[1]=foo||$lt||10', + ); + }); + it('should return a valid query string', () => { + const test = qb + .select(['foo', 'bar']) + .setFilter(['is', 'nnull']) + .setOr({ field: 'ok', operator: 'ne', value: false }) + .setLimit(1) + .setOffset(2) + .setPage(3) + .sortBy({ field: 'foo', order: 'DESC' }) + .resetCache() + .setIncludeDeleted(1) + .query(false); + expect(test).toEqual( + 'select=foo,bar&filter[0]=is||$nnull&or[0]=ok||$ne||false&limit=1&offset=2&page=3&sort[0]=foo,DESC&cache=0&includeDeleted=1', + ); + }); + }); + + describe('#search', () => { + it('should not throw, 1', () => { + (qb as any).search(); + expect(qb.queryObject).toEqual({}); + }); + it('should not throw, 2', () => { + (qb as any).search(false); + expect(qb.queryObject).toEqual({}); + }); + it('should set search string, 1', () => { + const test = qb + .search({ $or: [{ id: 1 }, { name: 'foo' }] }) + .query(false); + expect(test).toEqual('s={"$or":[{"id":1},{"name":"foo"}]}'); + }); + it('should set search string, 2', () => { + const test = qb.search({ $or: [{ id: 1 }, { name: 'foo' }] }).query(); + expect(test).toEqual( + 's=%7B%22%24or%22%3A%5B%7B%22id%22%3A1%7D%2C%7B%22name%22%3A%22foo%22%7D%5D%7D', + ); + }); + }); + + describe('#createFromParams', () => { + it('should return an empty query string', () => { + expect(CrudQueryBuilder.create().query()).toEqual(''); + }); + it('should return a valid query string, 1', () => { + const test = CrudQueryBuilder.create({ + fields: ['foo', 'bar'], + filter: ['is', 'nnull'], + or: { field: 'ok', operator: 'ne', value: false }, + limit: 1, + offset: 2, + page: 3, + sort: [['foo', 'DESC']], + resetCache: true, + }).query(false); + expect(test).toEqual( + 'select=foo,bar&filter[0]=is||$nnull&or[0]=ok||$ne||false&limit=1&offset=2&page=3&sort[0]=foo,DESC&cache=0', + ); + }); + it('should return a valid query string, 2', () => { + const test = CrudQueryBuilder.create({ + fields: ['foo', 'bar'], + }).query(false); + expect(test).toEqual('select=foo,bar'); + }); + it('should create from params with search', () => { + const test = CrudQueryBuilder.create({ + search: { $or: [{ id: 1 }] }, + }).query(false); + expect(test).toEqual('s={"$or":[{"id":1}]}'); + }); + it('should create from params with includeDeleted', () => { + const test = CrudQueryBuilder.create({ + includeDeleted: 1, + }).query(false); + expect(test).toEqual('includeDeleted=1'); + }); + }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-query.parser.spec.ts b/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-query.parser.spec.ts new file mode 100644 index 000000000..a41662f75 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-query.parser.spec.ts @@ -0,0 +1,930 @@ +import { type CrudParamsOptionsInterface } from '../../interfaces/crud-params-options.interface.js'; +import { CrudQueryParser } from '../crud-query.parser.js'; +import { CrudQueryParserException } from '../exceptions/crud-query-parser.exception.js'; +import { CrudQueryValidatorException } from '../exceptions/crud-query-validator.exception.js'; +import { type CrudParsedQueryInterface } from '../interfaces/crud-parsed-query.interface.js'; + +class TestEntity { + foo!: unknown; + bar!: unknown; + baz!: unknown; + bigInt!: number; + name!: unknown; +} + +const EMPTY_PARSED: CrudParsedQueryInterface = { + fields: [], + search: undefined, + filter: [], + or: [], + sort: [], + limit: undefined, + offset: undefined, + page: undefined, + cache: undefined, + includeDeleted: undefined, +}; + +describe('#request-query', () => { + describe('CrudQueryParser', () => { + let qp: CrudQueryParser; + + beforeEach(() => { + qp = CrudQueryParser.create(); + }); + + describe('#parseQuery', () => { + it('should return instance of CrudQueryParser', () => { + expect(qp.parseQuery({})).toBeInstanceOf(CrudQueryParser); + }); + + describe('#parse fields', () => { + it('should set empty array, 1', () => { + const test = qp.parseQuery({ select: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should set empty array, 2', () => { + const test = qp.parseQuery({ foo: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should set array, 1', () => { + const test = qp.parseQuery({ select: 'foo' }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + fields: ['foo'], + }); + }); + it('should set array, 2', () => { + const test = qp.parseQuery({ select: 'foo,bar' }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + fields: ['foo', 'bar'], + }); + }); + }); + + describe('#parse filter', () => { + it('should set empty array, 1', () => { + const test = qp.parseQuery({ filter: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should set empty array, 2', () => { + const test = qp.parseQuery({ foo: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should throw an error, 1', () => { + expect( + qp.parseQuery.bind(qp, { filter: 'foo||$invalid||bar' }), + ).toThrow(CrudQueryValidatorException); + }); + it('should throw an error, 2', () => { + expect(qp.parseQuery.bind(qp, { filter: 'foo||$eq' })).toThrow( + CrudQueryParserException, + ); + }); + it('should set array, 1', () => { + const test = qp.parseQuery({ filter: 'foo||$eq||bar' }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'eq', + value: 'bar', + relation: undefined, + }, + ], + }); + }); + it('should set array, 2', () => { + const test = qp.parseQuery({ + filter: ['foo||$eq||bar', 'baz||$ne||boo'], + }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'eq', + value: 'bar', + relation: undefined, + }, + { + field: 'baz', + operator: 'ne', + value: 'boo', + relation: undefined, + }, + ], + }); + }); + it('should set array, 3', () => { + const test = qp.parseQuery({ filter: ['foo||$in||1,2'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'in', + value: [1, 2], + relation: undefined, + }, + ], + }); + }); + it('should set array, 4', () => { + const test = qp.parseQuery({ filter: ['foo||$null'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'null', + relation: undefined, + }, + ], + }); + }); + it('should set array, 5', () => { + const test = qp.parseQuery({ filter: ['foo||$eq||{"foo":true}'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'eq', + value: '{"foo":true}', + relation: undefined, + }, + ], + }); + }); + it('should set array, 6', () => { + const test = qp.parseQuery({ filter: ['foo||$eq||1'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { field: 'foo', operator: 'eq', value: 1, relation: undefined }, + ], + }); + }); + it('should set date, 7', () => { + const now = new Date(); + const test = qp.parseQuery({ filter: [`foo||$eq||${now.toJSON()}`] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'eq', + value: now, + relation: undefined, + }, + ], + }); + }); + it('should set false, 8', () => { + const test = qp.parseQuery({ filter: ['foo||$eq||false'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'eq', + value: false, + relation: undefined, + }, + ], + }); + }); + it('should set true, 9', () => { + const test = qp.parseQuery({ filter: ['foo||$eq||true'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'eq', + value: true, + relation: undefined, + }, + ], + }); + }); + it('should set number, 10', () => { + const test = qp.parseQuery({ filter: ['foo||$eq||12345'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'eq', + value: 12345, + relation: undefined, + }, + ], + }); + }); + it('should set string, 11', () => { + const test = qp.parseQuery({ + filter: ['foo||$eq||4202140192612927005304000000236630'], + }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'eq', + value: '4202140192612927005304000000236630', + relation: undefined, + }, + ], + }); + }); + it('should parse $nnull operator', () => { + const test = qp.parseQuery({ filter: ['foo||$nnull'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'nnull', + relation: undefined, + }, + ], + }); + }); + it('should parse $nin operator', () => { + const test = qp.parseQuery({ filter: ['foo||$nin||1,2,3'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'nin', + value: [1, 2, 3], + relation: undefined, + }, + ], + }); + }); + it('should parse $between operator', () => { + const test = qp.parseQuery({ filter: ['foo||$between||1,10'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'between', + value: [1, 10], + relation: undefined, + }, + ], + }); + }); + it('should parse $gt operator', () => { + const test = qp.parseQuery({ filter: ['foo||$gt||5'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { field: 'foo', operator: 'gt', value: 5, relation: undefined }, + ], + }); + }); + it('should parse $lt operator', () => { + const test = qp.parseQuery({ filter: ['foo||$lt||5'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { field: 'foo', operator: 'lt', value: 5, relation: undefined }, + ], + }); + }); + it('should parse $gte operator', () => { + const test = qp.parseQuery({ filter: ['foo||$gte||5'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { field: 'foo', operator: 'gte', value: 5, relation: undefined }, + ], + }); + }); + it('should parse $lte operator', () => { + const test = qp.parseQuery({ filter: ['foo||$lte||5'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { field: 'foo', operator: 'lte', value: 5, relation: undefined }, + ], + }); + }); + it('should parse $starts operator', () => { + const test = qp.parseQuery({ filter: ['foo||$starts||bar'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'starts', + value: 'bar', + relation: undefined, + }, + ], + }); + }); + it('should parse $ends operator', () => { + const test = qp.parseQuery({ filter: ['foo||$ends||bar'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'ends', + value: 'bar', + relation: undefined, + }, + ], + }); + }); + it('should parse $contains operator', () => { + const test = qp.parseQuery({ filter: ['foo||$contains||bar'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'contains', + value: 'bar', + relation: undefined, + }, + ], + }); + }); + it('should parse $ncontains operator', () => { + const test = qp.parseQuery({ filter: ['foo||$ncontains||bar'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'ncontains', + value: 'bar', + relation: undefined, + }, + ], + }); + }); + it('should parse $in with string values', () => { + const test = qp.parseQuery({ filter: ['foo||$in||abc,def'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'in', + value: ['abc', 'def'], + relation: undefined, + }, + ], + }); + }); + it('should parse relation dotted field', () => { + const test = qp.parseQuery({ filter: ['bar.name||$eq||test'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'name', + operator: 'eq', + value: 'test', + relation: 'bar', + }, + ], + }); + }); + }); + + describe('#parse or', () => { + it('should set empty array, 1', () => { + const test = qp.parseQuery({ or: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should set empty array, 2', () => { + const test = qp.parseQuery({ foo: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should throw an error, 1', () => { + expect(qp.parseQuery.bind(qp, { or: 'foo||$invalid||bar' })).toThrow( + CrudQueryValidatorException, + ); + }); + it('should throw an error, 2', () => { + expect(qp.parseQuery.bind(qp, { or: 'foo||$eq' })).toThrow( + CrudQueryParserException, + ); + }); + it('should set array, 1', () => { + const test = qp.parseQuery({ or: 'foo||$eq||bar' }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + or: [ + { + field: 'foo', + operator: 'eq', + value: 'bar', + relation: undefined, + }, + ], + }); + }); + it('should set array, 2', () => { + const test = qp.parseQuery({ + or: ['foo||$eq||bar', 'baz||$ne||boo'], + }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + or: [ + { + field: 'foo', + operator: 'eq', + value: 'bar', + relation: undefined, + }, + { + field: 'baz', + operator: 'ne', + value: 'boo', + relation: undefined, + }, + ], + }); + }); + it('should set array, 3', () => { + const test = qp.parseQuery({ or: ['foo||$in||1,2'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + or: [ + { + field: 'foo', + operator: 'in', + value: [1, 2], + relation: undefined, + }, + ], + }); + }); + it('should set array, 4', () => { + const test = qp.parseQuery({ or: ['foo||$null'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + or: [ + { + field: 'foo', + operator: 'null', + relation: undefined, + }, + ], + }); + }); + it('should parse $nnull operator', () => { + const test = qp.parseQuery({ or: ['foo||$nnull'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + or: [ + { + field: 'foo', + operator: 'nnull', + relation: undefined, + }, + ], + }); + }); + it('should parse relation dotted field', () => { + const test = qp.parseQuery({ or: ['bar.name||$eq||test'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + or: [ + { + field: 'name', + operator: 'eq', + value: 'test', + relation: 'bar', + }, + ], + }); + }); + }); + + describe('#parse sort', () => { + it('should set empty array, 1', () => { + const test = qp.parseQuery({ sort: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should set empty array, 2', () => { + const test = qp.parseQuery({ foo: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should throw an error, 1', () => { + expect(qp.parseQuery.bind(qp, { sort: 'foo' })).toThrow( + CrudQueryValidatorException, + ); + }); + it('should throw an error, 2', () => { + expect(qp.parseQuery.bind(qp, { sort: 'foo,boo' })).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set array', () => { + const test = qp.parseQuery({ sort: ['foo,ASC', 'bar,DESC'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + sort: [ + { field: 'foo', order: 'ASC', relation: undefined }, + { field: 'bar', order: 'DESC', relation: undefined }, + ], + }); + }); + it('should parse single string sort', () => { + const test = qp.parseQuery({ sort: 'foo,ASC' }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + sort: [{ field: 'foo', order: 'ASC', relation: undefined }], + }); + }); + it('should parse relation dotted sort', () => { + const test = qp.parseQuery({ sort: ['bar.name,DESC'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + sort: [{ field: 'name', order: 'DESC', relation: 'bar' }], + }); + }); + }); + + describe('#parse limit', () => { + it('should set undefined, 1', () => { + const test = qp.parseQuery({ limit: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should set undefined, 2', () => { + const test = qp.parseQuery({ foo: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should throw an error', () => { + expect(qp.parseQuery.bind(qp, { limit: 'a' })).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set value', () => { + const test = qp.parseQuery({ limit: '10' }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + limit: 10, + }); + }); + }); + + describe('#parse offset', () => { + it('should set undefined, 1', () => { + const test = qp.parseQuery({ offset: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should set undefined, 2', () => { + const test = qp.parseQuery({ foo: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should throw an error', () => { + expect(qp.parseQuery.bind(qp, { offset: 'a' })).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set value', () => { + const test = qp.parseQuery({ offset: '10' }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + offset: 10, + }); + }); + }); + + describe('#parse page', () => { + it('should set undefined, 1', () => { + const test = qp.parseQuery({ page: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should set undefined, 2', () => { + const test = qp.parseQuery({ foo: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should throw an error', () => { + expect(qp.parseQuery.bind(qp, { page: ['a'] })).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set value', () => { + const test = qp.parseQuery({ page: ['10'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + page: 10, + }); + }); + }); + + describe('#parse cache', () => { + it('should set undefined, 1', () => { + const test = qp.parseQuery({ cache: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should set undefined, 2', () => { + const test = qp.parseQuery({ foo: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should throw an error', () => { + expect(qp.parseQuery.bind(qp, { cache: ['a'] })).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set value', () => { + const test = qp.parseQuery({ cache: ['10'] }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + cache: 10, + }); + }); + }); + + describe('#parse includeDeleted', () => { + it('should set undefined, 1', () => { + const test = qp.parseQuery({ includeDeleted: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should set undefined, 2', () => { + const test = qp.parseQuery({ foo: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should throw an error', () => { + expect(qp.parseQuery.bind(qp, { includeDeleted: 'a' })).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set value', () => { + const test = qp.parseQuery({ includeDeleted: '1' }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + includeDeleted: 1, + }); + }); + }); + }); + + describe('#parse search', () => { + it('should set undefined', () => { + const test = qp.parseQuery({ foo: '' }); + expect(test.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should throw an error, 1', () => { + expect(qp.parseQuery.bind(qp, { s: 'invalid' })).toThrow( + CrudQueryParserException, + ); + }); + it('should throw an error, 2', () => { + expect(qp.parseQuery.bind(qp, { s: 'true' })).toThrow( + CrudQueryParserException, + ); + }); + it('should parse search', () => { + const test = qp.parseQuery({ s: '{"$or":[{"id":1},{"name":"foo"}]}' }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + search: { $or: [{ id: 1 }, { name: 'foo' }] }, + }); + }); + it('should suppress filter and or when search is present', () => { + const test = qp.parseQuery({ + s: '{"$or":[{"id":1}]}', + filter: 'foo||$eq||bar', + or: 'foo||$ne||baz', + }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + search: { $or: [{ id: 1 }] }, + }); + }); + }); + + describe('#parseParams', () => { + it('should return instance of CrudQueryParser', () => { + expect(qp.parseParams({}, {})).toBeInstanceOf(CrudQueryParser); + }); + it('should throw an error, 1', () => { + const params = { foo: 'bar' }; + const options: CrudParamsOptionsInterface = {}; + expect(qp.parseParams.bind(qp, params, options)).toThrow( + CrudQueryValidatorException, + ); + }); + it('should throw an error, 2', () => { + const params = { foo: 'bar' }; + const options = {}; + expect(qp.parseParams.bind(qp, params, options)).toThrow( + CrudQueryValidatorException, + ); + }); + it('should throw an error, 3', () => { + const params = { foo: 'bar' }; + const options = { foo: {} }; + expect(qp.parseParams.bind(qp, params, options)).toThrow( + CrudQueryValidatorException, + ); + }); + it('should throw an error, 4', () => { + const params = { foo: 'bar' }; + const options = { + foo: { field: 'number' }, + } as unknown as CrudParamsOptionsInterface; + expect(qp.parseParams.bind(qp, params, options)).toThrow( + CrudQueryValidatorException, + ); + }); + it('should throw an error, 5', () => { + const params = { foo: 'bar' }; + const options: CrudParamsOptionsInterface = { + foo: { field: 'foo', type: 'number' }, + }; + expect(qp.parseParams.bind(qp, params, options)).toThrow( + CrudQueryValidatorException, + ); + }); + it('should throw an error, 6', () => { + const params = { foo: 'bar' }; + const options: CrudParamsOptionsInterface = { + foo: { field: 'foo', type: 'uuid' }, + }; + expect(qp.parseParams.bind(qp, params, options)).toThrow( + CrudQueryValidatorException, + ); + }); + it('should set routeParams', () => { + const params = { + foo: 'cb1751fd-7fcf-4eb5-b38e-86428b1fd88d', + bar: '1', + baz: 'string', + bigInt: '9007199254740999', + }; + const options: CrudParamsOptionsInterface = { + foo: { field: 'foo', type: 'uuid' }, + bar: { field: 'bar', type: 'number' }, + baz: { field: 'baz', type: 'string' }, + bigInt: { field: 'bigInt', type: 'string' }, + }; + const test = qp.parseParams(params, options); + expect(test.getRouteParams()).toEqual({ + foo: 'cb1751fd-7fcf-4eb5-b38e-86428b1fd88d', + bar: 1, + baz: 'string', + bigInt: '9007199254740999', + }); + }); + it('should set routeParams with disabled validation', () => { + const params = { + foo: 'cb1751fd', + bar: '123', + }; + const options: CrudParamsOptionsInterface = { + foo: { disabled: true }, + bar: { field: 'bar', type: 'number' }, + }; + const test = qp.parseParams(params, options); + expect(test.getRouteParams()).toEqual({ bar: 123 }); + }); + }); + + describe('#getParsedQuery', () => { + it('should return parsed query params', () => { + expect(qp.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should return populated state after parseQuery', () => { + qp.parseQuery({ + select: 'foo,bar', + filter: 'foo||$eq||1', + or: 'bar||$ne||2', + sort: 'foo,ASC', + limit: '10', + offset: '5', + page: '2', + cache: '60', + includeDeleted: '1', + }); + expect(qp.getParsedQuery()).toEqual({ + fields: ['foo', 'bar'], + search: undefined, + filter: [ + { field: 'foo', operator: 'eq', value: 1, relation: undefined }, + ], + or: [{ field: 'bar', operator: 'ne', value: 2, relation: undefined }], + sort: [{ field: 'foo', order: 'ASC', relation: undefined }], + limit: 10, + offset: 5, + page: 2, + cache: 60, + includeDeleted: 1, + }); + }); + }); + + describe('#getRouteParams', () => { + it('should return empty object by default', () => { + expect(qp.getRouteParams()).toEqual({}); + }); + it('should return populated params after parseParams', () => { + const params = { + foo: 'cb1751fd-7fcf-4eb5-b38e-86428b1fd88d', + bar: '42', + }; + const options: CrudParamsOptionsInterface = { + foo: { field: 'foo', type: 'uuid' }, + bar: { field: 'bar', type: 'number' }, + }; + qp.parseParams(params, options); + expect(qp.getRouteParams()).toEqual({ + foo: 'cb1751fd-7fcf-4eb5-b38e-86428b1fd88d', + bar: 42, + }); + }); + }); + + describe('#parseQuery edge cases', () => { + it('should return self when given non-object input', () => { + const result = qp.parseQuery( + null as unknown as Record, + ); + expect(result).toBeInstanceOf(CrudQueryParser); + expect(result.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should keep defaults when given empty object', () => { + const result = qp.parseQuery({}); + expect(result.getParsedQuery()).toEqual(EMPTY_PARSED); + }); + it('should parse array-indexed filter param names', () => { + const test = qp.parseQuery({ + 'filter[0]': 'foo||$eq||bar', + 'filter[1]': 'baz||$ne||boo', + }); + expect(test.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'eq', + value: 'bar', + relation: undefined, + }, + { + field: 'baz', + operator: 'ne', + value: 'boo', + relation: undefined, + }, + ], + }); + }); + }); + + describe('#parseParams edge cases', () => { + it('should return self when given non-object input', () => { + const result = qp.parseParams( + null as unknown as Record, + {}, + ); + expect(result).toBeInstanceOf(CrudQueryParser); + expect(result.getRouteParams()).toEqual({}); + }); + }); + + describe('#parseQuery + parseParams chaining', () => { + it('should compose query and route params on the same instance', () => { + qp.parseQuery({ + filter: 'foo||$eq||bar', + limit: '10', + }); + qp.parseParams({ id: 'cb1751fd-7fcf-4eb5-b38e-86428b1fd88d' }, { + id: { field: 'foo', type: 'uuid' }, + } as CrudParamsOptionsInterface); + expect(qp.getParsedQuery()).toEqual({ + ...EMPTY_PARSED, + filter: [ + { + field: 'foo', + operator: 'eq', + value: 'bar', + relation: undefined, + }, + ], + limit: 10, + }); + expect(qp.getRouteParams()).toEqual({ + foo: 'cb1751fd-7fcf-4eb5-b38e-86428b1fd88d', + }); + }); + }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-query.validator.spec.ts b/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-query.validator.spec.ts new file mode 100644 index 000000000..ed2c0d043 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-query.validator.spec.ts @@ -0,0 +1,252 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { WhereOperator } from '@concepta/nestjs-repository'; + +import { + isSortOrder, + validateComparisonOperator, + validateCondition, + validateFields, + validateNumeric, + validateParamOption, + validateSort, + validateUUID, +} from '../crud-query.validator.js'; +import { CrudQueryValidatorException } from '../exceptions/crud-query-validator.exception.js'; + +describe('#request-query', () => { + describe('#validator', () => { + describe('#validateFields', () => { + it('should pass for valid array of strings', () => { + expect(validateFields(['name', 'age'])).toBeUndefined(); + }); + + it('should throw for empty array', () => { + expect(() => validateFields([])).toThrow(CrudQueryValidatorException); + }); + + it('should throw for non-array', () => { + expect(() => validateFields('name' as any)).toThrow( + CrudQueryValidatorException, + ); + }); + + it('should throw for array with non-string elements', () => { + expect(() => validateFields([1, 2] as any)).toThrow( + CrudQueryValidatorException, + ); + }); + }); + + describe('#validateCondition', () => { + it('should pass for valid filter condition', () => { + expect( + validateCondition( + { field: 'name', operator: WhereOperator.EQ, value: 'test' }, + 'filter', + ), + ).toBeUndefined(); + }); + + it('should pass for valid or condition', () => { + expect( + validateCondition( + { field: 'name', operator: WhereOperator.NE, value: 'test' }, + 'or', + ), + ).toBeUndefined(); + }); + + it('should throw for non-object value', () => { + expect(() => validateCondition('bad' as any, 'filter')).toThrow( + CrudQueryValidatorException, + ); + }); + + it('should throw for missing field', () => { + expect(() => + validateCondition({ operator: '$eq' } as any, 'filter'), + ).toThrow(CrudQueryValidatorException); + }); + + it('should throw for empty field', () => { + expect(() => + validateCondition({ field: '', operator: '$eq' } as any, 'filter'), + ).toThrow(CrudQueryValidatorException); + }); + + it('should throw for valid field but invalid operator', () => { + expect(() => + validateCondition( + { field: 'name', operator: 'bad' } as any, + 'filter', + ), + ).toThrow(CrudQueryValidatorException); + }); + }); + + describe('#validateComparisonOperator', () => { + it('should pass for $eq', () => { + expect(validateComparisonOperator('$eq')).toBeUndefined(); + }); + + it('should pass for $ne', () => { + expect(validateComparisonOperator('$ne')).toBeUndefined(); + }); + + it('should pass for $in', () => { + expect(validateComparisonOperator('$in')).toBeUndefined(); + }); + + it('should throw for invalid operator', () => { + expect(() => validateComparisonOperator('$invalid')).toThrow( + CrudQueryValidatorException, + ); + }); + + it('should throw for empty string', () => { + expect(() => validateComparisonOperator('')).toThrow( + CrudQueryValidatorException, + ); + }); + }); + + describe('#isSortOrder', () => { + it('should return true for ASC', () => { + expect(isSortOrder('ASC')).toEqual(true); + }); + + it('should return true for DESC', () => { + expect(isSortOrder('DESC')).toEqual(true); + }); + + it('should return false for invalid string', () => { + expect(isSortOrder('INVALID')).toEqual(false); + }); + + it('should return false for non-string', () => { + expect(isSortOrder(123)).toEqual(false); + }); + }); + + describe('#validateSort', () => { + it('should pass for valid sort', () => { + expect(validateSort({ field: 'name', order: 'ASC' })).toBeUndefined(); + }); + + it('should pass for DESC order', () => { + expect(validateSort({ field: 'name', order: 'DESC' })).toBeUndefined(); + }); + + it('should throw for non-object', () => { + expect(() => validateSort('bad' as any)).toThrow( + CrudQueryValidatorException, + ); + }); + + it('should throw for missing field', () => { + expect(() => validateSort({ order: 'ASC' } as any)).toThrow( + CrudQueryValidatorException, + ); + }); + + it('should throw for empty field', () => { + expect(() => validateSort({ field: '', order: 'ASC' })).toThrow( + CrudQueryValidatorException, + ); + }); + + it('should throw for valid field but invalid order', () => { + expect(() => validateSort({ field: 'name', order: 'INVALID' })).toThrow( + CrudQueryValidatorException, + ); + }); + }); + + describe('#validateNumeric', () => { + it('should pass for valid number', () => { + expect(validateNumeric(10, 'limit')).toBeUndefined(); + }); + + it('should pass for zero', () => { + expect(validateNumeric(0, 'offset')).toBeUndefined(); + }); + + it('should throw for string value', () => { + expect(() => validateNumeric('10' as any, 'limit')).toThrow( + CrudQueryValidatorException, + ); + }); + + it('should throw for null', () => { + expect(() => validateNumeric(null as any, 'page')).toThrow( + CrudQueryValidatorException, + ); + }); + + it('should throw for undefined', () => { + expect(() => validateNumeric(undefined as any, 'cache')).toThrow( + CrudQueryValidatorException, + ); + }); + }); + + describe('#validateParamOption', () => { + it('should pass for valid option with field and type', () => { + const options = { id: { field: 'id', type: 'uuid' as const } }; + expect(validateParamOption(options, 'id')).toBeUndefined(); + }); + + it('should return early for disabled option', () => { + const options = { + id: { field: 'id', type: 'uuid' as const, disabled: true }, + }; + expect(validateParamOption(options, 'id')).toBeUndefined(); + }); + + it('should throw for non-object options', () => { + expect(() => validateParamOption(null as any, 'id')).toThrow( + CrudQueryValidatorException, + ); + }); + + it('should throw for missing option name', () => { + const options = { id: { field: 'id', type: 'uuid' as const } }; + expect(() => validateParamOption(options, 'missing')).toThrow( + CrudQueryValidatorException, + ); + }); + + it('should throw for option missing field', () => { + const options = { id: { type: 'uuid' } }; + expect(() => validateParamOption(options as any, 'id')).toThrow( + CrudQueryValidatorException, + ); + }); + + it('should throw for option missing type', () => { + const options = { id: { field: 'id' } }; + expect(() => validateParamOption(options as any, 'id')).toThrow( + CrudQueryValidatorException, + ); + }); + }); + + describe('#validateUUID', () => { + const uuid = 'cf0917fc-af7d-11e9-a2a3-2a2ae2dbcce4'; + const uuidV4 = '6650aad9-29bd-4601-b9b1-543a7a2d2d54'; + const invalid = 'invalid-uuid'; + + it('should throw an error', () => { + expect(validateUUID.bind(validateUUID, invalid)).toThrow( + CrudQueryValidatorException, + ); + }); + it('should pass, 1', () => { + expect(validateUUID(uuid, '')).toBeUndefined(); + }); + it('should pass, 2', () => { + expect(validateUUID(uuidV4, '')).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-scondition.converter.spec.ts b/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-scondition.converter.spec.ts new file mode 100644 index 000000000..e42c79888 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/__tests__/crud-scondition.converter.spec.ts @@ -0,0 +1,593 @@ +import { BadRequestException } from '@nestjs/common'; + +import { + WhereCompoundOperator, + WhereOperator, +} from '@concepta/nestjs-repository'; + +import { type SCondition } from '../crud-query.types.js'; +import { SConditionConverter } from '../crud-scondition.converter.js'; + +interface TestEntity { + id: string; + name: string; + age: number; + status: string; +} + +describe('SConditionConverter', () => { + describe('empty / falsy inputs', () => { + it('should return undefined for empty object', () => { + const result = SConditionConverter.convert({}); + expect(result).toEqual(undefined); + }); + + it('should return undefined for undefined search', () => { + const result = SConditionConverter.convert( + undefined as unknown as SCondition, + ); + expect(result).toEqual(undefined); + }); + + it('should return undefined for null search', () => { + const result = SConditionConverter.convert( + null as unknown as SCondition, + ); + expect(result).toEqual(undefined); + }); + }); + + describe('simple field equality', () => { + it('should convert single string field to eq condition', () => { + const result = SConditionConverter.convert({ + name: 'John', + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.EQ, + value: 'John', + }); + }); + + it('should convert numeric field to eq condition', () => { + const result = SConditionConverter.convert({ + age: 25, + }); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.EQ, + value: 25, + }); + }); + }); + + describe('null and undefined fields', () => { + it('should convert null field to isNull condition', () => { + const result = SConditionConverter.convert({ + name: null, + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.IS_NULL, + }); + }); + + it('should convert undefined field to isNull condition', () => { + const result = SConditionConverter.convert({ + name: undefined, + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.IS_NULL, + }); + }); + }); + + describe('multiple fields', () => { + it('should wrap multiple fields in and compound', () => { + const result = SConditionConverter.convert({ + name: 'John', + status: 'active', + }); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'name', operator: WhereOperator.EQ, value: 'John' }, + { field: 'status', operator: WhereOperator.EQ, value: 'active' }, + ], + }); + }); + + it('should handle mix of eq and isNull fields', () => { + const result = SConditionConverter.convert({ + name: 'John', + status: null, + }); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'name', operator: WhereOperator.EQ, value: 'John' }, + { field: 'status', operator: WhereOperator.IS_NULL }, + ], + }); + }); + }); + + describe('operator objects', () => { + it('should convert $eq operator', () => { + const result = SConditionConverter.convert({ + age: { $eq: 18 }, + }); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.EQ, + value: 18, + }); + }); + + it('should convert $ne operator', () => { + const result = SConditionConverter.convert({ + status: { $ne: 'inactive' }, + }); + expect(result).toEqual({ + field: 'status', + operator: WhereOperator.NE, + value: 'inactive', + }); + }); + + it('should convert $gt operator', () => { + const result = SConditionConverter.convert({ + age: { $gt: 18 }, + }); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.GT, + value: 18, + }); + }); + + it('should convert $gte operator', () => { + const result = SConditionConverter.convert({ + age: { $gte: 18 }, + }); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.GTE, + value: 18, + }); + }); + + it('should convert $lt operator', () => { + const result = SConditionConverter.convert({ + age: { $lt: 65 }, + }); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.LT, + value: 65, + }); + }); + + it('should convert $lte operator', () => { + const result = SConditionConverter.convert({ + age: { $lte: 65 }, + }); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.LTE, + value: 65, + }); + }); + + it('should convert $starts operator', () => { + const result = SConditionConverter.convert({ + name: { $starts: 'Jo' }, + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.STARTS, + value: 'Jo', + }); + }); + + it('should convert $nstarts operator', () => { + const result = SConditionConverter.convert({ + name: { $nstarts: 'Jo' }, + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.NSTARTS, + value: 'Jo', + }); + }); + + it('should convert $ends operator', () => { + const result = SConditionConverter.convert({ + name: { $ends: 'hn' }, + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.ENDS, + value: 'hn', + }); + }); + + it('should convert $nends operator', () => { + const result = SConditionConverter.convert({ + name: { $nends: 'hn' }, + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.NENDS, + value: 'hn', + }); + }); + + it('should convert $contains operator', () => { + const result = SConditionConverter.convert({ + name: { $contains: 'oh' }, + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'oh', + }); + }); + + it('should convert $ncontains operator', () => { + const result = SConditionConverter.convert({ + name: { $ncontains: 'oh' }, + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.NCONTAINS, + value: 'oh', + }); + }); + + it('should convert $in operator', () => { + const result = SConditionConverter.convert({ + status: { $in: ['active', 'pending'] as unknown as string }, + }); + expect(result).toEqual({ + field: 'status', + operator: WhereOperator.IN, + value: ['active', 'pending'], + }); + }); + + it('should convert $nin operator', () => { + const result = SConditionConverter.convert({ + status: { $nin: ['deleted', 'banned'] as unknown as string }, + }); + expect(result).toEqual({ + field: 'status', + operator: WhereOperator.NIN, + value: ['deleted', 'banned'], + }); + }); + + it('should convert $null operator', () => { + const result = SConditionConverter.convert({ + name: { $null: true }, + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.IS_NULL, + }); + }); + + it('should convert $nnull operator', () => { + const result = SConditionConverter.convert({ + name: { $nnull: true }, + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.NOT_NULL, + }); + }); + + it('should convert $between operator', () => { + const result = SConditionConverter.convert({ + age: { $between: [18, 65] as unknown as number }, + }); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.BETWEEN, + value: [18, 65], + }); + }); + }); + + describe('multiple operators on same field', () => { + it('should wrap two operators in and compound', () => { + const result = SConditionConverter.convert({ + age: { $gt: 18, $lt: 65 }, + }); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'age', operator: WhereOperator.GT, value: 18 }, + { field: 'age', operator: WhereOperator.LT, value: 65 }, + ], + }); + }); + }); + + describe('top-level $or', () => { + it('should convert $or with two conditions to or compound', () => { + const result = SConditionConverter.convert({ + $or: [{ name: 'Alice' }, { name: 'Bob' }], + }); + expect(result).toEqual({ + operator: WhereCompoundOperator.OR, + conditions: [ + { field: 'name', operator: WhereOperator.EQ, value: 'Alice' }, + { field: 'name', operator: WhereOperator.EQ, value: 'Bob' }, + ], + }); + }); + + it('should return undefined for empty $or array', () => { + const result = SConditionConverter.convert({ + $or: [], + }); + expect(result).toEqual(undefined); + }); + }); + + describe('top-level $and', () => { + it('should convert $and with two single-field conditions', () => { + const result = SConditionConverter.convert({ + $and: [{ name: 'Alice' }, { status: 'active' }], + }); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'name', operator: WhereOperator.EQ, value: 'Alice' }, + { field: 'status', operator: WhereOperator.EQ, value: 'active' }, + ], + }); + }); + + it('should return undefined for empty $and array', () => { + const result = SConditionConverter.convert({ + $and: [], + }); + expect(result).toEqual(undefined); + }); + + it('should unwrap single $and branch', () => { + const result = SConditionConverter.convert({ + $and: [{ name: 'Alice' }], + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.EQ, + value: 'Alice', + }); + }); + + it('should return undefined when all $and branches are empty', () => { + const result = SConditionConverter.convert({ + $and: [{}, {}], + }); + expect(result).toEqual(undefined); + }); + }); + + describe('fields combined with $or', () => { + it('should combine field conditions with $or using and', () => { + const result = SConditionConverter.convert({ + status: 'active', + $or: [{ name: 'Alice' }, { name: 'Bob' }], + }); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'status', operator: WhereOperator.EQ, value: 'active' }, + { + operator: WhereCompoundOperator.OR, + conditions: [ + { field: 'name', operator: WhereOperator.EQ, value: 'Alice' }, + { field: 'name', operator: WhereOperator.EQ, value: 'Bob' }, + ], + }, + ], + }); + }); + + it('should return field clauses when $or conditions are all empty', () => { + const result = SConditionConverter.convert({ + status: 'active', + $or: [{}, {}], + }); + expect(result).toEqual({ + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + }); + }); + }); + + describe('nested $or within field operators', () => { + it('should handle $or with single operator inside field', () => { + const result = SConditionConverter.convert({ + age: { $or: { $eq: 18 } }, + }); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.EQ, + value: 18, + }); + }); + + it('should handle $or with multiple operators inside field', () => { + const result = SConditionConverter.convert({ + age: { $or: { $null: true, $eq: 0 } }, + }); + expect(result).toEqual({ + operator: WhereCompoundOperator.OR, + conditions: [ + { field: 'age', operator: WhereOperator.EQ, value: 0 }, + { field: 'age', operator: WhereOperator.IS_NULL }, + ], + }); + }); + + it('should combine top-level operators with nested $or', () => { + const result = SConditionConverter.convert({ + age: { $gt: 0, $or: { $null: true, $eq: 0 } }, + }); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'age', operator: WhereOperator.GT, value: 0 }, + { + operator: WhereCompoundOperator.OR, + conditions: [ + { field: 'age', operator: WhereOperator.EQ, value: 0 }, + { field: 'age', operator: WhereOperator.IS_NULL }, + ], + }, + ], + }); + }); + }); + + describe('validation errors', () => { + it('should throw BadRequestException for $in with non-array value', () => { + expect(() => + SConditionConverter.convert({ + status: { $in: 'not-array' }, + }), + ).toThrow(BadRequestException); + }); + + it('should throw BadRequestException for $nin with non-array value', () => { + expect(() => + SConditionConverter.convert({ + status: { $nin: 'not-array' }, + }), + ).toThrow(BadRequestException); + }); + + it('should throw with message containing "requires array" for $in', () => { + expect(() => + SConditionConverter.convert({ + status: { $in: 'not-array' }, + }), + ).toThrow(/requires array/); + }); + + it('should throw BadRequestException for $between with single element', () => { + expect(() => + SConditionConverter.convert({ + age: { $between: [1] as unknown as number }, + }), + ).toThrow(BadRequestException); + }); + + it('should throw BadRequestException for $between with non-array', () => { + expect(() => + SConditionConverter.convert({ + age: { $between: 42 as unknown as number }, + }), + ).toThrow(BadRequestException); + }); + + it('should throw with BETWEEN message for invalid $between', () => { + expect(() => + SConditionConverter.convert({ + age: { $between: [1] as unknown as number }, + }), + ).toThrow(/BETWEEN operator requires an array with two elements/); + }); + + it('should throw BadRequestException for empty operator object', () => { + expect(() => + SConditionConverter.convert({ + age: {} as unknown as number, + }), + ).toThrow(BadRequestException); + }); + + it('should throw with "Empty filter operator object" message', () => { + expect(() => + SConditionConverter.convert({ + age: {} as unknown as number, + }), + ).toThrow(/Empty filter operator object/); + }); + }); + + describe('array field values are ignored', () => { + it('should skip array field values', () => { + const result = SConditionConverter.convert({ + name: 'John', + status: ['active', 'pending'] as unknown as string, + }); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.EQ, + value: 'John', + }); + }); + + it('should return undefined when only array field values present', () => { + const result = SConditionConverter.convert({ + status: ['active', 'pending'] as unknown as string, + }); + expect(result).toEqual(undefined); + }); + }); + + describe('nested and complex scenarios', () => { + it('should handle nested $or within $and', () => { + const result = SConditionConverter.convert({ + $and: [ + { $or: [{ name: 'Alice' }, { name: 'Bob' }] }, + { status: 'active' }, + ], + }); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + operator: WhereCompoundOperator.OR, + conditions: [ + { field: 'name', operator: WhereOperator.EQ, value: 'Alice' }, + { field: 'name', operator: WhereOperator.EQ, value: 'Bob' }, + ], + }, + { field: 'status', operator: WhereOperator.EQ, value: 'active' }, + ], + }); + }); + + it('should handle operator objects mixed with $or at top level', () => { + const result = SConditionConverter.convert({ + age: { $gte: 18 }, + $or: [{ status: 'active' }, { status: 'pending' }], + }); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'age', operator: WhereOperator.GTE, value: 18 }, + { + operator: WhereCompoundOperator.OR, + conditions: [ + { field: 'status', operator: WhereOperator.EQ, value: 'active' }, + { + field: 'status', + operator: WhereOperator.EQ, + value: 'pending', + }, + ], + }, + ], + }); + }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/request/crud-query.builder.ts b/packages/nestjs-crud/src/infrastructure/request/crud-query.builder.ts new file mode 100644 index 000000000..41d7de087 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/crud-query.builder.ts @@ -0,0 +1,348 @@ +import { stringify } from 'qs'; + +import { type PlainLiteralObject } from '@nestjs/common'; + +import { isNil, isObject, isString, isUndefined } from '@concepta/nestjs-core'; +import { + type EntityColumn, + type OrderSortKey, + type OrderSortKeyArr, + type WhereCondition, + type WhereConditionArr, +} from '@concepta/nestjs-repository'; + +import { hasValue } from '../utils/validation.js'; + +import { COND_OPERATOR_PREFIX, type SCondition } from './crud-query.types.js'; +import { + validateCondition, + validateFields, + validateNumeric, + validateSort, +} from './crud-query.validator.js'; +import { type CrudCreateQueryParamsInterface } from './interfaces/crud-create-query-params.interface.js'; +import { type CrudQueryBuilderOptionsInterface } from './interfaces/crud-query-builder-options.interface.js'; + +export class CrudQueryBuilder< + Entity extends PlainLiteralObject = PlainLiteralObject, +> { + private static _options: Required & { + paramNamesMap: Required< + CrudQueryBuilderOptionsInterface['paramNamesMap'] + > & { + [key: string]: string[]; + }; + } = { + delim: '||', + delimStr: ',', + paramNamesMap: { + fields: ['select'], + search: ['s'], + filter: ['filter'], + or: ['or'], + sort: ['sort'], + limit: ['limit'], + offset: ['offset'], + page: ['page'], + cache: ['cache'], + includeDeleted: ['includeDeleted'], + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public queryObject: Record = {}; + + public queryString = ''; + + private paramNames: Record = {}; + + constructor() { + this.setParamNames(); + } + + static setOptions(options: CrudQueryBuilderOptionsInterface) { + CrudQueryBuilder._options = { + ...CrudQueryBuilder._options, + ...options, + paramNamesMap: { + ...CrudQueryBuilder._options.paramNamesMap, + ...(options.paramNamesMap ? options.paramNamesMap : {}), + }, + }; + } + + static getOptions() { + return CrudQueryBuilder._options; + } + + static create( + params?: CrudCreateQueryParamsInterface, + ): CrudQueryBuilder { + const qb = new CrudQueryBuilder(); + return isObject(params) ? qb.createFromParams(params) : qb; + } + + get options(): CrudQueryBuilderOptionsInterface { + return CrudQueryBuilder._options; + } + + setParamNames() { + Object.keys(CrudQueryBuilder._options.paramNamesMap).forEach((key) => { + // Use the first alias as the canonical output name for query building + this.paramNames[key] = CrudQueryBuilder._options.paramNamesMap[key][0]; + }); + } + + query(encode = true): string { + let output = this.queryObject; + + // When search is set, filter and or are excluded (search supersedes them) + if (this.paramNames.search && this.queryObject[this.paramNames.search]) { + const { ...rest } = this.queryObject; + + if (this.paramNames.filter) { + rest[this.paramNames.filter] = undefined; + } + + if (this.paramNames.or) { + rest[this.paramNames.or] = undefined; + } + + output = rest; + } + + this.queryString = stringify(output, { encode }); + + return this.queryString; + } + + select(fields: EntityColumn[]): this { + if (Array.isArray(fields) && fields.length && this.paramNames.fields) { + validateFields(fields); + this.queryObject[this.paramNames.fields] = fields.join( + this.options.delimStr, + ); + } + return this; + } + + search(s: SCondition): this { + if (!isNil(s) && isObject(s) && this.paramNames.search) { + this.queryObject[this.paramNames.search] = JSON.stringify(s); + } + return this; + } + + setFilter( + f: + | WhereCondition + | WhereConditionArr + | Array | WhereConditionArr>, + ): this { + this.setCondition(f, 'filter'); + return this; + } + + setOr( + f: + | WhereCondition + | WhereConditionArr + | Array | WhereConditionArr>, + ): this { + this.setCondition(f, 'or'); + return this; + } + + sortBy( + s: + | OrderSortKey + | OrderSortKeyArr + | Array | OrderSortKeyArr>, + ): this { + if (!isNil(s)) { + const param = this.checkQueryObjectParam('sort', []); + if (param) { + const items = this.isSortArray(s) + ? s.map((o) => this.addSortBy(o)) + : [this.addSortBy(s)]; + + this.queryObject[param] = [...this.queryObject[param], ...items]; + } + } + return this; + } + + setLimit(n: number): this { + this.setNumeric(n, 'limit'); + return this; + } + + setOffset(n: number): this { + this.setNumeric(n, 'offset'); + return this; + } + + setPage(n: number): this { + this.setNumeric(n, 'page'); + return this; + } + + resetCache(): this { + this.setNumeric(0, 'cache'); + return this; + } + + setIncludeDeleted(n: number): this { + this.setNumeric(n, 'includeDeleted'); + return this; + } + + private cond( + f: WhereCondition | WhereConditionArr, + cond: 'filter' | 'or', + ): string { + if (!Array.isArray(f)) { + validateCondition(f, cond); + } + + const d = this.options.delim ?? CrudQueryBuilder._options.delim; + + if (Array.isArray(f)) { + const [field, operator, value] = f; + return ( + field + + d + + COND_OPERATOR_PREFIX + + operator + + (hasValue(value) ? d + value : '') + ); + } + + const value = 'value' in f ? f.value : undefined; + + return ( + f.field + + d + + COND_OPERATOR_PREFIX + + f.operator + + (hasValue(value) ? d + value : '') + ); + } + + private addSortBy(s: OrderSortKey | OrderSortKeyArr): string { + const sort: OrderSortKey = Array.isArray(s) + ? { field: s[0], order: s[1] } + : s; + validateSort(sort); + const ds = this.options.delimStr; + + return sort.field + ds + sort.order; + } + + private createFromParams(params: CrudCreateQueryParamsInterface): this { + if (params.fields) { + this.select(params.fields); + } + + if (params.search) { + this.search(params.search); + } + + if (params.filter) { + this.setFilter(params.filter); + } + + if (params.or) { + this.setOr(params.or); + } + + if (params.limit) { + this.setLimit(params.limit); + } + + if (params.offset) { + this.setOffset(params.offset); + } + + if (params.page) { + this.setPage(params.page); + } + + if (params.sort) { + this.sortBy(params.sort); + } + + if (params.resetCache) { + this.resetCache(); + } + + if (params.includeDeleted) { + this.setIncludeDeleted(params.includeDeleted); + } + + return this; + } + + private checkQueryObjectParam( + cond: keyof NonNullable, + defaults: unknown, + ): string | undefined { + const param = this.paramNames[cond]; + + if (param && isNil(this.queryObject[param]) && !isUndefined(defaults)) { + this.queryObject[param] = defaults; + } + + return param; + } + + private isSortArray( + s: + | OrderSortKey + | OrderSortKeyArr + | Array | OrderSortKeyArr>, + ): s is Array | OrderSortKeyArr> { + return Array.isArray(s) && !isString(s[0]); + } + + private isFilterArray( + f: + | WhereCondition + | WhereConditionArr + | Array | WhereConditionArr>, + ): f is Array | WhereConditionArr> { + return Array.isArray(f) && !isString(f[0]); + } + + private setCondition( + f: + | WhereCondition + | WhereConditionArr + | Array | WhereConditionArr>, + cond: 'filter' | 'or', + ): void { + if (!isNil(f)) { + const param = this.checkQueryObjectParam(cond, []); + if (param) { + const items = this.isFilterArray(f) + ? f.map((o) => this.cond(o, cond)) + : [this.cond(f, cond)]; + + this.queryObject[param] = [...this.queryObject[param], ...items]; + } + } + } + + private setNumeric( + n: number, + cond: 'limit' | 'offset' | 'page' | 'cache' | 'includeDeleted', + ): void { + if (!isNil(n)) { + validateNumeric(n, cond); + const condParam = this.paramNames[cond]; + if (typeof condParam === 'string') { + this.queryObject[condParam] = n; + } + } + } +} diff --git a/packages/nestjs-crud/src/infrastructure/request/crud-query.parser.ts b/packages/nestjs-crud/src/infrastructure/request/crud-query.parser.ts new file mode 100644 index 000000000..6c3fb77af --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/crud-query.parser.ts @@ -0,0 +1,399 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { isNil, isObject } from '@concepta/nestjs-core'; +import { + type EntityColumn, + type OrderSortKey, + Where, + type WhereCondition, + type WhereConditionScalar, +} from '@concepta/nestjs-repository'; + +import { type CrudParamsOptionsInterface } from '../interfaces/crud-params-options.interface.js'; +import { + hasValue, + isDateString, + isStringFull, + sanitizeForMessage, +} from '../utils/validation.js'; + +import { CrudQueryBuilder } from './crud-query.builder.js'; +import { COND_OPERATOR_FACTORY, type SCondition } from './crud-query.types.js'; +import { splitSortString } from './crud-query.utils.js'; +import { + validateComparisonOperator, + validateCondition, + validateNumeric, + validateParamOption, + validateSort, + validateUUID, +} from './crud-query.validator.js'; +import { CrudQueryParserException } from './exceptions/crud-query-parser.exception.js'; +import { type CrudParsedQueryInterface } from './interfaces/crud-parsed-query.interface.js'; +import { type CrudQueryBuilderOptionsInterface } from './interfaces/crud-query-builder-options.interface.js'; + +export class CrudQueryParser< + Entity extends PlainLiteralObject, +> implements CrudParsedQueryInterface { + public fields: EntityColumn[] = []; + + /** + * Route parameters as simple key-value pairs (e.g., \{ id: 5, userId: 'abc' \}) + */ + public routeParams: Record = {}; + + public search: SCondition | undefined; + + public filter: WhereCondition[] = []; + + public or: WhereCondition[] = []; + + public sort: OrderSortKey[] = []; + + public limit: number | undefined; + + public offset: number | undefined; + + public page: number | undefined; + + public cache: number | undefined; + + public includeDeleted: number | undefined; + + private _params: PlainLiteralObject = {}; + + private _query: PlainLiteralObject = {}; + + private _paramNames: string[] = []; + + private _paramsOptions: CrudParamsOptionsInterface | undefined; + + private get _options(): Required & { + paramNamesMap: Required; + } { + return CrudQueryBuilder.getOptions(); + } + + static create(): CrudQueryParser { + return new CrudQueryParser(); + } + + /** + * Get parsed query parameters (filter, sort, pagination, etc.) + */ + getParsedQuery(): CrudParsedQueryInterface { + return { + fields: this.fields, + search: this.search, + filter: this.filter, + or: this.or, + sort: this.sort, + limit: this.limit, + offset: this.offset, + page: this.page, + cache: this.cache, + includeDeleted: this.includeDeleted, + }; + } + + /** + * Get route parameters as simple key-value object + */ + getRouteParams(): Record { + return this.routeParams; + } + + parseQuery(query: PlainLiteralObject): this { + if (isObject(query)) { + const paramNames = Object.keys(query); + + if (paramNames.length) { + this._query = query; + this._paramNames = paramNames; + const searchData = this._query[this.getParamNames('search')[0]]; + this.search = this.parseSearchQueryParam(searchData); + if (isNil(this.search)) { + this.filter = this.parseFlatQueryParam( + 'filter', + this.conditionParser.bind(this, 'filter'), + ); + this.or = this.parseFlatQueryParam( + 'or', + this.conditionParser.bind(this, 'or'), + ); + } + this.fields = + this.parseQueryParam('fields', this.fieldsParser.bind(this))[0] || []; + this.sort = this.parseFlatQueryParam( + 'sort', + this.sortParser.bind(this), + ); + this.limit = this.parseQueryParam( + 'limit', + this.numericParser.bind(this, 'limit'), + )[0]; + this.offset = this.parseQueryParam( + 'offset', + this.numericParser.bind(this, 'offset'), + )[0]; + this.page = this.parseQueryParam( + 'page', + this.numericParser.bind(this, 'page'), + )[0]; + this.cache = this.parseQueryParam( + 'cache', + this.numericParser.bind(this, 'cache'), + )[0]; + this.includeDeleted = this.parseQueryParam( + 'includeDeleted', + this.numericParser.bind(this, 'includeDeleted'), + )[0]; + } + } + + return this; + } + + parseParams( + params: PlainLiteralObject, + options: CrudParamsOptionsInterface, + ): this { + if (isObject(params)) { + const paramNames = Object.keys(params); + + if (paramNames.length) { + this._params = params; + this._paramsOptions = options; + + // Build routeParams as simple key-value object + for (const name of paramNames) { + const parsedValue = this.paramParser(name); + if (parsedValue !== undefined) { + // Use the field name as key, store the parsed value + this.routeParams[parsedValue.field] = parsedValue.value; + } + } + } + } + + return this; + } + + private getParamNames( + type: keyof NonNullable, + ): string[] { + return this._paramNames.filter((p) => { + const aliases = this._options.paramNamesMap[type]; + + // Check for exact match or array-style parameter names (e.g., 'filter[0]', 'filter[1]') + return aliases.some((alias) => { + return p === alias || p.startsWith(`${alias}[`); + }); + }); + } + + private getParamValues< + U extends keyof NonNullable< + CrudQueryBuilderOptionsInterface['paramNamesMap'] + >, + R extends CrudParsedQueryInterface[U], + >(value: string | string[], parser: (data: string) => R): R[] { + if (typeof value === 'string' && isStringFull(value)) { + return [parser(value)]; + } + + if (Array.isArray(value) && value.length) { + return value.map((val) => parser(val)); + } + + return []; + } + + private parseFlatQueryParam( + type: keyof NonNullable, + parser: (data: string) => R, + ): R[] { + const param = this.getParamNames(type); + if (!param.length) return []; + return param.flatMap((name) => { + const value = this._query[name]; + if (typeof value === 'string' && isStringFull(value)) { + return [parser(value)]; + } + if (Array.isArray(value) && value.length) { + return value.map((val) => parser(val)); + } + return []; + }); + } + + private parseQueryParam< + U extends keyof NonNullable< + CrudQueryBuilderOptionsInterface['paramNamesMap'] + >, + R extends CrudParsedQueryInterface[U], + >(type: U, parser: (data: string) => R): R[] { + const param = this.getParamNames(type); + if (!param.length) return []; + return param.flatMap((name) => + this.getParamValues(this._query[name], parser), + ); + } + + private parseValue(val: string) { + try { + const parsed = JSON.parse(val); + + if (parsed instanceof Date === false && isObject(parsed)) { + return val; + } else if ( + typeof parsed === 'number' && + parsed.toLocaleString('fullwide', { useGrouping: false }) !== val + ) { + // JS cannot handle big numbers. Leave it as a string to prevent data loss + return val; + } + + return parsed; + } catch (_ignored) { + if (isDateString(val)) { + return new Date(val); + } + + return val; + } + } + + private parseValues(vals: string | string[]) { + if (Array.isArray(vals)) { + return vals.map((v: string) => this.parseValue(v)); + } else { + return this.parseValue(vals); + } + } + + private fieldsParser(data: string): EntityColumn[] { + return data.split(this._options.delimStr); + } + + private parseSearchQueryParam(d: string): SCondition | undefined { + try { + if (isNil(d)) { + return undefined; + } + + const data = JSON.parse(d); + + if (!isObject(data)) { + throw new Error(); + } + + return data; + } catch (_e) { + throw new CrudQueryParserException({ + message: 'Invalid search param. JSON expected', + }); + } + } + + private conditionParser( + cond: 'filter' | 'or', + data: string, + ): WhereCondition { + const isArrayValue = ['$in', '$nin', '$between']; + const isEmptyValue = ['$null', '$nnull']; + const param = data.split(this._options.delim); + let field: string; + let relation: string | undefined; + + if (param[0].includes('.')) { + const parts = param[0].split('.'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new CrudQueryParserException({ + message: `Invalid ${sanitizeForMessage(cond)} field format: expected 'relation.field'`, + }); + } + [relation, field] = parts; + } else { + field = param[0]; + } + + const operator = param[1]; + validateComparisonOperator(operator); + + let value: string | string[] = param[2] || ''; + + if (isArrayValue.some((name) => name === operator)) { + value = value.split(this._options.delimStr); + } + + value = this.parseValues(value); + + if (!isEmptyValue.some((name) => name === operator) && !hasValue(value)) { + throw new CrudQueryParserException({ + message: `Invalid ${sanitizeForMessage(cond)} value`, + }); + } + + const factory = COND_OPERATOR_FACTORY[operator]; + let condition = factory(field, value); + + if (relation) { + condition = Where.rel(relation, condition); + } + + validateCondition(condition, cond); + + return condition; + } + + private sortParser(data: string): OrderSortKey { + const sort = splitSortString(data, this._options.delimStr); + validateSort(sort); + return sort; + } + + private numericParser( + num: 'limit' | 'offset' | 'page' | 'cache' | 'includeDeleted', + data: string, + ): number { + const val = this.parseValue(data); + validateNumeric(val, num); + + return val; + } + + private paramParser( + name: string, + ): Pick | undefined { + const paramsOptions: CrudParamsOptionsInterface = + this._paramsOptions ?? {}; + + validateParamOption(paramsOptions, name); + const option = paramsOptions[name]; + + if ( + 'field' in option && + typeof option.field === 'string' && + option.disabled !== true + ) { + let value = this._params[name]; + + switch (option.type) { + case 'number': + value = this.parseValue(value); + validateNumeric(value, `param ${name}`); + break; + case 'uuid': + validateUUID(value, name); + break; + default: + break; + } + + return { field: option.field, value }; + } else { + return undefined; + } + } +} diff --git a/packages/nestjs-crud/src/infrastructure/request/crud-query.types.ts b/packages/nestjs-crud/src/infrastructure/request/crud-query.types.ts new file mode 100644 index 000000000..d15d6b20b --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/crud-query.types.ts @@ -0,0 +1,103 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type EntityColumn, + Where, + type WhereCondition, +} from '@concepta/nestjs-repository'; + +/** + * Wire format prefix for comparison operators in query strings. + */ +export const COND_OPERATOR_PREFIX = '$'; + +export enum CondOperator { + EQUALS = '$eq', + NOT_EQUALS = '$ne', + GREATER_THAN = '$gt', + LOWER_THAN = '$lt', + GREATER_THAN_EQUALS = '$gte', + LOWER_THAN_EQUALS = '$lte', + STARTS = '$starts', + NOT_STARTS = '$nstarts', + ENDS = '$ends', + NOT_ENDS = '$nends', + CONTAINS = '$contains', + NOT_CONTAINS = '$ncontains', + IN = '$in', + NOT_IN = '$nin', + IS_NULL = '$null', + NOT_NULL = '$nnull', + BETWEEN = '$between', +} + +export type ComparisonOperator = `${CondOperator}`; + +/** + * Shared factory map from $-prefixed wire operators to WhereCondition builders. + * + * Used by both CrudQueryParser (URL query string parsing) and + * SConditionConverter (JSON search parsing). Callers handle their + * own input validation before invoking these factories. + */ +export const COND_OPERATOR_FACTORY: Record< + CondOperator, + (field: string, value: unknown) => WhereCondition +> = { + [CondOperator.EQUALS]: (f, v) => Where.eq(f, v), + [CondOperator.NOT_EQUALS]: (f, v) => Where.ne(f, v), + [CondOperator.GREATER_THAN]: (f, v) => Where.gt(f, v), + [CondOperator.LOWER_THAN]: (f, v) => Where.lt(f, v), + [CondOperator.GREATER_THAN_EQUALS]: (f, v) => Where.gte(f, v), + [CondOperator.LOWER_THAN_EQUALS]: (f, v) => Where.lte(f, v), + [CondOperator.STARTS]: (f, v) => Where.starts(f, String(v)), + [CondOperator.NOT_STARTS]: (f, v) => Where.notStarts(f, String(v)), + [CondOperator.ENDS]: (f, v) => Where.ends(f, String(v)), + [CondOperator.NOT_ENDS]: (f, v) => Where.notEnds(f, String(v)), + [CondOperator.CONTAINS]: (f, v) => Where.contains(f, String(v)), + [CondOperator.NOT_CONTAINS]: (f, v) => Where.notContains(f, String(v)), + [CondOperator.IN]: (f, v) => Where.in(f, Array.isArray(v) ? v : []), + [CondOperator.NOT_IN]: (f, v) => Where.notIn(f, Array.isArray(v) ? v : []), + [CondOperator.IS_NULL]: (f) => Where.isNull(f), + [CondOperator.NOT_NULL]: (f) => Where.notNull(f), + [CondOperator.BETWEEN]: (f, v) => { + const arr = Array.isArray(v) ? v : []; + return Where.between(f, arr[0], arr[1]); + }, +}; + +// new search +export type SPrimitivesVal = string | number | boolean; + +export type SFieldValues = SPrimitivesVal | Array; + +export type SFieldOperator = { + [K in CondOperator]?: SFieldValues; +} & { + $or?: SFieldOperator; + $and?: never; +}; + +export type SField = SPrimitivesVal | SFieldOperator; + +export type SFields = Partial< + Record< + EntityColumn, + SField | Array | SConditionAND> | undefined | null + > +> & { + $or?: Array>; + $and?: never; +}; + +export type SConditionAND = { + [key: string]: unknown; + $and?: Array>; + $or?: never; +}; + +export type SConditionKey = '$and' | '$or'; + +export type SCondition = + | SFields + | SConditionAND; diff --git a/packages/nestjs-crud/src/infrastructure/request/crud-query.utils.ts b/packages/nestjs-crud/src/infrastructure/request/crud-query.utils.ts new file mode 100644 index 000000000..8718c4e8f --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/crud-query.utils.ts @@ -0,0 +1,18 @@ +export function splitSortString(sort: string, delim = ',') { + const [field, order] = sort.split(delim); + let sortField: string; + let relation: string | undefined; + + if (field.includes('.')) { + const parts = field.split('.'); + [relation, sortField] = parts; + } else { + sortField = field; + } + + return { + field: sortField ? sortField.trim() : undefined, + order: order ? order.trim().toUpperCase() : undefined, + ...(relation ? { relation } : {}), + }; +} diff --git a/packages/nestjs-crud/src/infrastructure/request/crud-query.validator.ts b/packages/nestjs-crud/src/infrastructure/request/crud-query.validator.ts new file mode 100644 index 000000000..a2e459994 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/crud-query.validator.ts @@ -0,0 +1,122 @@ +import { z } from 'zod'; + +import { type PlainLiteralObject } from '@nestjs/common'; + +import { isNil, isNumber, isObject } from '@concepta/nestjs-core'; +import { + type EntityColumn, + SortOrder, + type WhereCondition, + WhereOperator, +} from '@concepta/nestjs-repository'; + +import { type CrudParamsOptionsInterface } from '../interfaces/crud-params-options.interface.js'; +import { isArrayStrings, isStringFull } from '../utils/validation.js'; + +import { type ComparisonOperator, CondOperator } from './crud-query.types.js'; +import { CrudQueryValidatorException } from './exceptions/crud-query-validator.exception.js'; + +export const COMPARISON_OPERATORS: CondOperator[] = Object.values(CondOperator); + +export const SORT_OPERATORS: SortOrder[] = Object.values(SortOrder); + +export function validateFields( + fields: EntityColumn[], +): void { + if (!isArrayStrings(fields)) { + throw new CrudQueryValidatorException({ + message: 'Invalid fields. Array of strings expected', + }); + } +} + +export function validateCondition( + val: WhereCondition, + cond: 'filter' | 'or', +): void { + if (!isObject(val) || !isStringFull(val.field)) { + throw new CrudQueryValidatorException({ + message: `Invalid field type in ${cond} condition. String expected`, + }); + } + if (!Object.values(WhereOperator).includes(val.operator)) { + throw new CrudQueryValidatorException({ + message: `Invalid comparison operator. ${Object.values(WhereOperator).join()} expected`, + }); + } +} + +export function validateComparisonOperator( + operator: string, +): asserts operator is ComparisonOperator { + if (!COMPARISON_OPERATORS.some((op) => op === operator)) { + throw new CrudQueryValidatorException({ + message: `Invalid comparison operator. ${COMPARISON_OPERATORS.join()} expected`, + }); + } +} + +export function isSortOrder(value: unknown): value is SortOrder { + return typeof value === 'string' && SORT_OPERATORS.some((op) => op === value); +} + +export function validateSort(sort: { + field?: unknown; + order?: unknown; +}): asserts sort is { field: string; order: SortOrder } { + if ( + !isObject(sort) || + 'field' in sort === false || + !isStringFull(sort.field) + ) { + throw new CrudQueryValidatorException({ + message: 'Invalid sort field. String expected', + }); + } + if (!isSortOrder(sort.order)) { + throw new CrudQueryValidatorException({ + message: `Invalid sort order. ${SORT_OPERATORS.join()} expected`, + }); + } +} + +export function validateNumeric( + val: number, + num: 'limit' | 'offset' | 'page' | 'cache' | 'includeDeleted' | string, +): void { + if (!isNumber(val)) { + throw new CrudQueryValidatorException({ + message: `Invalid ${num}. Number expected`, + }); + } +} + +export function validateParamOption( + options: CrudParamsOptionsInterface, + name: string, +) { + if (!isObject(options)) { + throw new CrudQueryValidatorException({ + message: `Invalid param ${name}. Invalid crud options`, + }); + } + const option = options[name]; + if (option && option.disabled) { + return; + } + if (!isObject(option) || isNil(option.field) || isNil(option.type)) { + throw new CrudQueryValidatorException({ + message: 'Invalid param option in Crud', + }); + } +} + +const uuidSchema = z.uuid(); + +export function validateUUID(str: string, name: string) { + if (!uuidSchema.safeParse(str).success) { + throw new CrudQueryValidatorException({ + message: `Invalid param ${name}. UUID string expected`, + }); + } +} diff --git a/packages/nestjs-crud/src/infrastructure/request/crud-scondition.converter.ts b/packages/nestjs-crud/src/infrastructure/request/crud-scondition.converter.ts new file mode 100644 index 000000000..15167edbf --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/crud-scondition.converter.ts @@ -0,0 +1,205 @@ +import { BadRequestException, type PlainLiteralObject } from '@nestjs/common'; + +import { isObject } from '@concepta/nestjs-core'; +import { Where, type WhereClause } from '@concepta/nestjs-repository'; + +import { sanitizeForMessage } from '../utils/validation.js'; + +import { + COND_OPERATOR_FACTORY, + CondOperator, + type SCondition, + type SFieldOperator, +} from './crud-query.types.js'; +import { COMPARISON_OPERATORS } from './crud-query.validator.js'; + +/** + * Converts SCondition search trees (from ?s= JSON query param) + * into canonical WhereClause AST nodes. + * + * This is a pure mapping — no entity column validation. + * Field validation should be performed separately by the consumer. + */ +export class SConditionConverter { + private constructor() {} + + /** + * Convert an SCondition tree to a single WhereClause, or undefined if empty. + */ + static convert( + search: SCondition, + ): WhereClause | undefined { + const clauses = this.toWhereClauses(search); + if (clauses.length === 0) return undefined; + if (clauses.length === 1) return clauses[0]; + return Where.and(...clauses); + } + + /** + * Convert SCondition search tree to WhereClause array. + */ + private static toWhereClauses( + search?: SCondition, + ): WhereClause[] { + if (!search || !isObject(search) || Object.keys(search).length === 0) { + return []; + } + + // Handle $and conditions + if (search.$and && Array.isArray(search.$and) && search.$and.length) { + const andBranches = search.$and.map((s) => this.toWhereClauses(s)); + const nonEmptyBranches = andBranches.filter((b) => b.length > 0); + + if (nonEmptyBranches.length === 0) { + return []; + } + + if (nonEmptyBranches.length === 1) { + return nonEmptyBranches[0]; + } + + return [ + Where.and( + ...nonEmptyBranches.map((branch) => + branch.length === 1 ? branch[0] : Where.or(...branch), + ), + ), + ]; + } + + // Handle $or conditions + if (search.$or && Array.isArray(search.$or) && search.$or.length) { + const orConditions = search.$or.flatMap((s) => this.toWhereClauses(s)); + + const keys = Object.keys(search); + const otherKeys = keys.filter((k) => k !== '$or'); + if (otherKeys.length > 0) { + const fieldClauses = this.fieldConditions(search); + if (fieldClauses.length > 0 && orConditions.length > 0) { + return [Where.and(...fieldClauses, Where.or(...orConditions))]; + } + if (orConditions.length > 0) { + return [Where.or(...orConditions)]; + } + return fieldClauses; + } + + if (orConditions.length === 0) { + return []; + } + + return [Where.or(...orConditions)]; + } + + // Simple field conditions + return this.fieldConditions(search); + } + + /** + * Build WhereClause array from entity field values, ignoring $and/$or keys. + */ + private static fieldConditions( + search: SCondition, + ): WhereClause[] { + const clauses: WhereClause[] = []; + + for (const field of Object.keys(search)) { + if (field === '$and' || field === '$or') continue; + + const value = search[field]; + + if (value === null || value === undefined) { + clauses.push(Where.isNull(field)); + } else if (typeof value !== 'object') { + clauses.push(Where.eq(field, value)); + } else if (!Array.isArray(value)) { + clauses.push(...this.fieldOperators(field, value)); + } + } + + return clauses; + } + + /** + * Convert a field's SFieldOperator to WhereClause[]. + * Multiple operators on the same field produce an AND compound. + */ + private static fieldOperators( + field: string, + operators: SFieldOperator, + ): WhereClause[] { + const conditions: WhereClause[] = []; + + for (const key of COMPARISON_OPERATORS) { + if (key in operators) { + conditions.push(this.mapCondOperator(field, key, operators[key])); + } + } + + // Handle nested $or — e.g. { $or: { $null: true, $eq: 1 } } + if (operators.$or) { + const orOperators = operators.$or; + const orConditions: WhereClause[] = []; + for (const key of COMPARISON_OPERATORS) { + if (key in orOperators) { + orConditions.push(this.mapCondOperator(field, key, orOperators[key])); + } + } + if (orConditions.length === 1) { + conditions.push(orConditions[0]); + } else if (orConditions.length > 1) { + if (conditions.length === 0) { + return [Where.or(...orConditions)]; + } + return [Where.and(...conditions, Where.or(...orConditions))]; + } + } + + if (conditions.length === 0) { + throw new BadRequestException('Empty filter operator object'); + } + + if (conditions.length === 1) { + return [conditions[0]]; + } + + return [Where.and(...conditions)]; + } + + /** + * Map a CondOperator ($-prefixed) to a WhereClause. + * Validates array/pair inputs before delegating to the shared factory. + */ + private static mapCondOperator( + field: string, + operator: CondOperator, + value: unknown, + ): WhereClause { + if ( + (operator === CondOperator.IN || operator === CondOperator.NOT_IN) && + !Array.isArray(value) + ) { + throw new BadRequestException( + `${sanitizeForMessage(operator)} requires array`, + ); + } + + if (operator === CondOperator.BETWEEN) { + if (!Array.isArray(value) || value.length !== 2) { + throw new BadRequestException( + 'BETWEEN operator requires an array with two elements', + ); + } + } + + const factory = COND_OPERATOR_FACTORY[operator]; + + if (!factory) { + throw new BadRequestException( + `Unknown filter operator '${sanitizeForMessage(operator)}'`, + ); + } + + return factory(field, value); + } +} diff --git a/packages/nestjs-crud/src/infrastructure/request/exceptions/crud-query-parser.exception.ts b/packages/nestjs-crud/src/infrastructure/request/exceptions/crud-query-parser.exception.ts new file mode 100644 index 000000000..b66c2c4cf --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/exceptions/crud-query-parser.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; + +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +export class CrudQueryParserException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + this.errorCode = 'CRUD_QUERY_PARSER_ERROR'; + } +} diff --git a/packages/nestjs-crud/src/infrastructure/request/exceptions/crud-query-validator.exception.ts b/packages/nestjs-crud/src/infrastructure/request/exceptions/crud-query-validator.exception.ts new file mode 100644 index 000000000..472791ba5 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/exceptions/crud-query-validator.exception.ts @@ -0,0 +1,16 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { CrudException } from '../../exceptions/crud.exception.js'; + +export class CrudQueryValidatorException extends CrudException { + constructor(options?: RuntimeExceptionOptions) { + super({ + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + this.errorCode = 'CRUD_QUERY_VALIDATOR_ERROR'; + } +} diff --git a/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-create-query-params.interface.ts b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-create-query-params.interface.ts new file mode 100644 index 000000000..55683b601 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-create-query-params.interface.ts @@ -0,0 +1,35 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type EntityColumn, + type OrderSortKey, + type OrderSortKeyArr, + type WhereCondition, + type WhereConditionArr, +} from '@concepta/nestjs-repository'; + +import { type SCondition } from '../crud-query.types.js'; + +export interface CrudCreateQueryParamsInterface< + T extends PlainLiteralObject = PlainLiteralObject, +> { + fields?: EntityColumn[]; + search?: SCondition; + filter?: + | WhereCondition + | WhereConditionArr + | Array | WhereConditionArr>; + or?: + | WhereCondition + | WhereConditionArr + | Array | WhereConditionArr>; + sort?: + | OrderSortKey + | OrderSortKeyArr + | Array | OrderSortKeyArr>; + limit?: number; + offset?: number; + page?: number; + resetCache?: boolean; + includeDeleted?: number; +} diff --git a/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-options.interface.ts b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-options.interface.ts new file mode 100644 index 000000000..476147af2 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-options.interface.ts @@ -0,0 +1,21 @@ +import { + type PlainLiteralObject, + type StandardSchemaValidationPipeOptions, +} from '@nestjs/common'; + +import { type CrudParamsOptionsInterface } from '../../interfaces/crud-params-options.interface.js'; + +import { type CrudQueryOptionsInterface } from './crud-query-options.interface.js'; + +export interface CrudOptionsInterface { + query?: CrudQueryOptionsInterface; + params?: CrudParamsOptionsInterface; + /** + * Options merged into the `StandardSchemaValidationPipe` used to + * validate a `@CrudBody()` schema — lets callers configure pipe + * behavior (e.g. `exceptionFactory`, `errorHttpStatusCode`) via plain + * data instead of subclassing. `false` disables validation for the + * body (it is still bound, just unvalidated). + */ + validation?: StandardSchemaValidationPipeOptions | false; +} diff --git a/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-parsed-query.interface.ts b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-parsed-query.interface.ts new file mode 100644 index 000000000..e2beec2ae --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-parsed-query.interface.ts @@ -0,0 +1,29 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type EntityColumn, + type OrderSortKey, + type WhereCondition, +} from '@concepta/nestjs-repository'; + +import { type SCondition } from '../crud-query.types.js'; + +/** + * Interface representing parsed query string parameters from a CRUD request. + * + * Contains filter, sort, pagination, and other query configurations + * parsed from the request query string. Route parameters are stored + * separately in CrudContextInterface.params. + */ +export interface CrudParsedQueryInterface { + fields: EntityColumn[]; + search: SCondition | undefined; + filter: WhereCondition[]; + or: WhereCondition[]; + sort: OrderSortKey[]; + limit: number | undefined; + offset: number | undefined; + page: number | undefined; + cache: number | undefined; + includeDeleted: number | undefined; +} diff --git a/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-query-builder-options.interface.ts b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-query-builder-options.interface.ts new file mode 100644 index 000000000..9240cc465 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-query-builder-options.interface.ts @@ -0,0 +1,16 @@ +export interface CrudQueryBuilderOptionsInterface { + delim?: string; + delimStr?: string; + paramNamesMap?: { + fields?: string[]; + search?: string[]; + filter?: string[]; + or?: string[]; + sort?: string[]; + limit?: string[]; + offset?: string[]; + page?: string[]; + cache?: string[]; + includeDeleted?: string[]; + }; +} diff --git a/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-query-options.interface.ts b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-query-options.interface.ts new file mode 100644 index 000000000..c75b1291f --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-query-options.interface.ts @@ -0,0 +1,21 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type EntityColumn, + type JoinClause, + type OrderSortKey, +} from '@concepta/nestjs-repository'; + +import { type QueryFilterOption } from '../query-filter-option.type.js'; + +export interface CrudQueryOptionsInterface { + allow?: EntityColumn[]; + exclude?: EntityColumn[]; + persist?: EntityColumn[]; + filter?: QueryFilterOption; + sort?: OrderSortKey[]; + limit?: number; + maxLimit?: number; + cache?: number | false; + join?: JoinClause[]; +} diff --git a/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-request-config.interface.ts b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-request-config.interface.ts new file mode 100644 index 000000000..d8c2c2e47 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-request-config.interface.ts @@ -0,0 +1,34 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type CrudSchema, + type CrudValidationOptions, +} from '../../../crud.types.js'; +import { type CrudParamsOptionsInterface } from '../../interfaces/crud-params-options.interface.js'; + +/** + * Request configuration for CRUD operations. + * + * Used at controller level to set defaults, and at route level for overrides. + */ +export interface CrudRequestConfig { + /** + * URL parameter configuration for entity identification. + */ + params?: CrudParamsOptionsInterface; + + /** + * Schema for single-entity request bodies. + */ + body?: CrudSchema; + + /** + * Schema for batch request bodies. + */ + bodyBatch?: CrudSchema; + + /** + * Validation options for request processing. + */ + validation?: CrudValidationOptions; +} diff --git a/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-response-config.interface.ts b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-response-config.interface.ts new file mode 100644 index 000000000..04959d9cb --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/interfaces/crud-response-config.interface.ts @@ -0,0 +1,39 @@ +import { type CrudSchema } from '../../../crud.types.js'; +import { type CrudSerializationOptionsInterface } from '../../interfaces/crud-serialization-options.interface.js'; + +/** + * Response configuration for CRUD operations. + * + * Used at controller level to set defaults, and at route level for overrides. + */ +export interface CrudResponseConfig { + /** + * Schema for single resource responses. + */ + resource?: CrudSchema; + + /** + * Schema for collection responses (future use when de-paginate is supported). + */ + collection?: CrudSchema; + + /** + * Schema for paginated responses. + */ + paginated?: CrudSchema; + + /** + * Serialization options for response transformation. + */ + serialization?: CrudSerializationOptionsInterface; + + /** + * Return the deleted entity in delete/soft delete responses. + */ + returnDeleted?: boolean; + + /** + * Return the restored entity in restore responses. + */ + returnRestored?: boolean; +} diff --git a/packages/nestjs-crud/src/infrastructure/request/query-filter-option.type.ts b/packages/nestjs-crud/src/infrastructure/request/query-filter-option.type.ts new file mode 100644 index 000000000..ce11a1390 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/request/query-filter-option.type.ts @@ -0,0 +1,9 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type WhereCondition } from '@concepta/nestjs-repository'; + +import { type SCondition } from './crud-query.types.js'; + +export type QueryFilterOption = + | WhereCondition[] + | SCondition; diff --git a/packages/nestjs-crud/src/infrastructure/resolvers/crud-adapter.resolver.ts b/packages/nestjs-crud/src/infrastructure/resolvers/crud-adapter.resolver.ts new file mode 100644 index 000000000..78ad08b31 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/resolvers/crud-adapter.resolver.ts @@ -0,0 +1,106 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; + +import { DeepPartial } from '@concepta/nestjs-core'; + +import { CrudAdapter } from '../adapters/crud.adapter.js'; +import { CrudContextInterface } from '../interceptors/interfaces/crud-context.interface.js'; +import { CrudCreateBatchInterface } from '../interfaces/crud-create-batch.interface.js'; +import { CrudResponsePaginatedInterface } from '../interfaces/crud-response-paginated.interface.js'; +import { getDynamicAdapterToken } from '../utils/crud-infra.utils.js'; + +import { CrudResolverInterface } from './interfaces/crud-resolver.interface.js'; + +/** + * Adapter resolver - calls adapter directly without handlers. + * + * This is the simplest resolver. It bypasses query/command handlers entirely + * and calls the adapter methods directly. Use this when you don't need + * custom handler logic. + */ +@Injectable() +export class CrudAdapterResolver implements CrudResolverInterface { + constructor(private readonly moduleRef: ModuleRef) {} + + /** + * No-op - handlers not used by this resolver. + */ + static decorateQueryHandler(_handlerClass: Type, _queryClass: Type): void { + // Handlers are not used by CrudAdapterResolver + } + + /** + * No-op - handlers not used by this resolver. + */ + static decorateCommandHandler( + _handlerClass: Type, + _commandClass: Type, + ): void { + // Handlers are not used by CrudAdapterResolver + } + + async list( + ctx: CrudContextInterface, + ): Promise> { + return this.resolveAdapter(ctx).list(ctx); + } + + async read( + ctx: CrudContextInterface, + ): Promise { + return this.resolveAdapter(ctx).read(ctx); + } + + async create( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise { + return this.resolveAdapter(ctx).create(ctx, dto); + } + + async createBatch( + ctx: CrudContextInterface, + dto: CrudCreateBatchInterface>, + ): Promise { + return this.resolveAdapter(ctx).createBatch(ctx, dto); + } + + async update( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise { + return this.resolveAdapter(ctx).update(ctx, dto); + } + + async replace( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise { + return this.resolveAdapter(ctx).replace(ctx, dto); + } + + async delete( + ctx: CrudContextInterface, + ): Promise { + return this.resolveAdapter(ctx).delete(ctx); + } + + async softDelete( + ctx: CrudContextInterface, + ): Promise { + return this.resolveAdapter(ctx).softDelete(ctx); + } + + async restore( + ctx: CrudContextInterface, + ): Promise { + return this.resolveAdapter(ctx).restore(ctx); + } + + protected resolveAdapter( + ctx: CrudContextInterface, + ): CrudAdapter { + const adapterToken = getDynamicAdapterToken(ctx.entity); + return this.moduleRef.get(adapterToken, { strict: false }); + } +} diff --git a/packages/nestjs-crud/src/infrastructure/resolvers/crud-cqrs.resolver.ts b/packages/nestjs-crud/src/infrastructure/resolvers/crud-cqrs.resolver.ts new file mode 100644 index 000000000..d097b8a09 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/resolvers/crud-cqrs.resolver.ts @@ -0,0 +1,152 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { + CommandBus, + CommandHandler, + QueryBus, + QueryHandler, +} from '@nestjs/cqrs'; + +import { DeepPartial } from '@concepta/nestjs-core'; + +import { CrudContextInterface } from '../interceptors/interfaces/crud-context.interface.js'; +import { CrudCreateBatchInterface } from '../interfaces/crud-create-batch.interface.js'; +import { CrudResponsePaginatedInterface } from '../interfaces/crud-response-paginated.interface.js'; + +import { CrudResolverInterface } from './interfaces/crud-resolver.interface.js'; + +/** + * CQRS resolver - uses QueryBus/CommandBus for dispatching. + * + * This resolver uses the full CQRS pattern. Queries and commands are dispatched + * through their respective buses, enabling CQRS features like sagas, events, + * and cross-module routing. + * + * Requires `@nestjs/cqrs` as a dependency. + * + * @example + * ```typescript + * @Module({ + * imports: [ + * CrudModule.forRoot({ + * defaultResolver: CrudCqrsResolver, + * }), + * ], + * }) + * export class AppModule {} + * ``` + */ +@Injectable() +export class CrudCqrsResolver implements CrudResolverInterface { + constructor( + private readonly queryBus: QueryBus, + private readonly commandBus: CommandBus, + ) {} + + /** + * Apply `@QueryHandler()` decorator to register the handler with CQRS QueryBus. + */ + static decorateQueryHandler(handlerClass: Type, queryClass: Type): void { + QueryHandler(queryClass)(handlerClass); + } + + /** + * Apply `@CommandHandler()` decorator to register the handler with CQRS CommandBus. + */ + static decorateCommandHandler(handlerClass: Type, commandClass: Type): void { + CommandHandler(commandClass)(handlerClass); + } + + async list( + ctx: CrudContextInterface, + ): Promise> { + const QueryClass = ctx.options.route?.query; + if (!QueryClass) { + throw new Error('No query configured for list operation'); + } + return this.queryBus.execute(new QueryClass(ctx)); + } + + async read( + ctx: CrudContextInterface, + ): Promise { + const QueryClass = ctx.options.route?.query; + if (!QueryClass) { + throw new Error('No query configured for read operation'); + } + return this.queryBus.execute(new QueryClass(ctx)); + } + + async create( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise { + const CommandClass = ctx.options.route?.command; + if (!CommandClass) { + throw new Error('No command configured for create operation'); + } + return this.commandBus.execute(new CommandClass(ctx, dto)); + } + + async createBatch( + ctx: CrudContextInterface, + dto: CrudCreateBatchInterface>, + ): Promise { + const CommandClass = ctx.options.route?.command; + if (!CommandClass) { + throw new Error('No command configured for createBatch operation'); + } + return this.commandBus.execute(new CommandClass(ctx, dto)); + } + + async update( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise { + const CommandClass = ctx.options.route?.command; + if (!CommandClass) { + throw new Error('No command configured for update operation'); + } + return this.commandBus.execute(new CommandClass(ctx, dto)); + } + + async replace( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise { + const CommandClass = ctx.options.route?.command; + if (!CommandClass) { + throw new Error('No command configured for replace operation'); + } + return this.commandBus.execute(new CommandClass(ctx, dto)); + } + + async delete( + ctx: CrudContextInterface, + ): Promise { + const CommandClass = ctx.options.route?.command; + if (!CommandClass) { + throw new Error('No command configured for delete operation'); + } + return this.commandBus.execute(new CommandClass(ctx)); + } + + async softDelete( + ctx: CrudContextInterface, + ): Promise { + const CommandClass = ctx.options.route?.command; + if (!CommandClass) { + throw new Error('No command configured for soft delete operation'); + } + return this.commandBus.execute(new CommandClass(ctx)); + } + + async restore( + ctx: CrudContextInterface, + ): Promise { + const CommandClass = ctx.options.route?.command; + if (!CommandClass) { + throw new Error('No command configured for restore operation'); + } + return this.commandBus.execute(new CommandClass(ctx)); + } +} diff --git a/packages/nestjs-crud/src/infrastructure/resolvers/crud-operation.resolver.ts b/packages/nestjs-crud/src/infrastructure/resolvers/crud-operation.resolver.ts new file mode 100644 index 000000000..fe199edb3 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/resolvers/crud-operation.resolver.ts @@ -0,0 +1,174 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; + +import { DeepPartial } from '@concepta/nestjs-core'; + +import { CrudContextInterface } from '../interceptors/interfaces/crud-context.interface.js'; +import { CrudCreateBatchInterface } from '../interfaces/crud-create-batch.interface.js'; +import { CrudResponsePaginatedInterface } from '../interfaces/crud-response-paginated.interface.js'; + +import { CrudResolverInterface } from './interfaces/crud-resolver.interface.js'; + +/** + * Operation resolver - creates query/command instances and calls handlers directly. + * + * This resolver uses query/command handlers but does NOT route through the CQRS bus. + * Handlers are resolved directly via ModuleRef and invoked. Use this when you need + * custom handler logic but don't need CQRS features like sagas or events. + * + * @example + * ```typescript + * @Module({ + * imports: [ + * CrudModule.forRoot({ + * defaultResolver: CrudOperationResolver, + * }), + * ], + * }) + * export class AppModule {} + * ``` + */ +@Injectable() +export class CrudOperationResolver implements CrudResolverInterface { + constructor(private readonly moduleRef: ModuleRef) {} + + /** + * No-op - handler is resolved directly via ModuleRef. + */ + static decorateQueryHandler(_handlerClass: Type, _queryClass: Type): void { + // No additional decorators needed + } + + /** + * No-op - handler is resolved directly via ModuleRef. + */ + static decorateCommandHandler( + _handlerClass: Type, + _commandClass: Type, + ): void { + // No additional decorators needed + } + + async list( + ctx: CrudContextInterface, + ): Promise> { + const QueryClass = ctx.options.route?.query; + const HandlerClass = ctx.options.route?.queryHandler?.resolved; + if (!QueryClass || !HandlerClass) { + throw new Error('No query/handler configured for list operation'); + } + return this.executeQuery(HandlerClass, new QueryClass(ctx)); + } + + async read( + ctx: CrudContextInterface, + ): Promise { + const QueryClass = ctx.options.route?.query; + const HandlerClass = ctx.options.route?.queryHandler?.resolved; + if (!QueryClass || !HandlerClass) { + throw new Error('No query/handler configured for read operation'); + } + return this.executeQuery(HandlerClass, new QueryClass(ctx)); + } + + async create( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise { + const CommandClass = ctx.options.route?.command; + const HandlerClass = ctx.options.route?.commandHandler?.resolved; + if (!CommandClass || !HandlerClass) { + throw new Error('No command/handler configured for create operation'); + } + return this.executeCommand(HandlerClass, new CommandClass(ctx, dto)); + } + + async createBatch( + ctx: CrudContextInterface, + dto: CrudCreateBatchInterface>, + ): Promise { + const CommandClass = ctx.options.route?.command; + const HandlerClass = ctx.options.route?.commandHandler?.resolved; + if (!CommandClass || !HandlerClass) { + throw new Error( + 'No command/handler configured for createBatch operation', + ); + } + return this.executeCommand(HandlerClass, new CommandClass(ctx, dto)); + } + + async update( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise { + const CommandClass = ctx.options.route?.command; + const HandlerClass = ctx.options.route?.commandHandler?.resolved; + if (!CommandClass || !HandlerClass) { + throw new Error('No command/handler configured for update operation'); + } + return this.executeCommand(HandlerClass, new CommandClass(ctx, dto)); + } + + async replace( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise { + const CommandClass = ctx.options.route?.command; + const HandlerClass = ctx.options.route?.commandHandler?.resolved; + if (!CommandClass || !HandlerClass) { + throw new Error('No command/handler configured for replace operation'); + } + return this.executeCommand(HandlerClass, new CommandClass(ctx, dto)); + } + + async delete( + ctx: CrudContextInterface, + ): Promise { + const CommandClass = ctx.options.route?.command; + const HandlerClass = ctx.options.route?.commandHandler?.resolved; + if (!CommandClass || !HandlerClass) { + throw new Error('No command/handler configured for delete operation'); + } + return this.executeCommand(HandlerClass, new CommandClass(ctx)); + } + + async softDelete( + ctx: CrudContextInterface, + ): Promise { + const CommandClass = ctx.options.route?.command; + const HandlerClass = ctx.options.route?.commandHandler?.resolved; + if (!CommandClass || !HandlerClass) { + throw new Error( + 'No command/handler configured for soft delete operation', + ); + } + return this.executeCommand(HandlerClass, new CommandClass(ctx)); + } + + async restore( + ctx: CrudContextInterface, + ): Promise { + const CommandClass = ctx.options.route?.command; + const HandlerClass = ctx.options.route?.commandHandler?.resolved; + if (!CommandClass || !HandlerClass) { + throw new Error('No command/handler configured for restore operation'); + } + return this.executeCommand(HandlerClass, new CommandClass(ctx)); + } + + /** + * Execute a query handler. Return type is determined by the caller. + */ + private executeQuery(handlerClass: Type, query: unknown): Promise { + const handler = this.moduleRef.get(handlerClass, { strict: false }); + return handler.execute(query); + } + + /** + * Execute a command handler. Return type is determined by the caller. + */ + private executeCommand(handlerClass: Type, command: unknown): Promise { + const handler = this.moduleRef.get(handlerClass, { strict: false }); + return handler.execute(command); + } +} diff --git a/packages/nestjs-crud/src/infrastructure/resolvers/interfaces/crud-resolver.interface.ts b/packages/nestjs-crud/src/infrastructure/resolvers/interfaces/crud-resolver.interface.ts new file mode 100644 index 000000000..407a22f75 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/resolvers/interfaces/crud-resolver.interface.ts @@ -0,0 +1,85 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { type CrudContextInterface } from '../../interceptors/interfaces/crud-context.interface.js'; +import { type CrudCreateBatchInterface } from '../../interfaces/crud-create-batch.interface.js'; +import { type CrudResponsePaginatedInterface } from '../../interfaces/crud-response-paginated.interface.js'; + +/** + * Interface for CRUD resolver implementations. + * + * Resolvers control how CRUD operations are dispatched: + * - CrudAdapterResolver: calls adapter directly (simplest, no handlers) + * - CrudOperationResolver: calls handlers directly (no CQRS bus) + * - CrudCqrsResolver: uses QueryBus/CommandBus (full CQRS) + * + * Methods are generic to allow the same resolver instance to handle + * multiple entity types. + */ +export interface CrudResolverInterface { + list( + ctx: CrudContextInterface, + ): Promise>; + + read( + ctx: CrudContextInterface, + ): Promise; + + create( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise; + + createBatch( + ctx: CrudContextInterface, + dto: CrudCreateBatchInterface>, + ): Promise; + + update( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise; + + replace( + ctx: CrudContextInterface, + dto: DeepPartial, + ): Promise; + + delete( + ctx: CrudContextInterface, + ): Promise; + + softDelete( + ctx: CrudContextInterface, + ): Promise; + + restore( + ctx: CrudContextInterface, + ): Promise; +} + +/** + * Static methods that resolver classes must implement. + * + * These methods are called at decorator-time to apply the appropriate + * decorators to handler classes. + * + * Note: This is used as an intersection type (ResolverType & CrudResolverStatic) + * rather than `implements` since TypeScript doesn't check static members. + */ +export interface CrudResolverStatic { + /** + * Apply decorators to a query handler class. + * + * Called by CrudInitQuery decorator when resolving handler classes. + */ + decorateQueryHandler(handlerClass: Type, queryClass: Type): void; + + /** + * Apply decorators to a command handler class. + * + * Called by CrudInitCommand decorator when resolving handler classes. + */ + decorateCommandHandler(handlerClass: Type, commandClass: Type): void; +} diff --git a/packages/nestjs-crud/src/infrastructure/schemas/crud-create-batch.schema.spec.ts b/packages/nestjs-crud/src/infrastructure/schemas/crud-create-batch.schema.spec.ts new file mode 100644 index 000000000..17a4b84a1 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/schemas/crud-create-batch.schema.spec.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { createBatchSchema } from './crud-create-batch.schema.js'; + +describe(createBatchSchema, () => { + const itemSchema = z.object({ id: z.string(), name: z.string() }); + const schema = createBatchSchema(itemSchema); + + it('accepts a bulk array of the item schema', () => { + const result = schema.parse({ bulk: [{ id: '1', name: 'a' }] }); + + expect(result).toEqual({ bulk: [{ id: '1', name: 'a' }] }); + }); + + it('rejects an empty bulk array (matching legacy @ArrayNotEmpty())', () => { + const result = schema.safeParse({ bulk: [] }); + + expect(result.success).toBe(false); + }); + + it('rejects an item that does not match the item schema', () => { + const result = schema.safeParse({ bulk: [{ id: 1, name: 'a' }] }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/schemas/crud-create-batch.schema.ts b/packages/nestjs-crud/src/infrastructure/schemas/crud-create-batch.schema.ts new file mode 100644 index 000000000..364694e4a --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/schemas/crud-create-batch.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +/** + * Zod counterpart of the now-deleted `CrudCreateBatchDto` class, used by + * every schema-based (migrated) CRUD package. `bulk.min(1)` mirrors the + * legacy `@ArrayNotEmpty()` decorator. + * + * Callers wrap the result with `withOpenApi`, e.g. + * `withOpenApi(createBatchSchema(roleCreateSchema))`. + */ +export function createBatchSchema(itemSchema: T) { + return z.object({ + bulk: z.array(itemSchema).min(1), + }); +} diff --git a/packages/nestjs-crud/src/infrastructure/schemas/crud-response-paginated.schema.spec.ts b/packages/nestjs-crud/src/infrastructure/schemas/crud-response-paginated.schema.spec.ts new file mode 100644 index 000000000..1dcc9d3e8 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/schemas/crud-response-paginated.schema.spec.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +import { paginatedSchema } from './crud-response-paginated.schema.js'; + +describe(paginatedSchema, () => { + const itemSchema = z.object({ id: z.string(), name: z.string() }); + const schema = paginatedSchema(itemSchema); + + it('accepts a paginated shape with an array of the item schema', () => { + const result = schema.parse({ + data: [{ id: '1', name: 'a' }], + limit: 10, + count: 1, + total: 1, + page: 1, + pageCount: 1, + }); + + expect(result).toEqual({ + data: [{ id: '1', name: 'a' }], + limit: 10, + count: 1, + total: 1, + page: 1, + pageCount: 1, + }); + }); + + it('strips unknown keys, including metrics (not part of the schema, matching the legacy DTO)', () => { + const result = schema.parse({ + data: [], + limit: 0, + count: 0, + total: 0, + page: 0, + pageCount: 0, + metrics: { totalFetched: 1, totalValid: 1, fetchCalls: 1, duration: 1 }, + }); + + expect(result).not.toHaveProperty('metrics'); + }); + + it('rejects an item that does not match the item schema', () => { + const result = schema.safeParse({ + data: [{ id: 1, name: 'a' }], + limit: 0, + count: 0, + total: 0, + page: 0, + pageCount: 0, + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/schemas/crud-response-paginated.schema.ts b/packages/nestjs-crud/src/infrastructure/schemas/crud-response-paginated.schema.ts new file mode 100644 index 000000000..69da1ac7d --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/schemas/crud-response-paginated.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +/** + * Schema equivalent of `CrudResponsePaginatedDto` — the Zod counterpart + * used by schema-based (migrated) CRUD packages. `metrics` is intentionally + * omitted, matching the legacy DTO (which never `@Expose()`s it either). + * + * Callers wrap the result with `withNamedComponent` to register it as its + * own OpenAPI component, e.g. + * `withNamedComponent(paginatedSchema(cacheSchema), 'CachePaginated')`. + */ +export function paginatedSchema(itemSchema: T) { + return z.object({ + data: z.array(itemSchema), + limit: z.number(), + count: z.number(), + total: z.number(), + page: z.number(), + pageCount: z.number(), + }); +} diff --git a/packages/nestjs-crud/src/infrastructure/services/__tests__/crud-metadata.service.spec.ts b/packages/nestjs-crud/src/infrastructure/services/__tests__/crud-metadata.service.spec.ts new file mode 100644 index 000000000..19264044c --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/services/__tests__/crud-metadata.service.spec.ts @@ -0,0 +1,755 @@ +import { + CrudMetadata, + CrudMetadataLookupTarget, +} from '../crud-metadata.service.js'; + +// Test decorators for different lookup targets +const TestMethodDecorator = CrudMetadata.createDecorator({ + key: 'test:method', + lookupTarget: CrudMetadataLookupTarget.Method, +}); + +const TestClassDecorator = CrudMetadata.createDecorator({ + key: 'test:class', + lookupTarget: CrudMetadataLookupTarget.Class, +}); + +const TestMethodAndClassDecorator = CrudMetadata.createDecorator({ + key: 'test:method-and-class', + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, +}); + +const TestArrayDecorator = CrudMetadata.createDecorator({ + key: 'test:array', + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, +}); + +const TestArrayWithDedupeDecorator = CrudMetadata.createDecorator< + { name: string; value: number }[] +>({ + key: 'test:array-dedupe', + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + dedupeBy: 'name', +}); + +describe('CrudMetadata', () => { + describe('get() - direct lookup (no hierarchy walking)', () => { + it('should retrieve metadata from exact target', () => { + @TestClassDecorator('class-value') + class TestClass { + @TestMethodDecorator('method-value') + testMethod() {} + } + + const result = CrudMetadata.get(TestClassDecorator, TestClass); + expect(result).toBe('class-value'); + }); + + it('should return undefined when metadata not present', () => { + class TestClass { + testMethod() {} + } + + const result = CrudMetadata.get(TestClassDecorator, TestClass); + expect(result).toBeUndefined(); + }); + + it('should find inherited class metadata (JS prototype chain)', () => { + // NOTE: This is JavaScript's default metadata behavior via Reflect.getMetadata + // Metadata on parent classes IS inherited to child classes through the prototype chain + // This is intentional - `get()` is "direct" in that it doesn't do our custom + // hierarchy walking logic, but JS prototype inheritance still applies + @TestClassDecorator('parent-value') + class ParentClass { + parentMethod() {} + } + + class ChildClass extends ParentClass { + childMethod() {} + } + + // Child inherits parent's metadata through JS prototype chain + const result = CrudMetadata.get(TestClassDecorator, ChildClass); + expect(result).toBe('parent-value'); + }); + + it('should NOT find inherited method metadata', () => { + // Method metadata is NOT inherited - each method is its own target + class ParentClass { + @TestMethodDecorator('parent-method-value') + testMethod() {} + } + + class ChildClass extends ParentClass { + override testMethod() {} // Override without decorator + } + + // Child's method is a different function, no metadata + const handler = ChildClass.prototype.testMethod; + const result = CrudMetadata.get(TestMethodDecorator, handler); + expect(result).toBeUndefined(); + }); + + it('should get metadata from method handler directly', () => { + class TestClass { + @TestMethodDecorator('handler-value') + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.get(TestMethodDecorator, handler); + expect(result).toBe('handler-value'); + }); + }); + + describe('getHierarchy() - Method lookup target', () => { + it('should retrieve from method handler only', () => { + class TestClass { + @TestMethodDecorator('method-value') + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchy( + TestMethodDecorator, + handler, + TestClass, + ); + // Method lookup target only looks at handler, ignores class + expect(result).toBe('method-value'); + }); + + it('should ignore class metadata for method lookup target', () => { + // Create a separate class-level decorator to set class metadata + // TestMethodDecorator can't be applied to class (guard prevents it) + @TestClassDecorator('class-value') + class TestClass { + @TestMethodDecorator('method-value') + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + // Method lookup ignores class decorator entirely + const result = CrudMetadata.getHierarchy( + TestMethodDecorator, + handler, + TestClass, + ); + expect(result).toBe('method-value'); + }); + + it('should NOT inherit from parent method', () => { + class ParentClass { + @TestMethodDecorator('parent-method-value') + testMethod() {} + } + + class ChildClass extends ParentClass { + override testMethod() {} + } + + const handler = ChildClass.prototype.testMethod; + const result = CrudMetadata.getHierarchy( + TestMethodDecorator, + handler, + ChildClass, + ); + // Child overrides method without decorator - no inheritance for method-only + expect(result).toBeUndefined(); + }); + + it('should return undefined if method has no metadata', () => { + class TestClass { + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchy( + TestMethodDecorator, + handler, + TestClass, + ); + expect(result).toBeUndefined(); + }); + }); + + describe('getHierarchy() - Class lookup target', () => { + it('should retrieve from class directly', () => { + @TestClassDecorator('class-value') + class TestClass { + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchy( + TestClassDecorator, + handler, + TestClass, + ); + expect(result).toBe('class-value'); + }); + + it('should walk class hierarchy - child inherits from parent', () => { + @TestClassDecorator('parent-value') + class ParentClass { + parentMethod() {} + } + + class ChildClass extends ParentClass { + childMethod() {} + } + + const handler = ChildClass.prototype.childMethod; + const result = CrudMetadata.getHierarchy( + TestClassDecorator, + handler, + ChildClass, + ); + expect(result).toBe('parent-value'); + }); + + it('should prefer child class value over parent', () => { + @TestClassDecorator('parent-value') + class ParentClass { + parentMethod() {} + } + + @TestClassDecorator('child-value') + class ChildClass extends ParentClass { + childMethod() {} + } + + const handler = ChildClass.prototype.childMethod; + const result = CrudMetadata.getHierarchy( + TestClassDecorator, + handler, + ChildClass, + ); + expect(result).toBe('child-value'); + }); + + it('should walk multiple levels of inheritance', () => { + @TestClassDecorator('grandparent-value') + class GrandparentClass { + method() {} + } + + class ParentClass extends GrandparentClass {} + + class ChildClass extends ParentClass {} + + const handler = ChildClass.prototype.method; + const result = CrudMetadata.getHierarchy( + TestClassDecorator, + handler, + ChildClass, + ); + expect(result).toBe('grandparent-value'); + }); + + it('should use handler as class when cls not provided', () => { + @TestClassDecorator('class-value') + class TestClass { + testMethod() {} + } + + // When cls is not provided, handler is used as the class + const result = CrudMetadata.getHierarchy(TestClassDecorator, TestClass); + expect(result).toBe('class-value'); + }); + }); + + describe('getHierarchy() - MethodAndClass lookup target', () => { + it('should prefer method over class', () => { + @TestMethodAndClassDecorator('class-value') + class TestClass { + @TestMethodAndClassDecorator('method-value') + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchy( + TestMethodAndClassDecorator, + handler, + TestClass, + ); + expect(result).toBe('method-value'); + }); + + it('should fall back to class when method has no metadata', () => { + @TestMethodAndClassDecorator('class-value') + class TestClass { + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchy( + TestMethodAndClassDecorator, + handler, + TestClass, + ); + expect(result).toBe('class-value'); + }); + + it('should walk class hierarchy when method has no metadata', () => { + @TestMethodAndClassDecorator('parent-value') + class ParentClass { + parentMethod() {} + } + + class ChildClass extends ParentClass { + childMethod() {} + } + + const handler = ChildClass.prototype.childMethod; + const result = CrudMetadata.getHierarchy( + TestMethodAndClassDecorator, + handler, + ChildClass, + ); + expect(result).toBe('parent-value'); + }); + + it('should prefer child class over parent when method has no metadata', () => { + @TestMethodAndClassDecorator('parent-value') + class ParentClass { + parentMethod() {} + } + + @TestMethodAndClassDecorator('child-value') + class ChildClass extends ParentClass { + childMethod() {} + } + + const handler = ChildClass.prototype.childMethod; + const result = CrudMetadata.getHierarchy( + TestMethodAndClassDecorator, + handler, + ChildClass, + ); + expect(result).toBe('child-value'); + }); + }); + + describe('getHierarchyArray() - Array merging', () => { + it('should merge arrays from method and class', () => { + @TestArrayDecorator(['class-item']) + class TestClass { + @TestArrayDecorator(['method-item']) + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchyArray( + TestArrayDecorator, + handler, + TestClass, + ); + expect(result).toEqual(['method-item', 'class-item']); + }); + + it('should merge arrays from class hierarchy', () => { + @TestArrayDecorator(['parent-item']) + class ParentClass { + parentMethod() {} + } + + @TestArrayDecorator(['child-item']) + class ChildClass extends ParentClass { + childMethod() {} + } + + const handler = ChildClass.prototype.childMethod; + const result = CrudMetadata.getHierarchyArray( + TestArrayDecorator, + handler, + ChildClass, + ); + expect(result).toEqual(['child-item', 'parent-item']); + }); + + it('should merge arrays from method, child, and parent', () => { + @TestArrayDecorator(['parent-item']) + class ParentClass { + parentMethod() {} + } + + @TestArrayDecorator(['child-item']) + class ChildClass extends ParentClass { + @TestArrayDecorator(['method-item']) + childMethod() {} + } + + const handler = ChildClass.prototype.childMethod; + const result = CrudMetadata.getHierarchyArray( + TestArrayDecorator, + handler, + ChildClass, + ); + expect(result).toEqual(['method-item', 'child-item', 'parent-item']); + }); + + it('should return undefined when no targets have metadata', () => { + class TestClass { + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchyArray( + TestArrayDecorator, + handler, + TestClass, + ); + expect(result).toBeUndefined(); + }); + }); + + describe('getHierarchyArray() - Array deduplication', () => { + it('should deduplicate primitive arrays using Set', () => { + @TestArrayDecorator(['foo', 'bar']) + class ParentClass { + parentMethod() {} + } + + @TestArrayDecorator(['foo', 'baz']) // 'foo' duplicated from parent + class ChildClass extends ParentClass { + childMethod() {} + } + + const handler = ChildClass.prototype.childMethod; + const result = CrudMetadata.getHierarchyArray( + TestArrayDecorator, + handler, + ChildClass, + ); + // Duplicates removed, order preserved (child first) + expect(result).toEqual(['foo', 'baz', 'bar']); + }); + + it('should deduplicate by specified property', () => { + @TestArrayWithDedupeDecorator([ + { name: 'foo', value: 1 }, + { name: 'bar', value: 2 }, + ]) + class ParentClass { + parentMethod() {} + } + + @TestArrayWithDedupeDecorator([ + { name: 'foo', value: 100 }, // Same name, different value + { name: 'baz', value: 3 }, + ]) + class ChildClass extends ParentClass { + childMethod() {} + } + + const handler = ChildClass.prototype.childMethod; + const result = CrudMetadata.getHierarchyArray( + TestArrayWithDedupeDecorator, + handler, + ChildClass, + ); + // Child's 'foo' should win over parent's 'foo' (first occurrence kept) + expect(result).toEqual([ + { name: 'foo', value: 100 }, + { name: 'baz', value: 3 }, + { name: 'bar', value: 2 }, + ]); + }); + }); + + describe('getAll() - collect all values', () => { + it('should return all values from hierarchy as array', () => { + @TestMethodAndClassDecorator('parent-value') + class ParentClass { + parentMethod() {} + } + + @TestMethodAndClassDecorator('child-value') + class ChildClass extends ParentClass { + @TestMethodAndClassDecorator('method-value') + childMethod() {} + } + + const handler = ChildClass.prototype.childMethod; + const result = CrudMetadata.getAll( + TestMethodAndClassDecorator, + handler, + ChildClass, + ); + expect(result).toEqual(['method-value', 'child-value', 'parent-value']); + }); + + it('should filter out undefined values', () => { + @TestMethodAndClassDecorator('class-value') + class TestClass { + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getAll( + TestMethodAndClassDecorator, + handler, + TestClass, + ); + // Method has no decorator, so only class value + expect(result).toEqual(['class-value']); + }); + }); + + describe('accumulator pattern (get vs getHierarchy)', () => { + // This tests the critical distinction between get() and getHierarchy() + // for decorators that accumulate values (like CrudApiParam, CrudBody) + + const AccumulatorDecorator = CrudMetadata.createWrappedDecorator< + string[], + (value: string) => MethodDecorator + >( + { + key: 'test:accumulator', + lookupTarget: CrudMetadataLookupTarget.Method, + }, + (decorator) => + (value: string): MethodDecorator => + (target, propertyKey, descriptor) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const handler = descriptor.value as Function; + // Use get() for direct lookup - don't want hierarchy walking + const existing = CrudMetadata.get( + AccumulatorDecorator, + handler, + ); + decorator([...(existing ?? []), value])( + target, + propertyKey, + descriptor, + ); + }, + ); + + it('should accumulate multiple decorator applications', () => { + class TestClass { + @AccumulatorDecorator('first') + @AccumulatorDecorator('second') + @AccumulatorDecorator('third') + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.get(AccumulatorDecorator, handler); + // Decorators apply bottom-up, so third -> second -> first + expect(result).toEqual(['third', 'second', 'first']); + }); + + it('should NOT inherit accumulated values from parent method', () => { + class ParentClass { + @AccumulatorDecorator('parent-value') + testMethod() {} + } + + class ChildClass extends ParentClass { + @AccumulatorDecorator('child-value') + override testMethod() {} + } + + const parentHandler = ParentClass.prototype.testMethod; + const childHandler = ChildClass.prototype.testMethod; + + // Parent has only parent's value + const parentResult = CrudMetadata.get( + AccumulatorDecorator, + parentHandler, + ); + expect(parentResult).toEqual(['parent-value']); + + // Child has only child's value - no inheritance + const childResult = CrudMetadata.get( + AccumulatorDecorator, + childHandler, + ); + expect(childResult).toEqual(['child-value']); + }); + + it('should keep method accumulators separate from class', () => { + class TestClass { + @AccumulatorDecorator('method-a') + @AccumulatorDecorator('method-b') + methodA() {} + + @AccumulatorDecorator('method-c') + methodB() {} + } + + const handlerA = TestClass.prototype.methodA; + const handlerB = TestClass.prototype.methodB; + + const resultA = CrudMetadata.get( + AccumulatorDecorator, + handlerA, + ); + const resultB = CrudMetadata.get( + AccumulatorDecorator, + handlerB, + ); + + // Decorators apply bottom-up: method-b first, then method-a + expect(resultA).toEqual(['method-b', 'method-a']); + expect(resultB).toEqual(['method-c']); + }); + }); + + describe('getHierarchyArray() - single source', () => { + it('should return array from single target without duplication', () => { + @TestArrayDecorator(['only-item']) + class TestClass { + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchyArray( + TestArrayDecorator, + handler, + TestClass, + ); + expect(result).toEqual(['only-item']); + }); + + it('should deduplicate by property with single target', () => { + @TestArrayWithDedupeDecorator([ + { name: 'foo', value: 1 }, + { name: 'foo', value: 2 }, + { name: 'bar', value: 3 }, + ]) + class TestClass { + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchyArray( + TestArrayWithDedupeDecorator, + handler, + TestClass, + ); + expect(result).toEqual([ + { name: 'foo', value: 1 }, + { name: 'bar', value: 3 }, + ]); + }); + }); + + describe('getHierarchy() - Parameter lookup target', () => { + it('should look up from handler only (same as method)', () => { + // Parameter metadata is stored on the handler by NestJS internals. + // We simulate this by using a method decorator with Parameter lookup + // to verify buildTargets routes correctly. + const TestParamLookup = { + KEY: 'test:param-lookup', + LOOKUP_TARGET: CrudMetadataLookupTarget.Parameter, + } as const; + + class TestClass { + @TestMethodDecorator('method-value') + testMethod() {} + } + + // Use a method-decorated handler but query with Parameter lookup metadata + // to verify Parameter routes to handler (not class) + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchy( + TestParamLookup, + handler, + TestClass, + ); + // Different key, so no metadata found — but the path is exercised + expect(result).toBeUndefined(); + }); + }); + + describe('deduplicateByProperty - error handling', () => { + it('should throw TypeError when array contains non-object items', () => { + // Create a decorator with dedupeBy that stores primitive values + // We force this by manually constructing metadata with DEDUPE_BY + const PrimitiveWithDedupeDecorator = CrudMetadata.createDecorator< + string[] + >({ + key: 'test:primitive-dedupe', + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + dedupeBy: 'field', + }); + + @PrimitiveWithDedupeDecorator(['a', 'b']) + class TestClass { + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + expect(() => + CrudMetadata.getHierarchyArray( + PrimitiveWithDedupeDecorator, + handler, + TestClass, + ), + ).toThrow(TypeError); + }); + }); + + describe('edge cases', () => { + it('should handle empty targets gracefully', () => { + class TestClass { + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchy( + TestClassDecorator, + handler, + TestClass, + ); + expect(result).toBeUndefined(); + }); + + it('should handle decorators with no options', () => { + const NoValueDecorator = CrudMetadata.createDecorator({ + key: 'test:no-value', + lookupTarget: CrudMetadataLookupTarget.Method, + }); + + class TestClass { + @NoValueDecorator() + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + // Decorator was applied but with undefined value + const result = CrudMetadata.getHierarchy(NoValueDecorator, handler); + expect(result).toBeUndefined(); + }); + + it('should work with wrapped decorators', () => { + const WrappedDecorator = CrudMetadata.createWrappedDecorator< + number, + (value: number) => ClassDecorator & MethodDecorator + >( + { + key: 'test:wrapped', + lookupTarget: CrudMetadataLookupTarget.MethodAndClass, + }, + (decorator) => (value: number) => decorator(value), + ); + + @WrappedDecorator(10) + class TestClass { + @WrappedDecorator(20) + testMethod() {} + } + + const handler = TestClass.prototype.testMethod; + const result = CrudMetadata.getHierarchy( + WrappedDecorator, + handler, + TestClass, + ); + expect(result).toBe(20); // Method value takes precedence + }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/services/__tests__/crud-metaview.service.spec.ts b/packages/nestjs-crud/src/infrastructure/services/__tests__/crud-metaview.service.spec.ts new file mode 100644 index 000000000..9e219dde0 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/services/__tests__/crud-metaview.service.spec.ts @@ -0,0 +1,210 @@ +import { z } from 'zod'; + +import { CrudJoin } from '../../decorators/routes/crud-join.decorator.js'; +import { CrudRequestBodyBatch } from '../../decorators/routes/crud-request-body-batch.decorator.js'; +import { CrudRequestBody } from '../../decorators/routes/crud-request-body.decorator.js'; +import { CrudResponseResource } from '../../decorators/routes/crud-response-resource.decorator.js'; +import { CrudReturnRestored } from '../../decorators/routes/crud-return-restored.decorator.js'; +import { CrudValidate } from '../../decorators/routes/crud-validate.decorator.js'; +import { CrudMetaview } from '../crud-metaview.service.js'; + +describe('CrudMetaview', () => { + const metaview = new CrudMetaview(); + + describe('getValidationOptions', () => { + it('should resolve from handler then class when handler is provided', () => { + @CrudValidate({ transform: true }) + class TestController { + @CrudValidate({ validateCustomDecorators: true }) + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getValidationOptions(TestController, handler); + expect(result).toEqual({ validateCustomDecorators: true }); + }); + + it('should fall back to class when handler has no metadata', () => { + @CrudValidate({ transform: true }) + class TestController { + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getValidationOptions(TestController, handler); + expect(result).toEqual({ transform: true }); + }); + + it('should resolve from target when handler is omitted', () => { + @CrudValidate({ transform: true }) + class TestController { + testMethod() {} + } + + const result = metaview.getValidationOptions(TestController); + expect(result).toEqual({ transform: true }); + }); + }); + + describe('getRequestBodyBatch', () => { + it('should resolve batch body type from method', () => { + const batchSchema = z.object({}); + + class TestController { + @CrudRequestBodyBatch(batchSchema) + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getRequestBodyBatch(TestController, handler); + expect(result).toBe(batchSchema); + }); + + it('should resolve batch body type from class', () => { + const batchSchema = z.object({}); + + @CrudRequestBodyBatch(batchSchema) + class TestController { + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getRequestBodyBatch(TestController, handler); + expect(result).toBe(batchSchema); + }); + + it('should return undefined when not decorated', () => { + class TestController { + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getRequestBodyBatch(TestController, handler); + expect(result).toBeUndefined(); + }); + }); + + describe('getReturnRestored', () => { + it('should return true when decorated', () => { + @CrudReturnRestored(true) + class TestController { + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getReturnRestored(TestController, handler); + expect(result).toBe(true); + }); + + it('should default to false when not decorated', () => { + class TestController { + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getReturnRestored(TestController, handler); + expect(result).toBe(false); + }); + }); + + describe('getRequestBody', () => { + it('should resolve body type from method', () => { + const bodySchema = z.object({}); + + class TestController { + @CrudRequestBody(bodySchema) + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getRequestBody(TestController, handler); + expect(result).toBe(bodySchema); + }); + + it('should return undefined when not decorated', () => { + class TestController { + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getRequestBody(TestController, handler); + expect(result).toBeUndefined(); + }); + }); + + describe('getResponseResource', () => { + it('should resolve response type from method', () => { + const resourceSchema = z.object({}); + + class TestController { + @CrudResponseResource(resourceSchema) + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getResponseResource(TestController, handler); + expect(result).toBe(resourceSchema); + }); + + it('should return undefined when not decorated', () => { + class TestController { + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getResponseResource(TestController, handler); + expect(result).toBeUndefined(); + }); + }); + + describe('getContextOptions (join)', () => { + it('should resolve join from class decorator', () => { + @CrudJoin([{ relation: 'posts' }]) + class TestController { + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getContextOptions(TestController, handler); + expect(result.query?.join).toEqual([{ relation: 'posts' }]); + }); + + it('should resolve join from method decorator', () => { + class TestController { + @CrudJoin([{ relation: 'profile', joinType: 'INNER' }]) + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getContextOptions(TestController, handler); + expect(result.query?.join).toEqual([ + { relation: 'profile', joinType: 'INNER' }, + ]); + }); + + it('should merge class and method join with deduplication by relation', () => { + @CrudJoin([{ relation: 'posts', joinType: 'LEFT' }, { relation: 'tags' }]) + class TestController { + @CrudJoin([{ relation: 'posts', joinType: 'INNER' }]) + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getContextOptions(TestController, handler); + expect(result.query?.join).toEqual([ + { relation: 'posts', joinType: 'INNER' }, + { relation: 'tags' }, + ]); + }); + + it('should return undefined when not decorated', () => { + class TestController { + testMethod() {} + } + + const handler = TestController.prototype.testMethod; + const result = metaview.getContextOptions(TestController, handler); + expect(result.query?.join).toBeUndefined(); + }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/services/crud-metadata.service.ts b/packages/nestjs-crud/src/infrastructure/services/crud-metadata.service.ts new file mode 100644 index 000000000..988e99ba3 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/services/crud-metadata.service.ts @@ -0,0 +1,282 @@ +import { + applyDecorators, + type CustomDecorator, + type Type, +} from '@nestjs/common'; +import { + type CreateDecoratorOptions, + type ReflectableDecorator, + Reflector, +} from '@nestjs/core'; + +import { applyAssertTarget } from '../decorators/util/apply-assert-target.decorator.js'; + +/** + * Metadata target type for reflection operations. + * Accepts both Type (class constructor) and Function (method handler). + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +type CrudMetadataTarget = Type | Function; + +/** + * Lookup target for metadata retrieval. + */ +export const CrudMetadataLookupTarget = { + Method: 'method', + Class: 'class', + Parameter: 'parameter', + MethodAndClass: 'method-and-class', +} as const; + +export type CrudMetadataLookupTarget = + (typeof CrudMetadataLookupTarget)[keyof typeof CrudMetadataLookupTarget]; + +/** + * Options for creating a simple CRUD decorator. + */ +export interface CrudDecoratorOptions< + TConstraint, +> extends CreateDecoratorOptions { + lookupTarget: CrudMetadataLookupTarget; + dedupeBy?: string; +} + +/** + * Metadata properties attached to CRUD decorators. + */ +export interface CrudDecoratorMetadata<_TTransformed = unknown> { + KEY: string; + LOOKUP_TARGET: CrudMetadataLookupTarget; + DEDUPE_BY?: string; +} + +/** + * A reflectable CRUD decorator with lookup configuration. + * Includes a generic call signature to allow subtypes of TConstraint. + */ +export interface CrudReflectableDecorator< + TConstraint, + TTransformed = TConstraint, +> + extends + ReflectableDecorator, + CrudDecoratorMetadata { + (opts?: T): CustomDecorator; +} + +/** + * Static utility class for CRUD metadata operations. + * + * Provides decorator creation and hierarchy-aware metadata retrieval. + * Uses composition to wrap NestJS Reflector internally. + */ +export class CrudMetadata { + private static reflector = new Reflector(); + + // Prevent instantiation + private constructor() {} + + /** + * Build decorator internals shared by createDecorator and createWrappedDecorator. + */ + private static buildDecorator( + options: CrudDecoratorOptions, + ): { + decoratorFn: (opts?: TConstraint) => CustomDecorator; + metadata: CrudDecoratorMetadata; + } { + const { key, lookupTarget, dedupeBy } = options; + const baseDecoratorFn = Reflector.createDecorator({ key }); + + const decoratorFn = (opts?: TConstraint): CustomDecorator => { + // Only apply base decorator if value is provided (not undefined) + // This prevents Reflector.createDecorator from converting undefined to {} + const decorators = + opts === undefined + ? [applyAssertTarget(lookupTarget)] + : [applyAssertTarget(lookupTarget), baseDecoratorFn(opts)]; + + return Object.assign(applyDecorators(...decorators), { + KEY: baseDecoratorFn.KEY, + }); + }; + + return { + decoratorFn, + metadata: { + KEY: baseDecoratorFn.KEY, + LOOKUP_TARGET: lookupTarget, + DEDUPE_BY: dedupeBy, + }, + }; + } + + /** + * Create a simple CRUD decorator. + */ + static createDecorator( + options: CrudDecoratorOptions, + ): CrudReflectableDecorator { + const { decoratorFn, metadata } = this.buildDecorator(options); + return Object.assign(decoratorFn, metadata); + } + + /** + * Create a CRUD decorator with a custom generic signature. + * + * @param options - The decorator options + * @param wrapper - Function that wraps the decorator + */ + static createWrappedDecorator< + TConstraint, + TWrapper extends (...args: never[]) => unknown, + >( + options: CrudDecoratorOptions, + wrapper: (decorator: (opts?: TConstraint) => CustomDecorator) => TWrapper, + ): TWrapper & CrudDecoratorMetadata { + const { decoratorFn, metadata } = this.buildDecorator(options); + return Object.assign(wrapper(decoratorFn), metadata); + } + + /** + * Get metadata value from a single target (no hierarchy walking). + */ + static get( + decorator: CrudDecoratorMetadata, + target: CrudMetadataTarget, + ): TTransformed | undefined { + return this.reflector.get(decorator.KEY, target); + } + + /** + * Get scalar metadata value with hierarchy-aware lookup. + * + * Uses getAllAndOverride semantics (first defined value wins). + * + * Retrieves metadata based on the decorator's lookup target: + * - Method: looks up on handler only + * - Class: walks class hierarchy + * - Parameter: looks up on handler (where param metadata is stored) + * - MethodAndClass: checks handler first, then walks class hierarchy + */ + static getHierarchy( + decorator: CrudDecoratorMetadata, + handler: CrudMetadataTarget, + cls?: CrudMetadataTarget, + ): TTransformed | undefined { + const targets = this.buildTargets(decorator.LOOKUP_TARGET, handler, cls); + + if (targets.length === 0) return undefined; + + return this.reflector.getAllAndOverride( + decorator.KEY, + targets, + ); + } + + /** + * Get array metadata values with hierarchy-aware merge. + * + * Uses getAllAndMerge semantics (concatenate arrays, deduplicate). + * When the decorator has DEDUPE_BY, deduplicates by that property. + * Otherwise deduplicates by reference equality (Set). + */ + static getHierarchyArray( + decorator: CrudDecoratorMetadata, + handler: CrudMetadataTarget, + cls?: CrudMetadataTarget, + ): TElement[] | undefined { + const targets = this.buildTargets(decorator.LOOKUP_TARGET, handler, cls); + + if (targets.length === 0) return undefined; + + const values = this.reflector.getAll( + decorator.KEY, + targets, + ); + if (!values.some((v) => v !== undefined)) return undefined; + + const combined = this.reflector.getAllAndMerge( + decorator.KEY, + targets, + ); + + if (decorator.DEDUPE_BY) { + return this.deduplicateByProperty(combined, decorator.DEDUPE_BY); + } + + return [...new Set(combined)]; + } + + /** + * Get all metadata values from the hierarchy as an array. + */ + static getAll( + decorator: CrudDecoratorMetadata, + handler: CrudMetadataTarget, + cls?: CrudMetadataTarget, + ): TTransformed[] { + const targets = this.buildTargets(decorator.LOOKUP_TARGET, handler, cls); + return this.reflector + .getAll(decorator.KEY, targets) + .filter((v) => v !== undefined); + } + + private static buildTargets( + lookupTarget: CrudMetadataLookupTarget, + handler: CrudMetadataTarget, + cls?: CrudMetadataTarget, + ): CrudMetadataTarget[] { + const targets: CrudMetadataTarget[] = []; + + switch (lookupTarget) { + case CrudMetadataLookupTarget.Method: + case CrudMetadataLookupTarget.Parameter: + // Parameter metadata is stored on the method handler + targets.push(handler); + break; + case CrudMetadataLookupTarget.Class: + this.walkClassHierarchy(cls ?? handler, targets); + break; + case CrudMetadataLookupTarget.MethodAndClass: + targets.push(handler); + if (cls) this.walkClassHierarchy(cls, targets); + break; + } + + return targets; + } + + private static walkClassHierarchy( + cls: CrudMetadataTarget, + targets: CrudMetadataTarget[], + ): void { + let current: CrudMetadataTarget | null = cls; + while (current && current !== Function.prototype && current.name) { + targets.push(current); + current = Object.getPrototypeOf(current); + } + } + + private static isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object'; + } + + private static deduplicateByProperty(array: T[], property: string): T[] { + const seen = new Set(); + const result: T[] = []; + for (const item of array) { + if (!this.isRecord(item)) { + throw new TypeError( + `deduplicateByProperty expected object, got ${typeof item}`, + ); + } + const key = item[property]; + if (!seen.has(key)) { + seen.add(key); + result.push(item); + } + } + return result; + } +} diff --git a/packages/nestjs-crud/src/infrastructure/services/crud-metaview.service.ts b/packages/nestjs-crud/src/infrastructure/services/crud-metaview.service.ts new file mode 100644 index 000000000..8ba8d44af --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/services/crud-metaview.service.ts @@ -0,0 +1,252 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { + ApiBodyOptions, + ApiParamOptions, + ApiQueryOptions, + ApiResponseOptions, +} from '@nestjs/swagger'; + +import { Operation } from '@concepta/nestjs-core'; + +import { + ControllerTarget, + CrudSchema, + CrudValidationOptions, + MethodHandler, +} from '../../crud.types.js'; +import { CrudApiBody } from '../decorators/openapi/crud-api-body.decorator.js'; +import { CrudApiParam } from '../decorators/openapi/crud-api-param.decorator.js'; +import { CrudApiQuery } from '../decorators/openapi/crud-api-query.decorator.js'; +import { CrudApiResponse } from '../decorators/openapi/crud-api-response.decorator.js'; +import { CrudBody } from '../decorators/params/crud-body.decorator.js'; +import { CrudBodyMetadataInterface } from '../decorators/params/interfaces/crud-body-metadata.interface.js'; +import { CrudAdapter } from '../decorators/routes/crud-adapter.decorator.js'; +import { CrudAllow } from '../decorators/routes/crud-allow.decorator.js'; +import { CrudCache } from '../decorators/routes/crud-cache.decorator.js'; +import { + CrudCommandHandler, + CrudCommandHandlerOptionsInterface, +} from '../decorators/routes/crud-command-handler.decorator.js'; +import { + CrudCommand, + CrudCommandOptionsInterface, +} from '../decorators/routes/crud-command.decorator.js'; +import { CrudEntity } from '../decorators/routes/crud-entity.decorator.js'; +import { CrudExclude } from '../decorators/routes/crud-exclude.decorator.js'; +import { CrudFilter } from '../decorators/routes/crud-filter.decorator.js'; +import { CrudJoin } from '../decorators/routes/crud-join.decorator.js'; +import { CrudLimit } from '../decorators/routes/crud-limit.decorator.js'; +import { CrudMaxLimit } from '../decorators/routes/crud-max-limit.decorator.js'; +import { CrudName } from '../decorators/routes/crud-name.decorator.js'; +import { CrudOperation } from '../decorators/routes/crud-operation.decorator.js'; +import { CrudParams } from '../decorators/routes/crud-params.decorator.js'; +import { CrudPersist } from '../decorators/routes/crud-persist.decorator.js'; +import { + CrudQueryHandler, + CrudQueryHandlerOptionsInterface, +} from '../decorators/routes/crud-query-handler.decorator.js'; +import { + CrudQuery, + CrudQueryDecoratorOptionsInterface, +} from '../decorators/routes/crud-query.decorator.js'; +import { CrudRequestBodyBatch } from '../decorators/routes/crud-request-body-batch.decorator.js'; +import { CrudRequestBody } from '../decorators/routes/crud-request-body.decorator.js'; +import { CrudResolver } from '../decorators/routes/crud-resolver.decorator.js'; +import { CrudResponsePaginated } from '../decorators/routes/crud-response-paginated.decorator.js'; +import { CrudResponseResource } from '../decorators/routes/crud-response-resource.decorator.js'; +import { CrudReturnDeleted } from '../decorators/routes/crud-return-deleted.decorator.js'; +import { CrudReturnRestored } from '../decorators/routes/crud-return-restored.decorator.js'; +import { CrudSerialize } from '../decorators/routes/crud-serialize.decorator.js'; +import { CrudSort } from '../decorators/routes/crud-sort.decorator.js'; +import { CrudValidate } from '../decorators/routes/crud-validate.decorator.js'; +import { CrudParamsOptionsInterface } from '../interfaces/crud-params-options.interface.js'; +import { CrudSerializationOptionsInterface } from '../interfaces/crud-serialization-options.interface.js'; +import { CrudOptionsInterface } from '../request/interfaces/crud-options.interface.js'; +import { + CrudResolverInterface, + CrudResolverStatic, +} from '../resolvers/interfaces/crud-resolver.interface.js'; + +import { CrudMetadata } from './crud-metadata.service.js'; + +@Injectable() +export class CrudMetaview< + Entity extends PlainLiteralObject = PlainLiteralObject, +> { + public getContextOptions( + target: ControllerTarget, + handler: MethodHandler, + ): CrudOptionsInterface { + return { + params: this.getAllParamOptions(target, handler) ?? { + id: { + field: 'id', + type: 'number', + primary: true, + }, + }, + + query: { + allow: CrudMetadata.getHierarchyArray(CrudAllow, handler, target), + exclude: CrudMetadata.getHierarchyArray(CrudExclude, handler, target), + persist: CrudMetadata.getHierarchyArray(CrudPersist, handler, target), + filter: CrudMetadata.getHierarchy(CrudFilter, handler, target) ?? {}, + sort: CrudMetadata.getHierarchyArray(CrudSort, handler, target), + limit: CrudMetadata.getHierarchy(CrudLimit, handler, target), + maxLimit: CrudMetadata.getHierarchy(CrudMaxLimit, handler, target), + cache: CrudMetadata.getHierarchy(CrudCache, handler, target), + join: CrudMetadata.getHierarchyArray(CrudJoin, handler, target), + }, + }; + } + + public getOperation(handler: MethodHandler): Operation | undefined { + return CrudMetadata.getHierarchy(CrudOperation, handler); + } + + public getEntity(target: ControllerTarget): string | undefined { + return CrudMetadata.getHierarchy(CrudEntity, target); + } + + public getName(target: ControllerTarget): string | undefined { + return CrudMetadata.getHierarchy(CrudName, target); + } + + public getAdapter(target: ControllerTarget): Type | undefined { + return CrudMetadata.getHierarchy(CrudAdapter, target); + } + + public getAllParamOptions( + target: ControllerTarget, + handler: MethodHandler, + ): CrudParamsOptionsInterface | undefined { + return CrudMetadata.getHierarchy(CrudParams, handler, target); + } + + public getValidationOptions( + target: ControllerTarget, + handler?: MethodHandler, + ): CrudValidationOptions | undefined { + if (handler) { + return CrudMetadata.getHierarchy(CrudValidate, handler, target); + } + return CrudMetadata.getHierarchy(CrudValidate, target); + } + + public getBodyParamOptions( + handler: MethodHandler, + ): CrudBodyMetadataInterface[] | undefined { + return CrudMetadata.getHierarchy(CrudBody, handler); + } + + public getAllSerializationOptions( + target: ControllerTarget, + handler: MethodHandler, + ): CrudSerializationOptionsInterface | undefined { + return CrudMetadata.getHierarchy(CrudSerialize, handler, target); + } + + public getApiQueryOptions( + handler: MethodHandler, + ): ApiQueryOptions[][] | undefined { + return CrudMetadata.getHierarchy(CrudApiQuery, handler); + } + + public getApiParamsOptions( + handler: MethodHandler, + ): ApiParamOptions[] | undefined { + return CrudMetadata.getHierarchy(CrudApiParam, handler); + } + + public getApiResponseOptions( + handler: MethodHandler, + ): ApiResponseOptions[] | undefined { + return CrudMetadata.getHierarchy(CrudApiResponse, handler); + } + + public getApiBodyOptions(handler: MethodHandler): ApiBodyOptions | undefined { + return CrudMetadata.getHierarchy(CrudApiBody, handler); + } + + public getQuery( + handler: MethodHandler, + ): CrudQueryDecoratorOptionsInterface | undefined { + return CrudMetadata.getHierarchy(CrudQuery, handler); + } + + public getCommand( + handler: MethodHandler, + ): CrudCommandOptionsInterface | undefined { + return CrudMetadata.getHierarchy(CrudCommand, handler); + } + + public getQueryHandler( + handler: MethodHandler, + ): CrudQueryHandlerOptionsInterface | undefined { + return CrudMetadata.getHierarchy(CrudQueryHandler, handler); + } + + public getCommandHandler( + handler: MethodHandler, + ): CrudCommandHandlerOptionsInterface | undefined { + return CrudMetadata.getHierarchy(CrudCommandHandler, handler); + } + + public getReturnDeleted( + target: ControllerTarget, + handler: MethodHandler, + ): boolean { + return ( + CrudMetadata.getHierarchy(CrudReturnDeleted, handler, target) ?? false + ); + } + + public getReturnRestored( + target: ControllerTarget, + handler: MethodHandler, + ): boolean { + return ( + CrudMetadata.getHierarchy(CrudReturnRestored, handler, target) ?? false + ); + } + + public getRequestBody( + target: ControllerTarget, + handler: MethodHandler, + ): CrudSchema | undefined { + return CrudMetadata.getHierarchy(CrudRequestBody, handler, target); + } + + public getRequestBodyBatch( + target: ControllerTarget, + handler: MethodHandler, + ): CrudSchema | undefined { + return CrudMetadata.getHierarchy(CrudRequestBodyBatch, handler, target); + } + + public getResponseResource( + target: ControllerTarget, + handler: MethodHandler, + ): CrudSchema | undefined { + return CrudMetadata.getHierarchy(CrudResponseResource, handler, target); + } + + public getResponsePaginated( + target: ControllerTarget, + handler: MethodHandler, + ): CrudSchema | undefined { + return CrudMetadata.getHierarchy(CrudResponsePaginated, handler, target); + } + + /** + * Get the resolver class for a route. + * + * Resolution order: `method > controller > undefined` (caller uses module default) + */ + public getResolver( + target: ControllerTarget, + handler: MethodHandler, + ): (Type & CrudResolverStatic) | undefined { + return CrudMetadata.getHierarchy(CrudResolver, handler, target); + } +} diff --git a/packages/nestjs-crud/src/infrastructure/specifications/action.specification.ts b/packages/nestjs-crud/src/infrastructure/specifications/action.specification.ts new file mode 100644 index 000000000..cefa7ba8f --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/specifications/action.specification.ts @@ -0,0 +1,34 @@ +import { type ActionEnum, CompositeSpecification } from '@concepta/nestjs-core'; + +import { type CrudSpecContextInterface } from './interfaces/crud-spec-context.interface.js'; + +/** + * Specification that matches specific actions. + * + * Actions are high-level categories (CREATE, READ, UPDATE, DELETE) + * that group related CRUD operations. + * + * @example + * ```typescript + * // Match single action + * CrudSpec.action(ActionEnum.CREATE) + * + * // Match multiple actions + * CrudSpec.action(ActionEnum.UPDATE, ActionEnum.DELETE) + * + * // Using shortcuts + * CrudSpec.isCreate() + * CrudSpec.isRead() + * CrudSpec.isUpdate() + * CrudSpec.isDelete() + * ``` + */ +export class ActionSpecification extends CompositeSpecification { + constructor(private readonly actions: ActionEnum[]) { + super(); + } + + isSatisfiedBy(context: CrudSpecContextInterface): boolean { + return this.actions.includes(context.action); + } +} diff --git a/packages/nestjs-crud/src/infrastructure/specifications/crud-spec.factory.ts b/packages/nestjs-crud/src/infrastructure/specifications/crud-spec.factory.ts new file mode 100644 index 000000000..12a7cad07 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/specifications/crud-spec.factory.ts @@ -0,0 +1,110 @@ +import { + ActionEnum, + MutateOperations, + type Operation, + ReadOperations, + type SpecificationInterface, + WriteOperations, + Spec, +} from '@concepta/nestjs-core'; + +import { ActionSpecification } from './action.specification.js'; +import { type CrudSpecContextInterface } from './interfaces/crud-spec-context.interface.js'; +import { OperationSpecification } from './operation.specification.js'; + +/** + * Factory for creating CRUD-specific specifications. + * + * Extends the base Spec factory with CRUD operation and action matchers. + * + * @example + * ```typescript + * // Base specifications (from Spec) + * CrudSpec.always() + * CrudSpec.never() + * + * // CRUD-specific specifications + * CrudSpec.isCreate() + * CrudSpec.operation(Operation.Create) + * + * // Composed specifications + * CrudSpec.and(CrudSpec.isCreate(), CrudSpec.isQuery()) + * ``` + */ +export const CrudSpec = { + // Inherit base specifications + ...Spec, + + /** + * Match specific CRUD operations. + * + * @param operations - One or more operations to match + */ + operation: ( + ...operations: Operation[] + ): SpecificationInterface => + new OperationSpecification(operations), + + /** + * Match specific actions. + * + * @param actions - One or more actions to match + */ + action: ( + ...actions: ActionEnum[] + ): SpecificationInterface => + new ActionSpecification(actions), + + // ═══════════════════════════════════════════════════════════════════ + // Action Shortcuts + // ═══════════════════════════════════════════════════════════════════ + + /** + * Match CREATE action. + */ + isCreate: (): SpecificationInterface => + new ActionSpecification([ActionEnum.CREATE]), + + /** + * Match READ action. + */ + isRead: (): SpecificationInterface => + new ActionSpecification([ActionEnum.READ]), + + /** + * Match UPDATE action. + */ + isUpdate: (): SpecificationInterface => + new ActionSpecification([ActionEnum.UPDATE]), + + /** + * Match DELETE action. + */ + isDelete: (): SpecificationInterface => + new ActionSpecification([ActionEnum.DELETE]), + + // ═══════════════════════════════════════════════════════════════════ + // Operation Group Shortcuts + // ═══════════════════════════════════════════════════════════════════ + + /** + * Match query operations (List, Read). + * These operations read data without modification. + */ + isQuery: (): SpecificationInterface => + new OperationSpecification([...ReadOperations]), + + /** + * Match write operations (Create, CreateBatch, Update, Replace). + * These operations modify data but don't delete. + */ + isWrite: (): SpecificationInterface => + new OperationSpecification([...WriteOperations]), + + /** + * Match all mutation operations (write + delete + restore). + * Any operation that changes state. + */ + isMutation: (): SpecificationInterface => + new OperationSpecification([...MutateOperations]), +}; diff --git a/packages/nestjs-crud/src/infrastructure/specifications/interfaces/crud-spec-context.interface.ts b/packages/nestjs-crud/src/infrastructure/specifications/interfaces/crud-spec-context.interface.ts new file mode 100644 index 000000000..b6d8a1666 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/specifications/interfaces/crud-spec-context.interface.ts @@ -0,0 +1,13 @@ +import { type ActionEnum, type Operation } from '@concepta/nestjs-core'; + +/** + * Minimal context interface for domain specifications. + * + * Domain specifications only need action and operation to evaluate + * business rules. The full CrudContextInterface (infrastructure) + * extends this interface, so it satisfies the specification contract. + */ +export interface CrudSpecContextInterface { + operation: Operation; + action: ActionEnum; +} diff --git a/packages/nestjs-crud/src/infrastructure/specifications/operation.specification.ts b/packages/nestjs-crud/src/infrastructure/specifications/operation.specification.ts new file mode 100644 index 000000000..db2e69a0a --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/specifications/operation.specification.ts @@ -0,0 +1,29 @@ +import { type Operation, CompositeSpecification } from '@concepta/nestjs-core'; + +import { type CrudSpecContextInterface } from './interfaces/crud-spec-context.interface.js'; + +/** + * Specification that matches specific CRUD operations. + * + * @example + * ```typescript + * // Match single operation + * CrudSpec.operation(Operation.Create) + * + * // Match multiple operations + * CrudSpec.operation(Operation.Create, Operation.Update) + * + * // Using shortcut + * CrudSpec.isQuery() // List, Read + * CrudSpec.isWrite() // Create, CreateBatch, Update, Replace + * ``` + */ +export class OperationSpecification extends CompositeSpecification { + constructor(private readonly operations: Operation[]) { + super(); + } + + isSatisfiedBy(context: CrudSpecContextInterface): boolean { + return this.operations.includes(context.operation); + } +} diff --git a/packages/nestjs-crud/src/infrastructure/utils/__tests__/configurable-crud.builder.spec.ts b/packages/nestjs-crud/src/infrastructure/utils/__tests__/configurable-crud.builder.spec.ts new file mode 100644 index 000000000..3fc2c60ed --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/__tests__/configurable-crud.builder.spec.ts @@ -0,0 +1,660 @@ +import { Controller, Inject, PlainLiteralObject } from '@nestjs/common'; + +import { Ctx, Operation } from '@concepta/nestjs-core'; + +import { ConfigurableCrudOptionsTransformer } from '../../../crud.types.js'; +import { CrudAdapter } from '../../adapters/crud.adapter.js'; +import { CrudController } from '../../decorators/controller/crud-controller.decorator.js'; +import { CrudCreate } from '../../decorators/operations/crud-create.decorator.js'; +import { CrudList } from '../../decorators/operations/crud-list.decorator.js'; +import { CrudRead } from '../../decorators/operations/crud-read.decorator.js'; +import { CrudBody } from '../../decorators/params/crud-body.decorator.js'; +import { CrudEntity } from '../../decorators/routes/crud-entity.decorator.js'; +import { CrudDecoratorException } from '../../exceptions/crud-decorator.exception.js'; +import { CrudCtx } from '../../interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../../interceptors/interfaces/crud-context.interface.js'; +import { CrudAdapterResolver } from '../../resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../../resolvers/interfaces/crud-resolver.interface.js'; +import { ConfigurableCrudBuilder } from '../configurable-crud.builder.js'; + +interface TestEntity { + id: string; + name: string; +} + +describe('ConfigurableCrudBuilder', () => { + describe('build() - Path 1: Pre-decorated class', () => { + it('should extract providers from a pre-decorated controller', () => { + @CrudController({ + path: 'decorated', + entity: 'Decorated', + }) + class DecoratedController { + constructor( + @Inject(CrudAdapterResolver) + protected readonly crudResolver: CrudResolverInterface, + ) {} + + @CrudList() + async list(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.crudResolver.list(ctx); + } + + @CrudRead() + async read(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.crudResolver.read(ctx); + } + + @CrudCreate() + async create( + @Ctx(CrudCtx) ctx: CrudContextInterface, + @CrudBody() dto: TestEntity, + ) { + return this.crudResolver.create(ctx, dto); + } + } + + const builder = new ConfigurableCrudBuilder({ + controller: { class: DecoratedController }, + }); + + const result = builder.build(); + + expect(result.controllers['DecoratedController']).toBe( + DecoratedController, + ); + expect(result.providers.length).toBeGreaterThanOrEqual(3); + expect(result.adapters['CrudAdapter']).toBe(CrudAdapter); + expect(result.queryHandlers['Decorated_list_Handler']).toBeDefined(); + expect(result.queryHandlers['Decorated_read_Handler']).toBeDefined(); + expect(result.commandHandlers['Decorated_create_Handler']).toBeDefined(); + }); + + it('should throw when entity metadata is missing', () => { + // Controller without @CrudController decorator → no entity metadata + class BareController { + async list() { + return []; + } + } + + const builder = new ConfigurableCrudBuilder({ + controller: { class: BareController }, + }); + + expect(() => builder.build()).toThrow( + 'Controller BareController must have @CrudEntity or @CrudController with entity specified', + ); + }); + + it('should throw when @CrudEntity is applied without @CrudController', () => { + // Realistic mistake: @CrudEntity alone never applies @CrudInit, so no + // query/command classes get resolved and no adapter is registered — + // this would otherwise boot clean and 500 on every request. + @Controller('entity-only') + @CrudEntity('TestEntity') + class EntityOnlyController { + @CrudList() + async list(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return ctx; + } + } + + const builder = new ConfigurableCrudBuilder({ + controller: { class: EntityOnlyController }, + }); + + expect(() => builder.build()).toThrow(CrudDecoratorException); + }); + }); + + describe('build() - Path 2: Generated controller', () => { + it('should return providers array with adapter and handlers', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Create }, + ], + }); + + const result = builder.build(); + + // providers includes adapter provider + all handlers + expect(result.providers).toBeInstanceOf(Array); + expect(result.providers.length).toBe(3); // 1 adapter + 2 handlers + }); + + it('should return controllers map with generated controller', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [{ operation: Operation.List }], + }); + + const result = builder.build(); + + expect(result.controllers['TestEntityController']).toBeDefined(); + expect(result.controllers['TestEntityController'].name).toBe( + 'TestEntityController', + ); + }); + + it('should return queries map with query classes for read operations', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + ], + }); + + const result = builder.build(); + + expect(result.queries['TestEntityCrudListQuery']).toBeDefined(); + expect(result.queries['TestEntityCrudListQuery'].name).toBe( + 'TestEntityCrudListQuery', + ); + expect(result.queries['TestEntityCrudReadQuery']).toBeDefined(); + expect(result.queries['TestEntityCrudReadQuery'].name).toBe( + 'TestEntityCrudReadQuery', + ); + }); + + it('should return queryHandlers map with handler classes for read operations', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + ], + }); + + const result = builder.build(); + + expect(result.queryHandlers['TestEntity_list_Handler']).toBeDefined(); + expect(result.queryHandlers['TestEntity_list_Handler'].name).toBe( + 'TestEntity_list_Handler', + ); + expect(result.queryHandlers['TestEntity_read_Handler']).toBeDefined(); + expect(result.queryHandlers['TestEntity_read_Handler'].name).toBe( + 'TestEntity_read_Handler', + ); + }); + + it('should return commands map with command classes for write operations', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [ + { operation: Operation.Create }, + { operation: Operation.Update }, + { operation: Operation.Delete }, + { operation: Operation.SoftDelete }, + ], + }); + + const result = builder.build(); + + expect(result.commands['TestEntityCrudCreateCommand']).toBeDefined(); + expect(result.commands['TestEntityCrudCreateCommand'].name).toBe( + 'TestEntityCrudCreateCommand', + ); + expect(result.commands['TestEntityCrudUpdateCommand']).toBeDefined(); + expect(result.commands['TestEntityCrudUpdateCommand'].name).toBe( + 'TestEntityCrudUpdateCommand', + ); + expect(result.commands['TestEntityCrudDeleteCommand']).toBeDefined(); + expect(result.commands['TestEntityCrudDeleteCommand'].name).toBe( + 'TestEntityCrudDeleteCommand', + ); + expect(result.commands['TestEntityCrudSoftDeleteCommand']).toBeDefined(); + expect(result.commands['TestEntityCrudSoftDeleteCommand'].name).toBe( + 'TestEntityCrudSoftDeleteCommand', + ); + }); + + it('should return commandHandlers map with handler classes for write operations', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [ + { operation: Operation.Create }, + { operation: Operation.Update }, + { operation: Operation.Delete }, + { operation: Operation.SoftDelete }, + ], + }); + + const result = builder.build(); + + expect(result.commandHandlers['TestEntity_create_Handler']).toBeDefined(); + expect(result.commandHandlers['TestEntity_create_Handler'].name).toBe( + 'TestEntity_create_Handler', + ); + expect(result.commandHandlers['TestEntity_update_Handler']).toBeDefined(); + expect(result.commandHandlers['TestEntity_update_Handler'].name).toBe( + 'TestEntity_update_Handler', + ); + expect(result.commandHandlers['TestEntity_delete_Handler']).toBeDefined(); + expect(result.commandHandlers['TestEntity_delete_Handler'].name).toBe( + 'TestEntity_delete_Handler', + ); + expect( + result.commandHandlers['TestEntity_softDelete_Handler'], + ).toBeDefined(); + expect(result.commandHandlers['TestEntity_softDelete_Handler'].name).toBe( + 'TestEntity_softDelete_Handler', + ); + }); + + it('should return adapters map with adapter class', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [{ operation: Operation.List }], + }); + + const result = builder.build(); + + expect(result.adapters['CrudAdapter']).toBe(CrudAdapter); + }); + + it('should use controller name for class naming when provided', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + name: 'CustomName', + }, + operations: [{ operation: Operation.List }], + }); + + const result = builder.build(); + + // Controller still uses entity for class name + expect(result.controllers['TestEntityController']).toBeDefined(); + + // Queries and handlers use the custom name + expect(result.queries['CustomNameCrudListQuery']).toBeDefined(); + expect(result.queryHandlers['CustomName_list_Handler']).toBeDefined(); + }); + + it('should support custom method names for operations', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [{ operation: Operation.List, methodName: 'findAll' }], + }); + + const result = builder.build(); + + expect(result.queryHandlers['TestEntity_findAll_Handler']).toBeDefined(); + expect(result.queryHandlers['TestEntity_findAll_Handler'].name).toBe( + 'TestEntity_findAll_Handler', + ); + }); + + it('should generate all 9 operations', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + { operation: Operation.Create }, + { operation: Operation.CreateBatch }, + { operation: Operation.Update }, + { operation: Operation.Replace }, + { operation: Operation.Delete }, + { operation: Operation.SoftDelete }, + { operation: Operation.Restore }, + ], + }); + + const result = builder.build(); + + // All 9 handlers + expect(result.providers.length).toBe(10); // 1 adapter + 9 handlers + + // Read operations → queries + expect(result.queries['TestEntityCrudListQuery']).toBeDefined(); + expect(result.queries['TestEntityCrudReadQuery']).toBeDefined(); + + // Write operations → commands + expect(result.commands['TestEntityCrudCreateCommand']).toBeDefined(); + expect(result.commands['TestEntityCrudCreateBatchCommand']).toBeDefined(); + expect(result.commands['TestEntityCrudUpdateCommand']).toBeDefined(); + expect(result.commands['TestEntityCrudReplaceCommand']).toBeDefined(); + expect(result.commands['TestEntityCrudDeleteCommand']).toBeDefined(); + expect(result.commands['TestEntityCrudSoftDeleteCommand']).toBeDefined(); + expect(result.commands['TestEntityCrudRestoreCommand']).toBeDefined(); + }); + + it('should use custom resolver when specified', () => { + class CustomResolver implements CrudResolverInterface { + static decorateQueryHandler = vi.fn(); + static decorateCommandHandler = vi.fn(); + list = vi.fn(); + read = vi.fn(); + create = vi.fn(); + createBatch = vi.fn(); + update = vi.fn(); + replace = vi.fn(); + delete = vi.fn(); + softDelete = vi.fn(); + restore = vi.fn(); + } + + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + + resolver: CustomResolver, + }, + operations: [{ operation: Operation.List }], + }); + + const result = builder.build(); + + expect(result.controllers['TestEntityController']).toBeDefined(); + }); + }); + + describe('build() - Path 3: Hybrid', () => { + it('should augment existing methods and add new ones', () => { + @CrudController({ + path: 'hybrid', + entity: 'Hybrid', + }) + class HybridController { + constructor( + @Inject(CrudAdapterResolver) + protected readonly crudResolver: CrudResolverInterface, + ) {} + + @CrudList() + async list(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.crudResolver.list(ctx); + } + } + + const builder = new ConfigurableCrudBuilder({ + controller: { class: HybridController }, + operations: [ + // Augment existing method + { operation: Operation.List }, + // Add new method + { operation: Operation.Create }, + ], + }); + + const result = builder.build(); + + expect(result.controllers['HybridController']).toBe(HybridController); + expect(result.queryHandlers['Hybrid_list_Handler']).toBeDefined(); + expect(result.commandHandlers['Hybrid_create_Handler']).toBeDefined(); + }); + + it('should throw when method exists with mismatched operation', () => { + @CrudController({ + path: 'conflict', + entity: 'Conflict', + }) + class ConflictController { + constructor( + @Inject(CrudAdapterResolver) + protected readonly crudResolver: CrudResolverInterface, + ) {} + + @CrudList() + async list(@Ctx(CrudCtx) ctx: CrudContextInterface) { + return this.crudResolver.list(ctx); + } + } + + const builder = new ConfigurableCrudBuilder({ + controller: { class: ConflictController }, + operations: [ + // "list" method exists but is decorated with List, not Read + { operation: Operation.Read, methodName: 'list' }, + ], + }); + + expect(() => builder.build()).toThrow( + /Method "list" on ConflictController is decorated with operation "list" but operations array specifies "read"/, + ); + }); + + it('should throw when entity metadata is missing', () => { + // Controller without @CrudController decorator → no entity metadata + class BareController { + async list() { + return []; + } + } + + const builder = new ConfigurableCrudBuilder({ + controller: { class: BareController }, + operations: [{ operation: Operation.List }], + }); + + expect(() => builder.build()).toThrow(CrudDecoratorException); + expect(() => builder.build()).toThrow( + 'Controller BareController must have @CrudEntity or @CrudController with entity specified', + ); + }); + }); + + describe('setExtras()', () => { + it('should apply options transform during build', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [{ operation: Operation.List }], + }); + + const transform: ConfigurableCrudOptionsTransformer< + TestEntity, + PlainLiteralObject + > = vi.fn((options) => options); + builder.setExtras({ customPath: 'custom' }, transform); + + const result = builder.build(); + + expect(result.controllers['TestEntityController']).toBeDefined(); + }); + }); + + describe('validateOperations()', () => { + it('should throw on duplicate method names', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.List }, + ], + }); + + expect(() => builder.build()).toThrow( + /Duplicate method name "list" in operations/, + ); + }); + + it('should throw on duplicate custom method names', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [ + { operation: Operation.List, methodName: 'findAll' }, + { operation: Operation.Read, methodName: 'findAll' }, + ], + }); + + expect(() => builder.build()).toThrow( + /Duplicate method name "findAll" in operations/, + ); + }); + + it('should allow same operation with different method names', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [ + { operation: Operation.Delete, methodName: 'hardDelete' }, + { operation: Operation.Delete, methodName: 'removeOne' }, + ], + }); + + const result = builder.build(); + + expect( + result.commandHandlers['TestEntity_hardDelete_Handler'], + ).toBeDefined(); + expect( + result.commandHandlers['TestEntity_removeOne_Handler'], + ).toBeDefined(); + }); + }); + + describe('generated method implementations', () => { + it('should generate working methods for all operation types', () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + { operation: Operation.Create }, + { operation: Operation.CreateBatch }, + { operation: Operation.Update }, + { operation: Operation.Replace }, + { operation: Operation.Delete }, + { operation: Operation.SoftDelete }, + { operation: Operation.Restore }, + ], + }); + + const result = builder.build(); + const ControllerClass = result.controllers['TestEntityController']; + const proto = ControllerClass.prototype; + + // Verify all methods exist on the prototype + expect(typeof proto.list).toBe('function'); + expect(typeof proto.read).toBe('function'); + expect(typeof proto.create).toBe('function'); + expect(typeof proto.createBatch).toBe('function'); + expect(typeof proto.update).toBe('function'); + expect(typeof proto.replace).toBe('function'); + expect(typeof proto.delete).toBe('function'); + expect(typeof proto.softDelete).toBe('function'); + expect(typeof proto.restore).toBe('function'); + }); + + it('should delegate to crudResolver methods', async () => { + const builder = new ConfigurableCrudBuilder({ + controller: { + path: 'test-entity', + entity: 'TestEntity', + }, + operations: [ + { operation: Operation.List }, + { operation: Operation.Read }, + { operation: Operation.Create }, + { operation: Operation.CreateBatch }, + { operation: Operation.Update }, + { operation: Operation.Replace }, + { operation: Operation.Delete }, + { operation: Operation.SoftDelete }, + { operation: Operation.Restore }, + ], + }); + + const result = builder.build(); + const ControllerClass = result.controllers['TestEntityController']; + const proto = ControllerClass.prototype; + + const mockContext = {} as CrudContextInterface; + const mockDto = { id: '1', name: 'test' }; + const mockBatchDto = { bulk: [mockDto] }; + + const mockResolver: CrudResolverInterface = { + list: vi.fn().mockResolvedValue({ data: [] }), + read: vi.fn().mockResolvedValue(mockDto), + create: vi.fn().mockResolvedValue(mockDto), + createBatch: vi.fn().mockResolvedValue([mockDto]), + update: vi.fn().mockResolvedValue(mockDto), + replace: vi.fn().mockResolvedValue(mockDto), + delete: vi.fn().mockResolvedValue(null), + softDelete: vi.fn().mockResolvedValue(null), + restore: vi.fn().mockResolvedValue(null), + }; + + const instance = { crudResolver: mockResolver }; + + // Call each generated method with the mock resolver as `this` + await proto.list.call(instance, mockContext); + expect(mockResolver.list).toHaveBeenCalledWith(mockContext); + + await proto.read.call(instance, mockContext); + expect(mockResolver.read).toHaveBeenCalledWith(mockContext); + + await proto.create.call(instance, mockContext, mockDto); + expect(mockResolver.create).toHaveBeenCalledWith(mockContext, mockDto); + + await proto.createBatch.call(instance, mockContext, mockBatchDto); + expect(mockResolver.createBatch).toHaveBeenCalledWith( + mockContext, + mockBatchDto, + ); + + await proto.update.call(instance, mockContext, mockDto); + expect(mockResolver.update).toHaveBeenCalledWith(mockContext, mockDto); + + await proto.replace.call(instance, mockContext, mockDto); + expect(mockResolver.replace).toHaveBeenCalledWith(mockContext, mockDto); + + await proto.delete.call(instance, mockContext); + expect(mockResolver.delete).toHaveBeenCalledWith(mockContext); + + await proto.softDelete.call(instance, mockContext); + expect(mockResolver.softDelete).toHaveBeenCalledWith(mockContext); + + await proto.restore.call(instance, mockContext); + expect(mockResolver.restore).toHaveBeenCalledWith(mockContext); + }); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/utils/__tests__/crud-empty-body-guard.util.spec.ts b/packages/nestjs-crud/src/infrastructure/utils/__tests__/crud-empty-body-guard.util.spec.ts new file mode 100644 index 000000000..f78a313c9 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/__tests__/crud-empty-body-guard.util.spec.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +import { withEmptyBodyGuard } from '../crud-empty-body-guard.util.js'; + +describe('withEmptyBodyGuard', () => { + const optionalSchema = z.object({ + name: z.string().optional(), + }); + + it('allows an empty object by default', () => { + const schema = withEmptyBodyGuard(optionalSchema); + expect(schema.safeParse({}).success).toBe(true); + }); + + it('allows an empty object when allowEmptyBody is true', () => { + const schema = withEmptyBodyGuard(optionalSchema, true); + expect(schema.safeParse({}).success).toBe(true); + }); + + it('rejects an empty object when allowEmptyBody is false', () => { + const schema = withEmptyBodyGuard(optionalSchema, false); + const result = schema.safeParse({}); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toEqual( + 'Body must not be empty.', + ); + } + }); + + it('accepts a non-empty object when allowEmptyBody is false', () => { + const schema = withEmptyBodyGuard(optionalSchema, false); + expect(schema.safeParse({ name: 'Test' }).success).toBe(true); + }); + + it('never rejects as empty when the schema fills fields via .default()', () => { + const defaultedSchema = z.object({ + name: z.string().default(''), + }); + const schema = withEmptyBodyGuard(defaultedSchema, false); + const result = schema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: '' }); + } + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/utils/__tests__/swagger.helper.spec.ts b/packages/nestjs-crud/src/infrastructure/utils/__tests__/swagger.helper.spec.ts new file mode 100644 index 000000000..53b4ef093 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/__tests__/swagger.helper.spec.ts @@ -0,0 +1,26 @@ +import { Operation } from '@concepta/nestjs-core'; + +import { Swagger } from '../swagger.helper.js'; + +describe('Swagger.createQueryParamsMeta', () => { + it('does not include a join parameter for List — join is configured server-side via @CrudJoin(), not requestable per-call', () => { + const meta = Swagger.createQueryParamsMeta(Operation.List); + + expect(meta.some((m) => m.description?.includes('relational'))).toBe(false); + }); + + it('does not include a join parameter for Read', () => { + const meta = Swagger.createQueryParamsMeta(Operation.Read); + + expect(meta.some((m) => m.description?.includes('relational'))).toBe(false); + }); + + it('every returned parameter has a defined string name', () => { + const meta = [ + ...Swagger.createQueryParamsMeta(Operation.List), + ...Swagger.createQueryParamsMeta(Operation.Read), + ]; + + expect(meta.every((m) => typeof m.name === 'string')).toBe(true); + }); +}); diff --git a/packages/nestjs-crud/src/infrastructure/utils/configurable-crud.builder.ts b/packages/nestjs-crud/src/infrastructure/utils/configurable-crud.builder.ts new file mode 100644 index 000000000..232d4f4a7 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/configurable-crud.builder.ts @@ -0,0 +1,770 @@ +import { + applyDecorators, + Inject, + PlainLiteralObject, + Provider, + Type, +} from '@nestjs/common'; + +import { DeepPartial, Operation, Ctx } from '@concepta/nestjs-core'; +import { + Transactional, + TransactionalOptions, +} from '@concepta/nestjs-repository'; + +import { ConfigurableCrudOptionsTransformer } from '../../crud.types.js'; +import { CrudAdapter } from '../adapters/crud.adapter.js'; +import { CrudController } from '../decorators/controller/crud-controller.decorator.js'; +import { CrudInit } from '../decorators/controller/crud-init.decorator.js'; +import { CrudCreateBatch } from '../decorators/operations/crud-create-batch.decorator.js'; +import { CrudCreate } from '../decorators/operations/crud-create.decorator.js'; +import { CrudDelete } from '../decorators/operations/crud-delete.decorator.js'; +import { CrudList } from '../decorators/operations/crud-list.decorator.js'; +import { CrudRead } from '../decorators/operations/crud-read.decorator.js'; +import { CrudReplace } from '../decorators/operations/crud-replace.decorator.js'; +import { CrudRestore } from '../decorators/operations/crud-restore.decorator.js'; +import { CrudSoftDelete } from '../decorators/operations/crud-soft-delete.decorator.js'; +import { CrudUpdate } from '../decorators/operations/crud-update.decorator.js'; +import { CrudBody } from '../decorators/params/crud-body.decorator.js'; +import { CrudCommandHandler } from '../decorators/routes/crud-command-handler.decorator.js'; +import { CrudQueryHandler } from '../decorators/routes/crud-query-handler.decorator.js'; +import { CrudDecoratorException } from '../exceptions/crud-decorator.exception.js'; +import { CrudCtx } from '../interceptors/crud-context.overlay.js'; +import { CrudContextInterface } from '../interceptors/interfaces/crud-context.interface.js'; +import { + CrudControllerClassOptionsInterface, + CrudControllerOptionsInterface, +} from '../interfaces/crud-controller-options.interface.js'; +import { CrudCreateBatchInterface } from '../interfaces/crud-create-batch.interface.js'; +import { + CrudRouteCommandOptionsInterface, + CrudRouteQueryOptionsInterface, +} from '../interfaces/crud-route-ctlr-options.interface.js'; +import { CrudAdapterResolver } from '../resolvers/crud-adapter.resolver.js'; +import { CrudResolverInterface } from '../resolvers/interfaces/crud-resolver.interface.js'; +import { CrudMetaview } from '../services/crud-metaview.service.js'; + +import { createCrudAdapterProvider } from './create-crud-adapter-provider.js'; +import { + isBodyOperation, + isReadOperation, + getControllerName, + isAdapterType, +} from './crud-infra.utils.js'; +import { CrudOperationOptions } from './crud-operation-options.type.js'; +import { + ConfigurableCrudClassesMap, + ConfigurableCrudHost, +} from './interfaces/configurable-crud-host.interface.js'; +import { + ConfigurableCrudGeneratedOptions, + ConfigurableCrudHybridOptions, + ConfigurableCrudOptions, +} from './interfaces/configurable-crud-options.interface.js'; + +export class ConfigurableCrudBuilder< + Entity extends PlainLiteralObject, + ExtraOptions extends PlainLiteralObject = PlainLiteralObject, +> { + private extras: ExtraOptions; + private optionsTransform: ConfigurableCrudOptionsTransformer< + Entity, + ExtraOptions + >; + + constructor(private options: ConfigurableCrudOptions) { + this.extras = {} as ExtraOptions; + this.optionsTransform = (options, _extras) => options; + } + + setExtras( + extras: ExtraOptions, + optionsTransform: ConfigurableCrudOptionsTransformer, + ): ConfigurableCrudBuilder { + this.extras = extras; + this.optionsTransform = optionsTransform; + return this; + } + + /** + * Build the CRUD configuration and return generated classes. + * + * Returns an object with: + * - `providers` - All providers needed for the module (adapter, handlers) + * - `controllers` - Controller classes by name + * - `queries` - Query classes by name (for read operations) + * - `queryHandlers` - Query handler classes by name + * - `commands` - Command classes by name (for write operations) + * - `commandHandlers` - Command handler classes by name + * - `adapters` - Adapter classes by name + * + * @example + * ```typescript + * const { providers, controllers, queries, queryHandlers } = new ConfigurableCrudBuilder({ + * controller: { + * entity: 'User', + * path: 'users', + * adapter: CrudAdapter, // optional, defaults to CrudAdapter + * }, + * operations: [{ operation: Operation.List }], + * }).build(); + * + * // Destructure generated classes by name: + * const { UserController } = controllers; + * const { UserCrudListQuery } = queries; + * const { User_list_Handler } = queryHandlers; + * ``` + */ + build(): ConfigurableCrudHost { + const options = this.optionsTransform(this.options, this.extras); + + // Path 3: Hybrid - class with operations + if (this.isHybridOptions(options)) { + return this.buildHybrid(options.controller.class, options.operations); + } + + if (this.isControllerClassOptions(options.controller)) { + // Path 1: Pre-decorated class - generate handlers and adapter provider + const controllerClass = options.controller.class; + const { handlers, queries, queryHandlers, commands, commandHandlers } = + this.collectClassesFromController(controllerClass); + + // Extract adapter from controller metadata + const reflectionService = new CrudMetaview(); + const entity = reflectionService.getEntity(controllerClass); + const adapter = reflectionService.getAdapter(controllerClass); + + if (!entity) { + throw new CrudDecoratorException({ + message: `Controller ${controllerClass.name} must have @CrudEntity or @CrudController with entity specified`, + }); + } + + const hasAdapter = Boolean(adapter && isAdapterType(adapter)); + + // @CrudEntity alone (without @CrudController) never applies CrudInit, + // so no query/command classes get resolved and no adapter metadata is + // stamped — a controller in that state boots clean but 500s on every + // request. Catch it here instead of at first request. + if (handlers.length === 0 && !hasAdapter) { + throw new CrudDecoratorException({ + message: `Controller ${controllerClass.name} has @CrudEntity but no CRUD operations were resolved and no adapter provider was found. Use @CrudController (which wires operations via @CrudInit), not @CrudEntity alone.`, + }); + } + + const providers: Provider[] = [...handlers]; + const adapters: ConfigurableCrudClassesMap = {}; + + // Create adapter provider if we have an adapter type + if (hasAdapter && adapter) { + providers.unshift( + createCrudAdapterProvider({ entity, adapter }), + ); + adapters[adapter.name] = adapter; + } + + return { + providers, + controllers: { [controllerClass.name]: controllerClass }, + queries, + queryHandlers, + commands, + commandHandlers, + adapters, + }; + } + + if (!this.isGeneratedOptions(options)) { + throw new Error('Invalid options: expected operations array'); + } + + // Path 2: Generate new class from controller options + const { controller, operations } = options; + + // Validate operations have unique method names + this.validateOperations(operations); + + // Resolve adapter class to factory provider if needed + const adapter = controller.adapter ?? CrudAdapter; + const adapterProvider = isAdapterType(adapter) + ? createCrudAdapterProvider({ + entity: controller.entity, + adapter, + }) + : adapter; + + // Build controller config with resolved adapter + const resolvedController = { ...controller, adapter: adapterProvider }; + + // Generate controller class + const ConfigurableControllerClass = this.generateClass( + operations, + resolvedController, + ); + + // Collect classes from the controller's decorator metadata + const { handlers, queries, queryHandlers, commands, commandHandlers } = + this.collectClassesFromController(ConfigurableControllerClass); + + // Build adapters map + const adapters: ConfigurableCrudClassesMap = {}; + if (isAdapterType(adapter)) { + adapters[adapter.name] = adapter; + } + + return { + providers: [adapterProvider, ...handlers], + controllers: { + [ConfigurableControllerClass.name]: ConfigurableControllerClass, + }, + queries, + queryHandlers, + commands, + commandHandlers, + adapters, + }; + } + + /** + * Validate that all operations have unique method names. + */ + private validateOperations(operations: CrudOperationOptions[]): void { + const methodNames = new Set(); + + for (const op of operations) { + const methodName = op.methodName ?? op.operation; + + if (methodNames.has(methodName)) { + throw new Error( + `Duplicate method name "${methodName}" in operations. ` + + `When using multiple operations with the same operation type, each must have a unique methodName.`, + ); + } + methodNames.add(methodName); + } + } + + /** + * Get the operation decorator for a given operation type. + */ + private getOperationDecorator( + operation: Operation, + options: Record, + ): MethodDecorator { + switch (operation) { + case Operation.List: + return CrudList(options); + case Operation.Read: + return CrudRead(options); + case Operation.Create: + return CrudCreate(options); + case Operation.CreateBatch: + return CrudCreateBatch(options); + case Operation.Update: + return CrudUpdate(options); + case Operation.Replace: + return CrudReplace(options); + case Operation.Delete: + return CrudDelete(options); + case Operation.SoftDelete: + return CrudSoftDelete(options); + case Operation.Restore: + return CrudRestore(options); + default: { + const _exhaustive: never = operation; + throw new Error(`Unsupported operation: ${_exhaustive}`); + } + } + } + + /** + * Resolve the transactional method decorator for an operation. + * + * When controller-level transactional is enabled, read operations get + * `@Transactional(false)` to opt out. Per-operation transactional + * overrides the controller-level setting. + */ + private getTransactionalDecorator( + operation: Operation, + opTransactional?: boolean | TransactionalOptions, + controllerTransactional?: boolean | TransactionalOptions, + ): MethodDecorator[] { + // Explicit per-operation setting takes precedence + if (opTransactional !== undefined) { + if (opTransactional === false) { + return [Transactional(false)]; + } + const options = + typeof opTransactional === 'object' ? opTransactional : undefined; + return [Transactional(options)]; + } + + // When controller-level is enabled, opt out read operations + if (controllerTransactional && isReadOperation(operation)) { + return [Transactional(false)]; + } + + return []; + } + + /** + * Apply all decorators for an operation: operation decorator + parameter decorators. + */ + private applyOperationDecorators( + controllerClass: Type, + methodName: string, + op: CrudOperationOptions, + operationIdPrefix: string, + controllerTransactional?: boolean | TransactionalOptions, + ): void { + const { + operation, + extraDecorators = [], + transactional: opTransactional, + ...restOptions + } = op; + const proto = controllerClass.prototype; + const descriptor = Object.getOwnPropertyDescriptor(proto, methodName); + + // Build options with operationId + const optionsWithId = { + ...restOptions, + api: { + ...restOptions.api, + operation: { + operationId: `${operationIdPrefix}_${methodName}`, + ...restOptions.api?.operation, + }, + }, + }; + + // Apply operation decorator with transactional + extra decorators + const txDecorators = this.getTransactionalDecorator( + operation, + opTransactional, + controllerTransactional, + ); + const opDecorator = this.getOperationDecorator(operation, optionsWithId); + applyDecorators(opDecorator, ...txDecorators, ...extraDecorators)( + proto, + methodName, + descriptor, + ); + + // Apply parameter decorators + this.applyParameterDecorators(controllerClass, methodName, op); + } + + /** + * Apply only parameter decorators (CrudContext, CrudBody) without operation decorator. + * Also applies handler overrides if specified in the operation. + * Used for hybrid controllers where the method already has the operation decorator applied. + */ + private applyParameterDecorators( + controllerClass: Type, + methodName: string, + op: CrudOperationOptions, + ): void { + const { operation } = op; + const proto = controllerClass.prototype; + const descriptor = Object.getOwnPropertyDescriptor(proto, methodName); + + // Apply CrudContext to first parameter + Ctx(CrudCtx)(proto, methodName, 0); + + // Apply CrudBody to second parameter if operation requires body + if (isBodyOperation(operation)) { + const bodySchema = + operation === Operation.CreateBatch + ? op.request?.bodyBatch + : op.request?.body; + CrudBody({ schema: bodySchema })(proto, methodName, 1); + } + + // Apply handler overrides if specified + if (descriptor) { + if (isReadOperation(operation)) { + const queryOptions = op as CrudRouteQueryOptionsInterface; + if (queryOptions.queryHandler) { + CrudQueryHandler({ handler: queryOptions.queryHandler })( + proto, + methodName, + descriptor, + ); + } + } else { + const commandOptions = op as CrudRouteCommandOptionsInterface; + if (commandOptions.commandHandler) { + CrudCommandHandler({ + handler: commandOptions.commandHandler, + })(proto, methodName, descriptor); + } + } + } + } + + /** + * Result of collecting classes from controller. + */ + private collectClassesResult(): { + handlers: Type[]; + queries: ConfigurableCrudClassesMap; + queryHandlers: ConfigurableCrudClassesMap; + commands: ConfigurableCrudClassesMap; + commandHandlers: ConfigurableCrudClassesMap; + } { + return { + handlers: [], + queries: {}, + queryHandlers: {}, + commands: {}, + commandHandlers: {}, + }; + } + + /** + * Extract handlers and CQRS classes from a controller's decorated methods. + */ + private collectClassesFromController( + controller: Type, + ): ReturnType { + const reflectionService = new CrudMetaview(); + const result = this.collectClassesResult(); + + const methodNames = this.getControllerMethodNames(controller); + + for (const methodName of methodNames) { + const method = controller.prototype[methodName]; + const operation = reflectionService.getOperation(method); + + if (!operation) continue; + + if (isReadOperation(operation)) { + // Collect query class + const queryOptions = reflectionService.getQuery(method); + if (queryOptions?.resolved) { + result.queries[queryOptions.resolved.name] = queryOptions.resolved; + } + // Collect query handler + const queryHandlerOptions = reflectionService.getQueryHandler(method); + if (queryHandlerOptions?.resolved) { + result.handlers.push(queryHandlerOptions.resolved); + result.queryHandlers[queryHandlerOptions.resolved.name] = + queryHandlerOptions.resolved; + } + } else { + // Collect command class + const commandOptions = reflectionService.getCommand(method); + if (commandOptions?.resolved) { + result.commands[commandOptions.resolved.name] = + commandOptions.resolved; + } + // Collect command handler + const commandHandlerOptions = + reflectionService.getCommandHandler(method); + if (commandHandlerOptions?.resolved) { + result.handlers.push(commandHandlerOptions.resolved); + result.commandHandlers[commandHandlerOptions.resolved.name] = + commandHandlerOptions.resolved; + } + } + } + + return result; + } + + /** + * Get all method names from a controller, including inherited methods. + */ + private getControllerMethodNames(controller: Type): string[] { + const methods = new Set(); + let proto = controller.prototype; + + while (proto && proto !== Object.prototype) { + for (const name of Object.getOwnPropertyNames(proto)) { + if (name !== 'constructor' && typeof proto[name] === 'function') { + methods.add(name); + } + } + proto = Object.getPrototypeOf(proto); + } + + return Array.from(methods); + } + + /** + * Create a method implementation for a given operation type. + */ + private createMethodImplementation(operation: Operation): CallableFunction { + switch (operation) { + case Operation.List: + return function ( + this: { crudResolver: CrudResolverInterface }, + ctx: CrudContextInterface, + ) { + return this.crudResolver.list(ctx); + }; + case Operation.Read: + return function ( + this: { crudResolver: CrudResolverInterface }, + ctx: CrudContextInterface, + ) { + return this.crudResolver.read(ctx); + }; + case Operation.Create: + return function ( + this: { crudResolver: CrudResolverInterface }, + ctx: CrudContextInterface, + dto: DeepPartial, + ) { + return this.crudResolver.create(ctx, dto); + }; + case Operation.CreateBatch: + return function ( + this: { crudResolver: CrudResolverInterface }, + ctx: CrudContextInterface, + dto: CrudCreateBatchInterface>, + ) { + return this.crudResolver.createBatch(ctx, dto); + }; + case Operation.Update: + return function ( + this: { crudResolver: CrudResolverInterface }, + ctx: CrudContextInterface, + dto: DeepPartial, + ) { + return this.crudResolver.update(ctx, dto); + }; + case Operation.Replace: + return function ( + this: { crudResolver: CrudResolverInterface }, + ctx: CrudContextInterface, + dto: DeepPartial, + ) { + return this.crudResolver.replace(ctx, dto); + }; + case Operation.Delete: + return function ( + this: { crudResolver: CrudResolverInterface }, + ctx: CrudContextInterface, + ) { + return this.crudResolver.delete(ctx); + }; + case Operation.SoftDelete: + return function ( + this: { crudResolver: CrudResolverInterface }, + ctx: CrudContextInterface, + ) { + return this.crudResolver.softDelete(ctx); + }; + case Operation.Restore: + return function ( + this: { crudResolver: CrudResolverInterface }, + ctx: CrudContextInterface, + ) { + return this.crudResolver.restore(ctx); + }; + default: { + const _exhaustive: never = operation; + throw new Error(`Unsupported operation: ${_exhaustive}`); + } + } + } + + /** + * Generate a standalone controller class with methods for each operation. + */ + private generateClass( + operations: CrudOperationOptions[], + controller: CrudControllerOptionsInterface, + ): Type { + // Get the resolver class (defaults to CrudAdapterResolver) + const ResolverClass = controller.resolver ?? CrudAdapterResolver; + + // Create standalone class (no CrudBaseController inheritance) + class GeneratedController { + constructor( + @Inject(ResolverClass) + protected readonly crudResolver: CrudResolverInterface, + ) {} + } + + // Set class name to ${entity}Controller + Object.defineProperty(GeneratedController, 'name', { + value: `${controller.entity}Controller`, + }); + + // Generate methods for each operation + for (const op of operations) { + const { operation } = op; + const methodName = op.methodName ?? operation; + + // Create method implementation based on operation type + Object.defineProperty(GeneratedController.prototype, methodName, { + value: this.createMethodImplementation(operation), + writable: true, + configurable: true, + }); + + // Apply all decorators + this.applyOperationDecorators( + GeneratedController, + methodName, + op, + getControllerName(controller), + controller.transactional, + ); + } + + // Apply CrudController decorator to class + const classDecorators: Array = [ + CrudController(controller), + ]; + + if (controller.transactional) { + const txOptions = + typeof controller.transactional === 'object' + ? controller.transactional + : undefined; + classDecorators.push(Transactional(txOptions)); + } + + classDecorators.push(...(this.options.controller?.extraDecorators ?? [])); + + applyDecorators(...classDecorators)(GeneratedController); + + return GeneratedController as Type; + } + + /** + * Path 3: Hybrid - class with operations. + * + * For each operation: + * - Determine method name (explicit or default for operation) + * - If method exists with matching operation → augment/override its options + * - If method doesn't exist → create new method with implementation + decorators + */ + private buildHybrid( + controllerClass: Type, + operations: CrudOperationOptions[], + ): ConfigurableCrudHost { + const reflectionService = new CrudMetaview(); + + // Extract entity, name, and adapter from controller metadata + const entity = reflectionService.getEntity(controllerClass); + const name = reflectionService.getName(controllerClass); + const adapter = reflectionService.getAdapter(controllerClass); + + if (!entity) { + throw new CrudDecoratorException({ + message: `Controller ${controllerClass.name} must have @CrudEntity or @CrudController with entity specified`, + }); + } + + // Get effective controller name for operationId prefix + const controllerName = getControllerName({ entity, name }); + + // Process each operation + for (const op of operations) { + const methodName = op.methodName ?? op.operation; + const existingMethod = controllerClass.prototype[methodName]; + + if (existingMethod) { + // Method exists - check if operation matches + const existingOperation = + reflectionService.getOperation(existingMethod); + + if (existingOperation === op.operation) { + // Operation matches - only apply parameter decorators + // Do NOT re-apply operation decorator as it would overwrite resolved query/command metadata + this.applyParameterDecorators(controllerClass, methodName, op); + } else { + throw new Error( + `Method "${methodName}" on ${controllerClass.name} is decorated with operation ` + + `"${existingOperation}" but operations array specifies "${op.operation}". ` + + `Use a different methodName to avoid this conflict.`, + ); + } + } else { + // Method doesn't exist - create new method with implementation + Object.defineProperty(controllerClass.prototype, methodName, { + value: this.createMethodImplementation(op.operation), + writable: true, + configurable: true, + }); + + // Apply all decorators + this.applyOperationDecorators( + controllerClass, + methodName, + op, + controllerName, + ); + } + } + + // Re-run initialization decorators after augmentation + // This resolves query/command classes and applies @Body decorators + CrudInit()(controllerClass); + + // Collect classes from the now-decorated controller + const { handlers, queries, queryHandlers, commands, commandHandlers } = + this.collectClassesFromController(controllerClass); + + const providers: Provider[] = [...handlers]; + const adapters: ConfigurableCrudClassesMap = {}; + + // Create adapter provider if we have adapter type + if (adapter && isAdapterType(adapter)) { + providers.unshift(createCrudAdapterProvider({ entity, adapter })); + adapters[adapter.name] = adapter; + } + + return { + providers, + controllers: { [controllerClass.name]: controllerClass }, + queries, + queryHandlers, + commands, + commandHandlers, + adapters, + }; + } + + /** + * Type guard to check if options are for hybrid controller (class + operations). + */ + private isHybridOptions( + options: ConfigurableCrudOptions, + ): options is ConfigurableCrudHybridOptions { + return ( + this.isControllerClassOptions(options.controller) && + 'operations' in options && + Array.isArray(options.operations) + ); + } + + /** + * Type guard to check if options are for generated controller. + */ + private isGeneratedOptions( + options: ConfigurableCrudOptions, + ): options is ConfigurableCrudGeneratedOptions { + return ( + !this.isControllerClassOptions(options.controller) && + 'operations' in options && + Array.isArray(options.operations) + ); + } + + /** + * Type guard to check if options use the class path. + */ + private isControllerClassOptions( + options: + | CrudControllerClassOptionsInterface + | CrudControllerOptionsInterface, + ): options is CrudControllerClassOptionsInterface { + return 'class' in options && options.class !== undefined; + } +} diff --git a/packages/nestjs-crud/src/infrastructure/utils/create-crud-adapter-provider.ts b/packages/nestjs-crud/src/infrastructure/utils/create-crud-adapter-provider.ts new file mode 100644 index 000000000..26df8387a --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/create-crud-adapter-provider.ts @@ -0,0 +1,69 @@ +import { + type PlainLiteralObject, + type Provider, + type Type, +} from '@nestjs/common'; + +import { + getDynamicRepositoryToken, + type RepositoryInterface, +} from '@concepta/nestjs-repository'; + +import { type CrudAdapter } from '../adapters/crud.adapter.js'; + +import { getDynamicAdapterToken } from './crud-infra.utils.js'; + +/** + * Configuration for creating a CRUD adapter provider + */ +interface CreateCrudAdapterProviderConfig { + /** + * Entity key used for repository injection tokens. + */ + entity: string; + + /** + * The CRUD adapter class to instantiate. + */ + adapter: Type>; +} + +/** + * Creates a NestJS provider for a CRUD adapter. + * + * This factory eliminates boilerplate adapter class files by dynamically + * creating adapter instances from repositories. + * + * A unique token is derived from the entity key (e.g., 'CRUD_ADAPTER_USER'). + * Repository token is derived from entity via getDynamicRepositoryToken. + * + * @example + * ```typescript + * const UserCrudAdapterProvider = createCrudAdapterProvider({ + * entity: 'User', + * adapter: CrudAdapter, + * }); + * + * @Module({ + * providers: [UserCrudAdapterProvider], + * }) + * export class UserModule {} + * ``` + * + * @param config - Configuration for the CRUD adapter provider + * @returns A NestJS provider that creates the adapter instance + */ +export function createCrudAdapterProvider( + config: CreateCrudAdapterProviderConfig, +): Provider> { + const { entity, adapter } = config; + const token = getDynamicAdapterToken(entity); + + return { + provide: token, + inject: [getDynamicRepositoryToken(entity)], + useFactory: (repository: RepositoryInterface) => { + return new adapter(repository); + }, + }; +} diff --git a/packages/nestjs-crud/src/infrastructure/utils/crud-empty-body-guard.util.ts b/packages/nestjs-crud/src/infrastructure/utils/crud-empty-body-guard.util.ts new file mode 100644 index 000000000..6045d1b1c --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/crud-empty-body-guard.util.ts @@ -0,0 +1,27 @@ +import { type z } from 'zod'; + +import { isObject } from '@concepta/nestjs-core'; + +const EMPTY_BODY_MESSAGE = 'Body must not be empty.'; + +/** + * Wrap a body schema so validation also rejects an empty (`{}`) object, + * unless `allowEmptyBody` opts out (default `true`). A schema is the + * contract: one whose fields are all optional already declares `{}` a + * valid body (see #466 — server-populated resources legitimately post + * `{}`), so this only adds a check for the operations that want to reject + * that on purpose. Runs after the base schema's own parsing, so a schema + * with `.default()` fields — which always materializes those keys — is + * never considered empty regardless of this flag. + */ +export function withEmptyBodyGuard( + schema: T, + allowEmptyBody = true, +): z.ZodType { + if (allowEmptyBody) return schema; + + return schema.refine( + (value: unknown) => !isObject(value) || Object.keys(value).length > 0, + { message: EMPTY_BODY_MESSAGE }, + ); +} diff --git a/packages/nestjs-crud/src/infrastructure/utils/crud-infra.utils.ts b/packages/nestjs-crud/src/infrastructure/utils/crud-infra.utils.ts new file mode 100644 index 000000000..9cf842e71 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/crud-infra.utils.ts @@ -0,0 +1,123 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { + ActionEnum, + Operation, + type ReadOperation, +} from '@concepta/nestjs-core'; +import { + isArrayCondition, + type WhereCondition, + type WhereConditionArray, +} from '@concepta/nestjs-repository'; + +import { type CrudAdapter } from '../adapters/crud.adapter.js'; +import { type CrudAdapterProvider } from '../adapters/interfaces/crud-adapter.types.js'; +import { CrudDecoratorException } from '../exceptions/crud-decorator.exception.js'; +import { type CrudControllerEntityInterface } from '../interfaces/crud-controller-entity.interface.js'; + +/** + * Gets the dynamic adapter token for a given name. + * + * @param name - The entity name + * @returns A unique string token for the adapter + */ +export function getDynamicAdapterToken(name: string): string { + return `CRUD_ADAPTER_${name.toUpperCase()}`; +} + +/** + * Type guard to check if a value is a Type (class constructor) + */ +export function isAdapterType( + value: CrudAdapterProvider, +): value is Type> { + return typeof value === 'function'; +} + +/** + * Get the effective controller name for CQRS class naming and operationIds. + * + * @param options - Controller naming options + * @returns The name if provided, otherwise the entity key + */ +export function getControllerName( + options: CrudControllerEntityInterface, +): string { + return options.name ?? options.entity; +} + +export function getMethodHandler( + target: object, + propertyKey: string | symbol, +): CallableFunction { + const handler = Reflect.get(target, propertyKey); + if (typeof handler !== 'function') { + throw new CrudDecoratorException({ + message: `Property ${String(propertyKey)} is not a method`, + }); + } + return handler; +} + +/** + * Check if a class has an explicit constructor defined. + * Uses design:paramtypes metadata which is only set when a constructor exists. + * + * Accepts Function to work with ClassDecorator targets. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export function hasExplicitConstructor(targetClass: Function): boolean { + return Reflect.getMetadata('design:paramtypes', targetClass) !== undefined; +} + +export function queryFilterIsArray( + cond: WhereCondition, +): cond is WhereConditionArray { + return isArrayCondition(cond) && cond.value.length > 0; +} + +/** + * Maps a CRUD operation to its corresponding action category. + * + * @param operation - The CRUD operation (List, Read, Create, etc.) + * @returns The action category (CREATE, READ, UPDATE, DELETE) + */ +export function operationToAction(operation: Operation): ActionEnum { + switch (operation) { + case Operation.Create: + case Operation.CreateBatch: + return ActionEnum.CREATE; + case Operation.List: + case Operation.Read: + return ActionEnum.READ; + case Operation.Update: + case Operation.Replace: + case Operation.Restore: + return ActionEnum.UPDATE; + case Operation.Delete: + case Operation.SoftDelete: + return ActionEnum.DELETE; + } +} + +/** + * Type guard to check if an operation is a read operation. + */ +export function isReadOperation( + operation: unknown, +): operation is ReadOperation { + return operation === Operation.List || operation === Operation.Read; +} + +/** + * Type guard to check if an operation requires a body parameter. + */ +export function isBodyOperation(operation: Operation): boolean { + return [ + Operation.Create, + Operation.CreateBatch, + Operation.Update, + Operation.Replace, + ].includes(operation); +} diff --git a/packages/nestjs-crud/src/infrastructure/utils/crud-is-paginated.helper.ts b/packages/nestjs-crud/src/infrastructure/utils/crud-is-paginated.helper.ts new file mode 100644 index 000000000..cd99ef6bd --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/crud-is-paginated.helper.ts @@ -0,0 +1,14 @@ +import { type CrudResponsePaginatedInterface } from '../interfaces/crud-response-paginated.interface.js'; + +export function crudIsPaginatedHelper( + response: object, +): response is CrudResponsePaginatedInterface { + return ( + 'data' in response && + Array.isArray(response.data) === true && + 'count' in response && + 'total' in response && + 'page' in response && + 'pageCount' in response + ); +} diff --git a/packages/nestjs-crud/src/infrastructure/utils/crud-operation-options.type.ts b/packages/nestjs-crud/src/infrastructure/utils/crud-operation-options.type.ts new file mode 100644 index 000000000..c8b8bc632 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/crud-operation-options.type.ts @@ -0,0 +1,22 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type Operation } from '@concepta/nestjs-core'; + +import { + type CrudRouteCommandOptionsInterface, + type CrudRouteQueryOptionsInterface, +} from '../interfaces/crud-route-ctlr-options.interface.js'; + +import { type CrudExtraDecoratorsInterface } from './interfaces/crud-extra-decorators.interface.js'; + +/** + * Operation options type - intersection of base props with union of query/command options. + * + * Each operation specifies an operation type and optionally a custom method name. + * Multiple operations with the same operation are allowed when methodName differs. + */ +export type CrudOperationOptions = { + operation: Operation; + methodName?: string; +} & CrudExtraDecoratorsInterface & + (CrudRouteQueryOptionsInterface | CrudRouteCommandOptionsInterface); diff --git a/packages/nestjs-crud/src/infrastructure/utils/get-transactional-decorators.ts b/packages/nestjs-crud/src/infrastructure/utils/get-transactional-decorators.ts new file mode 100644 index 000000000..92d333e87 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/get-transactional-decorators.ts @@ -0,0 +1,21 @@ +import { + Transactional, + type TransactionalOptions, +} from '@concepta/nestjs-repository'; + +/** + * Resolve a transactional option into an array of method decorators. + * + * @param transactional - The transactional option from operation or route config. + * @returns An array containing the `@Transactional()` decorator, or empty. + */ +export function getTransactionalDecorators( + transactional?: boolean | TransactionalOptions, +): MethodDecorator[] { + if (!transactional) { + return []; + } + + const options = typeof transactional === 'object' ? transactional : undefined; + return [Transactional(options)]; +} diff --git a/packages/nestjs-crud/src/infrastructure/utils/interfaces/configurable-crud-host.interface.ts b/packages/nestjs-crud/src/infrastructure/utils/interfaces/configurable-crud-host.interface.ts new file mode 100644 index 000000000..43737c983 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/interfaces/configurable-crud-host.interface.ts @@ -0,0 +1,45 @@ +import { type Provider, type Type } from '@nestjs/common'; + +/** + * Map of classes by name for runtime access. + */ +export interface ConfigurableCrudClassesMap { + [className: string]: Type; +} + +/** + * Result from ConfigurableCrudBuilder.build(). + * + * Contains categorized maps of generated classes, accessible by their + * generated names via destructuring. + */ +export interface ConfigurableCrudHost { + /** + * All providers needed for the module (adapter, handlers). + */ + providers: Provider[]; + /** + * Controller classes by name. + */ + controllers: ConfigurableCrudClassesMap; + /** + * Query classes by name (for read operations). + */ + queries: ConfigurableCrudClassesMap; + /** + * Query handler classes by name. + */ + queryHandlers: ConfigurableCrudClassesMap; + /** + * Command classes by name (for write operations). + */ + commands: ConfigurableCrudClassesMap; + /** + * Command handler classes by name. + */ + commandHandlers: ConfigurableCrudClassesMap; + /** + * Adapter classes by name. + */ + adapters: ConfigurableCrudClassesMap; +} diff --git a/packages/nestjs-crud/src/infrastructure/utils/interfaces/configurable-crud-options.interface.ts b/packages/nestjs-crud/src/infrastructure/utils/interfaces/configurable-crud-options.interface.ts new file mode 100644 index 000000000..539b39dfb --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/interfaces/configurable-crud-options.interface.ts @@ -0,0 +1,59 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type CrudControllerClassOptionsInterface, + type CrudControllerOptionsInterface, +} from '../../interfaces/crud-controller-options.interface.js'; +import { type CrudOperationOptions } from '../crud-operation-options.type.js'; + +import { type CrudExtraDecoratorsInterface } from './crud-extra-decorators.interface.js'; + +/** + * Options for pre-decorated controller class. + * Operations are read from class metadata. + */ +export interface ConfigurableCrudClassOptions { + controller: CrudControllerClassOptionsInterface & + CrudExtraDecoratorsInterface; +} + +/** + * Options for hybrid controller class with operations. + * + * The class provides the base controller, and operations define which methods + * to augment or create: + * - If method exists with matching operation → augment/override its options + * - If method doesn't exist → create new method with implementation + decorators + */ +export interface ConfigurableCrudHybridOptions< + Entity extends PlainLiteralObject, +> { + controller: CrudControllerClassOptionsInterface & + CrudExtraDecoratorsInterface; + operations: CrudOperationOptions[]; +} + +/** + * Options for generated controller. + * Operations array defines what methods to generate. + */ +export interface ConfigurableCrudGeneratedOptions< + Entity extends PlainLiteralObject, +> { + controller: CrudControllerOptionsInterface & + CrudExtraDecoratorsInterface; + operations: CrudOperationOptions[]; +} + +/** + * Options for configurable CRUD builder. + * + * Either: + * - Pre-decorated class: `{ controller: { class: MyController } }` + * - Hybrid class + operations: `{ controller: { class: MyController }, operations: [...] }` + * - Generated controller: `{ controller: { entity: ..., adapter: ... }, operations: [...] }` + */ +export type ConfigurableCrudOptions = + | ConfigurableCrudClassOptions + | ConfigurableCrudHybridOptions + | ConfigurableCrudGeneratedOptions; diff --git a/packages/nestjs-crud/src/infrastructure/utils/interfaces/crud-extra-decorators.interface.ts b/packages/nestjs-crud/src/infrastructure/utils/interfaces/crud-extra-decorators.interface.ts new file mode 100644 index 000000000..345a656cc --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/interfaces/crud-extra-decorators.interface.ts @@ -0,0 +1,5 @@ +import { type applyDecorators } from '@nestjs/common'; + +export interface CrudExtraDecoratorsInterface { + extraDecorators?: ReturnType[]; +} diff --git a/packages/nestjs-crud/src/infrastructure/utils/swagger.helper.ts b/packages/nestjs-crud/src/infrastructure/utils/swagger.helper.ts new file mode 100644 index 000000000..71ae2b722 --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/swagger.helper.ts @@ -0,0 +1,165 @@ +import * as swagger from '@nestjs/swagger'; + +import { Operation } from '@concepta/nestjs-core'; + +import { CrudQueryBuilder } from '../request/crud-query.builder.js'; + +export { swagger }; + +export class Swagger { + static createQueryParamsMeta(operation: Operation.List | Operation.Read) { + /* istanbul ignore if */ + if (!swagger) { + return []; + } + + const { + fields, + search, + filter, + or, + sort, + limit, + offset, + page, + cache, + includeDeleted, + } = Swagger.getQueryParamsNames(); + const docsLink = `Docs`; + + const fieldsMeta = { + name: fields, + description: `Selects resource fields. ${docsLink}`, + required: false, + in: 'query', + schema: { type: 'array', items: { type: 'string' } }, + style: 'form', + explode: false, + }; + + const searchMeta = { + name: search, + description: `Adds search condition. ${docsLink}`, + required: false, + in: 'query', + schema: { type: 'string' }, + }; + + const filterMeta = { + name: filter, + description: `Adds filter condition. ${docsLink}`, + required: false, + in: 'query', + schema: { type: 'array', items: { type: 'string' } }, + style: 'form', + explode: true, + }; + + const orMeta = { + name: or, + description: `Adds OR condition. ${docsLink}`, + required: false, + in: 'query', + schema: { type: 'array', items: { type: 'string' } }, + style: 'form', + explode: true, + }; + + const sortMeta = { + name: sort, + description: `Adds sort by field. ${docsLink}`, + required: false, + in: 'query', + schema: { type: 'array', items: { type: 'string' } }, + style: 'form', + explode: true, + }; + + const limitMeta = { + name: limit, + description: `Limit amount of resources. ${docsLink}`, + required: false, + in: 'query', + schema: { type: 'integer' }, + }; + + const offsetMeta = { + name: offset, + description: `Offset amount of resources. ${docsLink}`, + required: false, + in: 'query', + schema: { type: 'integer' }, + }; + + const pageMeta = { + name: page, + description: `Page portion of resources. ${docsLink}`, + required: false, + in: 'query', + schema: { type: 'integer' }, + }; + + const cacheMeta = { + name: cache, + description: `Reset cache (if was enabled). ${docsLink}`, + required: false, + in: 'query', + schema: { type: 'integer', minimum: 0, maximum: 1 }, + }; + + const includeDeletedMeta = { + name: includeDeleted, + description: `Include deleted. ${docsLink}`, + required: false, + in: 'query', + schema: { type: 'integer', minimum: 0, maximum: 1 }, + }; + + switch (operation) { + case Operation.List: + return [ + fieldsMeta, + searchMeta, + filterMeta, + orMeta, + sortMeta, + limitMeta, + offsetMeta, + pageMeta, + cacheMeta, + includeDeletedMeta, + ]; + case Operation.Read: + return [fieldsMeta, cacheMeta, includeDeletedMeta]; + default: + return []; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static getQueryParamsNames(): any { + const qbOptions = CrudQueryBuilder.getOptions(); + const name = (n: string) => { + if (qbOptions?.paramNamesMap) { + return qbOptions.paramNamesMap[n][0]; + } else { + return; + } + }; + + return { + delim: qbOptions.delim, + delimStr: qbOptions.delimStr, + fields: name('fields'), + search: name('search'), + filter: name('filter'), + or: name('or'), + sort: name('sort'), + limit: name('limit'), + offset: name('offset'), + page: name('page'), + cache: name('cache'), + includeDeleted: name('includeDeleted'), + }; + } +} diff --git a/packages/nestjs-crud/src/infrastructure/utils/validation.ts b/packages/nestjs-crud/src/infrastructure/utils/validation.ts new file mode 100644 index 000000000..7b05010bf --- /dev/null +++ b/packages/nestjs-crud/src/infrastructure/utils/validation.ts @@ -0,0 +1,33 @@ +import { isNumber } from '@concepta/nestjs-core'; + +export const isStringFull = (val: unknown): val is string => + typeof val === 'string' && val.length > 0; + +export const isArrayStrings = (val: unknown): boolean => + Array.isArray(val) && val.length > 0 && val.every((v) => isStringFull(v)); + +export const isValue = (val: unknown): boolean => + isStringFull(val) || + isNumber(val) || + typeof val === 'boolean' || + val instanceof Date; + +export const hasValue = (val: unknown): boolean => + Array.isArray(val) && val.length > 0 + ? val.every((o) => isValue(o)) + : isValue(val); + +const ISO_DATE_REGEX = + /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/; + +export const isDateString = (val: string): boolean => + isStringFull(val) && ISO_DATE_REGEX.test(val) && !isNaN(Date.parse(val)); + +/** + * Sanitize a user-supplied value for safe inclusion in error messages. + * Strips HTML-sensitive characters and truncates to a reasonable length. + */ +export const sanitizeForMessage = (val: unknown, maxLength = 100): string => + String(val) + .replace(/[<>"'&`\r\n\0]/g, '') + .substring(0, maxLength); diff --git a/packages/nestjs-crud/src/interfaces/crud-module-for-feature-options.interface.ts b/packages/nestjs-crud/src/interfaces/crud-module-for-feature-options.interface.ts deleted file mode 100644 index 01bdbf436..000000000 --- a/packages/nestjs-crud/src/interfaces/crud-module-for-feature-options.interface.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { PlainLiteralObject, Type } from '@nestjs/common'; - -import { CrudAdapter } from '../crud/adapters/crud.adapter'; -import { CrudControllerOptionsInterface } from '../crud/interfaces/crud-controller-options.interface'; -import { CrudExtraDecoratorsInterface } from '../crud/interfaces/crud-extra-decorators.interface'; -import { CrudService } from '../services/crud.service'; -import { ConfigurableCrudOptions } from '../util/interfaces/configurable-crud-options.interface'; - -import { CrudModuleOptionsInterface } from './crud-module-options.interface'; - -/** - * Controller configuration type - either a class or config object for ConfigurableCrudBuilder - */ -type CrudForFeatureControllerOption = - | Type - | (CrudControllerOptionsInterface & CrudExtraDecoratorsInterface); - -/** - * Common configuration options shared by all CRUD feature variants - */ -interface CrudForFeatureCommonOptions - extends Omit, 'service' | 'controller'> { - /** - * The entity class - */ - entity: Type; - - /** - * Controller - either a class or config object for ConfigurableCrudBuilder - */ - controller: CrudForFeatureControllerOption; - - /** - * Optional custom service class extending CrudService - */ - service?: Type>; -} - -/** - * Configuration with adapter - generates service if not provided - */ -interface CrudForFeatureWithAdapterOptions - extends CrudForFeatureCommonOptions { - /** - * The CRUD adapter class to use (e.g., TypeOrmCrudAdapter) - */ - adapter: Type>; -} - -/** - * Configuration with service only - no adapter needed - * Use when service is self-contained and doesn't need an adapter - */ -interface CrudForFeatureWithServiceOptions - extends CrudForFeatureCommonOptions { - /** - * Adapter not allowed when using service-only config - */ - adapter?: undefined; - - /** - * Required custom service class (since no adapter to generate one) - */ - service: Type>; -} - -/** - * Configuration options for CRUD feature registration - * Either adapter or service must be provided - */ -type CrudForFeatureCrudsOption< - Entity extends PlainLiteralObject = PlainLiteralObject, -> = - | CrudForFeatureWithAdapterOptions - | CrudForFeatureWithServiceOptions; - -/** - * Infer Entity type from config and resolve to CrudForFeatureCrudsOption - */ -type CrudForFeatureCrudsOptionInfer = T extends { - entity: Type; -} - ? CrudForFeatureCrudsOption - : never; - -/** - * Base constraint for forFeature configurations (allows inference) - */ -export interface CrudForFeatureCrudsOptionInterface - extends Partial< - Omit, 'service' | 'controller'> - > { - entity: Type; - adapter?: Type; - service?: Type; - controller: CrudForFeatureControllerOption; -} - -/** - * Options for CrudModule.forFeature - * Uses inference to validate each config against CrudModuleForFeatureCrudsOption - */ -export interface CrudModuleForFeatureOptionsInterface< - TCruds extends Record, -> extends CrudModuleOptionsInterface { - /** - * CRUD configurations keyed by entity key - * Each config is validated against CrudModuleForFeatureCrudsOption with inferred Entity - */ - cruds?: { - [K in keyof TCruds]: CrudForFeatureCrudsOptionInfer; - }; -} diff --git a/packages/nestjs-crud/src/interfaces/crud-module-options-extras.interface.ts b/packages/nestjs-crud/src/interfaces/crud-module-options-extras.interface.ts deleted file mode 100644 index 1b443acfa..000000000 --- a/packages/nestjs-crud/src/interfaces/crud-module-options-extras.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface CrudModuleOptionsExtrasInterface - extends Pick {} diff --git a/packages/nestjs-crud/src/interfaces/crud-module-options.interface.ts b/packages/nestjs-crud/src/interfaces/crud-module-options.interface.ts deleted file mode 100644 index 9bad37705..000000000 --- a/packages/nestjs-crud/src/interfaces/crud-module-options.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CrudModuleSettingsInterface } from './crud-module-settings.interface'; - -export interface CrudModuleOptionsInterface { - settings?: CrudModuleSettingsInterface; -} diff --git a/packages/nestjs-crud/src/interfaces/crud-module-settings.interface.ts b/packages/nestjs-crud/src/interfaces/crud-module-settings.interface.ts deleted file mode 100644 index 7aa3e5516..000000000 --- a/packages/nestjs-crud/src/interfaces/crud-module-settings.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CrudSerializationOptionsInterface } from '../crud/interfaces/crud-serialization-options.interface'; - -export interface CrudModuleSettingsInterface { - serialization?: CrudSerializationOptionsInterface; -} diff --git a/packages/nestjs-crud/src/request/crud-request-query.builder.spec.ts b/packages/nestjs-crud/src/request/crud-request-query.builder.spec.ts deleted file mode 100644 index e17c2b359..000000000 --- a/packages/nestjs-crud/src/request/crud-request-query.builder.spec.ts +++ /dev/null @@ -1,382 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import 'jest-extended'; -import { CrudRequestQueryBuilder } from './crud-request-query.builder'; -import { CrudRequestQueryException } from './exceptions/crud-request-query.exception'; -import { CrudRequestQueryBuilderOptionsInterface } from './interfaces/crud-request-query-builder-options.interface'; - -const defaultOptions = { ...(CrudRequestQueryBuilder as any)._options }; - -describe('#request-query', () => { - describe('#RequestQueryBuilder', () => { - let qb: CrudRequestQueryBuilder; - - beforeEach(() => { - qb = CrudRequestQueryBuilder.create(); - }); - - afterEach(() => { - (CrudRequestQueryBuilder as any)._options = defaultOptions; - }); - - it('should be a function', () => { - expect(typeof CrudRequestQueryBuilder).toBe('function'); - }); - - describe('#static setOptions', () => { - it('should merge options, 1', () => { - const override = 'override'; - const options: CrudRequestQueryBuilderOptionsInterface = { - paramNamesMap: { fields: [override] }, - }; - CrudRequestQueryBuilder.setOptions(options); - const paramNamesMap = (CrudRequestQueryBuilder as any)._options - .paramNamesMap; - expect(paramNamesMap.fields[0]).toBe(override); - expect(paramNamesMap.page).toBe('page'); - }); - it('should merge options, 2', () => { - const override = 'override'; - CrudRequestQueryBuilder.setOptions({ delim: override }); - const _options = (CrudRequestQueryBuilder as any)._options; - expect(_options.delim).toBe(override); - }); - }); - - describe('#select', () => { - it('should not throw', () => { - (qb as any).select(); - expect(qb.queryObject.fields).toBeUndefined(); - }); - it('should throw an error', () => { - expect((qb.select as any).bind(qb, [false])).toThrow( - CrudRequestQueryException, - ); - }); - it('should set fields', () => { - qb.select(['foo', 'bar']); - const expected = 'foo,bar'; - expect(qb.queryObject.fields).toBe(expected); - }); - }); - - describe('#setFilter', () => { - it('should not throw', () => { - (qb as any).setFilter(); - expect(qb.queryObject.filter).toBeUndefined(); - }); - it('should throw an error, 1', () => { - expect((qb.setFilter as any).bind(qb, { field: 1 })).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 2', () => { - expect( - (qb.setFilter as any).bind(qb, { field: 'foo', operator: '$bar' }), - ).toThrow(CrudRequestQueryException); - }); - it('should throw an error, 3', () => { - expect((qb.setFilter as any).bind(qb, [{}])).toThrow( - CrudRequestQueryException, - ); - }); - it('should set filter, 1', () => { - qb.setFilter({ field: 'foo', operator: '$eq', value: 'bar' }); - const expected = ['foo||$eq||bar']; - expect(qb.queryObject.filter).toIncludeSameMembers(expected); - }); - it('should set filter, 2', () => { - qb.setFilter([ - { field: 'foo', operator: '$eq', value: 'bar' }, - { field: 'baz', operator: '$ne', value: 'zoo' }, - ]); - const expected = ['foo||$eq||bar', 'baz||$ne||zoo']; - expect(qb.queryObject.filter).toIncludeSameMembers(expected); - }); - it('should set filter, 3', () => { - qb.setFilter([ - ['foo', '$eq', 'bar'], - { field: 'baz', operator: '$ne', value: 'zoo' }, - ]); - const expected = ['foo||$eq||bar', 'baz||$ne||zoo']; - expect(qb.queryObject.filter).toIncludeSameMembers(expected); - }); - it('should set filter, 4', () => { - qb.setFilter([ - ['foo', '$eq', 'bar'], - ['baz', '$ne', 'zoo'], - ]); - const expected = ['foo||$eq||bar', 'baz||$ne||zoo']; - expect(qb.queryObject.filter).toIncludeSameMembers(expected); - }); - it('should set filter, 5', () => { - qb.setFilter(['foo', '$eq', 'bar']); - const expected = ['foo||$eq||bar']; - expect(qb.queryObject.filter).toIncludeSameMembers(expected); - }); - }); - - describe('#setOr', () => { - it('should not throw', () => { - (qb as any).setOr(); - expect(qb.queryObject.or).toBeUndefined(); - }); - it('should throw an error, 1', () => { - expect((qb.setOr as any).bind(qb, { field: 1 })).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 2', () => { - expect( - (qb.setOr as any).bind(qb, { field: 'foo', operator: '$bar' }), - ).toThrow(CrudRequestQueryException); - }); - it('should throw an error, 3', () => { - expect((qb.setOr as any).bind(qb, [{}])).toThrow( - CrudRequestQueryException, - ); - }); - it('should set or, 1', () => { - qb.setOr({ field: 'foo', operator: '$eq', value: 'bar' }); - const expected = ['foo||$eq||bar']; - expect(qb.queryObject.or).toIncludeSameMembers(expected); - }); - it('should set or, 2', () => { - qb.setOr([ - { field: 'foo', operator: '$eq', value: 'bar' }, - { field: 'baz', operator: '$ne', value: 'zoo' }, - ]); - const expected = ['foo||$eq||bar', 'baz||$ne||zoo']; - expect(qb.queryObject.or).toIncludeSameMembers(expected); - }); - }); - - describe('#sortBy', () => { - it('should not throw', () => { - (qb as any).sortBy(); - expect(qb.queryObject.sort).toBeUndefined(); - }); - it('should throw an error, 1', () => { - expect((qb.sortBy as any).bind(qb, { field: 1 })).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 2', () => { - expect( - (qb.sortBy as any).bind(qb, { field: 'foo', order: 'bar' }), - ).toThrow(CrudRequestQueryException); - }); - it('should throw an error, 3', () => { - expect((qb.sortBy as any).bind(qb, [{}])).toThrow( - CrudRequestQueryException, - ); - }); - it('should set sort, 1', () => { - qb.sortBy({ field: 'foo', order: 'ASC' }); - const expected = ['foo,ASC']; - expect(qb.queryObject.sort).toIncludeSameMembers(expected); - }); - it('should set sort, 2', () => { - qb.sortBy([ - { field: 'foo', order: 'ASC' }, - { field: 'bar', order: 'DESC' }, - ]); - const expected = ['foo,ASC', 'bar,DESC']; - expect(qb.queryObject.sort).toIncludeSameMembers(expected); - }); - it('should set sort, 3', () => { - qb.sortBy(['foo', 'ASC']); - const expected = ['foo,ASC']; - expect(qb.queryObject.sort).toIncludeSameMembers(expected); - }); - it('should set sort, 4', () => { - qb.sortBy([['foo', 'ASC']]); - const expected = ['foo,ASC']; - expect(qb.queryObject.sort).toIncludeSameMembers(expected); - }); - it('should set sort, 5', () => { - qb.sortBy([{ field: 'bar', order: 'DESC' }, ['foo', 'ASC']]); - const expected = ['bar,DESC', 'foo,ASC']; - expect(qb.queryObject.sort).toIncludeSameMembers(expected); - }); - }); - - describe('#setLimit', () => { - it('should not throw', () => { - (qb as any).setLimit(); - expect(qb.queryObject.limit).toBeUndefined(); - }); - it('should throw an error', () => { - expect((qb.setLimit as any).bind(qb, {})).toThrow( - CrudRequestQueryException, - ); - }); - it('should set limit', () => { - const expected = 10; - qb.setLimit(expected); - expect(qb.queryObject.limit).toBe(expected); - }); - }); - - describe('#setOffset', () => { - it('should not throw', () => { - (qb as any).setOffset(); - expect(qb.queryObject.offset).toBeUndefined(); - }); - it('should throw an error', () => { - expect((qb.setOffset as any).bind(qb, {})).toThrow( - CrudRequestQueryException, - ); - }); - it('should set offset', () => { - const expected = 10; - qb.setOffset(expected); - expect(qb.queryObject.offset).toBe(expected); - }); - }); - - describe('#setPage', () => { - it('should not throw', () => { - (qb as any).setPage(); - expect(qb.queryObject.page).toBeUndefined(); - }); - it('should throw an error', () => { - expect((qb.setPage as any).bind(qb, {})).toThrow( - CrudRequestQueryException, - ); - }); - it('should set page', () => { - const expected = 10; - qb.setPage(expected); - expect(qb.queryObject.page).toBe(expected); - }); - }); - - describe('#resetCache', () => { - it('should set cache', () => { - expect(qb.queryObject.cache).toBeUndefined(); - qb.resetCache(); - expect(qb.queryObject.cache).toBe(0); - }); - }); - - describe('#cond', () => { - it('should throw an error, 1', () => { - expect(qb.cond as any).toThrow(CrudRequestQueryException); - }); - it('should throw an error, 2', () => { - expect((qb.cond as any).bind(qb, {})).toThrow( - CrudRequestQueryException, - ); - }); - it('should return a filter string from an object', () => { - const test = qb.cond({ field: 'foo', operator: '$eq', value: 'bar' }); - const expected = 'foo||$eq||bar'; - expect(test).toBe(expected); - }); - it('should return a filter string from an array', () => { - const test = qb.cond(['foo', '$eq', 'bar']); - const expected = 'foo||$eq||bar'; - expect(test).toBe(expected); - }); - }); - - describe('#query', () => { - it('should return an empty string', () => { - expect(qb.query()).toBe(''); - }); - it('should return query with overrided fields name', () => { - CrudRequestQueryBuilder.setOptions({ - paramNamesMap: { fields: ['override'] }, - }); - qb.setParamNames(); - const test = qb.select(['foo', 'bar']).query(); - const test2 = qb.select(['foo', 'bar']).query(false); - const expected = 'override=foo%2Cbar'; - const expected2 = 'override=foo,bar'; - expect(test).toBe(expected); - expect(test2).toBe(expected2); - }); - it('should return valid query string with filters', () => { - const test = qb - .select(['foo', 'bar']) - .setFilter([ - { field: 'is', operator: '$notnull' }, - { field: 'foo', operator: '$lt', value: 10 }, - ]) - .query(false); - const expected = - 'fields=foo,bar&filter[0]=is||$notnull&filter[1]=foo||$lt||10'; - expect(test).toBe(expected); - }); - it('should return a valid query string', () => { - const test = qb - .select(['foo', 'bar']) - .setFilter(['is', '$notnull']) - .setOr({ field: 'ok', operator: '$ne', value: false }) - .setLimit(1) - .setOffset(2) - .setPage(3) - .sortBy({ field: 'foo', order: 'DESC' }) - .resetCache() - .setIncludeDeleted(1) - .query(false); - const expected = - 'fields=foo,bar&filter[0]=is||$notnull&or[0]=ok||$ne||false&limit=1&offset=2&page=3&sort[0]=foo,DESC&cache=0&include_deleted=1'; - expect(test).toBe(expected); - }); - }); - - describe('#search', () => { - it('should not throw, 1', () => { - (qb as any).search(); - expect(qb.queryObject.search).toBeUndefined(); - }); - it('should not throw, 2', () => { - (qb as any).search(false); - expect(qb.queryObject.search).toBeUndefined(); - }); - it('should set search string, 1', () => { - const test = qb - .search({ $or: [{ id: 1 }, { name: 'foo' }] }) - .query(false); - const expected = 's={"$or":[{"id":1},{"name":"foo"}]}'; - expect(test).toBe(expected); - }); - it('should set search string, 2', () => { - const test = qb.search({ $or: [{ id: 1 }, { name: 'foo' }] }).query(); - const expected = - 's=%7B%22%24or%22%3A%5B%7B%22id%22%3A1%7D%2C%7B%22name%22%3A%22foo%22%7D%5D%7D'; - expect(test).toBe(expected); - }); - }); - - describe('#createFromParams', () => { - it('should return an empty query string', () => { - const test = CrudRequestQueryBuilder.create().query(); - expect(test).toBe(''); - }); - it('should return a valid query string, 1', () => { - const test = CrudRequestQueryBuilder.create({ - fields: ['foo', 'bar'], - filter: ['is', '$notnull'], - or: { field: 'ok', operator: '$ne', value: false }, - limit: 1, - offset: 2, - page: 3, - sort: [['foo', 'DESC']], - resetCache: true, - }).query(false); - const expected = - 'fields=foo,bar&filter[0]=is||$notnull&or[0]=ok||$ne||false&limit=1&offset=2&page=3&sort[0]=foo,DESC&cache=0'; - expect(test).toBe(expected); - }); - it('should return a valid query string, 2', () => { - const test = CrudRequestQueryBuilder.create({ - fields: ['foo', 'bar'], - }).query(false); - const expected = 'fields=foo,bar'; - expect(test).toBe(expected); - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/request/crud-request-query.builder.ts b/packages/nestjs-crud/src/request/crud-request-query.builder.ts deleted file mode 100644 index 5ff8fd917..000000000 --- a/packages/nestjs-crud/src/request/crud-request-query.builder.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { stringify } from 'qs'; - -import { PlainLiteralObject } from '@nestjs/common'; -import { - isNil, - isObject, - isString, - isUndefined, -} from '@nestjs/common/utils/shared.utils'; - -import { hasValue } from '../util/validation'; - -import { - validateCondition, - validateFields, - validateNumeric, - validateSort, -} from './crud-request-query.validator'; -import { CrudCreateQueryParamsInterface } from './interfaces/crud-create-query-params.interface'; -import { CrudRequestQueryBuilderOptionsInterface } from './interfaces/crud-request-query-builder-options.interface'; -import { - QueryFields, - QueryFilter, - QueryFilterArr, - QuerySort, - QuerySortArr, - SCondition, -} from './types/crud-request-query.types'; - -// tslint:disable:variable-name ban-types -export class CrudRequestQueryBuilder< - Entity extends PlainLiteralObject = PlainLiteralObject, -> { - private static _options: Required & { - paramNamesMap: Required< - CrudRequestQueryBuilderOptionsInterface['paramNamesMap'] - > & { - [key: string]: string | string[]; - }; - } = { - delim: '||', - delimStr: ',', - paramNamesMap: { - fields: ['fields', 'select'], - search: 's', - filter: 'filter', - or: 'or', - sort: 'sort', - limit: ['limit', 'per_page'], - offset: 'offset', - page: 'page', - cache: 'cache', - includeDeleted: 'include_deleted', - }, - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public queryObject: Record = {}; - - public queryString = ''; - - private paramNames: Record = {}; - - constructor() { - this.setParamNames(); - } - - static setOptions(options: CrudRequestQueryBuilderOptionsInterface) { - CrudRequestQueryBuilder._options = { - ...CrudRequestQueryBuilder._options, - ...options, - paramNamesMap: { - ...CrudRequestQueryBuilder._options.paramNamesMap, - ...(options.paramNamesMap ? options.paramNamesMap : {}), - }, - }; - } - - static getOptions() { - return CrudRequestQueryBuilder._options; - } - - static create( - params?: CrudCreateQueryParamsInterface, - ): CrudRequestQueryBuilder { - const qb = new CrudRequestQueryBuilder(); - return isObject(params) && params !== undefined - ? qb.createFromParams(params) - : qb; - } - - get options(): CrudRequestQueryBuilderOptionsInterface { - return CrudRequestQueryBuilder._options; - } - - setParamNames() { - Object.keys(CrudRequestQueryBuilder._options.paramNamesMap).forEach( - (key) => { - const name = CrudRequestQueryBuilder._options.paramNamesMap[key]; - this.paramNames[key] = isString(name) ? name : name[0]; - }, - ); - } - - getParamNames() { - Object.keys(CrudRequestQueryBuilder._options.paramNamesMap).forEach( - (key) => { - const name = CrudRequestQueryBuilder._options.paramNamesMap[key]; - this.paramNames[key] = isString(name) ? name : name[0]; - }, - ); - } - - query(encode = true): string { - if (this.paramNames.search && this.queryObject[this.paramNames.search]) { - if (this.paramNames.filter) { - this.queryObject[this.paramNames.filter] = undefined; - } - - if (this.paramNames.or) { - this.queryObject[this.paramNames.or] = undefined; - } - } - this.queryString = stringify(this.queryObject, { encode }); - - return this.queryString; - } - - select(fields: QueryFields): this { - if (Array.isArray(fields) && fields.length && this.paramNames.fields) { - validateFields(fields); - this.queryObject[this.paramNames.fields] = fields.join( - this.options.delimStr, - ); - } - return this; - } - - search(s: SCondition) { - if (!isNil(s) && isObject(s) && this.paramNames.search) { - this.queryObject[this.paramNames.search] = JSON.stringify(s); - } - return this; - } - - setFilter( - f: - | QueryFilter - | QueryFilterArr - | Array | QueryFilterArr>, - ): this { - this.setCondition(f, 'filter'); - return this; - } - - setOr( - f: - | QueryFilter - | QueryFilterArr - | Array | QueryFilterArr>, - ): this { - this.setCondition(f, 'or'); - return this; - } - - sortBy( - s: - | QuerySort - | QuerySortArr - | Array | QuerySortArr>, - ): this { - if (!isNil(s)) { - const param = this.checkQueryObjectParam('sort', []); - if (param) { - this.queryObject[param] = [ - ...this.queryObject[param], - ...(Array.isArray(s) && !isString(s[0]) - ? (s as Array | QuerySortArr>).map((o) => - this.addSortBy(o), - ) - : [this.addSortBy(s as QuerySort | QuerySortArr)]), - ]; - } - } - return this; - } - - setLimit(n: number): this { - this.setNumeric(n, 'limit'); - return this; - } - - setOffset(n: number): this { - this.setNumeric(n, 'offset'); - return this; - } - - setPage(n: number): this { - this.setNumeric(n, 'page'); - return this; - } - - resetCache(): this { - this.setNumeric(0, 'cache'); - return this; - } - - setIncludeDeleted(n: number): this { - this.setNumeric(n, 'includeDeleted'); - return this; - } - - cond( - f: QueryFilter | QueryFilterArr, - cond: 'filter' | 'or' | 'search' = 'search', - ): string { - const filter = Array.isArray(f) - ? { field: f[0], operator: f[1], value: f[2] } - : f; - validateCondition(filter, cond); - const d = this.options.delim ?? CrudRequestQueryBuilder._options.delim; - - return ( - filter.field + - d + - filter.operator + - (hasValue(filter.value) ? d + filter.value : '') - ); - } - - private addSortBy(s: QuerySort | QuerySortArr): string { - const sort: QuerySort = Array.isArray(s) - ? { field: s[0], order: s[1] } - : s; - validateSort(sort); - const ds = this.options.delimStr; - - return sort.field + ds + sort.order; - } - - private createFromParams(params: CrudCreateQueryParamsInterface): this { - if (params.fields) { - this.select(params.fields); - } - - if (params.search) { - this.search(params.search); - } - - if (params.filter) { - this.setFilter(params.filter); - } - - if (params.or) { - this.setOr(params.or); - } - - if (params.limit) { - this.setLimit(params.limit); - } - - if (params.offset) { - this.setOffset(params.offset); - } - - if (params.page) { - this.setPage(params.page); - } - - if (params.sort) { - this.sortBy(params.sort); - } - - if (params.resetCache) { - this.resetCache(); - } - - if (params.includeDeleted) { - this.setIncludeDeleted(params.includeDeleted); - } - - return this; - } - - private checkQueryObjectParam( - cond: keyof NonNullable< - CrudRequestQueryBuilderOptionsInterface['paramNamesMap'] - >, - defaults: unknown, - ): string | undefined { - const param = this.paramNames[cond]; - - if (param && isNil(this.queryObject[param]) && !isUndefined(defaults)) { - this.queryObject[param] = defaults; - } - - return param; - } - - private setCondition( - f: - | QueryFilter - | QueryFilterArr - | Array | QueryFilterArr>, - cond: 'filter' | 'or', - ): void { - if (!isNil(f)) { - const param = this.checkQueryObjectParam(cond, []); - if (param) { - this.queryObject[param] = [ - ...this.queryObject[param], - ...(Array.isArray(f) && !isString(f[0]) - ? (f as Array | QueryFilterArr>).map( - (o) => this.cond(o, cond), - ) - : [ - this.cond( - f as QueryFilter | QueryFilterArr, - cond, - ), - ]), - ]; - } - } - } - - private setNumeric( - n: number, - cond: 'limit' | 'offset' | 'page' | 'cache' | 'includeDeleted', - ): void { - if (!isNil(n)) { - validateNumeric(n, cond); - const condParam = this.paramNames[cond]; - if (typeof condParam === 'string') { - this.queryObject[condParam] = n; - } - } - } -} diff --git a/packages/nestjs-crud/src/request/crud-request-query.contants.ts b/packages/nestjs-crud/src/request/crud-request-query.contants.ts deleted file mode 100644 index c6efadd15..000000000 --- a/packages/nestjs-crud/src/request/crud-request-query.contants.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ComparisonOperator } from './types/crud-request-query.types'; - -export const COMPARISON_OPERATORS: ReadonlyArray = [ - '$eq', - '$ne', - '$gt', - '$lt', - '$gte', - '$lte', - '$starts', - '$ends', - '$cont', - '$excl', - '$in', - '$notin', - '$between', - '$isnull', - '$notnull', - '$eqL', - '$neL', - '$startsL', - '$endsL', - '$contL', - '$exclL', - '$inL', - '$notinL', - '$or', - '$and', -] as const; diff --git a/packages/nestjs-crud/src/request/crud-request-query.parser.spec.ts b/packages/nestjs-crud/src/request/crud-request-query.parser.spec.ts deleted file mode 100644 index 7fb0f4536..000000000 --- a/packages/nestjs-crud/src/request/crud-request-query.parser.spec.ts +++ /dev/null @@ -1,531 +0,0 @@ -import 'jest-extended'; -import { CrudRequestQueryParser } from './crud-request-query.parser'; -import { CrudRequestQueryException } from './exceptions/crud-request-query.exception'; -import { CrudRequestParamsOptionsInterface } from './interfaces/crud-request-params-options.interface'; -import { CrudRequestParsedParamsInterface } from './interfaces/crud-request-parsed-params.interface'; -import { QueryFilter, QuerySort } from './types/crud-request-query.types'; - -class TestEntity { - foo!: unknown; - bar!: unknown; - baz!: unknown; - bigInt!: number; -} - -describe('#request-query', () => { - describe('RequestQueryParser', () => { - let qp: CrudRequestQueryParser; - - beforeEach(() => { - qp = CrudRequestQueryParser.create(); - }); - - describe('#parseQury', () => { - it('should return instance of RequestQueryParse', () => { - expect(qp.parseQuery({})).toBeInstanceOf(CrudRequestQueryParser); - }); - - describe('#parse fields', () => { - it('should set empty array, 1', () => { - const query = { select: '' }; - const expected: [] = []; - const test = qp.parseQuery(query); - expect(test.fields).toMatchObject(expected); - }); - it('should set empty array, 2', () => { - const query = { foo: '' }; - const expected: [] = []; - const test = qp.parseQuery(query); - expect(test.fields).toMatchObject(expected); - }); - it('should set array, 1', () => { - const query = { select: 'foo' }; - const expected = ['foo']; - const test = qp.parseQuery(query); - expect(test.fields).toMatchObject(expected); - }); - it('should set array, 2', () => { - const query = { select: 'foo,bar' }; - const expected = ['foo', 'bar']; - const test = qp.parseQuery(query); - expect(test.fields).toMatchObject(expected); - }); - }); - - describe('#parse filter', () => { - it('should set empty array, 1', () => { - const query = { filter: '' }; - const expected: [] = []; - const test = qp.parseQuery(query); - expect(test.filter).toMatchObject(expected); - }); - it('should set empty array, 2', () => { - const query = { foo: '' }; - const expected: [] = []; - const test = qp.parseQuery(query); - expect(test.filter).toMatchObject(expected); - }); - it('should throw an error, 1', () => { - const query = { filter: 'foo||$invalid||bar' }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 2', () => { - const query = { filter: 'foo||$eq' }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should set array, 1', () => { - const query = { filter: 'foo||$eq||bar' }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$eq', value: 'bar' }, - ]; - const test = qp.parseQuery(query); - expect(test.filter[0]).toMatchObject(expected[0]); - }); - it('should set array, 2', () => { - const query = { filter: ['foo||$eq||bar', 'baz||$ne||boo'] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$eq', value: 'bar' }, - { field: 'baz', operator: '$ne', value: 'boo' }, - ]; - const test = qp.parseQuery(query); - expect(test.filter[0]).toMatchObject(expected[0]); - expect(test.filter[1]).toMatchObject(expected[1]); - }); - it('should set array, 3', () => { - const query = { filter: ['foo||$in||1,2'] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$in', value: [1, 2] }, - ]; - const test = qp.parseQuery(query); - expect(test.filter[0]).toMatchObject(expected[0]); - }); - it('should set array, 4', () => { - const query = { filter: ['foo||$isnull'] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$isnull', value: '' }, - ]; - const test = qp.parseQuery(query); - expect(test.filter[0]).toMatchObject(expected[0]); - }); - it('should set array, 5', () => { - const query = { filter: ['foo||$eq||{"foo":true}'] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$eq', value: '{"foo":true}' }, - ]; - const test = qp.parseQuery(query); - expect(test.filter[0]).toMatchObject(expected[0]); - }); - it('should set array, 6', () => { - const query = { filter: ['foo||$eq||1'] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$eq', value: 1 }, - ]; - const test = qp.parseQuery(query); - expect(test.filter[0]).toMatchObject(expected[0]); - }); - it('should set date, 7', () => { - const now = new Date(); - const query = { filter: [`foo||$eq||${now.toJSON()}`] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$eq', value: now }, - ]; - const test = qp.parseQuery(query); - expect(test.filter[0]).toMatchObject(expected[0]); - }); - it('should set false, 8', () => { - const query = { filter: ['foo||$eq||false'] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$eq', value: false }, - ]; - const test = qp.parseQuery(query); - expect(test.filter[0]).toMatchObject(expected[0]); - }); - it('should set true, 9', () => { - const query = { filter: ['foo||$eq||true'] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$eq', value: true }, - ]; - const test = qp.parseQuery(query); - expect(test.filter[0]).toMatchObject(expected[0]); - }); - it('should set number, 10', () => { - const query = { filter: ['foo||$eq||12345'] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$eq', value: 12345 }, - ]; - const test = qp.parseQuery(query); - expect(test.filter[0]).toMatchObject(expected[0]); - }); - it('should set string, 11', () => { - const query = { - filter: ['foo||$eq||4202140192612927005304000000236630'], - }; - const expected: QueryFilter[] = [ - { - field: 'foo', - operator: '$eq', - value: '4202140192612927005304000000236630', - }, - ]; - const test = qp.parseQuery(query); - expect(test.filter[0]).toMatchObject(expected[0]); - }); - }); - - describe('#parse or', () => { - it('should set empty array, 1', () => { - const query = { or: '' }; - const expected: [] = []; - const test = qp.parseQuery(query); - expect(test.or).toMatchObject(expected); - }); - it('should set empty array, 2', () => { - const query = { foo: '' }; - const expected: [] = []; - const test = qp.parseQuery(query); - expect(test.or).toMatchObject(expected); - }); - it('should throw an error, 1', () => { - const query = { or: 'foo||$invalid||bar' }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 2', () => { - const query = { or: 'foo||$eq' }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should set array, 1', () => { - const query = { or: 'foo||$eq||bar' }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$eq', value: 'bar' }, - ]; - const test = qp.parseQuery(query); - expect(test.or[0]).toMatchObject(expected[0]); - }); - it('should set array, 2', () => { - const query = { or: ['foo||$eq||bar', 'baz||$ne||boo'] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$eq', value: 'bar' }, - { field: 'baz', operator: '$ne', value: 'boo' }, - ]; - const test = qp.parseQuery(query); - expect(test.or[0]).toMatchObject(expected[0]); - expect(test.or[1]).toMatchObject(expected[1]); - }); - it('should set array, 3', () => { - const query = { or: ['foo||$in||1,2'] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$in', value: [1, 2] }, - ]; - const test = qp.parseQuery(query); - expect(test.or[0]).toMatchObject(expected[0]); - }); - it('should set array, 4', () => { - const query = { or: ['foo||$isnull'] }; - const expected: QueryFilter[] = [ - { field: 'foo', operator: '$isnull', value: '' }, - ]; - const test = qp.parseQuery(query); - expect(test.or[0]).toMatchObject(expected[0]); - }); - }); - - describe('#parse sort', () => { - it('should set empty array, 1', () => { - const query = { sort: '' }; - const expected: [] = []; - const test = qp.parseQuery(query); - expect(test.sort).toMatchObject(expected); - }); - it('should set empty array, 2', () => { - const query = { foo: '' }; - const expected: [] = []; - const test = qp.parseQuery(query); - expect(test.sort).toMatchObject(expected); - }); - it('should throw an error, 1', () => { - const query = { sort: 'foo' }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 2', () => { - const query = { sort: 'foo,boo' }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should set array', () => { - const query = { sort: ['foo,ASC', 'bar,DESC'] }; - const expected: QuerySort[] = [ - { field: 'foo', order: 'ASC' }, - { field: 'bar', order: 'DESC' }, - ]; - const test = qp.parseQuery(query); - expect(test.sort[0]).toMatchObject(expected[0]); - expect(test.sort[1]).toMatchObject(expected[1]); - }); - }); - - describe('#parse limit', () => { - it('should set undefined, 1', () => { - const query = { limit: '' }; - const test = qp.parseQuery(query); - expect(test.limit).toBeUndefined(); - }); - it('should set undefined, 2', () => { - const query = { foo: '' }; - const test = qp.parseQuery(query); - expect(test.limit).toBeUndefined(); - }); - it('should throw an error', () => { - const query = { limit: 'a' }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should set value', () => { - const query = { limit: '10' }; - const expected = 10; - const test = qp.parseQuery(query); - expect(test.limit).toBe(expected); - }); - }); - - describe('#parse offset', () => { - it('should set undefined, 1', () => { - const query = { offset: '' }; - const test = qp.parseQuery(query); - expect(test.offset).toBeUndefined(); - }); - it('should set undefined, 2', () => { - const query = { foo: '' }; - const test = qp.parseQuery(query); - expect(test.offset).toBeUndefined(); - }); - it('should throw an error', () => { - const query = { offset: 'a' }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should set value', () => { - const query = { offset: '10' }; - const expected = 10; - const test = qp.parseQuery(query); - expect(test.offset).toBe(expected); - }); - }); - - describe('#parse page', () => { - it('should set undefined, 1', () => { - const query = { page: '' }; - const test = qp.parseQuery(query); - expect(test.page).toBeUndefined(); - }); - it('should set undefined, 2', () => { - const query = { foo: '' }; - const test = qp.parseQuery(query); - expect(test.page).toBeUndefined(); - }); - it('should throw an error', () => { - const query = { page: ['a'] }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should set value', () => { - const query = { page: ['10'] }; - const expected = 10; - const test = qp.parseQuery(query); - expect(test.page).toBe(expected); - }); - }); - - describe('#parse cache', () => { - it('should set undefined, 1', () => { - const query = { cache: '' }; - const test = qp.parseQuery(query); - expect(test.cache).toBeUndefined(); - }); - it('should set undefined, 2', () => { - const query = { foo: '' }; - const test = qp.parseQuery(query); - expect(test.cache).toBeUndefined(); - }); - it('should throw an error', () => { - const query = { cache: ['a'] }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should set value', () => { - const query = { cache: ['10'] }; - const expected = 10; - const test = qp.parseQuery(query); - expect(test.cache).toBe(expected); - }); - }); - }); - - describe('#parse search', () => { - it('should set undefined', () => { - const query = { foo: '' }; - const test = qp.parseQuery(query); - expect(test.search).toBeUndefined(); - }); - it('should throw an error, 1', () => { - const query = { s: 'invalid' }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 2', () => { - const query = { s: 'true' }; - expect(qp.parseQuery.bind(qp, query)).toThrow( - CrudRequestQueryException, - ); - }); - it('should parse search', () => { - const query = { s: '{"$or":[{"id":1},{"name":"foo"}]}' }; - const expected = { $or: [{ id: 1 }, { name: 'foo' }] }; - const test = qp.parseQuery(query); - expect(test.search).toMatchObject(expected); - }); - }); - - describe('#parseParams', () => { - it('should return instance of RequestQueryParse', () => { - expect(qp.parseParams({}, {})).toBeInstanceOf(CrudRequestQueryParser); - }); - it('should throw an error, 1', () => { - const params = { foo: 'bar' }; - const options: CrudRequestParamsOptionsInterface = {}; - expect(qp.parseParams.bind(qp, params, options)).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 2', () => { - const params = { foo: 'bar' }; - const options = {}; - expect(qp.parseParams.bind(qp, params, options)).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 3', () => { - const params = { foo: 'bar' }; - const options = { foo: {} }; - expect(qp.parseParams.bind(qp, params, options)).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 4', () => { - const params = { foo: 'bar' }; - const options = { - foo: { field: 'number' }, - } as unknown as CrudRequestParamsOptionsInterface; - expect(qp.parseParams.bind(qp, params, options)).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 5', () => { - const params = { foo: 'bar' }; - const options: CrudRequestParamsOptionsInterface = { - foo: { field: 'foo', type: 'number' }, - }; - expect(qp.parseParams.bind(qp, params, options)).toThrow( - CrudRequestQueryException, - ); - }); - it('should throw an error, 6', () => { - const params = { foo: 'bar' }; - const options: CrudRequestParamsOptionsInterface = { - foo: { field: 'foo', type: 'uuid' }, - }; - expect(qp.parseParams.bind(qp, params, options)).toThrow( - CrudRequestQueryException, - ); - }); - it('should set paramsFilter', () => { - const params = { - foo: 'cb1751fd-7fcf-4eb5-b38e-86428b1fd88d', - bar: '1', - baz: 'string', - bigInt: '9007199254740999', // Bigger than Number.MAX_SAFE_INTEGER - }; - const options: CrudRequestParamsOptionsInterface = { - foo: { field: 'foo', type: 'uuid' }, - bar: { field: 'bar', type: 'number' }, - baz: { field: 'baz', type: 'string' }, - bigInt: { field: 'bigInt', type: 'string' }, - }; - const test = qp.parseParams(params, options); - const expected = [ - { - field: 'foo', - operator: '$eq', - value: 'cb1751fd-7fcf-4eb5-b38e-86428b1fd88d', - }, - { field: 'bar', operator: '$eq', value: 1 }, - { field: 'baz', operator: '$eq', value: 'string' }, - { field: 'bigInt', operator: '$eq', value: '9007199254740999' }, - ]; - expect(test.paramsFilter).toMatchObject(expected); - }); - it('should set paramsFilter with disabled validation', () => { - const params = { - foo: 'cb1751fd', - bar: '123', - }; - const options: CrudRequestParamsOptionsInterface = { - foo: { disabled: true }, - bar: { field: 'bar', type: 'number' }, - }; - const test = qp.parseParams(params, options); - const expected = [{ field: 'bar', operator: '$eq', value: 123 }]; - expect(test.paramsFilter).toMatchObject(expected); - }); - }); - - describe('#setClassTransformOptions', () => { - it('it should set classTransformOptions, 1', () => { - qp.setClassTransformOptions(); - expect(qp.classTransformOptions).toMatchObject({}); - }); - it('it should set classTransformOptions, 2', () => { - const testOptions = { groups: ['TEST'] }; - qp.setClassTransformOptions(testOptions); - const parsed = qp.getParsed(); - expect(parsed.classTransformOptions).toMatchObject(testOptions); - }); - }); - - describe('#getParsed', () => { - it('should return parsed params', () => { - const expected: CrudRequestParsedParamsInterface = { - fields: [], - paramsFilter: [], - search: undefined, - classTransformOptions: undefined, - filter: [], - or: [], - sort: [], - limit: undefined, - offset: undefined, - page: undefined, - cache: undefined, - includeDeleted: undefined, - }; - const test = qp.getParsed(); - expect(test).toMatchObject(expected); - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/request/crud-request-query.parser.ts b/packages/nestjs-crud/src/request/crud-request-query.parser.ts deleted file mode 100644 index 234fac81b..000000000 --- a/packages/nestjs-crud/src/request/crud-request-query.parser.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { ClassTransformOptions } from 'class-transformer'; - -import { PlainLiteralObject } from '@nestjs/common'; -import { isNil, isObject, isString } from '@nestjs/common/utils/shared.utils'; - -import { hasValue, isDateString, isStringFull } from '../util/validation'; - -import { CrudRequestQueryBuilder } from './crud-request-query.builder'; -import { - validateCondition, - validateNumeric, - validateParamOption, - validateSort, - validateUUID, -} from './crud-request-query.validator'; -import { convertFilterToSearch, splitSortString } from './crud-request.utils'; -import { CrudRequestQueryException } from './exceptions/crud-request-query.exception'; -import { CrudRequestParamsOptionsInterface } from './interfaces/crud-request-params-options.interface'; -import { CrudRequestParsedParamsInterface } from './interfaces/crud-request-parsed-params.interface'; -import { CrudRequestQueryBuilderOptionsInterface } from './interfaces/crud-request-query-builder-options.interface'; -import { - ComparisonOperator, - QueryFields, - QueryFilter, - QuerySort, - SCondition, - SConditionAND, - SFields, -} from './types/crud-request-query.types'; - -// tslint:disable:variable-name ban-types -export class CrudRequestQueryParser - implements CrudRequestParsedParamsInterface -{ - public fields: QueryFields = []; - - public paramsFilter: QueryFilter[] = []; - - public authPersist: PlainLiteralObject | undefined; - - public classTransformOptions: ClassTransformOptions | undefined; - - public search: SCondition | undefined; - - public filter: QueryFilter[] = []; - - public or: QueryFilter[] = []; - - public sort: QuerySort[] = []; - - public limit: number | undefined; - - public offset: number | undefined; - - public page: number | undefined; - - public cache: number | undefined; - - public includeDeleted: number | undefined; - - private _params: PlainLiteralObject = {}; - - private _query: PlainLiteralObject = {}; - - private _paramNames: string[] = []; - - private _paramsOptions: CrudRequestParamsOptionsInterface | undefined; - - private get _options(): Required { - return CrudRequestQueryBuilder.getOptions(); - } - - static create(): CrudRequestQueryParser { - return new CrudRequestQueryParser(); - } - - getParsed(): CrudRequestParsedParamsInterface { - return { - fields: this.fields, - paramsFilter: this.paramsFilter, - classTransformOptions: this.classTransformOptions, - search: this.search, - filter: this.filter, - or: this.or, - sort: this.sort, - limit: this.limit, - offset: this.offset, - page: this.page, - cache: this.cache, - includeDeleted: this.includeDeleted, - }; - } - - parseQuery(query: PlainLiteralObject): this { - if (isObject(query)) { - const paramNames = Object.keys(query); - - if (paramNames.length) { - this._query = query; - this._paramNames = paramNames; - const searchData = this._query[this.getParamNames('search')[0]]; - this.search = this.parseSearchQueryParam(searchData); - if (isNil(this.search)) { - this.filter = this.parseFilterQueryParam(); - this.or = this.parseOrQueryParam(); - } - this.fields = - this.parseQueryParam('fields', this.fieldsParser.bind(this))[0] || []; - this.sort = this.parseSortQueryParam(); - this.limit = this.parseQueryParam( - 'limit', - this.numericParser.bind(this, 'limit'), - )[0]; - this.offset = this.parseQueryParam( - 'offset', - this.numericParser.bind(this, 'offset'), - )[0]; - this.page = this.parseQueryParam( - 'page', - this.numericParser.bind(this, 'page'), - )[0]; - this.cache = this.parseQueryParam( - 'cache', - this.numericParser.bind(this, 'cache'), - )[0]; - this.includeDeleted = this.parseQueryParam( - 'includeDeleted', - this.numericParser.bind(this, 'includeDeleted'), - )[0]; - } - } - - return this; - } - - parseParams( - params: PlainLiteralObject, - options: CrudRequestParamsOptionsInterface, - ): this { - if (isObject(params)) { - const paramNames = Object.keys(params); - - if (paramNames.length) { - this._params = params; - this._paramsOptions = options; - this.paramsFilter = paramNames - .map((name) => { - return this.paramParser(name); - }) - .filter( - (filter): filter is QueryFilter => filter !== undefined, - ); - } - } - - return this; - } - - setAuthPersist(persist: PlainLiteralObject = {}) { - this.authPersist = persist || /* istanbul ignore next */ {}; - } - - setClassTransformOptions(options: ClassTransformOptions = {}) { - this.classTransformOptions = options || /* istanbul ignore next */ {}; - } - - convertFilterToSearch( - filter: QueryFilter, - ): SFields | SConditionAND { - return convertFilterToSearch(filter); - } - - private getParamNames( - type: keyof NonNullable< - CrudRequestQueryBuilderOptionsInterface['paramNamesMap'] - >, - ): string[] { - return this._paramNames.filter((p) => { - const name = this._options.paramNamesMap[type]; - const expectedNames = isString(name) ? [name] : (name as string[]); - - // Check for exact match or array-style parameter names (e.g., 'filter[0]', 'filter[1]') - return expectedNames.some((expectedName) => { - return p === expectedName || p.startsWith(`${expectedName}[`); - }); - }); - } - - private getParamValues< - U extends keyof NonNullable< - CrudRequestQueryBuilderOptionsInterface['paramNamesMap'] - >, - R extends CrudRequestParsedParamsInterface[U], - >( - value: string | string[], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - parser: any, - ): R[] { - if (typeof value === 'string' && isStringFull(value)) { - return [parser.call(this, value)]; - } - - if (Array.isArray(value) && value.length) { - return value.map((val) => parser(val)); - } - - return []; - } - - private getFilterParamValues( - value: NonNullable< - CrudRequestQueryBuilderOptionsInterface['paramNamesMap'] - >['filter'], - ): QueryFilter[] { - const parser = this.conditionParser.bind(this, 'filter'); - - if (typeof value === 'string' && isStringFull(value)) { - return [parser.call(this, value)]; - } - - if (Array.isArray(value) && value.length) { - return value.map((val) => parser(val)); - } - - return []; - } - - private getOrParamValues( - value: NonNullable< - CrudRequestQueryBuilderOptionsInterface['paramNamesMap'] - >['or'], - ): QueryFilter[] { - const parser = this.conditionParser.bind(this, 'or'); - - if (typeof value === 'string' && isStringFull(value)) { - return [parser.call(this, value)]; - } - - if (Array.isArray(value) && value.length) { - return value.map((val) => parser(val)); - } - - return []; - } - - private getSortParamValues( - value: NonNullable< - CrudRequestQueryBuilderOptionsInterface['paramNamesMap'] - >['sort'], - ): QuerySort[] { - const parser = this.sortParser.bind(this); - - if (typeof value === 'string' && isStringFull(value)) { - return [parser.call(this, value)]; - } - - if (Array.isArray(value) && value.length) { - return value.map((val) => parser(val)); - } - - return []; - } - - private parseQueryParam< - U extends keyof NonNullable< - CrudRequestQueryBuilderOptionsInterface['paramNamesMap'] - >, - R extends CrudRequestParsedParamsInterface[U], - >( - type: U, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - parser: any, - ): R[] { - const param = this.getParamNames(type); - - if (Array.isArray(param) && param.length) { - return param.reduce( - (a: R[], name) => [ - ...a, - ...this.getParamValues(this._query[name], parser), - ], - [], - ); - } - - return []; - } - - private parseFilterQueryParam(): QueryFilter[] { - const param = this.getParamNames('filter'); - - if (Array.isArray(param) && param.length) { - return param.reduce( - (a: QueryFilter[], name) => [ - ...a, - ...this.getFilterParamValues(this._query[name]), - ], - [], - ); - } - - return []; - } - - private parseOrQueryParam(): QueryFilter[] { - const param = this.getParamNames('or'); - - if (Array.isArray(param) && param.length) { - return param.reduce( - (a: QueryFilter[], name) => [ - ...a, - ...this.getOrParamValues(this._query[name]), - ], - [], - ); - } - - return []; - } - - private parseSortQueryParam(): QuerySort[] { - const param = this.getParamNames('sort'); - - if (Array.isArray(param) && param.length) { - return param.reduce( - (a: QuerySort[], name) => [ - ...a, - ...this.getSortParamValues(this._query[name]), - ], - [], - ); - } - - return []; - } - - private parseValue(val: string) { - try { - const parsed = JSON.parse(val); - - if (parsed instanceof Date === false && isObject(parsed)) { - // throw new Error('Don\'t support object now'); - return val; - } else if ( - typeof parsed === 'number' && - parsed.toLocaleString('fullwide', { useGrouping: false }) !== val - ) { - // JS cannot handle big numbers. Leave it as a string to prevent data loss - return val; - } - - return parsed; - } catch (_ignored) { - if (isDateString(val)) { - return new Date(val); - } - - return val; - } - } - - private parseValues(vals: string | string[]) { - if (Array.isArray(vals)) { - return vals.map((v: string) => this.parseValue(v)); - } else { - return this.parseValue(vals); - } - } - - private fieldsParser(data: string): QueryFields { - return data.split(this._options.delimStr); - } - - private parseSearchQueryParam(d: string): SCondition | undefined { - try { - if (isNil(d)) { - return undefined; - } - - const data = JSON.parse(d); - - if (!isObject(data)) { - throw new Error(); - } - - return data; - } catch (_e) { - throw new CrudRequestQueryException({ - message: 'Invalid search param. JSON expected', - }); - } - } - - private conditionParser( - cond: 'filter' | 'or' | 'search', - data: string, - ): QueryFilter { - const isArrayValue = [ - 'in', - 'notin', - 'between', - '$in', - '$notin', - '$between', - '$inL', - '$notinL', - ]; - const isEmptyValue = ['isnull', 'notnull', '$isnull', '$notnull']; - const param = data.split(this._options.delim); - let field: string; - let relation: string | undefined; - - if (param[0].includes('.')) { - const parts = param[0].split('.'); - [relation, field] = parts; - } else { - field = param[0]; - } - - const operator = param[1] as ComparisonOperator; - let value: string | string[] = param[2] || ''; - - if (isArrayValue.some((name) => name === operator)) { - value = value.split(this._options.delimStr); - } - - value = this.parseValues(value); - - if (!isEmptyValue.some((name) => name === operator) && !hasValue(value)) { - throw new CrudRequestQueryException({ message: `Invalid ${cond} value` }); - } - - const condition: QueryFilter = { field, operator, value, relation }; - validateCondition(condition, cond); - - return condition; - } - - private sortParser(data: string): QuerySort { - const sort = splitSortString(data, this._options.delimStr); - validateSort(sort); - return sort as QuerySort; - } - - private numericParser( - num: 'limit' | 'offset' | 'page' | 'cache' | 'includeDeleted', - data: string, - ): number { - const val = this.parseValue(data); - validateNumeric(val, num); - - return val; - } - - private paramParser(name: string): QueryFilter | undefined { - const paramsOptions: CrudRequestParamsOptionsInterface = - this._paramsOptions ?? {}; - - validateParamOption(paramsOptions, name); - const option = paramsOptions[name]; - - if ( - 'field' in option && - typeof option.field === 'string' && - option.disabled !== true - ) { - let value = this._params[name]; - - switch (option.type) { - case 'number': - value = this.parseValue(value); - validateNumeric(value, `param ${name}`); - break; - case 'uuid': - validateUUID(value, name); - break; - default: - break; - } - - return { field: option.field, operator: '$eq', value }; - } else { - return undefined; - } - } -} diff --git a/packages/nestjs-crud/src/request/crud-request-query.validator.ts b/packages/nestjs-crud/src/request/crud-request-query.validator.ts deleted file mode 100644 index 2e0365a0e..000000000 --- a/packages/nestjs-crud/src/request/crud-request-query.validator.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; -import { isNil, isNumber, isObject } from '@nestjs/common/utils/shared.utils'; - -import { isArrayStrings, isStringFull } from '../util/validation'; - -import { CrudRequestQueryException } from './exceptions/crud-request-query.exception'; -import { CrudRequestParamsOptionsInterface } from './interfaces/crud-request-params-options.interface'; -import { - ComparisonOperator, - CondOperator, - QueryFields, - QueryFilter, - QuerySortOperator, -} from './types/crud-request-query.types'; - -export const deprecatedComparisonOperatorsList = [ - 'eq', - 'ne', - 'gt', - 'lt', - 'gte', - 'lte', - 'starts', - 'ends', - 'cont', - 'excl', - 'in', - 'notin', - 'isnull', - 'notnull', - 'between', -]; -export const comparisonOperatorsList = [ - ...deprecatedComparisonOperatorsList, - ...Object.keys(CondOperator).map( - (n) => CondOperator[n as keyof typeof CondOperator], - ), -]; - -export const sortOrdersList = ['ASC', 'DESC']; - -const comparisonOperatorsListStr = comparisonOperatorsList.join(); -const sortOrdersListStr = sortOrdersList.join(); - -export function validateFields( - fields: QueryFields, -): void { - if (!isArrayStrings(fields)) { - throw new CrudRequestQueryException({ - message: 'Invalid fields. Array of strings expected', - }); - } -} - -export function validateCondition( - val: QueryFilter, - cond: 'filter' | 'or' | 'search', -): void { - if (!isObject(val) || !isStringFull(val.field)) { - throw new CrudRequestQueryException({ - message: `Invalid field type in ${cond} condition. String expected`, - }); - } - validateComparisonOperator(val.operator); -} - -export function validateComparisonOperator(operator: ComparisonOperator): void { - if (!comparisonOperatorsList.includes(operator)) { - throw new CrudRequestQueryException({ - message: `Invalid comparison operator. ${comparisonOperatorsListStr} expected`, - }); - } -} - -export function isSortOrder(value: unknown): value is QuerySortOperator { - return value === 'ASC' || value === 'DESC'; -} - -export function validateSort(sort: { field?: unknown; order?: unknown }): void { - if ( - !isObject(sort) || - 'field' in sort === false || - !isStringFull(sort.field) - ) { - throw new CrudRequestQueryException({ - message: 'Invalid sort field. String expected', - }); - } - if (!isSortOrder(sort.order)) { - throw new CrudRequestQueryException({ - message: `Invalid sort order. ${sortOrdersListStr} expected`, - }); - } -} - -export function validateNumeric( - val: number, - num: 'limit' | 'offset' | 'page' | 'cache' | 'include_deleted' | string, -): void { - if (!isNumber(val)) { - throw new CrudRequestQueryException({ - message: `Invalid ${num}. Number expected`, - }); - } -} - -export function validateParamOption( - options: CrudRequestParamsOptionsInterface, - name: string, -) { - if (!isObject(options)) { - throw new CrudRequestQueryException({ - message: `Invalid param ${name}. Invalid crud options`, - }); - } - const option = options[name]; - if (option && option.disabled) { - return; - } - if (!isObject(option) || isNil(option.field) || isNil(option.type)) { - throw new CrudRequestQueryException({ - message: 'Invalid param option in Crud', - }); - } -} - -export function validateUUID(str: string, name: string) { - const uuid = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - const uuidV4 = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!uuidV4.test(str) && !uuid.test(str)) { - throw new CrudRequestQueryException({ - message: `Invalid param ${name}. UUID string expected`, - }); - } -} diff --git a/packages/nestjs-crud/src/request/crud-request.query.validator.spec.ts b/packages/nestjs-crud/src/request/crud-request.query.validator.spec.ts deleted file mode 100644 index aee03f1bf..000000000 --- a/packages/nestjs-crud/src/request/crud-request.query.validator.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { validateUUID } from './crud-request-query.validator'; - -describe('#request-query', () => { - describe('#validator', () => { - describe('#validateUUID', () => { - const uuid = 'cf0917fc-af7d-11e9-a2a3-2a2ae2dbcce4'; - const uuidV4 = '6650aad9-29bd-4601-b9b1-543a7a2d2d54'; - const invalid = 'invalid-uuid'; - - it('should throw an error', () => { - expect(validateUUID.bind(validateUUID, invalid)).toThrow(); - }); - it('should pass, 1', () => { - expect(validateUUID(uuid, '')).toBeUndefined(); - }); - it('should pass, 2', () => { - expect(validateUUID(uuidV4, '')).toBeUndefined(); - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/request/crud-request.utils.ts b/packages/nestjs-crud/src/request/crud-request.utils.ts deleted file mode 100644 index af15881ff..000000000 --- a/packages/nestjs-crud/src/request/crud-request.utils.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { COMPARISON_OPERATORS } from './crud-request-query.contants'; -import { - ComparisonOperator, - QueryFilter, - SConditionAND, - SFields, -} from './types/crud-request-query.types'; - -export function isComparisonOperator( - operator: string, -): operator is ComparisonOperator { - const found = COMPARISON_OPERATORS.find( - (validOperator) => operator === validOperator, - ); - return found !== undefined; -} - -export function comparisonOperatorKeys( - obj: Record, -): ComparisonOperator[] { - return Object.keys(obj).filter((key: string): key is ComparisonOperator => - isComparisonOperator(key), - ); -} - -export function splitSortString(sort: string, delim = ',') { - const [field, order] = sort.split(delim); - let sortField: string; - let relation: string | undefined; - - if (field.includes('.')) { - const parts = field.split('.'); - [relation, sortField] = parts; - } else { - sortField = field; - } - - return { - field: sortField ? sortField.trim() : undefined, - order: order ? order.trim().toUpperCase() : undefined, - relation, - }; -} - -export function convertFilterToSearch( - filter: QueryFilter, -): SFields | SConditionAND { - return filter - ? { - [filter.field]: { - [filter.operator]: - filter.operator === '$isnull' || filter.operator === '$notnull' - ? true - : filter.value, - }, - } - : {}; -} diff --git a/packages/nestjs-crud/src/request/exceptions/crud-request-query.exception.ts b/packages/nestjs-crud/src/request/exceptions/crud-request-query.exception.ts deleted file mode 100644 index 28933bb9d..000000000 --- a/packages/nestjs-crud/src/request/exceptions/crud-request-query.exception.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { CrudException } from '../../exceptions/crud.exception'; - -export class CrudRequestQueryException extends CrudException { - constructor(options?: RuntimeExceptionOptions) { - super(options); - } -} diff --git a/packages/nestjs-crud/src/request/interfaces/crud-create-query-params.interface.ts b/packages/nestjs-crud/src/request/interfaces/crud-create-query-params.interface.ts deleted file mode 100644 index 4d45a8888..000000000 --- a/packages/nestjs-crud/src/request/interfaces/crud-create-query-params.interface.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { - QueryFields, - QueryFilter, - QueryFilterArr, - QuerySort, - QuerySortArr, - SCondition, -} from '../types/crud-request-query.types'; - -export interface CrudCreateQueryParamsInterface< - T extends PlainLiteralObject = PlainLiteralObject, -> { - fields?: QueryFields; - search?: SCondition; - filter?: - | QueryFilter - | QueryFilterArr - | Array | QueryFilterArr>; - or?: - | QueryFilter - | QueryFilterArr - | Array | QueryFilterArr>; - sort?: QuerySort | QuerySortArr | Array | QuerySortArr>; - limit?: number; - offset?: number; - page?: number; - resetCache?: boolean; - includeDeleted?: number; -} diff --git a/packages/nestjs-crud/src/request/interfaces/crud-request-param-option.interface.ts b/packages/nestjs-crud/src/request/interfaces/crud-request-param-option.interface.ts deleted file mode 100644 index c1fdf44d5..000000000 --- a/packages/nestjs-crud/src/request/interfaces/crud-request-param-option.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { CrudEntityColumn } from '../../crud.types'; -import { ParamOptionType } from '../types/crud-request-param.types'; - -export interface CrudRequestParamOptionInterface { - field?: CrudEntityColumn; - type?: ParamOptionType; - primary?: boolean; - disabled?: boolean; -} diff --git a/packages/nestjs-crud/src/request/interfaces/crud-request-params-options.interface.ts b/packages/nestjs-crud/src/request/interfaces/crud-request-params-options.interface.ts deleted file mode 100644 index a25748f22..000000000 --- a/packages/nestjs-crud/src/request/interfaces/crud-request-params-options.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { CrudRequestParamOptionInterface } from './crud-request-param-option.interface'; - -export interface CrudRequestParamsOptionsInterface< - T extends PlainLiteralObject, -> { - [key: string]: CrudRequestParamOptionInterface; -} diff --git a/packages/nestjs-crud/src/request/interfaces/crud-request-parsed-params.interface.ts b/packages/nestjs-crud/src/request/interfaces/crud-request-parsed-params.interface.ts deleted file mode 100644 index 53fbf5afb..000000000 --- a/packages/nestjs-crud/src/request/interfaces/crud-request-parsed-params.interface.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ClassTransformOptions } from 'class-transformer'; - -import { PlainLiteralObject } from '@nestjs/common'; - -import { - QueryFields, - QueryFilter, - QuerySort, - SCondition, -} from '../types/crud-request-query.types'; - -export interface CrudRequestParsedParamsInterface< - T extends PlainLiteralObject, -> { - fields: QueryFields; - paramsFilter: QueryFilter[]; - classTransformOptions: ClassTransformOptions | undefined; - search: SCondition | undefined; - filter: QueryFilter[]; - or: QueryFilter[]; - sort: QuerySort[]; - limit: number | undefined; - offset: number | undefined; - page: number | undefined; - cache: number | undefined; - includeDeleted: number | undefined; -} diff --git a/packages/nestjs-crud/src/request/interfaces/crud-request-query-builder-options.interface.ts b/packages/nestjs-crud/src/request/interfaces/crud-request-query-builder-options.interface.ts deleted file mode 100644 index 5043ade6a..000000000 --- a/packages/nestjs-crud/src/request/interfaces/crud-request-query-builder-options.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface CrudRequestQueryBuilderOptionsInterface { - delim?: string; - delimStr?: string; - paramNamesMap?: { - fields?: string | string[]; - search?: string | string[]; - filter?: string | string[]; - or?: string | string[]; - sort?: string | string[]; - limit?: string | string[]; - offset?: string | string[]; - page?: string | string[]; - cache?: string | string[]; - includeDeleted?: string | string[]; - }; -} diff --git a/packages/nestjs-crud/src/request/types/crud-request-param.types.ts b/packages/nestjs-crud/src/request/types/crud-request-param.types.ts deleted file mode 100644 index 801812cb0..000000000 --- a/packages/nestjs-crud/src/request/types/crud-request-param.types.ts +++ /dev/null @@ -1 +0,0 @@ -export type ParamOptionType = 'number' | 'string' | 'uuid'; diff --git a/packages/nestjs-crud/src/request/types/crud-request-query.types.ts b/packages/nestjs-crud/src/request/types/crud-request-query.types.ts deleted file mode 100644 index 4c407828b..000000000 --- a/packages/nestjs-crud/src/request/types/crud-request-query.types.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { PlainLiteralObject, Type } from '@nestjs/common'; - -import { CrudQueryOptionsInterface } from '../../crud/interfaces/crud-query-options.interface'; -import { CrudEntityColumn } from '../../crud.types'; -import { CrudFetchServiceInterface } from '../../services/interfaces/crud-fetch-service.interface'; - -export type QueryFields = CrudEntityColumn[]; - -export type QueryFilter = { - field: CrudEntityColumn; - operator: ComparisonOperator; - value?: unknown; - relation?: string; -}; - -export type QueryFilterArr = [ - CrudEntityColumn, - ComparisonOperator, - unknown?, -]; - -export type QuerySort = { - field: CrudEntityColumn; - order: QuerySortOperator; - relation?: string; -}; - -export type QuerySortArr = [ - CrudEntityColumn, - QuerySortOperator, -]; - -export type QuerySortOperator = 'ASC' | 'DESC'; - -export enum CondOperator { - EQUALS = '$eq', - NOT_EQUALS = '$ne', - GREATER_THAN = '$gt', - LOWER_THAN = '$lt', - GREATER_THAN_EQUALS = '$gte', - LOWER_THAN_EQUALS = '$lte', - STARTS = '$starts', - ENDS = '$ends', - CONTAINS = '$cont', - EXCLUDES = '$excl', - IN = '$in', - NOT_IN = '$notin', - IS_NULL = '$isnull', - NOT_NULL = '$notnull', - BETWEEN = '$between', - EQUALS_LOW = '$eqL', - NOT_EQUALS_LOW = '$neL', - STARTS_LOW = '$startsL', - ENDS_LOW = '$endsL', - CONTAINS_LOW = '$contL', - EXCLUDES_LOW = '$exclL', - IN_LOW = '$inL', - NOT_IN_LOW = '$notinL', -} - -export type ComparisonOperator = keyof SFieldOperator; - -// new search -export type SPrimitivesVal = string | number | boolean; - -export type SFieldValues = SPrimitivesVal | Array; - -export type SFieldOperator = { - $eq?: SFieldValues; - $ne?: SFieldValues; - $gt?: SFieldValues; - $lt?: SFieldValues; - $gte?: SFieldValues; - $lte?: SFieldValues; - $starts?: SFieldValues; - $ends?: SFieldValues; - $cont?: SFieldValues; - $excl?: SFieldValues; - $in?: SFieldValues; - $notin?: SFieldValues; - $between?: SFieldValues; - $isnull?: SFieldValues; - $notnull?: SFieldValues; - $eqL?: SFieldValues; - $neL?: SFieldValues; - $startsL?: SFieldValues; - $endsL?: SFieldValues; - $contL?: SFieldValues; - $exclL?: SFieldValues; - $inL?: SFieldValues; - $notinL?: SFieldValues; - $or?: SFieldOperator; - $and?: never; -}; - -export type SField = SPrimitivesVal | SFieldOperator; - -export type SFields = Partial< - Record< - CrudEntityColumn, - SField | Array | SConditionAND> | undefined | null - > -> & { - $or?: Array>; - $and?: never; -}; - -export type SConditionAND = { - [key: string]: unknown; - $and?: Array>; - $or?: never; -}; - -export type SConditionKey = '$and' | '$or'; - -export type SCondition = - | SFields - | SConditionAND; - -export type QueryRelationCardinality = 'one' | 'many'; - -export type QueryJoinType = 'LEFT' | 'INNER'; - -type QueryRelationBase< - Entity extends PlainLiteralObject, - Relation extends PlainLiteralObject = PlainLiteralObject, -> = { - /** - * The type of relation multiplicity from root to relation entity. - * - 'one': Root has at most one related entity (1:1 or N:1) - * - 'many': Root can have multiple related entities (1:N) - */ - cardinality: QueryRelationCardinality; - /** - * The type of join to use when fetching this relation. - * - 'LEFT': Include all roots, even without matching relations (default) - * - 'INNER': Only include roots with matching relations - */ - join?: QueryJoinType; - /** - * The target CRUD service responsible for hydration. - */ - service: Type>; - /** - * The property name in the root (anchor) entity that holds the relation. - */ - property: CrudEntityColumn & string; - /** - * Filter to ensure uniqueness for many-cardinality relationships when sorting. - * Required for relation sorting on 'many' relationships to guarantee at most - * one relation row per root entity for consistent sort order. - * - * Example: `{ field: 'isLatest', operator: '$eq', value: true }` - */ - distinctFilter?: QueryFilter; - /** - * Options for the relation. - */ - options?: { - query: Pick, 'allow' | 'exclude'>; - }; -}; - -export type QueryRelation< - Entity extends PlainLiteralObject, - Relation extends PlainLiteralObject = PlainLiteralObject, -> = QueryRelationBase & { - /** - * Whether the root entity owns the foreign key. - * - false (default): Relation entity stores FK (relation[foreignKey] → root[primaryKey]) - * - true: Root entity stores FK (root[foreignKey] → relation[primaryKey]) - */ - owner?: boolean; -} & ( // Default ownership: relation[foreignKey] -> root[primaryKey] - | { - owner?: false | undefined; - /** - * The primary key field name in the root entity (target of the reference) - */ - primaryKey: CrudEntityColumn & string; - /** - * The foreign key field name in the relation entity (holds the reference) - */ - foreignKey: CrudEntityColumn & string; - } - // Root ownership: root[foreignKey] -> relation[primaryKey] - | { - owner: true; - /** - * The primary key field name in the relation entity (target of the reference) - */ - primaryKey: CrudEntityColumn & string; - /** - * The foreign key field name in the root entity (holds the reference) - */ - foreignKey: CrudEntityColumn & string; - } - ); diff --git a/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-mock-helpers.ts b/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-mock-helpers.ts deleted file mode 100644 index 1ab024924..000000000 --- a/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-mock-helpers.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -/** - * Creates a standard paginated response matching the service's expected format - * Uses sensible defaults - only specify limit when testing buffer behavior - */ -export const createPaginatedResponse = ( - data: T[], - options: { - limit?: number; - page?: number; - total?: number; - } = {}, -) => { - const limit = options.limit ?? 10; // Sane default, matches typical request limits - const page = options.page ?? 1; - const total = options.total ?? data.length; - const pageCount = Math.ceil(total / limit); - - return { - data, - count: data.length, - total, - limit, - page, - pageCount, - }; -}; diff --git a/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-assertions.ts b/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-assertions.ts deleted file mode 100644 index 2d7b4ccd7..000000000 --- a/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-assertions.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; -import { CrudResponsePaginatedInterface } from '../../crud/interfaces/crud-response-paginated.interface'; -import { CrudRequestParsedParamsInterface } from '../../request/interfaces/crud-request-parsed-params.interface'; -import { CrudQueryHelper } from '../helpers/crud-query.helper'; -import { CrudFetchServiceInterface } from '../interfaces/crud-fetch-service.interface'; - -// Type definitions for better type safety -interface RootWithRelations { - id: number; - [key: string]: unknown; -} - -// Minimal type for services that only need mock call tracking -interface ServiceWithMockTracking { - getMany: { - mock: { - invocationCallOrder: number[]; - }; - }; -} - -// Minimal type for services that only need call count verification -interface ServiceWithCallCount { - getMany: unknown; -} - -/** - * Shared assertion utilities for federation tests - * Reduces boilerplate and ensures consistent verification patterns - */ - -// Service call sequencing verification - root called before all relation services -export const assertRootFirst = ( - rootService: ServiceWithMockTracking, - relationServices: ServiceWithMockTracking[], -) => { - const rootCallOrder = rootService.getMany.mock.invocationCallOrder[0]; - - relationServices.forEach((relationService) => { - const relationCallOrder = - relationService.getMany.mock.invocationCallOrder[0]; - expect(relationCallOrder).toBeGreaterThan(rootCallOrder); - }); -}; - -// Service call sequencing verification - relation services called before root -export const assertRelationFirst = ( - rootService: ServiceWithMockTracking, - relationServices: ServiceWithMockTracking[], -) => { - const rootCallOrder = rootService.getMany.mock.invocationCallOrder[0]; - - relationServices.forEach((relationService) => { - const relationCallOrder = - relationService.getMany.mock.invocationCallOrder[0]; - expect(rootCallOrder).toBeGreaterThan(relationCallOrder); - }); -}; - -// Service call sequencing verification for getOne - root.getOne called before relation.getMany -export const assertRootFirstGetOne = ( - rootService: { getOne: { mock: { invocationCallOrder: number[] } } }, - relationServices: { getMany: { mock: { invocationCallOrder: number[] } } }[], -) => { - const rootCallOrder = rootService.getOne.mock.invocationCallOrder[0]; - - relationServices.forEach((relationService) => { - const relationCallOrder = - relationService.getMany.mock.invocationCallOrder[0]; - expect(relationCallOrder).toBeGreaterThan(rootCallOrder); - }); -}; - -// LEFT JOIN behavior verification - root service has no search constraints -export const assertLeftJoinBehavior = ( - rootService: jest.Mocked>, -) => { - const rootCall = rootService.getMany.mock.calls[0][0]; - - // Root service should have no search constraints (LEFT JOIN) - expect(rootCall.parsed.search).toBeUndefined(); -}; - -// INNER JOIN behavior verification -export const assertInnerJoinBehavior = < - R extends PlainLiteralObject, - L extends PlainLiteralObject, ->( - rootService: jest.Mocked>, - relationService: jest.Mocked>, - expectedRelationFilter: object, - discoveredRootIds: number[], -) => { - const relationCall = relationService.getMany.mock.calls[0][0]; - const rootCall = rootService.getMany.mock.calls[0][0]; - - // Relation service gets the explicit filter - expect(relationCall.parsed.search).toEqual(expectedRelationFilter); - - // Root service gets ID constraint from discovered relations - expect(rootCall.parsed.search).toEqual({ - id: { $in: discoveredRootIds }, - }); - - // Verify relation called first (INNER JOIN pattern) - assertRelationFirst(rootService, [relationService]); -}; - -// Generic service call counts - accepts array of service-count pairs -export const assertServiceCallCounts = ( - serviceCounts: Array<{ - service: ServiceWithCallCount; - count: number; - }>, -) => { - serviceCounts.forEach(({ service, count }) => { - expect(service.getMany).toHaveBeenCalledTimes(count); - }); -}; - -// Assert no relation service calls (for no-relations scenarios) -export const assertNoRelationServiceCalls = ( - relationService: jest.Mocked>, -) => { - expect(relationService.getMany).not.toHaveBeenCalled(); -}; - -// Shared helper for asserting service requests with parsed parameter filtering -const assertServiceRequest = ( - actualRequest: CrudRequestInterface, - expectedParsed: Partial>, - options: { ignore?: Array> } = { - ignore: ['filter', 'or', 'classTransformOptions'], - }, -) => { - const ignoreProps = options.ignore || [ - 'filter', - 'or', - 'classTransformOptions', - ]; - - // Create expected request with defaults - const helper = new CrudQueryHelper(); - const expected = helper.createRequest(); - - // Merge expected values - expected.parsed = { - ...expected.parsed, - ...expectedParsed, - }; - - // Create copies for comparison with ignored properties removed - const actualFiltered = { ...actualRequest.parsed }; - const expectedFiltered = { ...expected.parsed }; - - // Remove ignored properties from both objects - for (const prop of ignoreProps) { - delete actualFiltered[prop]; - delete expectedFiltered[prop]; - } - - // Compare filtered parsed objects - expect(actualFiltered).toEqual(expectedFiltered); -}; - -// Core helper function for asserting root service requests -const assertRootServiceRequest = ( - rootService: jest.Mocked>, - methodName: 'getOne' | 'getMany', - expectedParsed: Partial>, - callIndex: number = 0, - options: { ignore?: Array> } = { - ignore: ['filter', 'or', 'classTransformOptions'], - }, -) => { - const actual: CrudRequestInterface = - methodName === 'getMany' - ? rootService.getMany.mock.calls[callIndex][0] - : rootService.getOne.mock.calls[callIndex][0]; - - assertServiceRequest(actual, expectedParsed, options); -}; - -// Root service request verification - validates request matches expected exactly -export const assertRootGetManyRequest = ( - rootService: jest.Mocked>, - expectedParsed: Partial>, - callIndex: number = 0, - options?: { ignore?: Array> }, -) => { - assertRootServiceRequest( - rootService, - 'getMany', - expectedParsed, - callIndex, - options, - ); -}; - -// Root service getOne request verification -export const assertRootGetOneRequest = ( - rootService: jest.Mocked>, - expectedParsed: Partial>, - callIndex: number = 0, - options?: { ignore?: Array> }, -) => { - assertRootServiceRequest( - rootService, - 'getOne', - expectedParsed, - callIndex, - options, - ); -}; - -// Relation service request verification - validates request matches expected exactly -export const assertRelationRequest = ( - relationService: jest.Mocked>, - expectedParsed: Partial>, - callIndex: number = 0, - options?: { - ignore?: Array>; - }, -) => { - const actual: CrudRequestInterface = - relationService.getMany.mock.calls[callIndex][0]; - - assertServiceRequest(actual, expectedParsed, { - ignore: options?.ignore, - }); -}; - -// Result structure verification - checks all response properties and data contents -export const assertResultStructure = ( - result: CrudResponsePaginatedInterface, - expected: Partial> & - Pick, 'count' | 'total'>, -) => { - // Required properties - always checked - expect(result.total).toBe(expected.total); - expect(result.count).toBe(expected.count); - - if (expected.data !== undefined) { - expect(result.data).toEqual(expected.data); - } - - // Optional properties - checked only if provided for backward compatibility - if (expected.limit !== undefined) { - expect(result.limit).toBe(expected.limit); - } - - if (expected.page !== undefined) { - expect(result.page).toBe(expected.page); - } - - if (expected.pageCount !== undefined) { - expect(result.pageCount).toBe(expected.pageCount); - } - - // Metrics - optional nested object - if (expected.metrics !== undefined) { - expect(result.metrics).toEqual(expected.metrics); - } -}; - -// Combined enrichment verification - property + mappings -export const assertEnrichment = ( - result: CrudResponsePaginatedInterface, - relationProperty: string, - expectedMappings: Record, -) => { - // Check that all roots have the relation property - result.data.forEach((root: RootWithRelations) => { - expect(root).toHaveProperty(relationProperty); - expect(Array.isArray(root[relationProperty])).toBe(true); - }); - - // Verify specific root-relation mappings - const rootById = new Map( - result.data.map((r: RootWithRelations) => [r.id, r]), - ); - - Object.entries(expectedMappings).forEach(([rootId, expectedRelations]) => { - const root = rootById.get(Number(rootId)); - expect(root).toBeDefined(); - if (root) { - const relationValue = root[relationProperty] as unknown[]; - expect(relationValue).toHaveLength(expectedRelations.length); - - expectedRelations.forEach((expectedRelation) => { - expect(relationValue).toContainEqual(expectedRelation); - }); - } - }); -}; - -// One-to-one enrichment verification - property can be single object or null -export const assertOneToOneEnrichment = ( - result: CrudResponsePaginatedInterface, - relationProperty: string, - expectedMappings: Record, -) => { - const rootById = new Map( - result.data.map((r: RootWithRelations) => [r.id, r]), - ); - - // Verify all roots have the property - result.data.forEach((root: RootWithRelations) => { - expect(root).toHaveProperty(relationProperty); - }); - - // Verify specific mappings (object or null) - Object.entries(expectedMappings).forEach(([rootId, expectedValue]) => { - const root = rootById.get(Number(rootId)); - expect(root).toBeDefined(); - if (root) { - if (expectedValue === null) { - expect(root[relationProperty]).toBeNull(); - } else { - expect(root[relationProperty]).toEqual(expectedValue); - } - } - }); -}; - -// Sort order verification using ID sequences -export const assertSortOrder = ( - result: CrudResponsePaginatedInterface, - expectedIdSequence: number[], -) => { - expectedIdSequence.forEach((expectedId, index) => { - expect(result.data[index].id).toBe(expectedId); - }); -}; - -// Empty result verification -export const assertEmptyResult = ( - result: CrudResponsePaginatedInterface, -) => { - expect(result.data).toEqual([]); - expect(result.count).toBe(0); - expect(result.total).toBe(0); - expect(result.page).toBe(1); - // pageCount and limit can vary, so just verify they exist - expect(result.pageCount).toBeGreaterThanOrEqual(0); - expect(result.limit).toBeGreaterThanOrEqual(1); -}; - -// Relation sort behavior verification - relation service called first with filter and sort -export const assertRelationSortBehavior = < - R extends PlainLiteralObject, - L extends PlainLiteralObject, ->( - rootService: jest.Mocked>, - relationService: jest.Mocked>, - expectedRelationSearch: object, - expectedRelationSort: Array<{ field: string; order: string }>, -) => { - const relationCall = relationService.getMany.mock.calls[0][0]; - - // Relation service gets the filter AND sort - expect(relationCall.parsed.search).toEqual(expectedRelationSearch); - expect(relationCall.parsed.sort).toEqual(expectedRelationSort); - - // Verify relation called first (relation-sort pattern) - assertRelationFirst(rootService, [relationService]); -}; - -// Relation sort validation error verification -export const assertRelationSortValidationError = ( - error: Error, - relationFieldOrMessage?: string, -) => { - const CrudQueryException = error.constructor; - expect(error).toBeInstanceOf(CrudQueryException); - expect(error.message).toContain('distinctFilter configuration'); - - // If a specific field or message is provided, check for it - // This is optional since the error message format changed - if (relationFieldOrMessage) { - expect(error.message).toContain(relationFieldOrMessage); - } -}; diff --git a/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-data.ts b/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-data.ts deleted file mode 100644 index 6dfc131aa..000000000 --- a/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-data.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { - TestRoot, - TestRelation, - TestProfile, - TestSettings, -} from './crud-federation-test-entities'; - -/** - * Preset data builders for federation tests - * Provides minimal, focused datasets to reduce test verbosity - */ - -// Minimal root-relation dataset (2-3 entities for basic tests) -export const createMinimalRootRelationSet = () => ({ - roots: [ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - { id: 3, name: 'Root 3' }, - ] as TestRoot[], - - relations: [ - { id: 1, rootId: 1, title: 'Relation 1', isLatest: true }, - { id: 2, rootId: 2, title: 'Relation 2', isLatest: true }, - { id: 3, rootId: 2, title: 'Relation 3', isLatest: false }, - // Root 3 has no relations - useful for LEFT JOIN tests - ] as TestRelation[], -}); - -// Filtered dataset with mixed active/inactive states -export const createFilteredDataSet = () => ({ - roots: [ - { id: 1, name: 'Active Root' }, - { id: 2, name: 'Mixed Root' }, - { id: 3, name: 'Inactive Root' }, - ] as TestRoot[], - - activeRelations: [ - { id: 1, rootId: 1, title: 'Active Task', status: 'active' }, - { id: 2, rootId: 2, title: 'Active Item', status: 'active' }, - ] as (TestRelation & { status: string })[], - - allRelations: [ - { id: 1, rootId: 1, title: 'Active Task', status: 'active' }, - { id: 2, rootId: 2, title: 'Active Item', status: 'active' }, - { id: 3, rootId: 2, title: 'Pending Item', status: 'pending' }, - { id: 4, rootId: 3, title: 'Inactive Task', status: 'inactive' }, - ] as (TestRelation & { status: string })[], -}); - -// Multi-priority dataset for complex filtering -export const createPriorityDataSet = () => ({ - roots: [ - { id: 1, name: 'High Priority Project' }, - { id: 2, name: 'Mixed Priority Project' }, - ] as TestRoot[], - - highPriorityActiveRelations: [ - { - id: 1, - rootId: 1, - title: 'Critical Bug', - status: 'active', - priority: 10, - }, - { - id: 2, - rootId: 2, - title: 'Important Feature', - status: 'active', - priority: 8, - }, - ] as (TestRelation & { status: string; priority: number })[], - - allRelations: [ - { - id: 1, - rootId: 1, - title: 'Critical Bug', - status: 'active', - priority: 10, - }, - { - id: 2, - rootId: 2, - title: 'Important Feature', - status: 'active', - priority: 8, - }, - { id: 3, rootId: 1, title: 'Minor Fix', status: 'active', priority: 3 }, - { id: 4, rootId: 2, title: 'Old Task', status: 'completed', priority: 9 }, - ] as (TestRelation & { status: string; priority: number })[], -}); - -// Sort order dataset with predictable names -export const createSortDataSet = () => ({ - rootsByName: [ - { id: 3, name: 'Alpha Project' }, - { id: 1, name: 'Beta Project' }, - { id: 2, name: 'Gamma Project' }, - ] as TestRoot[], - - rootsById: [ - { id: 3, name: 'Project C' }, - { id: 2, name: 'Project B' }, - { id: 1, name: 'Project A' }, - ] as TestRoot[], - - relations: [ - { id: 1, rootId: 1, title: 'Task A' }, - { id: 2, rootId: 2, title: 'Task B' }, - { id: 3, rootId: 3, title: 'Task C' }, - ] as TestRelation[], -}); - -// Root sort behavior datasets for LEFT JOIN tests -export const createNameSortDataSet = () => ({ - roots: [ - { id: 1, name: 'Root A' }, - { id: 3, name: 'Root B' }, - { id: 2, name: 'Root C' }, - ] as TestRoot[], - - relations: [ - { id: 1, rootId: 1, title: 'Relation 1' }, - { id: 2, rootId: 3, title: 'Relation 2' }, - // Root 2 has no relations - ] as TestRelation[], -}); - -export const createIdDescSortDataSet = () => ({ - roots: [ - { id: 3, name: 'Root 5' }, - { id: 4, name: 'Root 4' }, - { id: 5, name: 'Root 3' }, - { id: 2, name: 'Root 2' }, - { id: 1, name: 'Root 1' }, - ] as TestRoot[], - - relations: [ - { id: 1, rootId: 2, title: 'Relation 1' }, - { id: 2, rootId: 4, title: 'Relation 2' }, - { id: 3, rootId: 4, title: 'Relation 3' }, - ] as TestRelation[], -}); - -export const createMultiSortDataSet = () => ({ - roots: [ - { id: 3, name: 'Root A' }, - { id: 1, name: 'Root A' }, - { id: 2, name: 'Root B' }, - ] as TestRoot[], - - relations: [ - { id: 1, rootId: 1, title: 'Relation 1' }, - { id: 2, rootId: 1, title: 'Relation 2' }, - { id: 3, rootId: 3, title: 'Relation 3' }, - // Root 2 has no relations - ] as TestRelation[], -}); - -// Multi-relation dataset (root with multiple relation types) -export const createMultiRelationSet = () => ({ - roots: [ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - ] as TestRoot[], - - relations: [ - { id: 1, rootId: 1, title: 'Relation 1' }, - { id: 2, rootId: 2, title: 'Relation 2' }, - ] as TestRelation[], - - profiles: [ - { id: 1, rootId: 1, bio: 'Profile 1', avatar: 'avatar1.jpg' }, - // Root 2 has no profile - ] as TestProfile[], - - settings: [ - { id: 1, rootId: 1, theme: 'dark', notifications: true }, - { id: 2, rootId: 2, theme: 'light', notifications: false }, - ] as TestSettings[], -}); - -// Empty dataset for edge case testing -export const createEmptyDataSet = () => ({ - roots: [] as TestRoot[], - relations: [] as TestRelation[], -}); - -// Single entity datasets for minimal tests -export const createSingleEntitySet = () => ({ - roots: [{ id: 1, name: 'Only Root' }] as TestRoot[], - relations: [{ id: 1, rootId: 1, title: 'Only Relation' }] as TestRelation[], -}); - -// Combined root and relation filters dataset -export const createCombinedFiltersSet = () => ({ - projectRoots: [ - { id: 1, name: 'Project Alpha' }, // Matches name filter + has active relations - { id: 2, name: 'Project Beta' }, // Matches name filter + has active relations - ] as TestRoot[], - - allRoots: [ - { id: 1, name: 'Project Alpha' }, - { id: 2, name: 'Project Beta' }, - { id: 3, name: 'Internal Tool' }, // Doesn't match name filter - { id: 4, name: 'Project Gamma' }, // Matches name filter but no active relations - ] as TestRoot[], - - activeRelations: [ - { id: 1, rootId: 1, title: 'Feature A', status: 'active' }, - { id: 2, rootId: 2, title: 'Feature B', status: 'active' }, - ] as (TestRelation & { status: string })[], - - allRelations: [ - { id: 1, rootId: 1, title: 'Feature A', status: 'active' }, - { id: 2, rootId: 2, title: 'Feature B', status: 'active' }, - { id: 3, rootId: 3, title: 'Internal Task', status: 'active' }, - { id: 4, rootId: 4, title: 'Old Feature', status: 'completed' }, - ] as (TestRelation & { status: string })[], -}); - -// Large dataset for integration testing with multiple relations per root -export const createLargeRootRelationSet = () => ({ - roots: [ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - { id: 3, name: 'Root 3' }, - { id: 4, name: 'Root 4' }, - { id: 5, name: 'Root 5' }, - ] as TestRoot[], - - relations: [ - { id: 1, rootId: 1, title: 'Relation 1' }, - { id: 2, rootId: 2, title: 'Relation 2' }, - { id: 3, rootId: 3, title: 'Relation 3' }, - { id: 4, rootId: 99, title: 'Relation with non-existent root' }, // Root 99 doesn't exist - ] as TestRelation[], -}); - -// Multiple relations per root scenarios -export const createMultiRelationEntitySet = () => ({ - roots: [ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - ] as TestRoot[], - - relations: [ - { id: 1, rootId: 1, title: 'Relation 1A' }, - { id: 2, rootId: 1, title: 'Relation 1B' }, - { id: 3, rootId: 1, title: 'Relation 1C' }, - { id: 4, rootId: 2, title: 'Relation 2A' }, - ] as TestRelation[], -}); - -// Varying relation counts dataset -export const createVaryingRelationCountSet = () => ({ - roots: [ - { id: 1, name: 'Root with 3 relations' }, - { id: 2, name: 'Root with 1 relation' }, - { id: 3, name: 'Root with 0 relations' }, - { id: 4, name: 'Root with 2 relations' }, - ] as TestRoot[], - - relations: [ - // Root 1 has 3 relations - { id: 1, rootId: 1, title: 'Relation 1A' }, - { id: 2, rootId: 1, title: 'Relation 1B' }, - { id: 3, rootId: 1, title: 'Relation 1C' }, - // Root 2 has 1 relation - { id: 4, rootId: 2, title: 'Relation 2A' }, - // Root 3 has 0 relations - // Root 4 has 2 relations - { id: 5, rootId: 4, title: 'Relation 4A' }, - { id: 6, rootId: 4, title: 'Relation 4B' }, - ] as TestRelation[], -}); - -// Pagination page 2 dataset -export const createPaginationPage2Set = () => ({ - roots: [ - { id: 6, name: 'Root 6' }, // Page 2 roots - { id: 7, name: 'Root 7' }, - { id: 8, name: 'Root 8' }, - { id: 9, name: 'Root 9' }, - { id: 10, name: 'Root 10' }, - ] as TestRoot[], - - relations: [ - { id: 11, rootId: 6, title: 'Relation 6A' }, - { id: 12, rootId: 6, title: 'Relation 6B' }, - { id: 13, rootId: 7, title: 'Relation 7A' }, - { id: 14, rootId: 8, title: 'Relation 8A' }, - { id: 15, rootId: 8, title: 'Relation 8B' }, - { id: 16, rootId: 8, title: 'Relation 8C' }, - // Root 9, 10 have no relations - ] as TestRelation[], -}); - -// Complex multi-relationship dataset for integration tests -export const createComplexMultiRelationSet = () => ({ - roots: [ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - { id: 3, name: 'Root 3' }, - { id: 4, name: 'Root 4' }, - { id: 5, name: 'Root 5' }, - ] as TestRoot[], - - relations: [ - { id: 1, rootId: 1, title: 'Relation 1A' }, - { id: 2, rootId: 1, title: 'Relation 1B' }, - { id: 3, rootId: 2, title: 'Relation 2A' }, - { id: 4, rootId: 4, title: 'Relation 4A' }, - { id: 5, rootId: 4, title: 'Relation 4B' }, - { id: 6, rootId: 4, title: 'Relation 4C' }, - // Root 3 and 5 have no relations - ] as TestRelation[], - - settings: [ - { id: 1, rootId: 1, theme: 'dark', notifications: true }, - { id: 2, rootId: 1, theme: 'light', notifications: false }, - { id: 3, rootId: 3, theme: 'auto', notifications: true }, - { id: 4, rootId: 5, theme: 'dark', notifications: false }, - { id: 5, rootId: 5, theme: 'light', notifications: true }, - { id: 6, rootId: 5, theme: 'auto', notifications: false }, - // Root 2 and 4 have no settings - ] as TestSettings[], -}); - -// Filtered root dataset -export const createFilteredRootSet = () => ({ - filteredRoots: [ - { id: 1, name: 'root-filter' }, - // { id: 2, name: 'other-root' }, // Filtered out - ] as TestRoot[], - - relations: [ - { id: 1, rootId: 1, title: 'relation-1' }, - // { id: 2, rootId: 2, title: 'relation-2' }, // Not fetched - root 2 was filtered out - ] as TestRelation[], -}); - -// Relation sort by title dataset for relation-driven sorting -export const createRelationSortByTitleSet = () => ({ - relationsByTitle: [ - { id: 1, rootId: 2, title: 'Alpha Task' }, - { id: 2, rootId: 1, title: 'Beta Task' }, - { id: 3, rootId: 3, title: 'Charlie Task' }, - { id: 4, rootId: 1, title: 'Delta Task' }, - ] as TestRelation[], - - rootsInRelationOrder: [ - { id: 2, name: 'Root 2' }, // Has "Alpha Task" (first) - { id: 1, name: 'Root 1' }, // Has "Beta Task" (second) - { id: 3, name: 'Root 3' }, // Has "Charlie Task" (third) - ] as TestRoot[], - - rootsInNaturalOrder: [ - { id: 1, name: 'Root 1' }, // Natural ID order (NOT relation sort order) - { id: 2, name: 'Root 2' }, - { id: 3, name: 'Root 3' }, - ] as TestRoot[], -}); - -// Relation sort by priority with multiple relations per root -export const createRelationSortByPrioritySet = () => ({ - relationsByPriority: [ - { id: 1, rootId: 1, title: 'Critical', priority: 10 }, - { id: 2, rootId: 1, title: 'High A', priority: 8 }, - { id: 3, rootId: 2, title: 'High B', priority: 7 }, - { id: 4, rootId: 3, title: 'Medium', priority: 5 }, - { id: 5, rootId: 2, title: 'Low', priority: 3 }, - ] as (TestRelation & { priority: number })[], - - uniqueRootsInOrder: [ - { id: 1, name: 'Root 1' }, // priority 10 (Critical) - { id: 2, name: 'Root 2' }, // priority 7 (High B) - { id: 3, name: 'Root 3' }, // priority 5 (Medium) - ] as TestRoot[], -}); - -// Large relation sort dataset for pagination testing -export const createRelationSortPaginationSet = () => ({ - allRelationsSorted: [ - { id: 1, rootId: 5, title: 'Alpha' }, - { id: 2, rootId: 2, title: 'Bravo' }, - { id: 3, rootId: 8, title: 'Charlie' }, - { id: 4, rootId: 1, title: 'Delta' }, - { id: 5, rootId: 9, title: 'Echo' }, - { id: 6, rootId: 4, title: 'Foxtrot' }, - { id: 7, rootId: 7, title: 'Golf' }, - { id: 8, rootId: 3, title: 'Hotel' }, - { id: 9, rootId: 6, title: 'India' }, - { id: 10, rootId: 10, title: 'Juliet' }, - ] as TestRelation[], - - firstPageRoots: [ - { id: 5, name: 'Root 5' }, // Alpha - { id: 2, name: 'Root 2' }, // Bravo - { id: 8, name: 'Root 8' }, // Charlie - { id: 1, name: 'Root 1' }, // Delta - { id: 9, name: 'Root 9' }, // Echo - ] as TestRoot[], - - secondPageRoots: [ - { id: 4, name: 'Root 4' }, // Foxtrot - { id: 7, name: 'Root 7' }, // Golf - { id: 3, name: 'Root 3' }, // Hotel - { id: 6, name: 'Root 6' }, // India - { id: 10, name: 'Root 10' }, // Juliet - ] as TestRoot[], -}); - -// Empty relation sort result dataset -export const createRelationSortEmptySet = () => ({ - roots: [ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - { id: 3, name: 'Root 3' }, - ] as TestRoot[], - - relations: [] as TestRelation[], // No relations match the sort filter -}); diff --git a/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-entities.ts b/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-entities.ts deleted file mode 100644 index 2431c0f91..000000000 --- a/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-entities.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { PlainLiteralObject, Type } from '@nestjs/common'; - -import { CrudRelationsInterface } from '../../crud/interfaces/crud-relations.interface'; -import { CrudRequestParsedParamsInterface } from '../../request/interfaces/crud-request-parsed-params.interface'; -import { QueryRelation } from '../../request/types/crud-request-query.types'; -import { CrudQueryHelper } from '../helpers/crud-query.helper'; - -// Mock entities -export interface TestRoot extends PlainLiteralObject { - id: number; - name: string; - companyId?: number; -} - -export interface TestRelation extends PlainLiteralObject { - id: number; - rootId: number; - title: string; - priority?: number; - status?: string; - isLatest?: boolean; -} - -export interface TestProfile extends PlainLiteralObject { - id: number; - rootId: number; - bio: string; - avatar?: string; -} - -export interface TestSettings extends PlainLiteralObject { - id: number; - rootId: number; - theme: string; - notifications: boolean; -} - -// Mock service classes -export class TestRootService {} -export class TestRelationService {} -export class TestProfileService {} -export class TestSettingsService {} - -// Factory functions for creating test data -export const createTestParsed = ( - overrides: Partial> = {}, -): CrudRequestParsedParamsInterface => { - const queryHelper = new CrudQueryHelper(); - const baseRequest = queryHelper.createRequest(); - return { - ...baseRequest.parsed, - ...overrides, - }; -}; - -export const createTestRelations = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - relations: QueryRelation[] = [], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): CrudRelationsInterface => ({ - rootKey: 'id', - relations, -}); - -// Common relation configurations -export const createOneToManyForwardRelation = ( - property: string, - service: Type, - options?: { - primaryKey?: string; - foreignKey?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - distinctFilter?: QueryRelation['distinctFilter']; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): QueryRelation => ({ - property, - primaryKey: options?.primaryKey || 'id', - foreignKey: options?.foreignKey || 'rootId', - cardinality: 'many', - service, - owner: false, - distinctFilter: options?.distinctFilter, -}); - -export const createOneToOneForwardRelation = ( - property: string, - service: Type, - primaryKey: string = 'id', - foreignKey: string = 'rootId', - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): QueryRelation => ({ - property, - primaryKey, - foreignKey, - cardinality: 'one', - service, - owner: false, -}); diff --git a/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-setup.ts b/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-setup.ts deleted file mode 100644 index dadbbf66a..000000000 --- a/packages/nestjs-crud/src/services/__FIXTURES__/crud-federation-test-setup.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { - CallHandler, - ExecutionContext, - PlainLiteralObject, -} from '@nestjs/common'; -import { HttpArgumentsHost } from '@nestjs/common/interfaces'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { CrudActions } from '../../crud/enums/crud-actions.enum'; -import { CrudRequestInterceptor } from '../../crud/interceptors/crud-request.interceptor'; -import { CrudModelOptionsInterface } from '../../crud/interfaces/crud-model-options.interface'; -import { CrudOptionsInterface } from '../../crud/interfaces/crud-options.interface'; -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; -import { CRUD_MODULE_CRUD_REQUEST_KEY } from '../../crud.constants'; -import { QueryRelation } from '../../request/types/crud-request-query.types'; -import { CrudFederationService } from '../crud-federation.service'; -import { CrudReflectionService } from '../crud-reflection.service'; -import { CrudRelationRegistry } from '../crud-relation.registry'; -import { CrudFetchServiceInterface } from '../interfaces/crud-fetch-service.interface'; - -import { - TestRoot, - TestRelation, - TestProfile, - TestSettings, - TestRootService, - TestRelationService, - TestProfileService, - TestSettingsService, - createTestRelations, -} from './crud-federation-test-entities'; - -// Request object interface for interceptor testing -interface MockRequest { - query: PlainLiteralObject; - [CRUD_MODULE_CRUD_REQUEST_KEY]?: CrudRequestInterface; -} - -export interface CrudFederationTestMocks { - service: CrudFederationService; - interceptor: CrudRequestInterceptor; - module: TestingModule; - mockRootService: jest.Mocked>; - mockRelationService: jest.Mocked>; - mockProfileService: jest.Mocked>; - mockSettingsService: jest.Mocked>; - relationRegistry: CrudRelationRegistry; - resetAllMocks: () => void; - registerRelation: ( - service: CrudFetchServiceInterface, // eslint-disable-line @typescript-eslint/no-explicit-any - ) => void; - applyInterceptorTransform: ( - query: PlainLiteralObject, - options?: Partial>, - action?: CrudActions, - ) => CrudRequestInterface; - createTestRequest: ( - query?: PlainLiteralObject, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - relations?: QueryRelation[], - ) => CrudRequestInterface; -} - -export const setupCrudFederationTests = - async (): Promise => { - // Mock service setup with proper constructors - const mockRootService = Object.create(TestRootService.prototype); - Object.assign(mockRootService, { - getMany: jest.fn().mockResolvedValue([ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - ]), - getOne: jest.fn().mockResolvedValue({ id: 1, name: 'Root 1' }), - }); - - const mockRelationService = Object.create(TestRelationService.prototype); - Object.assign(mockRelationService, { - getMany: jest.fn().mockResolvedValue([ - { id: 1, rootId: 1, title: 'Relation 1' }, - { id: 2, rootId: 2, title: 'Relation 2' }, - ]), - }); - - const mockProfileService = Object.create(TestProfileService.prototype); - Object.assign(mockProfileService, { - getMany: jest.fn().mockResolvedValue([ - { - id: 1, - rootId: 1, - bio: 'Profile for Root 1', - avatar: 'avatar1.jpg', - }, - ]), - }); - - const mockSettingsService = Object.create(TestSettingsService.prototype); - Object.assign(mockSettingsService, { - getMany: jest - .fn() - .mockResolvedValue([ - { id: 1, rootId: 1, theme: 'dark', notifications: true }, - ]), - }); - - // Create relation registry and register services - const relationRegistry = new CrudRelationRegistry< - TestRoot, - TestRelation[] - >(); - - // Register relations if any tests need them - // Note: Tests that don't use relations will pass empty relations array - // Tests that do use relations should call registerRelation themselves - - // Create interceptor with mocked reflection service - const mockReflectionService = mock>(); - const interceptor = new CrudRequestInterceptor( - mockReflectionService, - ); - - // Create service directly with constructor injection instead of using NestJS module - const service = new CrudFederationService( - mockRootService, - relationRegistry, - ); - - // Create module for compatibility (though not used for service injection anymore) - const module = await Test.createTestingModule({ - providers: [], - }).compile(); - - const resetAllMocks = () => { - // mockClear() clears call history but keeps implementation - // mockReset() clears call history and resets implementation to return undefined - // Using mockClear() since tests set up their own return values - mockRootService.getMany.mockClear(); - mockRootService.getOne.mockClear(); - mockRelationService.getMany.mockClear(); - mockProfileService.getMany.mockClear(); - mockSettingsService.getMany.mockClear(); - }; - - const registerRelation = ( - service: CrudFetchServiceInterface, // eslint-disable-line @typescript-eslint/no-explicit-any - ) => { - relationRegistry.register(service); - }; - - const applyInterceptorTransform = ( - query: PlainLiteralObject, - options: Partial> = {}, - action: CrudActions = CrudActions.ReadAll, - ): CrudRequestInterface => { - // Create request object that interceptor will mutate - const req: MockRequest = { query }; - - // Mock reflection service returns - mockReflectionService.getRequestOptions.mockReturnValue({ - model: {} as CrudModelOptionsInterface, - ...options, - }); - mockReflectionService.getAction.mockReturnValue(action); - - // Mock execution context - const mockContext = mock(); - const mockHttpContext = mock(); - mockHttpContext.getRequest.mockReturnValue(req); - mockContext.switchToHttp.mockReturnValue(mockHttpContext); - - // Execute interceptor - it will mutate req - interceptor.intercept(mockContext, mock()); - - // Return the transformed request (we know it exists after interceptor runs) - return req[CRUD_MODULE_CRUD_REQUEST_KEY]!; - }; - - const createTestRequest = ( - query?: PlainLiteralObject, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - relations: QueryRelation[] = [], - ): CrudRequestInterface => { - const options: Partial> = {}; - - // Add relations if provided - if (relations.length > 0) { - options.query = { - relations: createTestRelations(relations), - }; - } - - return applyInterceptorTransform(query || {}, options); - }; - - return { - service, - interceptor, - module, - mockRootService, - mockRelationService, - mockProfileService, - mockSettingsService, - relationRegistry, - resetAllMocks, - registerRelation, - applyInterceptorTransform, - createTestRequest, - }; - }; - -export const cleanupCrudFederationTests = async ( - mocks: CrudFederationTestMocks, -): Promise => { - // Reset mocks to prepare for next test - mocks.resetAllMocks(); - await mocks.module.close(); -}; diff --git a/packages/nestjs-crud/src/services/__TESTS__/b.query-params.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/b.query-params.spec.ts deleted file mode 100644 index 0d64c5c0c..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/b.query-params.spec.ts +++ /dev/null @@ -1,612 +0,0 @@ -import 'jest-extended'; -import request from 'supertest'; -import { DataSource } from 'typeorm'; - -import { INestApplication } from '@nestjs/common'; -import { APP_FILTER } from '@nestjs/core'; -import { Test } from '@nestjs/testing'; -import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; - -import { ExceptionsFilter } from '@concepta/nestjs-common'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { - CRUD_TEST_COMPANY_ENTITY_KEY, - CRUD_TEST_NOTE_ENTITY_KEY, - CRUD_TEST_PROJECT_ENTITY_KEY, - CRUD_TEST_USER_ENTITY_KEY, -} from '../../__fixtures__/crud-test.constants'; -import { CompanyCrudService } from '../../__fixtures__/typeorm/company/company-crud.service'; -import { CompanyTypeOrmCrudAdapter } from '../../__fixtures__/typeorm/company/company-typeorm-crud.adapter'; -import { CompanyEntity } from '../../__fixtures__/typeorm/company/company.entity'; -import { CompanyPaginatedDto } from '../../__fixtures__/typeorm/company/dto/company-paginated.dto'; -import { CompanyDto } from '../../__fixtures__/typeorm/company/dto/company.dto'; -import { NotePaginatedDto } from '../../__fixtures__/typeorm/note/dto/note-paginated.dto'; -import { NoteDto } from '../../__fixtures__/typeorm/note/dto/note.dto'; -import { NoteCrudService } from '../../__fixtures__/typeorm/note/note-crud.service'; -import { NoteTypeOrmCrudAdapter } from '../../__fixtures__/typeorm/note/note-typeorm-crud.adapter'; -import { NoteEntity } from '../../__fixtures__/typeorm/note/note.entity'; -import { ormSqliteConfig } from '../../__fixtures__/typeorm/orm.sqlite.config'; -import { ProjectCreateDto } from '../../__fixtures__/typeorm/project/dto/project-create.dto'; -import { ProjectPaginatedDto } from '../../__fixtures__/typeorm/project/dto/project-paginated.dto'; -import { ProjectDto } from '../../__fixtures__/typeorm/project/dto/project.dto'; -import { ProjectCrudService } from '../../__fixtures__/typeorm/project/project-crud.service'; -import { ProjectTypeOrmCrudAdapter } from '../../__fixtures__/typeorm/project/project-typeorm-crud.adapter'; -import { ProjectEntity } from '../../__fixtures__/typeorm/project/project.entity'; -import { Seeds } from '../../__fixtures__/typeorm/seeds'; -import { UserPaginatedDto } from '../../__fixtures__/typeorm/users/dto/user-paginated.dto'; -import { UserDto } from '../../__fixtures__/typeorm/users/dto/user.dto'; -import { UserCrudService } from '../../__fixtures__/typeorm/users/user-crud.service'; -import { UserTypeOrmCrudAdapter } from '../../__fixtures__/typeorm/users/user-typeorm-crud.adapter'; -import { UserEntity } from '../../__fixtures__/typeorm/users/user.entity'; -import { CrudGetMany } from '../../crud/decorators/actions/crud-get-many.decorator'; -import { CrudGetOne } from '../../crud/decorators/actions/crud-get-one.decorator'; -import { CrudUpdateOne } from '../../crud/decorators/actions/crud-update-one.decorator'; -import { CrudController } from '../../crud/decorators/controller/crud-controller.decorator'; -import { CrudBody } from '../../crud/decorators/params/crud-body.decorator'; -import { CrudRequest } from '../../crud/decorators/params/crud-request.decorator'; -import { CrudAllow } from '../../crud/decorators/routes/crud-allow.decorator'; -import { CrudExclude } from '../../crud/decorators/routes/crud-exclude.decorator'; -import { CrudFilter } from '../../crud/decorators/routes/crud-filter.decorator'; -import { CrudLimit } from '../../crud/decorators/routes/crud-limit.decorator'; -import { CrudMaxLimit } from '../../crud/decorators/routes/crud-max-limit.decorator'; -import { CrudSort } from '../../crud/decorators/routes/crud-sort.decorator'; -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; -import { CrudModule } from '../../crud.module'; -import { CrudRequestQueryBuilder } from '../../request/crud-request-query.builder'; - -// tslint:disable:max-classes-per-file -describe('#crud-typeorm', () => { - describe('#query params', () => { - let app: INestApplication; - let server: ReturnType; - let qb: CrudRequestQueryBuilder; - - @CrudController({ - path: 'companies', - model: { - type: CompanyDto, - paginatedType: CompanyPaginatedDto, - }, - }) - @CrudExclude(['updatedAt']) - @CrudFilter({ id: { $ne: 1 } }) - @CrudAllow(['id', 'name', 'domain', 'description']) - @CrudMaxLimit(5) - class CompaniesController { - constructor(public service: CompanyCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - } - - @CrudController({ - path: 'projects', - model: { type: ProjectDto, paginatedType: ProjectPaginatedDto }, - params: { - id: { - field: 'id', - type: 'number', - primary: true, - }, - }, - }) - @CrudSort([{ field: 'id', order: 'ASC' }]) - @CrudLimit(100) - class ProjectsController { - constructor(public service: ProjectCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - - @CrudGetOne() - getOne(@CrudRequest() request: CrudRequestInterface) { - return this.service.getOne(request); - } - - @CrudUpdateOne() - updateOne( - @CrudRequest() request: CrudRequestInterface, - @CrudBody() project: ProjectCreateDto, - ) { - return this.service.updateOne(request, project); - } - } - - @CrudController({ - path: 'projects2', - model: { type: ProjectDto, paginatedType: ProjectPaginatedDto }, - }) - class ProjectsController2 { - constructor(public service: ProjectCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - } - - @CrudController({ - path: 'projects3', - model: { type: ProjectDto, paginatedType: ProjectPaginatedDto }, - }) - @CrudFilter({ isActive: false }) - class ProjectsController3 { - constructor(public service: ProjectCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - } - - @CrudController({ - path: 'projects4', - model: { type: ProjectDto, paginatedType: ProjectPaginatedDto }, - }) - @CrudFilter({ isActive: true }) - class ProjectsController4 { - constructor(public service: ProjectCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - } - - @CrudController({ - path: 'users', - model: { type: UserDto, paginatedType: UserPaginatedDto }, - }) - class UsersController { - constructor(public service: UserCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - } - - @CrudController({ - path: 'notes', - model: { type: NoteDto, paginatedType: NotePaginatedDto }, - }) - class NotesController { - constructor(public service: NoteCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - } - - beforeAll(async () => { - const fixture = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot({ ...ormSqliteConfig }), - TypeOrmExtModule.forFeature({ - [CRUD_TEST_COMPANY_ENTITY_KEY]: { - entity: CompanyEntity, - }, - [CRUD_TEST_PROJECT_ENTITY_KEY]: { - entity: ProjectEntity, - }, - [CRUD_TEST_USER_ENTITY_KEY]: { - entity: UserEntity, - }, - [CRUD_TEST_NOTE_ENTITY_KEY]: { - entity: NoteEntity, - }, - }), - CrudModule.forRoot({}), - ], - controllers: [ - CompaniesController, - ProjectsController, - ProjectsController2, - ProjectsController3, - ProjectsController4, - UsersController, - NotesController, - ], - providers: [ - { provide: APP_FILTER, useClass: ExceptionsFilter }, - CompanyTypeOrmCrudAdapter, - CompanyCrudService, - UserTypeOrmCrudAdapter, - UserCrudService, - ProjectTypeOrmCrudAdapter, - ProjectCrudService, - NoteTypeOrmCrudAdapter, - NoteCrudService, - ], - }).compile(); - - app = fixture.createNestApplication(); - - await app.init(); - - server = app.getHttpServer(); - - const datasource = app.get(getDataSourceToken()); - const seeds = new Seeds(); - await seeds.up(datasource.createQueryRunner()); - }); - - beforeEach(() => { - qb = CrudRequestQueryBuilder.create(); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('#select', () => { - it('should throw status 400', async () => { - qb.setFilter({ field: 'invalid', operator: '$isnull' }); - const res = await request(server) - .get('/companies') - .query(qb.queryObject); - expect(res.status).toBe(500); - }); - }); - - describe('#query filter', () => { - it('should return data with limit', async () => { - qb.setLimit(4); - const res = await request(server) - .get('/companies') - .query(qb.queryObject); - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(4); - res.body.data.forEach((e: CompanyEntity) => { - expect(e.id).not.toBe(1); - }); - }); - it('should return with maxLimit', async () => { - qb.setLimit(7); - const res = await request(server) - .get('/companies') - .query(qb.queryObject); - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(5); - }); - it('should return with filter and or, 1', async () => { - qb.setFilter({ - field: 'name', - operator: '$notin', - value: ['Name2', 'Name3'], - }).setOr({ field: 'domain', operator: '$cont', value: 5 }); - const res = await request(server) - .get('/companies') - .query(qb.queryObject); - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(5); - }); - it('should return with filter and or, 2', async () => { - qb.setFilter({ field: 'name', operator: '$ends', value: 'foo' }) - .setOr({ field: 'name', operator: '$starts', value: 'P' }) - .setOr({ field: 'isActive', operator: '$eq', value: true }); - const res = await request(server) - .get('/projects') - .query(qb.queryObject); - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(10); - }); - it('should return with filter and or, 3', async () => { - qb.setOr({ field: 'companyId', operator: '$gt', value: 22 }) - .setFilter({ field: 'companyId', operator: '$gte', value: 6 }) - .setFilter({ field: 'companyId', operator: '$lt', value: 10 }); - const res = await request(server) - .get('/projects') - .query(qb.queryObject); - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(8); - }); - it('should return with filter and or, 4', async () => { - qb.setOr({ field: 'companyId', operator: '$in', value: [6, 10] }) - .setOr({ field: 'companyId', operator: '$lte', value: 10 }) - .setFilter({ field: 'isActive', operator: '$eq', value: false }) - .setFilter({ field: 'description', operator: '$notnull' }); - const res = await request(server) - .get('/projects') - .query(qb.queryObject); - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(10); - }); - it('should return with filter and or, 6', async () => { - qb.setOr({ field: 'companyId', operator: '$isnull' }); - const res = await request(server) - .get('/projects') - .query(qb.queryObject); - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(0); - }); - it('should return with filter and or, 6', async () => { - qb.setOr({ field: 'companyId', operator: '$between', value: [1, 5] }); - const res = await request(server) - .get('/projects') - .query(qb.queryObject); - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(10); - }); - it('should return with filter, 1', async () => { - qb.setOr({ field: 'companyId', operator: '$eq', value: 1 }); - const res = await request(server) - .get('/projects') - .query(qb.queryObject); - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(2); - }); - }); - - describe('#sort', () => { - it('should sort by field', async () => { - qb.sortBy({ field: 'id', order: 'DESC' }); - const res = await request(server) - .get('/users') - .query(qb.queryObject) - .expect(200); - expect(res.body.data[1].id).toBeLessThan(res.body.data[0].id); - }); - - it('should throw 400 if SQL injection has been detected', async () => { - qb.sortBy({ - field: ' ASC; SELECT CAST( version() AS INTEGER); --', - order: 'DESC', - }); - - const res = await request(server) - .get('/companies') - .query(qb.queryObject); - expect(res.status).toBeGreaterThanOrEqual(400); - }); - }); - - describe('#search', () => { - const projects2 = () => request(server).get('/projects2'); - const projects3 = () => request(server).get('/projects3'); - const projects4 = () => request(server).get('/projects4'); - - it('should return with search, 1', async () => { - const query = qb.search({ id: 1 }).query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(1); - }); - it('should return with search, 2', async () => { - const query = qb.search({ id: 1, name: 'Project1' }).query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(1); - }); - it('should return with search, 3', async () => { - const query = qb.search({ id: 1, name: { $eq: 'Project1' } }).query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(1); - }); - it('should return with search, 4', async () => { - const query = qb.search({ name: { $eq: 'Project1' } }).query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(1); - }); - it('should return with search, 5', async () => { - const query = qb.search({ id: { $notnull: true, $eq: 1 } }).query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(1); - }); - it('should return with search, 6', async () => { - const query = qb - .search({ id: { $or: { $isnull: true, $eq: 1 } } }) - .query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(1); - }); - it('should return with search, 7', async () => { - const query = qb.search({ id: { $or: { $eq: 1 } } }).query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(1); - }); - it('should return with search, 8', async () => { - const query = qb - .search({ id: { $notnull: true, $or: { $eq: 1, $in: [30, 31] } } }) - .query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(1); - }); - it('should return with search, 9', async () => { - const query = qb - .search({ id: { $notnull: true, $or: { $eq: 1 } } }) - .query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(1); - }); - it('should return with search, 10', async () => { - const query = qb.search({ id: null }).query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(0); - }); - it('should return with search, 11', async () => { - const query = qb - .search({ - $and: [{ id: { $notin: [5, 6, 7, 8, 9, 10] } }, { isActive: true }], - }) - .query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(4); - }); - it('should return with search, 12', async () => { - const query = qb - .search({ $and: [{ id: { $notin: [5, 6, 7, 8, 9, 10] } }] }) - .query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(14); - }); - it('should return with search, 13', async () => { - const query = qb.search({ $or: [{ id: 54 }] }).query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(0); - }); - it('should return with search, 14', async () => { - const query = qb - .search({ $or: [{ id: 54 }, { id: 33 }, { id: { $in: [1, 2] } }] }) - .query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(2); - expect(res.body.data[0].id).toBe(1); - expect(res.body.data[1].id).toBe(2); - }); - it('should return with search, 15', async () => { - const query = qb - .search({ $or: [{ id: 54 }], name: 'Project1' }) - .query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(0); - }); - it('should return with search, 16', async () => { - const query = qb - .search({ $or: [{ isActive: false }, { id: 3 }], name: 'Project3' }) - .query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(3); - }); - it('should return with search, 17', async () => { - const query = qb - .search({ - $or: [{ isActive: false }, { id: { $eq: 3 } }], - name: 'Project3', - }) - .query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(3); - }); - it('should return with search, 17', async () => { - const query = qb - .search({ - $or: [{ isActive: false }, { id: { $eq: 3 } }], - name: { $eq: 'Project3' }, - }) - .query(); - const res = await projects2().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(3); - }); - it('should return with default filter, 1', async () => { - const query = qb.search({ name: 'Project11' }).query(); - const res = await projects3().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(11); - }); - it('should return with default filter, 2', async () => { - const query = qb.search({ name: 'Project1' }).query(); - const res = await projects3().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(0); - }); - it('should return with default filter, 3', async () => { - const query = qb.search({ name: 'Project2' }).query(); - const res = await projects4().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].id).toBe(2); - }); - it('should return with default filter, 4', async () => { - const query = qb.search({ name: 'Project11' }).query(); - const res = await projects4().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(0); - }); - it('should return with $eqL search operator', async () => { - const query = qb.search({ name: { $eqL: 'project1' } }).query(); - const res = await projects4().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(1); - }); - it('should return with $neL search operator', async () => { - const query = qb.search({ name: { $neL: 'project1' } }).query(); - const res = await projects4().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(9); - }); - it('should return with $startsL search operator', async () => { - const query = qb.search({ email: { $startsL: '2' } }).query(); - const res = await request(server) - .get('/users') - .query(query) - .expect(200); - expect(res.body.data).toBeArrayOfSize(3); - }); - it('should return with $endsL search operator', async () => { - const query = qb.search({ domain: { $endsL: 'AiN10' } }).query(); - const res = await request(server) - .get('/companies') - .query(query) - .expect(200); - expect(res.body.data).toBeArrayOfSize(1); - expect(res.body.data[0].domain).toBe('Domain10'); - }); - it('should return with $contL search operator', async () => { - const query = qb.search({ email: { $contL: '1@' } }).query(); - const res = await request(server) - .get('/users') - .query(query) - .expect(200); - expect(res.body.data).toBeArrayOfSize(3); - }); - it('should return with $exclL search operator', async () => { - const query = qb.search({ email: { $exclL: '1@' } }).query(); - const res = await request(server) - .get('/users') - .query(query) - .expect(200); - expect(res.body.data).toBeArrayOfSize(18); - }); - it('should return with $inL search operator', async () => { - const query = qb.search({ name: { $inL: ['name2', 'name3'] } }).query(); - const res = await request(server) - .get('/companies') - .query(query) - .expect(200); - expect(res.body.data).toBeArrayOfSize(2); - }); - it('should return with $notinL search operator', async () => { - const query = qb - .search({ name: { $notinL: ['project7', 'project8', 'project9'] } }) - .query(); - const res = await projects4().query(query).expect(200); - expect(res.body.data).toBeArrayOfSize(7); - }); - it('should search by display column name, but use dbName in sql query', async () => { - const query = qb.search({ revisionId: 2 }).query(); - const res = await request(server) - .get('/notes') - .query(query) - .expect(200); - expect(res.body.data).toBeArrayOfSize(2); - expect(res.body.data[0].revisionId).toBe(2); - expect(res.body.data[1].revisionId).toBe(2); - }); - }); - - describe('#update', () => { - it('should update company id of project', async () => { - await request(server) - .patch('/projects/18') - .send({ companyId: 10 }) - .expect(200); - - const modified = await request(server).get('/projects/18').expect(200); - - expect(modified.body.companyId).toBe(10); - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/c.basic-crud.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/c.basic-crud.spec.ts deleted file mode 100644 index ea1307561..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/c.basic-crud.spec.ts +++ /dev/null @@ -1,647 +0,0 @@ -import request from 'supertest'; -import { DataSource } from 'typeorm'; - -import { INestApplication } from '@nestjs/common'; -import { APP_FILTER } from '@nestjs/core'; -import { Test } from '@nestjs/testing'; -import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; - -import { ExceptionsFilter } from '@concepta/nestjs-common'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { - CRUD_TEST_COMPANY_ENTITY_KEY, - CRUD_TEST_DEVICE_ENTITY_KEY, -} from '../../__fixtures__/crud-test.constants'; -import { CompanyCrudService } from '../../__fixtures__/typeorm/company/company-crud.service'; -import { CompanyTypeOrmCrudAdapter } from '../../__fixtures__/typeorm/company/company-typeorm-crud.adapter'; -import { CompanyEntity } from '../../__fixtures__/typeorm/company/company.entity'; -import { CompanyCreateManyDto } from '../../__fixtures__/typeorm/company/dto/company-create-many.dto'; -import { CompanyCreateDto } from '../../__fixtures__/typeorm/company/dto/company-create.dto'; -import { CompanyPaginatedDto } from '../../__fixtures__/typeorm/company/dto/company-paginated.dto'; -import { CompanyUpdateDto } from '../../__fixtures__/typeorm/company/dto/company-update.dto'; -import { CompanyDto } from '../../__fixtures__/typeorm/company/dto/company.dto'; -import { DeviceCrudService } from '../../__fixtures__/typeorm/device/device-crud.service'; -import { DeviceTypeOrmCrudAdapter } from '../../__fixtures__/typeorm/device/device-typeorm-crud.adapter'; -import { DeviceEntity } from '../../__fixtures__/typeorm/device/device.entity'; -import { DeviceCreateDto } from '../../__fixtures__/typeorm/device/dto/device-create.dto'; -import { DeviceDto } from '../../__fixtures__/typeorm/device/dto/device.dto'; -import { ormSqliteConfig } from '../../__fixtures__/typeorm/orm.sqlite.config'; -import { ProjectEntity } from '../../__fixtures__/typeorm/project/project.entity'; -import { Seeds } from '../../__fixtures__/typeorm/seeds'; -import { UserEntity } from '../../__fixtures__/typeorm/users/user.entity'; -import { CrudCreateMany } from '../../crud/decorators/actions/crud-create-many.decorator'; -import { CrudCreateOne } from '../../crud/decorators/actions/crud-create-one.decorator'; -import { CrudDeleteOne } from '../../crud/decorators/actions/crud-delete-one.decorator'; -import { CrudGetMany } from '../../crud/decorators/actions/crud-get-many.decorator'; -import { CrudGetOne } from '../../crud/decorators/actions/crud-get-one.decorator'; -import { CrudRecoverOne } from '../../crud/decorators/actions/crud-recover-one.decorator'; -import { CrudReplaceOne } from '../../crud/decorators/actions/crud-replace-one.decorator'; -import { CrudUpdateOne } from '../../crud/decorators/actions/crud-update-one.decorator'; -import { CrudController } from '../../crud/decorators/controller/crud-controller.decorator'; -import { CrudBody } from '../../crud/decorators/params/crud-body.decorator'; -import { CrudRequest } from '../../crud/decorators/params/crud-request.decorator'; -import { CrudLimit } from '../../crud/decorators/routes/crud-limit.decorator'; -import { CrudSoftDelete } from '../../crud/decorators/routes/crud-soft-delete.decorator'; -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; -import { CrudModule } from '../../crud.module'; -import { CrudRequestQueryBuilder } from '../../request/crud-request-query.builder'; - -const isMysql = process.env.TYPEORM_CONNECTION === 'mysql'; - -// tslint:disable:max-classes-per-file no-shadowed-variable -describe('#crud-typeorm', () => { - describe('#basic crud respects global limit', () => { - let app: INestApplication; - let server: ReturnType; - - @CrudController({ - path: 'companies0', - model: { - type: CompanyDto, - paginatedType: CompanyPaginatedDto, - }, - }) - @CrudLimit(3) - class CompaniesController0 { - constructor(public service: CompanyCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - } - - beforeAll(async () => { - const fixture = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot(ormSqliteConfig), - TypeOrmExtModule.forFeature({ - [CRUD_TEST_COMPANY_ENTITY_KEY]: { - entity: CompanyEntity, - }, - }), - CrudModule.forRoot({}), - ], - controllers: [CompaniesController0], - providers: [ - { provide: APP_FILTER, useClass: ExceptionsFilter }, - CompanyTypeOrmCrudAdapter, - CompanyCrudService, - ], - }).compile(); - - app = fixture.createNestApplication(); - - await app.init(); - server = app.getHttpServer(); - - const datasource = app.get(getDataSourceToken()); - const seeds = new Seeds(); - await seeds.up(datasource.createQueryRunner()); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('#getAll', () => { - it('should return an array of all entities', (done) => { - request(server) - .get('/companies0') - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(3); - expect(res.body.page).toBe(1); - done(); - }); - }); - }); - }); - - describe('#basic crud default', () => { - let app: INestApplication; - let server: ReturnType; - let qb: CrudRequestQueryBuilder; - - @CrudController({ - path: 'companies', - model: { - type: CompanyDto, - paginatedType: CompanyPaginatedDto, - }, - }) - class CompaniesController { - constructor(public service: CompanyCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - } - - beforeAll(async () => { - const fixture = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot(ormSqliteConfig), - TypeOrmExtModule.forFeature({ - [CRUD_TEST_COMPANY_ENTITY_KEY]: { - entity: CompanyEntity, - }, - }), - CrudModule.forRoot({}), - ], - controllers: [CompaniesController], - providers: [ - { provide: APP_FILTER, useClass: ExceptionsFilter }, - CompanyTypeOrmCrudAdapter, - CompanyCrudService, - ], - }).compile(); - - app = fixture.createNestApplication(); - - await app.init(); - server = app.getHttpServer(); - - const datasource = app.get(getDataSourceToken()); - const seeds = new Seeds(); - await seeds.up(datasource.createQueryRunner()); - }); - - beforeEach(() => { - qb = CrudRequestQueryBuilder.create(); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('#getAll', () => { - it('should return an array of all entities', (done) => { - request(server) - .get('/companies') - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(9); - expect(res.body.page).toBe(1); - done(); - }); - }); - it('should return an entities with limit', (done) => { - const query = qb.setLimit(5).query(); - request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(5); - expect(res.body.page).toBe(1); - done(); - }); - }); - it('should return an entities with limit and page', (done) => { - const query = qb - .setLimit(3) - .setPage(1) - .sortBy({ field: 'id', order: 'DESC' }) - .query(); - request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(3); - expect(res.body.count).toBe(3); - expect(res.body.page).toBe(1); - done(); - }); - }); - }); - }); - - describe('#basic crud', () => { - let app: INestApplication; - let server: ReturnType; - let qb: CrudRequestQueryBuilder; - - @CrudController({ - path: 'companies', - model: { - type: CompanyDto, - paginatedType: CompanyPaginatedDto, - }, - params: { - id: { - field: 'id', - type: 'number', - primary: true, - }, - }, - // query: { - // softDelete: true, - // }, - }) - @CrudSoftDelete(true) - class CompaniesController { - constructor(public service: CompanyCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - - @CrudGetOne() - getOne(@CrudRequest() request: CrudRequestInterface) { - return this.service.getOne(request); - } - - @CrudCreateOne() - createOne( - @CrudRequest() request: CrudRequestInterface, - @CrudBody() dto: CompanyCreateDto, - ) { - return this.service.createOne(request, dto); - } - - @CrudCreateMany({ path: 'bulk' }) - createMany( - @CrudRequest() request: CrudRequestInterface, - @CrudBody() dto: CompanyCreateManyDto, - ) { - return this.service.createMany(request, dto); - } - - @CrudUpdateOne() - updateOne( - @CrudRequest() request: CrudRequestInterface, - @CrudBody() dto: CompanyUpdateDto, - ) { - return this.service.updateOne(request, dto); - } - - @CrudReplaceOne() - replaceOne( - @CrudRequest() request: CrudRequestInterface, - @CrudBody() dto: CompanyCreateDto, - ) { - return this.service.replaceOne(request, dto); - } - - @CrudDeleteOne({ returnDeleted: true }) - deleteOne(@CrudRequest() request: CrudRequestInterface) { - return this.service.deleteOne(request); - } - - @CrudRecoverOne({ path: ':id/recover' }) - recoverOne(@CrudRequest() request: CrudRequestInterface) { - return this.service.recoverOne(request); - } - } - - @CrudController({ - path: 'devices', - model: { type: DeviceDto }, - params: { - deviceKey: { - field: 'deviceKey', - type: 'uuid', - primary: true, - }, - }, - }) - class DevicesController { - constructor(public service: DeviceCrudService) {} - - @CrudCreateOne({ returnShallow: true }) - createOne( - @CrudRequest() request: CrudRequestInterface, - @CrudBody() dto: DeviceCreateDto, - ) { - return this.service.createOne(request, dto); - } - } - - beforeAll(async () => { - const fixture = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot({ ...ormSqliteConfig, logging: false }), - TypeOrmModule.forFeature([ProjectEntity, UserEntity]), - TypeOrmExtModule.forFeature({ - [CRUD_TEST_COMPANY_ENTITY_KEY]: { - entity: CompanyEntity, - }, - [CRUD_TEST_DEVICE_ENTITY_KEY]: { - entity: DeviceEntity, - }, - }), - CrudModule.forRoot({}), - ], - controllers: [CompaniesController, DevicesController], - providers: [ - { provide: APP_FILTER, useClass: ExceptionsFilter }, - CompanyTypeOrmCrudAdapter, - CompanyCrudService, - DeviceTypeOrmCrudAdapter, - DeviceCrudService, - ], - }).compile(); - - app = fixture.createNestApplication(); - // service = app.get(CompanyService); - - await app.init(); - server = app.getHttpServer(); - - const datasource = app.get(getDataSourceToken()); - const seeds = new Seeds(); - await seeds.up(datasource.createQueryRunner()); - }); - - beforeEach(() => { - qb = CrudRequestQueryBuilder.create(); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('#getAll', () => { - it('should return an array of all entities', (done) => { - request(server) - .get('/companies?include_deleted=1') - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(10); - done(); - }); - }); - it('should return an entities with limit', (done) => { - const query = qb.setLimit(5).query(); - request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(5); - done(); - }); - }); - it('should return an entities with limit and page', (done) => { - const query = qb - .setLimit(3) - .setPage(1) - .sortBy({ field: 'id', order: 'DESC' }) - .query(); - request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(3); - expect(res.body.count).toBe(3); - expect(res.body.total).toBe(9); - expect(res.body.page).toBe(1); - expect(res.body.pageCount).toBe(3); - done(); - }); - }); - it('should return an entities with offset', (done) => { - const queryObj = qb.setOffset(3); - if (isMysql) { - queryObj.setLimit(10); - } - const query = queryObj.query(); - request(server) - .get('/companies') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - if (isMysql) { - expect(res.body.count).toBe(6); - expect(res.body.data.length).toBe(6); - } else { - expect(res.body.data.length).toBe(6); - } - done(); - }); - }); - }); - - describe('#getOne', () => { - it('should return status 404', (done) => { - request(server) - .get('/companies/333') - .end((_, res) => { - expect(res.status).toBe(404); - done(); - }); - }); - it('should return status 404 for deleted entity', (done) => { - request(server) - .get('/companies/9') - .end((_, res) => { - expect(res.status).toBe(404); - done(); - }); - }); - it('should return a deleted entity if include_deleted query param is specified', (done) => { - request(server) - .get('/companies/9?include_deleted=1') - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.id).toBe(9); - done(); - }); - }); - it('should return an entity, 1', (done) => { - request(server) - .get('/companies/1') - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.id).toBe(1); - done(); - }); - }); - it('should return an entity, 2', (done) => { - const query = qb.select(['domain']).query(); - request(server) - .get('/companies/1') - .query(query) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.id).toBe(1); - expect(res.body.domain).toBeTruthy(); - done(); - }); - }); - }); - - describe('#createOne', () => { - it('should return status 400', (done) => { - request(server) - .post('/companies') - .send('') - .end((_, res) => { - expect(res.status).toBe(400); - done(); - }); - }); - it('should return saved entity', (done) => { - const dto = { - name: 'test0', - domain: 'test0', - }; - request(server) - .post('/companies') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(201); - expect(res.body.id).toBeTruthy(); - done(); - }); - }); - it('should return with `returnShallow`', (done) => { - const dto = { description: 'returnShallow is true' }; - request(server) - .post('/devices') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(201); - expect(res.body.deviceKey).toBeTruthy(); - expect(res.body.description).toBeTruthy(); - done(); - }); - }); - }); - - describe('#createMany', () => { - it('should return status 400', (done) => { - const dto = { bulk: [] }; - request(server) - .post('/companies/bulk') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(400); - done(); - }); - }); - it('should return created entities', (done) => { - const dto = { - bulk: [ - { - name: 'test1', - domain: 'test1', - }, - { - name: 'test2', - domain: 'test2', - }, - ], - }; - request(server) - .post('/companies/bulk') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(201); - expect(res.body[0].id).toBeTruthy(); - expect(res.body[1].id).toBeTruthy(); - done(); - }); - }); - }); - - describe('#updateOne', () => { - it('should return status 404', (done) => { - const dto = { name: 'updated0' }; - request(server) - .patch('/companies/333') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(404); - done(); - }); - }); - it('should return updated entity, 1', (done) => { - const dto = { name: 'updated0' }; - request(server) - .patch('/companies/1') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.name).toBe('updated0'); - done(); - }); - }); - }); - - describe('#replaceOne', () => { - it('should create entity', (done) => { - const dto = { name: 'updated0', domain: 'domain0' }; - request(server) - .put('/companies/333') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.name).toBe('updated0'); - done(); - }); - }); - it('should return updated entity, 1', (done) => { - const dto = { name: 'updated0' }; - request(server) - .put('/companies/1') - .send(dto) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.name).toBe('updated0'); - done(); - }); - }); - }); - - describe('#deleteOne', () => { - it('should return status 404', (done) => { - request(server) - .delete('/companies/3333') - .end((_, res) => { - expect(res.status).toBe(404); - done(); - }); - }); - it('should softly delete entity', (done) => { - request(server) - .delete('/companies/5') - .end((_, res) => { - expect(res.status).toBe(200); - done(); - }); - }); - it('should not return softly deleted entity', (done) => { - request(server) - .get('/companies/5') - .end((_, res) => { - expect(res.status).toBe(404); - done(); - }); - }); - it('should recover softly deleted entity', (done) => { - request(server) - .patch('/companies/5/recover') - .end((_, res) => { - expect(res.status).toBe(200); - done(); - }); - }); - it('should return recovered entity', (done) => { - request(server) - .get('/companies/5') - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.id).toBe(5); - done(); - }); - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/combined-filters.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/combined-filters.spec.ts deleted file mode 100644 index 89064bb90..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/combined-filters.spec.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { createPaginatedResponse } from '../../__FIXTURES__/crud-federation-mock-helpers'; -import { - assertServiceCallCounts, - assertResultStructure, - assertEnrichment, - assertRelationRequest, - assertRootGetManyRequest, - assertRootFirst, -} from '../../__FIXTURES__/crud-federation-test-assertions'; -import { - createOneToManyForwardRelation, - TestRelationService, -} from '../../__FIXTURES__/crud-federation-test-entities'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -/** - * Behavior tests for combined root and relation filters with pagination - * Tests the interaction between root-side and relation-side filters - * with proper INNER JOIN behavior and pagination handling - */ -describe('CrudFederationService - Behavior: Combined Root+Relation Filters', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - // Register the 'relations' relation that tests use - mocks.registerRelation(mocks.mockRelationService); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('Combined Filters with Pagination', () => { - it('should handle root filter + relation filter with page 1', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - const req = mocks.createTestRequest( - { - filter: ['name||$cont||Project', 'relations.status||$eq||active'], - page: '1', - limit: '3', - }, - [relation], - ); - - // Test data - 5 roots match name filter, but only 3 have active relations - const activeRelations = [ - { - id: 1, - rootId: 1, - title: 'Feature A', - status: 'active', - isLatest: true, - }, - { - id: 2, - rootId: 2, - title: 'Feature B', - status: 'active', - isLatest: true, - }, - { - id: 3, - rootId: 4, - title: 'Feature C', - status: 'active', - isLatest: true, - }, - // Root 3 and 5 have inactive relations or no relations - ]; - - const page1ProjectRoots = [ - { id: 1, name: 'Project Alpha' }, - { id: 2, name: 'Project Beta' }, - { id: 4, name: 'Project Delta' }, - ]; - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(activeRelations, { total: 3 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(page1ProjectRoots, { limit: 3, total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 2 }, // 1 total count + 1 data retrieval - { service: mocks.mockRelationService, count: 2 }, // 1 constraint discovery + 1 enrichment - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify relation filter applied first (constraint discovery call) - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [{ status: { $eq: 'active' } }, { isLatest: { $eq: true } }], - }, - limit: 3, - offset: 0, - }, - 0, - ); - - // Verify enrichment call (relation filter + root ID constraints) - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { status: { $eq: 'active' } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [1, 2, 4] } }, - ], - }, - }, - 1, - ); - - // Verify root total count call (first call - index 0) - only has original root filters - assertRootGetManyRequest( - mocks.mockRootService, - { - search: { name: { $cont: 'Project' } }, - page: 1, - limit: 1, - }, - 0, - ); - - // Verify root filter + discovered root IDs constraint (data retrieval call - index 1) - assertRootGetManyRequest( - mocks.mockRootService, - { - search: { - $and: [{ name: { $cont: 'Project' } }, { id: { $in: [1, 2, 4] } }], - }, - page: 1, - limit: 3, - }, - 1, - ); - - // ASSERT - Result verification - assertResultStructure(result, { count: 3, total: 3 }); - expect(result.page).toBe(1); - expect(result.pageCount).toBe(1); - - assertEnrichment(result, 'relations', { - 1: [ - { - id: 1, - rootId: 1, - title: 'Feature A', - status: 'active', - isLatest: true, - }, - ], - 2: [ - { - id: 2, - rootId: 2, - title: 'Feature B', - status: 'active', - isLatest: true, - }, - ], - 4: [ - { - id: 3, - rootId: 4, - title: 'Feature C', - status: 'active', - isLatest: true, - }, - ], - }); - }); - - it('should handle root filter + relation filter with page 2', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - const req = mocks.createTestRequest( - { - filter: ['name||$cont||Task', 'relations.priority||$gte||5'], - page: '2', - limit: '2', - }, - [relation], - ); - - // Test data - roots with Task names and high priority relations - const highPriorityRelations = [ - { id: 1, rootId: 1, title: 'Critical Task', priority: 10 }, - { id: 2, rootId: 2, title: 'High Task A', priority: 8 }, - { id: 3, rootId: 3, title: 'High Task B', priority: 7 }, - { id: 4, rootId: 5, title: 'Medium Task', priority: 5 }, - { id: 5, rootId: 6, title: 'Important Task', priority: 6 }, - ]; - - // Page 2 of Task roots (with pagination applied) - const page2TaskRoots = [ - { id: 5, name: 'Task Manager' }, - { id: 6, name: 'Task Scheduler' }, - ]; - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(highPriorityRelations, { total: 5 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(page2TaskRoots, { limit: 2, total: 5 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 2 }, // 1 total count + 1 data retrieval - { service: mocks.mockRelationService, count: 2 }, // 1 constraint discovery + 1 enrichment - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify relation filter applied first (constraint discovery with proper pagination offset for page 2) - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [{ priority: { $gte: 5 } }, { isLatest: { $eq: true } }], - }, - limit: 2, - offset: 2, // Page 2: (2-1) * 2 = 2 - }); - - // Verify root total count call (first call - index 0) - only has original root filters - assertRootGetManyRequest( - mocks.mockRootService, - { - search: { name: { $cont: 'Task' } }, - page: 1, - limit: 1, - }, - 0, - ); - - // Verify root filter + discovered root IDs constraint (data retrieval call - index 1) - assertRootGetManyRequest( - mocks.mockRootService, - { - search: { - $and: [ - { name: { $cont: 'Task' } }, - { id: { $in: [1, 2, 3, 5, 6] } }, - ], - }, - page: 1, - limit: 2, - }, - 1, - ); - - // ASSERT - Result verification - assertResultStructure(result, { count: 2, total: 5 }); - expect(result.page).toBe(2); - expect(result.pageCount).toBe(3); - - assertEnrichment(result, 'relations', { - 5: [{ id: 4, rootId: 5, title: 'Medium Task', priority: 5 }], - 6: [{ id: 5, rootId: 6, title: 'Important Task', priority: 6 }], - }); - }); - - it('should handle multiple root filters + relation filters with pagination', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - const req = mocks.createTestRequest( - { - filter: [ - 'name||$cont||Project', - 'companyId||$eq||1', - 'relations.status||$eq||active', - 'relations.priority||$gte||7', - ], - page: '1', - limit: '5', - }, - [relation], - ); - - // Test data - complex filter scenario - const activeHighPriorityRelations = [ - { - id: 1, - rootId: 1, - title: 'Critical Feature', - status: 'active', - priority: 10, - }, - { - id: 2, - rootId: 3, - title: 'High Priority Task', - status: 'active', - priority: 8, - }, - { - id: 3, - rootId: 4, - title: 'Important Feature', - status: 'active', - priority: 7, - }, - ]; - - const filteredProjectRoots = [ - { id: 1, name: 'Project Alpha', companyId: 1 }, - { id: 3, name: 'Project Gamma', companyId: 1 }, - { id: 4, name: 'Project Delta', companyId: 1 }, - ]; - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(activeHighPriorityRelations, { total: 3 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(filteredProjectRoots, { limit: 5, total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 2 }, // 1 total count + 1 data retrieval - { service: mocks.mockRelationService, count: 2 }, // 1 constraint discovery + 1 enrichment - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify relation filters applied first (AND condition, constraint discovery with limit) - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [ - { status: { $eq: 'active' } }, - { priority: { $gte: 7 } }, - { isLatest: { $eq: true } }, - ], - }, - limit: 5, - offset: 0, - }); - - // Verify root total count call (first call - index 0) - only has original root filters - assertRootGetManyRequest( - mocks.mockRootService, - { - search: { - $and: [{ name: { $cont: 'Project' } }, { companyId: { $eq: 1 } }], - }, - page: 1, - limit: 1, - }, - 0, - ); - - // Verify multiple root filters + discovered root IDs constraint (data retrieval call - index 1) - assertRootGetManyRequest( - mocks.mockRootService, - { - search: { - $and: [ - { name: { $cont: 'Project' } }, - { companyId: { $eq: 1 } }, - { id: { $in: [1, 3, 4] } }, - ], - }, - page: 1, - limit: 5, // Should match user-requested limit - }, - 1, - ); - - // ASSERT - Result verification - assertResultStructure(result, { count: 3, total: 3 }); - expect(result.page).toBe(1); - expect(result.pageCount).toBe(1); - - assertEnrichment(result, 'relations', { - 1: [ - { - id: 1, - rootId: 1, - title: 'Critical Feature', - status: 'active', - priority: 10, - }, - ], - 3: [ - { - id: 2, - rootId: 3, - title: 'High Priority Task', - status: 'active', - priority: 8, - }, - ], - 4: [ - { - id: 3, - rootId: 4, - title: 'Important Feature', - status: 'active', - priority: 7, - }, - ], - }); - }); - - it('should handle combined filters when results are reduced below page size', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - const req = mocks.createTestRequest( - { - filter: [ - 'name||$cont||Enterprise', - 'relations.status||$eq||critical', - ], - page: '1', - limit: '10', // Request 10 but only 2 results match both filters - }, - [relation], - ); - - const criticalRelations = [ - { id: 1, rootId: 2, title: 'System Outage', status: 'critical' }, - { id: 2, rootId: 5, title: 'Security Breach', status: 'critical' }, - ]; - - const enterpriseRoots = [ - { id: 2, name: 'Enterprise Suite' }, - { id: 5, name: 'Enterprise Security' }, - ]; - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(criticalRelations, { total: 2 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(enterpriseRoots, { limit: 10, total: 2 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 2 }, // 1 total count + 1 data retrieval - { service: mocks.mockRelationService, count: 2 }, // 1 constraint discovery + 1 enrichment - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify relation filter applied first (constraint discovery with limit) - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [{ status: { $eq: 'critical' } }, { isLatest: { $eq: true } }], - }, - limit: 10, - offset: 0, - }); - - // Verify root total count call (first call - index 0) - only has original root filters - assertRootGetManyRequest( - mocks.mockRootService, - { - search: { name: { $cont: 'Enterprise' } }, - page: 1, - limit: 1, - }, - 0, - ); - - // Verify root filter + discovered root IDs constraint (data retrieval call - index 1) - assertRootGetManyRequest( - mocks.mockRootService, - { - search: { - $and: [{ name: { $cont: 'Enterprise' } }, { id: { $in: [2, 5] } }], - }, - page: 1, - limit: 10, // Should match user-requested limit - }, - 1, - ); - - // ASSERT - Result verification (fewer results than requested page size) - assertResultStructure(result, { count: 2, total: 2 }); - expect(result.page).toBe(1); - expect(result.pageCount).toBe(1); - - assertEnrichment(result, 'relations', { - 2: [{ id: 1, rootId: 2, title: 'System Outage', status: 'critical' }], - 5: [{ id: 2, rootId: 5, title: 'Security Breach', status: 'critical' }], - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/complex-scenario.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/complex-scenario.spec.ts deleted file mode 100644 index 7a436b143..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/complex-scenario.spec.ts +++ /dev/null @@ -1,1006 +0,0 @@ -import { createPaginatedResponse } from '../../__FIXTURES__/crud-federation-mock-helpers'; -import { - assertServiceCallCounts, - assertRootGetManyRequest, - assertRelationRequest, - assertRootFirst, - assertResultStructure, - assertEnrichment, - assertOneToOneEnrichment, - assertSortOrder, -} from '../../__FIXTURES__/crud-federation-test-assertions'; -import { - createOneToOneForwardRelation, - createOneToManyForwardRelation, - TestRelationService, - TestProfileService, -} from '../../__FIXTURES__/crud-federation-test-entities'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -// Extended interfaces for complex test entities -interface TestComment { - id: number; - rootId: number; - title: string; - status: string; - priority: number; - isLatest: boolean; - createdAt: string; -} - -interface TestProfile { - id: number; - rootId: number; - bio: string; - isActive: boolean; -} - -interface TestRoot { - id: number; - name: string; - status: string; -} - -/** - * Comprehensive test scenario covering the most complex CRUD federation use case: - * - Root service with multiple filters and mixed ASC/DESC sorts - * - Two relations: one-to-one (profiles) and one-to-many (comments) - * - Each relation with its own filters and sorts - * - Large dataset with sparse relations and pagination past page 2 - * - Tests buffer strategy, constraint intersection, and enrichment - */ -describe('CrudFederationService - Complex Scenario: Multi-Relation with Pagination', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - // Register both relation services - mocks.registerRelation(mocks.mockRelationService); // comments - mocks.registerRelation(mocks.mockProfileService); // profiles - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('Most Complex Federation Scenario', () => { - it('should handle root filters, relation sorts, multiple relations, and page 2 pagination', async () => { - // ARRANGE - Complex configuration - const profileRelation = createOneToOneForwardRelation( - 'profiles', - TestProfileService, - 'id', - 'rootId', - ); - - const commentRelation = createOneToManyForwardRelation( - 'comments', - TestRelationService, - { - distinctFilter: { field: 'isLatest', operator: '$eq', value: true }, // Required for many-cardinality sort - }, - ); - - const req = mocks.createTestRequest( - { - // Root filters - multiple conditions - filter: [ - 'name||$cont||Project', // Root name contains "Project" - 'status||$eq||active', // Root status equals "active" - 'profiles.isActive||$eq||true', // Profile filter - 'comments.status||$eq||published', // Comment filter 1 - 'comments.priority||$gte||5', // Comment filter 2 - 'comments.rootId||$notnull', // Required for relation sort - ], - // Mixed sorts - root sorts and relation sort - sort: [ - 'name,ASC', // Root sort 1 - 'id,DESC', // Root sort 2 - 'comments.priority,DESC', // Relation sort (drives strategy) - 'comments.createdAt,ASC', // Relation sort 2 - ], - // Pagination to page 2 - page: '2', - limit: '5', - }, - [profileRelation, commentRelation], - ); - - // ARRANGE - Complex mock data - const testData = createComplexTestData(); - - // Mock comment service responses for sequential constraint processing - // First call: constraint discovery with sort and filters - mocks.mockRelationService.getMany - .mockResolvedValueOnce( - createPaginatedResponse(testData.page2CommentsDiscovery, { - total: 25, // Total comments matching filters across all pages - }), - ) - // Second call: enrichment for final roots - .mockResolvedValueOnce( - createPaginatedResponse(testData.allCommentsForFinalRoots, { - total: 15, // Comments for the specific roots returned - }), - ); - - // Mock profile service response for enrichment - mocks.mockProfileService.getMany.mockResolvedValue( - createPaginatedResponse(testData.profilesForFinalRoots, { - total: 4, // Profiles for the specific roots returned - }), - ); - - // Mock root service response after constraint discovery - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(testData.page2Roots, { - limit: 5, - total: 18, // Total roots after all constraints applied - }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 2 }, // Multiple calls in complex scenario - { service: mocks.mockRelationService, count: 2 }, // constraint + enrichment - { service: mocks.mockProfileService, count: 2 }, // Constraint discovery + enrichment - ]); - - // Verify root-first strategy in complex scenario - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - - // ASSERT - Comment service constraint discovery call - assertRelationRequest( - mocks.mockRelationService, - { - page: undefined, // Comment relation is driving but not first, so page is reset - offset: 0, // Offset-based pagination starts at 0 - limit: 5, // User's limit - sort: [ - { field: 'priority', order: 'DESC' }, - { field: 'createdAt', order: 'ASC' }, - ], - search: { - $and: [ - { status: { $eq: 'published' } }, // Comment filter from request - { priority: { $gte: 5 } }, - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, // distinctFilter from relation config - ], - }, - }, - 0, - ); - - // This assertion is redundant with the one above, removing it since we've already verified call 0 - - // ASSERT - Root service total count call (call 0) - assertRootGetManyRequest( - mocks.mockRootService, - { - page: 1, - limit: 1, - search: { - $and: [ - { name: { $cont: 'Project' } }, - { status: { $eq: 'active' } }, - ], - }, - }, - 0, // Call 0: Total count - ); - - // ASSERT - Root service data retrieval call (call 1) - assertRootGetManyRequest( - mocks.mockRootService, - { - page: 1, // Reset to page 1 after constraint discovery - limit: 5, - search: { - $and: [ - { name: { $cont: 'Project' } }, - { status: { $eq: 'active' } }, - { id: { $in: [7, 11, 15, 22, 28] } }, // Constrained by discovered IDs - ], - }, - sort: [ - { field: 'name', order: 'ASC' }, // Root sort 1 - { field: 'id', order: 'DESC' }, // Root sort 2 - ], - }, - 1, // Call 1: Data retrieval - ); - - // ASSERT - Comment service enrichment call (should constrain by discovered root IDs) - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { status: { $eq: 'published' } }, - { priority: { $gte: 5 } }, - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, // distinctFilter from relation config - { rootId: { $in: [7, 11, 15, 22, 28] } }, - ], - }, - }, - 1, // Call 1: Enrichment call - ); - - // ASSERT - Profile service constraint discovery call (broad filter) - assertRelationRequest( - mocks.mockProfileService, - { - search: { - isActive: { $eq: true }, - }, - }, - 0, // Call 0: Profile constraint discovery - ); - - // ASSERT - Profile service enrichment call (should have filters and root ID constraints) - assertRelationRequest( - mocks.mockProfileService, - { - search: { - $and: [ - { isActive: { $eq: true } }, - { rootId: { $in: [7, 11, 15, 22, 28] } }, - ], - }, - }, - 1, // Call 1: Profile enrichment call - ); - - // ASSERT - Result structure matches what mocks returned - assertResultStructure(result, { count: 5, total: 18 }); - expect(result.page).toBe(2); - expect(result.pageCount).toBe(4); // Math.ceil(18/5) - - // ASSERT - Root sort order matches what mock returned (not hardcoded IDs) - const expectedIds = testData.page2Roots.map((root) => root.id); - assertSortOrder(result, expectedIds); - - // ASSERT - Enrichment verification based on mock data - // Build expected enrichment from what the mocks returned - const expectedCommentEnrichment: Record = {}; - const expectedProfileEnrichment: Record = {}; - - // Build comment enrichment expectations from mock data - for (const root of testData.page2Roots) { - const rootComments = testData.allCommentsForFinalRoots.filter( - (c) => c.rootId === root.id, - ); - expectedCommentEnrichment[root.id] = rootComments; - } - - // Build profile enrichment expectations from mock data - for (const root of testData.page2Roots) { - const rootProfile = testData.profilesForFinalRoots.find( - (p) => p.rootId === root.id, - ); - expectedProfileEnrichment[root.id] = rootProfile || null; - } - - assertEnrichment(result, 'comments', expectedCommentEnrichment); - assertOneToOneEnrichment(result, 'profiles', expectedProfileEnrichment); - - // Verify all roots have relation properties initialized (even if null/empty) - result.data.forEach((root) => { - expect(root).toHaveProperty('comments'); - expect(root).toHaveProperty('profiles'); - }); - }); - - it('should handle sparse data requiring multiple iterations', async () => { - // ARRANGE - Minimal setup to start - // Test INNER JOIN sparsity without relation sorting to avoid distinctFilter requirement - const commentRelation = createOneToManyForwardRelation( - 'comments', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, // Required for many-cardinality with filters - ); - - const req = mocks.createTestRequest( - { - filter: ['comments.priority||$gte||8'], - page: '1', - limit: '10', - }, - [commentRelation], - ); - - // Mock sparse data - each batch has 10 comments but few unique root IDs - // This simulates the sparse data problem where relation-driven pagination - // returns many relation records but only a few unique root entities per page - const constraintBatch1 = [ - { - id: 822, - rootId: 479, - title: 'Security check 1', - priority: 11, - isLatest: true, - }, - { - id: 823, - rootId: 479, - title: 'Security check 2', - priority: 11, - isLatest: true, - }, - { - id: 824, - rootId: 479, - title: 'Security check 3', - priority: 11, - isLatest: true, - }, - { - id: 825, - rootId: 479, - title: 'Security check 4', - priority: 11, - isLatest: true, - }, - { - id: 112, - rootId: 67, - title: 'Feature request 1', - priority: 10, - isLatest: true, - }, - { - id: 113, - rootId: 67, - title: 'Feature request 2', - priority: 10, - isLatest: true, - }, - { - id: 114, - rootId: 67, - title: 'Feature request 3', - priority: 10, - isLatest: true, - }, - { - id: 203, - rootId: 89, - title: 'Critical issue 1', - priority: 10, - isLatest: true, - }, - { - id: 204, - rootId: 89, - title: 'Critical issue 2', - priority: 10, - isLatest: true, - }, - { - id: 205, - rootId: 89, - title: 'Critical issue 3', - priority: 10, - isLatest: true, - }, - ]; - - const constraintBatch2 = [ - { - id: 47, - rootId: 23, - title: 'Bug fix 1', - priority: 10, - isLatest: true, - }, - { - id: 48, - rootId: 23, - title: 'Bug fix 2', - priority: 10, - isLatest: true, - }, - { - id: 49, - rootId: 23, - title: 'Bug fix 3', - priority: 10, - isLatest: true, - }, - { - id: 50, - rootId: 23, - title: 'Bug fix 4', - priority: 10, - isLatest: true, - }, - { - id: 51, - rootId: 23, - title: 'Bug fix 5', - priority: 10, - isLatest: true, - }, - { - id: 341, - rootId: 156, - title: 'Performance fix 1', - priority: 9, - isLatest: true, - }, - { - id: 342, - rootId: 156, - title: 'Performance fix 2', - priority: 9, - isLatest: true, - }, - { - id: 343, - rootId: 156, - title: 'Performance fix 3', - priority: 9, - isLatest: true, - }, - { - id: 344, - rootId: 156, - title: 'Performance fix 4', - priority: 9, - isLatest: true, - }, - { - id: 345, - rootId: 156, - title: 'Performance fix 5', - priority: 9, - isLatest: true, - }, - ]; - - const constraintBatch3 = [ - { - id: 389, - rootId: 201, - title: 'Security patch 1', - priority: 9, - isLatest: true, - }, - { - id: 390, - rootId: 201, - title: 'Security patch 2', - priority: 9, - isLatest: true, - }, - { - id: 391, - rootId: 201, - title: 'Security patch 3', - priority: 9, - isLatest: true, - }, - { - id: 392, - rootId: 201, - title: 'Security patch 4', - priority: 9, - isLatest: true, - }, - { - id: 393, - rootId: 201, - title: 'Security patch 5', - priority: 9, - isLatest: true, - }, - { - id: 421, - rootId: 234, - title: 'UI improvement 1', - priority: 9, - isLatest: true, - }, - { - id: 422, - rootId: 234, - title: 'UI improvement 2', - priority: 9, - isLatest: true, - }, - { - id: 423, - rootId: 234, - title: 'UI improvement 3', - priority: 9, - isLatest: true, - }, - { - id: 424, - rootId: 234, - title: 'UI improvement 4', - priority: 9, - isLatest: true, - }, - { - id: 425, - rootId: 234, - title: 'UI improvement 5', - priority: 9, - isLatest: true, - }, - ]; - - const constraintBatch4 = [ - { - id: 534, - rootId: 298, - title: 'Documentation 1', - priority: 8, - isLatest: true, - }, - { - id: 535, - rootId: 298, - title: 'Documentation 2', - priority: 8, - isLatest: true, - }, - { - id: 536, - rootId: 298, - title: 'Documentation 3', - priority: 8, - isLatest: true, - }, - { - id: 537, - rootId: 298, - title: 'Documentation 4', - priority: 8, - isLatest: true, - }, - { - id: 538, - rootId: 298, - title: 'Documentation 5', - priority: 8, - isLatest: true, - }, - { - id: 539, - rootId: 298, - title: 'Documentation 6', - priority: 8, - isLatest: true, - }, - { - id: 540, - rootId: 298, - title: 'Documentation 7', - priority: 8, - isLatest: true, - }, - { - id: 541, - rootId: 298, - title: 'Documentation 8', - priority: 8, - isLatest: true, - }, - { - id: 542, - rootId: 298, - title: 'Documentation 9', - priority: 8, - isLatest: true, - }, - { - id: 543, - rootId: 298, - title: 'Documentation 10', - priority: 8, - isLatest: true, - }, - ]; - - const constraintBatch5 = [ - { - id: 612, - rootId: 345, - title: 'API enhancement 1', - priority: 8, - isLatest: true, - }, - { - id: 614, - rootId: 345, - title: 'API enhancement 3', - priority: 8, - isLatest: true, - }, - { - id: 687, - rootId: 389, - title: 'Database optimization 1', - priority: 8, - isLatest: true, - }, - { - id: 688, - rootId: 389, - title: 'Database optimization 2', - priority: 8, - isLatest: true, - }, - { - id: 689, - rootId: 389, - title: 'Database optimization 3', - priority: 8, - isLatest: true, - }, - { - id: 734, - rootId: 412, - title: 'Testing improvements 1', - priority: 8, - isLatest: true, - }, - { - id: 735, - rootId: 412, - title: 'Testing improvements 2', - priority: 8, - isLatest: true, - }, - ]; - - const allMatchingComments = [ - ...constraintBatch1, - ...constraintBatch2, - ...constraintBatch3, - ...constraintBatch4, - ...constraintBatch5, - ]; - - const totalComments = 500; // Large total to ensure service continues iterations - - mocks.mockRelationService.getMany - .mockResolvedValueOnce( - createPaginatedResponse(constraintBatch1, { - total: totalComments, - limit: 10, - }), - ) - .mockResolvedValueOnce( - createPaginatedResponse(constraintBatch2, { - total: totalComments, - limit: 10, - }), - ) - .mockResolvedValueOnce( - createPaginatedResponse(constraintBatch3, { - total: totalComments, - limit: 10, - }), - ) - .mockResolvedValueOnce( - createPaginatedResponse(constraintBatch4, { - total: totalComments, - limit: 10, - }), - ) - .mockResolvedValueOnce( - createPaginatedResponse(constraintBatch5, { - total: totalComments, - limit: 10, - }), - ) - .mockResolvedValueOnce( - createPaginatedResponse([], { total: totalComments, limit: 10 }), - ) // Empty - no more results - .mockResolvedValueOnce( - createPaginatedResponse(allMatchingComments, { - total: allMatchingComments.length, - }), - ); // Enrichment - - // Add root service mock with corresponding roots - const correspondingRoots = [ - { id: 23, name: 'Project Alpha', status: 'active' }, - { id: 67, name: 'Project Beta', status: 'active' }, - { id: 89, name: 'Project Gamma', status: 'active' }, - { id: 156, name: 'Project Delta', status: 'active' }, - { id: 201, name: 'Project Epsilon', status: 'active' }, - { id: 234, name: 'Project Zeta', status: 'active' }, - { id: 298, name: 'Project Eta', status: 'active' }, - { id: 345, name: 'Project Theta', status: 'active' }, - { id: 389, name: 'Project Iota', status: 'active' }, - { id: 412, name: 'Project Kappa', status: 'active' }, - { id: 479, name: 'Project Toomuch', status: 'active' }, - ]; - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(correspondingRoots, { total: 1000 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call counts (should be much cleaner now) - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, // Single call with accumulated root IDs - { service: mocks.mockRelationService, count: 6 }, // 5 constraint discovery + 1 enrichment - ]); - - // ASSERT - Comment service constraint discovery call (first iteration, unconstrained) - assertRelationRequest( - mocks.mockRelationService, - { - offset: 0, - limit: 10, - search: { - $and: [ - { priority: { $gte: 8 } }, - { isLatest: { $eq: true } }, // distinctFilter from relation config - ], - }, - }, - 0, - ); - - // ASSERT - Comment service constraint discovery calls (iterations 1-4) - assertRelationRequest( - mocks.mockRelationService, - { - offset: 10, - limit: 10, - search: { - $and: [ - { priority: { $gte: 8 } }, - { isLatest: { $eq: true } }, // distinctFilter from relation config - ], - }, - }, - 1, // Call 1: Iteration 2 - ); - - assertRelationRequest( - mocks.mockRelationService, - { - offset: 20, - limit: 10, - search: { - $and: [ - { priority: { $gte: 8 } }, - { isLatest: { $eq: true } }, // distinctFilter from relation config - ], - }, - }, - 2, // Call 2: Iteration 3 - ); - - assertRelationRequest( - mocks.mockRelationService, - { - offset: 30, - limit: 10, - search: { - $and: [ - { priority: { $gte: 8 } }, - { isLatest: { $eq: true } }, // distinctFilter from relation config - ], - }, - }, - 3, // Call 3: Iteration 4 - ); - - assertRelationRequest( - mocks.mockRelationService, - { - offset: 40, - limit: 10, - search: { - $and: [ - { priority: { $gte: 8 } }, - { isLatest: { $eq: true } }, // distinctFilter from relation config - ], - }, - }, - 4, // Call 4: Iteration 5 - ); - - // ASSERT - Comment service enrichment call (final call with discovered root IDs) - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { priority: { $gte: 8 } }, - { isLatest: { $eq: true } }, // distinctFilter from relation config - { - rootId: { - $in: [479, 67, 89, 23, 156, 201, 234, 298, 345, 389], - }, - }, // First 10 root IDs (user limit) - ], - }, - }, - 5, // Call 5: Enrichment - ); - - // ASSERT - Root service data retrieval call (single call with all accumulated root IDs) - assertRootGetManyRequest( - mocks.mockRootService, - { - page: 1, - limit: 10, - search: { - id: { $in: [479, 67, 89, 23, 156, 201, 234, 298, 345, 389, 412] }, // All accumulated root IDs (11 total after iterations) - }, - }, - 0, // Call 0: Data retrieval (no total count call in this scenario) - ); - - // ASSERT - Result structure (accumulated root IDs from multiple iterations) - expect(result.data).toHaveLength(10); - expect(result.total).toBe(500); // Total from first relation query - expect(result.count).toBe(10); // 10 roots returned - - // ASSERT - Basic enrichment check - result.data.forEach((root) => { - expect(root).toHaveProperty('comments'); - expect(Array.isArray(root.comments)).toBe(true); - }); - }); - }); -}); - -// Complex test data builders -function createComplexTestData() { - return { - // Page 2 comment discovery - comments that drive the sort order - page2CommentsDiscovery: [ - { - id: 25, - rootId: 7, - title: 'Critical Issue', - status: 'published', - priority: 10, - isLatest: true, - createdAt: '2024-01-15', - }, - { - id: 41, - rootId: 11, - title: 'High Priority Task', - status: 'published', - priority: 9, - isLatest: true, - createdAt: '2024-01-12', - }, - { - id: 55, - rootId: 15, - title: 'Important Feature', - status: 'published', - priority: 8, - isLatest: true, - createdAt: '2024-01-10', - }, - { - id: 72, - rootId: 22, - title: 'Security Update', - status: 'published', - priority: 7, - isLatest: true, - createdAt: '2024-01-08', - }, - { - id: 88, - rootId: 28, - title: 'Performance Fix', - status: 'published', - priority: 6, - isLatest: true, - createdAt: '2024-01-05', - }, - ] as TestComment[], - - // All comments for the final roots (for enrichment) - allCommentsForFinalRoots: [ - { - id: 25, - rootId: 7, - title: 'Critical Issue', - status: 'published', - priority: 10, - isLatest: true, - createdAt: '2024-01-15', - }, - { - id: 26, - rootId: 7, - title: 'Old Issue', - status: 'published', - priority: 8, - isLatest: false, - createdAt: '2024-01-01', - }, - { - id: 41, - rootId: 11, - title: 'High Priority Task', - status: 'published', - priority: 9, - isLatest: true, - createdAt: '2024-01-12', - }, - { - id: 55, - rootId: 15, - title: 'Important Feature', - status: 'published', - priority: 8, - isLatest: true, - createdAt: '2024-01-10', - }, - { - id: 56, - rootId: 15, - title: 'Minor Update', - status: 'draft', - priority: 3, - isLatest: false, - createdAt: '2024-01-03', - }, - { - id: 72, - rootId: 22, - title: 'Security Update', - status: 'published', - priority: 7, - isLatest: true, - createdAt: '2024-01-08', - }, - { - id: 88, - rootId: 28, - title: 'Performance Fix', - status: 'published', - priority: 6, - isLatest: true, - createdAt: '2024-01-05', - }, - ] as TestComment[], - - // Page 2 roots after constraint discovery - page2Roots: [ - { id: 7, name: 'Project Alpha', status: 'active' }, - { id: 11, name: 'Project Beta', status: 'active' }, - { id: 15, name: 'Project Charlie', status: 'active' }, - { id: 22, name: 'Project Delta', status: 'active' }, - { id: 28, name: 'Project Echo', status: 'active' }, - ] as TestRoot[], - - // Profiles for the final roots (sparse - not all roots have profiles) - profilesForFinalRoots: [ - { id: 7, rootId: 7, bio: 'Senior Developer Profile', isActive: true }, - { id: 11, rootId: 11, bio: 'Team Lead Profile', isActive: true }, - // Root 15 has no active profile - { id: 22, rootId: 22, bio: 'Product Manager Profile', isActive: true }, - { id: 28, rootId: 28, bio: 'DevOps Engineer Profile', isActive: true }, - ] as TestProfile[], - }; -} diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/distinct-filter-validation.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/distinct-filter-validation.spec.ts deleted file mode 100644 index 20b91ab97..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/distinct-filter-validation.spec.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { CrudFederationException } from '../../../exceptions/crud-federation.exception'; -import { assertRelationRequest } from '../../__FIXTURES__/crud-federation-test-assertions'; -import { - createOneToManyForwardRelation, - createOneToOneForwardRelation, - TestRelationService, -} from '../../__FIXTURES__/crud-federation-test-entities'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -/** - * Validation tests for distinctFilter requirements on many-cardinality relations - * Tests that relation sorting requires distinctFilter for many relationships - */ -describe('CrudFederationService - Behavior: distinctFilter Validation', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - mocks.registerRelation(mocks.mockRelationService); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('distinctFilter requirement validation', () => { - it('should throw error when many-cardinality relation lacks distinctFilter', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - // Remove distinctFilter to test validation - const req = mocks.createTestRequest( - { - sort: ['relations.title,ASC'], // Trying to sort by relation field - }, - [relation], - ); - - // ACT & ASSERT - const error = await mocks.service.getMany(req).catch((e) => e); - - expect(error).toBeInstanceOf(CrudFederationException); - expect(error.message).toContain( - 'requires a distinctFilter configuration', - ); - expect(error.message).toContain('many-cardinality relationship'); - }); - - it('should succeed when many-cardinality relation has distinctFilter and $notnull', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - const req = mocks.createTestRequest( - { - sort: ['relations.title,ASC'], // Sorting by relation field - limit: '3', - }, - [relation], - ); - - // Mock data - const relationData = [ - { id: 1, rootId: 1, title: 'Alpha Task', isLatest: true }, - { id: 2, rootId: 2, title: 'Beta Task', isLatest: true }, - { id: 3, rootId: 3, title: 'Charlie Task', isLatest: true }, - ]; - const rootData = [ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - { id: 3, name: 'Root 3' }, - ]; - - mocks.mockRelationService.getMany.mockResolvedValue({ - data: relationData, - count: 3, - total: 3, - page: 1, - pageCount: 1, - limit: 3, - }); - - mocks.mockRootService.getMany.mockResolvedValue({ - data: rootData, - count: 3, - total: 3, - page: 1, - pageCount: 1, - limit: 3, - }); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - expect(result).toBeDefined(); - expect(result.data).toHaveLength(3); - expect(result.total).toBe(3); - - // Verify distinctFilter was applied - assertRelationRequest(mocks.mockRelationService, { - filter: [ - { - field: 'isLatest', - operator: '$eq', - value: true, - relation: 'relations', - }, - ], - limit: 3, - offset: 0, - search: { - $and: [{ rootId: { $notnull: true } }, { isLatest: { $eq: true } }], - }, - sort: [{ field: 'title', order: 'ASC' }], - }); - }); - - it('should automatically inject $notnull filter for relation sorting', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - const req = mocks.createTestRequest( - { - // No $notnull filter provided - system should inject it automatically - sort: ['relations.title,ASC'], - }, - [relation], - ); - - // Mock data - mocks.mockRelationService.getMany.mockResolvedValue({ - data: [{ id: 1, rootId: 1, title: 'Test Relation', isLatest: true }], - count: 1, - total: 1, - page: 1, - pageCount: 1, - limit: 1, - }); - - mocks.mockRootService.getMany.mockResolvedValue({ - data: [{ id: 1, name: 'Root 1' }], - count: 1, - total: 1, - page: 1, - pageCount: 1, - limit: 1, - }); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Should succeed because $notnull filter was automatically injected - expect(result).toBeDefined(); - expect(result.data).toHaveLength(1); - }); - - it('should work fine with one-cardinality relations (no distinctFilter needed)', async () => { - // ARRANGE - Using createOneToOneForwardRelation for proper typing - const relation = createOneToOneForwardRelation( - 'profile', - TestRelationService, - ); - const req = mocks.createTestRequest( - { - sort: ['profile.title,ASC'], // No distinctFilter needed for one-to-one - }, - [relation], - ); - - // Mock data - mocks.mockRelationService.getMany.mockResolvedValue({ - data: [{ id: 1, rootId: 1, title: 'Developer Profile' }], - count: 1, - total: 1, - page: 1, - pageCount: 1, - limit: 1, - }); - - mocks.mockRootService.getMany.mockResolvedValue({ - data: [{ id: 1, name: 'Root 1' }], - count: 1, - total: 1, - page: 1, - pageCount: 1, - limit: 1, - }); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - expect(result).toBeDefined(); - expect(result.data).toHaveLength(1); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/inner-join-behavior.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/inner-join-behavior.spec.ts deleted file mode 100644 index 8c2924818..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/inner-join-behavior.spec.ts +++ /dev/null @@ -1,1019 +0,0 @@ -import { createPaginatedResponse } from '../../__FIXTURES__/crud-federation-mock-helpers'; -import { - assertServiceCallCounts, - assertResultStructure, - assertEmptyResult, - assertRelationRequest, - assertRootGetManyRequest, - assertRelationFirst, - assertRootFirst, -} from '../../__FIXTURES__/crud-federation-test-assertions'; -import { - createMinimalRootRelationSet, - createFilteredDataSet, - createPriorityDataSet, - createCombinedFiltersSet, -} from '../../__FIXTURES__/crud-federation-test-data'; -import { - createOneToManyForwardRelation, - TestRelationService, -} from '../../__FIXTURES__/crud-federation-test-entities'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -/** - * Behavior tests for INNER JOIN pattern achieved through right-side filters - * - * Key Concept: While LEFT JOIN is the default federation behavior (all roots returned), - * INNER JOIN can be achieved using existence filters on relation fields like: - * - relations.rootId||$notnull - * - relations.status||$notnull - * - * This causes only roots with matching relations to be returned. - */ -describe('CrudFederationService - Behavior: INNER JOIN via Filters', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - // Register the 'relations' relation that tests use - mocks.registerRelation(mocks.mockRelationService); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('Forward Relationships with INNER JOIN', () => { - it('should constrain root results when relation existence filter present (INNER JOIN)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - - const req = mocks.createTestRequest( - { filter: ['relations.rootId||$notnull'], page: 1, limit: 10 }, - [relation], - ); - - const data = createMinimalRootRelationSet(); - - mocks.mockRootService.getMany.mockResolvedValueOnce( - createPaginatedResponse(data.roots.slice(0, 2), { - limit: 10, - total: 2, - }), - ); - - mocks.mockRelationService.getMany - .mockResolvedValueOnce( - createPaginatedResponse(data.relations.slice(0, 2), { total: 2 }), - ) - .mockResolvedValueOnce( - createPaginatedResponse(data.relations.slice(0, 3), { total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [{ rootId: { $notnull: true } }, { isLatest: { $eq: true } }], - }, - limit: 10, - offset: 0, - }); - - // Second relation call - enrichment with discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [1, 2] } }, - ], - }, - }, - 1, - ); - - assertRootGetManyRequest(mocks.mockRootService, { - search: { - id: { $in: [1, 2] }, - }, - page: 1, - limit: 10, - }); - - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, - ]); - - const expectedData = [ - { - ...data.roots[0], - relations: [data.relations[0]], - }, - { - ...data.roots[1], - relations: [data.relations[1], data.relations[2]], - }, - ]; - - assertResultStructure(result, { - count: 2, - total: 2, - pageCount: 1, - page: 1, - limit: 10, - data: expectedData, - }); - }); - - it('should apply INNER JOIN with relation value filters (status=active)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - const req = mocks.createTestRequest( - { filter: ['relations.status||$eq||active'] }, - [relation], - ); - const data = createFilteredDataSet(); - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.activeRelations, { total: 2 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots.slice(0, 2), { - limit: 10, - total: 2, - }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [{ status: { $eq: 'active' } }, { isLatest: { $eq: true } }], - }, - limit: 10, - offset: 0, - }); - - // Second relation call - enrichment with discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { status: { $eq: 'active' } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [1, 2] } }, - ], - }, - }, - 1, - ); - - assertRootGetManyRequest(mocks.mockRootService, { - search: { - id: { $in: [1, 2] }, - }, - page: 1, - limit: 10, - }); - - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, - ]); - - assertRelationFirst(mocks.mockRootService, [mocks.mockRelationService]); - - const expectedData = [ - { - ...data.roots[0], - relations: [data.activeRelations[0]], - }, - { - ...data.roots[1], - relations: [data.activeRelations[1]], - }, - ]; - - assertResultStructure(result, { - count: 2, - total: 2, - page: 1, - pageCount: 1, - limit: 10, - data: expectedData, - }); - }); - - it('should apply INNER JOIN with multiple relation filters (AND condition)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - const req = mocks.createTestRequest( - { - filter: [ - 'relations.status||$eq||active', - 'relations.priority||$gte||5', - ], - }, - [relation], - ); - const data = createPriorityDataSet(); - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.highPriorityActiveRelations, { - total: 2, - }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 2 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [ - { status: { $eq: 'active' } }, - { priority: { $gte: 5 } }, - { isLatest: { $eq: true } }, - ], - }, - limit: 10, - offset: 0, - }); - - // Second relation call - enrichment with discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { status: { $eq: 'active' } }, - { priority: { $gte: 5 } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [1, 2] } }, - ], - }, - }, - 1, - ); - - assertRootGetManyRequest(mocks.mockRootService, { - search: { - id: { $in: [1, 2] }, - }, - page: 1, - limit: 10, - }); - - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, - ]); - - assertRelationFirst(mocks.mockRootService, [mocks.mockRelationService]); - - const expectedData = [ - { - ...data.roots[0], - relations: [data.highPriorityActiveRelations[0]], - }, - { - ...data.roots[1], - relations: [data.highPriorityActiveRelations[1]], - }, - ]; - - assertResultStructure(result, { - count: 2, - total: 2, - page: 1, - pageCount: 1, - limit: 10, - data: expectedData, - }); - }); - - it('should return empty result when no relations match filters (INNER JOIN)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - const req = mocks.createTestRequest( - { filter: ['relations.status||$eq||archived'] }, - [relation], - ); - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse([], { total: 0 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 0 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [{ status: { $eq: 'archived' } }, { isLatest: { $eq: true } }], - }, - limit: 10, - offset: 0, - }); - assertEmptyResult(result); - }); - - it('should apply INNER JOIN with combined root and relation filters', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - - const req = mocks.createTestRequest( - { - filter: ['name||$cont||Project', 'relations.status||$eq||active'], - page: 1, - limit: 10, - }, - [relation], - ); - - const data = createCombinedFiltersSet(); - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.activeRelations, { total: 2 }), - ); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.projectRoots, { limit: 10, total: 2 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertRootGetManyRequest(mocks.mockRootService, { - search: { - name: { $cont: 'Project' }, - }, - page: 1, - limit: 1, - }); - - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [{ status: { $eq: 'active' } }, { isLatest: { $eq: true } }], - }, - limit: 10, - offset: 0, - }); - - // Second relation call - enrichment with discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { status: { $eq: 'active' } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [1, 2] } }, - ], - }, - }, - 1, - ); - - assertRootGetManyRequest( - mocks.mockRootService, - { - search: { - $and: [{ name: { $cont: 'Project' } }, { id: { $in: [1, 2] } }], - }, - page: 1, - limit: 10, - }, - 1, - ); - - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 2 }, - { service: mocks.mockRelationService, count: 2 }, - ]); - - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - - const expectedData = [ - { - ...data.projectRoots[0], - relations: [data.activeRelations[0]], - }, - { - ...data.projectRoots[1], - relations: [data.activeRelations[1]], - }, - ]; - - assertResultStructure(result, { - count: 2, - total: 2, - page: 1, - pageCount: 1, - limit: 10, - data: expectedData, - }); - }); - - describe('INNER JOIN with Pagination', () => { - it('should handle INNER JOIN with pagination on page 1', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { - distinctFilter: { field: 'isLatest', operator: '$eq', value: true }, - }, - ); - const req = mocks.createTestRequest( - { - filter: ['relations.status||$eq||active'], - page: '1', - limit: '3', - }, - [relation], - ); - - // Create test data - roots 1,2,3,5,7 have active relations - const activeRelations = [ - { id: 1, rootId: 1, title: 'Relation 1A', status: 'active' }, - { id: 2, rootId: 2, title: 'Relation 2A', status: 'active' }, - { id: 3, rootId: 3, title: 'Relation 3A', status: 'active' }, - { id: 4, rootId: 5, title: 'Relation 5A', status: 'active' }, - { id: 5, rootId: 7, title: 'Relation 7A', status: 'active' }, - ]; - - const page1Roots = [ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - { id: 3, name: 'Root 3' }, - ]; - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(activeRelations.slice(0, 3), { total: 5 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(page1Roots, { limit: 3, total: 5 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [{ status: { $eq: 'active' } }, { isLatest: { $eq: true } }], - }, - limit: 3, - offset: 0, - }); - - // Second relation call - enrichment with discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { status: { $eq: 'active' } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [1, 2, 3] } }, - ], - }, - }, - 1, - ); - - assertRootGetManyRequest(mocks.mockRootService, { - search: { id: { $in: [1, 2, 3] } }, - page: 1, - limit: 3, - }); - - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, - ]); - - assertRelationFirst(mocks.mockRootService, [mocks.mockRelationService]); - - const expectedData = page1Roots.map((root, index) => ({ - ...root, - relations: [activeRelations[index]], - })); - - assertResultStructure(result, { - count: 3, - total: 5, - page: 1, - pageCount: 2, - limit: 3, - data: expectedData, - }); - }); - - it('should handle INNER JOIN with pagination on page 2', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { - distinctFilter: { field: 'isLatest', operator: '$eq', value: true }, - }, - ); - const req = mocks.createTestRequest( - { - filter: ['relations.status||$eq||active'], - page: '2', - limit: '3', - }, - [relation], - ); - - // Same active relations as page 1 - const activeRelations = [ - { id: 1, rootId: 1, title: 'Relation 1A', status: 'active' }, - { id: 2, rootId: 2, title: 'Relation 2A', status: 'active' }, - { id: 3, rootId: 3, title: 'Relation 3A', status: 'active' }, - { id: 4, rootId: 5, title: 'Relation 5A', status: 'active' }, - { id: 5, rootId: 7, title: 'Relation 7A', status: 'active' }, - ]; - - // Page 2 roots (remaining 2 roots) - const page2Roots = [ - { id: 5, name: 'Root 5' }, - { id: 7, name: 'Root 7' }, - ]; - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(activeRelations.slice(3), { total: 5 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(page2Roots, { limit: 3, total: 5 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [{ status: { $eq: 'active' } }, { isLatest: { $eq: true } }], - }, - limit: 3, - offset: 3, // Page 2: (2-1) * 3 = 3 - }); - - // Second relation call - enrichment with discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { status: { $eq: 'active' } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [5, 7] } }, - ], - }, - }, - 1, - ); - - assertRootGetManyRequest(mocks.mockRootService, { - search: { id: { $in: [5, 7] } }, - page: 1, - limit: 3, - }); - - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, - ]); - - assertRelationFirst(mocks.mockRootService, [mocks.mockRelationService]); - - const expectedData = page2Roots.map((root, index) => ({ - ...root, - relations: [activeRelations.slice(3)[index]], - })); - - assertResultStructure(result, { - count: 2, - total: 5, - page: 2, - pageCount: 2, - limit: 3, - data: expectedData, - }); - }); - - it('should handle INNER JOIN pagination when filter reduces results below page size', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { - distinctFilter: { field: 'isLatest', operator: '$eq', value: true }, - }, - ); - const req = mocks.createTestRequest( - { - filter: ['relations.status||$eq||critical'], - page: '1', - limit: '5', // Request 5 but only 2 roots have critical relations - }, - [relation], - ); - - const criticalRelations = [ - { id: 1, rootId: 1, title: 'Critical Task A', status: 'critical' }, - { id: 2, rootId: 3, title: 'Critical Task B', status: 'critical' }, - ]; - - const filteredRoots = [ - { id: 1, name: 'Root 1' }, - { id: 3, name: 'Root 3' }, - ]; - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(criticalRelations, { total: 2 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(filteredRoots, { limit: 5, total: 2 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [ - { status: { $eq: 'critical' } }, - { isLatest: { $eq: true } }, - ], - }, - limit: 5, - offset: 0, - }); - - // Second relation call - enrichment with discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { status: { $eq: 'critical' } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [1, 3] } }, - ], - }, - }, - 1, - ); - - assertRootGetManyRequest(mocks.mockRootService, { - search: { id: { $in: [1, 3] } }, - page: 1, - limit: 5, - }); - - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, - ]); - - assertRelationFirst(mocks.mockRootService, [mocks.mockRelationService]); - - const expectedData = filteredRoots.map((root, index) => ({ - ...root, - relations: [criticalRelations[index]], - })); - - assertResultStructure(result, { - count: 2, - total: 2, - page: 1, - pageCount: 1, - limit: 5, - data: expectedData, - }); - }); - }); - - describe('INNER JOIN with Relation Sorting', () => { - it('should preserve relation sort order in INNER JOIN scenario', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { - distinctFilter: { field: 'isLatest', operator: '$eq', value: true }, - }, // distinctFilter for uniqueness - ); - const req = mocks.createTestRequest( - { - filter: [ - 'relations.status||$eq||active', - 'relations.rootId||$notnull', - ], // INNER JOIN trigger + required $notnull - sort: ['relations.title,ASC'], // Relation sort - }, - [relation], - ); - - // Relation data sorted by title: Alpha, Beta, Charlie - const sortedActiveRelations = [ - { id: 1, rootId: 2, title: 'Alpha Task', status: 'active' }, - { id: 2, rootId: 1, title: 'Beta Task', status: 'active' }, - { id: 3, rootId: 3, title: 'Charlie Task', status: 'active' }, - ]; - - // Root data returned in natural order (NOT relation sort order) - const rootsInNaturalOrder = [ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - { id: 3, name: 'Root 3' }, - ]; - - // First call: distinctFilter applied for sorting (unique relations) - // Second call: all active relations for enrichment - mocks.mockRelationService.getMany - .mockResolvedValueOnce( - createPaginatedResponse(sortedActiveRelations, { total: 3 }), - ) - .mockResolvedValueOnce( - createPaginatedResponse(sortedActiveRelations, { total: 3 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(rootsInNaturalOrder, { - limit: 3, - total: 3, - }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, - ]); - assertRelationFirst(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify first call: relation service called with INNER JOIN filter and sort - assertRelationRequest( - mocks.mockRelationService, - { - limit: 10, - offset: 0, - search: { - $and: [ - { status: { $eq: 'active' } }, - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, - ], - }, - sort: [{ field: 'title', order: 'ASC' }], - }, - 0, - ); - - // Verify second call: enrichment call for discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { status: { $eq: 'active' } }, - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [2, 1, 3] } }, - ], - }, - }, - 1, - ); - - // Verify root service called with discovered IDs from sorted relations - assertRootGetManyRequest(mocks.mockRootService, { - search: { id: { $in: [2, 1, 3] } }, // IDs in relation sort order - page: 1, - limit: 10, - }); - - // ASSERT - Result verification: roots should be in relation sort order [2, 1, 3] - // Create expected data in sorted order based on relation title sorting - const expectedData = [ - { - ...rootsInNaturalOrder[1], // Root 2 (Alpha Task) - relations: [sortedActiveRelations[0]], - }, - { - ...rootsInNaturalOrder[0], // Root 1 (Beta Task) - relations: [sortedActiveRelations[1]], - }, - { - ...rootsInNaturalOrder[2], // Root 3 (Charlie Task) - relations: [sortedActiveRelations[2]], - }, - ]; - - assertResultStructure(result, { - count: 3, - total: 3, - page: 1, - pageCount: 1, - limit: 10, - data: expectedData, - }); - }); - - it('should handle INNER JOIN with relation sort and multiple relations per root', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { - distinctFilter: { field: 'isLatest', operator: '$eq', value: true }, - }, // distinctFilter for uniqueness - ); - const req = mocks.createTestRequest( - { - filter: [ - 'relations.priority||$gte||5', - 'relations.rootId||$notnull', - ], // INNER JOIN trigger + required $notnull - sort: ['relations.priority,DESC'], // Relation sort by priority - }, - [relation], - ); - - // Relations sorted by priority DESC with multiple per root - const sortedHighPriorityRelations = [ - { - id: 1, - rootId: 1, - title: 'Critical', - priority: 10, - status: 'active', - }, - { id: 2, rootId: 1, title: 'High A', priority: 8, status: 'active' }, - { id: 3, rootId: 2, title: 'High B', priority: 7, status: 'active' }, - { id: 4, rootId: 3, title: 'Medium', priority: 5, status: 'active' }, - ]; - - // Roots in natural order (will be re-ordered by service) - const rootsInNaturalOrder = [ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - { id: 3, name: 'Root 3' }, - ]; - - // First call: distinctFilter applied for sorting (unique relations) - // Second call: all high priority relations for enrichment - const uniqueHighPriorityRelations = sortedHighPriorityRelations.filter( - (relation, index, array) => - array.findIndex((r) => r.rootId === relation.rootId) === index, - ); - mocks.mockRelationService.getMany - .mockResolvedValueOnce( - createPaginatedResponse(uniqueHighPriorityRelations, { total: 3 }), - ) - .mockResolvedValueOnce( - createPaginatedResponse(sortedHighPriorityRelations, { total: 4 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(rootsInNaturalOrder, { - limit: 3, - total: 3, - }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, - ]); - assertRelationFirst(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify first call: relation service called with filter and sort - assertRelationRequest( - mocks.mockRelationService, - { - limit: 10, - offset: 0, - search: { - $and: [ - { priority: { $gte: 5 } }, - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, - ], - }, - sort: [{ field: 'priority', order: 'DESC' }], - }, - 0, - ); - - // Verify second call: enrichment call for discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { priority: { $gte: 5 } }, - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [1, 2, 3] } }, - ], - }, - }, - 1, - ); - - // Verify root service called with deduped IDs in relation order [1, 2, 3] - assertRootGetManyRequest(mocks.mockRootService, { - search: { id: { $in: [1, 2, 3] } }, - page: 1, - limit: 10, - }); - - // ASSERT - Result verification: roots should be ordered by first relation occurrence - // Create expected data ordered by first occurrence in priority-sorted relations - const expectedData = [ - { - ...rootsInNaturalOrder[0], // Root 1 (has Critical + High A) - relations: sortedHighPriorityRelations.filter( - (r) => r.rootId === 1, - ), - }, - { - ...rootsInNaturalOrder[1], // Root 2 (has High B) - relations: sortedHighPriorityRelations.filter( - (r) => r.rootId === 2, - ), - }, - { - ...rootsInNaturalOrder[2], // Root 3 (has Medium) - relations: sortedHighPriorityRelations.filter( - (r) => r.rootId === 3, - ), - }, - ]; - - assertResultStructure(result, { - count: 3, - total: 3, - page: 1, - pageCount: 1, - limit: 10, - data: expectedData, - }); - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/join-type.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/join-type.spec.ts deleted file mode 100644 index 458041aa6..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/join-type.spec.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { createPaginatedResponse } from '../../__FIXTURES__/crud-federation-mock-helpers'; -import { - assertServiceCallCounts, - assertInnerJoinBehavior, - assertLeftJoinBehavior, - assertResultStructure, - assertEnrichment, - assertRelationRequest, -} from '../../__FIXTURES__/crud-federation-test-assertions'; -import { createMinimalRootRelationSet } from '../../__FIXTURES__/crud-federation-test-data'; -import { - createOneToManyForwardRelation, - TestRelationService, -} from '../../__FIXTURES__/crud-federation-test-entities'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -/** - * Tests for join type behavior (LEFT vs INNER) for forward relations - * Tests automatic $notnull filter injection for INNER join relations - */ -describe('CrudFederationService - Behavior: Join Type (Forward Relations)', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - // Note: We register basic relations, but tests may override with specific join types - mocks.registerRelation(mocks.mockRelationService); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('Forward relationships (one-to-many)', () => { - it('should use LEFT JOIN by default (no join property specified)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - // No join property specified - should default to LEFT JOIN - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - - const data = createMinimalRootRelationSet(); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 3 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Should use LEFT JOIN behavior (root-first, no search constraints) - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertLeftJoinBehavior(mocks.mockRootService); - assertResultStructure(result, { count: 3, total: 3 }); - - // Verify all roots returned (LEFT JOIN behavior) - expect(result.data).toHaveLength(3); - assertEnrichment(result, 'relations', { - 1: [{ id: 1, rootId: 1, title: 'Relation 1', isLatest: true }], - 2: [ - { id: 2, rootId: 2, title: 'Relation 2', isLatest: true }, - { id: 3, rootId: 2, title: 'Relation 3', isLatest: false }, - ], - 3: [], // Root 3 has no relations (LEFT JOIN behavior) - }); - }); - - it('should use LEFT JOIN when join: "LEFT" is explicitly specified', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - relation.join = 'LEFT'; // Explicitly specify LEFT JOIN - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - - const data = createMinimalRootRelationSet(); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 3 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Should use LEFT JOIN behavior - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertLeftJoinBehavior(mocks.mockRootService); - assertResultStructure(result, { count: 3, total: 3 }); - }); - - it('should automatically inject $notnull filter for join: "INNER" forward relation', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - relation.join = 'INNER'; // Specify INNER JOIN - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - - const data = createMinimalRootRelationSet(); - // Only relations with rootId values (simulating INNER JOIN result) - const innerJoinRelations = data.relations.filter( - (relation) => relation.rootId, - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(innerJoinRelations, { total: 3 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 3 }), - ); - - // ACT - await mocks.service.getMany(req); - - // ASSERT - Should trigger INNER JOIN behavior with $notnull search condition - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [{ rootId: { $notnull: true } }, { isLatest: { $eq: true } }], - }, - limit: 10, - offset: 0, - }); - - // Should trigger INNER JOIN behavior (relation-first) - assertInnerJoinBehavior( - mocks.mockRootService, - mocks.mockRelationService, - { $and: [{ rootId: { $notnull: true } }, { isLatest: { $eq: true } }] }, // Expected search condition - [1, 2], - ); - }); - - it('should preserve existing filters when injecting $notnull for INNER join', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - relation.join = 'INNER'; - const req = mocks.createTestRequest( - { - filter: ['relations.status||$eq||active'], // Existing filter - page: '1', - limit: '10', - }, - [relation], - ); - - const data = createMinimalRootRelationSet(); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations.slice(0, 2), { total: 2 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { total: 2 }), - ); - - // ACT - await mocks.service.getMany(req); - - // ASSERT - Should have both existing filter and injected $notnull in search conditions - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [ - { status: { $eq: 'active' } }, - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, - ], - }, - limit: 10, - offset: 0, - }); - }); - - it('should not inject duplicate $notnull filter if one already exists', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, - ); - relation.join = 'INNER'; - const req = mocks.createTestRequest( - { - filter: ['relations.rootId||$notnull'], // Already has $notnull filter - page: '1', - limit: '10', - }, - [relation], - ); - - const data = createMinimalRootRelationSet(); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 3 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { total: 2 }), - ); - - // ACT - await mocks.service.getMany(req); - - // ASSERT - Should have only one $notnull search condition (not duplicated) - assertRelationRequest(mocks.mockRelationService, { - search: { - $and: [{ rootId: { $notnull: true } }, { isLatest: { $eq: true } }], - }, - limit: 10, - offset: 0, - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/no-relations.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/no-relations.spec.ts deleted file mode 100644 index 04f334178..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/no-relations.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { createPaginatedResponse } from '../../__FIXTURES__/crud-federation-mock-helpers'; -import { - assertServiceCallCounts, - assertNoRelationServiceCalls, - assertRootGetManyRequest, - assertResultStructure, - assertEmptyResult, - assertSortOrder, -} from '../../__FIXTURES__/crud-federation-test-assertions'; -import { - createMinimalRootRelationSet, - createSortDataSet, -} from '../../__FIXTURES__/crud-federation-test-data'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -/** - * Behavior tests for queries without any relation relationships - * Verifies that root-only queries pass through unchanged - */ -describe('CrudFederationService - Behavior: No Relations Query', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - it('should pass through root request unchanged when no relations exist', async () => { - // ARRANGE - const req = mocks.createTestRequest({}); - const data = createMinimalRootRelationSet(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 0 }, - ]); - assertNoRelationServiceCalls(mocks.mockRelationService); - assertRootGetManyRequest(mocks.mockRootService, {}); - assertResultStructure(result, { count: 3, total: 3 }); - }); - - it('should preserve root filters when no relations exist', async () => { - // ARRANGE - Use interceptor to properly transform filters to search - const req = mocks.createTestRequest({ filter: ['name||$eq||test'] }); - const filteredRoots = [{ id: 1, name: 'test' }]; - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(filteredRoots, { limit: 10, total: 1 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 0 }, - ]); - assertNoRelationServiceCalls(mocks.mockRelationService); - assertRootGetManyRequest(mocks.mockRootService, { - search: { name: { $eq: 'test' } }, - }); - assertResultStructure(result, { count: 1, total: 1 }); - expect(result.data[0]).toEqual({ id: 1, name: 'test' }); - }); - - it('should preserve root sorting when no relations exist', async () => { - // ARRANGE - const req = mocks.createTestRequest({ sort: ['name,ASC'] }); - const data = createSortDataSet(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.rootsByName, { limit: 10, total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 0 }, - ]); - assertNoRelationServiceCalls(mocks.mockRelationService); - assertRootGetManyRequest(mocks.mockRootService, { - sort: [{ field: 'name', order: 'ASC' }], - }); - assertResultStructure(result, { count: 3, total: 3 }); - assertSortOrder(result, [3, 1, 2]); - }); - - it('should handle empty root results with no relations', async () => { - // ARRANGE - const req = mocks.createTestRequest({}); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse([], { limit: 10, total: 0 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 0 }, - ]); - assertNoRelationServiceCalls(mocks.mockRelationService); - assertRootGetManyRequest(mocks.mockRootService, {}); - assertEmptyResult(result); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/relation-sort-behavior.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/relation-sort-behavior.spec.ts deleted file mode 100644 index 6c1a1e2e3..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/relation-sort-behavior.spec.ts +++ /dev/null @@ -1,616 +0,0 @@ -import { CondOperator } from '../../../request/types/crud-request-query.types'; -import { createPaginatedResponse } from '../../__FIXTURES__/crud-federation-mock-helpers'; -import { - assertServiceCallCounts, - assertRootGetManyRequest, - assertRelationRequest, - assertRelationFirst, - assertResultStructure, - assertEnrichment, - assertEmptyResult, - assertSortOrder, -} from '../../__FIXTURES__/crud-federation-test-assertions'; -import { - createRelationSortByTitleSet, - createRelationSortByPrioritySet, - createRelationSortPaginationSet, - createRelationSortEmptySet, -} from '../../__FIXTURES__/crud-federation-test-data'; -import { - createOneToManyForwardRelation, - TestRelationService, -} from '../../__FIXTURES__/crud-federation-test-entities'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -/** - * Behavior tests for relation sort strategy (Scenario 13) - * Relation sort requires INNER JOIN semantics with $notnull filter on join key - * Causes relation-first sequencing with sort applied to driving relation - */ -describe('CrudFederationService - Behavior: Relation Sort Strategy', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - // Register the 'relations' relation that tests use - mocks.registerRelation(mocks.mockRelationService); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('Forward relationship relation sort', () => { - it('should sort roots by relation field with distinctFilter and $notnull filter', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, // distinctFilter for uniqueness - ); - const req = mocks.createTestRequest( - { - sort: ['relations.title,ASC'], - page: '1', - limit: '10', - }, - [relation], - ); - - const data = createRelationSortByTitleSet(); - - // Sequential approach: constraint call + enrichment call - mocks.mockRelationService.getMany - .mockResolvedValueOnce( - createPaginatedResponse(data.relationsByTitle.slice(0, 3), { - total: 3, - }), - ) - .mockResolvedValueOnce( - createPaginatedResponse(data.relationsByTitle, { - total: 4, - }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.rootsInNaturalOrder, { - limit: 10, - total: 3, - }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, // constraint + enrichment - ]); - - // Verify first call: relation service called with user pagination and distinctFilter applied - assertRelationRequest( - mocks.mockRelationService, - { - limit: 10, - offset: 0, - page: undefined, - sort: [{ field: 'title', order: 'ASC' }], - search: { - $and: [ - { rootId: { [CondOperator.NOT_NULL]: true } }, - { isLatest: { $eq: true } }, - ], - }, - }, - 0, - ); - - // Verify distinctFilter was applied to first call - assertRelationRequest(mocks.mockRelationService, { - filter: [ - { - field: 'isLatest', - operator: '$eq', - value: true, - relation: 'relations', - }, - { - field: 'rootId', - operator: '$notnull', - relation: 'relations', - value: '', - }, - ], - limit: 10, - offset: 0, - search: { - $and: [{ rootId: { $notnull: true } }, { isLatest: { $eq: true } }], - }, - sort: [{ field: 'title', order: 'ASC' }], - }); - - // Verify second call: enrichment call for discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [2, 1, 3] } }, - ], - }, - }, - 1, - ); - - // Verify root request has discovered IDs - assertRootGetManyRequest(mocks.mockRootService, { - search: { id: { $in: [2, 1, 3] } }, // Root IDs discovered from sorted relations - page: 1, - limit: 10, - sort: [], // No root sorts (relation sort takes precedence) - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 3, total: 3 }); - - // Verify sort order preserved in final results - assertSortOrder(result, [2, 1, 3]); // Roots in relation sort order - - // Verify enrichment - assertEnrichment(result, 'relations', { - 2: [{ id: 1, rootId: 2, title: 'Alpha Task' }], - 1: [ - { id: 2, rootId: 1, title: 'Beta Task' }, - { id: 4, rootId: 1, title: 'Delta Task' }, - ], - 3: [{ id: 3, rootId: 3, title: 'Charlie Task' }], - }); - }); - - it('should handle relation sort with additional AND filters', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, // distinctFilter for uniqueness - ); - const req = mocks.createTestRequest( - { - filter: ['relations.priority||$gte||5'], - sort: ['relations.priority,DESC'], - page: '1', - limit: '10', - }, - [relation], - ); - - const data = createRelationSortByPrioritySet(); - // Only relations with priority >= 5, and only first relation per rootId (distinctFilter effect) - const highPriorityRelations = data.relationsByPriority - .filter((relation) => relation.priority >= 5) - .filter( - (relation, index, array) => - array.findIndex((r) => r.rootId === relation.rootId) === index, - ); - - // First call: distinctFilter applied for sorting (3 unique relations) - // Second call: all high priority relations for enrichment (4 total high priority relations) - const allHighPriorityRelations = data.relationsByPriority.filter( - (relation) => relation.priority >= 5, - ); - mocks.mockRelationService.getMany - .mockResolvedValueOnce( - createPaginatedResponse(highPriorityRelations, { total: 3 }), - ) - .mockResolvedValueOnce( - createPaginatedResponse(allHighPriorityRelations, { total: 4 }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.uniqueRootsInOrder, { - limit: 10, - total: 3, - }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, // constraint + enrichment - ]); - - // Verify relation called first (relation-sort pattern) - assertRelationFirst(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify first call: relation service called with multiple filters and sort - assertRelationRequest( - mocks.mockRelationService, - { - limit: 10, - offset: 0, - page: undefined, - search: { - $and: [ - { priority: { $gte: 5 } }, - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, - ], - }, - sort: [{ field: 'priority', order: 'DESC' }], - }, - 0, - ); - - // Verify second call: enrichment call for discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { priority: { $gte: 5 } }, - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [1, 2, 3] } }, - ], - }, - }, - 1, - ); - - // Verify root request has discovered IDs and correct pagination - assertRootGetManyRequest(mocks.mockRootService, { - search: { id: { $in: [1, 2, 3] } }, - page: 1, - limit: 10, - sort: [], - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 3, total: 3 }); - assertSortOrder(result, [1, 2, 3]); // Sorted by priority DESC - }); - - it('should deduplicate roots when multiple relations match', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, // distinctFilter for uniqueness - ); - const req = mocks.createTestRequest( - { - sort: ['relations.priority,DESC'], - page: '1', - limit: '10', - }, - [relation], - ); - - const data = createRelationSortByPrioritySet(); - // Apply distinctFilter effect - only first relation per rootId - const uniqueRelations = data.relationsByPriority.filter( - (relation, index, array) => - array.findIndex((r) => r.rootId === relation.rootId) === index, - ); - - // First call: distinctFilter applied for sorting (unique relations) - // Second call: all relations for enrichment - mocks.mockRelationService.getMany - .mockResolvedValueOnce( - createPaginatedResponse(uniqueRelations, { total: 3 }), - ) - .mockResolvedValueOnce( - createPaginatedResponse(data.relationsByPriority, { - total: data.relationsByPriority.length, - }), - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.uniqueRootsInOrder, { - limit: 10, - total: 3, - }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, // constraint + enrichment - ]); - - // Verify relation called first (relation-sort pattern) - assertRelationFirst(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify first call: relation service called with filter and sort - assertRelationRequest( - mocks.mockRelationService, - { - limit: 10, - offset: 0, - page: undefined, - search: { - $and: [{ rootId: { $notnull: true } }, { isLatest: { $eq: true } }], - }, - sort: [{ field: 'priority', order: 'DESC' }], - }, - 0, - ); - - // Verify second call: enrichment call for discovered root IDs - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, - { rootId: { $in: [1, 2, 3] } }, - ], - }, - }, - 1, - ); - - // Verify root request has discovered IDs and correct pagination - assertRootGetManyRequest(mocks.mockRootService, { - search: { id: { $in: [1, 2, 3] } }, // Deduplicated root IDs - page: 1, - limit: 10, - sort: [], - }); - - // Roots appear only once despite multiple relations - assertResultStructure(result, { count: 3, total: 3 }); - expect(result.data.map((p) => p.id)).toEqual([1, 2, 3]); - }); - - it('should return empty result when no relations match with sort', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, // distinctFilter for uniqueness - ); - const req = mocks.createTestRequest( - { - filter: ['relations.status||$eq||archived'], - sort: ['relations.title,ASC'], - }, - [relation], - ); - - const data = createRelationSortEmptySet(); - - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 0 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - No relations found, so root not called - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 0 }, - { service: mocks.mockRelationService, count: 1 }, // Only one call since no relations found - ]); - - // Verify the single relation service call had correct filters and sort - assertRelationRequest( - mocks.mockRelationService, - { - limit: 10, - offset: 0, - page: undefined, - search: { - $and: [ - { status: { $eq: 'archived' } }, - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, // distinctFilter - ], - }, - sort: [{ field: 'title', order: 'ASC' }], - }, - 0, - ); - - assertEmptyResult(result); - }); - - it('should apply relation sort with pagination correctly', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, // distinctFilter for uniqueness - ); - const req = mocks.createTestRequest( - { - sort: ['relations.title,ASC'], - page: '1', - limit: '5', // First page, 5 roots - }, - [relation], - ); - - const data = createRelationSortPaginationSet(); - - // First call: distinctFilter applied for sorting (paginated relations - first 5 for page 1) - // Second call: enrichment for discovered root IDs from page 1 (same 5 relations) - const firstPageRelations = data.allRelationsSorted.slice(0, 5); // First 5 relations for page 1 - mocks.mockRelationService.getMany - .mockResolvedValueOnce( - createPaginatedResponse(firstPageRelations, { total: 10 }), // Total across all pages - ) - .mockResolvedValueOnce( - createPaginatedResponse(firstPageRelations, { total: 5 }), // Current page count - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.firstPageRoots, { limit: 5, total: 10 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, // constraint + enrichment - ]); - - // Verify relation called first (relation-sort pattern) - assertRelationFirst(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify first call: relation service called with filter and sort - assertRelationRequest( - mocks.mockRelationService, - { - limit: 5, - offset: 0, - page: undefined, - search: { - $and: [{ rootId: { $notnull: true } }, { isLatest: { $eq: true } }], - }, - sort: [{ field: 'title', order: 'ASC' }], - }, - 0, - ); - - // Verify second call: enrichment call for paginated root IDs only (first page) - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, // distinctFilter - { rootId: { $in: [5, 2, 8, 1, 9] } }, // Only first page root IDs - ], - }, - }, - 1, - ); - - // Verify root request has only paginated root IDs (page 1: first 5) - assertRootGetManyRequest(mocks.mockRootService, { - search: { id: { $in: [5, 2, 8, 1, 9] } }, // Only first page root IDs - limit: 5, // Page limit, not total discovered count - page: 1, - sort: [], - }); - - // Verify pagination structure - expect(result.count).toBe(5); - expect(result.total).toBe(10); - expect(result.page).toBe(1); - expect(result.pageCount).toBe(2); - - // Verify first page sort order - assertSortOrder(result, [5, 2, 8, 1, 9]); - }); - - it('should apply relation sort with pagination correctly for page 2', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, // distinctFilter for uniqueness - ); - const req = mocks.createTestRequest( - { - sort: ['relations.title,ASC'], - page: '2', - limit: '5', // Second page, 5 roots - }, - [relation], - ); - - const data = createRelationSortPaginationSet(); - - // First call: distinctFilter applied for sorting (paginated relations - second 5 for page 2) - // Second call: enrichment for discovered root IDs from page 2 (same 5 relations) - const secondPageRelations = data.allRelationsSorted.slice(5, 10); // Second 5 relations for page 2 - mocks.mockRelationService.getMany - .mockResolvedValueOnce( - createPaginatedResponse(secondPageRelations, { total: 10 }), // Total across all pages - ) - .mockResolvedValueOnce( - createPaginatedResponse(secondPageRelations, { total: 5 }), // Current page count - ); - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.secondPageRoots, { - limit: 5, - total: 10, - }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 2 }, // constraint + enrichment - ]); - - // Verify relation called first (relation-sort pattern) - assertRelationFirst(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify first call: relation service called with filter and sort - assertRelationRequest( - mocks.mockRelationService, - { - limit: 5, - offset: 5, - page: undefined, - search: { - $and: [{ rootId: { $notnull: true } }, { isLatest: { $eq: true } }], - }, - sort: [{ field: 'title', order: 'ASC' }], - }, - 0, - ); - - // Verify second call: enrichment call for paginated root IDs only (second page) - assertRelationRequest( - mocks.mockRelationService, - { - search: { - $and: [ - { rootId: { $notnull: true } }, - { isLatest: { $eq: true } }, // distinctFilter - { rootId: { $in: [4, 7, 3, 6, 10] } }, // Only second page root IDs - ], - }, - }, - 1, - ); - - // Verify root request has only paginated root IDs (page 2: second 5) - assertRootGetManyRequest(mocks.mockRootService, { - search: { id: { $in: [4, 7, 3, 6, 10] } }, // Only second page root IDs - page: 1, - limit: 5, - sort: [], - }); - - // Verify pagination structure - expect(result.count).toBe(5); - expect(result.total).toBe(10); - expect(result.page).toBe(2); - expect(result.pageCount).toBe(2); - - // Verify second page sort order - assertSortOrder(result, [4, 7, 3, 6, 10]); // Foxtrot, Golf, Hotel, India, Juliet - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/relation-sort-validation.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/relation-sort-validation.spec.ts deleted file mode 100644 index 289ee0b42..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/relation-sort-validation.spec.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { CrudFederationException } from '../../../exceptions/crud-federation.exception'; -import { - assertServiceCallCounts, - assertRelationSortValidationError, -} from '../../__FIXTURES__/crud-federation-test-assertions'; -import { - createOneToManyForwardRelation, - TestRelationService, -} from '../../__FIXTURES__/crud-federation-test-entities'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -/** - * Validation tests for relation sort requirements (Scenario 14) - * Relation sort requires specific $notnull filter on join key to ensure INNER JOIN semantics - * Tests various invalid configurations and validates helpful error messages - */ -describe('CrudFederationService - Behavior: Relation Sort Validation', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - // Register the 'relations' relation that tests use - mocks.registerRelation(mocks.mockRelationService); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('Forward relationship validation', () => { - it('should throw error when relation sort lacks any filters', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest( - { - sort: ['relations.title,ASC'], // No filters - will error! - }, - [relation], - ); - - // ACT & ASSERT - const error = await mocks.service.getMany(req).catch((e) => e); - - assertRelationSortValidationError(error); - - // No services should be called when validation fails - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 0 }, - { service: mocks.mockRelationService, count: 0 }, - ]); - }); - - it('should throw error when relation sort has unrelated relation filters only', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest( - { - filter: ['relations.status||$eq||active'], // Unrelated filter, missing $notnull - sort: ['relations.priority,DESC'], - }, - [relation], - ); - - // ACT & ASSERT - const error = await mocks.service.getMany(req).catch((e) => e); - - assertRelationSortValidationError(error); - - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 0 }, - { service: mocks.mockRelationService, count: 0 }, - ]); - }); - - it('should throw error when relation sort has non-notnull filter', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest( - { - // This should trigger the validation error since no $notnull filter exists - filter: ['relations.status||$eq||active'], - sort: ['relations.title,ASC'], - }, - [relation], - ); - - // ACT & ASSERT - const error = await mocks.service.getMany(req).catch((e) => e); - - expect(error).toBeInstanceOf(CrudFederationException); - expect(error.message).toContain('distinctFilter configuration'); - - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 0 }, - { service: mocks.mockRelationService, count: 0 }, - ]); - }); - - it('should throw error when relation sort has AND filter on non-join field only', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest( - { - filter: [ - 'relations.status||$eq||active', - 'relations.priority||$gte||5', - ], // No join key filter - sort: ['relations.createdAt,DESC'], - }, - [relation], - ); - - // ACT & ASSERT - const error = await mocks.service.getMany(req).catch((e) => e); - - assertRelationSortValidationError(error); - - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 0 }, - { service: mocks.mockRelationService, count: 0 }, - ]); - }); - - it('should provide helpful error message with join key filter suggestion', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest( - { - sort: ['relations.priority,DESC'], - }, - [relation], - ); - - // ACT & ASSERT - const error = await mocks.service.getMany(req).catch((e) => e); - - expect(error).toBeInstanceOf(CrudFederationException); - expect(error.message).toContain('distinctFilter configuration'); - // The error message now suggests using distinctFilter configuration - }); - }); - - describe('Mixed filter scenarios', () => { - it('should throw error when root filters exist but no relation join key filter', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest( - { - filter: ['name||$cont||Project'], // Root filter only - sort: ['relations.title,ASC'], // Relation sort - }, - [relation], - ); - - // ACT & ASSERT - const error = await mocks.service.getMany(req).catch((e) => e); - - assertRelationSortValidationError(error); - }); - }); - - describe('Valid configurations (should not throw)', () => { - it('should not throw error when valid $notnull filter exists', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, // distinctFilter configuration - ); - const req = mocks.createTestRequest( - { - filter: ['relations.rootId||$notnull'], // Valid filter - sort: ['relations.title,ASC'], - }, - [relation], - ); - - // Mock empty responses to avoid actual fetch logic - mocks.mockRelationService.getMany.mockResolvedValue({ - data: [], - count: 0, - total: 0, - page: 1, - pageCount: 0, - limit: 100, - }); - - // ACT - Should not throw - const result = await mocks.service.getMany(req); - - // ASSERT - Validation passed, relation service called - expect(result.data).toEqual([]); - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 0 }, // No roots when no relations - { service: mocks.mockRelationService, count: 1 }, - ]); - }); - - it('should not throw error when valid $notnull filter exists with additional filters', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - { distinctFilter: { field: 'isLatest', operator: '$eq', value: true } }, // distinctFilter configuration - ); - const req = mocks.createTestRequest( - { - filter: [ - 'relations.rootId||$notnull', // Valid join key filter - 'relations.status||$eq||active', // Additional filter OK - ], - sort: ['relations.priority,DESC'], - }, - [relation], - ); - - // Mock empty responses - mocks.mockRelationService.getMany.mockResolvedValue({ - data: [], - count: 0, - total: 0, - page: 1, - pageCount: 0, - limit: 100, - }); - - // ACT - Should not throw - const result = await mocks.service.getMany(req); - - // ASSERT - Validation passed - expect(result.data).toEqual([]); - assertServiceCallCounts([ - { service: mocks.mockRelationService, count: 1 }, - ]); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/root-sort-behavior.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/root-sort-behavior.spec.ts deleted file mode 100644 index de4b5757c..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/root-sort-behavior.spec.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { createPaginatedResponse } from '../../__FIXTURES__/crud-federation-mock-helpers'; -import { - assertServiceCallCounts, - assertRootFirst, - assertLeftJoinBehavior, - assertRootGetManyRequest, - assertResultStructure, - assertEnrichment, - assertSortOrder, - assertRelationRequest, -} from '../../__FIXTURES__/crud-federation-test-assertions'; -import { - createNameSortDataSet, - createIdDescSortDataSet, - createMultiSortDataSet, -} from '../../__FIXTURES__/crud-federation-test-data'; -import { - createOneToManyForwardRelation, - TestRelationService, -} from '../../__FIXTURES__/crud-federation-test-entities'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -/** - * Behavior tests for root sort strategy (LEFT JOIN compatible) - * Root sort allows LEFT JOIN behavior - all roots returned, sorted by root field - * No constraint validation needed for root sorts - */ -describe('CrudFederationService - Behavior: Root Sort Strategy', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - // Register the 'relations' relation that tests use - mocks.registerRelation(mocks.mockRelationService); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('Single root field sort', () => { - it('should sort roots by name with LEFT JOIN behavior', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest( - { - page: '1', - limit: '10', - sort: ['name,ASC'], - }, - [relation], - ); - - const data = createNameSortDataSet(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 3 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 2 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root service called with sort parameters - assertRootGetManyRequest(mocks.mockRootService, { - sort: [{ field: 'name', order: 'ASC' }], - page: 1, - limit: 10, - }); - - // Verify relation service called with all root IDs for enrichment - assertRelationRequest(mocks.mockRelationService, { - search: { - rootId: { $in: [1, 3, 2] }, - }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 3, total: 3 }); - assertSortOrder(result, [1, 3, 2]); // Root A, Root B, Root C by name ASC - assertEnrichment(result, 'relations', { - 1: [{ id: 1, rootId: 1, title: 'Relation 1' }], - 2: [], // No relations - 3: [{ id: 2, rootId: 3, title: 'Relation 2' }], - }); - }); - - it('should sort roots by id descending with LEFT JOIN behavior', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest( - { - page: '1', - limit: '5', - sort: 'id,DESC', - }, - [relation], - ); - - const data = createIdDescSortDataSet(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 5, total: 5 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root service called with descending sort parameters - assertRootGetManyRequest(mocks.mockRootService, { - sort: [{ field: 'id', order: 'DESC' }], - page: 1, - limit: 5, - }); - - // Verify relation service called with all root IDs for enrichment - assertRelationRequest(mocks.mockRelationService, { - search: { - rootId: { $in: [3, 4, 5, 2, 1] }, - }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 5, total: 5 }); - assertSortOrder(result, [3, 4, 5, 2, 1]); // As returned by mock (already sorted DESC) - assertEnrichment(result, 'relations', { - 1: [], // No relations - 2: [{ id: 1, rootId: 2, title: 'Relation 1' }], - 3: [], // No relations - 4: [ - { id: 2, rootId: 4, title: 'Relation 2' }, - { id: 3, rootId: 4, title: 'Relation 3' }, - ], - 5: [], // No relations - }); - }); - }); - - describe('Multiple root field sorts', () => { - it('should sort roots by multiple fields with LEFT JOIN behavior', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest( - { - page: '1', - limit: '10', - sort: ['name,ASC', 'id,DESC'], - }, - [relation], - ); - - const data = createMultiSortDataSet(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 3 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root service called with multi-field sort parameters - assertRootGetManyRequest(mocks.mockRootService, { - sort: [ - { field: 'name', order: 'ASC' }, - { field: 'id', order: 'DESC' }, - ], - page: 1, - limit: 10, - }); - - // Verify relation service called with all root IDs for enrichment - assertRelationRequest(mocks.mockRelationService, { - search: { - rootId: { $in: [3, 1, 2] }, - }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 3, total: 3 }); - assertSortOrder(result, [3, 1, 2]); // Root A (id:3), Root A (id:1), Root B (id:2) - assertEnrichment(result, 'relations', { - 1: [ - { id: 1, rootId: 1, title: 'Relation 1' }, - { id: 2, rootId: 1, title: 'Relation 2' }, - ], - 2: [], // No relations - 3: [{ id: 3, rootId: 3, title: 'Relation 3' }], - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/unsupported-features.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/unsupported-features.spec.ts deleted file mode 100644 index 17054e9c0..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-behavior/unsupported-features.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { CrudFederationException } from '../../../exceptions/crud-federation.exception'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -describe('CrudFederationService - Unsupported Features Validation', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('OR filter via query string validation', () => { - it('should throw error when req.parsed.or has filters', async () => { - const req = mocks.createTestRequest(); - req.parsed.or = [{ field: 'name', operator: '$cont', value: 'test' }]; - - await expect(mocks.service.getMany(req)).rejects.toThrow( - 'OR filter via query string is not supported in CRUD federation. ' + - 'Use AND filter conditions instead.', - ); - - expect(mocks.mockRootService.getMany).not.toHaveBeenCalled(); - }); - - it('should not throw error when req.parsed.or is empty array', async () => { - const req = mocks.createTestRequest(); - req.parsed.or = []; - - // Should not throw CrudFederationException for empty or array - try { - await mocks.service.getMany(req); - } catch (error) { - expect(error).not.toBeInstanceOf(CrudFederationException); - } - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-integration/get-one-hydration.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-integration/get-one-hydration.spec.ts deleted file mode 100644 index f44b5dcc3..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-integration/get-one-hydration.spec.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { createPaginatedResponse } from '../../__FIXTURES__/crud-federation-mock-helpers'; -import { - assertRelationRequest, - assertRootFirstGetOne, - assertRootGetOneRequest, -} from '../../__FIXTURES__/crud-federation-test-assertions'; -import { - createMinimalRootRelationSet, - createSingleEntitySet, - createMultiRelationSet, -} from '../../__FIXTURES__/crud-federation-test-data'; -import { - createOneToManyForwardRelation, - createOneToOneForwardRelation, - TestRelationService, - TestProfileService, -} from '../../__FIXTURES__/crud-federation-test-entities'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -/** - * Integration tests for getOne federation with relation hydration - * Tests single entity fetching with relation hydration support - */ -describe('CrudFederationService - Integration: getOne Hydration', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - // Register the relations that tests use - mocks.registerRelation(mocks.mockRelationService); - mocks.registerRelation(mocks.mockProfileService); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('no relations', () => { - it('should fetch single root without relations', async () => { - // ARRANGE - const data = createSingleEntitySet(); - mocks.mockRootService.getOne.mockResolvedValue(data.roots[0]); - - const req = mocks.createTestRequest({}); - - // ACT - const result = await mocks.service.getOne(req); - - // ASSERT - expect(result).toEqual(data.roots[0]); - expect(mocks.mockRootService.getOne).toHaveBeenCalledTimes(1); - expect(mocks.mockRelationService.getMany).toHaveBeenCalledTimes(0); - - // Verify root service was called with correct parameters - assertRootGetOneRequest(mocks.mockRootService, {}); - }); - }); - - describe('one-to-one forward relation', () => { - it('should hydrate existing one-to-one relation', async () => { - // ARRANGE - const data = createMinimalRootRelationSet(); - mocks.mockRootService.getOne.mockResolvedValue(data.roots[0]); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse([data.relations[0]], { total: 1 }), - ); - - const relation = createOneToOneForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({}, [relation]); - - // ACT - const result = await mocks.service.getOne(req); - - // ASSERT - expect(result.id).toBe(1); - - // Service call verification - expect(mocks.mockRootService.getOne).toHaveBeenCalledTimes(1); - expect(mocks.mockRelationService.getMany).toHaveBeenCalledTimes(1); - assertRootFirstGetOne(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify root service was called with correct parameters - assertRootGetOneRequest(mocks.mockRootService, {}); - - // Verify relation service was called with correct filter - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $eq: 1 } }, - }); - - // Verify enrichment - the relation should be attached to the root - expect(result.relations).toEqual(data.relations[0]); - }); - - it('should handle missing one-to-one relation', async () => { - // ARRANGE - const data = createSingleEntitySet(); - mocks.mockRootService.getOne.mockResolvedValue(data.roots[0]); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse([], { total: 0 }), - ); - - const relation = createOneToOneForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({}, [relation]); - - // ACT - const result = await mocks.service.getOne(req); - - // ASSERT - expect(result.id).toBe(1); - - // Service call verification - expect(mocks.mockRootService.getOne).toHaveBeenCalledTimes(1); - expect(mocks.mockRelationService.getMany).toHaveBeenCalledTimes(1); - assertRootFirstGetOne(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify root service was called with correct parameters - assertRootGetOneRequest(mocks.mockRootService, {}); - - // Verify relation service was called with correct filter and NO limit - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $eq: 1 } }, - }); - - // Verify enrichment - relation should be null when missing - expect(result.relations).toBeNull(); - }); - }); - - describe('one-to-many forward relation', () => { - it('should hydrate multiple one-to-many relations', async () => { - // ARRANGE - const data = createMultiRelationSet(); - mocks.mockRootService.getOne.mockResolvedValue(data.roots[0]); - // Create test data with multiple relations for root 1 - const multipleRelations = [ - { id: 1, rootId: 1, title: 'Relation 1' }, - { id: 2, rootId: 1, title: 'Relation 2' }, - ]; - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(multipleRelations, { total: 2 }), - ); - - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({}, [relation]); - - // ACT - const result = await mocks.service.getOne(req); - - // ASSERT - expect(result.id).toBe(1); - - // Service call verification - expect(mocks.mockRootService.getOne).toHaveBeenCalledTimes(1); - expect(mocks.mockRelationService.getMany).toHaveBeenCalledTimes(1); - assertRootFirstGetOne(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify root service was called with correct parameters - assertRootGetOneRequest(mocks.mockRootService, {}); - - // Verify relation service was called with correct filter and NO limit - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $eq: 1 } }, - }); - - // Verify enrichment - the relations array should be properly attached - expect(result.relations).toEqual(multipleRelations); - }); - - it('should handle empty one-to-many relation', async () => { - // ARRANGE - const data = createSingleEntitySet(); - mocks.mockRootService.getOne.mockResolvedValue(data.roots[0]); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse([], { total: 0 }), - ); - - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({}, [relation]); - - // ACT - const result = await mocks.service.getOne(req); - - // ASSERT - expect(result.id).toBe(1); - - // Service call verification - expect(mocks.mockRootService.getOne).toHaveBeenCalledTimes(1); - expect(mocks.mockRelationService.getMany).toHaveBeenCalledTimes(1); - assertRootFirstGetOne(mocks.mockRootService, [mocks.mockRelationService]); - - // Verify root service was called with correct parameters - assertRootGetOneRequest(mocks.mockRootService, {}); - - // Verify relation service was called with correct filter and NO limit - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $eq: 1 } }, - }); - - // Verify enrichment - empty relations array - expect(result.relations).toEqual([]); - }); - }); - - describe('mixed relation types', () => { - it('should hydrate both one-to-one and one-to-many relations', async () => { - // ARRANGE - const data = createMultiRelationSet(); - mocks.mockRootService.getOne.mockResolvedValue(data.roots[0]); - - // Mock profile service (one-to-one) - mocks.mockProfileService.getMany.mockResolvedValue( - createPaginatedResponse([data.profiles[0]], { total: 1 }), - ); - - // Mock relation service (one-to-many) - create multiple relations for root 1 - const multipleRelations = [ - { id: 1, rootId: 1, title: 'Relation 1' }, - { id: 2, rootId: 1, title: 'Relation 2' }, - ]; - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(multipleRelations, { total: 2 }), - ); - - const profileRelation = createOneToOneForwardRelation( - 'profile', - TestProfileService, - ); - const relationRelation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({}, [ - profileRelation, - relationRelation, - ]); - - // ACT - const result = await mocks.service.getOne(req); - - // ASSERT - expect(result.id).toBe(1); - - // Service call verification - expect(mocks.mockRootService.getOne).toHaveBeenCalledTimes(1); - expect(mocks.mockProfileService.getMany).toHaveBeenCalledTimes(1); - expect(mocks.mockRelationService.getMany).toHaveBeenCalledTimes(1); - assertRootFirstGetOne(mocks.mockRootService, [ - mocks.mockProfileService, - mocks.mockRelationService, - ]); - - // Verify root service was called with correct parameters - assertRootGetOneRequest(mocks.mockRootService, {}); - - // Verify profile service was called with correct filter and NO limit - assertRelationRequest(mocks.mockProfileService, { - search: { rootId: { $eq: 1 } }, - }); - - // Verify relation service was called with correct filter and NO limit - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $eq: 1 } }, - }); - - // Verify enrichment - both relations should be properly attached - expect(result.profile).toEqual(data.profiles[0]); - expect(result.relations).toEqual(multipleRelations); - }); - }); - - describe('null foreign key handling', () => { - it('should handle null foreign key in forward relationship', async () => { - // ARRANGE - const rootWithNullForeignKey = { - id: 1, - name: 'Only Root', - profileId: null, - }; - - mocks.mockRootService.getOne.mockResolvedValue(rootWithNullForeignKey); - mocks.mockProfileService.getMany.mockResolvedValue( - createPaginatedResponse([], { total: 0 }), - ); - - // Create forward relation but this root has no profile, so it should return null - const relation = createOneToOneForwardRelation( - 'profile', - TestProfileService, - ); - const req = mocks.createTestRequest({}, [relation]); - - // ACT - const result = await mocks.service.getOne(req); - - // ASSERT - expect(result.id).toBe(1); - - // Service call verification - expect(mocks.mockRootService.getOne).toHaveBeenCalledTimes(1); - expect(mocks.mockProfileService.getMany).toHaveBeenCalledTimes(1); - assertRootFirstGetOne(mocks.mockRootService, [mocks.mockProfileService]); - - // Verify root service was called with correct parameters - assertRootGetOneRequest(mocks.mockRootService, {}); - - // Verify profile service was called with correct filter and NO limit - assertRelationRequest(mocks.mockProfileService, { - search: { rootId: { $eq: 1 } }, - }); - - // Verify enrichment - profile should be null for null foreign key - expect(result.profile).toBeNull(); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-integration/one-to-many-forward.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-integration/one-to-many-forward.spec.ts deleted file mode 100644 index 63617c1e4..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-integration/one-to-many-forward.spec.ts +++ /dev/null @@ -1,832 +0,0 @@ -import { createPaginatedResponse } from '../../__FIXTURES__/crud-federation-mock-helpers'; -import { - assertServiceCallCounts, - assertRootFirst, - assertLeftJoinBehavior, - assertRootGetManyRequest, - assertRelationRequest, - assertEnrichment, - assertResultStructure, -} from '../../__FIXTURES__/crud-federation-test-assertions'; -import { - createLargeRootRelationSet, - createFilteredRootSet, - createMultiRelationEntitySet, - createSingleEntitySet, - createVaryingRelationCountSet, - createPaginationPage2Set, - createComplexMultiRelationSet, -} from '../../__FIXTURES__/crud-federation-test-data'; -import { - createOneToManyForwardRelation, - TestRelationService, - TestSettingsService, -} from '../../__FIXTURES__/crud-federation-test-entities'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -/** - * Integration tests for forward relationship behavior - * Forward relationships: Relation.rootId -> Root.id - * Focuses on service interactions, call sequencing, and parameter passing - */ -describe('CrudFederationService - Integration: Forward Relationships', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - // Register the relations that tests use - mocks.registerRelation(mocks.mockRelationService); - mocks.registerRelation(mocks.mockSettingsService); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('Service Call Sequencing', () => { - it('should call relation service with proper parameters during discovery (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - const data = createLargeRootRelationSet(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 5 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 4 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root service parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 1, - limit: 10, - }); - - // Verify relation service called with root IDs - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $in: [1, 2, 3, 4, 5] } }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 5, total: 5 }); - assertEnrichment(result, 'relations', { - 1: [{ id: 1, rootId: 1, title: 'Relation 1' }], - 2: [{ id: 2, rootId: 2, title: 'Relation 2' }], - 3: [{ id: 3, rootId: 3, title: 'Relation 3' }], - 4: [], // No relations - 5: [], // No relations - }); - - // Relation 4 (rootId: 99) should not be attached to any root since root 99 doesn't exist - // This is verified implicitly by checking that roots 4 and 5 have empty arrays - }); - - it('should call root service with no filters when relations are empty (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - const data = createLargeRootRelationSet(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 5 }), - ); - // Empty relations to test LEFT JOIN behavior - all roots should still be returned - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse([], { limit: 100, total: 0 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify relation service called with root IDs - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $in: [1, 2, 3, 4, 5] } }, - }); - - // Verify root service parameters - assertRootGetManyRequest(mocks.mockRootService, { - limit: 10, - page: 1, - }); - - // ASSERT - Verify all roots have empty relation arrays when no relations exist - assertResultStructure(result, { count: 5, total: 5 }); - assertEnrichment(result, 'relations', { - 1: [], - 2: [], - 3: [], - 4: [], - 5: [], - }); - }); - }); - - describe('Parameter Passing and Request Construction', () => { - it('should preserve original request parameters in root service call (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - const data = createSingleEntitySet(); - const extendedData = { - roots: [...data.roots, { id: 2, name: 'Root 2' }], - relations: data.relations, - }; - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(extendedData.roots, { limit: 10, total: 2 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(extendedData.relations, { - limit: 100, - total: 1, - }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root service gets original request parameters - assertRootGetManyRequest(mocks.mockRootService, { - limit: 10, - page: 1, - }); - - // Verify relation service called with root IDs - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $in: [1, 2] } }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 2, total: 2 }); - assertEnrichment(result, 'relations', { - 1: [{ id: 1, rootId: 1, title: 'Only Relation' }], - 2: [], - }); - }); - - it('should call root service with relation metadata after relation discovery (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - const data = { - roots: [ - { id: 5, name: 'Root 5' }, - { id: 8, name: 'Root 8' }, - ], - relations: [ - { id: 1, rootId: 5, title: 'Relation 1' }, - { id: 2, rootId: 8, title: 'Relation 2' }, - ], - }; - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 2 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 2 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockRelationService]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root service parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 1, - limit: 10, - }); - - // Verify relation service called with root IDs - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $in: [5, 8] } }, - }); - - // Verify root pagination parameters - assertRootGetManyRequest(mocks.mockRootService, { - limit: 10, - page: 1, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 2, total: 2 }); - assertEnrichment(result, 'relations', { - 5: [{ id: 1, rootId: 5, title: 'Relation 1' }], - 8: [{ id: 2, rootId: 8, title: 'Relation 2' }], - }); - }); - }); - - describe('Filter Application and Processing', () => { - it('should delegate root filters correctly and preserve LEFT JOIN behavior (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest( - { - page: '1', - limit: '10', - filter: [ - 'name||$eq||root-filter', // Root filter only - ], - }, - [relation], - ); - const data = createFilteredRootSet(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.filteredRoots, { limit: 10, total: 1 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 1 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - - // Verify root gets filter converted to search - assertRootGetManyRequest(mocks.mockRootService, { - search: { name: { $eq: 'root-filter' } }, - limit: 10, - page: 1, - }); - - // Verify filter delegation - original filter array still present - assertRootGetManyRequest(mocks.mockRootService, { - filter: [{ field: 'name', operator: '$eq', value: 'root-filter' }], - limit: 10, - offset: undefined, - page: 1, - search: { - name: { $eq: 'root-filter' }, - }, - }); - - // Verify relation service called with root IDs - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $eq: 1 } }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 1, total: 1 }); - assertEnrichment(result, 'relations', { - 1: [{ id: 1, rootId: 1, title: 'relation-1' }], - }); - }); - }); - - describe('One-to-Many Relationship Data Patterns', () => { - it('should handle root with multiple relations - all relations returned in collection (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - const data = createMultiRelationEntitySet(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 2 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 4 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify relation service called with root IDs - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $in: [1, 2] } }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 2, total: 2 }); - assertEnrichment(result, 'relations', { - 1: [ - { id: 1, rootId: 1, title: 'Relation 1A' }, - { id: 2, rootId: 1, title: 'Relation 1B' }, - { id: 3, rootId: 1, title: 'Relation 1C' }, - ], - 2: [{ id: 4, rootId: 2, title: 'Relation 2A' }], - }); - }); - - it('should handle root with single relation - relation returned in collection (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - const singleRelationData = createSingleEntitySet(); - const extendedData = { - roots: [...singleRelationData.roots, { id: 2, name: 'Root 2' }], - relations: singleRelationData.relations, - }; - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(extendedData.roots, { limit: 10, total: 2 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(extendedData.relations, { - limit: 100, - total: 1, - }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify relation service called with root IDs - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $in: [1, 2] } }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 2, total: 2 }); - assertEnrichment(result, 'relations', { - 1: [{ id: 1, rootId: 1, title: 'Only Relation' }], - 2: [], - }); - }); - - it('should handle multiple roots with varying relation counts (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - const data = createVaryingRelationCountSet(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 4 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 6 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify relation service called with root IDs - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $in: [1, 2, 3, 4] } }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 4, total: 4 }); - assertEnrichment(result, 'relations', { - 1: [ - { id: 1, rootId: 1, title: 'Relation 1A' }, - { id: 2, rootId: 1, title: 'Relation 1B' }, - { id: 3, rootId: 1, title: 'Relation 1C' }, - ], - 2: [{ id: 4, rootId: 2, title: 'Relation 2A' }], - 3: [], - 4: [ - { id: 5, rootId: 4, title: 'Relation 4A' }, - { id: 6, rootId: 4, title: 'Relation 4B' }, - ], - }); - }); - - it('should handle pagination impact on relation collection completeness (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '2', limit: '5' }, [ - relation, - ]); - const data = createPaginationPage2Set(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 5, total: 10 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 6 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root pagination parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 2, - limit: 5, - }); - - // Verify relation service called with root IDs - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $in: [6, 7, 8, 9, 10] } }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 5, total: 10 }); - assertEnrichment(result, 'relations', { - 6: [ - { id: 11, rootId: 6, title: 'Relation 6A' }, - { id: 12, rootId: 6, title: 'Relation 6B' }, - ], - 7: [{ id: 13, rootId: 7, title: 'Relation 7A' }], - 8: [ - { id: 14, rootId: 8, title: 'Relation 8A' }, - { id: 15, rootId: 8, title: 'Relation 8B' }, - { id: 16, rootId: 8, title: 'Relation 8C' }, - ], - 9: [], - 10: [], - }); - }); - - it('should handle root with multiple relationships correctly (LEFT JOIN)', async () => { - // ARRANGE - const relationsRelation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const settingsRelation = createOneToManyForwardRelation( - 'settings', - TestSettingsService, - ); - const req = mocks.createTestRequest({}, [ - relationsRelation, - settingsRelation, - ]); - const data = createComplexMultiRelationSet(); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(data.roots, { limit: 10, total: 5 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(data.relations, { total: 6 }), - ); - mocks.mockSettingsService.getMany.mockResolvedValue( - createPaginatedResponse(data.settings, { total: 6 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - { service: mocks.mockSettingsService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [ - mocks.mockRelationService, - mocks.mockSettingsService, - ]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root pagination parameters - assertRootGetManyRequest(mocks.mockRootService, {}); - - // Verify relation services called with all root IDs - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $in: [1, 2, 3, 4, 5] } }, - }); - assertRelationRequest(mocks.mockSettingsService, { - search: { - rootId: { $in: [1, 2, 3, 4, 5] }, - }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 5, total: 5 }); - - // Verify both relationship properties are enriched - result.data.forEach((root) => { - expect(root).toHaveProperty('relations'); - expect(Array.isArray(root.relations)).toBe(true); - expect(root).toHaveProperty('settings'); - expect(Array.isArray(root.settings)).toBe(true); - }); - - // Verify relation enrichment - assertEnrichment(result, 'relations', { - 1: [ - { id: 1, rootId: 1, title: 'Relation 1A' }, - { id: 2, rootId: 1, title: 'Relation 1B' }, - ], - 2: [{ id: 3, rootId: 2, title: 'Relation 2A' }], - 3: [], - 4: [ - { id: 4, rootId: 4, title: 'Relation 4A' }, - { id: 5, rootId: 4, title: 'Relation 4B' }, - { id: 6, rootId: 4, title: 'Relation 4C' }, - ], - 5: [], - }); - - // Verify settings enrichment - assertEnrichment(result, 'settings', { - 1: [ - { id: 1, rootId: 1, theme: 'dark', notifications: true }, - { id: 2, rootId: 1, theme: 'light', notifications: false }, - ], - 2: [], - 3: [{ id: 3, rootId: 3, theme: 'auto', notifications: true }], - 4: [], - 5: [ - { id: 4, rootId: 5, theme: 'dark', notifications: false }, - { id: 5, rootId: 5, theme: 'light', notifications: true }, - { id: 6, rootId: 5, theme: 'auto', notifications: false }, - ], - }); - }); - }); - - describe('Pagination edge cases', () => { - it('should handle request for page beyond available pages', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '3', limit: '5' }, [ - relation, - ]); // Request page 3 when only 2 pages exist - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse([], { limit: 5, total: 10 }), // Empty results for page 3 - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse([], { total: 0 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 0 }, // No relation call since no roots - ]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root pagination parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 3, - limit: 5, - }); - - // ASSERT - Result verification (empty page) - assertResultStructure(result, { count: 0, total: 10 }); - expect(result.page).toBe(3); - expect(result.pageCount).toBe(2); // Still shows correct page count - expect(result.data).toEqual([]); - }); - - it('should handle single result with pagination parameters', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - - const singleRoot = [{ id: 1, name: 'Only Root' }]; - const singleRootRelations = [ - { id: 1, rootId: 1, title: 'Relation 1' }, - { id: 2, rootId: 1, title: 'Relation 2' }, - ]; - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(singleRoot, { limit: 10, total: 1 }), - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(singleRootRelations, { total: 2 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify relation service called with single root ID - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $eq: 1 } }, - }); - - // ASSERT - Result verification (single result) - assertResultStructure(result, { count: 1, total: 1 }); - expect(result.page).toBe(1); - expect(result.pageCount).toBe(1); - - assertEnrichment(result, 'relations', { - 1: [ - { id: 1, rootId: 1, title: 'Relation 1' }, - { id: 2, rootId: 1, title: 'Relation 2' }, - ], - }); - }); - - it('should handle zero results with pagination parameters', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '5' }, [ - relation, - ]); - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse([], { limit: 5, total: 0 }), // No results - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse([], { total: 0 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 0 }, // No relation call since no roots - ]); - assertLeftJoinBehavior(mocks.mockRootService); - - // ASSERT - Result verification (zero results) - assertResultStructure(result, { count: 0, total: 0 }); - expect(result.page).toBe(1); - expect(result.pageCount).toBe(0); - expect(result.data).toEqual([]); - }); - - it('should handle last page with partial results', async () => { - // ARRANGE - const relation = createOneToManyForwardRelation( - 'relations', - TestRelationService, - ); - const req = mocks.createTestRequest({ page: '3', limit: '5' }, [ - relation, - ]); // Last page with only 2 results - - const lastPageRoots = [ - { id: 11, name: 'Root 11' }, - { id: 12, name: 'Root 12' }, - ]; - - const lastPageRelations = [ - { id: 11, rootId: 11, title: 'Relation 11A' }, - { id: 12, rootId: 12, title: 'Relation 12A' }, - { id: 13, rootId: 12, title: 'Relation 12B' }, - ]; - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(lastPageRoots, { limit: 5, total: 12 }), // Page 3 of 12 total (partial page) - ); - mocks.mockRelationService.getMany.mockResolvedValue( - createPaginatedResponse(lastPageRelations, { total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockRelationService, count: 1 }, - ]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root pagination parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 3, - limit: 5, - }); - - // Verify relation service called with last page root IDs - assertRelationRequest(mocks.mockRelationService, { - search: { rootId: { $in: [11, 12] } }, - }); - - // ASSERT - Result verification (partial last page) - assertResultStructure(result, { count: 2, total: 12 }); - expect(result.page).toBe(3); - expect(result.pageCount).toBe(3); // 12 total / 5 per page = 3 pages - - assertEnrichment(result, 'relations', { - 11: [{ id: 11, rootId: 11, title: 'Relation 11A' }], - 12: [ - { id: 12, rootId: 12, title: 'Relation 12A' }, - { id: 13, rootId: 12, title: 'Relation 12B' }, - ], - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-integration/one-to-one-forward.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/crud-federation-integration/one-to-one-forward.spec.ts deleted file mode 100644 index 88d712fc3..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/crud-federation-integration/one-to-one-forward.spec.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { createPaginatedResponse } from '../../__FIXTURES__/crud-federation-mock-helpers'; -import { - assertServiceCallCounts, - assertResultStructure, - assertOneToOneEnrichment, - assertRootFirst, - assertLeftJoinBehavior, - assertRootGetManyRequest, - assertRelationRequest, -} from '../../__FIXTURES__/crud-federation-test-assertions'; -import { createMultiRelationSet } from '../../__FIXTURES__/crud-federation-test-data'; -import { - createOneToOneForwardRelation, - TestProfile, - TestSettings, - TestProfileService, - TestSettingsService, -} from '../../__FIXTURES__/crud-federation-test-entities'; -import { - setupCrudFederationTests, - cleanupCrudFederationTests, - CrudFederationTestMocks, -} from '../../__FIXTURES__/crud-federation-test-setup'; - -/** - * Integration tests for one-to-one forward relationship behavior - * One-to-one forward relationships: Profile.rootId -> Root.id (Root.profile) - * Focuses on service coordination for single entity enrichment - */ -describe('CrudFederationService - Integration: One-to-One Forward Relationships', () => { - let mocks: CrudFederationTestMocks; - - beforeEach(async () => { - mocks = await setupCrudFederationTests(); - // Register the relations that tests use (one-to-one cardinality) - mocks.registerRelation(mocks.mockProfileService); - mocks.registerRelation(mocks.mockSettingsService); - }); - - afterEach(async () => { - await cleanupCrudFederationTests(mocks); - }); - - describe('Root with existing related entity', () => { - it('should populate profile entity object on root (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToOneForwardRelation( - 'profile', - TestProfileService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - - // Use data helper for consistent test data - const data = createMultiRelationSet(); - const rootData = data.roots; - const profileData = data.profiles; - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(rootData, { limit: 10, total: 2 }), - ); - mocks.mockProfileService.getMany.mockResolvedValue( - createPaginatedResponse(profileData, { total: 1 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockProfileService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockProfileService]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root service parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 1, - limit: 10, - }); - - // Verify profile service called with root IDs - assertRelationRequest(mocks.mockProfileService, { - search: { - rootId: { $in: [1, 2] }, - }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 2, total: 2 }); - assertOneToOneEnrichment(result, 'profile', { - 1: { - id: 1, - rootId: 1, - bio: 'Profile 1', - avatar: 'avatar1.jpg', - }, - 2: null, - }); - }); - }); - - describe('Root with missing related entity', () => { - it('should populate null profile object on root, root still included (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToOneForwardRelation( - 'profile', - TestProfileService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - relation, - ]); - - // Use data helper for consistent test data - const data = createMultiRelationSet(); - const rootData = data.roots; - const profileData: TestProfile[] = []; // No profiles for this test - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(rootData, { limit: 10, total: 2 }), - ); - mocks.mockProfileService.getMany.mockResolvedValue( - createPaginatedResponse(profileData, { total: 0 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockProfileService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockProfileService]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root service parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 1, - limit: 10, - }); - - // Verify profile service called with root IDs - assertRelationRequest(mocks.mockProfileService, { - search: { - rootId: { $in: [1, 2] }, - }, - }); - - // ASSERT - Result verification (LEFT JOIN: all roots returned with null profiles) - assertResultStructure(result, { count: 2, total: 2 }); - assertOneToOneEnrichment(result, 'profile', { - 1: null, - 2: null, - }); - }); - }); - - describe('Root with multiple relationships', () => { - it('should handle multiple one-to-one forward relationships correctly (LEFT JOIN)', async () => { - // ARRANGE - const profileRelation = createOneToOneForwardRelation( - 'profile', - TestProfileService, - ); - const settingsRelation = createOneToOneForwardRelation( - 'settings', - TestSettingsService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '10' }, [ - profileRelation, - settingsRelation, - ]); - - // Use data helper for consistent test data - const data = createMultiRelationSet(); - // Extend with additional roots for this test - const rootData = [ - ...data.roots, - { id: 3, name: 'Root 3' }, - { id: 4, name: 'Root 4' }, - { id: 5, name: 'Root 5' }, - ]; - - // Custom profile and settings data for this complex scenario - const profileData: TestProfile[] = [ - { id: 1, rootId: 1, bio: 'Profile 1', avatar: 'avatar1.jpg' }, // from data helper - { id: 2, rootId: 3, bio: 'Profile for Root 3' }, - { - id: 3, - rootId: 4, - bio: 'Profile for Root 4', - avatar: 'avatar4.jpg', - }, - // Roots 2 and 5 have no profiles - ]; - - const settingsData: TestSettings[] = [ - { id: 1, rootId: 1, theme: 'dark', notifications: true }, // from data helper - { id: 2, rootId: 2, theme: 'light', notifications: false }, // from data helper - { id: 3, rootId: 5, theme: 'auto', notifications: true }, - // Roots 3 and 4 have no settings - ]; - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(rootData, { limit: 10, total: 5 }), - ); - mocks.mockProfileService.getMany.mockResolvedValue( - createPaginatedResponse(profileData, { total: 3 }), - ); - mocks.mockSettingsService.getMany.mockResolvedValue( - createPaginatedResponse(settingsData, { total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockProfileService, count: 1 }, - { service: mocks.mockSettingsService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [ - mocks.mockProfileService, - mocks.mockSettingsService, - ]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root service parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 1, - limit: 10, - }); - - // Verify profile service called with all root IDs - assertRelationRequest(mocks.mockProfileService, { - search: { - rootId: { $in: [1, 2, 3, 4, 5] }, - }, - }); - - // Verify settings service called with all root IDs - assertRelationRequest(mocks.mockSettingsService, { - search: { - rootId: { $in: [1, 2, 3, 4, 5] }, - }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 5, total: 5 }); - - // Verify profile enrichment - assertOneToOneEnrichment(result, 'profile', { - 1: { id: 1, rootId: 1, bio: 'Profile 1', avatar: 'avatar1.jpg' }, - 2: null, - 3: { id: 2, rootId: 3, bio: 'Profile for Root 3' }, - 4: { - id: 3, - rootId: 4, - bio: 'Profile for Root 4', - avatar: 'avatar4.jpg', - }, - 5: null, - }); - - // Verify settings enrichment - assertOneToOneEnrichment(result, 'settings', { - 1: { id: 1, rootId: 1, theme: 'dark', notifications: true }, - 2: { id: 2, rootId: 2, theme: 'light', notifications: false }, - 3: null, - 4: null, - 5: { id: 3, rootId: 5, theme: 'auto', notifications: true }, - }); - }); - }); - - describe('Pagination handling', () => { - it('should handle page 1 pagination with profile enrichment (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToOneForwardRelation( - 'profile', - TestProfileService, - ); - const req = mocks.createTestRequest({ page: '1', limit: '5' }, [ - relation, - ]); - - // Create test data for pagination - page 1 (roots 1-5) - const rootData = [ - { id: 1, name: 'Root 1' }, - { id: 2, name: 'Root 2' }, - { id: 3, name: 'Root 3' }, - { id: 4, name: 'Root 4' }, - { id: 5, name: 'Root 5' }, - ]; - - const profileData: TestProfile[] = [ - { - id: 1, - rootId: 1, - bio: 'Profile for Root 1', - avatar: 'avatar1.jpg', - }, - { id: 2, rootId: 3, bio: 'Profile for Root 3' }, - { - id: 3, - rootId: 5, - bio: 'Profile for Root 5', - avatar: 'avatar5.jpg', - }, - // Roots 2 and 4 have no profiles - ]; - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(rootData, { limit: 5, total: 10 }), - ); - mocks.mockProfileService.getMany.mockResolvedValue( - createPaginatedResponse(profileData, { total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockProfileService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockProfileService]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root service parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 1, - limit: 5, - }); - - // Verify root pagination parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 1, - limit: 5, - }); - - // Verify profile service called with page 1 root IDs - assertRelationRequest(mocks.mockProfileService, { - search: { - rootId: { $in: [1, 2, 3, 4, 5] }, - }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 5, total: 10 }); - expect(result.page).toBe(1); - expect(result.pageCount).toBe(2); - - // Verify profile enrichment for page 1 - assertOneToOneEnrichment(result, 'profile', { - 1: { - id: 1, - rootId: 1, - bio: 'Profile for Root 1', - avatar: 'avatar1.jpg', - }, - 2: null, - 3: { id: 2, rootId: 3, bio: 'Profile for Root 3' }, - 4: null, - 5: { - id: 3, - rootId: 5, - bio: 'Profile for Root 5', - avatar: 'avatar5.jpg', - }, - }); - }); - - it('should handle page 2 pagination with profile enrichment (LEFT JOIN)', async () => { - // ARRANGE - const relation = createOneToOneForwardRelation( - 'profile', - TestProfileService, - ); - const req = mocks.createTestRequest({ page: '2', limit: '5' }, [ - relation, - ]); - - // Create test data for pagination - page 2 (roots 6-10) - const rootData = [ - { id: 6, name: 'Root 6' }, - { id: 7, name: 'Root 7' }, - { id: 8, name: 'Root 8' }, - { id: 9, name: 'Root 9' }, - { id: 10, name: 'Root 10' }, - ]; - - const profileData: TestProfile[] = [ - { id: 4, rootId: 6, bio: 'Profile for Root 6' }, - { - id: 5, - rootId: 8, - bio: 'Profile for Root 8', - avatar: 'avatar8.jpg', - }, - { - id: 6, - rootId: 10, - bio: 'Profile for Root 10', - avatar: 'avatar10.jpg', - }, - // Roots 7 and 9 have no profiles - ]; - - mocks.mockRootService.getMany.mockResolvedValue( - createPaginatedResponse(rootData, { limit: 5, total: 10 }), - ); - mocks.mockProfileService.getMany.mockResolvedValue( - createPaginatedResponse(profileData, { total: 3 }), - ); - - // ACT - const result = await mocks.service.getMany(req); - - // ASSERT - Service call verification - assertServiceCallCounts([ - { service: mocks.mockRootService, count: 1 }, - { service: mocks.mockProfileService, count: 1 }, - ]); - assertRootFirst(mocks.mockRootService, [mocks.mockProfileService]); - assertLeftJoinBehavior(mocks.mockRootService); - - // Verify root service parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 2, - limit: 5, - }); - - // Verify root pagination parameters - assertRootGetManyRequest(mocks.mockRootService, { - page: 2, - limit: 5, - }); - - // Verify profile service called with page 2 root IDs - assertRelationRequest(mocks.mockProfileService, { - search: { - rootId: { $in: [6, 7, 8, 9, 10] }, - }, - }); - - // ASSERT - Result verification - assertResultStructure(result, { count: 5, total: 10 }); - expect(result.page).toBe(2); - expect(result.pageCount).toBe(2); - - // Verify profile enrichment for page 2 - assertOneToOneEnrichment(result, 'profile', { - 6: { id: 4, rootId: 6, bio: 'Profile for Root 6' }, - 7: null, - 8: { - id: 5, - rootId: 8, - bio: 'Profile for Root 8', - avatar: 'avatar8.jpg', - }, - 9: null, - 10: { - id: 6, - rootId: 10, - bio: 'Profile for Root 10', - avatar: 'avatar10.jpg', - }, - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/__TESTS__/d.federated-crud.spec.ts b/packages/nestjs-crud/src/services/__TESTS__/d.federated-crud.spec.ts deleted file mode 100644 index 3bbd62ada..000000000 --- a/packages/nestjs-crud/src/services/__TESTS__/d.federated-crud.spec.ts +++ /dev/null @@ -1,214 +0,0 @@ -import request from 'supertest'; -import { DataSource } from 'typeorm'; - -import { INestApplication } from '@nestjs/common'; -import { APP_FILTER } from '@nestjs/core'; -import { Test } from '@nestjs/testing'; -import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; - -import { ExceptionsFilter } from '@concepta/nestjs-common'; - -import { CompanyCrudService } from '../../__fixtures__/typeorm/company/company-crud.service'; -import { CompanyTypeOrmCrudAdapter } from '../../__fixtures__/typeorm/company/company-typeorm-crud.adapter'; -import { CompanyEntity } from '../../__fixtures__/typeorm/company/company.entity'; -import { CompanyPaginatedDto } from '../../__fixtures__/typeorm/company/dto/company-paginated.dto'; -import { CompanyDto } from '../../__fixtures__/typeorm/company/dto/company.dto'; -import { ormSqliteConfig } from '../../__fixtures__/typeorm/orm.sqlite.config'; -import { Seeds } from '../../__fixtures__/typeorm/seeds'; -import { UserProfileCrudService } from '../../__fixtures__/typeorm/user-profile/user-profile-crud.service'; -import { UserProfileTypeOrmCrudAdapter } from '../../__fixtures__/typeorm/user-profile/user-profile-typeorm-crud.adapter'; -import { UserProfileEntity } from '../../__fixtures__/typeorm/user-profile/user-profile.entity'; -import { UserPaginatedDto } from '../../__fixtures__/typeorm/users/dto/user-paginated.dto'; -import { UserDto } from '../../__fixtures__/typeorm/users/dto/user.dto'; -import { UserCrudService } from '../../__fixtures__/typeorm/users/user-crud.service'; -import { UserTypeOrmCrudAdapter } from '../../__fixtures__/typeorm/users/user-typeorm-crud.adapter'; -import { UserEntity } from '../../__fixtures__/typeorm/users/user.entity'; -import { CrudGetMany } from '../../crud/decorators/actions/crud-get-many.decorator'; -import { CrudGetOne } from '../../crud/decorators/actions/crud-get-one.decorator'; -import { CrudController } from '../../crud/decorators/controller/crud-controller.decorator'; -import { CrudRequest } from '../../crud/decorators/params/crud-request.decorator'; -import { CrudLimit } from '../../crud/decorators/routes/crud-limit.decorator'; -import { CrudRelations } from '../../crud/decorators/routes/crud-relations.decorator'; -import { CrudSort } from '../../crud/decorators/routes/crud-sort.decorator'; -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; -import { CrudModule } from '../../crud.module'; -import { CrudRelationRegistry } from '../crud-relation.registry'; - -// tslint:disable:max-classes-per-file no-shadowed-variable -describe.skip('#crud-typeorm', () => { - describe('#basic crud respects global limit', () => { - let app: INestApplication; - let server: ReturnType; - - @CrudController({ - path: 'companies0', - model: { - type: CompanyDto, - paginatedType: CompanyPaginatedDto, - }, - }) - @CrudLimit(3) - @CrudSort([{ field: 'id', order: 'ASC' }]) - @CrudRelations({ - rootKey: 'id', - relations: [ - { - join: 'INNER', - cardinality: 'many', - service: UserCrudService, - property: 'users', - primaryKey: 'id', - foreignKey: 'companyId', - }, - ], - }) - class CompaniesController0 { - constructor(public service: CompanyCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - } - - @CrudController({ - path: 'users', - model: { - type: UserDto, - paginatedType: UserPaginatedDto, - }, - }) - @CrudSort([{ field: 'id', order: 'ASC' }]) - @CrudRelations({ - rootKey: 'id', - relations: [ - { - cardinality: 'one', - service: UserProfileCrudService, - property: 'userProfile', - primaryKey: 'id', - foreignKey: 'userId', - }, - // { - // owner: true, - // cardinality: 'many', - // service: CompanyCrudService, - // property: 'company', - // primaryKey: 'id', - // foreignKey: 'companyId', - // }, - ], - }) - class UsersController { - constructor(public service: UserCrudService) {} - - @CrudGetMany() - getMany(@CrudRequest() request: CrudRequestInterface) { - return this.service.getMany(request); - } - - @CrudGetOne() - getOne(@CrudRequest() request: CrudRequestInterface) { - return this.service.getOne(request); - } - } - - beforeAll(async () => { - const fixture = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot(ormSqliteConfig), - TypeOrmModule.forFeature([ - CompanyEntity, - UserEntity, - UserProfileEntity, - ]), - CrudModule.forRoot({}), - ], - controllers: [CompaniesController0, UsersController], - providers: [ - { provide: APP_FILTER, useClass: ExceptionsFilter }, - CompanyTypeOrmCrudAdapter, - CompanyCrudService, - UserTypeOrmCrudAdapter, - UserCrudService, - UserProfileTypeOrmCrudAdapter, - UserProfileCrudService, - { - provide: 'COMPANY_RELATION_REGISTRY', - inject: [UserCrudService], - useFactory(userCrudService) { - const registry = new CrudRelationRegistry< - CompanyEntity, - [UserEntity] - >(); - registry.register(userCrudService); - return registry; - }, - }, - { - provide: 'USER_RELATION_REGISTRY', - inject: [UserProfileCrudService, CompanyCrudService], - useFactory(userProfileCrudService, companyCrudService) { - const registry = new CrudRelationRegistry< - UserEntity, - [UserProfileEntity, CompanyEntity] - >(); - registry.register(userProfileCrudService); - registry.register(companyCrudService); - return registry; - }, - }, - ], - }).compile(); - - app = fixture.createNestApplication(); - - await app.init(); - server = app.getHttpServer(); - - const datasource = app.get(getDataSourceToken()); - const seeds = new Seeds(); - await seeds.up(datasource.createQueryRunner()); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('#getAll', () => { - it('should return an array of all company entities', (done) => { - request(server) - .get( - '/companies0?filter=name||$startsL||Name&filter=users.isActive||$eq||true', - ) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.data.length).toBe(3); - expect(res.body.page).toBe(1); - done(); - }); - }); - it.only('should return an array of all user entities', (done) => { - request(server) - .get( - // '/users?sort[]=userProfile.nickName,DESC&page=2&limit=10', - '/users?filter[]=userProfile.favoriteColor||$eq||Orange&page=1&limit=10', - ) - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body.data).toBe({}); - done(); - }); - }); - it('should return one user entity', (done) => { - request(server) - .get('/users/1') - .end((_, res) => { - expect(res.status).toBe(200); - expect(res.body).toBe({}); - done(); - }); - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/crud-federation.scenarios.md b/packages/nestjs-crud/src/services/crud-federation.scenarios.md deleted file mode 100644 index 03bef67da..000000000 --- a/packages/nestjs-crud/src/services/crud-federation.scenarios.md +++ /dev/null @@ -1,447 +0,0 @@ -# CRUD Federation Service - Test Scenarios - -> **Last Updated**: September 14, 2025 - Updated based on current -> implementation and test coverage analysis - -## Overview - -This document tracks test scenario definitions and coverage status for the -`CrudFederationService`. - -**📋 Documentation references**: - -- `CLAUDE.md` - Architecture overview and testing guidance for Claude Code -- `FEDERATION_TEST_STYLE.md` - Detailed testing patterns and anti-patterns - -### Federation Scope - -- **Current Implementation**: Supports both `getMany` and `getOne` operations - with relationship-aware data fetching -- **Federation Patterns**: Handles forward relationships (fully tested), - inverse relationships (implementation exists, limited testing), constraint - enforcement, cross-service coordination -- **JOIN Types**: LEFT JOIN (default), INNER JOIN (via filters or `join: - 'INNER'` property) -- **Optimization Features**: Intelligent caching, minimal API call strategies - -### Query Strategy - -- **Root-First Approach** (LEFT JOIN): Used when no relation filters exist. - Fetches roots first, then relations with `rootId: { $in: [...] }` - constraints to prevent unbounded relation queries -- **Relation-First Approach** (INNER JOIN): Used when relation filters exist. - Fetches relations first with filters, then roots constrained by discovered - root IDs - -## Core Federation Scenarios - -### 1. Service Coordination Patterns - -**Test File**: `behavior/service-coordination.spec.ts` | **Status**: ❌ Missing - -- **Scenario**: Cross-cutting service coordination patterns that apply to all - relationship types -- **Expected**: Consistent parameter passing and filter delegation regardless - of cardinality -- **Test Cases**: Parameter passing, request construction, filter delegation - -### 2. No Relations Query - -**Test File**: `behavior/no-relations.spec.ts` | **Status**: ✅ Implemented & -Complete - -- **Scenario**: Root query with no relation entities -- **Expected**: Direct root service call, no federated logic -- **Test Cases**: ✅ Simple root fetch with pagination, filters, sorting - -### 3. One-to-One (Forward) Relationships - -**Test File**: `integration/one-to-one-forward.spec.ts` | **Status**: ✅ -Implemented & Complete - -- **Scenario**: Root has single related entity (`Root.profile` ← - `Profile.rootId`) -- **Expected**: Root-first discovery, single entity enrichment (LEFT JOIN - behavior) -- **Test Cases**: - - ✅ Root with existing related entity - entity object populated - - ✅ Root with missing related entity - null object, root still included - - ✅ Root with multiple relationships - - ✅ **Pagination**: Page-based pagination (always paginate mode enforced, - offset calculated internally) - - ✅ Page 1 and Page 2 with profile enrichment - - ✅ Null profile handling across pages - - ❌ **Edge Cases**: Empty results, single record, null foreign keys - -### 4. One-to-Many (Forward) Relationships - -**Test File**: `integration/one-to-many-forward.spec.ts` | **Status**: ✅ -Implemented & Complete - -- **Scenario**: Root has relations collection (`Root.relations[]` ← - `Relation.rootId`) -- **Expected**: Root-first discovery, relations enrichment (LEFT JOIN - behavior) -- **Test Cases**: - - ✅ Root with multiple relations - all relations returned in collection - - ✅ Root with no relations - empty collection, root still included - - ✅ Root with single relation - relation returned in collection - - ✅ Root with multiple relationships - - ✅ Multiple roots with varying relation counts - - ✅ **Pagination**: Page-based pagination (always paginate mode enforced, - offset calculated internally) - - ✅ Page 2 with relation collection enrichment - - ✅ **Edge Cases**: Request beyond available pages, single result, zero - results, partial last page - -### 5. One-to-One (Inverse) Relationships - -**Test File**: `integration/one-to-one-inverse.spec.ts` | **Status**: ❌ -Missing Tests - -- **Scenario**: Profile-driven query with Root enrichment (`Profile.root` ← - `Root.id` via `Profile.rootId`) -- **Expected**: Profile service drives, Root service follows with enrichment - (LEFT JOIN behavior) -- **Note**: Feature implementation exists (`owner: true` relationships), but - dedicated integration tests are missing -- **Test Cases**: - - Profile with existing related Root - root object populated - - Profile with missing related Root - null root, profile still included - - **Pagination**: Page-based pagination (always paginate mode enforced, - offset calculated internally) - - **Edge Cases**: Empty results, single record, null foreign keys - -### 6. One-to-Many (Inverse) Relationships - -**Test File**: `integration/one-to-many-inverse.spec.ts` | **Status**: ❌ -Missing Tests - -- **Scenario**: Relation-driven query with Root enrichment (`Relation.root` ← - `Root.id` via `Relation.rootId`) -- **Expected**: Relation service drives, Root service follows with enrichment - (LEFT JOIN behavior) -- **Note**: Feature implementation exists (`owner: true` relationships), but - dedicated integration tests are missing -- **Test Cases**: - - Collection of relations with existing roots - each relation gets their - one root - - Collection of relations with missing roots - some relations have null - root - - Collection of relations sharing same root - multiple relations reference - same root ID - - **Pagination**: Page-based pagination (always paginate mode enforced, - offset calculated internally) - - **Edge Cases**: Empty results, single record, null foreign keys - -### 7. Mixed Relationship Types - -**Test File**: `integration/mixed-relations.spec.ts` | **Status**: ❌ -Missing Tests - -- **Scenario**: Root with both forward and inverse relationships -- **Expected**: Both forward and inverse relations enrich, LEFT JOIN behavior -- **Note**: Feature implementation exists, but dedicated integration tests are - missing -- **Test Cases**: - - Various combinations of forward/inverse data presence - - **Pagination**: Complex pagination with mixed relationship types - - **Edge Cases**: Partial enrichment scenarios - -## Join Behavior Scenarios - -### 8. INNER JOIN via Filters - -**Test File**: `behavior/inner-join-behavior.spec.ts` | **Status**: ✅ -Implemented & Complete - -- **Scenario**: Achieving INNER JOIN through explicit relation filters -- **Expected**: Only roots with matching relations returned -- **Test Cases**: - - ✅ `relations.rootId||$notnull` - Existence filter triggers INNER JOIN - - ✅ `relations.status||$eq||active` - Value filters trigger INNER JOIN - - ✅ Multiple relation filters (AND condition) - Combined filters - constrain roots - - ✅ No matching relations - Returns empty result without root query - - ✅ Root + relation filters combined - INNER JOIN with root-side - filtering - - ✅ **Pagination**: Page 1, Page 2, and edge cases with INNER JOIN - behavior - - ✅ Relation filter with pagination constraints - - ✅ Filter reducing results below page size - -**Examples**: - -```text -// Left join (default) - returns all roots -GET /roots?join=relations - -// Inner join - only roots with relations -GET /roots?join=relations&filter=relations.rootId||$notnull -``` - -### 9. Join Type Control - -**Test File**: `behavior/join-type.spec.ts` | **Status**: ✅ Implemented & -Complete - -- **Scenario**: Explicit control of JOIN behavior via `join` property on - relations -- **Expected**: LEFT JOIN (default), INNER JOIN via `join: 'INNER'` with - automatic $notnull injection -- **Test Cases**: - - ✅ Default LEFT JOIN behavior (no join property specified) - - ✅ Explicit LEFT JOIN via `join: 'LEFT'` - - ✅ INNER JOIN via `join: 'INNER'` with automatic $notnull filter - injection - - ✅ Preservation of existing filters when injecting $notnull for INNER - join - -## Filter and Sort Scenarios - -### 10. Filter Delegation - -**Test File**: `behavior/filter-delegation.spec.ts` | **Status**: ❌ Missing - -- **Scenario**: Root vs relation filter routing -- **Expected**: Root filters → root service, relation filters → relation - service -- **Test Cases**: Mixed root/relation filters, prefix removal - -### 11. Sort Delegation - -**Test File**: `behavior/sort-delegation.spec.ts` | **Status**: ❌ Missing - -- **Scenario**: Root vs relation sort strategies with validation -- **Expected**: Root sort allows LEFT JOIN, relation sort requires INNER JOIN - (AND NOT_NULL filter on join key) -- **Test Cases**: - - Root sort with LEFT JOIN behavior - - Relation sort with valid NOT_NULL join key filter (success) - - Relation sort without NOT_NULL join key filter (error) - -### 12. Root Sort Strategy - -**Test File**: `behavior/root-sort-behavior.spec.ts` | **Status**: ✅ -Implemented & Complete - -- **Scenario**: Sorting on root fields only (LEFT JOIN compatible) -- **Expected**: Root-driven sorting with all roots returned, no constraint - validation needed -- **Test Cases**: - - ✅ Single root field sort (name ASC, id DESC) - LEFT JOIN behavior - with all roots - - ✅ Multiple root field sorts - Combined sort criteria with LEFT JOIN - - ✅ Root sort with pagination - Offset/limit integrity maintained - - ✅ Root sort with root filters - Combined filtering and sorting - - ✅ LEFT JOIN guarantee - All roots returned regardless of relation - existence - -### 13. Relation Sort Strategy with Validation - -**Test File**: `behavior/relation-sort-behavior.spec.ts` + -`behavior/relation-sort-validation.spec.ts` | **Status**: ✅ Implemented & -Complete - -- **Scenario**: Sorting on relation fields with comprehensive validation - (INNER JOIN required) -- **Expected**: Relation-driven sorting with mandatory AND filter on join - key, proper error handling -- **Test Cases**: - - ✅ Relation sort with `relations.rootId||$notnull` (forward - relationship) - - ✅ Relation sort with additional AND filters - - ✅ Root deduplication when multiple relations match - - ✅ Empty result when no relations match with sort - - ✅ Relation sort with pagination correctly applied (page 1) - - ✅ **Validation Cases**: - - ✅ Relation sort without any filters → Error with join key filter - suggestion - - ✅ Relation sort with unrelated relation filters → Error requires - join key filter - - ✅ Relation sort with non-$notnull filters → Error requires - $notnull filter - - ✅ Valid relation sort with $notnull filter → Success - - ✅ Valid relation sort with $notnull + additional filters → - Success - -### 14. Combined Root+Relation Filters - -**Test File**: `behavior/combined-filters.spec.ts` | **Status**: ✅ -Implemented & Complete - -- **Scenario**: Requests with both root-side and relation-side filters - applied simultaneously -- **Expected**: Proper filter delegation and INNER JOIN behavior when - relation filters present -- **Test Cases**: - - ✅ Root filter + relation filter with page 1 and page 2 - - ✅ Multiple root filters + multiple relation filters (complex AND - conditions) - - ✅ Combined filters reducing results below page size - - ✅ **Pagination**: Full pagination coverage with combined filtering - - ✅ Page 1 and Page 2 with root + relation filters - - ✅ Multiple filter combinations across pages - -### 15. Combined Sort Strategies - -**Test File**: `behavior/combined-sorts.spec.ts` | **Status**: ❌ Missing - -- **Scenario**: Requests with both root and relation sort fields specified -- **Expected**: Proper validation and error handling for unsupported - combinations -- **Test Cases**: - - Root sort + relation sort → Error (unsupported combination) - - Sort field precedence analysis - - Error message clarity for mixed sort scenarios - -## Performance Scenarios - -### 16. Distinct Filter Validation - -**Test File**: `behavior/distinct-filter-validation.spec.ts` | **Status**: ✅ -Implemented & Complete - -- **Scenario**: Validation of distinctFilter requirements for - many-cardinality relations -- **Expected**: Many-cardinality relations require distinctFilter, - one-cardinality relations do not -- **Test Cases**: - - ✅ Error when many-cardinality relation lacks distinctFilter - - ✅ Success when many-cardinality relation has distinctFilter and $notnull - - ✅ Requirement for $notnull filter even with distinctFilter - - ✅ One-cardinality relations work without distinctFilter - -### 17. API Call Optimization - -**Test File**: `e2e/performance.spec.ts` | **Status**: ❌ Missing - -- **Scenario**: Minimizing service calls -- **Expected**: 3 calls for typical root+2relations scenario -- **Test Cases**: Single relation (2 calls), multiple relations (3 calls) - -### 18. GetOne Hydration - -**Test File**: `integration/get-one-hydration.spec.ts` | **Status**: ✅ -Implemented & Complete - -- **Scenario**: Single entity retrieval with relation hydration -- **Expected**: Root retrieved via getOne, relations fetched and attached -- **Test Cases**: - - ✅ GetOne with one-to-many forward relations - - ✅ GetOne with one-to-one forward relations - - ✅ GetOne with multiple relation types - - ✅ GetOne with no matching relations (empty arrays/null values) - -## Error Handling Scenarios - -### 19. Service Errors - -**Test File**: `e2e/error-handling.spec.ts` | **Status**: ❌ Missing - -- **Scenario**: Relation services throwing errors -- **Expected**: Proper error propagation with context -- **Test Cases**: Service unavailable, timeout, connection issues - -### 20. Unsupported Query Features Validation - -**Test File**: `behavior/unsupported-features.spec.ts` | **Status**: ✅ -Implemented & Complete - -- **Scenario**: Validation of unsupported search and OR filter features -- **Expected**: Clear error messages when unsupported query features are - used -- **Test Cases**: - - ✅ Search via query string (`req.parsed.search`) throws error - - ✅ OR filter via query string (`req.parsed.or`) throws error - - ✅ Combined search and OR filters (search error takes precedence) - - ✅ Validation in `getMany` method (metrics available when - `includeMetrics: true`) - - ✅ Empty OR array allowed (no error) - -## Test Category Overview - -> **Note**: For detailed test organization and placement guidance, see -> `CLAUDE.md` in this directory. - -### Test Categories Summary - -- **Unit** (`crud-federation-unit/`) - Pure functions and calculations -- **Behavior** (`crud-federation-behavior/`) - Core patterns (JOIN logic, - delegation) -- **Integration** (`crud-federation-integration/`) - Service coordination - per relationship type -- **E2E** (`crud-federation-e2e/`) - Complete scenarios [Future] - -### Test Coverage Summary - -- ✅ **Implemented**: 12 scenarios fully covered (including comprehensive - pagination, JOIN control, distinct filter validation, getOne hydration) -- ❌ **Missing**: 9 scenarios not yet tested (inverse relationships, unit - tests, performance tests) - -**Total Scenarios**: 21 (updated based on current implementation) - -**Total Test Cases**: 50+ (across 12 implemented scenarios with comprehensive coverage) - -## Testing Gaps and Implementation Plan - -### Current State Analysis - -Based on the streamlined scenario mapping: - -**✅ Well Covered (12 scenarios)**: - -- No relations query -- Forward relationships (one-to-one & one-to-many) with comprehensive - enrichment and pagination tests -- INNER JOIN behavior with comprehensive filter testing -- Join type control (LEFT/INNER via join property) -- Root sort behavior with comprehensive testing -- Relation sort with validation and error handling -- Combined root+relation filters with pagination -- Distinct filter validation for many-cardinality relations -- GetOne hydration with multiple relation types -- Unsupported query features validation - -**❌ Missing Implementation (9 scenarios)**: - -- Inverse relationships (one-to-one & one-to-many) - Feature - implementation exists (`owner: true`) but missing dedicated integration - tests -- Mixed relationship types - Feature exists but missing integration tests -- Service coordination patterns -- Filter delegation (partially tested in integration, needs extraction to - behavior) -- Sort delegation -- Combined sort strategies -- API call optimization (performance tests) -- Service error handling - -### Implementation Priorities - -#### Phase 1: Complete Core Behavior Tests (High Priority) - -- Extract `behavior/filter-delegation.spec.ts` from integration tests -- Create `behavior/sort-delegation.spec.ts` -- Create `behavior/combined-sorts.spec.ts` -- Add `e2e/performance.spec.ts` for API call optimization verification - -#### Phase 2: Inverse Relationships Testing (Medium Priority) - -- `integration/one-to-one-inverse.spec.ts` - Test owner: true relationships -- `integration/one-to-many-inverse.spec.ts` - Test inverse collections -- `integration/mixed-relations.spec.ts` - Test forward + inverse combinations - -#### Phase 3: Performance and Error Handling (Lower Priority) - -- `e2e/error-handling.spec.ts` - Service failures and recovery -- `e2e/performance.spec.ts` - API call optimization verification - -### Prevention Strategy - -- Use the scenario mapping table above as authoritative source -- Before writing tests, check the mapping table -- Each scenario should have exactly one test location -- Feature implementation exists for inverse relationships but needs - comprehensive testing diff --git a/packages/nestjs-crud/src/services/crud-federation.service.ts b/packages/nestjs-crud/src/services/crud-federation.service.ts deleted file mode 100644 index 414349a93..000000000 --- a/packages/nestjs-crud/src/services/crud-federation.service.ts +++ /dev/null @@ -1,2294 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { CrudRequestInterface } from '../crud/interfaces/crud-request.interface'; -import { CrudResponsePaginatedInterface } from '../crud/interfaces/crud-response-paginated.interface'; -import { - CRUD_FEDERATION_DEFAULT_LIMIT, - CRUD_FEDERATION_DEFAULT_PAGE, - CRUD_RELATION_CARDINALITY_ONE, - CRUD_RELATION_CARDINALITY_MANY, - CRUD_FEDERATION_MAX_ITERATIONS, - CRUD_FEDERATION_MAX_BUFFER_SIZE, -} from '../crud.constants'; -import { CrudFederationException } from '../exceptions/crud-federation.exception'; -import { - QueryFilter, - QueryRelation, - QuerySort, - QuerySortOperator, - CondOperator, -} from '../request/types/crud-request-query.types'; - -import { CrudRelationRegistry } from './crud-relation.registry'; -import { CrudSearchHelper } from './helpers/crud-search.helper'; -import { CrudFederationFetchOptionsInterface } from './interfaces/crud-federation-fetch-options.interface'; -import { CrudFetchServiceInterface } from './interfaces/crud-fetch-service.interface'; -import { CrudRelationBindingInterface } from './interfaces/crud-relation-binding.interface'; - -/** - * Utility function to find the relation binding that matches a QueryRelation - */ -function findRelationBinding< - T extends PlainLiteralObject, - Relations extends PlainLiteralObject[], ->( - relation: QueryRelation | null, - bindings: CrudRelationBindingInterface[], -): CrudRelationBindingInterface | null { - if (!relation) return null; - return ( - bindings.find((b) => b.relation.property === relation.property) || null - ); -} - -/** - * Utility function to validate that many-cardinality relations require distinctFilter - */ -function validateManyCardinalityDistinctFilter( - relation: QueryRelation, - errorContext: string, -): void { - // One-to-one relationships are always supported - if (relation.cardinality === CRUD_RELATION_CARDINALITY_ONE) { - return; - } - - // Many relationships require distinctFilter - if (!relation.distinctFilter) { - throw new CrudFederationException({ - message: - `${errorContext} on many-cardinality relationship '%s' requires a distinctFilter configuration. ` + - "Add distinctFilter: { fieldName: { [CondOperator.EQUALS]: 'value' } } to the relation configuration. " + - 'This is required because many-cardinality relationships can have multiple related entities per root, ' + - 'which would result in ambiguous sort ordering and inaccurate pagination totals. ' + - 'The distinctFilter ensures exactly one relation entity per root, making operations deterministic.', - messageParams: [relation.property], - }); - } -} - -/** - * Cache entry for filters organized by relation property - */ -type RelationFilterCache = { - relationAndFilters: QueryFilter[]; - relationOrFilters: QueryFilter[]; -}; - -/** - * Instance-based filter analyzer with caching to avoid repeated processing - */ -class FilterAnalyzer { - private readonly rootAndFilters: QueryFilter[]; - private readonly rootOrFilters: QueryFilter[]; - private readonly filtersByRelation: Map>; - private readonly relations: QueryRelation[]; - - constructor(req: CrudRequestInterface) { - // Single-pass processing - build cache and separate root filters - this.rootAndFilters = []; - this.rootOrFilters = []; - this.filtersByRelation = new Map(); - - // Store relations for later use - this.relations = req.options?.query?.relations?.relations || []; - - const andFilters = req.parsed.filter || []; - const orFilters = req.parsed.or || []; - this.processFilters(andFilters, orFilters); - - // Process additional filters if relations exist - if (this.relations.length > 0) { - this.injectInnerJoinFilters(req, this.relations); - this.processDistinctFilters(this.relations); - } - } - - /** - * Get root AND filters only - */ - getRootAndFilters(): QueryFilter[] { - return this.rootAndFilters; - } - - /** - * Get root OR filters only - */ - getRootOrFilters(): QueryFilter[] { - return this.rootOrFilters; - } - - /** - * Check if there are any root filters (AND or OR) - */ - hasRootFilters(): boolean { - return this.rootAndFilters.length > 0 || this.rootOrFilters.length > 0; - } - - /** - * Check if a specific relation has any filters - */ - hasFiltersForRelation( - relation: QueryRelation, - ): boolean { - const cached = this.filtersByRelation.get(relation.property); - return cached - ? cached.relationAndFilters.length > 0 || - cached.relationOrFilters.length > 0 - : false; - } - - /** - * Get AND filters for a specific relation - */ - private getRelationAndFilters( - relation: QueryRelation, - ): QueryFilter[] { - const cached = this.filtersByRelation.get(relation.property); - return cached ? cached.relationAndFilters : []; - } - - /** - * Get OR filters for a specific relation - */ - private getRelationOrFilters( - relation: QueryRelation, - ): QueryFilter[] { - const cached = this.filtersByRelation.get(relation.property); - return cached ? cached.relationOrFilters : []; - } - - /** - * Apply filters for a specific relation directly to a relation request - */ - applyRelationFilters( - relationReq: CrudRequestInterface, - relation: QueryRelation, - ): void { - // Apply AND filters - const relationAndFilters = this.getRelationAndFilters(relation); - for (const filter of relationAndFilters) { - const relationFilter: QueryFilter = { ...filter }; - relationReq.parsed.filter.push(relationFilter); - } - - // Apply OR filters - const relationOrFilters = this.getRelationOrFilters(relation); - for (const filter of relationOrFilters) { - const relationFilter: QueryFilter = { ...filter }; - relationReq.parsed.or.push(relationFilter); - } - } - - /** - * Add constraint filter directly to a request (for ephemeral ID constraints) - */ - static addConstraintFilter( - req: CrudRequestInterface, - field: string, - values: unknown[], - relation?: string, - ): void { - if (values.length === 0) { - return; // No constraints to add - } - - if (values.length === 1) { - // single value: use CondOperator.EQUALS operator - req.parsed.filter.push({ - field, - operator: CondOperator.EQUALS, - value: values[0], - relation, - }); - } else { - // multiple values: use CondOperator.IN operator - req.parsed.filter.push({ - field, - operator: CondOperator.IN, - value: values, - relation, - }); - } - } - - /** - * Check if any filters exist for the given relations - */ - hasRelationFilters( - relations: CrudRelationBindingInterface[], - ): boolean { - if (this.filtersByRelation.size === 0) { - return false; - } - - // Check each relation directly - for (const relationBinding of relations) { - if (this.hasFiltersForRelation(relationBinding.relation)) { - return true; - } - } - - return false; - } - - /** - * Single-pass filter processing - simpler approach without duplication - */ - private processFilters( - andFilters: QueryFilter[], - orFilters: QueryFilter[], - ): void { - this.processFilterArray(andFilters, true); - this.processFilterArray(orFilters, false); - } - - /** - * Process a single filter array - */ - private processFilterArray( - filters: QueryFilter[], - isAndFilter: boolean, - ): void { - for (const filter of filters) { - if (filter.relation) { - this.addRelationFilter(filter, isAndFilter); - } else { - (isAndFilter ? this.rootAndFilters : this.rootOrFilters).push(filter); - } - } - } - - /** - * Helper to add a relation filter to the cache - */ - private addRelationFilter( - filter: QueryFilter, - isAndFilter: boolean, - ): void { - let cached = this.filtersByRelation.get(filter.relation!); - - if (!cached) { - cached = { - relationAndFilters: [], - relationOrFilters: [], - }; - this.filtersByRelation.set(filter.relation!, cached); - } - - if (isAndFilter) { - cached.relationAndFilters.push(filter); - } else { - cached.relationOrFilters.push(filter); - } - } - - /** - * Inject CondOperator.NOT_NULL filters for relations requiring INNER JOIN semantics - */ - private injectInnerJoinFilters( - req: CrudRequestInterface, - relations: QueryRelation[], - ): void { - const relationsSortedOn = new Set(); - const allSorts = req.parsed.sort || []; - - for (const sortConfig of allSorts) { - const drivingRelation = this.findRelationForSortField(sortConfig); - if (drivingRelation) { - relationsSortedOn.add(drivingRelation.property); - } - } - - const innerJoinRelations = relations.filter( - (relation) => - relation.join === 'INNER' || relationsSortedOn.has(relation.property), - ); - - for (const relation of innerJoinRelations) { - const foreignKeyField = relation.foreignKey; - - // Check if NOT_NULL filter already exists in root filters or relation filters - const existingInRoot = this.rootAndFilters.find( - (filter) => - filter.field === foreignKeyField && - filter.operator === CondOperator.NOT_NULL, - ); - - // Check if it exists in relation filters for this relation - const relationCache = this.filtersByRelation.get(relation.property); - const existingInRelation = relationCache?.relationAndFilters.find( - (filter) => - filter.field === foreignKeyField && - filter.operator === CondOperator.NOT_NULL, - ); - - if (!existingInRoot && !existingInRelation) { - // Push directly onto our internal arrays - const innerJoinFilter: QueryFilter = { - field: foreignKeyField, - operator: CondOperator.NOT_NULL, - relation: relation.owner ? undefined : relation.property, - }; - - if (innerJoinFilter.relation) { - // It's a relation filter - this.addRelationFilter(innerJoinFilter, true); - } else { - // It's a root filter - this.rootAndFilters.push(innerJoinFilter); - } - } - } - } - - findRelationForSortField( - sortConfig: QuerySort, - ): QueryRelation | null { - if (sortConfig.relation) { - return ( - this.relations.find( - (relation) => relation.property === sortConfig.relation, - ) || null - ); - } - return null; - } - - /** - * Process distinct filters from relations that have them defined - */ - private processDistinctFilters( - relations: QueryRelation[], - ): void { - for (const relation of relations) { - if (relation.distinctFilter) { - // Add the distinct filter for this relation - const distinctFilter: QueryFilter = { - field: relation.distinctFilter.field, - operator: relation.distinctFilter.operator, - value: relation.distinctFilter.value, - relation: relation.property, - }; - - this.addRelationFilter(distinctFilter, true); - } - } - } -} - -/** - * Analyzes and categorizes sort configurations for CRUD federation queries. - * Separates sorts into root vs relation sorts and validates relation sort requirements. - */ -class SortAnalyzer< - T extends PlainLiteralObject, - Relations extends PlainLiteralObject[] = PlainLiteralObject[], -> { - private readonly relationSorts: SortConfiguration[]; - private readonly rootSorts: SortConfiguration[]; - private readonly drivingRelation?: CrudRelationBindingInterface< - T, - Relations[number] - >; - - constructor( - req: CrudRequestInterface, - filterAnalyzer: FilterAnalyzer, - relations: CrudRelationBindingInterface[], - validatedRelations: Set, - ) { - const allSorts = req.parsed.sort || []; - // Categorize sorts into relation vs root sorts - const sortCategories = this.categorizeSorts( - allSorts, - filterAnalyzer, - relations, - validatedRelations, - ); - this.relationSorts = sortCategories.relationSorts; - this.rootSorts = sortCategories.rootSorts; - - // Identify driving relation (first relation with sort) - this.drivingRelation = this.relationSorts[0]?.drivingRelation; - } - - /** - * Get sorts for relation queries - */ - getRelationSorts(): SortConfiguration[] { - return this.relationSorts; - } - - /** - * Get sorts for root queries - */ - getRootSorts(): SortConfiguration[] { - return this.rootSorts; - } - - /** - * Get the driving relation for RELATION_FIRST strategy - */ - getDrivingRelation(): - | CrudRelationBindingInterface - | undefined { - return this.drivingRelation; - } - - /** - * Check if there are any relation sorts - */ - hasRelationSorts(): boolean { - return this.relationSorts.length > 0; - } - - /** - * Apply root sorts to a request (filters out relation sorts) - */ - applyRootSorts(req: CrudRequestInterface): void { - req.parsed.sort = this.rootSorts.map((sort) => ({ - field: sort.field, - order: sort.order, - })); - } - - /** - * Categorize sorts into relation vs root sorts - */ - private categorizeSorts( - allSorts: QuerySort[], - filterAnalyzer: FilterAnalyzer, - relations: CrudRelationBindingInterface[], - validatedRelations: Set, - ): { - relationSorts: SortConfiguration[]; - rootSorts: SortConfiguration[]; - } { - const relationSorts: SortConfiguration[] = []; - const rootSorts: SortConfiguration[] = []; - - for (const sortConfig of allSorts) { - const sortField = sortConfig.field; - const sortOrder = sortConfig.order; - - // Check if sort belongs to a relation - const foundRelation = filterAnalyzer.findRelationForSortField(sortConfig); - const drivingRelation = findRelationBinding(foundRelation, relations); - - if (drivingRelation) { - // Validate relation sort requirements (skip if already validated) - this.validateRelationSortRequirements( - sortField, - drivingRelation, - validatedRelations, - ); - - relationSorts.push({ - field: sortField, - order: sortOrder, - isRelationSort: true, - drivingRelation, - }); - } else { - rootSorts.push({ - field: sortField, - order: sortOrder, - isRelationSort: false, - }); - } - } - - return { relationSorts, rootSorts }; - } - - /** - * Validate relation sort requirements - */ - private validateRelationSortRequirements( - sortField: string, - drivingRelation: CrudRelationBindingInterface, - validatedRelations: Set, - ): void { - const relation = drivingRelation.relation; - if (!validatedRelations.has(relation.property)) { - validateManyCardinalityDistinctFilter( - relation, - `Sorting by relation field '${sortField}'`, - ); - validatedRelations.add(relation.property); - } - } -} - -/** - * Determines and manages the execution strategy for CRUD federation queries. - * Analyzes filters, sorts, and relations to decide between ROOT_FIRST and RELATION_FIRST strategies. - */ -class ExecutionStrategy< - T extends PlainLiteralObject, - Relations extends PlainLiteralObject[] = PlainLiteralObject[], -> { - private readonly type: JoinStrategyType; - public readonly sortAnalyzer: SortAnalyzer; - public readonly filterAnalyzer: FilterAnalyzer; - public readonly drivingRelation?: CrudRelationBindingInterface< - T, - Relations[number] - >; - - constructor( - req: CrudRequestInterface, - relations: CrudRelationBindingInterface[], - ) { - // Create filter analyzer with complete filter processing - this.filterAnalyzer = new FilterAnalyzer(req); - - // Track validated relations to avoid redundant validation - const validatedRelations = new Set(); - - // Validate relation filter requirements - this.validateRelationFilterRequirements(relations, validatedRelations); - - // Create sort analyzer instance - this.sortAnalyzer = new SortAnalyzer( - req, - this.filterAnalyzer, - relations, - validatedRelations, - ); - - // Determine driving relation (considers both sorts and filters) - this.drivingRelation = this.determineDrivingRelation(relations); - - // Determine strategy type using sort analyzer - const hasRelationFilters = - this.filterAnalyzer.hasRelationFilters(relations); - this.type = - this.sortAnalyzer.hasRelationSorts() || hasRelationFilters - ? JoinStrategyType.RELATION_FIRST - : JoinStrategyType.ROOT_FIRST; - } - - /** - * Check if this is a RELATION_FIRST strategy - */ - isRelationFirst(): boolean { - return this.type === JoinStrategyType.RELATION_FIRST; - } - - /** - * Check if this is a ROOT_FIRST strategy - */ - isRootFirst(): boolean { - return this.type === JoinStrategyType.ROOT_FIRST; - } - - /** - * Determine the driving relation based on sorts and filters - * Priority: 1) First relation with sort, 2) First relation with filter - */ - private determineDrivingRelation( - relations: CrudRelationBindingInterface[], - ): CrudRelationBindingInterface | undefined { - // Priority 1: First relation with a sort - const sortDrivingRelation = this.sortAnalyzer.getDrivingRelation(); - if (sortDrivingRelation) { - return sortDrivingRelation; - } - - // Priority 2: First relation with a filter - for (const relationBinding of relations) { - if (this.filterAnalyzer.hasFiltersForRelation(relationBinding.relation)) { - return relationBinding; - } - } - - return undefined; - } - - /** - * Validate relation filter requirements - */ - private validateRelationFilterRequirements( - relations: CrudRelationBindingInterface[], - validatedRelations: Set, - ): void { - for (const relationBinding of relations) { - const relation = relationBinding.relation; - const hasFilters = this.filterAnalyzer.hasFiltersForRelation(relation); - - if (hasFilters && !validatedRelations.has(relation.property)) { - validateManyCardinalityDistinctFilter(relation, 'Relation filters'); - validatedRelations.add(relation.property); - } - } - } -} - -/** - * Manages offset-based pagination for iterative constraint discovery - * - * The BufferStrategy addresses the "sparse data problem" in relation-first federation: - * When sorting by a relation field, the first page of sorted relations might only - * correspond to a few unique root entities. For example, if sorting posts by comment.title, - * the first 10 comments might all belong to just 2 posts, leaving the user with only - * 2 posts instead of the requested 10. - * - * Uses offset-based pagination to progressively fetch more relation data until enough - * unique root IDs are discovered to satisfy the user's requested limit. - */ -class BufferStrategy { - private currentOffset: number = 0; - private readonly batchSize: number; - private readonly maxOffset: number; - - constructor( - userLimit: number, - options: { - batchSize?: number; - maxOffset?: number; - } = {}, - ) { - const { - batchSize = userLimit, - maxOffset = CRUD_FEDERATION_MAX_BUFFER_SIZE, - } = options; - - this.batchSize = batchSize; - // Ensure maxOffset doesn't exceed the constant limit - this.maxOffset = Math.min(maxOffset, CRUD_FEDERATION_MAX_BUFFER_SIZE); - } - - /** - * Advance to next batch and return parameters (limit and offset) - */ - advance(): { limit: number; offset: number } { - const limit = this.batchSize; - const offset = this.currentOffset; - - // Advance offset for next iteration - this.currentOffset += limit; - - return { limit, offset }; - } - - /** - * Check if we've reached the maximum offset limit - */ - hasReachedLimit(): boolean { - return this.currentOffset >= this.maxOffset; - } -} - -export class CrudFederationService< - Root extends PlainLiteralObject, - Relations extends PlainLiteralObject[], -> { - private readonly rootSearchHelper = new CrudSearchHelper(); - private readonly relationSearchHelper = new CrudSearchHelper< - Relations[number] - >(); - - constructor( - private readonly rootService: CrudFetchServiceInterface, - private readonly relationRegistry?: CrudRelationRegistry, - ) {} - - /** - * Get relation bindings for relation configurations. - * Throws error if relations are configured but no registry is available. - */ - private getRelationBindings( - req: CrudRequestInterface, - ): CrudRelationBindingInterface[] { - const relations = req.options?.query?.relations?.relations; - - if (!relations || relations.length === 0) { - return []; - } - - if (!this.relationRegistry) { - const relationNames = relations.map((r) => r.property).join(', '); - throw new CrudFederationException({ - message: - 'Relation registry is required when relations are configured: %s. Inject CrudRelationRegistry in the CrudService constructor.', - messageParams: [relationNames], - }); - } - - return this.relationRegistry.getBindings(relations); - } - - /** Validate that search and or filters via query string are not supported */ - private validateUnsupportedQueryFeatures( - req: CrudRequestInterface, - executionStrategy: ExecutionStrategy, - ): void { - // check if search conditions exist via query string - if (req.parsed.search) { - throw new CrudFederationException({ - message: - 'Search via query string is not supported in CRUD federation. ' + - 'Use filter conditions instead.', - }); - } - - // check if OR conditions exist via query string - if (executionStrategy.filterAnalyzer.getRootOrFilters().length > 0) { - throw new CrudFederationException({ - message: - 'OR filter via query string is not supported in CRUD federation. ' + - 'Use AND filter conditions instead.', - }); - } - } - - /** Create standardized error for unsupported owner relationship operations */ - private createOwnerRelationshipError( - operation: string, - relationProperty?: string, - ): CrudFederationException { - const relationContext = relationProperty - ? ` for relationship "${relationProperty}"` - : ''; - - return new CrudFederationException({ - message: - `${operation} on owner relationships is not supported${relationContext}. ` + - 'Owner relationships (where owner=true) store the foreign key on the root entity pointing to the relation, ' + - 'which means constraint propagation cannot extract root IDs from the relation data. ' + - 'Consider using enrichment-only access for owner relationships, or restructure the query to filter/sort on root fields instead.', - messageParams: relationProperty ? [relationProperty] : [], - }); - } - - /** Validate owner relationship configurations for supported scenarios */ - private validateOwnerRelationships( - relations: CrudRelationBindingInterface[], - executionStrategy: ExecutionStrategy, - allSorts: QuerySort[], - ): void { - for (const relationBinding of relations) { - const relation = relationBinding.relation; - - if (relation.owner) { - // Check for relation filters and sorts on owner relationships - const hasRelationFilters = - executionStrategy.filterAnalyzer.hasFiltersForRelation(relation); - const hasRelationSorts = allSorts.some( - (sort) => sort.relation === relation.property, - ); - - if (hasRelationFilters || hasRelationSorts) { - const constraintType = hasRelationFilters - ? hasRelationSorts - ? 'Filtering and sorting' - : 'Filtering' - : 'Sorting'; - - throw this.createOwnerRelationshipError( - constraintType, - relation.property, - ); - } - } - } - } - - /** - * Main federation method - uses hybrid strategy based on sort and relation requirements - * - * Strategy Selection: - * - ROOT_FIRST: Fetch roots first, then relations for those specific roots - * - Efficient for most queries (LEFT JOIN semantics) - * - Used when sorting by root fields or when no relation constraints exist - * - Maintains predictable pagination on root entities - * - * - RELATION_FIRST: Fetch relations first, extract root IDs, then fetch roots - * - Required for relation field sorting (to maintain sort order) - * - Required for relation filtering (INNER JOIN semantics) - * - Uses BufferStrategy to handle sparse data during iteration - * - More complex but enables relation-driven queries - * - * @param req - CRUD request with parsed filters, sorts, and pagination - * @param options - Optional fetch options including metrics collection - * @returns Paginated response with hydrated relations and optional performance metrics - */ - async getMany( - req: CrudRequestInterface, - options?: CrudFederationFetchOptionsInterface, - ): Promise> { - const { includeMetrics = false } = options || {}; - - const startTime = Date.now(); - - // extract relation configurations from relations - const relations = this.getRelationBindings(req); - - // Create execution strategy (which creates filterAnalyzer internally) - const executionStrategy = new ExecutionStrategy(req, relations); - - // validation: reject unsupported owner relationship scenarios - const allSorts = req.parsed.sort || []; - this.validateOwnerRelationships(relations, executionStrategy, allSorts); - - // validation: reject unsupported search and or filters via query string - this.validateUnsupportedQueryFeatures(req, executionStrategy); - - let totalFetched = 0; - let fetchCalls = 0; - let resultRoots: Root[] = []; - let allRelationResults: RelationResult[] = []; - let accurateTotal = 0; - - // Cache root key once for this request to avoid repeated validation - const rootKey = relations.length > 0 ? this.getRootKey(req) : ''; - - // Execute strategy based on analysis - if (executionStrategy.isRelationFirst()) { - // RELATION_FIRST: Sequential constraint-building for relation sorts or INNER JOIN - const sequentialResult = await this.fetchWithSequentialConstraints({ - req, - relations, - rootKey, - executionStrategy, - }); - resultRoots = sequentialResult.resultRoots; - allRelationResults = sequentialResult.allRelationResults; - accurateTotal = sequentialResult.accurateTotal; - fetchCalls += sequentialResult.fetchCalls; - totalFetched += sequentialResult.totalFetched; - } else { - // ROOT_FIRST: Handle all root-first scenarios - const rootFirstResult = await this.executeRootFirstStrategy({ - req, - relations, - rootKey, - executionStrategy, - }); - resultRoots = rootFirstResult.resultRoots; - allRelationResults = rootFirstResult.allRelationResults; - accurateTotal = rootFirstResult.accurateTotal; - fetchCalls += rootFirstResult.fetchCalls; - totalFetched += rootFirstResult.totalFetched; - } - - // Always return roots even if accurateTotal === 0 - // Empty roots means the root service returned empty, not that relations are empty - - // Hydrate relationships using pre-fetched relation data - if (resultRoots.length > 0 && allRelationResults.length > 0) { - const allRelationArrays = allRelationResults.map((result) => result.data); - - this.hydrateRelations(rootKey, resultRoots, relations, allRelationArrays); - } else if (resultRoots.length > 0) { - // Initialize empty relations for roots when no relations exist - this.initializeRelationProperties(resultRoots, relations); - } - - // Return result with accurate pagination metadata and optional metrics - const metrics: FederationMetrics = { - totalFetched, - fetchCalls, - duration: Date.now() - startTime, - }; - - return this.buildFinalResponse( - resultRoots, - accurateTotal, - req, - includeMetrics, - metrics, - ); - } - - /** Helper: Deduplicate array while preserving order of first occurrences */ - private deduplicatePreservingOrder(items: T[]): T[] { - return [...new Set(items.filter((item) => item != null))]; - } - - /** Helper: Merge relation results, deduplicating and preserving order */ - private mergeRelationResults( - allRelationResults: RelationResult[], - newResults: RelationResult[], - ): void { - for (const newResult of newResults) { - const existingIndex = allRelationResults.findIndex( - (existing) => existing.config === newResult.config, - ); - if (existingIndex >= 0) { - // Merge data, deduplicating while preserving order - const combinedData = [ - ...allRelationResults[existingIndex].data, - ...newResult.data, - ]; - allRelationResults[existingIndex].data = - this.deduplicatePreservingOrder(combinedData); - allRelationResults[existingIndex].total = Math.max( - allRelationResults[existingIndex].total || 0, - newResult.total || 0, - ); - } else { - // First time seeing this relation config - allRelationResults.push(newResult); - } - } - } - - /** Helper: Clone request with parsed overrides */ - private cloneRequest( - req: CrudRequestInterface, - parsedOverrides: Partial['parsed']>, - ): CrudRequestInterface { - return { - ...req, - parsed: { - ...req.parsed, - ...parsedOverrides, - }, - }; - } - - /** Helper: Re-order roots to match the specified ID order */ - private reorderRootsByIds( - fetchedRoots: Root[], - orderedIds: unknown[], - rootKey: keyof Root, - ): Root[] { - // create map for O(1) lookup - const rootMap = new Map(); - for (const root of fetchedRoots) { - rootMap.set(root[rootKey], root); - } - - // map ordered IDs to roots and filter out undefined results - const mappedRoots: (Root | undefined)[] = orderedIds.map((id) => - rootMap.get(id), - ); - const validRoots: Root[] = mappedRoots.filter( - (root): root is Root => root !== undefined, - ); - - // Deduplicate in case orderedIds contains duplicate IDs - // (though this should be rare since constraint IDs are usually deduplicated) - return this.deduplicatePreservingOrder(validRoots); - } - - /** Single entity fetching with relation hydration */ - async getOne(req: CrudRequestInterface): Promise { - // extract relation configurations from relations - const relations = this.getRelationBindings(req); - - // build search conditions before calling service - this.rootSearchHelper.buildSearch(req); - - // fetch the root entity first - const root = await this.rootService.getOne(req); - - // if no relations requested, return root as-is - if (relations.length === 0) { - return root; - } - - // fetch relations for the single root entity - const relationArrays = await this.fetchRelationsForSingleRoot( - root, - relations, - ); - - // hydrate relations for the single root entity - const rootKey = this.getRootKey(req); - this.hydrateRelations(rootKey, [root], relations, relationArrays); - - return root; - } - - /** Helper: Fetch relations for a single root entity */ - private async fetchRelationsForSingleRoot( - root: Root, - relations: CrudRelationBindingInterface[], - ): Promise { - const relationPromises = relations.map( - async ( - relationBinding: CrudRelationBindingInterface, - ): Promise => { - const relation = relationBinding.relation; - - // extract constraint value based on relationship direction - const constraintValue = relation.owner - ? root[relation.foreignKey] // Inverse: root's foreign key - : root[relation.primaryKey]; // Forward: root's primary key - - if (constraintValue == null) { - return []; - } - - // Execute relation query with single constraint value - const constraintField = relation.owner - ? relation.primaryKey - : relation.foreignKey; - - const result = await this.executeRelationQuery(relationBinding, { - constraintField, - constraintValues: [constraintValue], - }); - - return result.data; - }, - ); - - return Promise.all(relationPromises); - } - - /** - * Execute a standardized relation query with filters, constraints and search building - */ - private async executeRelationQuery( - relationBinding: CrudRelationBindingInterface, - options: { - executionStrategy?: ExecutionStrategy; - constraintField?: string; - constraintValues?: unknown[]; - limit?: number; - sorts?: SortConfiguration[]; - } = {}, - ): Promise<{ data: Relations[number][]; total?: number; count?: number }> { - const { - executionStrategy, - constraintField, - constraintValues = [], - limit, - sorts, - } = options; - - // Create relation request with filters - const relationReq = this.createRelationRequest({ - executionStrategy, - relationBinding, - limit, - sorts, - }); - - // Add constraint filter if provided - if (constraintField && constraintValues.length > 0) { - FilterAnalyzer.addConstraintFilter( - relationReq, - constraintField, - constraintValues, - relationBinding.relation.property, - ); - } - - // Build search conditions - this.relationSearchHelper.buildSearch(relationReq, { - relation: relationBinding.relation, - }); - - // Execute relation query - return relationBinding.service.getMany(relationReq); - } - - /** - * Hydrate relations on root entities by setting relation results using QueryRelation.property - * - * @param rootKey - Root entity primary key field name for lookups - * @param roots - Root entities to hydrate - * @param relations - Relation configurations defining relationships - * @param relationArrays - Pre-fetched relation data arrays (parallel to relations) - */ - private hydrateRelations( - rootKey: string, - roots: Root[], - relations: CrudRelationBindingInterface[], - relationArrays: Relations[number][][], - ): void { - // create a map for quick root lookups - const rootMap = new Map(); - for (const root of roots) { - rootMap.set(root[rootKey], root); - } - - // process each relation configuration and its corresponding relation array - for (let index = 0; index < relationArrays.length; index++) { - const relationArray = relationArrays[index]; - const relationBinding = relations[index]; - const relation = relationBinding.relation; - - if (relation.owner) { - this.hydrateOwnerRelations(roots, relation, relationArray); - } else { - this.hydrateForwardRelations(rootMap, relation, relationArray); - } - - // initialize empty relations for roots that have no relations - this.initializeRelationProperties(roots, [relationBinding], true); - } - } - - /** - * Hydrate owner (inverse) relationships: root[foreignKey] : relation[primaryKey] - */ - private hydrateOwnerRelations( - roots: Root[], - relation: QueryRelation, - relationArray: Relations[number][], - ): void { - if (relation.cardinality === CRUD_RELATION_CARDINALITY_MANY) { - // For many cardinality, group relations by their primary key to handle multiple entities per key - const relationsByKey = new Map(); - for (const relationEntity of relationArray) { - const key = relationEntity[relation.primaryKey]; - if (!relationsByKey.has(key)) { - relationsByKey.set(key, []); - } - relationsByKey.get(key)!.push(relationEntity); - } - - // assign relation arrays to roots based on root's foreign key - for (const root of roots) { - const foreignKeyValue = root[relation.foreignKey]; - if (foreignKeyValue != null) { - const relationEntities = relationsByKey.get(foreignKeyValue) || []; - this.setRelationProperty(root, relation, relationEntities); - } - } - } else { - // For one cardinality, use existing single-entity logic - const relationsById = new Map(); - for (const relationEntity of relationArray) { - relationsById.set(relationEntity[relation.primaryKey], relationEntity); - } - - // assign relations to roots based on root's foreign key - for (const root of roots) { - const foreignKeyValue = root[relation.foreignKey]; - if (foreignKeyValue != null) { - const relationEntity = relationsById.get(foreignKeyValue); - if (relationEntity) { - const value = - relation.cardinality === CRUD_RELATION_CARDINALITY_ONE - ? relationEntity - : [relationEntity]; - this.setRelationProperty(root, relation, value); - } - } - } - } - } - - /** - * Hydrate forward relationships: relation[foreignKey] : root[primaryKey] - */ - private hydrateForwardRelations( - rootMap: Map, - relation: QueryRelation, - relationArray: Relations[number][], - ): void { - // group relation entities by their foreign key (which points to root) - const relationsByRootKey = new Map(); - for (const relationEntity of relationArray) { - const rootKeyValue = relationEntity[relation.foreignKey]; - const existingRelations = relationsByRootKey.get(rootKeyValue); - if (existingRelations) { - existingRelations.push(relationEntity); - } else { - relationsByRootKey.set(rootKeyValue, [relationEntity]); - } - } - - // set relation entities on their root entities using the relation property - for (const [rootKeyValue, relationEntities] of relationsByRootKey) { - const root = rootMap.get(rootKeyValue); - if (root) { - this.assignRelationToRoot(root, relation, relationEntities); - } - } - } - - /** - * Assign relation entities to a root (handles single entity or array) - */ - private assignRelationToRoot( - root: Root, - relation: QueryRelation, - relationData: Relations[number] | Relations[number][], - ): void { - let value: Relations[number] | Relations[number][] | null; - - if (Array.isArray(relationData)) { - // Multiple entities provided - if (relation.cardinality === CRUD_RELATION_CARDINALITY_ONE) { - value = relationData[0] || null; // Take first for one-to-one - } else { - value = relationData; // Use array for one-to-many - } - } else { - // Single entity provided - if (relation.cardinality === CRUD_RELATION_CARDINALITY_ONE) { - value = relationData; // Use single entity for one-to-one - } else { - value = [relationData]; // Wrap in array for one-to-many - } - } - - this.setRelationProperty(root, relation, value); - } - - /** Set a relation property value on a root entity */ - private setRelationProperty( - root: Root, - relation: QueryRelation, - value: Relations[number] | Relations[number][] | null, - ): void { - Object.assign(root, { [relation.property]: value }); - } - - /** - * Initialize relation properties on roots - * - * @param roots - Root entities to initialize - * @param relations - Relations to initialize (single relation or array) - * @param onlyIfMissing - Only initialize if property doesn't exist (default: false) - */ - private initializeRelationProperties( - roots: Root[], - relations: CrudRelationBindingInterface[], - onlyIfMissing: boolean = false, - ): void { - for (const root of roots) { - for (const binding of relations) { - const relation = binding.relation; - - if (!onlyIfMissing || !(relation.property in root)) { - const defaultValue = - relation.cardinality === CRUD_RELATION_CARDINALITY_ONE ? null : []; - this.setRelationProperty(root, relation, defaultValue); - } - } - } - } - - /** Type guard to safely extract root key from request options */ - private getRootKey(req: CrudRequestInterface): string { - const relations = req.options?.query?.relations; - if (!relations) { - throw new CrudFederationException({ - message: - 'Relations configuration is required but not found in request options', - }); - } - - const key = relations.rootKey; - if (!key || typeof key !== 'string') { - throw new CrudFederationException({ - message: - 'Root key must be specified in relations.rootKey as a non-empty string', - }); - } - - return key; - } - - /** Helper: Create relation request with optional filters and sorts applied */ - private createRelationRequest( - options: { - limit?: number; - offset?: number; - executionStrategy?: ExecutionStrategy; - relationBinding?: CrudRelationBindingInterface; - sorts?: SortConfiguration[]; - } = {}, - ): CrudRequestInterface { - const { limit, offset, executionStrategy, relationBinding, sorts } = - options; - - const relationReq: CrudRequestInterface = { - parsed: { - filter: [], - or: [], - sort: [], - limit, - page: undefined, // Relation services use limit/offset, never page - fields: [], - paramsFilter: [], - classTransformOptions: {}, - search: undefined, - offset, - cache: undefined, - includeDeleted: undefined, - }, - options: {}, - }; - - // apply relation sorts if provided - if (sorts && sorts.length > 0) { - relationReq.parsed.sort = sorts.map((sort) => ({ - field: sort.field, - order: sort.order, - })); - } - - // apply relation filters if provided - if (executionStrategy && relationBinding) { - executionStrategy.filterAnalyzer.applyRelationFilters( - relationReq, - relationBinding.relation, - ); - } - - return relationReq; - } - - /** Get root count with optional filter checking */ - private async getRootTotal( - req: CrudRequestInterface, - executionStrategy: ExecutionStrategy, - ): Promise { - // If no root filters exist, return max - if (!executionStrategy.filterAnalyzer.hasRootFilters()) { - return Number.MAX_SAFE_INTEGER; - } - - const countReq = this.cloneRequest(req, { - limit: 1, // We only need the count - page: 1, - offset: undefined, - sort: [], // No sorting needed for count - }); - - this.rootSearchHelper.buildSearch(countReq); - const rootResult = await this.rootService.getMany(countReq); - - return rootResult.total || rootResult.count || 0; - } - - /** Consolidated method to fetch roots directly */ - private async fetchRootsDirectly( - rootReq: CrudRequestInterface, - executionStrategy?: ExecutionStrategy, - ): Promise<{ - roots: Root[]; - total: number; - fetchCalls: number; - totalFetched: number; - }> { - // Apply root sorts if execution strategy is available - if (executionStrategy) { - executionStrategy.sortAnalyzer.applyRootSorts(rootReq); - } - - // build search conditions from parsed request - this.rootSearchHelper.buildSearch(rootReq); - - const rootResult = await this.rootService.getMany(rootReq); - - return { - roots: rootResult.data, - total: rootResult.total || rootResult.count, - fetchCalls: 1, - totalFetched: rootResult.data.length, - }; - } - - /** Execute ROOT_FIRST strategy handling all root-first scenarios */ - private async executeRootFirstStrategy(options: { - req: CrudRequestInterface; - relations: CrudRelationBindingInterface[]; - rootKey: string; - executionStrategy: ExecutionStrategy; - }): Promise<{ - resultRoots: Root[]; - allRelationResults: Array<{ - config: CrudRelationBindingInterface; - data: Relations[number][]; - total?: number; - }>; - accurateTotal: number; - fetchCalls: number; - totalFetched: number; - }> { - const { req, relations, rootKey, executionStrategy } = options; - - // Handle no relations case - if (relations.length === 0) { - const noRelationResult = await this.fetchRootsDirectly( - req, - executionStrategy, - ); - return { - resultRoots: noRelationResult.roots, - allRelationResults: [], - accurateTotal: noRelationResult.total, - fetchCalls: noRelationResult.fetchCalls, - totalFetched: noRelationResult.totalFetched, - }; - } - - // LEFT JOIN: Use root-first strategy for optimal performance - const leftJoinResult = await this.fetchRelationsForLeftJoin( - req, - relations, - rootKey, - executionStrategy, - ); - - return { - resultRoots: leftJoinResult.roots, - allRelationResults: leftJoinResult.relationResults, - accurateTotal: leftJoinResult.total, - fetchCalls: leftJoinResult.fetchCalls, - totalFetched: leftJoinResult.totalFetched, - }; - } - - /** Handle LEFT JOIN case - root-first strategy */ - private async fetchRelationsForLeftJoin( - rootReq: CrudRequestInterface, - relations: CrudRelationBindingInterface[], - rootKey: string, - executionStrategy: ExecutionStrategy, - ): Promise<{ - roots: Root[]; - total: number; - fetchCalls: number; - totalFetched: number; - relationResults: RelationResult[]; - }> { - // First fetch roots using consolidated method - const rootsResult = await this.fetchRootsDirectly( - rootReq, - executionStrategy, - ); - const fetchedRoots = rootsResult.roots; - - let fetchCalls = rootsResult.fetchCalls; - let totalFetched = rootsResult.totalFetched; - let relationResults: RelationResult[] = []; - - // Now fetch relations with root ID constraints - if (fetchedRoots.length > 0) { - const relationEnrichment = await this.fetchRelationsForRoots({ - req: rootReq, - relations, - roots: fetchedRoots, - rootKey, - executionStrategy, - }); - - fetchCalls += relationEnrichment.fetchCalls; - totalFetched += relationEnrichment.totalFetched; - relationResults = relationEnrichment.allRelationResults; - } - - return { - roots: fetchedRoots, - total: rootsResult.total, - fetchCalls, - totalFetched, - relationResults, - }; - } - - /** Build final response with pagination metadata and optional metrics */ - private buildFinalResponse( - resultRoots: Root[], - accurateTotal: number, - rootReq: CrudRequestInterface, - includeMetrics: boolean, - metrics: FederationMetrics, - ): CrudResponsePaginatedInterface { - const result: CrudResponsePaginatedInterface = { - data: resultRoots, - count: resultRoots.length, - total: accurateTotal, - limit: rootReq.parsed.limit || CRUD_FEDERATION_DEFAULT_LIMIT, - page: rootReq.parsed.page || CRUD_FEDERATION_DEFAULT_PAGE, - pageCount: rootReq.parsed.limit - ? Math.ceil(accurateTotal / rootReq.parsed.limit) - : Math.ceil(accurateTotal / CRUD_FEDERATION_DEFAULT_LIMIT), - metrics: includeMetrics - ? { - totalFetched: metrics.totalFetched, - totalValid: resultRoots.length, - fetchCalls: metrics.fetchCalls, - duration: metrics.duration, - } - : undefined, - }; - - return result; - } - - /** - * Discover root IDs through iterative constraint processing - * - * Uses BufferStrategy to handle sparse data by progressively increasing - * the fetch limit until enough unique root IDs are discovered to satisfy - * the user's requested limit. - */ - private async discoverConstrainedRootIds(options: { - req: CrudRequestInterface; - relations: CrudRelationBindingInterface[]; - rootKey: string; - executionStrategy: ExecutionStrategy; - processedRootIds?: Set; - }): Promise<{ - rootIds: unknown[]; - accurateTotal: number; - allRelationResults: Array<{ - config: CrudRelationBindingInterface; - data: Relations[number][]; - total?: number; - }>; - fetchCalls: number; - totalFetched: number; - }> { - const { req, relations, rootKey, executionStrategy, processedRootIds } = - options; - const userPage = req.parsed.page || 1; - const userLimit = req.parsed.limit || CRUD_FEDERATION_DEFAULT_LIMIT; - - let constraintRootIds: unknown[] = []; - const accumulatedRootIds: Set = new Set(); // Accumulate across all iterations - const bufferStrategy = new BufferStrategy(userLimit); - let isDrivingRelationExhausted = false; - let relationTotal = 0; - let fetchCalls = 0; - let totalFetched = 0; - const allRelationResults: RelationResult[] = []; - - // Iterative approach to handle sparse data - for ( - let iteration = 0; - iteration < CRUD_FEDERATION_MAX_ITERATIONS; - iteration++ - ) { - constraintRootIds = []; - const tempRelationResults: typeof allRelationResults = []; - - // Process relations sequentially - each passes constraints to the next - const constraintResult = await this.processRelationsSequentially({ - relations, - rootKey, - executionStrategy, - userPage, - bufferStrategy, - constraintRootIds, - }); - - // Update metrics and results - fetchCalls += constraintResult.fetchCalls; - totalFetched += constraintResult.totalFetched; - constraintRootIds = constraintResult.finalConstraintIds; - isDrivingRelationExhausted = constraintResult.isDrivingRelationExhausted; - relationTotal = constraintResult.relationTotal; - tempRelationResults.push(...constraintResult.relationResults); - - // Accumulate root IDs across iterations - for (const rootId of constraintRootIds) { - accumulatedRootIds.add(rootId); - } - - // Filter out already processed root IDs when called from outer loop - if (processedRootIds) { - constraintRootIds = constraintRootIds.filter( - (rootId) => !processedRootIds.has(rootId), - ); - // Also filter from accumulated set - for (const rootId of processedRootIds) { - accumulatedRootIds.delete(rootId); - } - } - - // Check if we should stop iterating after processing all relations - const haveEnoughRootIds = accumulatedRootIds.size >= userLimit; - const noDataFound = constraintRootIds.length === 0; - - if (noDataFound || isDrivingRelationExhausted || haveEnoughRootIds) { - // Empty intersection, hit max iterations, driving relation exhausted, or have enough IDs - // Accumulate all relation data from this iteration for hydration - this.mergeRelationResults(allRelationResults, tempRelationResults); - break; - } - - // Check if we've reached maximum offset to prevent infinite loops - if (bufferStrategy.hasReachedLimit()) { - break; - } - - // Not enough IDs after processing - try next batch (offset automatically advanced) - } - - return { - rootIds: Array.from(accumulatedRootIds), - accurateTotal: relationTotal, // Return relation total - accurate total calculated in fetchWithSequentialConstraints - allRelationResults, - fetchCalls, - totalFetched, - }; - } - - /** - * Fetch root entities for specific root IDs - */ - private async fetchConstrainedRoots(options: { - req: CrudRequestInterface; - rootKey: string; - rootIds: unknown[]; - executionStrategy: ExecutionStrategy; - }): Promise<{ - roots: Root[]; - fetchCalls: number; - totalFetched: number; - }> { - const { req, rootKey, rootIds, executionStrategy } = options; - - // Extract root-only filters (preserve root filters, remove relation filters) - const constrainedRootReq = this.cloneRequest(req, { - filter: executionStrategy.filterAnalyzer.getRootAndFilters(), // Preserve root filters, add constraint filters - or: executionStrategy.filterAnalyzer.getRootOrFilters(), // Preserve root or filters - page: 1, // Always use page 1 when fetching specific IDs - limit: req.parsed.limit || CRUD_FEDERATION_DEFAULT_LIMIT, // Preserve original limit - offset: undefined, - }); - - // Apply root sorts (filters out relation sorts) - executionStrategy.sortAnalyzer.applyRootSorts(constrainedRootReq); - - FilterAnalyzer.addConstraintFilter(constrainedRootReq, rootKey, rootIds); - - this.rootSearchHelper.buildSearch(constrainedRootReq); - - const rootResult = await this.rootService.getMany(constrainedRootReq); - - return { - roots: rootResult.data, - fetchCalls: 1, - totalFetched: rootResult.data.length, - }; - } - - /** Sequential constraint-building approach for both INNER JOIN and relation sorts */ - private async fetchWithSequentialConstraints(options: { - req: CrudRequestInterface; - relations: CrudRelationBindingInterface[]; - rootKey: string; - executionStrategy: ExecutionStrategy; - }): Promise> { - const { req, relations, rootKey, executionStrategy } = options; - const userLimit = req.parsed.limit || CRUD_FEDERATION_DEFAULT_LIMIT; - - // Get root filter total if applicable - const rootFilterTotal = await this.getRootTotal(req, executionStrategy); - - // Initialize state for iterative processing - const state = this.initializeIterationState(req); - - // Process iterations to accumulate roots - const iterationResult = await this.processSequentialIterations({ - req, - relations, - rootKey, - executionStrategy, - userLimit, - state, - }); - - // Trim to requested limit and preserve sort order - const finalRoots = iterationResult.accumulatedRoots.slice(0, userLimit); - - // Enrich final roots with complete relation data - const finalResult = await this.enrichFinalRoots({ - req, - relations, - rootKey, - finalRoots, - executionStrategy, - allRelationResults: iterationResult.allRelationResults, - totalFetchCalls: iterationResult.totalFetchCalls, - totalFetched: iterationResult.totalFetched, - }); - - // Calculate accurate total using MIN of root and relation constraints - const accurateTotal = Math.min( - rootFilterTotal, - iterationResult.relationTotal, - ); - - return { - resultRoots: finalRoots, - allRelationResults: finalResult.allRelationResults, - accurateTotal, - fetchCalls: finalResult.totalFetchCalls, - totalFetched: finalResult.totalFetched, - }; - } - - /** Get root filter total if root filters exist */ - - /** Initialize state for iterative processing */ - private initializeIterationState(req: CrudRequestInterface) { - return { - accumulatedRoots: [] as Root[], - processedRootIds: new Set(), - allRelationResults: [] as RelationResult[], - totalFetchCalls: 0, - totalFetched: 0, - relationTotal: 0, - isExhausted: false, - currentRelationPage: req.parsed.page || 1, - }; - } - - /** Process sequential iterations to accumulate roots */ - private async processSequentialIterations(options: { - req: CrudRequestInterface; - relations: CrudRelationBindingInterface[]; - rootKey: string; - executionStrategy: ExecutionStrategy; - userLimit: number; - state: { - accumulatedRoots: Root[]; - processedRootIds: Set; - allRelationResults: RelationResult[]; - totalFetchCalls: number; - totalFetched: number; - relationTotal: number; - isExhausted: boolean; - currentRelationPage: number; - }; - }) { - const { req, relations, rootKey, executionStrategy, userLimit, state } = - options; - - for ( - let iteration = 0; - iteration < CRUD_FEDERATION_MAX_ITERATIONS && !state.isExhausted; - iteration++ - ) { - // Process single iteration - const iterationComplete = await this.processSingleIteration({ - req, - relations, - rootKey, - executionStrategy, - userLimit, - state, - }); - - if (iterationComplete) { - break; - } - - // Advance to next relation page - state.currentRelationPage++; - } - - return state; - } - - /** Process a single iteration of root discovery and fetching */ - private async processSingleIteration(options: { - req: CrudRequestInterface; - relations: CrudRelationBindingInterface[]; - rootKey: string; - executionStrategy: ExecutionStrategy; - userLimit: number; - state: { - accumulatedRoots: Root[]; - processedRootIds: Set; - allRelationResults: RelationResult[]; - totalFetchCalls: number; - totalFetched: number; - relationTotal: number; - isExhausted: boolean; - currentRelationPage: number; - }; - }): Promise { - const { req, relations, rootKey, executionStrategy, userLimit, state } = - options; - - // Create request with updated pagination - const iterationReq = this.cloneRequest(req, { - page: state.currentRelationPage, - }); - - // Discover root IDs - const discoveryResult = await this.discoverConstrainedRootIds({ - req: iterationReq, - relations, - rootKey, - executionStrategy, - processedRootIds: state.processedRootIds, - }); - - // Update metrics - state.totalFetchCalls += discoveryResult.fetchCalls; - state.totalFetched += discoveryResult.totalFetched; - state.relationTotal = Math.max( - state.relationTotal, - discoveryResult.accurateTotal, - ); - - // Merge relation results - this.mergeRelationResults( - state.allRelationResults, - discoveryResult.allRelationResults, - ); - - // Check for exhaustion - if (discoveryResult.rootIds.length === 0) { - if (state.currentRelationPage > (req.parsed.page || 1)) { - state.isExhausted = true; - } - return true; // Iteration complete - } - - if (discoveryResult.rootIds.length < userLimit) { - state.isExhausted = true; - } - - // Fetch and process roots - const newRoots = await this.fetchAndProcessRoots({ - req, - rootKey, - rootIds: discoveryResult.rootIds, - processedRootIds: state.processedRootIds, - executionStrategy, - }); - - // Update metrics from root fetching - state.totalFetchCalls += newRoots.fetchCalls; - state.totalFetched += newRoots.totalFetched; - state.accumulatedRoots.push(...newRoots.roots); - - // Check completion conditions - return ( - state.accumulatedRoots.length >= userLimit || newRoots.roots.length === 0 - ); - } - - /** Fetch and process roots, filtering out already processed ones */ - private async fetchAndProcessRoots(options: { - req: CrudRequestInterface; - rootKey: string; - rootIds: unknown[]; - processedRootIds: Set; - executionStrategy: ExecutionStrategy; - }): Promise<{ roots: Root[]; fetchCalls: number; totalFetched: number }> { - const { req, rootKey, rootIds, processedRootIds, executionStrategy } = - options; - - // Fetch roots for discovered IDs - const constrainedRoots = await this.fetchConstrainedRoots({ - req, - rootKey, - rootIds, - executionStrategy, - }); - - // Reorder to preserve relation-driven sort order - const reorderedRoots = this.reorderRootsByIds( - constrainedRoots.roots, - rootIds, - rootKey, - ); - - // Filter out already processed roots - const newRoots = reorderedRoots.filter((root) => { - const rootId = root[rootKey]; - if (processedRootIds.has(rootId)) { - return false; - } - processedRootIds.add(rootId); - return true; - }); - - return { - roots: newRoots, - fetchCalls: constrainedRoots.fetchCalls, - totalFetched: constrainedRoots.totalFetched, - }; - } - - /** Enrich final roots with complete relation data */ - private async enrichFinalRoots(options: { - req: CrudRequestInterface; - relations: CrudRelationBindingInterface[]; - rootKey: string; - finalRoots: Root[]; - executionStrategy: ExecutionStrategy; - allRelationResults: RelationResult[]; - totalFetchCalls: number; - totalFetched: number; - }) { - const { req, relations, rootKey, finalRoots, executionStrategy } = options; - let { allRelationResults, totalFetchCalls, totalFetched } = options; - - if (finalRoots.length > 0) { - const enrichmentResult = await this.fetchRelationsForRoots({ - req, - relations, - roots: finalRoots, - rootKey, - executionStrategy, - }); - - // Replace constraint-filtered results with complete enrichment data - allRelationResults = enrichmentResult.allRelationResults; - totalFetchCalls += enrichmentResult.fetchCalls; - totalFetched += enrichmentResult.totalFetched; - } - - return { allRelationResults, totalFetchCalls, totalFetched }; - } - - /** Fetch relations for given root entities */ - private async fetchRelationsForRoots(options: { - req: CrudRequestInterface; - relations: CrudRelationBindingInterface[]; - roots: Root[]; - rootKey: string; - executionStrategy: ExecutionStrategy; - existingRelationResults?: RelationResult[]; - }): Promise<{ - allRelationResults: RelationResult[]; - fetchCalls: number; - totalFetched: number; - }> { - const { relations, roots, rootKey, executionStrategy } = options; - - // extract root IDs from the provided roots - const rootIds = roots.map((root) => root[rootKey]); - - if (relations.length === 0 || rootIds.length === 0) { - return { - allRelationResults: [], - fetchCalls: 0, - totalFetched: 0, - }; - } - - const relationPromises = relations.map( - async ( - relationBinding: CrudRelationBindingInterface, - ) => { - // Always make fresh service calls with proper constraints for enrichment - // Removed reuse logic to ensure correct constraint application - - // Extract constraint configuration and values based on relationship direction - const constraintConfig = this.getConstraintConfig( - relationBinding, - rootKey, - ); - const constraintValues = this.getConstraintValuesFromRoots( - roots, - constraintConfig, - ); - - // Execute standardized relation query - const result = await this.executeRelationQuery(relationBinding, { - executionStrategy, - constraintField: constraintConfig.field, - constraintValues, - }); - - return result.data; - }, - ); - - const relationArrays = await Promise.all(relationPromises); - const totalFetched = relationArrays.reduce( - (sum, arr) => sum + arr.length, - 0, - ); - - // Convert to the expected result structure - const allRelationResults = relations.map((config, index) => ({ - config, - data: relationArrays[index], - total: relationArrays[index].length, - })); - - return { - allRelationResults, - fetchCalls: relationPromises.length, - totalFetched, - }; - } - - /** Process relations sequentially, passing constraints from one to the next */ - private async processRelationsSequentially(options: { - relations: CrudRelationBindingInterface[]; - rootKey: string; - executionStrategy: ExecutionStrategy; - userPage: number; - bufferStrategy: BufferStrategy; - constraintRootIds: unknown[]; - }): Promise<{ - finalConstraintIds: unknown[]; - relationResults: RelationResult[]; - fetchCalls: number; - totalFetched: number; - isDrivingRelationExhausted: boolean; - relationTotal: number; - }> { - const { relations, rootKey, executionStrategy, userPage, bufferStrategy } = - options; - - // Get next batch parameters for offset-based pagination - const { limit: drivingRelationRequestedLimit, offset: relationOffset } = - bufferStrategy.advance(); - - let constraintRootIds = options.constraintRootIds; - let fetchCalls = 0; - let totalFetched = 0; - let isDrivingRelationExhausted = false; - let relationTotal = 0; - const relationResults: RelationResult[] = []; - - // Filter out owner relations from sequential processing - they cannot provide root ID constraints - // and will be handled in the enrichment phase after roots are fetched - const nonOwnerRelations = relations.filter( - (relationBinding) => !relationBinding.relation.owner, - ); - - for (let i = 0; i < nonOwnerRelations.length; i++) { - const relationBinding = nonOwnerRelations[i]; - const isDriving = relationBinding === executionStrategy.drivingRelation; - const isFirstRelation = i === 0; - - // Apply pagination to: - // 1. Driving relations (have relation sorts) - // 2. First relation when no driving relation exists (relation filters only) - const shouldApplyPagination = - isDriving || (!executionStrategy.drivingRelation && isFirstRelation); - - // Calculate correct offset for user pagination (page -> offset conversion) - // Only apply user pagination offset on the first iteration (relationOffset = 0) - // For subsequent iterations, use relationOffset from BufferStrategy - const userOffset = - isDriving && - isFirstRelation && - userPage && - drivingRelationRequestedLimit && - relationOffset === 0 // Only on first iteration - ? (userPage - 1) * drivingRelationRequestedLimit - : relationOffset; - - // Create relation request - const relationReq = this.createRelationRequest({ - executionStrategy, - relationBinding, - limit: shouldApplyPagination - ? drivingRelationRequestedLimit - : undefined, - offset: shouldApplyPagination ? userOffset : undefined, - sorts: isDriving - ? executionStrategy.sortAnalyzer.getRelationSorts() - : undefined, - }); - - // Apply constraints from previous relation - if (constraintRootIds.length > 0) { - FilterAnalyzer.addConstraintFilter( - relationReq, - rootKey, - constraintRootIds, - ); - } - - // Build search conditions - this.relationSearchHelper.buildSearch(relationReq, { - relation: relationBinding.relation, - }); - - // Execute relation query - const relationResult = await relationBinding.service.getMany(relationReq); - fetchCalls += 1; - - // Handle undefined or missing data - if (!relationResult || !relationResult.data) { - relationResults.push({ - config: relationBinding, - data: [], - total: 0, - }); - constraintRootIds = []; - break; - } - - totalFetched += relationResult.data.length; - - // Track total from driving relation or first relation for INNER JOIN - if (isDriving || (i === 0 && relationTotal === 0)) { - relationTotal = relationResult.total || 0; - } - - // Check if driving relation or first relation (when no driving relation) is exhausted - const hasPaginationApplied = - isDriving || (!executionStrategy.drivingRelation && isFirstRelation); - if ( - hasPaginationApplied && - relationResult.data.length < drivingRelationRequestedLimit - ) { - isDrivingRelationExhausted = true; - } - - // Extract root IDs from this relation to pass to next relation - // Skip constraint extraction for owner relationships as they cannot provide root IDs - if (!relationBinding.relation.owner) { - const rootIds = this.getRootIdsFromRelationData(relationResult.data, { - field: relationBinding.relation.foreignKey, - rootField: '', // Not used for extraction - isOwner: false, // Forward relationships only - }); - // Pass these root IDs to the next relation (progressive constraint) - constraintRootIds = rootIds; - } - // For owner relationships, keep existing constraintRootIds unchanged - - // Store relation result - relationResults.push({ - config: relationBinding, - data: relationResult.data, - total: relationResult.total, - }); - - // Early exit if no root IDs found - if (constraintRootIds.length === 0) { - break; - } - } - - return { - finalConstraintIds: constraintRootIds, - relationResults, - fetchCalls, - totalFetched, - isDrivingRelationExhausted, - relationTotal, - }; - } - - /** - * Get constraint configuration for a relation binding - */ - private getConstraintConfig( - relationBinding: CrudRelationBindingInterface, - rootKey: string, - ): ConstraintConfig { - const relation = relationBinding.relation; - - if (relation.owner) { - return { - field: relation.primaryKey, - rootField: relation.foreignKey, - isOwner: true, - }; - } else { - return { - field: relation.foreignKey, - rootField: rootKey, - isOwner: false, - }; - } - } - - /** - * Extract constraint values from roots for a specific relation - */ - private getConstraintValuesFromRoots( - roots: Root[], - constraintConfig: ConstraintConfig, - ): unknown[] { - if (constraintConfig.isOwner) { - // Owner relationship: extract foreign keys from roots and deduplicate - const foreignKeys = roots - .map((root) => root[constraintConfig.rootField]) - .filter((fk) => fk != null); - return [...new Set(foreignKeys)]; - } else { - // Forward relationship: extract root IDs (primary keys) - return roots.map((root) => root[constraintConfig.rootField]); - } - } - - /** - * Extract root IDs from relation data using constraint configuration - */ - private getRootIdsFromRelationData( - relationData: Relations[number][], - constraintConfig: ConstraintConfig, - ): unknown[] { - if (constraintConfig.isOwner) { - throw new CrudFederationException({ - message: - 'ASSERTION ERROR: getRootIdsFromRelationData called with owner relationship. ' + - 'Caller should filter out owner relationships before calling this method.', - }); - } - - // Forward relationship: Extract foreign keys as root IDs - const allRootIds = relationData.map( - (relationEntity) => relationEntity[constraintConfig.field], - ); - return [...new Set(allRootIds.filter((item) => item != null))]; - } -} - -// Internal types - -/** Join strategy types for different getMany approaches */ -enum JoinStrategyType { - ROOT_FIRST = 'ROOT_FIRST', - RELATION_FIRST = 'RELATION_FIRST', -} - -/** Constraint configuration for root/relation data extraction */ -interface ConstraintConfig { - field: string; // Field to use for constraints in relation - rootField: string; // Field to use for constraints in root - isOwner: boolean; // Whether this is an owner relationship -} - -/** Relation data with its configuration */ -type RelationResult< - Root extends PlainLiteralObject, - Relations extends PlainLiteralObject[], -> = { - config: CrudRelationBindingInterface; - data: Relations[number][]; - total?: number; -}; - -/** Sort configuration with its target (relation vs root) */ -interface SortConfiguration< - Root extends PlainLiteralObject, - Relations extends PlainLiteralObject[], -> { - field: string; - order: QuerySortOperator; - isRelationSort: boolean; - drivingRelation?: CrudRelationBindingInterface; -} - -/** Performance metrics for federation operations */ -interface FederationMetrics { - totalFetched: number; - fetchCalls: number; - duration: number; -} - -/** Standard federation result structure */ -interface FederationResult< - Root extends PlainLiteralObject, - Relations extends PlainLiteralObject[], -> { - resultRoots: Root[]; - allRelationResults: RelationResult[]; - accurateTotal: number; - fetchCalls: number; - totalFetched: number; -} diff --git a/packages/nestjs-crud/src/services/crud-reflection.service.ts b/packages/nestjs-crud/src/services/crud-reflection.service.ts deleted file mode 100644 index 354b648b9..000000000 --- a/packages/nestjs-crud/src/services/crud-reflection.service.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { Injectable, PlainLiteralObject } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -import { CrudActions } from '../crud/enums/crud-actions.enum'; -import { CrudApiParamMetadataInterface } from '../crud/interfaces/crud-api-param-metadata.interface'; -import { CrudApiQueryMetadataInterface } from '../crud/interfaces/crud-api-query-metadata.interface'; -import { CrudApiResponseMetadataInterface } from '../crud/interfaces/crud-api-response-metadata.interface'; -import { CrudModelOptionsInterface } from '../crud/interfaces/crud-model-options.interface'; -import { CrudOptionsInterface } from '../crud/interfaces/crud-options.interface'; -import { CrudParamsOptionsInterface } from '../crud/interfaces/crud-params-options.interface'; -import { - CrudCreateOneOptionsInterface, - CrudDeleteOneOptionsInterface, - CrudRecoverOneOptionsInterface, - CrudReplaceOneOptionsInterface, - CrudUpdateOneOptionsInterface, -} from '../crud/interfaces/crud-route-options.interface'; -import { CrudSerializationOptionsInterface } from '../crud/interfaces/crud-serialization-options.interface'; -import { CrudServiceQueryOptionsInterface } from '../crud/interfaces/crud-service-query-options.interface'; -import { CrudValidationMetadataInterface } from '../crud/interfaces/crud-validation-metadata.interface'; -import { - CRUD_MODULE_ROUTE_ACTION_METADATA, - CRUD_MODULE_ROUTE_CREATE_ONE_METADATA, - CRUD_MODULE_ROUTE_DELETE_ONE_METADATA, - CRUD_MODULE_ROUTE_MODEL_METADATA, - CRUD_MODULE_ROUTE_RECOVER_ONE_METADATA, - CRUD_MODULE_ROUTE_REPLACE_ONE_METADATA, - CRUD_MODULE_ROUTE_UPDATE_ONE_METADATA, - CRUD_MODULE_ROUTE_VALIDATION_METADATA, - CRUD_MODULE_ROUTE_PARAMS_METADATA, - CRUD_MODULE_ROUTE_QUERY_ALLOW_METADATA, - CRUD_MODULE_ROUTE_QUERY_EXCLUDE_METADATA, - CRUD_MODULE_ROUTE_QUERY_PERSIST_METADATA, - CRUD_MODULE_ROUTE_QUERY_FILTER_METADATA, - CRUD_MODULE_ROUTE_QUERY_SORT_METADATA, - CRUD_MODULE_ROUTE_QUERY_LIMIT_METADATA, - CRUD_MODULE_ROUTE_QUERY_MAX_LIMIT_METADATA, - CRUD_MODULE_ROUTE_QUERY_CACHE_METADATA, - CRUD_MODULE_ROUTE_QUERY_SOFT_DELETE_METADATA, - CRUD_MODULE_ROUTE_SERIALIZATION_METADATA, - CRUD_MODULE_PARAM_BODY_METADATA, - CRUD_MODULE_API_PARAMS_METADATA, - CRUD_MODULE_API_RESPONSE_METADATA, - CRUD_MODULE_API_QUERY_METADATA, - CRUD_MODULE_ROUTE_RELATIONS_METADATA, -} from '../crud.constants'; -import { - CrudValidationOptions, - ReflectionTargetOrHandler, -} from '../crud.types'; - -@Injectable() -export class CrudReflectionService< - Entity extends PlainLiteralObject = PlainLiteralObject, -> { - private reflector = new Reflector(); - - public getRequestOptions( - target: ReflectionTargetOrHandler, - handler: ReflectionTargetOrHandler, - ): CrudOptionsInterface { - return { - model: this.getAllModelOptions(target, handler), - - params: this.getAllParamOptions(handler, target) ?? { - id: { - field: 'id', - type: 'number', - primary: true, - }, - }, - - routes: { - createOne: { - returnShallow: false, - ...(this.reflector.get>( - CRUD_MODULE_ROUTE_CREATE_ONE_METADATA, - handler, - ) ?? {}), - }, - replaceOne: { - returnShallow: false, - ...(this.reflector.get>( - CRUD_MODULE_ROUTE_REPLACE_ONE_METADATA, - handler, - ) ?? {}), - }, - updateOne: { - returnShallow: false, - ...(this.reflector.get>( - CRUD_MODULE_ROUTE_UPDATE_ONE_METADATA, - handler, - ) ?? {}), - }, - deleteOne: { - returnDeleted: false, - ...(this.reflector.get>( - CRUD_MODULE_ROUTE_DELETE_ONE_METADATA, - handler, - ) ?? {}), - }, - recoverOne: { - returnRecovered: false, - ...(this.reflector.get>( - CRUD_MODULE_ROUTE_RECOVER_ONE_METADATA, - handler, - ) ?? {}), - }, - }, - - query: { - allow: this.reflector.getAllAndOverride< - CrudServiceQueryOptionsInterface['allow'] - >(CRUD_MODULE_ROUTE_QUERY_ALLOW_METADATA, [handler, target]), - - exclude: this.reflector.getAllAndOverride< - CrudServiceQueryOptionsInterface['exclude'] - >(CRUD_MODULE_ROUTE_QUERY_EXCLUDE_METADATA, [handler, target]), - - persist: this.reflector.getAllAndOverride< - CrudServiceQueryOptionsInterface['persist'] - >(CRUD_MODULE_ROUTE_QUERY_PERSIST_METADATA, [handler, target]), - - filter: - this.reflector.getAllAndOverride< - CrudServiceQueryOptionsInterface['filter'] - >(CRUD_MODULE_ROUTE_QUERY_FILTER_METADATA, [handler, target]) ?? {}, - - sort: this.reflector.getAllAndOverride< - CrudServiceQueryOptionsInterface['sort'] - >(CRUD_MODULE_ROUTE_QUERY_SORT_METADATA, [handler, target]), - - limit: this.reflector.getAllAndOverride< - CrudServiceQueryOptionsInterface['limit'] - >(CRUD_MODULE_ROUTE_QUERY_LIMIT_METADATA, [handler, target]), - - maxLimit: this.reflector.getAllAndOverride< - CrudServiceQueryOptionsInterface['maxLimit'] - >(CRUD_MODULE_ROUTE_QUERY_MAX_LIMIT_METADATA, [handler, target]), - - cache: this.reflector.getAllAndOverride< - CrudServiceQueryOptionsInterface['cache'] - >(CRUD_MODULE_ROUTE_QUERY_CACHE_METADATA, [handler, target]), - - softDelete: this.reflector.getAllAndOverride< - CrudServiceQueryOptionsInterface['softDelete'] - >(CRUD_MODULE_ROUTE_QUERY_SOFT_DELETE_METADATA, [handler, target]), - - relations: this.reflector.getAllAndOverride< - CrudServiceQueryOptionsInterface['relations'] - >(CRUD_MODULE_ROUTE_RELATIONS_METADATA, [handler, target]), - }, - }; - } - - public getAction(handler: ReflectionTargetOrHandler): CrudActions { - return this.reflector.get( - CRUD_MODULE_ROUTE_ACTION_METADATA, - handler, - ); - } - - public getAllModelOptions( - target: ReflectionTargetOrHandler, - handler: ReflectionTargetOrHandler, - ) { - return this.reflector.getAllAndOverride( - CRUD_MODULE_ROUTE_MODEL_METADATA, - [handler, target], - ); - } - - public getAllParamOptions( - target: ReflectionTargetOrHandler, - handler: ReflectionTargetOrHandler, - ): CrudParamsOptionsInterface { - return this.reflector.getAllAndOverride>( - CRUD_MODULE_ROUTE_PARAMS_METADATA, - [handler, target], - ); - } - - public getValidationOptions( - target: ReflectionTargetOrHandler, - ): CrudValidationOptions { - return this.reflector.get(CRUD_MODULE_ROUTE_VALIDATION_METADATA, target); - } - - public getBodyParamOptions(target: ReflectionTargetOrHandler) { - return this.reflector.get[]>( - CRUD_MODULE_PARAM_BODY_METADATA, - target, - ); - } - - public getAllSerializationOptions( - target: ReflectionTargetOrHandler, - handler: ReflectionTargetOrHandler, - ): CrudSerializationOptionsInterface { - return this.reflector.getAllAndOverride( - CRUD_MODULE_ROUTE_SERIALIZATION_METADATA, - [handler, target], - ); - } - - public getApiQueryOptions(target: ReflectionTargetOrHandler) { - return this.reflector.get( - CRUD_MODULE_API_QUERY_METADATA, - target, - ); - } - - public getApiParamsOptions(target: ReflectionTargetOrHandler) { - return this.reflector.get( - CRUD_MODULE_API_PARAMS_METADATA, - target, - ); - } - - public getApiResponseOptions(target: ReflectionTargetOrHandler) { - return this.reflector.get( - CRUD_MODULE_API_RESPONSE_METADATA, - target, - ); - } -} diff --git a/packages/nestjs-crud/src/services/crud-relation.registry.ts b/packages/nestjs-crud/src/services/crud-relation.registry.ts deleted file mode 100644 index c67c5b3ec..000000000 --- a/packages/nestjs-crud/src/services/crud-relation.registry.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { CrudException } from '../exceptions/crud.exception'; -import { QueryRelation } from '../request/types/crud-request-query.types'; - -import { CrudFetchServiceInterface } from './interfaces/crud-fetch-service.interface'; -import { CrudRelationBindingInterface } from './interfaces/crud-relation-binding.interface'; - -/** - * Registry to manage relation configuration mappings between root and relation entities. - * - * This class provides functionality to register CRUD services for relation entities - * and convert internal bindings to the registry format required by federation services. - * It acts as a bridge between service configuration and runtime service resolution. - */ -export class CrudRelationRegistry< - Entity extends PlainLiteralObject, - Relations extends PlainLiteralObject[], -> { - private services: CrudFetchServiceInterface[] = []; - - /** - * Gets a service by constructor type. - * - * @param serviceType - The service constructor to find - * @returns The matching service instance or undefined - */ - private getService( - serviceType: NewableFunction, - ): CrudFetchServiceInterface | undefined { - return this.services.find((svc) => svc.constructor === serviceType); - } - - /** - * Registers a relation service. - * Ensures only one instance per service class type is registered. - * - * @param service - The CRUD service capable of fetching the relation entities - */ - register(service: CrudFetchServiceInterface): void { - if (!this.getService(service.constructor)) { - this.services.push(service); - } - } - - /** - * Gets relation bindings for the specified relations. - * - * @param relations - Array of relation configurations to get bindings for - * @returns Array of relation bindings with services and relation metadata - */ - getBindings( - relations: QueryRelation[], - ): CrudRelationBindingInterface[] { - return relations.map((relation) => { - const service = this.getService(relation.service); - if (!service) { - throw new CrudException({ - message: 'Relation service not found for relation service type: %s', - messageParams: [relation.service.name], - }); - } - // Return a new binding that uses the relation from the request - // but the service from the registry - return { relation, service }; - }); - } -} diff --git a/packages/nestjs-crud/src/services/crud.service.e2e-spec.ts b/packages/nestjs-crud/src/services/crud.service.e2e-spec.ts deleted file mode 100644 index e9d8a6c97..000000000 --- a/packages/nestjs-crud/src/services/crud.service.e2e-spec.ts +++ /dev/null @@ -1,184 +0,0 @@ -import supertest from 'supertest'; - -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { AppModuleFixture } from '../__fixtures__/app.module.fixture'; -import { PhotoFixture } from '../__fixtures__/photo/photo.entity.fixture'; -import { PhotoFactoryFixture } from '../__fixtures__/photo/photo.factory.fixture'; -import { PhotoSeederFixture } from '../__fixtures__/photo/photo.seeder.fixture'; - -describe('AppController (e2e)', () => { - describe('Authentication', () => { - let app: INestApplication; - let seedingSource: SeedingSource; - - let photoFactory: PhotoFactoryFixture; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - - const dataSource = app.get(getDataSourceToken()); - seedingSource = new SeedingSource({ dataSource }); - await seedingSource.initialize(); - photoFactory = new PhotoFactoryFixture({ seedingSource }); - await seedingSource.run.one(PhotoSeederFixture); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - it('GET /photo?limit=10', async () => { - const response = await supertest(app.getHttpServer()) - .get('/photo?limit=10') - .expect(200); - - expect(response.body).toBeInstanceOf(Object); - expect(response.body.data).toBeInstanceOf(Array); - expect(response.body.data.length).toEqual(10); - }); - - it('GET /photo?limit=10&page=1', async () => { - const response = await supertest(app.getHttpServer()) - .get('/photo?limit=10&page=1') - .expect(200); - - expect(response.body).toBeInstanceOf(Object); - expect(response.body.data).toBeInstanceOf(Array); - expect(response.body.data.length).toEqual(10); - expect(response.body.page).toEqual(1); - expect(response.body.pageCount).toEqual(2); - expect(response.body.count).toEqual(10); - expect(response.body.total).toEqual(15); - expect(typeof response.body.data[0].id).toEqual('string'); - }); - - it('GET /photo/:id', async () => { - const photo = await photoFactory.create(); - expect(photo).toBeInstanceOf(PhotoFixture); - - const response = await supertest(app.getHttpServer()) - .get(`/photo/${photo.id}`) - .expect(200); - - expect(response.body).toBeInstanceOf(Object); - }); - - it('POST /photo', async () => { - const photo = await photoFactory.make(); - - const newPhoto: Partial> & - Omit = photo; - - delete newPhoto.id; - - const response = await supertest(app.getHttpServer()) - .post('/photo') - .send(newPhoto) - .expect(201); - - expect(response.body).toBeInstanceOf(Object); - expect(typeof response.body.id).toEqual('string'); - }); - - it('POST /photo/bulk', async () => { - const photos = await photoFactory.createMany(5); - - const response = await supertest(app.getHttpServer()) - .post('/photo/bulk') - .send({ - bulk: photos, - }) - .expect(201); - - expect(response.body).toBeInstanceOf(Array); - expect(response.body.length).toEqual(5); - }); - - it('PATCH /photo/:id', async () => { - const photo = await photoFactory.create(); - expect(photo).toBeInstanceOf(PhotoFixture); - photo.views = 37; - - const { id, ...rest } = { ...photo }; - - const response = await supertest(app.getHttpServer()) - .patch(`/photo/${id}`) - .send(rest) - .expect(200); - - expect(response.body).toMatchObject(photo); - expect(response.body.views).toEqual(37); - }); - - it('PUT /photo/:id', async () => { - const photo = await photoFactory.create(); - expect(photo).toBeInstanceOf(PhotoFixture); - - const { id, ...rest } = { ...photo }; - - const response = await supertest(app.getHttpServer()) - .put(`/photo/${id}`) - .send(rest) - .expect(200); - - expect(response.body).toMatchObject(photo); - }); - - it('DELETE /photo/1', async () => { - const photo = await photoFactory.create(); - expect(photo).toBeInstanceOf(PhotoFixture); - - await supertest(app.getHttpServer()) - .delete(`/photo/${photo.id}`) - .expect(200); - - await supertest(app.getHttpServer()) - .get(`/photo/${photo.id}`) - .expect(404); - }); - - it('DELETE /photo/soft/1', async () => { - const photo = await photoFactory.create(); - expect(photo).toBeInstanceOf(PhotoFixture); - - await supertest(app.getHttpServer()) - .delete(`/photo/soft/${photo.id}`) - .expect(200); - - await supertest(app.getHttpServer()) - .get(`/photo/${photo.id}`) - .expect(404); - }); - - it('PATCH /photo/recover/1', async () => { - const photo = await photoFactory.create(); - expect(photo).toBeInstanceOf(PhotoFixture); - - await supertest(app.getHttpServer()) - .delete(`/photo/soft/${photo.id}`) - .expect(200); - - await supertest(app.getHttpServer()) - .get(`/photo/${photo.id}`) - .expect(404); - - await supertest(app.getHttpServer()) - .patch(`/photo/recover/${photo.id}`) - .expect(200); - - await supertest(app.getHttpServer()) - .get(`/photo/${photo.id}`) - .expect(200); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/crud.service.spec.ts b/packages/nestjs-crud/src/services/crud.service.spec.ts deleted file mode 100644 index ff8d62da1..000000000 --- a/packages/nestjs-crud/src/services/crud.service.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { mock } from 'jest-mock-extended'; -import { Repository } from 'typeorm'; - -import { Inject, Injectable, Type } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; - -import { TypeOrmCrudAdapter } from '../crud/adapters/typeorm-crud.adapter'; -import { CrudCreateManyInterface } from '../crud/interfaces/crud-create-many.interface'; -import { CrudRequestInterface } from '../crud/interfaces/crud-request.interface'; -import { CrudServiceQueryOptionsInterface } from '../crud/interfaces/crud-service-query-options.interface'; - -import { CrudService } from './crud.service'; -import { CrudQueryHelper } from './helpers/crud-query.helper'; - -jest.mock('../crud/adapters/typeorm-crud.adapter'); - -describe(CrudService.name, () => { - // fake entity/repo - class Thing { - name!: string; - } - - class ThingRepository extends Repository {} - - // test orm service - @Injectable() - class TestCrudAdapter extends TypeOrmCrudAdapter {} - - class TestCrudService extends CrudService { - constructor( - @Inject(TestCrudAdapter) - protected readonly crudAdapter: TestCrudAdapter, - ) { - super(crudAdapter); - } - } - - let ormService: TestCrudService; - let mockRequest: CrudRequestInterface; - let mockOverrides: CrudServiceQueryOptionsInterface; - - beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ - providers: [ - TestCrudAdapter, - TestCrudService, - CrudQueryHelper, - { provide: Repository, useValue: mock(ThingRepository) }, - ], - }).compile(); - - ormService = moduleRef.get(TestCrudService); - - mockRequest = { - options: {}, - parsed: { search: { name: 'apple' } }, - } as unknown as CrudRequestInterface; - - mockOverrides = { - filter: { name: 'pear' }, - }; - }); - - afterEach(async () => { - jest.resetAllMocks(); - }); - - describe('simple crud methods', () => { - type SingleArg = keyof Pick< - TestCrudService, - 'getMany' | 'getOne' | 'deleteOne' - >; - - const crudMethods: SingleArg[] = ['getMany', 'getOne', 'deleteOne']; - - it.each(crudMethods)( - '%s should use custom options', - async (crudMethod: SingleArg) => { - const spy = jest.spyOn(TypeOrmCrudAdapter.prototype, crudMethod); - - await ormService[crudMethod](mockRequest, mockOverrides); - - expect(spy).toHaveBeenCalledTimes(1); - - expect(spy).toHaveBeenCalledWith({ - options: { query: {} }, - parsed: { - search: { $and: [{ name: 'apple' }, { name: 'pear' }] }, - }, - }); - }, - ); - }); - - describe('complex crud methods (have dto argument)', () => { - type DoubleArg = keyof Pick< - TestCrudService, - 'createMany' | 'createOne' | 'updateOne' | 'replaceOne' - >; - - const crudMethods: DoubleArg[] = [ - 'createMany', - 'createOne', - 'updateOne', - 'replaceOne', - ]; - - it.each(crudMethods)( - '%s should use custom options', - async (crudMethod: DoubleArg) => { - const spy = jest.spyOn(TypeOrmCrudAdapter.prototype, crudMethod); - - let dto: Type | CrudCreateManyInterface>; - - if (crudMethod === 'createMany') { - dto = { bulk: [class extends Thing {}] }; - await ormService[crudMethod](mockRequest, dto, mockOverrides); - } else { - dto = class extends Thing {}; - await ormService[crudMethod](mockRequest, dto, mockOverrides); - } - - expect(spy).toHaveBeenCalledTimes(1); - - expect(spy).toHaveBeenCalledWith( - { - options: { query: {} }, - parsed: { - search: { $and: [{ name: 'apple' }, { name: 'pear' }] }, - }, - }, - dto, - ); - }, - ); - }); -}); diff --git a/packages/nestjs-crud/src/services/crud.service.ts b/packages/nestjs-crud/src/services/crud.service.ts deleted file mode 100644 index 1f12180d2..000000000 --- a/packages/nestjs-crud/src/services/crud.service.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Injectable, PlainLiteralObject } from '@nestjs/common'; - -import { CrudAdapter } from '../crud/adapters/crud.adapter'; -import { CrudRequestInterface } from '../crud/interfaces/crud-request.interface'; -import { CrudResponsePaginatedInterface } from '../crud/interfaces/crud-response-paginated.interface'; -import { CrudServiceQueryOptionsInterface } from '../crud/interfaces/crud-service-query-options.interface'; -import { CrudQueryException } from '../exceptions/crud-query.exception'; - -import { CrudFederationService } from './crud-federation.service'; -import { CrudRelationRegistry } from './crud-relation.registry'; -import { CrudQueryHelper } from './helpers/crud-query.helper'; -import { CrudSearchHelper } from './helpers/crud-search.helper'; -import { CrudFetchServiceInterface } from './interfaces/crud-fetch-service.interface'; - -@Injectable() -export class CrudService< - Entity extends PlainLiteralObject, - Relations extends PlainLiteralObject[] = PlainLiteralObject[], -> implements CrudFetchServiceInterface -{ - protected readonly federationService: CrudFederationService< - Entity, - Relations - >; - - constructor( - protected crudAdapter: CrudAdapter, - protected relationRegistry?: CrudRelationRegistry, - ) { - // Create federation service with dependencies - this.federationService = new CrudFederationService( - this.crudAdapter, - this.relationRegistry, - ); - } - - protected readonly crudQueryHelper: CrudQueryHelper = - new CrudQueryHelper(); - - protected readonly crudSearchHelper: CrudSearchHelper = - new CrudSearchHelper(); - - async getMany( - req: CrudRequestInterface, - queryOptions?: CrudServiceQueryOptionsInterface, - ): Promise> { - // apply options - this.crudQueryHelper.modifyRequest(req, queryOptions); - - // get root result - try { - // Use federated service if relations are configured - if (this.hasRelations(req)) { - return await this.federationService.getMany(req); - } else { - // build search conditions - this.crudSearchHelper.buildSearch(req); - return await this.crudAdapter.getMany(req); - } - } catch (e) { - throw new CrudQueryException(this.crudAdapter.entityName(), { - originalError: e, - }); - } - } - - async getOne( - req: CrudRequestInterface, - queryOptions?: CrudServiceQueryOptionsInterface, - ): ReturnType['getOne']> { - // apply options - this.crudQueryHelper.modifyRequest(req, queryOptions); - - // return root result - try { - // check if relations are requested - if (this.hasRelations(req)) { - return this.federationService.getOne(req); - } else { - // build search conditions - this.crudSearchHelper.buildSearch(req); - return this.crudAdapter.getOne(req); - } - } catch (e) { - throw new CrudQueryException(this.crudAdapter.entityName(), { - originalError: e, - }); - } - } - - async createMany( - req: CrudRequestInterface, - dto: Parameters['createMany']>[1], - queryOptions?: CrudServiceQueryOptionsInterface, - ): ReturnType['createMany']> { - // apply options - this.crudQueryHelper.modifyRequest(req, queryOptions); - // build search conditions - this.crudSearchHelper.buildSearch(req); - // return root result - try { - return this.crudAdapter.createMany(req, dto); - } catch (e) { - throw new CrudQueryException(this.crudAdapter.entityName(), { - originalError: e, - }); - } - } - - async createOne( - req: CrudRequestInterface, - dto: Parameters['createOne']>[1], - queryOptions?: CrudServiceQueryOptionsInterface, - ): ReturnType['createOne']> { - // apply options - this.crudQueryHelper.modifyRequest(req, queryOptions); - // build search conditions - this.crudSearchHelper.buildSearch(req); - // return root result - try { - return this.crudAdapter.createOne(req, dto); - } catch (e) { - throw new CrudQueryException(this.crudAdapter.entityName(), { - originalError: e, - }); - } - } - - async updateOne( - req: CrudRequestInterface, - dto: Parameters['updateOne']>[1], - queryOptions?: CrudServiceQueryOptionsInterface, - ): ReturnType['updateOne']> { - // apply options - this.crudQueryHelper.modifyRequest(req, queryOptions); - // build search conditions - this.crudSearchHelper.buildSearch(req); - // return root result - try { - return this.crudAdapter.updateOne(req, dto); - } catch (e) { - throw new CrudQueryException(this.crudAdapter.entityName(), { - originalError: e, - }); - } - } - - async replaceOne( - req: CrudRequestInterface, - dto: Parameters['replaceOne']>[1], - queryOptions?: CrudServiceQueryOptionsInterface, - ): ReturnType['replaceOne']> { - // apply options - this.crudQueryHelper.modifyRequest(req, queryOptions); - // build search conditions - this.crudSearchHelper.buildSearch(req); - // return root result - try { - return this.crudAdapter.replaceOne(req, dto); - } catch (e) { - throw new CrudQueryException(this.crudAdapter.entityName(), { - originalError: e, - }); - } - } - - async deleteOne( - req: CrudRequestInterface, - queryOptions?: CrudServiceQueryOptionsInterface, - ): ReturnType['deleteOne']> { - // apply options - this.crudQueryHelper.modifyRequest(req, queryOptions); - // build search conditions - this.crudSearchHelper.buildSearch(req); - // return root result - try { - return this.crudAdapter.deleteOne(req); - } catch (e) { - throw new CrudQueryException(this.crudAdapter.entityName(), { - originalError: e, - }); - } - } - - async recoverOne( - req: CrudRequestInterface, - queryOptions?: CrudServiceQueryOptionsInterface, - ): ReturnType['recoverOne']> { - // apply options - this.crudQueryHelper.modifyRequest(req, queryOptions); - // build search conditions - this.crudSearchHelper.buildSearch(req); - // return root result - try { - return this.crudAdapter.recoverOne(req); - } catch (e) { - throw new CrudQueryException(this.crudAdapter.entityName(), { - originalError: e, - }); - } - } - - protected hasRelations(req: CrudRequestInterface): boolean { - // check if relations are configured and present - const relations = req.options?.query?.relations?.relations ?? []; - return relations.length > 0; - } -} diff --git a/packages/nestjs-crud/src/services/helpers/crud-query.helper.spec.ts b/packages/nestjs-crud/src/services/helpers/crud-query.helper.spec.ts deleted file mode 100644 index 73c337b12..000000000 --- a/packages/nestjs-crud/src/services/helpers/crud-query.helper.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Test } from '@nestjs/testing'; - -import { CrudQueryOptionsInterface } from '../../crud/interfaces/crud-query-options.interface'; -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; -import { CrudServiceQueryOptionsInterface } from '../../crud/interfaces/crud-service-query-options.interface'; -import { SCondition } from '../../request/types/crud-request-query.types'; - -import { CrudQueryHelper } from './crud-query.helper'; - -class TestEntity { - name!: string; -} - -describe('CrudQueryHelper', () => { - let crudQueryService: CrudQueryHelper; - - beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ - providers: [CrudQueryHelper], - }).compile(); - - crudQueryService = - moduleRef.get>(CrudQueryHelper); - }); - - describe('IsDefined', () => { - it('was CrudQueryService defined', async () => { - expect(crudQueryService).toBeDefined(); - }); - }); - - describe('modifyRequest', () => { - describe('when adding search', () => { - it('should add search to existing conditions', async () => { - // the fake request - const req = { parsed: {} } as CrudRequestInterface; - - req.parsed.search = { - name: 'apple', - }; - - const options: CrudServiceQueryOptionsInterface = { - filter: { - name: 'pear', - }, - }; - - crudQueryService.modifyRequest(req, options); - - expect(req.parsed.search).toEqual>({ - $and: [ - { - name: 'apple', - }, - { - name: 'pear', - }, - ], - }); - }); - - it('should directly assign search when rootSearch is empty', async () => { - // the fake request with undefined search - const req = { parsed: {} } as CrudRequestInterface; - - const options: CrudServiceQueryOptionsInterface = { - filter: { - name: 'pear', - }, - }; - - crudQueryService.modifyRequest(req, options); - - // Should directly assign without creating empty $and array - expect(req.parsed.search).toEqual>({ - name: 'pear', - }); - }); - - it('should directly assign search when rootSearch is empty object', async () => { - // the fake request with empty search object - const req = { - parsed: { search: {} }, - } as CrudRequestInterface; - - const options: CrudServiceQueryOptionsInterface = { - filter: { - name: 'pear', - }, - }; - - crudQueryService.modifyRequest(req, options); - - // Should directly assign without creating empty $and array - expect(req.parsed.search).toEqual>({ - name: 'pear', - }); - }); - }); - - describe('when adding options', () => { - it('should add options', async () => { - // the fake request - const req = { - options: {}, - } as CrudRequestInterface; - - const options: CrudServiceQueryOptionsInterface = { - cache: false, - }; - - crudQueryService.modifyRequest(req, options); - - expect(req.options.query).toEqual< - CrudQueryOptionsInterface - >({ - cache: false, - }); - }); - }); - }); -}); diff --git a/packages/nestjs-crud/src/services/helpers/crud-query.helper.ts b/packages/nestjs-crud/src/services/helpers/crud-query.helper.ts deleted file mode 100644 index 5e52215dc..000000000 --- a/packages/nestjs-crud/src/services/helpers/crud-query.helper.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Injectable, PlainLiteralObject } from '@nestjs/common'; -import { isUndefined } from '@nestjs/common/utils/shared.utils'; - -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; -import { CrudServiceQueryOptionsInterface } from '../../crud/interfaces/crud-service-query-options.interface'; -import { SCondition } from '../../request/types/crud-request-query.types'; - -@Injectable() -export class CrudQueryHelper { - createRequest< - T extends PlainLiteralObject = Entity, - >(): CrudRequestInterface { - return { - parsed: { - search: undefined, - sort: [], - fields: [], - limit: undefined, - offset: undefined, - page: undefined, - paramsFilter: [], - classTransformOptions: {}, - filter: [], - or: [], - cache: undefined, - includeDeleted: undefined, - }, - options: {}, - }; - } - - modifyRequest( - req: CrudRequestInterface, - options?: CrudServiceQueryOptionsInterface, - ) { - // get any options? - if (options) { - // deconstruct - const { filter, ...rest } = options; - // merge the options - this.mergeOptions(req, rest); - // add filters to search - if (filter) { - this.addSearch(req, filter); - } - } - } - - mergeOptions( - req: CrudRequestInterface, - options: Omit, 'filter'>, - ) { - // already have options on request? - if (req.options) { - // yes, merge them - req.options.query = { - ...req.options?.query, - ...options, - }; - } else { - // no, set the property - req.options = { - query: options, - }; - } - } - - addSearch( - req: CrudRequestInterface, - search?: SCondition | SCondition[], - ) { - if (search) { - if (isUndefined(req.parsed.search)) { - req.parsed.search = {}; - } - return this.combineSearch(req.parsed.search, search); - } - } - - combineSearch( - rootSearch: SCondition, - search: SCondition | SCondition[], - ) { - // handle array recursively - if (Array.isArray(search)) { - for (const searchItem of search) { - this.combineSearch(rootSearch, searchItem); - } - return; - } - - // skip empty searches - if (!search || Object.keys(search).length === 0) { - return; - } - - if (Array.isArray(rootSearch?.$and)) { - if (Array.isArray(search?.$and)) { - rootSearch.$and.push(...search.$and); - } else { - rootSearch.$and.push(search); - } - } else { - const hasExistingConditions = Object.keys(rootSearch).length > 0; - - if (hasExistingConditions) { - const { ...fields } = rootSearch; - for (const key of Object.keys(rootSearch)) { - delete rootSearch[key]; - } - rootSearch.$and = [fields, search]; - } else { - // Directly assign search properties when rootSearch is empty - Object.assign(rootSearch, search); - } - } - } -} diff --git a/packages/nestjs-crud/src/services/helpers/crud-search.helper.ts b/packages/nestjs-crud/src/services/helpers/crud-search.helper.ts deleted file mode 100644 index 810b51e74..000000000 --- a/packages/nestjs-crud/src/services/helpers/crud-search.helper.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Injectable, PlainLiteralObject } from '@nestjs/common'; - -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; -import { convertFilterToSearch } from '../../request/crud-request.utils'; -import { - SCondition, - QueryRelation, -} from '../../request/types/crud-request-query.types'; - -/** - * Helper service for building search conditions from parsed CRUD requests - */ -@Injectable() -export class CrudSearchHelper { - /** - * Build final search conditions from parsed request data - */ - buildSearch( - req: CrudRequestInterface, - options?: { relation?: QueryRelation }, - ): void { - const { relation } = options || {}; - const searchConditions = this.getSearchConditions(req, relation); - - req.parsed.search = - searchConditions.length === 0 - ? undefined - : searchConditions.length === 1 - ? searchConditions[0] - : { $and: searchConditions }; - } - - /** - * Get all search conditions from various sources - */ - private getSearchConditions( - req: CrudRequestInterface, - relation?: QueryRelation, - ): SCondition[] { - const { parsed, options } = req; - - // params condition - const paramsSearch = this.getParamsSearch(req); - - // if `CrudOptions.query.filter` is array or search condition type - const optionsFilter = - options?.query?.filter !== undefined && - Array.isArray(options.query.filter) && - options.query.filter.length - ? options.query.filter.map(convertFilterToSearch) - : options?.query?.filter - ? [options.query.filter as SCondition] - : []; - - let search: SCondition[] = []; - - // Match filters where relation property equals the target (undefined matches undefined) - const relationProperty = relation?.property ?? undefined; - const applicableFilters = (parsed.filter || []).filter( - (f) => f.relation === relationProperty, - ); - const applicableOrs = (parsed.or || []).filter( - (f) => f.relation === relationProperty, - ); - - if (parsed.search) { - search = [parsed.search]; - } else if (applicableFilters.length && applicableOrs.length) { - search = - applicableFilters.length === 1 && applicableOrs.length === 1 - ? [ - { - $or: [ - convertFilterToSearch(applicableFilters[0]), - convertFilterToSearch(applicableOrs[0]), - ], - }, - ] - : [ - { - $or: [ - { $and: applicableFilters.map(convertFilterToSearch) }, - { $and: applicableOrs.map(convertFilterToSearch) }, - ], - }, - ]; - } else if (applicableFilters.length) { - search = applicableFilters.map(convertFilterToSearch); - } else { - if (applicableOrs.length) { - search = - applicableOrs.length === 1 - ? [convertFilterToSearch(applicableOrs[0])] - : /* istanbul ignore next */ [ - { - $or: applicableOrs.map(convertFilterToSearch), - }, - ]; - } - } - - return [...paramsSearch, ...optionsFilter, ...search]; - } - - /** - * Get search conditions from params - */ - private getParamsSearch( - req: CrudRequestInterface, - ): SCondition[] { - const { parsed } = req; - - return Array.isArray(parsed.paramsFilter) && parsed.paramsFilter.length - ? parsed.paramsFilter.map(convertFilterToSearch) - : []; - } -} diff --git a/packages/nestjs-crud/src/services/interfaces/crud-federation-fetch-options.interface.ts b/packages/nestjs-crud/src/services/interfaces/crud-federation-fetch-options.interface.ts deleted file mode 100644 index 6564bf32a..000000000 --- a/packages/nestjs-crud/src/services/interfaces/crud-federation-fetch-options.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface CrudFederationFetchOptionsInterface { - includeMetrics?: boolean; -} diff --git a/packages/nestjs-crud/src/services/interfaces/crud-fetch-service.interface.ts b/packages/nestjs-crud/src/services/interfaces/crud-fetch-service.interface.ts deleted file mode 100644 index 871721ff6..000000000 --- a/packages/nestjs-crud/src/services/interfaces/crud-fetch-service.interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { CrudRequestInterface } from '../../crud/interfaces/crud-request.interface'; -import { CrudResponsePaginatedInterface } from '../../crud/interfaces/crud-response-paginated.interface'; - -/** - * Interface for services that can fetch entities using CRUD requests - */ -export interface CrudFetchServiceInterface< - Entity extends PlainLiteralObject = PlainLiteralObject, -> { - getMany( - req: CrudRequestInterface, - ): Promise>; - - getOne(req: CrudRequestInterface): Promise; -} diff --git a/packages/nestjs-crud/src/services/interfaces/crud-relation-binding.interface.ts b/packages/nestjs-crud/src/services/interfaces/crud-relation-binding.interface.ts deleted file mode 100644 index a919db630..000000000 --- a/packages/nestjs-crud/src/services/interfaces/crud-relation-binding.interface.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { QueryRelation } from '../../request/types/crud-request-query.types'; - -import { CrudFetchServiceInterface } from './crud-fetch-service.interface'; - -/** - * Represents a binding between a CRUD service and a relation configuration. - * - * This interface combines a service instance with its corresponding relation metadata - * to enable federated data fetching across related entities. Each binding represents - * a complete configuration for fetching and enriching data from a relation entity. - */ -export interface CrudRelationBindingInterface< - Root extends PlainLiteralObject, - Relation extends PlainLiteralObject, -> { - /** The CRUD service responsible for fetching relation entities */ - service: CrudFetchServiceInterface; - /** The relation configuration defining how root and relation entities are connected */ - relation: QueryRelation; -} diff --git a/packages/nestjs-crud/src/util/configurable-crud.builder.e2e-spec.ts b/packages/nestjs-crud/src/util/configurable-crud.builder.e2e-spec.ts deleted file mode 100644 index 9fc6884a3..000000000 --- a/packages/nestjs-crud/src/util/configurable-crud.builder.e2e-spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import supertest from 'supertest'; - -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { AppCcbCustomModuleFixture } from '../__fixtures__/app-ccb-custom.module.fixture'; -import { AppCcbSubModuleFixture } from '../__fixtures__/app-ccb-sub.module.fixture'; -import { AppCcbUseClassModuleFixture } from '../__fixtures__/app-ccb-useclass.module.fixture'; -import { AppCcbModuleFixture } from '../__fixtures__/app-ccb.module.fixture'; -import { PhotoFixture } from '../__fixtures__/photo/photo.entity.fixture'; -import { PhotoFactoryFixture } from '../__fixtures__/photo/photo.factory.fixture'; -import { PhotoSeederFixture } from '../__fixtures__/photo/photo.seeder.fixture'; - -describe.each([ - { testModule: AppCcbModuleFixture }, - { testModule: AppCcbCustomModuleFixture }, - { testModule: AppCcbSubModuleFixture }, - { testModule: AppCcbUseClassModuleFixture }, -])('Configurable Crud Builder (e2e)', ({ testModule }) => { - let app: INestApplication; - let seedingSource: SeedingSource; - - let photoFactory: PhotoFactoryFixture; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [testModule], - }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - - const dataSource = app.get(getDataSourceToken()); - seedingSource = new SeedingSource({ dataSource }); - await seedingSource.initialize(); - photoFactory = new PhotoFactoryFixture({ seedingSource }); - await seedingSource.run.one(PhotoSeederFixture); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - it('GET /photo?limit=10', async () => { - const response = await supertest(app.getHttpServer()) - .get('/photo?limit=10') - .expect(200); - - expect(response.body.data).toBeInstanceOf(Object); - expect(response.body.data).toBeInstanceOf(Array); - expect(response.body.data.length).toEqual(10); - }); - - it('GET /photo?limit=10&page=1', async () => { - const response = await supertest(app.getHttpServer()) - .get('/photo?limit=10&page=1') - .expect(200); - - expect(response.body).toBeInstanceOf(Object); - expect(response.body.data).toBeInstanceOf(Array); - expect(response.body.data.length).toEqual(10); - expect(response.body.page).toEqual(1); - expect(response.body.pageCount).toEqual(2); - expect(response.body.count).toEqual(10); - expect(response.body.total).toEqual(15); - expect(typeof response.body.data[0].id).toEqual('string'); - }); - - it('GET /photo/:id', async () => { - const photo = await photoFactory.create(); - expect(photo).toBeInstanceOf(PhotoFixture); - - const response = await supertest(app.getHttpServer()) - .get(`/photo/${photo.id}`) - .expect(200); - - expect(response.body).toBeInstanceOf(Object); - }); - - it('POST /photo', async () => { - const photo = await photoFactory.make(); - - const newPhoto: Partial> & - Omit = photo; - - delete newPhoto.id; - - const response = await supertest(app.getHttpServer()) - .post('/photo') - .send(newPhoto) - .expect(201); - - expect(response.body).toBeInstanceOf(Object); - expect(typeof response.body.id).toEqual('string'); - }); - - it('POST /photo/bulk', async () => { - const photos = await photoFactory.createMany(5); - - const response = await supertest(app.getHttpServer()) - .post('/photo/bulk') - .send({ - bulk: photos, - }) - .expect(201); - - expect(response.body).toBeInstanceOf(Array); - expect(response.body.length).toEqual(5); - }); - - it('PATCH /photo/:id', async () => { - const photo = await photoFactory.create(); - expect(photo).toBeInstanceOf(PhotoFixture); - photo.views = 37; - - const { id, ...rest } = { ...photo }; - - const response = await supertest(app.getHttpServer()) - .patch(`/photo/${id}`) - .send(rest) - .expect(200); - - expect(response.body).toMatchObject(photo); - expect(response.body.views).toEqual(37); - }); - - it('PUT /photo/:id', async () => { - const photo = await photoFactory.create(); - expect(photo).toBeInstanceOf(PhotoFixture); - - const { id, ...rest } = { ...photo }; - - const response = await supertest(app.getHttpServer()) - .put(`/photo/${id}`) - .send(rest) - .expect(200); - - expect(response.body).toMatchObject(photo); - }); - - it('DELETE /photo/1', async () => { - const photo = await photoFactory.create(); - expect(photo).toBeInstanceOf(PhotoFixture); - - await supertest(app.getHttpServer()) - .delete(`/photo/${photo.id}`) - .expect(200); - - await supertest(app.getHttpServer()).get(`/photo/${photo.id}`).expect(404); - }); - - it('PATCH /photo/recover/1', async () => { - const photo = await photoFactory.create(); - expect(photo).toBeInstanceOf(PhotoFixture); - - await supertest(app.getHttpServer()) - .delete(`/photo/${photo.id}`) - .expect(200); - - await supertest(app.getHttpServer()).get(`/photo/${photo.id}`).expect(404); - - await supertest(app.getHttpServer()) - .patch(`/photo/recover/${photo.id}`) - .expect(200); - - await supertest(app.getHttpServer()).get(`/photo/${photo.id}`).expect(200); - }); -}); diff --git a/packages/nestjs-crud/src/util/configurable-crud.builder.ts b/packages/nestjs-crud/src/util/configurable-crud.builder.ts deleted file mode 100644 index 057c48acd..000000000 --- a/packages/nestjs-crud/src/util/configurable-crud.builder.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { - applyDecorators, - Inject, - PlainLiteralObject, - Type, -} from '@nestjs/common'; - -import { DeepPartial } from '@concepta/nestjs-common'; - -import { CrudAdapter } from '../crud/adapters/crud.adapter'; -import { CrudBaseController } from '../crud/controllers/crud-base.controller'; -import { CrudCreateMany } from '../crud/decorators/actions/crud-create-many.decorator'; -import { CrudCreateOne } from '../crud/decorators/actions/crud-create-one.decorator'; -import { CrudDeleteOne } from '../crud/decorators/actions/crud-delete-one.decorator'; -import { CrudGetMany } from '../crud/decorators/actions/crud-get-many.decorator'; -import { CrudGetOne } from '../crud/decorators/actions/crud-get-one.decorator'; -import { CrudRecoverOne } from '../crud/decorators/actions/crud-recover-one.decorator'; -import { CrudReplaceOne } from '../crud/decorators/actions/crud-replace-one.decorator'; -import { CrudUpdateOne } from '../crud/decorators/actions/crud-update-one.decorator'; -import { CrudController } from '../crud/decorators/controller/crud-controller.decorator'; -import { CrudBody } from '../crud/decorators/params/crud-body.decorator'; -import { CrudRequest } from '../crud/decorators/params/crud-request.decorator'; -import { CrudCreateManyInterface } from '../crud/interfaces/crud-create-many.interface'; -import { CrudRequestInterface } from '../crud/interfaces/crud-request.interface'; -import { ConfigurableCrudOptionsTransformer } from '../crud.types'; -import { CrudService } from '../services/crud.service'; - -import { ConfigurableCrudDecorators } from './interfaces/configurable-crud-decorators.interface'; -import { ConfigurableCrudHost } from './interfaces/configurable-crud-host.interface'; -import { - ConfigurableCrudOptions, - ConfigurableCrudServiceAdapterOption, -} from './interfaces/configurable-crud-options.interface'; - -export class ConfigurableCrudBuilder< - Entity extends PlainLiteralObject, - Creatable extends DeepPartial, - Updatable extends DeepPartial, - Replaceable extends Creatable = Creatable, - ExtraOptions extends PlainLiteralObject = PlainLiteralObject, -> { - private extras: ExtraOptions; - private optionsTransform: ConfigurableCrudOptionsTransformer< - Entity, - ExtraOptions - >; - - constructor(private options: ConfigurableCrudOptions) { - this.extras = {} as ExtraOptions; - this.optionsTransform = (options, _extras) => options; - } - - setExtras( - extras: ExtraOptions, - optionsTransform: ConfigurableCrudOptionsTransformer, - ): ConfigurableCrudBuilder< - Entity, - Creatable, - Updatable, - Replaceable, - ExtraOptions - > { - this.extras = extras; - this.optionsTransform = optionsTransform; - return this; - } - - build(): ConfigurableCrudHost { - const options = this.optionsTransform(this.options, this.extras); - const decorators = this.generateDecorators(options); - - // Use provided class or generate one from adapter - const ConfigurableServiceClass = - 'useClass' in options.service - ? options.service.useClass - : this.generateService(options.service); - - const ConfigurableControllerClass = this.generateClass(options, decorators); - - return { - ConfigurableServiceProvider: { - provide: options.service.serviceToken, - useClass: ConfigurableServiceClass, - }, - ConfigurableServiceClass, - ConfigurableControllerClass, - ...decorators, - }; - } - - private generateDecorators>( - options: O, - ): ConfigurableCrudDecorators { - const { - controller, - getMany, - getOne, - createMany, - createOne, - updateOne, - replaceOne, - deleteOne, - recoverOne, - } = options; - - const operationIdPrefix = - (Array.isArray(options.controller?.path) - ? options.controller?.path.join() - : options.controller?.path - )?.replace(/[^\w]/g, '_') ?? randomUUID(); - - return { - CrudController: applyDecorators( - CrudController(controller), - ...(controller?.extraDecorators ?? []), - ), - CrudGetMany: applyDecorators( - CrudGetMany({ - api: { operation: { operationId: `${operationIdPrefix}_getMany` } }, - ...getMany, - }), - ...(getMany?.extraDecorators ?? []), - ), - CrudGetOne: applyDecorators( - CrudGetOne({ - api: { operation: { operationId: `${operationIdPrefix}_getOne` } }, - ...getOne, - }), - ...(getOne?.extraDecorators ?? []), - ), - CrudCreateMany: applyDecorators( - CrudCreateMany({ - api: { - operation: { operationId: `${operationIdPrefix}_createMany` }, - }, - ...createMany, - }), - ...(createMany?.extraDecorators ?? []), - ), - CrudCreateOne: applyDecorators( - CrudCreateOne({ - api: { operation: { operationId: `${operationIdPrefix}_createOne` } }, - ...createOne, - }), - ...(createOne?.extraDecorators ?? []), - ), - CrudUpdateOne: applyDecorators( - CrudUpdateOne({ - api: { operation: { operationId: `${operationIdPrefix}_updateOne` } }, - ...updateOne, - }), - ...(updateOne?.extraDecorators ?? []), - ), - CrudReplaceOne: applyDecorators( - CrudReplaceOne({ - api: { - operation: { operationId: `${operationIdPrefix}_replaceOne` }, - }, - ...replaceOne, - }), - ...(replaceOne?.extraDecorators ?? []), - ), - CrudDeleteOne: applyDecorators( - CrudDeleteOne({ - api: { operation: { operationId: `${operationIdPrefix}_deleteOne` } }, - ...deleteOne, - }), - ...(deleteOne?.extraDecorators ?? []), - ), - CrudRecoverOne: applyDecorators( - CrudRecoverOne({ - api: { - operation: { operationId: `${operationIdPrefix}_recoverOne` }, - }, - ...recoverOne, - }), - ...(recoverOne?.extraDecorators ?? []), - ), - }; - } - - private generateClass>( - options: O, - decorators: ConfigurableCrudDecorators, - ): typeof CrudBaseController { - const { - CrudController, - CrudGetMany, - CrudGetOne, - CrudCreateMany, - CrudCreateOne, - CrudUpdateOne, - CrudReplaceOne, - CrudDeleteOne, - CrudRecoverOne, - } = decorators; - - class InternalCrudClass extends CrudBaseController< - Entity, - Creatable, - Updatable, - Replaceable - > { - constructor( - @Inject(options.service.serviceToken) - protected crudService: CrudService, - ) { - super(crudService); - } - } - - if (options?.getMany) { - InternalCrudClass.prototype.getMany = async function ( - this: InternalCrudClass, - crudRequest: CrudRequestInterface, - ) { - return this.crudService.getMany(crudRequest); - }; - - CrudGetMany( - InternalCrudClass.prototype, - 'getMany', - Object.getOwnPropertyDescriptor(InternalCrudClass.prototype, 'getMany'), - ); - CrudRequest()(InternalCrudClass.prototype, 'getMany', 0); - } - - if (options?.getOne) { - InternalCrudClass.prototype.getOne = async function ( - this: InternalCrudClass, - crudRequest: CrudRequestInterface, - ) { - return this.crudService.getOne(crudRequest); - }; - - CrudGetOne( - InternalCrudClass.prototype, - 'getOne', - Object.getOwnPropertyDescriptor(InternalCrudClass.prototype, 'getOne'), - ); - CrudRequest()(InternalCrudClass.prototype, 'getOne', 0); - } - - if (options?.createMany) { - InternalCrudClass.prototype.createMany = async function ( - this: InternalCrudClass, - crudRequest: CrudRequestInterface, - createManyDto: CrudCreateManyInterface, - ) { - return this.crudService.createMany(crudRequest, { - ...createManyDto, - // TODO: this cast is a temporary workaround - bulk: createManyDto.bulk as (Entity | Partial)[], - }); - }; - - CrudCreateMany( - InternalCrudClass.prototype, - 'createMany', - Object.getOwnPropertyDescriptor( - InternalCrudClass.prototype, - 'createMany', - ), - ); - CrudRequest()(InternalCrudClass.prototype, 'createMany', 0); - CrudBody()(InternalCrudClass.prototype, 'createMany', 1); - } - - if (options?.createOne) { - InternalCrudClass.prototype.createOne = async function ( - this: InternalCrudClass, - crudRequest: CrudRequestInterface, - createDto: Creatable, - ) { - return this.crudService.createOne( - crudRequest, - // TODO: this cast is a temporary workaround - createDto as Entity | Partial, - ); - }; - - CrudCreateOne( - InternalCrudClass.prototype, - 'createOne', - Object.getOwnPropertyDescriptor( - InternalCrudClass.prototype, - 'createOne', - ), - ); - CrudRequest()(InternalCrudClass.prototype, 'createOne', 0); - CrudBody({ validation: { expectedType: options.createOne?.dto } })( - InternalCrudClass.prototype, - 'createOne', - 1, - ); - } - - if (options?.updateOne) { - InternalCrudClass.prototype.updateOne = async function ( - this: InternalCrudClass, - crudRequest: CrudRequestInterface, - updateDto: Updatable, - ) { - return this.crudService.updateOne( - crudRequest, - // TODO: this cast is a temporary workaround - updateDto as Entity | Partial, - ); - }; - - CrudUpdateOne( - InternalCrudClass.prototype, - 'updateOne', - Object.getOwnPropertyDescriptor( - InternalCrudClass.prototype, - 'updateOne', - ), - ); - CrudRequest()(InternalCrudClass.prototype, 'updateOne', 0); - CrudBody({ validation: { expectedType: options.updateOne?.dto } })( - InternalCrudClass.prototype, - 'updateOne', - 1, - ); - } - - if (options?.replaceOne) { - InternalCrudClass.prototype.replaceOne = async function ( - this: InternalCrudClass, - crudRequest: CrudRequestInterface, - replaceDto: Replaceable, - ) { - return this.crudService.replaceOne( - crudRequest, - // TODO: this cast is a temporary workaround - replaceDto as Entity | Partial, - ); - }; - - CrudReplaceOne( - InternalCrudClass.prototype, - 'replaceOne', - Object.getOwnPropertyDescriptor( - InternalCrudClass.prototype, - 'replaceOne', - ), - ); - CrudRequest()(InternalCrudClass.prototype, 'replaceOne', 0); - CrudBody()(InternalCrudClass.prototype, 'replaceOne', 1); - } - - if (options?.deleteOne) { - InternalCrudClass.prototype.deleteOne = async function ( - this: InternalCrudClass, - crudRequest: CrudRequestInterface, - ) { - return this.crudService.deleteOne(crudRequest); - }; - - CrudDeleteOne( - InternalCrudClass.prototype, - 'deleteOne', - Object.getOwnPropertyDescriptor( - InternalCrudClass.prototype, - 'deleteOne', - ), - ); - CrudRequest()(InternalCrudClass.prototype, 'deleteOne', 0); - } - - if (options?.recoverOne) { - InternalCrudClass.prototype.recoverOne = async function ( - this: InternalCrudClass, - crudRequest: CrudRequestInterface, - ) { - return this.crudService.recoverOne(crudRequest); - }; - - CrudRecoverOne( - InternalCrudClass.prototype, - 'recoverOne', - Object.getOwnPropertyDescriptor( - InternalCrudClass.prototype, - 'recoverOne', - ), - ); - CrudRequest()(InternalCrudClass.prototype, 'recoverOne', 0); - } - - CrudController(InternalCrudClass); - - return InternalCrudClass; - } - - private generateService( - options: ConfigurableCrudServiceAdapterOption, - ): Type> { - const { adapterToken } = options; - - class InternalServiceClass extends CrudService { - constructor( - @Inject(adapterToken) - protected readonly crudAdapter: CrudAdapter, - ) { - super(crudAdapter); - } - } - - return InternalServiceClass; - } -} diff --git a/packages/nestjs-crud/src/util/create-crud-adapter-provider.ts b/packages/nestjs-crud/src/util/create-crud-adapter-provider.ts deleted file mode 100644 index dab68f7d0..000000000 --- a/packages/nestjs-crud/src/util/create-crud-adapter-provider.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - InjectionToken, - PlainLiteralObject, - Provider, - Type, -} from '@nestjs/common'; - -import { - getDynamicRepositoryToken, - RepositoryInterface, -} from '@concepta/nestjs-common'; - -import { CrudAdapter } from '../crud/adapters/crud.adapter'; - -import { getDynamicCrudAdapterToken } from './inject-dynamic-crud-adapter.decorator'; - -/** - * Configuration for creating a CRUD adapter provider - */ -interface CreateCrudAdapterProviderConfig { - /** - * The entity key used to identify the repository - * (e.g., 'USER_MODULE_USER_ENTITY_KEY') - */ - entityKey: string; - - /** - * The CRUD adapter class to instantiate - * (e.g., TypeOrmCrudAdapter) - */ - adapter: Type>; - - /** - * Optional custom injection token - * If not provided, uses getDynamicCrudAdapterToken(entityKey) - */ - injectionToken?: InjectionToken>; -} - -/** - * Creates a NestJS provider for a CRUD adapter - * - * This factory eliminates boilerplate adapter class files by dynamically - * creating adapter instances from repository adapters. - * - * @example - * ```typescript - * const UserCrudAdapterProvider = createCrudAdapterProvider({ - * entityKey: USER_MODULE_USER_ENTITY_KEY, - * adapter: TypeOrmCrudAdapter, - * }); - * - * @Module({ - * providers: [UserCrudAdapterProvider], - * }) - * export class UserModule {} - * ``` - * - * @param config - Configuration for the CRUD adapter provider - * @returns A NestJS provider that creates the adapter instance - */ -export function createCrudAdapterProvider( - config: CreateCrudAdapterProviderConfig, -): Provider { - const { entityKey, adapter, injectionToken } = config; - - return { - provide: injectionToken ?? getDynamicCrudAdapterToken(entityKey), - inject: [getDynamicRepositoryToken(entityKey)], - useFactory: (repository: RepositoryInterface) => { - return new adapter(repository); - }, - }; -} diff --git a/packages/nestjs-crud/src/util/create-crud-service-provider.ts b/packages/nestjs-crud/src/util/create-crud-service-provider.ts deleted file mode 100644 index 7bb570750..000000000 --- a/packages/nestjs-crud/src/util/create-crud-service-provider.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - InjectionToken, - PlainLiteralObject, - Provider, - Type, -} from '@nestjs/common'; - -import { CrudAdapter } from '../crud/adapters/crud.adapter'; -import { CrudService } from '../services/crud.service'; - -import { getDynamicCrudAdapterToken } from './inject-dynamic-crud-adapter.decorator'; -import { getDynamicCrudServiceToken } from './inject-dynamic-crud-service.decorator'; - -/** - * Base configuration shared by all variants - */ -interface CreateCrudServiceProviderBaseConfig< - Entity extends PlainLiteralObject, -> { - entityKey?: string; - injectionToken?: InjectionToken>; -} - -/** - * Configuration with adapter - generates service from adapter - */ -interface CreateCrudServiceProviderWithAdapterConfig< - Entity extends PlainLiteralObject, -> extends CreateCrudServiceProviderBaseConfig { - useClass?: never; -} - -/** - * Configuration with useClass - uses provided service class directly - */ -interface CreateCrudServiceProviderWithClassConfig< - Entity extends PlainLiteralObject, -> extends CreateCrudServiceProviderBaseConfig { - useClass: Type>; -} - -/** - * Configuration for creating a CRUD service provider - */ -type CreateCrudServiceProviderConfig = - | CreateCrudServiceProviderWithAdapterConfig - | CreateCrudServiceProviderWithClassConfig; - -/** - * Creates a NestJS provider for a CRUD service - * - * This factory eliminates boilerplate service class files by dynamically - * creating service instances from adapters. - * - * @example - * ```typescript - * const UserCrudServiceProvider = createCrudServiceProvider({ - * entityKey: 'user', - * injectionToken: 'UserCrudService', - * }); - * - * @Module({ - * providers: [UserCrudServiceProvider], - * }) - * export class UserModule {} - * ``` - * - * @param config - Configuration for the CRUD service provider - * @returns A NestJS provider that creates the service instance - */ -export function createCrudServiceProvider( - config: CreateCrudServiceProviderConfig, -): Provider { - const { entityKey, injectionToken, useClass } = config; - - // Determine the provider token - const serviceToken = - injectionToken ?? - (entityKey ? getDynamicCrudServiceToken(entityKey) : CrudService); - - // Use class directly if provided - if (useClass) { - return { - provide: serviceToken, - useClass, - }; - } - - // Generate service from adapter - return { - provide: serviceToken, - inject: [getDynamicCrudAdapterToken(entityKey!)], - useFactory: (adapter: CrudAdapter) => { - return new CrudService(adapter); - }, - }; -} diff --git a/packages/nestjs-crud/src/util/crud-is-paginated.helper.ts b/packages/nestjs-crud/src/util/crud-is-paginated.helper.ts deleted file mode 100644 index 958ac60e5..000000000 --- a/packages/nestjs-crud/src/util/crud-is-paginated.helper.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { CrudResponsePaginatedInterface } from '../crud/interfaces/crud-response-paginated.interface'; - -export function crudIsPaginatedHelper( - response: object, -): response is CrudResponsePaginatedInterface { - return ( - 'data' in response && - Array.isArray(response.data) === true && - 'count' in response && - 'total' in response && - 'page' in response && - 'pageCount' in response - ); -} diff --git a/packages/nestjs-crud/src/util/inject-dynamic-crud-adapter.decorator.ts b/packages/nestjs-crud/src/util/inject-dynamic-crud-adapter.decorator.ts deleted file mode 100644 index e5f747607..000000000 --- a/packages/nestjs-crud/src/util/inject-dynamic-crud-adapter.decorator.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Inject } from '@nestjs/common'; - -/** - * Gets the injection token for a dynamic CRUD adapter - * - * @param entityKey - The entity key used to identify the CRUD adapter - * @returns The injection token string - */ -export function getDynamicCrudAdapterToken(entityKey: string): string { - return `DYNAMIC_CRUD_ADAPTER_TOKEN_${entityKey}`; -} - -/** - * Decorator to inject a dynamic CRUD adapter by entity key - * - * This decorator works with adapters created by `createCrudAdapterProvider` - * or any adapter registered with the `getDynamicCrudAdapterToken(entityKey)` pattern. - * - * @example - * ```typescript - * @Injectable() - * export class UserCrudService extends CrudService { - * constructor( - * @InjectDynamicCrudAdapter(USER_MODULE_USER_ENTITY_KEY) - * protected readonly crudAdapter: CrudAdapter, - * ) { - * super(crudAdapter); - * } - * } - * ``` - * - * @param entityKey - The entity key used to identify the CRUD adapter - * @returns A parameter decorator for dependency injection - */ -export function InjectDynamicCrudAdapter(entityKey: string) { - return Inject(getDynamicCrudAdapterToken(entityKey)); -} diff --git a/packages/nestjs-crud/src/util/inject-dynamic-crud-service.decorator.ts b/packages/nestjs-crud/src/util/inject-dynamic-crud-service.decorator.ts deleted file mode 100644 index a8192eb4c..000000000 --- a/packages/nestjs-crud/src/util/inject-dynamic-crud-service.decorator.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Inject } from '@nestjs/common'; - -/** - * Gets the injection token for a dynamic CRUD service - * - * @param entityKey - The entity key used to identify the CRUD service - * @returns The injection token string - */ -export function getDynamicCrudServiceToken(entityKey: string): string { - return `DYNAMIC_CRUD_SERVICE_TOKEN_${entityKey}`; -} - -/** - * Decorator to inject a dynamic CRUD service by entity key - * - * This decorator works with services created by `createCrudServiceProvider` - * or any service registered with the getDynamicCrudServiceToken pattern. - * - * @example - * ```typescript - * @Controller('users') - * export class UserController { - * constructor( - * @InjectDynamicCrudService(USER_MODULE_USER_ENTITY_KEY) - * private readonly userService: CrudService, - * ) {} - * } - * ``` - * - * @param entityKey - The entity key used to identify the CRUD service - * @returns A parameter decorator for dependency injection - */ -export function InjectDynamicCrudService(entityKey: string) { - return Inject(getDynamicCrudServiceToken(entityKey)); -} diff --git a/packages/nestjs-crud/src/util/interfaces/configurable-crud-decorators.interface.ts b/packages/nestjs-crud/src/util/interfaces/configurable-crud-decorators.interface.ts deleted file mode 100644 index 205cc2d8d..000000000 --- a/packages/nestjs-crud/src/util/interfaces/configurable-crud-decorators.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { applyDecorators } from '@nestjs/common'; - -export interface ConfigurableCrudDecorators { - CrudController: ReturnType; - CrudGetMany: ReturnType; - CrudGetOne: ReturnType; - CrudCreateMany: ReturnType; - CrudCreateOne: ReturnType; - CrudUpdateOne: ReturnType; - CrudReplaceOne: ReturnType; - CrudDeleteOne: ReturnType; - CrudRecoverOne: ReturnType; -} diff --git a/packages/nestjs-crud/src/util/interfaces/configurable-crud-host.interface.ts b/packages/nestjs-crud/src/util/interfaces/configurable-crud-host.interface.ts deleted file mode 100644 index 0da1cffaa..000000000 --- a/packages/nestjs-crud/src/util/interfaces/configurable-crud-host.interface.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ClassProvider, PlainLiteralObject, Type } from '@nestjs/common'; - -import { DeepPartial } from '@concepta/nestjs-common'; - -import { CrudBaseController } from '../../crud/controllers/crud-base.controller'; -import { CrudService } from '../../services/crud.service'; - -import { ConfigurableCrudDecorators } from './configurable-crud-decorators.interface'; - -export interface ConfigurableCrudHost< - Entity extends PlainLiteralObject, - Creatable extends DeepPartial, - Updatable extends DeepPartial, - Replaceable extends Creatable = Creatable, -> extends ConfigurableCrudDecorators { - ConfigurableControllerClass: typeof CrudBaseController< - Entity, - Creatable, - Updatable, - Replaceable - >; - ConfigurableServiceClass: Type>; - ConfigurableServiceProvider: ClassProvider; -} diff --git a/packages/nestjs-crud/src/util/interfaces/configurable-crud-options.interface.ts b/packages/nestjs-crud/src/util/interfaces/configurable-crud-options.interface.ts deleted file mode 100644 index b872c1059..000000000 --- a/packages/nestjs-crud/src/util/interfaces/configurable-crud-options.interface.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { InjectionToken, PlainLiteralObject, Type } from '@nestjs/common'; - -import { CrudAdapter } from '../../crud/adapters/crud.adapter'; -import { CrudControllerOptionsInterface } from '../../crud/interfaces/crud-controller-options.interface'; -import { CrudExtraDecoratorsInterface } from '../../crud/interfaces/crud-extra-decorators.interface'; -import { - CrudCreateManyOptionsInterface, - CrudCreateOneOptionsInterface, - CrudDeleteOneOptionsInterface, - CrudReadAllOptionsInterface, - CrudReadOneOptionsInterface, - CrudRecoverOneOptionsInterface, - CrudReplaceOneOptionsInterface, - CrudUpdateOneOptionsInterface, -} from '../../crud/interfaces/crud-route-options.interface'; -import { CrudService } from '../../services/crud.service'; - -/** - * Service config with adapter token - generates a service class that injects the adapter - */ -export interface ConfigurableCrudServiceAdapterOption< - Entity extends PlainLiteralObject, -> { - serviceToken: InjectionToken>; - adapterToken: InjectionToken>; -} - -/** - * Service config with useClass - uses the provided service class directly - */ -export interface ConfigurableCrudServiceUseClassOption< - Entity extends PlainLiteralObject, -> { - serviceToken: InjectionToken>; - useClass: Type>; -} - -export interface ConfigurableCrudOptions { - /** - * Service configuration - either adapter (generate) or useClass (use directly) - */ - service: - | ConfigurableCrudServiceAdapterOption - | ConfigurableCrudServiceUseClassOption; - controller: CrudControllerOptionsInterface & - CrudExtraDecoratorsInterface; - getMany?: CrudReadAllOptionsInterface & CrudExtraDecoratorsInterface; - getOne?: CrudReadOneOptionsInterface & CrudExtraDecoratorsInterface; - createMany?: CrudCreateManyOptionsInterface & - CrudExtraDecoratorsInterface; - createOne?: CrudCreateOneOptionsInterface & - CrudExtraDecoratorsInterface; - updateOne?: CrudUpdateOneOptionsInterface & - CrudExtraDecoratorsInterface; - replaceOne?: CrudReplaceOneOptionsInterface & - CrudExtraDecoratorsInterface; - deleteOne?: CrudDeleteOneOptionsInterface & - CrudExtraDecoratorsInterface; - recoverOne?: CrudRecoverOneOptionsInterface & - CrudExtraDecoratorsInterface; -} diff --git a/packages/nestjs-crud/src/util/validation.ts b/packages/nestjs-crud/src/util/validation.ts deleted file mode 100644 index af6c06384..000000000 --- a/packages/nestjs-crud/src/util/validation.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { isNumber } from '@nestjs/common/utils/shared.utils'; - -export const isStringFull = (val: unknown): val is string => - typeof val === 'string' && val.length > 0; - -export const isArrayStrings = (val: unknown): boolean => - Array.isArray(val) && val.length > 0 && val.every((v) => isStringFull(v)); - -export const isValue = (val: unknown): boolean => - isStringFull(val) || - isNumber(val) || - typeof val === 'boolean' || - val instanceof Date; - -export const hasValue = (val: unknown): boolean => - Array.isArray(val) && val.length > 0 - ? val.every((o) => isValue(o)) - : isValue(val); - -export const isDateString = (val: string): boolean => - isStringFull(val) && - /^\d{4}-[01]\d-[0-3]\d(?:T[0-2]\d:[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[-+][0-2]\d(?::?[0-5]\d)?)?)?$/g.test( - val, - ); diff --git a/packages/nestjs-crud/tsconfig.json b/packages/nestjs-crud/tsconfig.json index 0d6aa546c..f23770557 100644 --- a/packages/nestjs-crud/tsconfig.json +++ b/packages/nestjs-crud/tsconfig.json @@ -3,11 +3,14 @@ "compilerOptions": { "composite": true, "rootDir": "src", - "outDir": "dist", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", "typeRoots": [ "./node_modules/@types", - "../../node_modules/@types" - ] + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] }, "include": [ "src/**/*.ts" diff --git a/packages/nestjs-email/src/config/email-settings.config.spec.ts b/packages/nestjs-email/src/config/email-settings.config.spec.ts index 4cafd5a8f..380706b57 100644 --- a/packages/nestjs-email/src/config/email-settings.config.spec.ts +++ b/packages/nestjs-email/src/config/email-settings.config.spec.ts @@ -1,7 +1,7 @@ import { ConfigModule } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; -import { EmailSettingsInterface } from '../interfaces/email-settings.interface'; +import { type EmailSettingsInterface } from '../interfaces/email-settings.interface'; import { emailSettingsConfig } from './email-settings.config'; diff --git a/packages/nestjs-email/src/config/email-settings.config.ts b/packages/nestjs-email/src/config/email-settings.config.ts index d11de32f2..9f15f6ac0 100644 --- a/packages/nestjs-email/src/config/email-settings.config.ts +++ b/packages/nestjs-email/src/config/email-settings.config.ts @@ -1,6 +1,6 @@ import { registerAs } from '@nestjs/config'; -import { EmailSettingsInterface } from '../interfaces/email-settings.interface'; +import { type EmailSettingsInterface } from '../interfaces/email-settings.interface'; /** * Get email settings from environment variables. diff --git a/packages/nestjs-email/src/email.module-definition.ts b/packages/nestjs-email/src/email.module-definition.ts index f5f1ecd91..20aace1f3 100644 --- a/packages/nestjs-email/src/email.module-definition.ts +++ b/packages/nestjs-email/src/email.module-definition.ts @@ -1,8 +1,8 @@ import { ConfigurableModuleBuilder, - DynamicModule, + type DynamicModule, Logger, - Provider, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; @@ -14,9 +14,9 @@ import { EMAIL_MODULE_MAILER_SERVICE_TOKEN, } from './email.constants'; import { EmailService } from './email.service'; -import { EmailOptionsExtrasInterface } from './interfaces/email-options-extras.interface'; -import { EmailOptionsInterface } from './interfaces/email-options.interface'; -import { EmailSettingsInterface } from './interfaces/email-settings.interface'; +import { type EmailOptionsExtrasInterface } from './interfaces/email-options-extras.interface'; +import { type EmailOptionsInterface } from './interfaces/email-options.interface'; +import { type EmailSettingsInterface } from './interfaces/email-settings.interface'; const RAW_OPTIONS_TOKEN = Symbol('__EMAIL_MODULE_RAW_OPTIONS_TOKEN__'); diff --git a/packages/nestjs-email/src/email.service.spec.ts b/packages/nestjs-email/src/email.service.spec.ts index d0a9602a1..85ecbfbd1 100644 --- a/packages/nestjs-email/src/email.service.spec.ts +++ b/packages/nestjs-email/src/email.service.spec.ts @@ -8,7 +8,7 @@ import { NotAnErrorException } from '@concepta/nestjs-common'; import { EMAIL_MODULE_MAILER_SERVICE_TOKEN } from './email.constants'; import { EmailService } from './email.service'; import { EmailSendException } from './exceptions/email-send.exception'; -import { EmailServiceInterface } from './interfaces/email-service.interface'; +import { type EmailServiceInterface } from './interfaces/email-service.interface'; describe(EmailService, () => { let logger: Logger; diff --git a/packages/nestjs-email/src/exceptions/email-send.exception.ts b/packages/nestjs-email/src/exceptions/email-send.exception.ts index bfc2c7cf9..7b5191bfd 100644 --- a/packages/nestjs-email/src/exceptions/email-send.exception.ts +++ b/packages/nestjs-email/src/exceptions/email-send.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { EmailException } from './email.exception'; diff --git a/packages/nestjs-email/src/exceptions/email.exception.ts b/packages/nestjs-email/src/exceptions/email.exception.ts index 67cbdd792..40606e74b 100644 --- a/packages/nestjs-email/src/exceptions/email.exception.ts +++ b/packages/nestjs-email/src/exceptions/email.exception.ts @@ -1,6 +1,6 @@ import { RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; export class EmailException extends RuntimeException { diff --git a/packages/nestjs-email/src/interfaces/email-options-extras.interface.ts b/packages/nestjs-email/src/interfaces/email-options-extras.interface.ts index ccbdf153b..180b8d11e 100644 --- a/packages/nestjs-email/src/interfaces/email-options-extras.interface.ts +++ b/packages/nestjs-email/src/interfaces/email-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface EmailOptionsExtrasInterface - extends Pick {} +export interface EmailOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-email/src/interfaces/email-options.interface.ts b/packages/nestjs-email/src/interfaces/email-options.interface.ts index 6a0f59ffc..2256644c9 100644 --- a/packages/nestjs-email/src/interfaces/email-options.interface.ts +++ b/packages/nestjs-email/src/interfaces/email-options.interface.ts @@ -1,5 +1,5 @@ -import { EmailServiceInterface } from './email-service.interface'; -import { EmailSettingsInterface } from './email-settings.interface'; +import { type EmailServiceInterface } from './email-service.interface'; +import { type EmailSettingsInterface } from './email-settings.interface'; export interface EmailOptionsInterface { settings?: EmailSettingsInterface; diff --git a/packages/nestjs-email/src/interfaces/email-service.interface.ts b/packages/nestjs-email/src/interfaces/email-service.interface.ts index 148d7f74a..f43ab2531 100644 --- a/packages/nestjs-email/src/interfaces/email-service.interface.ts +++ b/packages/nestjs-email/src/interfaces/email-service.interface.ts @@ -1,3 +1,3 @@ -import { EmailSendInterface } from '@concepta/nestjs-common'; +import { type EmailSendInterface } from '@concepta/nestjs-common'; export interface EmailServiceInterface extends EmailSendInterface {} diff --git a/packages/nestjs-event/src/config/event-settings.config.ts b/packages/nestjs-event/src/config/event-settings.config.ts index dd970338a..9ea8b4314 100644 --- a/packages/nestjs-event/src/config/event-settings.config.ts +++ b/packages/nestjs-event/src/config/event-settings.config.ts @@ -1,7 +1,7 @@ import { registerAs } from '@nestjs/config'; import { EVENT_MODULE_DEFAULT_EMITTER_SERVICE_SETTINGS_TOKEN } from '../event-constants'; -import { EventSettingsInterface } from '../interfaces/event-settings.interface'; +import { type EventSettingsInterface } from '../interfaces/event-settings.interface'; /** * Get event settings config from environment variables. diff --git a/packages/nestjs-event/src/event-manager.ts b/packages/nestjs-event/src/event-manager.ts index 7573c8915..3cc5d5248 100644 --- a/packages/nestjs-event/src/event-manager.ts +++ b/packages/nestjs-event/src/event-manager.ts @@ -1,8 +1,8 @@ import { Logger } from '@nestjs/common'; import { EventException } from './exceptions/event.exception'; -import { EventDispatchService } from './services/event-dispatch.service'; -import { EventListenService } from './services/event-listen.service'; +import { type EventDispatchService } from './services/event-dispatch.service'; +import { type EventListenService } from './services/event-listen.service'; interface EventManagerOptions { allowManualShutdown: boolean; diff --git a/packages/nestjs-event/src/event-types.ts b/packages/nestjs-event/src/event-types.ts index dbe74d10a..44069fd7f 100644 --- a/packages/nestjs-event/src/event-types.ts +++ b/packages/nestjs-event/src/event-types.ts @@ -1,6 +1,6 @@ -import { EventAsyncInterface } from './events/interfaces/event-async.interface'; -import { EventBaseInterface } from './events/interfaces/event-base.interface'; -import { EventExpectsReturnOfInterface } from './events/interfaces/event-expects-return-of.interface'; +import { type EventAsyncInterface } from './events/interfaces/event-async.interface'; +import { type EventBaseInterface } from './events/interfaces/event-base.interface'; +import { type EventExpectsReturnOfInterface } from './events/interfaces/event-expects-return-of.interface'; export type EventPayload = V; diff --git a/packages/nestjs-event/src/event.module-definition.ts b/packages/nestjs-event/src/event.module-definition.ts index d0897672e..6abf371d0 100644 --- a/packages/nestjs-event/src/event.module-definition.ts +++ b/packages/nestjs-event/src/event.module-definition.ts @@ -2,10 +2,10 @@ import EventEmitter2 from 'eventemitter2'; import { ConfigurableModuleBuilder, - DynamicModule, + type DynamicModule, Logger, - ModuleMetadata, - Provider, + type ModuleMetadata, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; @@ -17,9 +17,9 @@ import { EVENT_MODULE_SETTINGS_TOKEN, } from './event-constants'; import { EventManager } from './event-manager'; -import { EventOptionsExtrasInterface } from './interfaces/event-options-extras.interface'; -import { EventOptionsInterface } from './interfaces/event-options.interface'; -import { EventSettingsInterface } from './interfaces/event-settings.interface'; +import { type EventOptionsExtrasInterface } from './interfaces/event-options-extras.interface'; +import { type EventOptionsInterface } from './interfaces/event-options.interface'; +import { type EventSettingsInterface } from './interfaces/event-settings.interface'; import { EventDispatchService } from './services/event-dispatch.service'; import { EventListenService } from './services/event-listen.service'; diff --git a/packages/nestjs-event/src/event.module.spec.ts b/packages/nestjs-event/src/event.module.spec.ts index 3333948e0..3480a1ca5 100644 --- a/packages/nestjs-event/src/event.module.spec.ts +++ b/packages/nestjs-event/src/event.module.spec.ts @@ -1,6 +1,6 @@ import EventEmitter2 from 'eventemitter2'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { EVENT_MODULE_EMITTER_SERVICE_TOKEN } from './event-constants'; import { EventManager } from './event-manager'; diff --git a/packages/nestjs-event/src/events/event-async.ts b/packages/nestjs-event/src/events/event-async.ts index c631b58fd..f2c276bbe 100644 --- a/packages/nestjs-event/src/events/event-async.ts +++ b/packages/nestjs-event/src/events/event-async.ts @@ -1,7 +1,7 @@ import { EventManager } from '../event-manager'; import { EventBase } from './event-base'; -import { EventAsyncInterface } from './interfaces/event-async.interface'; +import { type EventAsyncInterface } from './interfaces/event-async.interface'; /** * Abstract async event class. diff --git a/packages/nestjs-event/src/events/event-base.ts b/packages/nestjs-event/src/events/event-base.ts index 9e959f3f7..2290a4d26 100644 --- a/packages/nestjs-event/src/events/event-base.ts +++ b/packages/nestjs-event/src/events/event-base.ts @@ -1,7 +1,7 @@ import { EVENT_MODULE_EVENT_KEY_PREFIX } from '../event-constants'; -import { EventPayload } from '../event-types'; +import { type EventPayload } from '../event-types'; -import { EventBaseInterface } from './interfaces/event-base.interface'; +import { type EventBaseInterface } from './interfaces/event-base.interface'; /** * Abstract event class. @@ -29,9 +29,10 @@ import { EventBaseInterface } from './interfaces/event-base.interface'; * const myEvent = new MyEvent({id: 1234, active: true}); * ``` */ -export abstract class EventBase

- implements EventBaseInterface -{ +export abstract class EventBase< + P = undefined, + R = P, +> implements EventBaseInterface { /** * Expects return of payload * diff --git a/packages/nestjs-event/src/events/event.ts b/packages/nestjs-event/src/events/event.ts index 6bb82c988..83b114097 100644 --- a/packages/nestjs-event/src/events/event.ts +++ b/packages/nestjs-event/src/events/event.ts @@ -1,7 +1,7 @@ import { EventManager } from '../event-manager'; import { EventBase } from './event-base'; -import { EventInterface } from './interfaces/event.interface'; +import { type EventInterface } from './interfaces/event.interface'; /** * Abstract event class. diff --git a/packages/nestjs-event/src/events/interfaces/event-async.interface.ts b/packages/nestjs-event/src/events/interfaces/event-async.interface.ts index 80e24950d..2af856992 100644 --- a/packages/nestjs-event/src/events/interfaces/event-async.interface.ts +++ b/packages/nestjs-event/src/events/interfaces/event-async.interface.ts @@ -1,7 +1,9 @@ -import { EventBaseInterface } from './event-base.interface'; +import { type EventBaseInterface } from './event-base.interface'; /** * The interface that all async events must adhere to */ -export interface EventAsyncInterface

- extends EventBaseInterface> {} +export interface EventAsyncInterface< + P = undefined, + R = P, +> extends EventBaseInterface> {} diff --git a/packages/nestjs-event/src/events/interfaces/event-base.interface.ts b/packages/nestjs-event/src/events/interfaces/event-base.interface.ts index a95525b0f..b333b87ce 100644 --- a/packages/nestjs-event/src/events/interfaces/event-base.interface.ts +++ b/packages/nestjs-event/src/events/interfaces/event-base.interface.ts @@ -1,14 +1,13 @@ -import { EventPayload } from '../../event-types'; +import { type EventPayload } from '../../event-types'; -import { EventExpectsReturnOfInterface } from './event-expects-return-of.interface'; -import { EventKeyInterface } from './event-key.interface'; +import { type EventExpectsReturnOfInterface } from './event-expects-return-of.interface'; +import { type EventKeyInterface } from './event-key.interface'; /** * The interface that defines Event key and payload signatures. */ export interface EventBaseInterface

- extends EventKeyInterface, - EventExpectsReturnOfInterface { + extends EventKeyInterface, EventExpectsReturnOfInterface { /** * Return the payload that should be emitted. */ diff --git a/packages/nestjs-event/src/events/interfaces/event-class.interface.ts b/packages/nestjs-event/src/events/interfaces/event-class.interface.ts index 8ce0b6d0b..33f5ce7b8 100644 --- a/packages/nestjs-event/src/events/interfaces/event-class.interface.ts +++ b/packages/nestjs-event/src/events/interfaces/event-class.interface.ts @@ -1,6 +1,6 @@ -import { EventInstance } from '../../event-types'; +import { type EventInstance } from '../../event-types'; -import { EventKeyInterface } from './event-key.interface'; +import { type EventKeyInterface } from './event-key.interface'; /** * Interface defining static signature of newable events. diff --git a/packages/nestjs-event/src/events/interfaces/event.interface.ts b/packages/nestjs-event/src/events/interfaces/event.interface.ts index 3763e8f8c..a89882372 100644 --- a/packages/nestjs-event/src/events/interfaces/event.interface.ts +++ b/packages/nestjs-event/src/events/interfaces/event.interface.ts @@ -1,8 +1,10 @@ -import { EventBaseInterface } from './event-base.interface'; +import { type EventBaseInterface } from './event-base.interface'; /** * The interface that all standard events must adhere to * */ -export interface EventInterface

- extends EventBaseInterface {} +export interface EventInterface

extends EventBaseInterface< + P, + void +> {} diff --git a/packages/nestjs-event/src/exceptions/event-dispatch.exception.ts b/packages/nestjs-event/src/exceptions/event-dispatch.exception.ts index 758268ac4..9aa353a86 100644 --- a/packages/nestjs-event/src/exceptions/event-dispatch.exception.ts +++ b/packages/nestjs-event/src/exceptions/event-dispatch.exception.ts @@ -1,9 +1,9 @@ import { - RuntimeException, - RuntimeExceptionOptions, + type RuntimeException, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; -import { EventBaseInterface } from '../events/interfaces/event-base.interface'; +import { type EventBaseInterface } from '../events/interfaces/event-base.interface'; import { EventException } from './event.exception'; @@ -26,7 +26,7 @@ export class EventDispatchException extends EventException { }); this.context = { - ...super.context, + ...this.context, event, }; diff --git a/packages/nestjs-event/src/exceptions/event-listen.exception.ts b/packages/nestjs-event/src/exceptions/event-listen.exception.ts index 44cd67848..e23b78e73 100644 --- a/packages/nestjs-event/src/exceptions/event-listen.exception.ts +++ b/packages/nestjs-event/src/exceptions/event-listen.exception.ts @@ -1,9 +1,9 @@ import { - RuntimeException, - RuntimeExceptionOptions, + type RuntimeException, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; -import { EventListenOnInterface } from '../services/interfaces/event-listen-on.interface'; +import { type EventListenOnInterface } from '../services/interfaces/event-listen-on.interface'; import { EventException } from './event.exception'; diff --git a/packages/nestjs-event/src/exceptions/event-listener.exception.ts b/packages/nestjs-event/src/exceptions/event-listener.exception.ts index 9067906df..98a6388ea 100644 --- a/packages/nestjs-event/src/exceptions/event-listener.exception.ts +++ b/packages/nestjs-event/src/exceptions/event-listener.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { EventException } from './event.exception'; diff --git a/packages/nestjs-event/src/exceptions/event.exception.ts b/packages/nestjs-event/src/exceptions/event.exception.ts index 780957a1a..f3a78692c 100644 --- a/packages/nestjs-event/src/exceptions/event.exception.ts +++ b/packages/nestjs-event/src/exceptions/event.exception.ts @@ -1,6 +1,6 @@ import { RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; /** * Generic event exception. diff --git a/packages/nestjs-event/src/interfaces/event-emitter2-options.interface.ts b/packages/nestjs-event/src/interfaces/event-emitter2-options.interface.ts index a9b91f1d7..8aad0f020 100644 --- a/packages/nestjs-event/src/interfaces/event-emitter2-options.interface.ts +++ b/packages/nestjs-event/src/interfaces/event-emitter2-options.interface.ts @@ -1,4 +1,4 @@ -import { ConstructorOptions } from 'eventemitter2'; +import { type ConstructorOptions } from 'eventemitter2'; /** * Valid options for EventEmitter2. diff --git a/packages/nestjs-event/src/interfaces/event-options-extras.interface.ts b/packages/nestjs-event/src/interfaces/event-options-extras.interface.ts index 9174b4cc7..469b1ee22 100644 --- a/packages/nestjs-event/src/interfaces/event-options-extras.interface.ts +++ b/packages/nestjs-event/src/interfaces/event-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface EventOptionsExtrasInterface - extends Pick {} +export interface EventOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-event/src/interfaces/event-options.interface.ts b/packages/nestjs-event/src/interfaces/event-options.interface.ts index 601273ed4..7d9010fa8 100644 --- a/packages/nestjs-event/src/interfaces/event-options.interface.ts +++ b/packages/nestjs-event/src/interfaces/event-options.interface.ts @@ -1,4 +1,4 @@ -import { EventSettingsInterface } from './event-settings.interface'; +import { type EventSettingsInterface } from './event-settings.interface'; /** * Event module options interface diff --git a/packages/nestjs-event/src/interfaces/event-settings.interface.ts b/packages/nestjs-event/src/interfaces/event-settings.interface.ts index 5922180e9..e222eac02 100644 --- a/packages/nestjs-event/src/interfaces/event-settings.interface.ts +++ b/packages/nestjs-event/src/interfaces/event-settings.interface.ts @@ -1,4 +1,4 @@ -import { EventEmitter2OptionsInterface } from './event-emitter2-options.interface'; +import { type EventEmitter2OptionsInterface } from './event-emitter2-options.interface'; /** * Event module settings interface diff --git a/packages/nestjs-event/src/listeners/event-listener-on.ts b/packages/nestjs-event/src/listeners/event-listener-on.ts index 9fe73ccb7..ceb46cebd 100644 --- a/packages/nestjs-event/src/listeners/event-listener-on.ts +++ b/packages/nestjs-event/src/listeners/event-listener-on.ts @@ -1,7 +1,7 @@ import { EventManager } from '../event-manager'; -import { EventClassInterface } from '../events/interfaces/event-class.interface'; -import { EventListenOnOptionsInterface } from '../services/interfaces/event-listen-on-options.interface'; -import { EventListenOnInterface } from '../services/interfaces/event-listen-on.interface'; +import { type EventClassInterface } from '../events/interfaces/event-class.interface'; +import { type EventListenOnOptionsInterface } from '../services/interfaces/event-listen-on-options.interface'; +import { type EventListenOnInterface } from '../services/interfaces/event-listen-on.interface'; import { EventListener } from './event-listener'; diff --git a/packages/nestjs-event/src/listeners/event-listener.spec.ts b/packages/nestjs-event/src/listeners/event-listener.spec.ts index 43bff8c38..5ab1bbdfd 100644 --- a/packages/nestjs-event/src/listeners/event-listener.spec.ts +++ b/packages/nestjs-event/src/listeners/event-listener.spec.ts @@ -1,4 +1,4 @@ -import { EventEmitter2, Listener as EmitterListener } from 'eventemitter2'; +import { EventEmitter2, type Listener as EmitterListener } from 'eventemitter2'; import { Event } from '../events/event'; import { EventListenerException } from '../exceptions/event-listener.exception'; diff --git a/packages/nestjs-event/src/listeners/event-listener.ts b/packages/nestjs-event/src/listeners/event-listener.ts index 90d4ee636..3cb7e55c1 100644 --- a/packages/nestjs-event/src/listeners/event-listener.ts +++ b/packages/nestjs-event/src/listeners/event-listener.ts @@ -1,9 +1,9 @@ -import { Listener as EmitterListener } from 'eventemitter2'; +import { type Listener as EmitterListener } from 'eventemitter2'; -import { EventInstance, EventReturnType } from '../event-types'; +import { type EventInstance, type EventReturnType } from '../event-types'; import { EventListenerException } from '../exceptions/event-listener.exception'; -import { EventListenerInterface } from './interfaces/event-listener.interface'; +import { type EventListenerInterface } from './interfaces/event-listener.interface'; /** * Abstract event listener class. diff --git a/packages/nestjs-event/src/listeners/interfaces/event-listener.interface.ts b/packages/nestjs-event/src/listeners/interfaces/event-listener.interface.ts index 090b52206..c0fa472fe 100644 --- a/packages/nestjs-event/src/listeners/interfaces/event-listener.interface.ts +++ b/packages/nestjs-event/src/listeners/interfaces/event-listener.interface.ts @@ -1,6 +1,6 @@ -import { Listener as EmitterListener } from 'eventemitter2'; +import { type Listener as EmitterListener } from 'eventemitter2'; -import { EventInstance, EventReturnType } from '../../event-types'; +import { type EventInstance, type EventReturnType } from '../../event-types'; /** * The interface that defines Event Listener signature. diff --git a/packages/nestjs-event/src/services/event-dispatch.service.spec.ts b/packages/nestjs-event/src/services/event-dispatch.service.spec.ts index 223c9df4d..764bd7e48 100644 --- a/packages/nestjs-event/src/services/event-dispatch.service.spec.ts +++ b/packages/nestjs-event/src/services/event-dispatch.service.spec.ts @@ -6,11 +6,11 @@ import { EVENT_MODULE_EMITTER_SERVICE_TOKEN, EVENT_MODULE_SETTINGS_TOKEN, } from '../event-constants'; -import { EventReturnType } from '../event-types'; +import { type EventReturnType } from '../event-types'; import { Event } from '../events/event'; import { EventAsync } from '../events/event-async'; import { EventDispatchException } from '../exceptions/event-dispatch.exception'; -import { EventSettingsInterface } from '../interfaces/event-settings.interface'; +import { type EventSettingsInterface } from '../interfaces/event-settings.interface'; import { EventDispatchService } from './event-dispatch.service'; diff --git a/packages/nestjs-event/src/services/event-listen.service.spec.ts b/packages/nestjs-event/src/services/event-listen.service.spec.ts index 0843e37f6..cc5a0a52f 100644 --- a/packages/nestjs-event/src/services/event-listen.service.spec.ts +++ b/packages/nestjs-event/src/services/event-listen.service.spec.ts @@ -9,7 +9,7 @@ import { import { Event } from '../events/event'; import { EventAsync } from '../events/event-async'; import { EventListenException } from '../exceptions/event-listen.exception'; -import { EventSettingsInterface } from '../interfaces/event-settings.interface'; +import { type EventSettingsInterface } from '../interfaces/event-settings.interface'; import { EventListenerOn } from '../listeners/event-listener-on'; import { EventListenService } from './event-listen.service'; diff --git a/packages/nestjs-event/src/services/interfaces/event-listen-on-options.interface.ts b/packages/nestjs-event/src/services/interfaces/event-listen-on-options.interface.ts index 867abccb0..eedbf9472 100644 --- a/packages/nestjs-event/src/services/interfaces/event-listen-on-options.interface.ts +++ b/packages/nestjs-event/src/services/interfaces/event-listen-on-options.interface.ts @@ -1,6 +1,7 @@ -import { OnOptions } from 'eventemitter2'; +import { type OnOptions } from 'eventemitter2'; /** * Interfaces defining public options object for "listen on" style events. */ -export interface EventListenOnOptionsInterface - extends Partial> {} +export interface EventListenOnOptionsInterface extends Partial< + Pick +> {} diff --git a/packages/nestjs-event/src/services/interfaces/event-listen-on.interface.ts b/packages/nestjs-event/src/services/interfaces/event-listen-on.interface.ts index bb8277fa3..083dc70f0 100644 --- a/packages/nestjs-event/src/services/interfaces/event-listen-on.interface.ts +++ b/packages/nestjs-event/src/services/interfaces/event-listen-on.interface.ts @@ -1,6 +1,6 @@ -import { EventListenerInterface } from '../../listeners/interfaces/event-listener.interface'; +import { type EventListenerInterface } from '../../listeners/interfaces/event-listener.interface'; -import { EventListenOnOptionsInterface } from './event-listen-on-options.interface'; +import { type EventListenOnOptionsInterface } from './event-listen-on-options.interface'; /** * The interface that defines Event Listen On signature. diff --git a/packages/nestjs-federated/README.md b/packages/nestjs-federated/README.md index 9189b4bf0..d010bfeff 100644 --- a/packages/nestjs-federated/README.md +++ b/packages/nestjs-federated/README.md @@ -1,262 +1,247 @@ # Rockets NestJS Federated Authentication -Authenticate via federated login +Authenticate via federated login (OAuth providers like GitHub, Google, Apple). ## Project [![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-federated)](https://www.npmjs.com/package/@concepta/nestjs-federated) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-federated)](https://www.npmjs.com/package/@concepta/nestjs-federated) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-federated)](https://www.npmjs.com/package/@concepta/nestjs-federated) [![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) [![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-federated%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) ## Table of Contents 1. [Tutorials](#tutorials) - [Introduction](#introduction) - - [Getting Started with Federated Authentication](#getting-started-with-federated-authentication) - - [Step 1: Create User Entity](#step-1-create-user-entity) - - [Step 2: Create Federated Entity](#step-2-create-federated-entity) - - [Step 3: Implement FederatedUserModelServiceInterface](#step-3-implement-federatedusermodelserviceinterface) - - [Step 4: Configure the Module](#step-4-configure-the-module) - - [Step 5: Integrate with other Oauth Module](#step-5-integrate-with-other-oauth-module) + - [Getting Started](#getting-started) + - [Step 1: Create the Identity Entity](#step-1-create-the-identity-entity) + - [Step 2: Configure the User Port](#step-2-configure-the-user-port) + - [Step 3: Configure the Module](#step-3-configure-the-module) + - [Step 4: Integrate with an OAuth Module](#step-4-integrate-with-an-oauth-module) 2. [How-To Guides](#how-to-guides) - - [Implement FederatedUserModelServiceInterface](#implement-federatedusermodelserviceinterface) - - [Using federated with Rockets Github Module](#using-federated-with-rockets-github-module) + - [Override the Identity Repository](#override-the-identity-repository) 3. [Reference](#reference) + - [Module Options](#module-options) + - [Key Exports](#key-exports) 4. [Explanation](#explanation) - - [Federated Services](#federated-services) - - [Module Options Responsibilities](#module-options-responsibilities) + - [Architecture](#architecture) + - [The Sign Flow](#the-sign-flow) ## Tutorials ### Introduction -Before we begin, you'll need to set up OAuth Apps for the social providers you -wish to use (e.g., GitHub, Google, Facebook) to obtain the necessary credentials. -For detailed guides on creating OAuth Apps and obtaining your Client IDs and -Client Secrets, please refer to the official documentation of each provider and -refer to the [`@concepta/nestjs-auth-github`](https://www.rockets.tools/reference/rockets/nestjs-auth-github/README), -[`nestjs-auth-apple`](https://www.rockets.tools/reference/rockets/nestjs-auth-apple/README), -and [`@concepta/nestjs-auth-google`](https://www.rockets.tools/reference/rockets/nestjs-auth-google/README) -documentation to use our modules. +The `@concepta/nestjs-federated` module manages the link between OAuth provider +identities and your application's users. When a user authenticates via an +external provider (GitHub, Google, Apple), this module: -### Getting Started with Federated Authentication +1. Looks up an existing identity record by provider + subject +2. If found, returns the associated user +3. If not found, creates the user and identity record in a single transaction -#### Installation +Before you begin, set up OAuth Apps for your social providers to obtain Client +IDs and Client Secrets. Refer to the provider-specific auth modules: -To get started, install the `FederatedModule` package: +- [`@concepta/nestjs-auth-github`](https://www.rockets.tools/reference/rockets/nestjs-auth-github/README) +- [`@concepta/nestjs-auth-apple`](https://www.rockets.tools/reference/rockets/nestjs-auth-apple/README) +- [`@concepta/nestjs-auth-google`](https://www.rockets.tools/reference/rockets/nestjs-auth-google/README) -`yarn add @concepta/nestjs-federated` +### Getting Started -### Step 1: Create User Entity +#### Installation -First, let's create the `UserEntity`: +```sh +yarn add @concepta/nestjs-federated @nestjs/common @nestjs/config @nestjs/core +``` -```ts -import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; -import { FederatedEntity } from '../federated/federated.entity'; +This package is ESM-only and requires Node.js >= 22.12 and NestJS 12. -@Entity() -export class UserEntity { - @PrimaryGeneratedColumn('uuid') - id: string; +### Peer Dependencies - @Column() - name: string; +| Package | Required | Notes | +| --- | --- | --- | +| `@nestjs/common` | Yes | NestJS framework peer | +| `@nestjs/config` | Yes | Used by the module's config factory | +| `@nestjs/core` | Yes | Required transitively by `@nestjs/cqrs` | +| `@nestjs/cqrs` | No | Optional peer — required in practice, `FederatedUserPort` dispatches through the `QueryBus`/`CommandBus` | +| `rxjs` | Yes | NestJS requirement | +| `typeorm` | No | Only if using the TypeORM repository adapter | +| `@concepta/nestjs-repository-typeorm` | No | Only if using the TypeORM repository adapter | - @OneToMany(() => FederatedEntity, (federated) => federated.user) - federated!: FederatedEntity; -} +For TypeORM entity base classes (optional): + +```sh +yarn add @concepta/nestjs-repository-typeorm ``` -### Step 2: Create Federated Entity +### Step 1: Create the Identity Entity -Next, create the `FederatedEntity`: +Create a concrete entity that extends one of the abstract base classes from the +optional TypeORM subpath: ```ts -import { Entity, ManyToOne } from 'typeorm'; -import { FederatedSqliteEntity } from '@concepta/nestjs-typeorm-ext'; +import { Entity, ManyToOne, JoinColumn } from 'typeorm'; +import { IdentitySqliteEntity } from '@concepta/nestjs-federated/optional/typeorm'; import { UserEntity } from '../user/user.entity'; @Entity() -export class FederatedEntity extends FederatedSqliteEntity { - @ManyToOne(() => UserEntity, (user) => user.federated) - user!: UserEntity; +export class IdentityEntity extends IdentitySqliteEntity { + @ManyToOne(() => UserEntity, { eager: true }) + @JoinColumn() + user: UserEntity; } ``` -### Step 3: Implement FederatedUserModelServiceInterface +The `user` property is declared `abstract` on the base class, so you must +provide it with the appropriate TypeORM relationship decorator. + +For PostgreSQL, extend `IdentityPostgresEntity` instead. + +### Step 2: Configure the User Port -Refer to [Implement FederatedUserModelServiceInterface](#implement-federatedusermodelserviceinterface) -section +The module communicates with your user system through a `userPort` — a set of +query/command class references that the module dispatches via the NestJS CQRS +`QueryBus` and `CommandBus`. -### Step 4: Configure the Module +You need to provide three class references: -Finally, set up the module configuration: +- `getByIdQuery` — a query class with `(ctx, id)` constructor +- `getByEmailQuery` — a query class with `(ctx, email)` constructor +- `createCommand` — a command class with `(ctx, dto)` constructor + +Each must have a registered handler in your application. For example, if you use +`@concepta/nestjs-user`, its `GetUserQuery`, `GetUserByEmailQuery`, and +`CreateUserCommand` satisfy these contracts. + +### Step 3: Configure the Module ```ts -import { AuthenticationModule } from '@concepta/nestjs-authentication'; -import { FederatedModule } from '@concepta/nestjs-federated'; -import { JwtModule } from '@concepta/nestjs-jwt'; import { Module } from '@nestjs/common'; -import { FederatedUserModelService } from './federated/federated-model.service'; -import { FederatedEntity } from './federated/federated.entity'; -import { AuthGithubModule } from '@concepta/nestjs-auth-github'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { UserEntity } from './user/user.entity'; +import { FederatedModule } from '@concepta/nestjs-federated'; +import { GetUserQuery } from './user/queries/get-user.query'; +import { GetUserByEmailQuery } from './user/queries/get-user-by-email.query'; +import { CreateUserCommand } from './user/commands/create-user.command'; @Module({ imports: [ - ConfigModule.forRoot({ - isGlobal: true, - }), - TypeOrmExtModule.forRoot({ - type: 'sqlite', - database: ':memory:', - entities: [UserEntity, FederatedEntity], - }), - JwtModule.forRoot({}), - AuthenticationModule.forRoot({}), - TypeOrmExtModule.forFeature({ - federated: { - entity: FederatedEntity, - }, - }), FederatedModule.forRoot({ - userModelService: new FederatedUserModelService(), + entities: { identity: 'identity' }, + userPort: { + getByIdQuery: GetUserQuery, + getByEmailQuery: GetUserByEmailQuery, + createCommand: CreateUserCommand, + }, }), ], - controllers: [], - providers: [], }) export class AppModule {} ``` -This configuration uses SQLite for testing, but you can use any database -supported by TypeORM. +### Step 4: Integrate with an OAuth Module -### Step 5: Integrate with other Oauth Module +To complete the authentication flow, use one of the Rockets auth modules: -To complete the integration with OAuth providers and set up the whole -authentication flow, you'll need to implement one of the @concepta social -authentication modules. Follow the documentation for the specific module you -want to use: +- [GitHub Authentication](https://www.rockets.tools/reference/rockets/nestjs-auth-github/README) +- [Apple Authentication](https://www.rockets.tools/reference/rockets/nestjs-auth-apple/README) +- [Google Authentication](https://www.rockets.tools/reference/rockets/nestjs-auth-google/README) -1. GitHub Authentication: - Refer to the [@concepta/nestjs-auth-github documentation](https://www.rockets.tools/reference/rockets/nestjs-auth-github/README) - for detailed instructions on setting up GitHub OAuth authentication. - -2. Apple Authentication: - For Apple Sign-In, follow the [nestjs-auth-apple documentation](https://www.rockets.tools/reference/rockets/nestjs-auth-apple/README) - to implement Apple's OAuth flow. - -3. Google Authentication: - To set up Google OAuth, consult the [@concepta/nestjs-auth-google documentation](https://www.rockets.tools/reference/rockets/nestjs-auth-google/README) - for step-by-step guidance. - -These documentation resources will guide you through: - -- Obtaining the necessary OAuth credentials from the respective providers -- Configuring the OAuth module in your NestJS application -- Setting up the required controllers and routes -- Implementing the authentication flow - -By following these provider-specific guides, you'll be able to complete the -federated authentication setup and enable users to log in using their preferred -social accounts. +These modules call `FederatedOAuthService.sign()` internally to handle the +identity lookup and user creation. ## How-To Guides -### Implement FederatedUserModelServiceInterface +### Override the Identity Repository -Create a service that implements `FederatedUserModelServiceInterface`: - -```ts -// user.mock.ts -export const mockUser = { - id: 'abc', - email: 'me@dispostable.com', - username: 'me@dispostable.com', -} -``` +To provide a custom repository implementation, pass it via the `repositories` +option: ```ts -// user-model.service.ts -import { Injectable } from '@nestjs/common'; -import { ReferenceEmail } from '@concepta/nestjs-common'; -import { - FederatedUserModelServiceInterface - FederatedCredentialsInterface, -} from '@concepta/nestjs-federated'; -import { mockUser } from './user.mock'; - - -@Injectable() -export class UserModelServiceFixture - implements FederatedUserModelServiceInterface -{ - async byId( - id: string - ): ReturnType { - if (id === mockUser.id) { - return mockUser; - } else { - throw new Error(); - } - } - - async byEmail( - email: ReferenceEmail - ): Promise { - return email === mockUser.email ? mockUser : null; - } - - async create( - _object: ReferenceEmailInterface & ReferenceUsernameInterface - ): Promise { - return mockUser; - } -} +FederatedModule.forRoot({ + entities: { identity: 'identity' }, + repositories: { + identity: CustomIdentityRepository, + }, + userPort: { ... }, +}) ``` -### Using federated with Rockets Github Module - -For detailed instructions on using the federated module with the Rockets GitHub -module, please refer to the [@concepta/nestjs-auth-github documentation](https://www.rockets.tools/reference/rockets/nestjs-auth-github/README). +Your custom repository must implement `IdentityRepositoryInterface`. ## Reference -For detailed information on the properties, methods, and classes used in -the `@concepta/nestjs-federated`, please refer to the API documentation -available at -[FederatedModule API Documentation](https://www.rockets.tools/reference/rockets/nestjs-federated/README). -This documentation provides comprehensive details on the interfaces and -services that you can utilize to customize and extend the authentication -functionality within your NestJS application. +### Module Options + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `userPort` | `FederatedUserPortSettings` | Yes | Query/command class references for user lookup and creation | +| `settings` | `FederatedSettingsInterface` | No | Reserved for future settings | + +#### Extras (passed alongside options) + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `global` | `boolean` | No | Register as a global module (default: `false`) | +| `entities.identity` | `string` | No | Entity key for identity (default: `'identity'`) | +| `repositories.identity` | `Type` | No | Custom repository class | + +### Key Exports + +| Export | Type | Description | +|--------|------|-------------| +| `FederatedModule` | Module | The NestJS dynamic module | +| `FederatedOAuthService` | Service | Core orchestration service with `sign()` method | +| `Identity` | Aggregate | Domain aggregate for identity records | +| `IdentityCreatedEvent` | Event | Emitted when a new identity is created | +| `FederatedUserPort` | Port | QueryBus/CommandBus-based port for user operations | +| `CreateIdentityCommand` | Command | CQRS command for direct identity creation | +| `FindIdentityByProviderQuery` | Query | CQRS query to find identity by provider + subject | +| `IdentityRepositoryInterface` | Interface | Contract for custom repository implementations | +| `FederatedCredentialsInterface` | Interface | User credentials shape (`id`, `email`, `username`) | +| `identitySchema` | Schema | Zod schema for identity records (serialization) | +| `identityCreateSchema` | Schema | Zod schema for identity creation (validation) | +| `FederatedException` | Exception | Base exception (extends `RuntimeException`, which extends NestJS's `HttpException` — no filter needed; wire body `{ statusCode, message, errorCode, error? }`) | +| `IdentityCreateUserException` | Exception | User creation via the user port failed | +| `IdentityFindUserException` | Exception | User lookup via the user port failed | +| `IdentityUserRelationshipException` | Exception | Identity record has no valid user relationship | + +#### Optional TypeORM Exports + +Available via `@concepta/nestjs-federated/optional/typeorm`: + +| Export | Description | +|--------|-------------| +| `IdentitySqliteEntity` | Abstract base entity for SQLite | +| `IdentityPostgresEntity` | Abstract base entity for PostgreSQL | ## Explanation -### Federated Services - -1. **User Creation and Association**: The federated service then takes over: - - It checks if a user associated with the provider (e.g., GitHub, Google, - Facebook) account already exists. - - If the user doesn't exist, it creates a new user account. - - It associates the provider with the user account, creating a - link between the user's application account and their provider identity. - -### Module Options Responsibilities - -The `FederatedOptionsInterface` defines the configuration options for the -federated module. Here are the responsibilities of each option: - -- **userModelService**: This is an implementation of the - `FederatedUserModelServiceInterface`. It is responsible for looking up users - based on various criteria such as user ID or email. This service ensures that - the application can retrieve user information from the database or any other - storage mechanism. - -By configuring these options, you can customize the behavior of the federated -module to suit your application's requirements, ensuring seamless integration -with multiple social authentication providers. +### Architecture + +The module follows DDD/Clean Architecture: + +- **Domain layer**: `Identity` aggregate (write-once), `FederatedOAuthService` + (orchestration), `FederatedUserPort` (external user system integration), + repository interface +- **Application layer**: `CreateIdentityCommand` and + `FindIdentityByProviderQuery` with their handlers +- **Infrastructure layer**: TypeORM entity base classes, repository + implementation, mapper, Zod schemas, provider factories + +### The Sign Flow + +`FederatedOAuthService.sign(ctx, provider, email, subject)` orchestrates the +full federated login: + +1. **Lookup**: Find an existing identity by `provider` + `subject` +2. **Existing identity found**: + - Verify the identity has a valid user relationship + - Look up the user via `FederatedUserPort.getById()` + - Return the user credentials +3. **No identity found** (wrapped in a transaction): + - Check if a user with the given email already exists via + `FederatedUserPort.getByEmail()` + - If no user exists, create one via `FederatedUserPort.create()` + - Create an `Identity` aggregate and persist it + - Emit `IdentityCreatedEvent` on transaction commit + - Return the user credentials diff --git a/packages/nestjs-federated/package.json b/packages/nestjs-federated/package.json index dbf422883..5fff59b5d 100644 --- a/packages/nestjs-federated/package.json +++ b/packages/nestjs-federated/package.json @@ -1,30 +1,60 @@ { "name": "@concepta/nestjs-federated", - "version": "7.0.0-alpha.10", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "version": "8.0.0-alpha.10", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./optional/typeorm": { + "types": "./dist/optional-typeorm.d.ts", + "default": "./dist/optional-typeorm.js" + } + }, "license": "BSD-3-Clause", "publishConfig": { "access": "public" }, "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" ], "dependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/swagger": "^11.2.2" + "@concepta/nestjs-core": "^8.0.0-alpha.10", + "@concepta/nestjs-repository": "8.0.0-alpha.10", + "zod": "^4.4.3" }, "devDependencies": { - "@concepta/nestjs-typeorm-ext": "^7.0.0-alpha.10", - "@concepta/nestjs-user": "^7.0.0-alpha.10", - "@nestjs/testing": "^11.1.9" + "@concepta/nestjs-repository-typeorm": "8.0.0-alpha.10", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/testing": "^12.0.1", + "vitest-mock-extended": "^4.0.0" }, "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", + "@concepta/nestjs-repository-typeorm": "8.0.0-alpha.10", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", "rxjs": "^7.1.0", "typeorm": "^0.3.0" + }, + "peerDependenciesMeta": { + "@concepta/nestjs-repository-typeorm": { + "optional": true + }, + "@nestjs/cqrs": { + "optional": true + }, + "typeorm": { + "optional": true + } } } diff --git a/packages/nestjs-federated/src/__fixtures__/federated/federated-entity.fixture.ts b/packages/nestjs-federated/src/__fixtures__/federated/federated-entity.fixture.ts deleted file mode 100644 index a87db77f6..000000000 --- a/packages/nestjs-federated/src/__fixtures__/federated/federated-entity.fixture.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Entity } from 'typeorm'; - -import { FederatedSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -@Entity() -export class FederatedEntityFixture extends FederatedSqliteEntity {} diff --git a/packages/nestjs-federated/src/__fixtures__/user/entities/user.entity.fixture.ts b/packages/nestjs-federated/src/__fixtures__/user/entities/user.entity.fixture.ts deleted file mode 100644 index 0ef2b8de4..000000000 --- a/packages/nestjs-federated/src/__fixtures__/user/entities/user.entity.fixture.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Entity } from 'typeorm'; - -import { UserSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -@Entity() -export class UserEntityFixture extends UserSqliteEntity {} diff --git a/packages/nestjs-federated/src/__fixtures__/user/services/user-model.service.fixture.ts b/packages/nestjs-federated/src/__fixtures__/user/services/user-model.service.fixture.ts deleted file mode 100644 index d127fad54..000000000 --- a/packages/nestjs-federated/src/__fixtures__/user/services/user-model.service.fixture.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ReferenceEmail, - ReferenceEmailInterface, - ReferenceUsernameInterface, -} from '@concepta/nestjs-common'; - -import { FederatedCredentialsInterface } from '../../../interfaces/federated-credentials.interface'; -import { FederatedUserModelServiceInterface } from '../../../interfaces/federated-user-model-service.interface'; -import { UserFixture } from '../user.fixture'; - -@Injectable() -export class UserModelServiceFixture - implements FederatedUserModelServiceInterface -{ - async byId( - id: string, - ): ReturnType { - if (id === UserFixture.id) { - return UserFixture; - } else { - throw new Error(); - } - } - - async byEmail( - email: ReferenceEmail, - ): ReturnType { - return email === UserFixture.email ? UserFixture : null; - } - - async create( - _object: ReferenceEmailInterface & ReferenceUsernameInterface, - ): Promise { - return UserFixture; - } -} diff --git a/packages/nestjs-federated/src/__fixtures__/user/user.fixture.ts b/packages/nestjs-federated/src/__fixtures__/user/user.fixture.ts deleted file mode 100644 index 9eae02f84..000000000 --- a/packages/nestjs-federated/src/__fixtures__/user/user.fixture.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const UserFixture = { - id: 'abc', - email: 'me@dispostable.com', - username: 'me@dispostable.com', -}; diff --git a/packages/nestjs-federated/src/__fixtures__/user/user.module.fixture.ts b/packages/nestjs-federated/src/__fixtures__/user/user.module.fixture.ts deleted file mode 100644 index 980ef90c4..000000000 --- a/packages/nestjs-federated/src/__fixtures__/user/user.module.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { UserModelServiceFixture } from './services/user-model.service.fixture'; - -@Global() -@Module({ - providers: [UserModelServiceFixture], - exports: [UserModelServiceFixture], -}) -export class UserModuleFixture {} diff --git a/packages/nestjs-federated/src/__tests__/exception-fault.spec.ts b/packages/nestjs-federated/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..5629e0a7a --- /dev/null +++ b/packages/nestjs-federated/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,61 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { FederatedException } from '../domain/exceptions/federated.exception.js'; +import { IdentityCreateUserException } from '../domain/exceptions/identity-create-user.exception.js'; +import { IdentityFindUserException } from '../domain/exceptions/identity-find-user.exception.js'; +import { IdentityUserRelationshipException } from '../domain/exceptions/identity-user-relationship.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'FederatedException (default)', + build: () => new FederatedException(), + fault: 'internal', + }, + { + name: 'IdentityCreateUserException', + build: () => new IdentityCreateUserException('SomeEntity'), + fault: 'internal', + }, + { + name: 'IdentityFindUserException', + build: () => new IdentityFindUserException('SomeEntity', { id: 'user-id' }), + fault: 'client', + }, + { + name: 'IdentityUserRelationshipException', + build: () => new IdentityUserRelationshipException('identity-id'), + fault: 'internal', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-federated/src/__tests__/fixtures/entities/identity-entity.fixture.ts b/packages/nestjs-federated/src/__tests__/fixtures/entities/identity-entity.fixture.ts new file mode 100644 index 000000000..5d19df294 --- /dev/null +++ b/packages/nestjs-federated/src/__tests__/fixtures/entities/identity-entity.fixture.ts @@ -0,0 +1,11 @@ +import { Entity, ManyToOne } from 'typeorm'; + +import { ReferenceIdInterface } from '@concepta/nestjs-core'; + +import { IdentitySqliteEntity } from '../../../infrastructure/persistence/typeorm/identity-sqlite.entity.js'; + +@Entity() +export class IdentityEntityFixture extends IdentitySqliteEntity { + @ManyToOne('UserEntityFixture', { eager: true }) + user!: ReferenceIdInterface; +} diff --git a/packages/nestjs-federated/src/__tests__/helpers/mock.helpers.ts b/packages/nestjs-federated/src/__tests__/helpers/mock.helpers.ts new file mode 100644 index 000000000..ac1c9e696 --- /dev/null +++ b/packages/nestjs-federated/src/__tests__/helpers/mock.helpers.ts @@ -0,0 +1,55 @@ +import { mock } from 'vitest-mock-extended'; + +import { + createMockCommandBus, + createMockEventPublisher, +} from '@concepta/nestjs-core/testing'; +import { createMockTransaction } from '@concepta/nestjs-repository/testing'; + +import { type Identity } from '../../domain/aggregates/identity.js'; +import { type FederatedUserPort } from '../../domain/ports/federated-user.port.js'; +import { type FederatedOAuthService } from '../../domain/services/federated-oauth.service.js'; +import { IdentityMapper } from '../../infrastructure/persistence/identity.mapper.js'; +import { type IdentityRepository } from '../../infrastructure/persistence/identity.repository.js'; +import { type IdentityEntityInterface } from '../../infrastructure/persistence/interfaces/identity-entity.interface.js'; + +export { + createMockCommandBus, + createMockEventPublisher, + createMockTransaction, +}; +export type { MockTransactionHandle } from '@concepta/nestjs-repository/testing'; + +export function createMockIdentityRepository() { + return mock(); +} + +export function createMockFederatedUserPort() { + return mock(); +} + +export function createMockFederatedOAuthService() { + return mock(); +} + +export function createMockIdentityEntity( + overrides: Record = {}, +): IdentityEntityInterface { + return { + id: 'identity-id', + provider: 'google', + subject: 'subject-id', + user: { id: 'user-id' }, + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + ...overrides, + }; +} + +const identityMapper = new IdentityMapper(); + +export function toIdentityDomain(entity: IdentityEntityInterface): Identity { + return identityMapper.toDomain(entity); +} diff --git a/packages/nestjs-federated/src/application/commands/handlers/create-identity.handler.ts b/packages/nestjs-federated/src/application/commands/handlers/create-identity.handler.ts new file mode 100644 index 000000000..af35dbf94 --- /dev/null +++ b/packages/nestjs-federated/src/application/commands/handlers/create-identity.handler.ts @@ -0,0 +1,39 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { Identity } from '../../../domain/aggregates/identity.js'; +import { IdentityRepositoryInterface } from '../../../domain/repositories/identity-repository.interface.js'; +import { FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN } from '../../../federated.constants.js'; +import { CreateIdentityCommand } from '../impl/create-identity.command.js'; + +@CommandHandler(CreateIdentityCommand) +export class CreateIdentityHandler implements ICommandHandler { + constructor( + @Inject(FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN) + private readonly identityRepo: IdentityRepositoryInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: CreateIdentityCommand): Promise { + const { ctx, dto } = command; + + return this.txScope.run(ctx, async (txCtx) => { + const eventContext = createEventContext(txCtx, {}, {}); + + const identity = this.eventPublisher.mergeObjectContext( + Identity.create(eventContext, dto), + ); + + await this.identityRepo.save(txCtx, identity); + + txCtx.trx.onCommit(() => identity.commit()); + txCtx.trx.onRollback(() => identity.uncommit()); + + return identity; + }); + } +} diff --git a/packages/nestjs-federated/src/application/commands/impl/create-identity.command.ts b/packages/nestjs-federated/src/application/commands/impl/create-identity.command.ts new file mode 100644 index 000000000..11d78c52b --- /dev/null +++ b/packages/nestjs-federated/src/application/commands/impl/create-identity.command.ts @@ -0,0 +1,14 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type Identity } from '../../../domain/aggregates/identity.js'; +import { type IdentityCreatableInterface } from '../../../domain/interfaces/identity-creatable.interface.js'; + +export class CreateIdentityCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly dto: IdentityCreatableInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-federated/src/application/queries/handlers/find-identity-by-provider.handler.ts b/packages/nestjs-federated/src/application/queries/handlers/find-identity-by-provider.handler.ts new file mode 100644 index 000000000..e1e7276a3 --- /dev/null +++ b/packages/nestjs-federated/src/application/queries/handlers/find-identity-by-provider.handler.ts @@ -0,0 +1,20 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { Identity } from '../../../domain/aggregates/identity.js'; +import { IdentityRepositoryInterface } from '../../../domain/repositories/identity-repository.interface.js'; +import { FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN } from '../../../federated.constants.js'; +import { FindIdentityByProviderQuery } from '../impl/find-identity-by-provider.query.js'; + +@QueryHandler(FindIdentityByProviderQuery) +export class FindIdentityByProviderHandler implements IQueryHandler { + constructor( + @Inject(FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN) + private readonly identityRepo: IdentityRepositoryInterface, + ) {} + + async execute(query: FindIdentityByProviderQuery): Promise { + const { ctx, provider, subject } = query; + return this.identityRepo.findByProviderAndSubject(ctx, provider, subject); + } +} diff --git a/packages/nestjs-federated/src/application/queries/impl/find-identity-by-provider.query.ts b/packages/nestjs-federated/src/application/queries/impl/find-identity-by-provider.query.ts new file mode 100644 index 000000000..0019efe90 --- /dev/null +++ b/packages/nestjs-federated/src/application/queries/impl/find-identity-by-provider.query.ts @@ -0,0 +1,14 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type Identity } from '../../../domain/aggregates/identity.js'; + +export class FindIdentityByProviderQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly provider: string, + public readonly subject: string, + ) { + super(); + } +} diff --git a/packages/nestjs-federated/src/config/federated-default.config.ts b/packages/nestjs-federated/src/config/federated-default.config.ts deleted file mode 100644 index be7b69aef..000000000 --- a/packages/nestjs-federated/src/config/federated-default.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { FEDERATED_MODULE_DEFAULT_SETTINGS_TOKEN } from '../federated.constants'; -import { FederatedSettingsInterface } from '../interfaces/federated-settings.interface'; - -/** - * Default configuration for federated module. - */ -export const federatedDefaultConfig = registerAs( - FEDERATED_MODULE_DEFAULT_SETTINGS_TOKEN, - (): FederatedSettingsInterface => ({}), -); diff --git a/packages/nestjs-federated/src/domain/aggregates/identity.ts b/packages/nestjs-federated/src/domain/aggregates/identity.ts new file mode 100644 index 000000000..6bacd9570 --- /dev/null +++ b/packages/nestjs-federated/src/domain/aggregates/identity.ts @@ -0,0 +1,59 @@ +import { randomUUID } from 'crypto'; + +import { + type DomainFactory, + type EventContextHost, + type ReferenceIdInterface, +} from '@concepta/nestjs-core'; +import { + type AggregateMetaInterface, + DomainAggregate, +} from '@concepta/nestjs-core/aggregate'; + +import { IdentityCreatedEvent } from '../events/identity-created.event.js'; +import { type IdentityCreatableInterface } from '../interfaces/identity-creatable.interface.js'; +import { type IdentityInterface } from '../interfaces/identity.interface.js'; + +export class Identity extends DomainAggregate { + constructor( + id: string, + props: IdentityInterface, + version?: number, + meta?: AggregateMetaInterface, + ) { + super(id, props, version, meta); + } + + get provider() { + return this.props.provider; + } + get subject() { + return this.props.subject; + } + get user(): ReferenceIdInterface { + return this.props.user; + } + + static create( + eventContext: EventContextHost, + dto: IdentityCreatableInterface, + ): Identity { + return Identity.createWithId(eventContext, randomUUID(), dto); + } + + static createWithId( + eventContext: EventContextHost, + id: string, + dto: IdentityCreatableInterface, + ): Identity { + const { provider, subject, user } = dto; + + const identity = new Identity(id, { provider, subject, user }); + + identity.apply(new IdentityCreatedEvent(eventContext, identity.toPlain())); + + return identity; + } +} + +Identity satisfies DomainFactory; diff --git a/packages/nestjs-federated/src/domain/events/identity-created.event.ts b/packages/nestjs-federated/src/domain/events/identity-created.event.ts new file mode 100644 index 000000000..2790c70ed --- /dev/null +++ b/packages/nestjs-federated/src/domain/events/identity-created.event.ts @@ -0,0 +1,12 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type IdentityInterface } from '../interfaces/identity.interface.js'; + +export class IdentityCreatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly identity: IdentityInterface, + ) {} +} diff --git a/packages/nestjs-federated/src/domain/exceptions/federated.exception.ts b/packages/nestjs-federated/src/domain/exceptions/federated.exception.ts new file mode 100644 index 000000000..ca8e7b18d --- /dev/null +++ b/packages/nestjs-federated/src/domain/exceptions/federated.exception.ts @@ -0,0 +1,11 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +export class FederatedException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super(options); + this.errorCode = 'FEDERATED_ERROR'; + } +} diff --git a/packages/nestjs-federated/src/domain/exceptions/identity-create-user.exception.ts b/packages/nestjs-federated/src/domain/exceptions/identity-create-user.exception.ts new file mode 100644 index 000000000..947c99e64 --- /dev/null +++ b/packages/nestjs-federated/src/domain/exceptions/identity-create-user.exception.ts @@ -0,0 +1,28 @@ +import { + type RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +import { FederatedException } from './federated.exception.js'; + +export class IdentityCreateUserException extends FederatedException { + declare context: RuntimeException['context'] & { + entityName: string; + }; + + constructor(entityName: string, options?: RuntimeExceptionOptions) { + super({ + message: 'Error while trying to create a %s reference', + messageParams: [entityName], + fault: 'internal', + ...options, + }); + + this.context = { + ...this.context, + entityName, + }; + + this.errorCode = 'FEDERATED_IDENTITY_CREATE_USER_ERROR'; + } +} diff --git a/packages/nestjs-federated/src/domain/exceptions/identity-find-user.exception.ts b/packages/nestjs-federated/src/domain/exceptions/identity-find-user.exception.ts new file mode 100644 index 000000000..f0349231c --- /dev/null +++ b/packages/nestjs-federated/src/domain/exceptions/identity-find-user.exception.ts @@ -0,0 +1,38 @@ +import { HttpStatus } from '@nestjs/common'; + +import { + type ReferenceIdInterface, + type RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +import { FederatedException } from './federated.exception.js'; + +export class IdentityFindUserException extends FederatedException { + declare context: RuntimeException['context'] & { + entityName: string; + user: ReferenceIdInterface; + }; + + constructor( + entityName: string, + user: ReferenceIdInterface, + options?: RuntimeExceptionOptions, + ) { + super({ + message: 'Error while trying to find user %s', + messageParams: [user.id], + httpStatus: HttpStatus.NOT_FOUND, + fault: 'client', + ...options, + }); + + this.errorCode = 'FEDERATED_IDENTITY_FIND_USER_ERROR'; + + this.context = { + ...this.context, + entityName, + user, + }; + } +} diff --git a/packages/nestjs-federated/src/domain/exceptions/identity-user-relationship.exception.ts b/packages/nestjs-federated/src/domain/exceptions/identity-user-relationship.exception.ts new file mode 100644 index 000000000..e91336fbd --- /dev/null +++ b/packages/nestjs-federated/src/domain/exceptions/identity-user-relationship.exception.ts @@ -0,0 +1,35 @@ +import { HttpStatus } from '@nestjs/common'; + +import { + type RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +import { FederatedException } from './federated.exception.js'; + +export class IdentityUserRelationshipException extends FederatedException { + declare context: RuntimeException['context'] & { + identityId: string; + }; + + constructor(identityId: string, options?: RuntimeExceptionOptions) { + super({ + // A stored federated identity whose `user` relation is null/missing is + // a dangling reference in our own data (broken FK, missing eager + // load) — the caller presented a valid identity, so this isn't their + // mistake. + message: 'Error while trying to load user relationship from identity %s', + messageParams: [identityId], + httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + fault: 'internal', + ...options, + }); + + this.errorCode = 'FEDERATED_IDENTITY_USER_RELATIONSHIP_ERROR'; + + this.context = { + ...this.context, + identityId, + }; + } +} diff --git a/packages/nestjs-federated/src/domain/interfaces/identity-creatable.interface.ts b/packages/nestjs-federated/src/domain/interfaces/identity-creatable.interface.ts new file mode 100644 index 000000000..6186ebaa2 --- /dev/null +++ b/packages/nestjs-federated/src/domain/interfaces/identity-creatable.interface.ts @@ -0,0 +1,6 @@ +import { type IdentityInterface } from './identity.interface.js'; + +export interface IdentityCreatableInterface extends Pick< + IdentityInterface, + 'provider' | 'subject' | 'user' +> {} diff --git a/packages/nestjs-federated/src/domain/interfaces/identity.interface.ts b/packages/nestjs-federated/src/domain/interfaces/identity.interface.ts new file mode 100644 index 000000000..0b1a28d09 --- /dev/null +++ b/packages/nestjs-federated/src/domain/interfaces/identity.interface.ts @@ -0,0 +1,7 @@ +import { type ReferenceIdInterface } from '@concepta/nestjs-core'; + +export interface IdentityInterface { + provider: string; + subject: string; + user: ReferenceIdInterface; +} diff --git a/packages/nestjs-federated/src/domain/ports/federated-user.port.ts b/packages/nestjs-federated/src/domain/ports/federated-user.port.ts new file mode 100644 index 000000000..6458dc4e4 --- /dev/null +++ b/packages/nestjs-federated/src/domain/ports/federated-user.port.ts @@ -0,0 +1,70 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; + +import { + ReferenceEmail, + ReferenceEmailInterface, + ReferenceId, + ReferenceUsernameInterface, +} from '@concepta/nestjs-core'; + +import { FederatedCredentialsInterface } from '../../interfaces/federated-credentials.interface.js'; + +export type FederatedUserResult = FederatedCredentialsInterface | null; + +export interface GetUserByIdQueryInterface { + ctx: PlainLiteralObject; + id: ReferenceId; +} + +export interface GetUserByEmailQueryInterface { + ctx: PlainLiteralObject; + email: ReferenceEmail; +} + +export interface CreateUserCommandInterface { + ctx: PlainLiteralObject; + dto: ReferenceEmailInterface & ReferenceUsernameInterface; +} + +export interface FederatedUserPortSettings { + getByIdQuery: Type; + getByEmailQuery: Type; + createCommand: Type; +} + +@Injectable() +export class FederatedUserPort { + constructor( + private readonly portSettings: FederatedUserPortSettings, + private readonly queryBus: QueryBus, + private readonly commandBus: CommandBus, + ) {} + + async getById( + ctx: PlainLiteralObject, + userId: ReferenceId, + ): Promise { + return this.queryBus.execute( + new this.portSettings.getByIdQuery(ctx, userId), + ); + } + + async getByEmail( + ctx: PlainLiteralObject, + email: ReferenceEmail, + ): Promise { + return this.queryBus.execute( + new this.portSettings.getByEmailQuery(ctx, email), + ); + } + + async create( + ctx: PlainLiteralObject, + dto: ReferenceEmailInterface & ReferenceUsernameInterface, + ): Promise { + return this.commandBus.execute( + new this.portSettings.createCommand(ctx, dto), + ); + } +} diff --git a/packages/nestjs-federated/src/domain/repositories/identity-repository.interface.ts b/packages/nestjs-federated/src/domain/repositories/identity-repository.interface.ts new file mode 100644 index 000000000..d54746574 --- /dev/null +++ b/packages/nestjs-federated/src/domain/repositories/identity-repository.interface.ts @@ -0,0 +1,19 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Identity } from '../aggregates/identity.js'; + +export interface IdentityRepositoryInterface { + get(ctx: PlainLiteralObject, id: ReferenceId): Promise; + + findByProviderAndSubject( + ctx: PlainLiteralObject, + provider: string, + subject: string, + ): Promise; + + save(ctx: PlainLiteralObject, identity: Identity): Promise; + + remove(ctx: PlainLiteralObject, identity: Identity): Promise; +} diff --git a/packages/nestjs-federated/src/domain/services/federated-oauth-service.interface.ts b/packages/nestjs-federated/src/domain/services/federated-oauth-service.interface.ts new file mode 100644 index 000000000..50b0f4def --- /dev/null +++ b/packages/nestjs-federated/src/domain/services/federated-oauth-service.interface.ts @@ -0,0 +1,12 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type FederatedCredentialsInterface } from '../../interfaces/federated-credentials.interface.js'; + +export interface FederatedOAuthServiceInterface { + sign( + ctx: PlainLiteralObject, + provider: string, + email: string, + subject: string, + ): Promise; +} diff --git a/packages/nestjs-federated/src/domain/services/federated-oauth.service.spec.ts b/packages/nestjs-federated/src/domain/services/federated-oauth.service.spec.ts new file mode 100644 index 000000000..0f65fd98c --- /dev/null +++ b/packages/nestjs-federated/src/domain/services/federated-oauth.service.spec.ts @@ -0,0 +1,203 @@ +import { HttpStatus } from '@nestjs/common'; +import { type EventPublisher } from '@nestjs/cqrs'; + +import { AppContextHost } from '@concepta/nestjs-core'; + +import { + createMockEventPublisher, + createMockIdentityRepository, + createMockFederatedUserPort, + createMockTransaction, + createMockIdentityEntity, + toIdentityDomain, +} from '../../__tests__/helpers/mock.helpers.js'; +import { type FederatedCredentialsInterface } from '../../interfaces/federated-credentials.interface.js'; +import { IdentityCreateUserException } from '../exceptions/identity-create-user.exception.js'; +import { IdentityFindUserException } from '../exceptions/identity-find-user.exception.js'; +import { IdentityUserRelationshipException } from '../exceptions/identity-user-relationship.exception.js'; + +import { FederatedOAuthService } from './federated-oauth.service.js'; + +describe(FederatedOAuthService, () => { + let service: FederatedOAuthService; + let identityRepo: ReturnType; + let userPort: ReturnType; + let txScope: ReturnType['transaction']; + let eventPublisher: EventPublisher; + + const mockUser: FederatedCredentialsInterface = { + id: 'user-id', + email: 'test@example.com', + username: 'testuser', + }; + + const mockIdentityEntity = createMockIdentityEntity(); + const mockIdentity = toIdentityDomain(mockIdentityEntity); + + beforeEach(() => { + identityRepo = createMockIdentityRepository(); + userPort = createMockFederatedUserPort(); + + const { transaction } = createMockTransaction(); + txScope = transaction; + eventPublisher = createMockEventPublisher(); + + service = new FederatedOAuthService( + identityRepo, + txScope, + eventPublisher, + userPort, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('sign', () => { + it('should return existing user when identity exists', async () => { + identityRepo.findByProviderAndSubject.mockResolvedValue(mockIdentity); + userPort.getById.mockResolvedValue(mockUser); + + const result = await service.sign( + {}, + 'google', + 'test@example.com', + 'subject-id', + ); + + expect(result).toEqual(mockUser); + expect(identityRepo.findByProviderAndSubject).toHaveBeenCalledWith( + expect.any(AppContextHost), + 'google', + 'subject-id', + ); + expect(userPort.getById).toHaveBeenCalledWith( + expect.any(AppContextHost), + 'user-id', + ); + }); + + it('should create new user and identity when they do not exist', async () => { + identityRepo.findByProviderAndSubject.mockResolvedValue(null); + userPort.getByEmail.mockResolvedValue(null); + userPort.create.mockResolvedValue(mockUser); + identityRepo.save.mockResolvedValue(undefined); + + const result = await service.sign( + {}, + 'google', + 'test@example.com', + 'subject-id', + ); + + expect(result).toEqual(mockUser); + expect(identityRepo.findByProviderAndSubject).toHaveBeenCalledWith( + expect.any(AppContextHost), + 'google', + 'subject-id', + ); + expect(userPort.getByEmail).toHaveBeenCalledWith( + expect.any(AppContextHost), + 'test@example.com', + ); + expect(userPort.create).toHaveBeenCalledWith(expect.any(AppContextHost), { + email: 'test@example.com', + username: 'test@example.com', + }); + expect(identityRepo.save).toHaveBeenCalled(); + }); + + it('should use existing user when email exists but identity does not', async () => { + identityRepo.findByProviderAndSubject.mockResolvedValue(null); + userPort.getByEmail.mockResolvedValue(mockUser); + identityRepo.save.mockResolvedValue(undefined); + + const result = await service.sign( + {}, + 'google', + 'test@example.com', + 'subject-id', + ); + + expect(result).toEqual(mockUser); + expect(identityRepo.findByProviderAndSubject).toHaveBeenCalledWith( + expect.any(AppContextHost), + 'google', + 'subject-id', + ); + expect(userPort.getByEmail).toHaveBeenCalledWith( + expect.any(AppContextHost), + 'test@example.com', + ); + expect(userPort.create).not.toHaveBeenCalled(); + expect(identityRepo.save).toHaveBeenCalled(); + }); + + it('should throw IdentityUserRelationshipException when identity exists but has no user', async () => { + const identityWithoutUser = toIdentityDomain( + createMockIdentityEntity({ + user: { id: null }, + }), + ); + identityRepo.findByProviderAndSubject.mockResolvedValue( + identityWithoutUser, + ); + + await expect( + service.sign({}, 'google', 'test@example.com', 'subject-id'), + ).rejects.toThrow(IdentityUserRelationshipException); + }); + + it('should classify a broken identity/user relation as internal/500, not client/404', async () => { + const identityWithoutUser = toIdentityDomain( + createMockIdentityEntity({ + user: { id: null }, + }), + ); + identityRepo.findByProviderAndSubject.mockResolvedValue( + identityWithoutUser, + ); + + try { + await service.sign({}, 'google', 'test@example.com', 'subject-id'); + throw new Error('Expected IdentityUserRelationshipException'); + } catch (e) { + expect(e).toBeInstanceOf(IdentityUserRelationshipException); + expect((e as IdentityUserRelationshipException).httpStatus).toBe( + HttpStatus.INTERNAL_SERVER_ERROR, + ); + expect((e as IdentityUserRelationshipException).fault).toBe('internal'); + } + }); + + it('should throw IdentityFindUserException when user is not found', async () => { + identityRepo.findByProviderAndSubject.mockResolvedValue(mockIdentity); + userPort.getById.mockResolvedValue(null); + + await expect( + service.sign({}, 'google', 'test@example.com', 'subject-id'), + ).rejects.toThrow(IdentityFindUserException); + }); + + it('should throw IdentityCreateUserException when user creation fails', async () => { + identityRepo.findByProviderAndSubject.mockResolvedValue(null); + userPort.getByEmail.mockResolvedValue(null); + userPort.create.mockRejectedValue(new Error('Failed to create user')); + + await expect( + service.sign({}, 'google', 'test@example.com', 'subject-id'), + ).rejects.toThrow(IdentityCreateUserException); + }); + + it('should throw IdentityCreateUserException when user creation fails with non-Error', async () => { + identityRepo.findByProviderAndSubject.mockResolvedValue(null); + userPort.getByEmail.mockResolvedValue(null); + userPort.create.mockRejectedValue('string error'); + + await expect( + service.sign({}, 'google', 'test@example.com', 'subject-id'), + ).rejects.toThrow(IdentityCreateUserException); + }); + }); +}); diff --git a/packages/nestjs-federated/src/domain/services/federated-oauth.service.ts b/packages/nestjs-federated/src/domain/services/federated-oauth.service.ts new file mode 100644 index 000000000..babec0b91 --- /dev/null +++ b/packages/nestjs-federated/src/domain/services/federated-oauth.service.ts @@ -0,0 +1,104 @@ +import { Inject, Injectable, PlainLiteralObject } from '@nestjs/common'; +import { EventPublisher } from '@nestjs/cqrs'; + +import { createEventContext, NotAnErrorException } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN } from '../../federated.constants.js'; +import { FederatedCredentialsInterface } from '../../interfaces/federated-credentials.interface.js'; +import { Identity } from '../aggregates/identity.js'; +import { IdentityCreateUserException } from '../exceptions/identity-create-user.exception.js'; +import { IdentityFindUserException } from '../exceptions/identity-find-user.exception.js'; +import { IdentityUserRelationshipException } from '../exceptions/identity-user-relationship.exception.js'; +import { FederatedUserPort } from '../ports/federated-user.port.js'; +import { IdentityRepositoryInterface } from '../repositories/identity-repository.interface.js'; + +import { FederatedOAuthServiceInterface } from './federated-oauth-service.interface.js'; + +@Injectable() +export class FederatedOAuthService implements FederatedOAuthServiceInterface { + constructor( + @Inject(FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN) + private readonly identityRepo: IdentityRepositoryInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + private readonly userPort: FederatedUserPort, + ) {} + + async sign( + ctx: PlainLiteralObject, + provider: string, + email: string, + subject: string, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const existing = await this.identityRepo.findByProviderAndSubject( + txCtx, + provider, + subject, + ); + + if (!existing) { + return this.createUserWithIdentity(txCtx, provider, email, subject); + } + + if (!existing.user?.id) { + throw new IdentityUserRelationshipException(existing.id); + } + + const user = await this.userPort.getById(txCtx, existing.user.id); + + if (!user) { + throw new IdentityFindUserException( + this.constructor.name, + existing.user, + ); + } + + return user; + }); + } + + protected async createUserWithIdentity( + ctx: PlainLiteralObject, + provider: string, + email: string, + subject: string, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const user = await this.findOrCreateUser(txCtx, email); + + const eventContext = createEventContext(txCtx, {}, {}); + const identity = this.eventPublisher.mergeObjectContext( + Identity.create(eventContext, { provider, subject, user }), + ); + + await this.identityRepo.save(txCtx, identity); + + txCtx.trx.onCommit(() => identity.commit()); + txCtx.trx.onRollback(() => identity.uncommit()); + + return user; + }); + } + + private async findOrCreateUser( + ctx: PlainLiteralObject, + email: string, + ): Promise { + const existing = await this.userPort.getByEmail(ctx, email); + + if (existing) { + return existing; + } + + try { + return await this.userPort.create(ctx, { email, username: email }); + } catch (e) { + const exception = e instanceof Error ? e : new NotAnErrorException(e); + throw new IdentityCreateUserException(this.constructor.name, { + originalError: exception, + }); + } + } +} diff --git a/packages/nestjs-federated/src/dto/federated-create.dto.ts b/packages/nestjs-federated/src/dto/federated-create.dto.ts deleted file mode 100644 index 88e66f9cd..000000000 --- a/packages/nestjs-federated/src/dto/federated-create.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { PickType } from '@nestjs/swagger'; - -import { FederatedCreatableInterface } from '@concepta/nestjs-common'; - -import { FederatedDto } from './federated.dto'; - -/** - * Federated Create DTO - */ -@Exclude() -export class FederatedCreateDto - extends PickType(FederatedDto, ['provider', 'subject', 'user'] as const) - implements FederatedCreatableInterface {} diff --git a/packages/nestjs-federated/src/dto/federated-update.dto.ts b/packages/nestjs-federated/src/dto/federated-update.dto.ts deleted file mode 100644 index a3960ad92..000000000 --- a/packages/nestjs-federated/src/dto/federated-update.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { PickType } from '@nestjs/swagger'; - -import { FederatedUpdatableInterface } from '@concepta/nestjs-common'; - -import { FederatedDto } from './federated.dto'; - -/** - * Federated Update DTO - */ -@Exclude() -export class FederatedUpdateDto - extends PickType(FederatedDto, ['id', 'provider', 'subject'] as const) - implements FederatedUpdatableInterface {} diff --git a/packages/nestjs-federated/src/dto/federated.dto.ts b/packages/nestjs-federated/src/dto/federated.dto.ts deleted file mode 100644 index a8e44a76d..000000000 --- a/packages/nestjs-federated/src/dto/federated.dto.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; -import { IsString, ValidateNested } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { - ReferenceIdInterface, - FederatedInterface, - CommonEntityDto, - ReferenceIdDto, -} from '@concepta/nestjs-common'; - -/** - * Federated DTO - */ -@Exclude() -export class FederatedDto - extends CommonEntityDto - implements FederatedInterface -{ - /** - * provider - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'provider of the federated', - }) - @IsString() - provider = ''; - - /** - * subject - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'subject of the federated', - }) - @IsString() - subject = ''; - - /** - * userId - */ - @Expose() - @ApiProperty({ - type: ReferenceIdDto, - description: 'User data', - }) - @Type(() => ReferenceIdDto) - @ValidateNested() - user: ReferenceIdInterface = new ReferenceIdDto(); -} diff --git a/packages/nestjs-federated/src/exceptions/federated-create-user.exception.ts b/packages/nestjs-federated/src/exceptions/federated-create-user.exception.ts deleted file mode 100644 index 929a11bb0..000000000 --- a/packages/nestjs-federated/src/exceptions/federated-create-user.exception.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { FederatedException } from './federated.exception'; - -export class FederatedCreateUserException extends FederatedException { - context: RuntimeException['context'] & { - entityName: string; - }; - - constructor(entityName: string, options?: RuntimeExceptionOptions) { - super({ - message: 'Error while trying to create a %s reference', - messageParams: [entityName], - ...options, - }); - - this.context = { - ...super.context, - entityName, - }; - - this.errorCode = 'FEDERATED_CREATE_USER_ERROR'; - } -} diff --git a/packages/nestjs-federated/src/exceptions/federated-create.exception.ts b/packages/nestjs-federated/src/exceptions/federated-create.exception.ts deleted file mode 100644 index 5b8d3f590..000000000 --- a/packages/nestjs-federated/src/exceptions/federated-create.exception.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { FederatedException } from './federated.exception'; - -export class FederatedCreateException extends FederatedException { - context: RuntimeException['context'] & { - entityName: string; - }; - - constructor(entityName: string, options?: RuntimeExceptionOptions) { - super({ - message: 'Error while trying create a federated', - messageParams: [entityName], - ...options, - }); - - this.context = { - ...super.context, - entityName, - }; - - this.errorCode = 'FEDERATED_CREATE_ERROR'; - } -} diff --git a/packages/nestjs-federated/src/exceptions/federated-find-user.exception.ts b/packages/nestjs-federated/src/exceptions/federated-find-user.exception.ts deleted file mode 100644 index 6512ee493..000000000 --- a/packages/nestjs-federated/src/exceptions/federated-find-user.exception.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { - ReferenceIdInterface, - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { FederatedException } from './federated.exception'; - -export class FederatedFindUserException extends FederatedException { - context: RuntimeException['context'] & { - entityName: string; - user: ReferenceIdInterface; - }; - - constructor( - entityName: string, - user: ReferenceIdInterface, - options?: RuntimeExceptionOptions, - ) { - super({ - message: 'Error while trying find user %s', - messageParams: [user.id], - httpStatus: HttpStatus.NOT_FOUND, - ...options, - }); - - this.errorCode = 'FEDERATED_FIND_USER_ERROR'; - - this.context = { - ...super.context, - entityName, - user, - }; - } -} diff --git a/packages/nestjs-federated/src/exceptions/federated-missing-entities-options.exception.ts b/packages/nestjs-federated/src/exceptions/federated-missing-entities-options.exception.ts deleted file mode 100644 index 1dd3cf0e3..000000000 --- a/packages/nestjs-federated/src/exceptions/federated-missing-entities-options.exception.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { FederatedException } from './federated.exception'; - -export class FederatedMissingEntitiesOptionsException extends FederatedException { - constructor() { - super({ - message: 'You must provide the entities option', - }); - this.errorCode = 'FEDERATED_MISSING_ENTITIES_OPTION'; - } -} diff --git a/packages/nestjs-federated/src/exceptions/federated-query.exception.ts b/packages/nestjs-federated/src/exceptions/federated-query.exception.ts deleted file mode 100644 index 402d8ab5e..000000000 --- a/packages/nestjs-federated/src/exceptions/federated-query.exception.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { FederatedException } from './federated.exception'; - -export class FederatedQueryException extends FederatedException { - context: RuntimeException['context'] & { - entityName: string; - }; - - constructor(entityName: string, options?: RuntimeExceptionOptions) { - super({ - message: 'Error while trying to do a query to federated', - messageParams: [entityName], - ...options, - }); - - this.context = { - ...super.context, - entityName, - }; - - this.errorCode = 'FEDERATED_QUERY_ERROR'; - } -} diff --git a/packages/nestjs-federated/src/exceptions/federated-user-relationship.exception.ts b/packages/nestjs-federated/src/exceptions/federated-user-relationship.exception.ts deleted file mode 100644 index 9a034b32d..000000000 --- a/packages/nestjs-federated/src/exceptions/federated-user-relationship.exception.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { FederatedException } from './federated.exception'; - -export class FederatedUserRelationshipException extends FederatedException { - context: RuntimeException['context'] & { - federatedId: string; - }; - - constructor(federatedId: string, options?: RuntimeExceptionOptions) { - super({ - message: 'Error while trying to load user relationship from federated %s', - messageParams: [federatedId], - httpStatus: HttpStatus.NOT_FOUND, - ...options, - }); - - this.errorCode = 'FEDERATED_USER_RELATIONSHIP_ERROR'; - - this.context = { - ...super.context, - federatedId, - }; - } -} diff --git a/packages/nestjs-federated/src/exceptions/federated.exception.ts b/packages/nestjs-federated/src/exceptions/federated.exception.ts deleted file mode 100644 index 5fb839f93..000000000 --- a/packages/nestjs-federated/src/exceptions/federated.exception.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -/** - * Generic federated exception. - */ -export class FederatedException extends RuntimeException { - constructor(options?: RuntimeExceptionOptions) { - super(options); - this.errorCode = 'FEDERATED_ERROR'; - } -} diff --git a/packages/nestjs-federated/src/federated.constants.ts b/packages/nestjs-federated/src/federated.constants.ts index 681f66f0d..e1b4802e4 100644 --- a/packages/nestjs-federated/src/federated.constants.ts +++ b/packages/nestjs-federated/src/federated.constants.ts @@ -4,7 +4,7 @@ export const FEDERATED_MODULE_SETTINGS_TOKEN = export const FEDERATED_MODULE_DEFAULT_SETTINGS_TOKEN = 'FEDERATED_MODULE_DEFAULT_SETTINGS_TOKEN'; -export const FEDERATED_MODULE_USER_MODEL_SERVICE_TOKEN = - 'FEDERATED_MODULE_USER_MODEL_SERVICE_TOKEN'; +export const FEDERATED_MODULE_DEFAULT_ENTITY_KEY = 'identity'; -export const FEDERATED_MODULE_FEDERATED_ENTITY_KEY = 'federated'; +export const FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN = + 'FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN'; diff --git a/packages/nestjs-federated/src/federated.module-definition.ts b/packages/nestjs-federated/src/federated.module-definition.ts index 73f344751..75cf1cb85 100644 --- a/packages/nestjs-federated/src/federated.module-definition.ts +++ b/packages/nestjs-federated/src/federated.module-definition.ts @@ -1,23 +1,27 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { CommandBus, CqrsModule, QueryBus } from '@nestjs/cqrs'; -import { createSettingsProvider } from '@concepta/nestjs-common'; +import { createSettingsProvider } from '@concepta/nestjs-core'; -import { federatedDefaultConfig } from './config/federated-default.config'; +import { CreateIdentityHandler } from './application/commands/handlers/create-identity.handler.js'; +import { FindIdentityByProviderHandler } from './application/queries/handlers/find-identity-by-provider.handler.js'; +import { FederatedUserPort } from './domain/ports/federated-user.port.js'; +import { FederatedOAuthService } from './domain/services/federated-oauth.service.js'; import { + FEDERATED_MODULE_DEFAULT_ENTITY_KEY, FEDERATED_MODULE_SETTINGS_TOKEN, - FEDERATED_MODULE_USER_MODEL_SERVICE_TOKEN, -} from './federated.constants'; -import { FederatedOptionsExtrasInterface } from './interfaces/federated-options-extras.interface'; -import { FederatedOptionsInterface } from './interfaces/federated-options.interface'; -import { FederatedSettingsInterface } from './interfaces/federated-settings.interface'; -import { FederatedModelService } from './services/federated-model.service'; -import { FederatedOAuthService } from './services/federated-oauth.service'; -import { FederatedService } from './services/federated.service'; +} from './federated.constants.js'; +import { federatedDefaultConfig } from './infrastructure/config/federated-default.config.js'; +import { type FederatedSettingsInterface } from './infrastructure/config/interfaces/federated-settings.interface.js'; +import { IdentityMapper } from './infrastructure/persistence/identity.mapper.js'; +import { createIdentityRepositoryProvider } from './infrastructure/utils/create-identity-repository-provider.js'; +import { type FederatedOptionsExtrasInterface } from './interfaces/federated-options-extras.interface.js'; +import { type FederatedOptionsInterface } from './interfaces/federated-options.interface.js'; const RAW_OPTIONS_TOKEN = Symbol('__FEDERATED_MODULE_RAW_OPTIONS_TOKEN__'); @@ -30,7 +34,10 @@ export const { optionsInjectionToken: RAW_OPTIONS_TOKEN, }) .setExtras( - { global: false }, + { + global: false, + entities: { identity: FEDERATED_MODULE_DEFAULT_ENTITY_KEY }, + }, definitionTransform, ) .build(); @@ -47,13 +54,18 @@ function definitionTransform( extras: FederatedOptionsExtrasInterface, ): DynamicModule { const { imports = [], providers = [] } = definition; - const { global = false } = extras; + const { global = false, entities, repositories } = extras; + const entityKey = entities?.identity ?? FEDERATED_MODULE_DEFAULT_ENTITY_KEY; return { ...definition, global, imports: createFederatedImports({ imports }), - providers: createFederatedProviders({ providers }), + providers: createFederatedProviders({ + providers, + entityKey, + repositories, + }), exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createFederatedExports()], }; } @@ -64,6 +76,7 @@ export function createFederatedImports(options: { return [ ...(options.imports || []), ConfigModule.forFeature(federatedDefaultConfig), + CqrsModule.forRoot(), ]; } @@ -72,24 +85,31 @@ export function createFederatedExports(): Required< >['exports'] { return [ FEDERATED_MODULE_SETTINGS_TOKEN, - FEDERATED_MODULE_USER_MODEL_SERVICE_TOKEN, - FederatedService, FederatedOAuthService, - FederatedModelService, + IdentityMapper, ]; } export function createFederatedProviders(options: { overrides?: FederatedOptions; providers?: Provider[]; + entityKey: string; + repositories?: FederatedOptionsExtrasInterface['repositories']; }): Provider[] { return [ ...(options.providers ?? []), - createFederatedSettingsProvider(options.overrides), - createFederatedUserModelServiceProvider(options.overrides), - FederatedService, FederatedOAuthService, - FederatedModelService, + createFederatedSettingsProvider(options.overrides), + ...createIdentityRepositoryProvider( + options.entityKey, + options.repositories?.identity, + ), + createFederatedUserPortProvider(), + IdentityMapper, + // command handlers + CreateIdentityHandler, + // query handlers + FindIdentityByProviderHandler, ]; } @@ -107,13 +127,14 @@ export function createFederatedSettingsProvider( }); } -export function createFederatedUserModelServiceProvider( - optionsOverrides?: FederatedOptions, -): Provider { +function createFederatedUserPortProvider(): Provider { return { - provide: FEDERATED_MODULE_USER_MODEL_SERVICE_TOKEN, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: FederatedOptionsInterface) => - optionsOverrides?.userModelService ?? options.userModelService, + provide: FederatedUserPort, + inject: [RAW_OPTIONS_TOKEN, QueryBus, CommandBus], + useFactory: ( + options: FederatedOptionsInterface, + queryBus: QueryBus, + commandBus: CommandBus, + ) => new FederatedUserPort(options.userPort, queryBus, commandBus), }; } diff --git a/packages/nestjs-federated/src/federated.module.spec.ts b/packages/nestjs-federated/src/federated.module.spec.ts deleted file mode 100644 index efbdc90f3..000000000 --- a/packages/nestjs-federated/src/federated.module.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { - RepositoryInterface, - getDynamicRepositoryToken, - FederatedEntityInterface, -} from '@concepta/nestjs-common'; -import { - TypeOrmExtModule, - TypeOrmRepositoryAdapter, -} from '@concepta/nestjs-typeorm-ext'; - -import { FEDERATED_MODULE_FEDERATED_ENTITY_KEY } from './federated.constants'; -import { FederatedModule } from './federated.module'; -import { FederatedUserModelServiceInterface } from './interfaces/federated-user-model-service.interface'; -import { FederatedModelService } from './services/federated-model.service'; -import { FederatedOAuthService } from './services/federated-oauth.service'; -import { FederatedService } from './services/federated.service'; - -import { FederatedEntityFixture } from './__fixtures__/federated/federated-entity.fixture'; -import { UserEntityFixture } from './__fixtures__/user/entities/user.entity.fixture'; -import { UserModelServiceFixture } from './__fixtures__/user/services/user-model.service.fixture'; -import { UserModuleFixture } from './__fixtures__/user/user.module.fixture'; - -describe(FederatedModule, () => { - let testModule: TestingModule; - let federatedModule: FederatedModule; - let federatedService: FederatedService; - let federatedOauthService: FederatedOAuthService; - let userModelService: FederatedUserModelServiceInterface; - let federatedDynamicRepo: RepositoryInterface; - let federatedModelService: FederatedModelService; - - describe(FederatedModule.forRoot, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - TypeOrmExtModule.forFeature({ - federated: { - entity: FederatedEntityFixture, - }, - }), - FederatedModule.forRoot({ - userModelService: new UserModelServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - describe(FederatedModule.register, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - TypeOrmExtModule.forFeature({ - federated: { - entity: FederatedEntityFixture, - }, - }), - FederatedModule.register({ - userModelService: new UserModelServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - describe(FederatedModule.forRootAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - FederatedModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - federated: { - entity: FederatedEntityFixture, - }, - }), - ], - inject: [UserModelServiceFixture], - useFactory: (userModelService) => ({ - userModelService, - }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - describe(FederatedModule.registerAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - FederatedModule.registerAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - federated: { - entity: FederatedEntityFixture, - }, - }), - ], - inject: [UserModelServiceFixture], - useFactory: (userModelService) => ({ - userModelService, - }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - function commonVars() { - federatedModule = testModule.get(FederatedModule); - federatedService = testModule.get(FederatedService); - federatedOauthService = testModule.get(FederatedOAuthService); - federatedModelService = testModule.get(FederatedModelService); - userModelService = testModule.get( - UserModelServiceFixture, - ); - federatedDynamicRepo = testModule.get( - getDynamicRepositoryToken(FEDERATED_MODULE_FEDERATED_ENTITY_KEY), - ); - } - - function commonTests() { - expect(federatedModule).toBeInstanceOf(FederatedModule); - expect(federatedService).toBeInstanceOf(FederatedService); - expect(federatedModelService).toBeInstanceOf(FederatedModelService); - expect(federatedOauthService).toBeInstanceOf(FederatedOAuthService); - expect(userModelService).toBeInstanceOf(UserModelServiceFixture); - expect(federatedDynamicRepo).toBeInstanceOf(TypeOrmRepositoryAdapter); - } -}); - -function testModuleFactory( - extraImports: DynamicModule['imports'] = [], -): ModuleMetadata { - return { - imports: [ - UserModuleFixture, - TypeOrmExtModule.forRoot({ - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [UserEntityFixture, FederatedEntityFixture], - }), - ...extraImports, - ], - }; -} diff --git a/packages/nestjs-federated/src/federated.module.ts b/packages/nestjs-federated/src/federated.module.ts index 8eb885ac5..a79ab5688 100644 --- a/packages/nestjs-federated/src/federated.module.ts +++ b/packages/nestjs-federated/src/federated.module.ts @@ -4,15 +4,9 @@ import { FederatedAsyncOptions, FederatedModuleClass, FederatedOptions, -} from './federated.module-definition'; -import { FederatedModelService } from './services/federated-model.service'; -import { FederatedOAuthService } from './services/federated-oauth.service'; -import { FederatedService } from './services/federated.service'; +} from './federated.module-definition.js'; -@Module({ - providers: [FederatedService, FederatedOAuthService, FederatedModelService], - exports: [FederatedService, FederatedOAuthService, FederatedModelService], -}) +@Module({}) export class FederatedModule extends FederatedModuleClass { static register(options: FederatedOptions): DynamicModule { return super.register(options); diff --git a/packages/nestjs-federated/src/index.spec.ts b/packages/nestjs-federated/src/index.spec.ts deleted file mode 100644 index 31d1af249..000000000 --- a/packages/nestjs-federated/src/index.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - FederatedModule, - FederatedService, - FederatedOAuthService, - FederatedDto, - FederatedCreateDto, - FederatedUpdateDto, -} from './index'; - -describe('Federated Module', () => { - it('should be a function', () => { - expect(FederatedModule).toBeInstanceOf(Function); - }); -}); - -describe('Federated Service', () => { - it('should be a function', () => { - expect(FederatedService).toBeInstanceOf(Function); - }); -}); - -describe('Federated OAuth Service', () => { - it('should be a function', () => { - expect(FederatedOAuthService).toBeInstanceOf(Function); - }); -}); - -describe('Federated Dto', () => { - it('should be a function', () => { - expect(FederatedDto).toBeInstanceOf(Function); - }); -}); - -describe('Federated Create Dto', () => { - it('should be a function', () => { - expect(FederatedCreateDto).toBeInstanceOf(Function); - }); -}); - -describe('Federated Update Dto', () => { - it('should be a function', () => { - expect(FederatedUpdateDto).toBeInstanceOf(Function); - }); -}); diff --git a/packages/nestjs-federated/src/index.ts b/packages/nestjs-federated/src/index.ts index 7a1536cdf..c45c1779c 100644 --- a/packages/nestjs-federated/src/index.ts +++ b/packages/nestjs-federated/src/index.ts @@ -1,20 +1,71 @@ -export { FederatedModule } from './federated.module'; - -export { FederatedService } from './services/federated.service'; -export { FederatedOAuthService } from './services/federated-oauth.service'; - -export { FederatedCredentialsInterface } from './interfaces/federated-credentials.interface'; -export { FederatedUserModelServiceInterface } from './interfaces/federated-user-model-service.interface'; - -export { FederatedDto } from './dto/federated.dto'; -export { FederatedCreateDto } from './dto/federated-create.dto'; -export { FederatedUpdateDto } from './dto/federated-update.dto'; - -// exceptions -export { FederatedException } from './exceptions/federated.exception'; -export { FederatedCreateException } from './exceptions/federated-create.exception'; -export { FederatedQueryException } from './exceptions/federated-query.exception'; -export { FederatedCreateUserException } from './exceptions/federated-create-user.exception'; -export { FederatedUserRelationshipException } from './exceptions/federated-user-relationship.exception'; -export { FederatedFindUserException } from './exceptions/federated-find-user.exception'; -export { FederatedMissingEntitiesOptionsException } from './exceptions/federated-missing-entities-options.exception'; +// module +export { FederatedModule } from './federated.module.js'; + +// aggregate +export { Identity } from './domain/aggregates/identity.js'; + +// domain interfaces +export { IdentityInterface } from './domain/interfaces/identity.interface.js'; +export { IdentityCreatableInterface } from './domain/interfaces/identity-creatable.interface.js'; + +// domain events +export { IdentityCreatedEvent } from './domain/events/identity-created.event.js'; + +// domain repositories +export { IdentityRepositoryInterface } from './domain/repositories/identity-repository.interface.js'; + +// domain ports +export { + FederatedUserPort, + FederatedUserPortSettings, + FederatedUserResult, + GetUserByIdQueryInterface, + GetUserByEmailQueryInterface, + CreateUserCommandInterface, +} from './domain/ports/federated-user.port.js'; + +// domain services +export { FederatedOAuthService } from './domain/services/federated-oauth.service.js'; +export { FederatedOAuthServiceInterface } from './domain/services/federated-oauth-service.interface.js'; + +// commands +export { CreateIdentityCommand } from './application/commands/impl/create-identity.command.js'; + +// command handlers +export { CreateIdentityHandler } from './application/commands/handlers/create-identity.handler.js'; + +// queries +export { FindIdentityByProviderQuery } from './application/queries/impl/find-identity-by-provider.query.js'; + +// query handlers +export { FindIdentityByProviderHandler } from './application/queries/handlers/find-identity-by-provider.handler.js'; + +// public interfaces +export { FederatedCredentialsInterface } from './interfaces/federated-credentials.interface.js'; +export { FederatedOptionsInterface } from './interfaces/federated-options.interface.js'; +export { FederatedOptionsExtrasInterface } from './interfaces/federated-options-extras.interface.js'; + +// config interfaces +export { FederatedSettingsInterface } from './infrastructure/config/interfaces/federated-settings.interface.js'; + +// schemas (Zod / Standard Schema) +export { identitySchema } from './infrastructure/schemas/identity.schema.js'; +export { identityCreateSchema } from './infrastructure/schemas/identity-create.schema.js'; + +// persistence +export { IdentityMapper } from './infrastructure/persistence/identity.mapper.js'; +export { IdentityRepository } from './infrastructure/persistence/identity.repository.js'; +export { IdentityEntityInterface } from './infrastructure/persistence/interfaces/identity-entity.interface.js'; + +// domain exceptions +export { FederatedException } from './domain/exceptions/federated.exception.js'; +export { IdentityCreateUserException } from './domain/exceptions/identity-create-user.exception.js'; +export { IdentityFindUserException } from './domain/exceptions/identity-find-user.exception.js'; +export { IdentityUserRelationshipException } from './domain/exceptions/identity-user-relationship.exception.js'; + +// constants +export { + FEDERATED_MODULE_SETTINGS_TOKEN, + FEDERATED_MODULE_DEFAULT_ENTITY_KEY, + FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN, +} from './federated.constants.js'; diff --git a/packages/nestjs-federated/src/infrastructure/config/federated-default.config.ts b/packages/nestjs-federated/src/infrastructure/config/federated-default.config.ts new file mode 100644 index 000000000..3cb3ee9fe --- /dev/null +++ b/packages/nestjs-federated/src/infrastructure/config/federated-default.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from '@nestjs/config'; + +import { FEDERATED_MODULE_DEFAULT_SETTINGS_TOKEN } from '../../federated.constants.js'; + +import { type FederatedSettingsInterface } from './interfaces/federated-settings.interface.js'; + +export const federatedDefaultConfig = registerAs( + FEDERATED_MODULE_DEFAULT_SETTINGS_TOKEN, + (): FederatedSettingsInterface => ({}), +); diff --git a/packages/nestjs-federated/src/interfaces/federated-settings.interface.ts b/packages/nestjs-federated/src/infrastructure/config/interfaces/federated-settings.interface.ts similarity index 100% rename from packages/nestjs-federated/src/interfaces/federated-settings.interface.ts rename to packages/nestjs-federated/src/infrastructure/config/interfaces/federated-settings.interface.ts diff --git a/packages/nestjs-federated/src/infrastructure/persistence/identity.mapper.ts b/packages/nestjs-federated/src/infrastructure/persistence/identity.mapper.ts new file mode 100644 index 000000000..bb8567a08 --- /dev/null +++ b/packages/nestjs-federated/src/infrastructure/persistence/identity.mapper.ts @@ -0,0 +1,23 @@ +import { DomainMapper } from '@concepta/nestjs-core/aggregate'; + +import { Identity } from '../../domain/aggregates/identity.js'; +import { type IdentityInterface } from '../../domain/interfaces/identity.interface.js'; + +import { type IdentityEntityInterface } from './interfaces/identity-entity.interface.js'; + +export class IdentityMapper extends DomainMapper< + IdentityEntityInterface, + IdentityInterface, + Identity +> { + createAggregate(entity: IdentityEntityInterface): Identity { + const { id, version, dateCreated, dateUpdated, dateDeleted, ...props } = + entity; + + return new Identity(id, props, version, { + dateCreated, + dateUpdated, + dateDeleted, + }); + } +} diff --git a/packages/nestjs-federated/src/infrastructure/persistence/identity.repository.ts b/packages/nestjs-federated/src/infrastructure/persistence/identity.repository.ts new file mode 100644 index 000000000..467a18644 --- /dev/null +++ b/packages/nestjs-federated/src/infrastructure/persistence/identity.repository.ts @@ -0,0 +1,55 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; +import { type RepositoryInterface, Where } from '@concepta/nestjs-repository'; + +import { type Identity } from '../../domain/aggregates/identity.js'; +import { type IdentityRepositoryInterface } from '../../domain/repositories/identity-repository.interface.js'; + +import { type IdentityMapper } from './identity.mapper.js'; +import { type IdentityEntityInterface } from './interfaces/identity-entity.interface.js'; + +export class IdentityRepository implements IdentityRepositoryInterface { + constructor( + protected readonly repository: RepositoryInterface, + private readonly mapper: IdentityMapper, + ) {} + + async get( + ctx: PlainLiteralObject, + id: ReferenceId, + ): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.eq('id', id), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByProviderAndSubject( + ctx: PlainLiteralObject, + provider: string, + subject: string, + ): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.and(w.eq('provider', provider), w.eq('subject', subject)), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async save(ctx: PlainLiteralObject, identity: Identity): Promise { + identity.stampUpdated(); + await this.repository.upsert(this.mapper.toPersistence(identity), { ctx }); + } + + async remove(ctx: PlainLiteralObject, identity: Identity): Promise { + await this.repository.delete(this.mapper.toPersistence(identity), { ctx }); + } +} diff --git a/packages/nestjs-federated/src/infrastructure/persistence/interfaces/identity-entity.interface.ts b/packages/nestjs-federated/src/infrastructure/persistence/interfaces/identity-entity.interface.ts new file mode 100644 index 000000000..6599058db --- /dev/null +++ b/packages/nestjs-federated/src/infrastructure/persistence/interfaces/identity-entity.interface.ts @@ -0,0 +1,14 @@ +import { + type AuditInterface, + type ReferenceIdInterface, + type ReferenceVersionInterface, +} from '@concepta/nestjs-core'; + +import { type IdentityInterface } from '../../../domain/interfaces/identity.interface.js'; + +export interface IdentityEntityInterface + extends + ReferenceIdInterface, + ReferenceVersionInterface, + IdentityInterface, + AuditInterface {} diff --git a/packages/nestjs-federated/src/infrastructure/persistence/typeorm/identity-postgres.entity.ts b/packages/nestjs-federated/src/infrastructure/persistence/typeorm/identity-postgres.entity.ts new file mode 100644 index 000000000..d52940a67 --- /dev/null +++ b/packages/nestjs-federated/src/infrastructure/persistence/typeorm/identity-postgres.entity.ts @@ -0,0 +1,19 @@ +import { Column } from 'typeorm'; + +import { ReferenceIdInterface } from '@concepta/nestjs-core'; +import { CommonPostgresEntity } from '@concepta/nestjs-repository-typeorm'; + +import { IdentityEntityInterface } from '../interfaces/identity-entity.interface.js'; + +export abstract class IdentityPostgresEntity + extends CommonPostgresEntity + implements IdentityEntityInterface +{ + @Column() + provider!: string; + + @Column() + subject!: string; + + abstract user: ReferenceIdInterface; +} diff --git a/packages/nestjs-federated/src/infrastructure/persistence/typeorm/identity-sqlite.entity.ts b/packages/nestjs-federated/src/infrastructure/persistence/typeorm/identity-sqlite.entity.ts new file mode 100644 index 000000000..786555a04 --- /dev/null +++ b/packages/nestjs-federated/src/infrastructure/persistence/typeorm/identity-sqlite.entity.ts @@ -0,0 +1,19 @@ +import { Column } from 'typeorm'; + +import { ReferenceIdInterface } from '@concepta/nestjs-core'; +import { CommonSqliteEntity } from '@concepta/nestjs-repository-typeorm'; + +import { IdentityEntityInterface } from '../interfaces/identity-entity.interface.js'; + +export abstract class IdentitySqliteEntity + extends CommonSqliteEntity + implements IdentityEntityInterface +{ + @Column() + provider!: string; + + @Column() + subject!: string; + + abstract user: ReferenceIdInterface; +} diff --git a/packages/nestjs-federated/src/infrastructure/schemas/identity-create.schema.ts b/packages/nestjs-federated/src/infrastructure/schemas/identity-create.schema.ts new file mode 100644 index 000000000..3b96cf33d --- /dev/null +++ b/packages/nestjs-federated/src/infrastructure/schemas/identity-create.schema.ts @@ -0,0 +1,11 @@ +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type IdentityCreatableInterface } from '../../domain/interfaces/identity-creatable.interface.js'; + +import { identitySchema } from './identity.schema.js'; + +export const identityCreateSchema = withOpenApi( + conformsTo()( + identitySchema.pick({ provider: true, subject: true, user: true }), + ), +); diff --git a/packages/nestjs-federated/src/infrastructure/schemas/identity.schema.spec.ts b/packages/nestjs-federated/src/infrastructure/schemas/identity.schema.spec.ts new file mode 100644 index 000000000..f0ba6fccd --- /dev/null +++ b/packages/nestjs-federated/src/infrastructure/schemas/identity.schema.spec.ts @@ -0,0 +1,56 @@ +import { identityCreateSchema } from './identity-create.schema.js'; +import { identitySchema } from './identity.schema.js'; + +const validIdentity = { + id: 'abc', + version: 1, + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-02'), + dateDeleted: null, + provider: 'google', + subject: 'subject-123', + user: { id: 'user-abc' }, +}; + +describe('identitySchema', () => { + it('accepts a valid identity entity', () => { + expect(identitySchema.parse(validIdentity)).toEqual(validIdentity); + }); + + it('strips unknown keys', () => { + const result = identitySchema.parse({ ...validIdentity, _internal: 'x' }); + expect(result).not.toHaveProperty('_internal'); + }); + + it('rejects a missing user reference', () => { + const { user: _user, ...rest } = validIdentity; + expect(identitySchema.safeParse(rest).success).toBe(false); + }); +}); + +describe('identityCreateSchema', () => { + const validCreate = { + provider: 'google', + subject: 'subject-123', + user: { id: 'user-abc' }, + }; + + it('accepts a valid create payload', () => { + expect(identityCreateSchema.parse(validCreate)).toEqual(validCreate); + }); + + it('rejects a missing provider', () => { + const { provider: _provider, ...rest } = validCreate; + expect(identityCreateSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects a missing subject', () => { + const { subject: _subject, ...rest } = validCreate; + expect(identityCreateSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects a missing user reference', () => { + const { user: _user, ...rest } = validCreate; + expect(identityCreateSchema.safeParse(rest).success).toBe(false); + }); +}); diff --git a/packages/nestjs-federated/src/infrastructure/schemas/identity.schema.ts b/packages/nestjs-federated/src/infrastructure/schemas/identity.schema.ts new file mode 100644 index 000000000..24095d4aa --- /dev/null +++ b/packages/nestjs-federated/src/infrastructure/schemas/identity.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { + conformsTo, + referenceIdSchema, + withNamedComponent, +} from '@concepta/nestjs-core'; +import { domainAggregateSchema } from '@concepta/nestjs-core/aggregate'; + +import { type IdentityInterface } from '../../domain/interfaces/identity.interface.js'; + +export const identitySchema = withNamedComponent( + conformsTo()( + domainAggregateSchema.extend({ + provider: z.string().meta({ description: 'Provider of the identity' }), + subject: z.string().meta({ description: 'Subject of the identity' }), + user: referenceIdSchema.meta({ description: 'User reference' }), + }), + ), + 'Identity', +); diff --git a/packages/nestjs-federated/src/infrastructure/utils/create-identity-repository-provider.ts b/packages/nestjs-federated/src/infrastructure/utils/create-identity-repository-provider.ts new file mode 100644 index 000000000..48e0a8bde --- /dev/null +++ b/packages/nestjs-federated/src/infrastructure/utils/create-identity-repository-provider.ts @@ -0,0 +1,37 @@ +import { type Provider, type Type } from '@nestjs/common'; + +import { + getDynamicRepositoryToken, + type RepositoryInterface, +} from '@concepta/nestjs-repository'; + +import { type IdentityRepositoryInterface } from '../../domain/repositories/identity-repository.interface.js'; +import { FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN } from '../../federated.constants.js'; +import { IdentityMapper } from '../persistence/identity.mapper.js'; +import { IdentityRepository } from '../persistence/identity.repository.js'; +import { type IdentityEntityInterface } from '../persistence/interfaces/identity-entity.interface.js'; + +export function createIdentityRepositoryProvider( + entityKey: string, + customRepository?: Type, +): Provider[] { + if (customRepository) { + return [ + { + provide: FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN, + useClass: customRepository, + }, + ]; + } + + return [ + { + provide: FEDERATED_MODULE_IDENTITY_REPOSITORY_TOKEN, + inject: [getDynamicRepositoryToken(entityKey), IdentityMapper], + useFactory: ( + repository: RepositoryInterface, + mapper: IdentityMapper, + ) => new IdentityRepository(repository, mapper), + }, + ]; +} diff --git a/packages/nestjs-federated/src/interfaces/federated-credentials.interface.ts b/packages/nestjs-federated/src/interfaces/federated-credentials.interface.ts index cff15a449..5939648bb 100644 --- a/packages/nestjs-federated/src/interfaces/federated-credentials.interface.ts +++ b/packages/nestjs-federated/src/interfaces/federated-credentials.interface.ts @@ -1,13 +1,14 @@ import { - ReferenceEmailInterface, - ReferenceIdInterface, - ReferenceUsernameInterface, -} from '@concepta/nestjs-common'; + type ReferenceEmailInterface, + type ReferenceIdInterface, + type ReferenceUsernameInterface, +} from '@concepta/nestjs-core'; /** * Credentials Interface */ export interface FederatedCredentialsInterface - extends ReferenceIdInterface, + extends + ReferenceIdInterface, ReferenceUsernameInterface, ReferenceEmailInterface {} diff --git a/packages/nestjs-federated/src/interfaces/federated-entities-options.interface.ts b/packages/nestjs-federated/src/interfaces/federated-entities-options.interface.ts deleted file mode 100644 index fb043f74e..000000000 --- a/packages/nestjs-federated/src/interfaces/federated-entities-options.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - FederatedEntityInterface, - RepositoryEntityOptionInterface, -} from '@concepta/nestjs-common'; - -import { FEDERATED_MODULE_FEDERATED_ENTITY_KEY } from '../federated.constants'; - -export interface FederatedEntitiesOptionsInterface { - [FEDERATED_MODULE_FEDERATED_ENTITY_KEY]: RepositoryEntityOptionInterface; -} diff --git a/packages/nestjs-federated/src/interfaces/federated-model-service.interface.ts b/packages/nestjs-federated/src/interfaces/federated-model-service.interface.ts deleted file mode 100644 index b7291bd27..000000000 --- a/packages/nestjs-federated/src/interfaces/federated-model-service.interface.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - CreateOneInterface, - ReferenceIdInterface, - RemoveOneInterface, - ReplaceOneInterface, - UpdateOneInterface, - FederatedCreatableInterface, - FederatedUpdatableInterface, - FederatedEntityInterface, -} from '@concepta/nestjs-common'; - -export interface FederatedModelServiceInterface - extends CreateOneInterface< - FederatedCreatableInterface, - FederatedEntityInterface - >, - UpdateOneInterface, - ReplaceOneInterface< - FederatedCreatableInterface & ReferenceIdInterface, - FederatedEntityInterface - >, - RemoveOneInterface< - Pick, - FederatedEntityInterface - > {} diff --git a/packages/nestjs-federated/src/interfaces/federated-oauth-service.interface.ts b/packages/nestjs-federated/src/interfaces/federated-oauth-service.interface.ts deleted file mode 100644 index 4b4adf6fc..000000000 --- a/packages/nestjs-federated/src/interfaces/federated-oauth-service.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FederatedCredentialsInterface } from './federated-credentials.interface'; - -export interface FederatedOAuthServiceInterface { - // TODO: should provider be a enum? - // marshall says: maybe providers is an array of allowed strings in settings? - sign( - provider: string, - email: string, - subject: string, - ): Promise; -} diff --git a/packages/nestjs-federated/src/interfaces/federated-options-extras.interface.ts b/packages/nestjs-federated/src/interfaces/federated-options-extras.interface.ts index 2c7df5444..c1166e7db 100644 --- a/packages/nestjs-federated/src/interfaces/federated-options-extras.interface.ts +++ b/packages/nestjs-federated/src/interfaces/federated-options-extras.interface.ts @@ -1,4 +1,15 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule, type Type } from '@nestjs/common'; -export interface FederatedOptionsExtrasInterface - extends Pick {} +import { type IdentityRepositoryInterface } from '../domain/repositories/identity-repository.interface.js'; + +export interface FederatedOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> { + entities?: { + identity?: string; + }; + repositories?: { + identity?: Type; + }; +} diff --git a/packages/nestjs-federated/src/interfaces/federated-options.interface.ts b/packages/nestjs-federated/src/interfaces/federated-options.interface.ts index 07fea1f11..d0cd0ae66 100644 --- a/packages/nestjs-federated/src/interfaces/federated-options.interface.ts +++ b/packages/nestjs-federated/src/interfaces/federated-options.interface.ts @@ -1,14 +1,7 @@ -import { FederatedSettingsInterface } from './federated-settings.interface'; -import { FederatedUserModelServiceInterface } from './federated-user-model-service.interface'; +import { type FederatedUserPortSettings } from '../domain/ports/federated-user.port.js'; +import { type FederatedSettingsInterface } from '../infrastructure/config/interfaces/federated-settings.interface.js'; export interface FederatedOptionsInterface { - /** - * Implementation of user model service class. - */ - userModelService: FederatedUserModelServiceInterface; - - /** - * Settings - */ + userPort: FederatedUserPortSettings; settings?: FederatedSettingsInterface; } diff --git a/packages/nestjs-federated/src/interfaces/federated-service.interface.ts b/packages/nestjs-federated/src/interfaces/federated-service.interface.ts deleted file mode 100644 index 73d00c5b1..000000000 --- a/packages/nestjs-federated/src/interfaces/federated-service.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { FederatedEntityInterface } from '@concepta/nestjs-common'; - -export interface FederatedServiceInterface { - exists( - provider: string, - federatedRef: string, - ): Promise; -} diff --git a/packages/nestjs-federated/src/interfaces/federated-user-model-service.interface.ts b/packages/nestjs-federated/src/interfaces/federated-user-model-service.interface.ts deleted file mode 100644 index dbd66e440..000000000 --- a/packages/nestjs-federated/src/interfaces/federated-user-model-service.interface.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - ByEmailInterface, - ByIdInterface, - CreateOneInterface, - ReferenceEmail, - ReferenceEmailInterface, - ReferenceId, - ReferenceUsernameInterface, -} from '@concepta/nestjs-common'; - -import { FederatedCredentialsInterface } from './federated-credentials.interface'; - -export interface FederatedUserModelServiceInterface - extends ByIdInterface, - ByEmailInterface, - CreateOneInterface< - ReferenceEmailInterface & ReferenceUsernameInterface, - FederatedCredentialsInterface - > {} diff --git a/packages/nestjs-federated/src/optional-typeorm.ts b/packages/nestjs-federated/src/optional-typeorm.ts new file mode 100644 index 000000000..8158ea517 --- /dev/null +++ b/packages/nestjs-federated/src/optional-typeorm.ts @@ -0,0 +1,2 @@ +export { IdentitySqliteEntity } from './infrastructure/persistence/typeorm/identity-sqlite.entity.js'; +export { IdentityPostgresEntity } from './infrastructure/persistence/typeorm/identity-postgres.entity.js'; diff --git a/packages/nestjs-federated/src/services/federated-model.service.ts b/packages/nestjs-federated/src/services/federated-model.service.ts deleted file mode 100644 index cba84dfcf..000000000 --- a/packages/nestjs-federated/src/services/federated-model.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ModelService, - RepositoryInterface, - FederatedCreatableInterface, - FederatedUpdatableInterface, - InjectDynamicRepository, - FederatedEntityInterface, -} from '@concepta/nestjs-common'; - -import { FederatedCreateDto } from '../dto/federated-create.dto'; -import { FederatedUpdateDto } from '../dto/federated-update.dto'; -import { FEDERATED_MODULE_FEDERATED_ENTITY_KEY } from '../federated.constants'; -import { FederatedModelServiceInterface } from '../interfaces/federated-model-service.interface'; - -/** - * Federated model service - */ -@Injectable() -export class FederatedModelService - extends ModelService< - FederatedEntityInterface, - FederatedCreatableInterface, - FederatedUpdatableInterface - > - implements FederatedModelServiceInterface -{ - protected createDto = FederatedCreateDto; - protected updateDto = FederatedUpdateDto; - - /** - * Constructor - * - * @param repo - instance of the federated repo - */ - constructor( - @InjectDynamicRepository(FEDERATED_MODULE_FEDERATED_ENTITY_KEY) - repo: RepositoryInterface, - ) { - super(repo); - } -} diff --git a/packages/nestjs-federated/src/services/federated-oauth.service.spec.ts b/packages/nestjs-federated/src/services/federated-oauth.service.spec.ts deleted file mode 100644 index 4dbf34aca..000000000 --- a/packages/nestjs-federated/src/services/federated-oauth.service.spec.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { FederatedEntityInterface } from '@concepta/nestjs-common'; - -import { FederatedCreateUserException } from '../exceptions/federated-create-user.exception'; -import { FederatedFindUserException } from '../exceptions/federated-find-user.exception'; -import { FederatedUserRelationshipException } from '../exceptions/federated-user-relationship.exception'; -import { FEDERATED_MODULE_USER_MODEL_SERVICE_TOKEN } from '../federated.constants'; -import { FederatedCredentialsInterface } from '../interfaces/federated-credentials.interface'; -import { FederatedUserModelServiceInterface } from '../interfaces/federated-user-model-service.interface'; - -import { FederatedModelService } from './federated-model.service'; -import { FederatedOAuthService } from './federated-oauth.service'; -import { FederatedService } from './federated.service'; - -describe('FederatedOAuthService', () => { - let service: FederatedOAuthService; - let userModelService: jest.Mocked; - let federatedService: jest.Mocked; - let federatedModelService: jest.Mocked; - - const mockUser: FederatedCredentialsInterface = { - id: 'user-id', - email: 'test@example.com', - username: 'testuser', - }; - - const mockFederated: FederatedEntityInterface = { - id: 'federated-id', - provider: 'google', - subject: 'subject-id', - user: { id: 'user-id' }, - dateCreated: new Date(), - dateUpdated: new Date(), - dateDeleted: null, - version: 1, - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - FederatedOAuthService, - { - provide: FEDERATED_MODULE_USER_MODEL_SERVICE_TOKEN, - useValue: { - byId: jest.fn(), - byEmail: jest.fn(), - create: jest.fn(), - }, - }, - { - provide: FederatedService, - useValue: { - exists: jest.fn(), - }, - }, - { - provide: FederatedModelService, - useValue: { - create: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get(FederatedOAuthService); - userModelService = module.get(FEDERATED_MODULE_USER_MODEL_SERVICE_TOKEN); - federatedService = module.get(FederatedService); - federatedModelService = module.get(FederatedModelService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('sign', () => { - it('should return existing user when federated exists', async () => { - // Arrange - jest.spyOn(federatedService, 'exists').mockResolvedValue(mockFederated); - jest.spyOn(userModelService, 'byId').mockResolvedValue(mockUser); - - // Act - const result = await service.sign( - 'google', - 'test@example.com', - 'subject-id', - ); - - // Assert - expect(result).toBe(mockUser); - expect(federatedService.exists).toHaveBeenCalledWith( - 'google', - 'subject-id', - ); - expect(userModelService.byId).toHaveBeenCalledWith('user-id'); - }); - - it('should create new user and federated when they do not exist', async () => { - // Arrange - jest.spyOn(federatedService, 'exists').mockResolvedValue(null); - jest.spyOn(userModelService, 'byEmail').mockResolvedValue(null); - jest.spyOn(userModelService, 'create').mockResolvedValue(mockUser); - jest - .spyOn(federatedModelService, 'create') - .mockResolvedValue(mockFederated); - - // Act - const result = await service.sign( - 'google', - 'test@example.com', - 'subject-id', - ); - - // Assert - expect(result).toBe(mockUser); - expect(federatedService.exists).toHaveBeenCalledWith( - 'google', - 'subject-id', - ); - expect(userModelService.byEmail).toHaveBeenCalledWith('test@example.com'); - expect(userModelService.create).toHaveBeenCalledWith({ - email: 'test@example.com', - username: 'test@example.com', - }); - expect(federatedModelService.create).toHaveBeenCalledWith({ - provider: 'google', - subject: 'subject-id', - user: mockUser, - }); - }); - - it('should use existing user when email exists but federated does not', async () => { - // Arrange - jest.spyOn(federatedService, 'exists').mockResolvedValue(null); - jest.spyOn(userModelService, 'byEmail').mockResolvedValue(mockUser); - jest - .spyOn(federatedModelService, 'create') - .mockResolvedValue(mockFederated); - - // Act - const result = await service.sign( - 'google', - 'test@example.com', - 'subject-id', - ); - - // Assert - expect(result).toBe(mockUser); - expect(federatedService.exists).toHaveBeenCalledWith( - 'google', - 'subject-id', - ); - expect(userModelService.byEmail).toHaveBeenCalledWith('test@example.com'); - expect(userModelService.create).not.toHaveBeenCalled(); - expect(federatedModelService.create).toHaveBeenCalledWith({ - provider: 'google', - subject: 'subject-id', - user: mockUser, - }); - }); - - it('should throw FederatedUserRelationshipException when federated exists but has no user', async () => { - // Arrange - const federatedWithoutUser = { - ...mockFederated, - user: { id: null } as unknown as { id: string }, - }; - jest - .spyOn(federatedService, 'exists') - .mockResolvedValue(federatedWithoutUser); - - // Act & Assert - await expect( - service.sign('google', 'test@example.com', 'subject-id'), - ).rejects.toThrow(FederatedUserRelationshipException); - }); - - it('should throw FederatedFindUserException when user is not found', async () => { - // Arrange - jest.spyOn(federatedService, 'exists').mockResolvedValue(mockFederated); - jest.spyOn(userModelService, 'byId').mockResolvedValue(null); - - // Act & Assert - await expect( - service.sign('google', 'test@example.com', 'subject-id'), - ).rejects.toThrow(FederatedFindUserException); - }); - - it('should throw FederatedCreateUserException when user creation fails', async () => { - // Arrange - jest.spyOn(federatedService, 'exists').mockResolvedValue(null); - jest.spyOn(userModelService, 'byEmail').mockResolvedValue(null); - jest - .spyOn(userModelService, 'create') - .mockRejectedValue(new Error('Failed to create user')); - - // Act & Assert - await expect( - service.sign('google', 'test@example.com', 'subject-id'), - ).rejects.toThrow(FederatedCreateUserException); - }); - - it('should throw ModelMutateException when federated creation fails', async () => { - // Arrange - jest.spyOn(federatedService, 'exists').mockResolvedValue(null); - jest.spyOn(userModelService, 'byEmail').mockResolvedValue(mockUser); - jest - .spyOn(federatedModelService, 'create') - .mockRejectedValue(new Error('Failed to create federated')); - - // Act & Assert - await expect( - service.sign('google', 'test@example.com', 'subject-id'), - ).rejects.toThrow( - 'Error while trying to mutate a FederatedOAuthService model', - ); - }); - }); -}); diff --git a/packages/nestjs-federated/src/services/federated-oauth.service.ts b/packages/nestjs-federated/src/services/federated-oauth.service.ts deleted file mode 100644 index 40e54e3ea..000000000 --- a/packages/nestjs-federated/src/services/federated-oauth.service.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { - NotAnErrorException, - ReferenceIdInterface, - ModelMutateException, - FederatedEntityInterface, -} from '@concepta/nestjs-common'; - -import { FederatedCreateUserException } from '../exceptions/federated-create-user.exception'; -import { FederatedCreateException } from '../exceptions/federated-create.exception'; -import { FederatedFindUserException } from '../exceptions/federated-find-user.exception'; -import { FederatedUserRelationshipException } from '../exceptions/federated-user-relationship.exception'; -import { FEDERATED_MODULE_USER_MODEL_SERVICE_TOKEN } from '../federated.constants'; -import { FederatedCredentialsInterface } from '../interfaces/federated-credentials.interface'; -import { FederatedModelServiceInterface } from '../interfaces/federated-model-service.interface'; -import { FederatedOAuthServiceInterface } from '../interfaces/federated-oauth-service.interface'; -import { FederatedUserModelServiceInterface } from '../interfaces/federated-user-model-service.interface'; - -import { FederatedModelService } from './federated-model.service'; -import { FederatedService } from './federated.service'; - -@Injectable() -export class FederatedOAuthService implements FederatedOAuthServiceInterface { - constructor( - @Inject(FEDERATED_MODULE_USER_MODEL_SERVICE_TOKEN) - public userModelService: FederatedUserModelServiceInterface, - public federatedService: FederatedService, - @Inject(FederatedModelService) - public federatedModelService: FederatedModelServiceInterface, - ) {} - - /** - * Sign in with federated creating a user if it doesn't exist - * - * @param provider - provider name (github, facebook, google) - * @param email - email account - * @param subject - subject (user id/ profile id from provider) - */ - async sign( - provider: string, - email: string, - subject: string, - ): Promise { - const federated = await this.federatedService.exists(provider, subject); - - // if there is no federated user, create one - if (!federated) { - return await this.createUserWithFederated(provider, email, subject); - } else { - if (!federated.user?.id) { - throw new FederatedUserRelationshipException(federated.id); - } - - const user = await this.userModelService.byId(federated.user.id); - - if (!user) { - throw new FederatedFindUserException( - this.constructor.name, - federated.user, - ); - } - - return user; - } - } - - /** - * Logic to create user and federated - * - * @internal - */ - protected async createUserWithFederated( - provider: string, - email: string, - subject: string, - ): Promise { - // Check if user exists by email - const user = await this.userModelService.byEmail(email); - const userResult: FederatedCredentialsInterface = user - ? user - : await this.createUser(email, email); - - // Create federated - await this.createFederated(provider, subject, userResult); - - return userResult; - } - - /** - * Create a user - * - * @internal - */ - protected async createUser( - email: string, - username: string, - ): Promise { - try { - const newUser = await this.userModelService.create({ - email, - username, - }); - - if (!newUser) - throw new FederatedCreateUserException(this.constructor.name, { - message: 'Failed to create user', - }); - - return newUser; - } catch (e) { - const exception = e instanceof Error ? e : new NotAnErrorException(e); - throw new FederatedCreateUserException(this.constructor.name, exception); - } - } - - /** - * Create federated credentials - * - * @internal - */ - private async createFederated( - provider: string, - subject: string, - user: ReferenceIdInterface, - ): Promise { - try { - const federated = await this.federatedModelService.create({ - provider, - subject, - user, - }); - - if (!federated) - throw new FederatedCreateException(this.constructor.name, { - message: 'Failed to create federated', - }); - - return federated; - } catch (e) { - const exception = e instanceof Error ? e : new NotAnErrorException(e); - throw new ModelMutateException(this.constructor.name, exception); - } - } -} diff --git a/packages/nestjs-federated/src/services/federated.service.spec.ts b/packages/nestjs-federated/src/services/federated.service.spec.ts deleted file mode 100644 index 9ea4b11d1..000000000 --- a/packages/nestjs-federated/src/services/federated.service.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { - RepositoryInterface, - FederatedEntityInterface, - getDynamicRepositoryToken, -} from '@concepta/nestjs-common'; - -import { FederatedQueryException } from '../exceptions/federated-query.exception'; -import { FEDERATED_MODULE_FEDERATED_ENTITY_KEY } from '../federated.constants'; - -import { FederatedService } from './federated.service'; - -describe(FederatedService.name, () => { - let service: FederatedService; - let repo: RepositoryInterface; - - beforeEach(async () => { - const mockRepo = { - findOne: jest.fn(), - entityName: () => 'FederatedEntity', - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - FederatedService, - { - provide: getDynamicRepositoryToken( - FEDERATED_MODULE_FEDERATED_ENTITY_KEY, - ), - useValue: mockRepo, - }, - ], - }).compile(); - - service = module.get(FederatedService); - repo = module.get( - getDynamicRepositoryToken(FEDERATED_MODULE_FEDERATED_ENTITY_KEY), - ); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe(FederatedService.prototype.exists.name, () => { - it('should return federated entity when it exists', async () => { - // Arrange - const provider = 'google'; - const subject = '123456'; - const expectedEntity = { - id: '1', - provider, - subject, - } as FederatedEntityInterface; - jest.spyOn(repo, 'findOne').mockResolvedValue(expectedEntity); - - // Act - const result = await service.exists(provider, subject); - - // Assert - expect(result).toBe(expectedEntity); - expect(repo.findOne).toHaveBeenCalledWith({ - where: { - provider, - subject, - }, - }); - }); - - it('should return null when federated entity does not exist', async () => { - // Arrange - const provider = 'google'; - const subject = '123456'; - jest.spyOn(repo, 'findOne').mockResolvedValue(null); - - // Act - const result = await service.exists(provider, subject); - - // Assert - expect(result).toBeNull(); - expect(repo.findOne).toHaveBeenCalledWith({ - where: { - provider, - subject, - }, - }); - }); - - it('should throw FederatedQueryException when repository throws an error', async () => { - // Arrange - const provider = 'google'; - const subject = '123456'; - const error = new Error('Database error'); - jest.spyOn(repo, 'findOne').mockImplementation(() => { - throw error; - }); - - // Act & Assert - let thrownError: unknown; - try { - await service.exists(provider, subject); - } catch (e) { - thrownError = e; - } - - expect(thrownError).toBeDefined(); - expect(thrownError).toBeInstanceOf(FederatedQueryException); - expect((thrownError as FederatedQueryException).context.entityName).toBe( - 'FederatedEntity', - ); - expect(repo.findOne).toHaveBeenCalledWith({ - where: { - provider, - subject, - }, - }); - }); - - it('should throw FederatedQueryException when repository throws a non-Error object', async () => { - // Arrange - const provider = 'google'; - const subject = '123456'; - const error = 'Database error'; - jest.spyOn(repo, 'findOne').mockImplementation(() => { - throw error; - }); - - // Act & Assert - let thrownError: unknown; - try { - await service.exists(provider, subject); - } catch (e) { - thrownError = e; - } - - expect(thrownError).toBeDefined(); - expect(thrownError).toBeInstanceOf(FederatedQueryException); - expect((thrownError as FederatedQueryException).context.entityName).toBe( - 'FederatedEntity', - ); - expect(repo.findOne).toHaveBeenCalledWith({ - where: { - provider, - subject, - }, - }); - }); - }); -}); diff --git a/packages/nestjs-federated/src/services/federated.service.ts b/packages/nestjs-federated/src/services/federated.service.ts deleted file mode 100644 index c118a8b15..000000000 --- a/packages/nestjs-federated/src/services/federated.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - NotAnErrorException, - RepositoryInterface, - InjectDynamicRepository, - FederatedEntityInterface, -} from '@concepta/nestjs-common'; - -import { FederatedQueryException } from '../exceptions/federated-query.exception'; -import { FEDERATED_MODULE_FEDERATED_ENTITY_KEY } from '../federated.constants'; -import { FederatedServiceInterface } from '../interfaces/federated-service.interface'; - -@Injectable() -export class FederatedService implements FederatedServiceInterface { - constructor( - @InjectDynamicRepository(FEDERATED_MODULE_FEDERATED_ENTITY_KEY) - protected readonly repo: RepositoryInterface, - ) {} - - async exists(provider: string, subject: string) { - try { - return this.repo.findOne({ - where: { - provider, - subject, - }, - }); - } catch (e) { - const exception = e instanceof Error ? e : new NotAnErrorException(e); - throw new FederatedQueryException(this.repo.entityName(), exception); - } - } -} diff --git a/packages/nestjs-federated/tsconfig.json b/packages/nestjs-federated/tsconfig.json index d27d25640..263c943e6 100644 --- a/packages/nestjs-federated/tsconfig.json +++ b/packages/nestjs-federated/tsconfig.json @@ -4,10 +4,13 @@ "composite": true, "rootDir": "./src", "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", "typeRoots": [ "./node_modules/@types", - "../../node_modules/@types" - ] + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] }, "include": [ "src/**/*.ts" diff --git a/packages/nestjs-file/package.json b/packages/nestjs-file/package.json index 908e689ef..2d95bf4f6 100644 --- a/packages/nestjs-file/package.json +++ b/packages/nestjs-file/package.json @@ -15,7 +15,7 @@ "@concepta/nestjs-typeorm-ext": "^7.0.0-alpha.10", "@nestjs/common": "^11.1.9", "@nestjs/config": "^4.0.2", - "@nestjs/swagger": "^11.2.2" + "@nestjs/swagger": "11.2.2" }, "devDependencies": { "@concepta/nestjs-user": "^7.0.0-alpha.10", diff --git a/packages/nestjs-file/src/__fixtures__/aws-storage.service.ts b/packages/nestjs-file/src/__fixtures__/aws-storage.service.ts index 67a3e0872..d14210fcb 100644 --- a/packages/nestjs-file/src/__fixtures__/aws-storage.service.ts +++ b/packages/nestjs-file/src/__fixtures__/aws-storage.service.ts @@ -1,6 +1,6 @@ -import { FileCreatableInterface } from '@concepta/nestjs-common'; +import { type FileCreatableInterface } from '@concepta/nestjs-common'; -import { FileStorageServiceInterface } from '../interfaces/file-storage-service.interface'; +import { type FileStorageServiceInterface } from '../interfaces/file-storage-service.interface'; import { AWS_KEY_FIXTURE, diff --git a/packages/nestjs-file/src/__fixtures__/aws.controller.ts b/packages/nestjs-file/src/__fixtures__/aws.controller.ts index b32553a68..89e45587e 100644 --- a/packages/nestjs-file/src/__fixtures__/aws.controller.ts +++ b/packages/nestjs-file/src/__fixtures__/aws.controller.ts @@ -1,5 +1,5 @@ -import { FileCreateDto } from '../dto/file-create.dto'; -import { FileService } from '../services/file.service'; +import { type FileCreateDto } from '../dto/file-create.dto'; +import { type FileService } from '../services/file.service'; export class AwsController { constructor(private fileService: FileService) {} diff --git a/packages/nestjs-file/src/config/file-default.config.ts b/packages/nestjs-file/src/config/file-default.config.ts index b461dc693..a60f0d98a 100644 --- a/packages/nestjs-file/src/config/file-default.config.ts +++ b/packages/nestjs-file/src/config/file-default.config.ts @@ -1,7 +1,7 @@ import { registerAs } from '@nestjs/config'; import { FILE_MODULE_DEFAULT_SETTINGS_TOKEN } from '../file.constants'; -import { FileSettingsInterface } from '../interfaces/file-settings.interface'; +import { type FileSettingsInterface } from '../interfaces/file-settings.interface'; /** * Default configuration for file module. diff --git a/packages/nestjs-file/src/entities/common-postgres.entity.ts b/packages/nestjs-file/src/entities/common-postgres.entity.ts new file mode 100644 index 000000000..86db48081 --- /dev/null +++ b/packages/nestjs-file/src/entities/common-postgres.entity.ts @@ -0,0 +1,24 @@ +import { + CreateDateColumn, + DeleteDateColumn, + PrimaryGeneratedColumn, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; + +export abstract class CommonPostgresEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + dateCreated!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + dateUpdated!: Date; + + @DeleteDateColumn({ type: 'timestamptz' }) + dateDeleted!: Date | null; + + @VersionColumn({ type: 'integer' }) + version!: number; +} diff --git a/packages/nestjs-file/src/entities/common-sqlite.entity.ts b/packages/nestjs-file/src/entities/common-sqlite.entity.ts new file mode 100644 index 000000000..15e315bf7 --- /dev/null +++ b/packages/nestjs-file/src/entities/common-sqlite.entity.ts @@ -0,0 +1,24 @@ +import { + CreateDateColumn, + DeleteDateColumn, + PrimaryGeneratedColumn, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; + +export abstract class CommonSqliteEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'datetime' }) + dateCreated!: Date; + + @UpdateDateColumn({ type: 'datetime' }) + dateUpdated!: Date; + + @DeleteDateColumn({ type: 'datetime' }) + dateDeleted!: Date | null; + + @VersionColumn({ type: 'integer' }) + version!: number; +} diff --git a/packages/nestjs-typeorm-ext/src/entities/file/file-postgres.entity.ts b/packages/nestjs-file/src/entities/file-postgres.entity.ts similarity index 88% rename from packages/nestjs-typeorm-ext/src/entities/file/file-postgres.entity.ts rename to packages/nestjs-file/src/entities/file-postgres.entity.ts index 29ba74816..c25b6cf02 100644 --- a/packages/nestjs-typeorm-ext/src/entities/file/file-postgres.entity.ts +++ b/packages/nestjs-file/src/entities/file-postgres.entity.ts @@ -2,7 +2,7 @@ import { Column, Entity, Unique } from 'typeorm'; import { FileEntityInterface } from '@concepta/nestjs-common'; -import { CommonPostgresEntity } from '../common/common-postgres.entity'; +import { CommonPostgresEntity } from './common-postgres.entity'; /** * File Postgres Entity diff --git a/packages/nestjs-typeorm-ext/src/entities/file/file-sqlite.entity.ts b/packages/nestjs-file/src/entities/file-sqlite.entity.ts similarity index 88% rename from packages/nestjs-typeorm-ext/src/entities/file/file-sqlite.entity.ts rename to packages/nestjs-file/src/entities/file-sqlite.entity.ts index 72758c13a..0e7a37554 100644 --- a/packages/nestjs-typeorm-ext/src/entities/file/file-sqlite.entity.ts +++ b/packages/nestjs-file/src/entities/file-sqlite.entity.ts @@ -2,7 +2,7 @@ import { Column, Entity, Unique } from 'typeorm'; import { FileEntityInterface } from '@concepta/nestjs-common'; -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; +import { CommonSqliteEntity } from './common-sqlite.entity'; /** * File Sqlite Entity diff --git a/packages/nestjs-file/src/exceptions/file-create.exception.ts b/packages/nestjs-file/src/exceptions/file-create.exception.ts index e5a511604..4bd5decbd 100644 --- a/packages/nestjs-file/src/exceptions/file-create.exception.ts +++ b/packages/nestjs-file/src/exceptions/file-create.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { FileException } from './file.exception'; diff --git a/packages/nestjs-file/src/exceptions/file-download-url-missing.exception.ts b/packages/nestjs-file/src/exceptions/file-download-url-missing.exception.ts index 97b4c18e0..db0414f6a 100644 --- a/packages/nestjs-file/src/exceptions/file-download-url-missing.exception.ts +++ b/packages/nestjs-file/src/exceptions/file-download-url-missing.exception.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { FileException } from './file.exception'; diff --git a/packages/nestjs-file/src/exceptions/file-duplicated.exception.ts b/packages/nestjs-file/src/exceptions/file-duplicated.exception.ts index 279d24c3a..9be6b7569 100644 --- a/packages/nestjs-file/src/exceptions/file-duplicated.exception.ts +++ b/packages/nestjs-file/src/exceptions/file-duplicated.exception.ts @@ -1,8 +1,8 @@ import { HttpStatus } from '@nestjs/common'; import { - RuntimeException, - RuntimeExceptionOptions, + type RuntimeException, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; import { FileException } from './file.exception'; @@ -28,7 +28,7 @@ export class FileDuplicateEntryException extends FileException { this.errorCode = 'FILE_DUPLICATE_ENTRY_ERROR'; this.context = { - ...super.context, + ...this.context, serviceKey, fileName, }; diff --git a/packages/nestjs-file/src/exceptions/file-id-missing.exception.ts b/packages/nestjs-file/src/exceptions/file-id-missing.exception.ts index c84ad29f5..a9fb6f59e 100644 --- a/packages/nestjs-file/src/exceptions/file-id-missing.exception.ts +++ b/packages/nestjs-file/src/exceptions/file-id-missing.exception.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { FileException } from './file.exception'; diff --git a/packages/nestjs-file/src/exceptions/file-name-missing.exception.ts b/packages/nestjs-file/src/exceptions/file-name-missing.exception.ts index c40b7feb3..d8a2557e0 100644 --- a/packages/nestjs-file/src/exceptions/file-name-missing.exception.ts +++ b/packages/nestjs-file/src/exceptions/file-name-missing.exception.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { FileException } from './file.exception'; diff --git a/packages/nestjs-file/src/exceptions/file-query.exception.ts b/packages/nestjs-file/src/exceptions/file-query.exception.ts index 0c98fa7a0..5b1551201 100644 --- a/packages/nestjs-file/src/exceptions/file-query.exception.ts +++ b/packages/nestjs-file/src/exceptions/file-query.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { FileException } from './file.exception'; diff --git a/packages/nestjs-file/src/exceptions/file-service-key-missing.exception.ts b/packages/nestjs-file/src/exceptions/file-service-key-missing.exception.ts index 20f5e7370..8c9c26ac6 100644 --- a/packages/nestjs-file/src/exceptions/file-service-key-missing.exception.ts +++ b/packages/nestjs-file/src/exceptions/file-service-key-missing.exception.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { FileException } from './file.exception'; diff --git a/packages/nestjs-file/src/exceptions/file-storage-service-not-found.exception.ts b/packages/nestjs-file/src/exceptions/file-storage-service-not-found.exception.ts index 416ede56b..872dff880 100644 --- a/packages/nestjs-file/src/exceptions/file-storage-service-not-found.exception.ts +++ b/packages/nestjs-file/src/exceptions/file-storage-service-not-found.exception.ts @@ -1,6 +1,6 @@ import { - RuntimeException, - RuntimeExceptionOptions, + type RuntimeException, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; import { FileException } from './file.exception'; @@ -20,7 +20,7 @@ export class FileStorageServiceNotFoundException extends FileException { this.errorCode = 'FILE_STORAGE_SERVICE_NOT_FOUND_ERROR'; this.context = { - ...super.context, + ...this.context, storageServiceName: assignmentName, }; } diff --git a/packages/nestjs-file/src/exceptions/file-upload-url-missing.exception.ts b/packages/nestjs-file/src/exceptions/file-upload-url-missing.exception.ts index b2edefa38..ff2352339 100644 --- a/packages/nestjs-file/src/exceptions/file-upload-url-missing.exception.ts +++ b/packages/nestjs-file/src/exceptions/file-upload-url-missing.exception.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { FileException } from './file.exception'; diff --git a/packages/nestjs-file/src/exceptions/file.exception.ts b/packages/nestjs-file/src/exceptions/file.exception.ts index 25314d407..ccc5fac4a 100644 --- a/packages/nestjs-file/src/exceptions/file.exception.ts +++ b/packages/nestjs-file/src/exceptions/file.exception.ts @@ -1,6 +1,6 @@ import { RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; /** diff --git a/packages/nestjs-file/src/file.module-definition.ts b/packages/nestjs-file/src/file.module-definition.ts index 950fa42b4..684f63a6e 100644 --- a/packages/nestjs-file/src/file.module-definition.ts +++ b/packages/nestjs-file/src/file.module-definition.ts @@ -1,7 +1,7 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; @@ -12,9 +12,9 @@ import { FILE_MODULE_SETTINGS_TOKEN, FILE_STRATEGY_SERVICE_KEY, } from './file.constants'; -import { FileOptionsExtrasInterface } from './interfaces/file-options-extras.interface'; -import { FileOptionsInterface } from './interfaces/file-options.interface'; -import { FileSettingsInterface } from './interfaces/file-settings.interface'; +import { type FileOptionsExtrasInterface } from './interfaces/file-options-extras.interface'; +import { type FileOptionsInterface } from './interfaces/file-options.interface'; +import { type FileSettingsInterface } from './interfaces/file-settings.interface'; import { FileModelService } from './services/file-model.service'; import { FileStrategyService } from './services/file-strategy.service'; import { FileService } from './services/file.service'; diff --git a/packages/nestjs-file/src/file.module.spec.ts b/packages/nestjs-file/src/file.module.spec.ts index 07da6c9cd..407d09195 100644 --- a/packages/nestjs-file/src/file.module.spec.ts +++ b/packages/nestjs-file/src/file.module.spec.ts @@ -1,10 +1,10 @@ -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type DynamicModule, type ModuleMetadata } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { - RepositoryInterface, + type RepositoryInterface, getDynamicRepositoryToken, - FileEntityInterface, + type FileEntityInterface, } from '@concepta/nestjs-common'; import { TypeOrmExtModule, diff --git a/packages/nestjs-file/src/index.ts b/packages/nestjs-file/src/index.ts index 172196bb1..96b0388f0 100644 --- a/packages/nestjs-file/src/index.ts +++ b/packages/nestjs-file/src/index.ts @@ -1,5 +1,9 @@ export { FileModule } from './file.module'; +// entities +export { FileSqliteEntity } from './entities/file-sqlite.entity'; +export { FilePostgresEntity } from './entities/file-postgres.entity'; + export { FileServiceInterface } from './interfaces/file-service.interface'; export { FileStorageServiceInterface } from './interfaces/file-storage-service.interface'; diff --git a/packages/nestjs-file/src/interfaces/file-entities-options.interface.ts b/packages/nestjs-file/src/interfaces/file-entities-options.interface.ts index 5dd6c83ac..ef4c00f4a 100644 --- a/packages/nestjs-file/src/interfaces/file-entities-options.interface.ts +++ b/packages/nestjs-file/src/interfaces/file-entities-options.interface.ts @@ -1,9 +1,9 @@ import { - FileEntityInterface, - RepositoryEntityOptionInterface, + type FileEntityInterface, + type RepositoryEntityOptionInterface, } from '@concepta/nestjs-common'; -import { FILE_MODULE_FILE_ENTITY_KEY } from '../file.constants'; +import { type FILE_MODULE_FILE_ENTITY_KEY } from '../file.constants'; export interface FileEntitiesOptionsInterface { [FILE_MODULE_FILE_ENTITY_KEY]: RepositoryEntityOptionInterface; diff --git a/packages/nestjs-file/src/interfaces/file-model-service.interface.ts b/packages/nestjs-file/src/interfaces/file-model-service.interface.ts index f69e2b498..ed5c4ad79 100644 --- a/packages/nestjs-file/src/interfaces/file-model-service.interface.ts +++ b/packages/nestjs-file/src/interfaces/file-model-service.interface.ts @@ -1,13 +1,14 @@ import { - ByIdInterface, - FileCreatableInterface, - ReferenceId, - CreateOneInterface, - FileEntityInterface, + type ByIdInterface, + type FileCreatableInterface, + type ReferenceId, + type CreateOneInterface, + type FileEntityInterface, } from '@concepta/nestjs-common'; export interface FileModelServiceInterface - extends ByIdInterface, + extends + ByIdInterface, CreateOneInterface { getUniqueFile( org: Pick, diff --git a/packages/nestjs-file/src/interfaces/file-options-extras.interface.ts b/packages/nestjs-file/src/interfaces/file-options-extras.interface.ts index f54fc9cbb..93566df53 100644 --- a/packages/nestjs-file/src/interfaces/file-options-extras.interface.ts +++ b/packages/nestjs-file/src/interfaces/file-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface FileOptionsExtrasInterface - extends Pick {} +export interface FileOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-file/src/interfaces/file-options.interface.ts b/packages/nestjs-file/src/interfaces/file-options.interface.ts index b138befe9..d851e12db 100644 --- a/packages/nestjs-file/src/interfaces/file-options.interface.ts +++ b/packages/nestjs-file/src/interfaces/file-options.interface.ts @@ -1,5 +1,5 @@ -import { FileSettingsInterface } from './file-settings.interface'; -import { FileStorageServiceInterface } from './file-storage-service.interface'; +import { type FileSettingsInterface } from './file-settings.interface'; +import { type FileStorageServiceInterface } from './file-storage-service.interface'; export interface FileOptionsInterface { storageServices?: FileStorageServiceInterface[]; diff --git a/packages/nestjs-file/src/interfaces/file-service.interface.ts b/packages/nestjs-file/src/interfaces/file-service.interface.ts index 10bfb45ec..df1e0827d 100644 --- a/packages/nestjs-file/src/interfaces/file-service.interface.ts +++ b/packages/nestjs-file/src/interfaces/file-service.interface.ts @@ -1,7 +1,7 @@ import { - FileCreatableInterface, - FileInterface, - ReferenceIdInterface, + type FileCreatableInterface, + type FileInterface, + type ReferenceIdInterface, } from '@concepta/nestjs-common'; export interface FileServiceInterface { diff --git a/packages/nestjs-file/src/interfaces/file-storage-service.interface.ts b/packages/nestjs-file/src/interfaces/file-storage-service.interface.ts index 64f8bda27..ea362542d 100644 --- a/packages/nestjs-file/src/interfaces/file-storage-service.interface.ts +++ b/packages/nestjs-file/src/interfaces/file-storage-service.interface.ts @@ -1,4 +1,4 @@ -import { FileCreatableInterface } from '@concepta/nestjs-common'; +import { type FileCreatableInterface } from '@concepta/nestjs-common'; export interface FileStorageServiceInterface { KEY: string; diff --git a/packages/nestjs-file/src/interfaces/file-strategy-service.interface.ts b/packages/nestjs-file/src/interfaces/file-strategy-service.interface.ts index bea5d9bf0..0cb74c634 100644 --- a/packages/nestjs-file/src/interfaces/file-strategy-service.interface.ts +++ b/packages/nestjs-file/src/interfaces/file-strategy-service.interface.ts @@ -1,6 +1,6 @@ -import { FileCreatableInterface } from '@concepta/nestjs-common'; +import { type FileCreatableInterface } from '@concepta/nestjs-common'; -import { FileStorageServiceInterface } from './file-storage-service.interface'; +import { type FileStorageServiceInterface } from './file-storage-service.interface'; export interface FileStrategyServiceInterface { getUploadUrl(file: FileCreatableInterface): Promise; diff --git a/packages/nestjs-file/src/services/file-strategy.service.spec.ts b/packages/nestjs-file/src/services/file-strategy.service.spec.ts index 7b1569e3b..2416c392f 100644 --- a/packages/nestjs-file/src/services/file-strategy.service.spec.ts +++ b/packages/nestjs-file/src/services/file-strategy.service.spec.ts @@ -1,6 +1,6 @@ import { FileCreateDto } from '../dto/file-create.dto'; import { FileStorageServiceNotFoundException } from '../exceptions/file-storage-service-not-found.exception'; -import { FileStorageServiceInterface } from '../interfaces/file-storage-service.interface'; +import { type FileStorageServiceInterface } from '../interfaces/file-storage-service.interface'; import { FileStrategyService } from './file-strategy.service'; diff --git a/packages/nestjs-file/src/services/file-strategy.service.ts b/packages/nestjs-file/src/services/file-strategy.service.ts index c7ddbe20c..7809d322e 100644 --- a/packages/nestjs-file/src/services/file-strategy.service.ts +++ b/packages/nestjs-file/src/services/file-strategy.service.ts @@ -1,9 +1,9 @@ -import { FileCreatableInterface } from '@concepta/nestjs-common'; +import { type FileCreatableInterface } from '@concepta/nestjs-common'; import { FileDownloadUrlMissingException } from '../exceptions/file-download-url-missing.exception'; import { FileStorageServiceNotFoundException } from '../exceptions/file-storage-service-not-found.exception'; -import { FileStorageServiceInterface } from '../interfaces/file-storage-service.interface'; -import { FileStrategyServiceInterface } from '../interfaces/file-strategy-service.interface'; +import { type FileStorageServiceInterface } from '../interfaces/file-storage-service.interface'; +import { type FileStrategyServiceInterface } from '../interfaces/file-strategy-service.interface'; export class FileStrategyService implements FileStrategyServiceInterface { private readonly storageServices: FileStorageServiceInterface[] = []; diff --git a/packages/nestjs-file/src/services/file.service.spec.ts b/packages/nestjs-file/src/services/file.service.spec.ts index 210873ea5..83b80151e 100644 --- a/packages/nestjs-file/src/services/file.service.spec.ts +++ b/packages/nestjs-file/src/services/file.service.spec.ts @@ -1,18 +1,18 @@ import { randomUUID } from 'crypto'; -import { mock, MockProxy } from 'jest-mock-extended'; +import { mock, type MockProxy } from 'jest-mock-extended'; import { - FileCreatableInterface, - RepositoryInterface, - FileEntityInterface, + type FileCreatableInterface, + type RepositoryInterface, + type FileEntityInterface, } from '@concepta/nestjs-common'; -import { FileCreateDto } from '../dto/file-create.dto'; +import { type FileCreateDto } from '../dto/file-create.dto'; import { FileQueryException } from '../exceptions/file-query.exception'; import { FileModelService } from './file-model.service'; -import { FileStrategyService } from './file-strategy.service'; +import { type FileStrategyService } from './file-strategy.service'; import { FileService } from './file.service'; describe(FileService.name, () => { diff --git a/packages/nestjs-invitation/README.md b/packages/nestjs-invitation/README.md index d91c54d36..dea1d89a7 100644 --- a/packages/nestjs-invitation/README.md +++ b/packages/nestjs-invitation/README.md @@ -1,15 +1,679 @@ # Rockets NestJS Invitation -Invite user by email +Invite users by email with OTP-based acceptance, notification dispatch through +consumer-supplied ports, and event-driven lifecycle management. ## Project [![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-invitation)](https://www.npmjs.com/package/@concepta/nestjs-invitation) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-invitation)](https://www.npmjs.com/package/@concepta/nestjs-invitation) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-invitation)](https://www.npmjs.com/package/@concepta/nestjs-invitation) [![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) [![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-invitation%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +## Table of Contents + +- [Provided Features](#provided-features) +- [Installation](#installation) +- [Module Registration](#module-registration) +- [Architecture Overview](#architecture-overview) +- [Aggregate](#aggregate) +- [Ports](#ports) +- [Policies](#policies) +- [Commands](#commands) +- [Queries](#queries) +- [Domain Events](#domain-events) +- [Schemas](#schemas) +- [Exceptions](#exceptions) +- [HTTP Gateway](#http-gateway) +- [Entry Points](#entry-points) +- [Seeding](#seeding) +- [Default Configuration](#default-configuration) + +## Provided Features + +### Invitation Lifecycle + +- Create invitation by user ID (with explicit code) +- Create invitation by email address (auto-generates code, resolves user) +- Send/resend invitation email (with OTP passcode generation) +- Accept invitation (OTP validation + payload for downstream listeners) +- Revoke all invitations for a user+category +- Remove (hard delete) an invitation + +### OTP Integration + +- Auto-create OTP on invitation send +- Consume OTP on acceptance (single-use) +- Clear all OTPs for a user+category on revocation +- Configurable OTP type (uuid, numeric, etc.) +- Configurable expiration duration +- Optional clear-on-create behavior +- Optional rate limiting (rateSeconds + rateThreshold) + +### Notification Dispatch + +- The module sends NOTHING itself — listeners dispatch commands through + `InvitationNotificationPort`, whose command classes are supplied by the + consumer (see [Ports](#ports)) +- Invitation notification command carries the passcode and expiration +- Acceptance confirmation command dispatched on accept +- The consumer's `@CommandHandler`s decide the transport (email, SMS, push) + and resolve addresses/templates from their own module settings + +### User Resolution + +- Look up user by ID +- Look up user by email address + +### Event-Driven Architecture + +- `InvitationCreatedEvent` -- fired on creation +- `InvitationDispatchedEvent` -- fired when email should be sent + (carries OTP metadata) +- `InvitationAcceptedEvent` -- fired on acceptance + (carries optional payload for downstream listeners) +- `InvitationRevokedEvent` -- fired on revocation +- `InvitationRemovedEvent` -- fired on deletion +- `InvitationDispatchedListener` dispatches the invitation notification + command via the notification port +- `InvitationAcceptedListener` dispatches the acceptance confirmation + command via the notification port +- Auto-clear OTPs on revocation via `InvitationRevokedListener` + +### Auto-Revocation + +- On acceptance: automatically revoke all sibling invitations (same user+category) +- On revocation: automatically clear associated OTPs + +### HTTP Gateway Overview + +- Create invitation endpoint (by user ID) +- Create invitation endpoint (by email) +- Send/resend invitation endpoint +- Accept invitation endpoint +- Delete invitation endpoint +- List invitations (paginated) +- Read single invitation + +### Repository + +- Get invitation by ID +- Find invitation by code +- Find all invitations by user+category +- Save (insert/update) +- Remove single invitation +- Batch remove invitations +- Domain-to-persistence mapping via `InvitationMapper` + +### Seeding Overview + +- `InvitationFactory` for generating test invitation entities + +### Configurable Settings + +- OTP: namespace, type, expiration, clear-on-create, rate limiting + +--- ## Installation -`yarn add @concepta/nestjs-invitation` +```sh +yarn add @concepta/nestjs-invitation @nestjs/common @nestjs/config @nestjs/core +``` + +This package is ESM-only and requires Node.js >= 22.12 and NestJS 12. + +### Peer Dependencies + +| Package | Required | Notes | +| --- | --- | --- | +| `@concepta/nestjs-crud` | Yes | The main entry imports `paginatedSchema` from it | +| `@nestjs/common` | Yes | NestJS framework peer | +| `@nestjs/config` | Yes | Used by the module's config factory | +| `@nestjs/core` | Yes | Required transitively by `@nestjs/cqrs` | +| `@nestjs/cqrs` | No | Optional peer — required in practice, the module dispatches all commands/queries through it | +| `rxjs` | Yes | NestJS requirement | +| `typeorm` | No | Only if using the TypeORM repository adapter | +| `@concepta/nestjs-repository-typeorm` | No | Only if using the TypeORM repository adapter | + +## Module Registration + +### Synchronous + +```ts +import { InvitationModule } from '@concepta/nestjs-invitation'; + +@Module({ + imports: [ + InvitationModule.register({ + settings: { + otp: { + namespace: 'user-otp', + type: 'uuid', + expiresIn: '24h', + }, + }, + ports: { + otp: { + createCommand: CreateOtpCommand, // e.g. from @concepta/nestjs-otp + consumeCommand: ConsumeOtpCommand, + clearCommand: ClearOtpsCommand, + validateQuery: ValidateOtpQuery, + }, + user: { + getByIdQuery: GetUserQuery, // e.g. from @concepta/nestjs-user + getByEmailQuery: GetUserByEmailQuery, + }, + notification: { + sendInvitationCommand: MySendInvitationCommand, // consumer-authored + sendAcceptedCommand: MySendAcceptedCommand, + }, + }, + }), + ], +}) +export class AppModule {} +``` + +### Asynchronous + +```ts +@Module({ + imports: [ + InvitationModule.registerAsync({ + useFactory: async () => ({ + settings: { /* ... */ }, + ports: { /* ... */ }, + }), + }), + ], +}) +export class AppModule {} +``` + +`register()` / `registerAsync()` register the module **locally** (scoped to +the importing module). + +`forRoot()` / `forRootAsync()` register the module **globally**. + +### Options + +```ts +interface InvitationOptionsInterface extends ModuleOptionsControllerInterface { + settings?: InvitationSettingsInterface; + ports: InvitationPortsInterface; +} + +interface InvitationPortsInterface { + otp: InvitationOtpPortSettings; + user: InvitationUserPortSettings; + notification: InvitationNotificationPortSettings; +} + +interface InvitationSettingsInterface { + otp: InvitationOtpSettingsInterface; +} +``` + +## Architecture Overview + +The module follows a DDD/CQRS architecture: + +```text +Gateways (HTTP request/response handlers) + | +Application (Commands / Queries / Listeners) + | +Domain (Invitation aggregate, Events, Ports, Policies) + | +Infrastructure (Repository, Mapper, Schemas, Config) +``` + +| Layer | Directory | Responsibility | +| --- | --- | --- | +| Domain | `domain/` | Aggregate, events, ports, policies, repository interface | +| Application | `application/` | Command/query handlers, event listeners, exceptions | +| Infrastructure | `infrastructure/` | Schemas, persistence (repository, mapper, entities), config | +| Gateways | `gateways/` | HTTP request handlers (REST endpoints) | + +## Aggregate + +The `Invitation` class extends `DomainAggregate` and +encapsulates all invitation domain logic. + +### Factory Methods + +```ts +// Create with auto-generated UUID +const invitation = Invitation.create(eventContext, { + code: 'abc-123', + category: 'onboarding', + userId: 'user-1', + constraints: { role: 'editor' }, +}); + +// Create with a specific ID +const invitation = Invitation.createWithId(eventContext, id, dto); +``` + +### Operations + +```ts +// Dispatch invitation (fires InvitationDispatchedEvent) +invitation.dispatch(eventContext); + +// Accept invitation (fires InvitationAcceptedEvent) +invitation.accept(eventContext, payload); + +// Revoke invitation (fires InvitationRevokedEvent) +invitation.revoke(eventContext); + +// Remove invitation (fires InvitationRemovedEvent) +invitation.remove(eventContext); + +// Convert to plain object +const plain = invitation.toPlain(); +``` + +### Properties + +| Property | Type | Description | +| --- | --- | --- | +| `code` | `string` | Unique invitation code | +| `category` | `string` | Invitation category | +| `userId` | `ReferenceId` | Invited user ID | +| `constraints` | `LiteralObject \| undefined` | Optional constraints | +| `dateAccepted` | `Date \| null` | Acceptance timestamp | +| `dateRevoked` | `Date \| null` | Revocation timestamp | +| `active` | `boolean` | `true` if not accepted and not revoked | +| `isAccepted` | `boolean` | `true` if accepted | +| `isRevoked` | `boolean` | `true` if revoked | + +## Ports + +External integrations are abstracted via three ports. Each port dispatches +commands/queries through the NestJS CQRS bus. + +### InvitationOtpPort + +Settings: + +```ts +interface InvitationOtpPortSettings { + createCommand: Type; + consumeCommand: Type; + clearCommand: Type; + validateQuery: Type; +} +``` + +| Method | Signature | Description | +| --- | --- | --- | +| `create` | `(ctx, category, assigneeId)` | Create OTP with rate limiting support | +| `consume` | `(ctx, category, passcode)` | Validate and consume OTP (single-use) | +| `validate` | `(ctx, category, passcode)` | Validate OTP without consuming | +| `clear` | `(ctx, category, assigneeId)` | Remove all OTPs for user+category | + +### InvitationUserPort + +Settings: + +```ts +interface InvitationUserPortSettings { + getByIdQuery: Type; + getByEmailQuery: Type; +} +``` + +| Method | Signature | Description | +| --- | --- | --- | +| `getById` | `(ctx, userId)` | Fetch user by ID | +| `getByEmail` | `(ctx, email)` | Fetch user by email | + +Returns +`InvitationUserResult = (ReferenceIdInterface & InvitationUserInterface) | null`. + +### InvitationNotificationPort + +Dispatches notification commands through the CQRS bus. The consumer provides +command classes and registers the matching `@CommandHandler`s — the handler +decides the transport (email, SMS, push, etc.) and resolves any address/template +config from its own module settings. + +Settings: + +```ts +interface InvitationNotificationPortSettings { + sendInvitationCommand: Type; + sendAcceptedCommand: Type; +} +``` + +Command interfaces (transport-agnostic — no email fields): + +```ts +interface SendInvitationNotificationCommandInterface { + ctx: PlainLiteralObject; + invitation: InvitationEventPayloadInterface; + passcode: string; + tokenExp: Date; +} + +interface SendAcceptedNotificationCommandInterface { + ctx: PlainLiteralObject; + invitation: InvitationEventPayloadInterface; +} +``` + +| Method | Signature | Description | +| --- | --- | --- | +| `sendInvitation` | `(ctx, invitation, { passcode, tokenExp })` | Dispatch invitation notification | +| `sendAccepted` | `(ctx, invitation)` | Dispatch acceptance confirmation notification | + +## Policies + +### InvitationOtpPolicy + +Behavioral configuration for OTP handling. + +| Property | Type | Description | +| --- | --- | --- | +| `namespace` | `string` | OTP namespace | +| `type` | `string` | OTP type (uuid, numeric, etc.) | +| `expiresIn` | `string` | Expiration duration | +| `clearOtpOnCreate` | `boolean` | Clear existing OTPs before creating | +| `rateSeconds` | `number` | Rate limit window in seconds | +| `rateThreshold` | `number` | Max creations in rate window | + +## Commands + +| Command | Handler | Description | +| --- | --- | --- | +| `CreateInvitationCommand` | `CreateInvitationHandler` | Create invitation with user ID + code | +| `CreateInvitationByEmailCommand` | `CreateInvitationByEmailHandler` | Create invitation by email (resolves user, generates code) | +| `SendInvitationCommand` | `SendInvitationHandler` | Resend invitation by ID (generates new OTP) | +| `AcceptInvitationCommand` | `AcceptInvitationHandler` | Validate OTP, accept, revoke siblings | +| `RevokeInvitationsCommand` | `RevokeInvitationsHandler` | Revoke all invitations for user+category | +| `RemoveInvitationCommand` | `RemoveInvitationHandler` | Hard delete an invitation | + +### Dispatching a Command + +```ts +import { CommandBus } from '@nestjs/cqrs'; +import { CreateInvitationCommand, Invitation } from '@concepta/nestjs-invitation'; + +const invitation = await this.commandBus.execute( + new CreateInvitationCommand(ctx, { + code: 'abc-123', + category: 'onboarding', + userId: 'user-1', + }), +); +``` + +## Queries + +| Query | Handler | Description | +| --- | --- | --- | +| `GetInvitationQuery` | `GetInvitationHandler` | Fetch invitation by ID | +| `FindInvitationByCodeQuery` | `FindInvitationByCodeHandler` | Fetch invitation by code | + +### Dispatching a Query + +```ts +import { QueryBus } from '@nestjs/cqrs'; +import { GetInvitationQuery, Invitation } from '@concepta/nestjs-invitation'; + +const invitation = await this.queryBus.execute( + new GetInvitationQuery(ctx, invitationId), +); +``` + +## Domain Events + +All events carry an `eventContext` and a plain `InvitationEventPayloadInterface` +snapshot. + +| Event | Emitted When | Built-in Listener | +| --- | --- | --- | +| `InvitationCreatedEvent` | `Invitation.create()` | -- | +| `InvitationDispatchedEvent` | `Invitation.dispatch()` | `InvitationDispatchedListener` -- dispatches invitation notification command via notification port | +| `InvitationAcceptedEvent` | `Invitation.accept()` | `InvitationAcceptedListener` -- dispatches acceptance notification command via notification port | +| `InvitationRevokedEvent` | `Invitation.revoke()` | `InvitationRevokedListener` -- clears OTPs | +| `InvitationRemovedEvent` | `Invitation.remove()` | -- | + +`InvitationDispatchedEvent` carries OTP metadata (`passcode`, `tokenExp`) +via `EventContextHost` meta. The `InvitationDispatchedListener` extracts +this metadata and passes it to the notification port. + +### Handling an Event + +Listen for invitation events from any module. For example, to activate a user +when their invitation is accepted: + +```ts +import { CommandBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { InvitationAcceptedEvent } from '@concepta/nestjs-invitation'; + +@EventsHandler(InvitationAcceptedEvent) +export class ActivateUserOnInvitationAccepted + implements IEventHandler +{ + constructor(private readonly commandBus: CommandBus) {} + + async handle(event: InvitationAcceptedEvent) { + const { invitation } = event; + + // Only handle invitations in the 'user' category + if (invitation.category !== 'user') return; + + // Activate the invited user + await this.commandBus.execute( + new UpdateUserCommand({}, invitation.userId, { active: true }), + ); + } +} +``` + +Register the listener as a provider in your module to start receiving events. + +## Schemas + +All schemas are Zod v4 objects (Standard Schema compatible), replacing the +legacy class-validator DTO classes. All are exported from the main entry. + +| Schema | Fields | Purpose | +| --- | --- | --- | +| `invitationSchema` | id, code, category, userId, active, constraints, timestamps | Full invitation representation (response resource) | +| `invitationCreateSchema` | category, userId, code, constraints? | Create by user ID | +| `invitationCreateByEmailSchema` | email (validated email), category, constraints? | Create by email | +| `invitationAcceptSchema` | passcode, payload? | Accept invitation | +| `invitationPaginatedSchema` | data: invitationSchema[] + pagination meta | Paginated response wrapper | + +## Exceptions + +| Exception | HTTP Status | Error Code | +| --- | --- | --- | +| `InvitationException` | -- | `INVITATION_ERROR` | +| `InvitationAlreadyAcceptedException` | 409 | `INVITATION_ALREADY_ACCEPTED_ERROR` | +| `InvitationRevokedException` | 409 | `INVITATION_REVOKED_ERROR` | +| `InvitationNotFoundException` | 404 | `INVITATION_NOT_FOUND_ERROR` | +| `InvitationUserUndefinedException` | -- (400 for a client-supplied email; see source) | `INVITATION_USER_UNDEFINED_ERROR` | +| `InvitationNotAcceptedException` | -- (400 for a wrong/expired passcode; see source) | `INVITATION_NOT_ACCEPTED_ERROR` | + +All exceptions extend `InvitationException`, which extends +`RuntimeException` from `@concepta/nestjs-core`. `RuntimeException` extends +NestJS's `HttpException`, so no exception filter registration is needed — +errors serialize over the wire as `{ statusCode, message, errorCode, error? }` +(no `timestamp`). + +## HTTP Gateway + +The gateway layer bridges `@concepta/nestjs-crud` operations to domain +commands and queries. + +### Request Handlers + +| Handler | Request Class | Operation | +| --- | --- | --- | +| `CreateInvitationRequestHandler` | `CreateInvitationRequest` | Create by user ID | +| `CreateInvitationByEmailRequestHandler` | `CreateInvitationByEmailRequest` | Create by email | +| `SendInvitationRequestHandler` | `SendInvitationRequest` | Send/resend invitation | +| `AcceptInvitationRequestHandler` | `AcceptInvitationRequest` | Accept with passcode | +| `DeleteInvitationRequestHandler` | `DeleteInvitationRequest` | Hard delete | +| `ListInvitationsRequestHandler` | `ListInvitationsRequest` | Paginated list | +| `ReadInvitationRequestHandler` | `ReadInvitationRequest` | Read single | + +### Wiring with CrudModule + +```ts +import { Module } from '@nestjs/common'; +import { Operation } from '@concepta/nestjs-core'; +import { CrudCqrsResolver, CrudModule } from '@concepta/nestjs-crud'; +import { + InvitationInterface, + InvitationModule, + invitationSchema, + invitationCreateSchema, + invitationPaginatedSchema, + CreateInvitationRequest, + CreateInvitationRequestHandler, + DeleteInvitationRequest, + DeleteInvitationRequestHandler, + ListInvitationsRequest, + ListInvitationsRequestHandler, + ReadInvitationRequest, + ReadInvitationRequestHandler, +} from '@concepta/nestjs-invitation'; + +@Module({ + imports: [ + InvitationModule.forRoot({ /* options */ }), + CrudModule.forFeature({ + crud: { + controller: { + entity: 'invitation', + path: 'invitation', + resolver: CrudCqrsResolver, + transactional: true, + request: { body: invitationCreateSchema }, + response: { + resource: invitationSchema, + paginated: invitationPaginatedSchema, + }, + }, + operations: [ + { + operation: Operation.List, + query: ListInvitationsRequest, + queryHandler: ListInvitationsRequestHandler, + }, + { + operation: Operation.Read, + query: ReadInvitationRequest, + queryHandler: ReadInvitationRequestHandler, + }, + { + operation: Operation.Create, + request: { body: invitationCreateSchema }, + command: CreateInvitationRequest, + commandHandler: CreateInvitationRequestHandler, + }, + { + operation: Operation.Delete, + command: DeleteInvitationRequest, + commandHandler: DeleteInvitationRequestHandler, + }, + ], + }, + }), + ], +}) +export class InvitationFeatureModule {} +``` + +Builder-generated controllers derive request body validation from +`operations[].request.body` automatically. + +### Handwritten Acceptance Controller + +Acceptance is exposed through a handwritten `@CrudController` class rather +than a generated one. Handwritten controllers must supply the schema +explicitly for runtime validation — either on the operation decorator's +`request.body` or via `@CrudBody({ schema })`: + +```ts +import { CommandBus } from '@nestjs/cqrs'; +import { Ctx } from '@concepta/nestjs-core'; +import { + CrudBody, + CrudContextInterface, + CrudController, + CrudCtx, + CrudUpdate, +} from '@concepta/nestjs-crud'; +import { + AcceptInvitationRequest, + AcceptInvitationRequestHandler, + InvitationAcceptableInterface, + invitationAcceptSchema, +} from '@concepta/nestjs-invitation'; + +@CrudController({ + path: 'invitation-acceptance', + entity: 'invitation', + request: { + params: { + code: { field: 'code', type: 'string' }, + }, + }, +}) +export class InvitationAcceptanceController { + constructor(private readonly commandBus: CommandBus) {} + + @CrudUpdate({ + path: ':code', + command: AcceptInvitationRequest, + commandHandler: AcceptInvitationRequestHandler, + request: { body: invitationAcceptSchema }, + }) + async acceptInvitation( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody() dto: InvitationAcceptableInterface, + ): Promise { + await this.commandBus.execute(new AcceptInvitationRequest(context, dto)); + } +} +``` + +Register `AcceptInvitationRequestHandler` as a provider and the controller in +`controllers` of your module. + +## Entry Points + +| Import Path | Contents | +| --- | --- | +| `@concepta/nestjs-invitation` | Module, aggregate, commands, queries, events, handlers, ports, policies, schemas, repository, mapper, exceptions, gateway request/handler classes | +| `@concepta/nestjs-invitation/optional/typeorm` | `InvitationSqliteEntity`, `InvitationPostgresEntity` | +| `@concepta/nestjs-invitation/optional/seeding` | `InvitationFactory` | + +## Seeding + +An `InvitationFactory` is available for test seeding: + +```ts +import { InvitationFactory } from '@concepta/nestjs-invitation/optional/seeding'; +``` + +It generates random `code` and `category` values using +`crypto.randomUUID()` and `faker.person.jobType()`. + +## Default Configuration + +| Setting | Default | +| --- | --- | +| `otp.namespace` | `user-otp` | +| `otp.type` | `uuid` | +| `otp.expiresIn` | `7d` | +| `otp.clearOtpOnCreate` | `false` (env: `INVITATION_OTP_CLEAR_ON_CREATE`) | diff --git a/packages/nestjs-invitation/package.json b/packages/nestjs-invitation/package.json index c724073bb..96d8c2ff2 100644 --- a/packages/nestjs-invitation/package.json +++ b/packages/nestjs-invitation/package.json @@ -1,44 +1,72 @@ { "name": "@concepta/nestjs-invitation", - "version": "7.0.0-alpha.10", + "version": "8.0.0-alpha.10", "description": "Rockets NestJS Invitation", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./optional/seeding": { + "types": "./dist/optional-seeding.d.ts", + "default": "./dist/optional-seeding.js" + }, + "./optional/typeorm": { + "types": "./dist/optional-typeorm.d.ts", + "default": "./dist/optional-typeorm.js" + } + }, "license": "BSD-3-Clause", "publishConfig": { "access": "public" }, "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" ], "dependencies": { - "@concepta/nestjs-access-control": "^7.0.0-alpha.10", - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@concepta/nestjs-event": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", - "@nestjs/swagger": "^11.2.2" + "@concepta/nestjs-core": "^8.0.0-alpha.10", + "@concepta/nestjs-repository": "8.0.0-alpha.10", + "zod": "^4.4.3" }, "devDependencies": { - "@concepta/nestjs-crud": "^7.0.0-alpha.10", - "@concepta/nestjs-email": "^7.0.0-alpha.10", - "@concepta/nestjs-otp": "^7.0.0-alpha.10", - "@concepta/nestjs-password": "^7.0.0-alpha.10", - "@concepta/nestjs-typeorm-ext": "^7.0.0-alpha.10", - "@concepta/nestjs-user": "^7.0.0-alpha.10", + "@concepta/nestjs-crud": "8.0.0-alpha.10", + "@concepta/nestjs-otp": "8.0.0-alpha.10", + "@concepta/nestjs-password": "8.0.0-alpha.10", + "@concepta/nestjs-repository-typeorm": "8.0.0-alpha.10", + "@concepta/nestjs-user": "8.0.0-alpha.10", "@concepta/typeorm-seeding": "^4.0.0", "@faker-js/faker": "^8.4.1", "@nestjs-modules/mailer": "^1.11.2", - "@nestjs/testing": "^11.1.9", - "@nestjs/typeorm": "^11.0.0", - "jest-mock-extended": "^4.0.0", - "supertest": "^6.3.4" + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/testing": "^12.0.1", + "@nestjs/typeorm": "^12.0.1", + "supertest": "^6.3.4", + "vitest-mock-extended": "^4.0.0" }, "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", + "@concepta/nestjs-crud": "8.0.0-alpha.10", + "@concepta/nestjs-repository-typeorm": "8.0.0-alpha.10", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", "rxjs": "^7.1.0", "typeorm": "^0.3.0" + }, + "peerDependenciesMeta": { + "@concepta/nestjs-repository-typeorm": { + "optional": true + }, + "@nestjs/cqrs": { + "optional": true + } } } diff --git a/packages/nestjs-invitation/src/__fixtures__/app-crud.module.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/app-crud.module.fixture.ts deleted file mode 100644 index 083e60370..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/app-crud.module.fixture.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { MailerModule, MailerService } from '@nestjs-modules/mailer'; - -import { Logger, Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { EmailSendOptionsInterface } from '@concepta/nestjs-common'; -import { CrudModule } from '@concepta/nestjs-crud'; -import { EmailModule, EmailService } from '@concepta/nestjs-email'; -import { EventModule } from '@concepta/nestjs-event'; -import { OtpModule, OtpService } from '@concepta/nestjs-otp'; -import { PasswordModule } from '@concepta/nestjs-password'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { UserModelService, UserModule } from '@concepta/nestjs-user'; - -import { InvitationAcceptedEventAsync } from '../events/invitation-accepted.event'; -import { InvitationModule } from '../invitation.module'; - -import { InvitationAcceptanceController } from './controllers/invitation-acceptance.controller'; -import { InvitationReattemptController } from './controllers/invitation-reattempt.controller'; -import { InvitationController } from './controllers/invitation.controller'; -import { InvitationEntityFixture } from './invitation/entities/invitation.entity.fixture'; -import { InvitationCrudService } from './invitation-crud.service'; -import { InvitationTypeOrmCrudAdapter } from './invitation-typeorm-crud.adapter'; -import { default as ormConfig } from './ormconfig.fixture'; -import { UserOtpEntityFixture } from './user/entities/user-otp.entity.fixture'; -import { UserEntityFixture } from './user/entities/user.entity.fixture'; - -@Module({ - imports: [ - EventModule.forRoot({}), - TypeOrmExtModule.forRoot(ormConfig), - TypeOrmModule.forFeature([ - InvitationEntityFixture, - UserEntityFixture, - UserOtpEntityFixture, - ]), - CrudModule.forRoot({}), - MailerModule.forRoot({ transport: { host: '' } }), - EmailModule.forRootAsync({ - inject: [MailerService], - useFactory: (mailerService: MailerService) => ({ mailerService }), - }), - InvitationModule.registerAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - invitation: { - entity: InvitationEntityFixture, - }, - }), - ], - inject: [UserModelService, OtpService, EmailService], - useFactory: (userModelService, otpService, emailService) => ({ - userModelService, - otpService, - emailService, - }), - }), - OtpModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - 'user-otp': { - entity: UserOtpEntityFixture, - }, - }), - ], - useFactory: () => ({}), - entities: ['user-otp'], - }), - PasswordModule.forRoot({}), - UserModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - user: { - entity: UserEntityFixture, - }, - }), - ], - useFactory: () => ({ - settings: { - invitationAcceptedEvent: InvitationAcceptedEventAsync, - }, - }), - }), - EmailModule.register({ - mailerService: { - sendMail(sendMailOptions: EmailSendOptionsInterface): Promise { - Logger.debug('email sent', sendMailOptions); - - return Promise.resolve(); - }, - }, - }), - ], - providers: [ - InvitationCrudService, - InvitationTypeOrmCrudAdapter, - { - provide: Logger, - useValue: { - log: jest.fn(), - debug: jest.fn(async (arg1, arg2) => { - return { arg1, arg2 }; - }), - }, - }, - ], - controllers: [ - InvitationController, - InvitationAcceptanceController, - InvitationReattemptController, - ], -}) -export class AppCrudModuleFixture {} diff --git a/packages/nestjs-invitation/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/app.module.fixture.ts deleted file mode 100644 index 030cfb002..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/app.module.fixture.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { MailerModule, MailerService } from '@nestjs-modules/mailer'; - -import { Logger, Module } from '@nestjs/common'; - -import { EmailSendOptionsInterface } from '@concepta/nestjs-common'; -import { CrudModule } from '@concepta/nestjs-crud'; -import { EmailModule, EmailService } from '@concepta/nestjs-email'; -import { EventModule } from '@concepta/nestjs-event'; -import { OtpModule, OtpService } from '@concepta/nestjs-otp'; -import { PasswordModule } from '@concepta/nestjs-password'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { UserModelService, UserModule } from '@concepta/nestjs-user'; - -import { InvitationAcceptedEventAsync } from '../events/invitation-accepted.event'; -import { InvitationModule } from '../invitation.module'; - -import { InvitationAcceptanceController } from './controllers/invitation-acceptance.controller'; -import { InvitationReattemptController } from './controllers/invitation-reattempt.controller'; -import { InvitationEntityFixture } from './invitation/entities/invitation.entity.fixture'; -import { default as ormConfig } from './ormconfig.fixture'; -import { UserOtpEntityFixture } from './user/entities/user-otp.entity.fixture'; -import { UserEntityFixture } from './user/entities/user.entity.fixture'; - -@Module({ - imports: [ - EventModule.forRoot({}), - TypeOrmExtModule.forRoot(ormConfig), - CrudModule.forRoot({}), - MailerModule.forRoot({ transport: { host: '' } }), - EmailModule.forRootAsync({ - inject: [MailerService], - useFactory: (mailerService: MailerService) => ({ mailerService }), - }), - InvitationModule.registerAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - invitation: { - entity: InvitationEntityFixture, - }, - }), - ], - inject: [UserModelService, OtpService, EmailService], - useFactory: (userModelService, otpService, emailService) => ({ - userModelService, - otpService, - emailService, - }), - }), - OtpModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - 'user-otp': { - entity: UserOtpEntityFixture, - }, - }), - ], - useFactory: () => ({}), - entities: ['user-otp'], - }), - PasswordModule.forRoot({}), - UserModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - user: { - entity: UserEntityFixture, - }, - }), - ], - useFactory: () => ({ - settings: { - invitationAcceptedEvent: InvitationAcceptedEventAsync, - }, - }), - }), - EmailModule.register({ - mailerService: { - sendMail(sendMailOptions: EmailSendOptionsInterface): Promise { - Logger.debug('email sent', sendMailOptions); - - return Promise.resolve(); - }, - }, - }), - ], - providers: [ - { - provide: Logger, - useValue: { - log: jest.fn(), - debug: jest.fn(async (arg1, arg2) => { - return { arg1, arg2 }; - }), - }, - }, - ], - controllers: [ - // InvitationController, - InvitationAcceptanceController, - InvitationReattemptController, - ], -}) -export class AppModuleFixture {} diff --git a/packages/nestjs-invitation/src/__fixtures__/controllers/invitation-acceptance.controller.ts b/packages/nestjs-invitation/src/__fixtures__/controllers/invitation-acceptance.controller.ts deleted file mode 100644 index 8359a7aef..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/controllers/invitation-acceptance.controller.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - Body, - Controller, - Get, - Logger, - Param, - Patch, - Query, - UsePipes, - ValidationPipe, -} from '@nestjs/common'; -import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; - -import { InvitationAcceptInviteDto } from '../../dto/invitation-accept-invite.dto'; -import { InvitationNotAcceptedException } from '../../exceptions/invitation-not-accepted.exception'; -import { InvitationAcceptanceService } from '../../services/invitation-acceptance.service'; - -@Controller('invitation-acceptance') -@ApiTags('invitation-acceptance') -export class InvitationAcceptanceController { - constructor( - private readonly invitationAcceptanceService: InvitationAcceptanceService, - ) {} - - @UsePipes(new ValidationPipe({ transform: true, forbidUnknownValues: true })) - @ApiBody({ - type: InvitationAcceptInviteDto, - description: 'DTO to accept invitation token.', - }) - @ApiOperation({ - summary: 'Accept one invitation by code, passcode and payload.', - }) - @ApiOkResponse() - @Patch('/:code') - async acceptInvite( - @Param('code') code: string, - @Body() invitationAcceptInviteDto: InvitationAcceptInviteDto, - ): Promise { - const { passcode, payload } = invitationAcceptInviteDto; - - let success: boolean | null | undefined; - - try { - success = await this.invitationAcceptanceService.accept({ - code, - passcode, - payload, - }); - } catch (e) { - Logger.error(e); - } - - if (!success) { - // the client should have checked using validate passcode first - throw new InvitationNotAcceptedException(); - } - } - - @ApiOperation({ - summary: 'Check if passcode is valid.', - }) - @ApiOkResponse() - @Get('/:code') - async validatePasscode( - @Param('code') code: string, - @Query('passcode') passcode: string, - ): Promise { - await this.invitationAcceptanceService.validate(code, passcode); - } -} diff --git a/packages/nestjs-invitation/src/__fixtures__/controllers/invitation-reattempt.controller.ts b/packages/nestjs-invitation/src/__fixtures__/controllers/invitation-reattempt.controller.ts deleted file mode 100644 index da9baa2f4..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/controllers/invitation-reattempt.controller.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - Controller, - Param, - Post, - UsePipes, - ValidationPipe, -} from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; - -import { InvitationAttemptService } from '../../services/invitation-attempt.service'; - -@Controller('invitation-reattempt') -@ApiTags('invitation-reattempt') -export class InvitationReattemptController { - constructor( - private readonly invitationAttemptService: InvitationAttemptService, - ) {} - - @UsePipes(new ValidationPipe({ transform: true, forbidUnknownValues: true })) - @ApiOperation({ - summary: 'Reattempt one invitation by code', - }) - @ApiOkResponse() - @Post('/:code') - async reattemptInvite(@Param('code') code: string): Promise { - await this.invitationAttemptService.send(code); - } -} diff --git a/packages/nestjs-invitation/src/__fixtures__/controllers/invitation.controller.ts b/packages/nestjs-invitation/src/__fixtures__/controllers/invitation.controller.ts deleted file mode 100644 index f01912fa1..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/controllers/invitation.controller.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { ApiOperation, ApiTags } from '@nestjs/swagger'; - -import { - AccessControlCreateOne, - AccessControlDeleteOne, - AccessControlReadMany, - AccessControlReadOne, -} from '@concepta/nestjs-access-control'; -import { InvitationInterface } from '@concepta/nestjs-common'; -import { - CrudBody, - CrudController, - CrudControllerInterface, - CrudCreateOne, - CrudDeleteOne, - CrudReadMany, - CrudReadOne, - CrudRequest, - CrudRequestInterface, -} from '@concepta/nestjs-crud'; - -import { InvitationCreateInviteDto } from '../../dto/invitation-create-invite.dto'; -import { InvitationPaginatedDto } from '../../dto/invitation-paginated.dto'; -import { InvitationDto } from '../../dto/invitation.dto'; -import { InvitationException } from '../../exceptions/invitation.exception'; -import { InvitationCreateInviteInterface } from '../../interfaces/domain/invitation-create-invite.interface'; -import { InvitationSendInviteInterface } from '../../interfaces/domain/invitation-send-invite.interface'; -import { InvitationResource } from '../../invitation.types'; -import { InvitationSendService } from '../../services/invitation-send.service'; -import { InvitationCrudService } from '../invitation-crud.service'; - -@CrudController({ - path: 'invitation', - model: { - type: InvitationDto, - paginatedType: InvitationPaginatedDto, - }, - validation: { - transformOptions: { - // TODO temporary fix because this could be unsafe - excludeExtraneousValues: false, - }, - }, -}) -@ApiTags('invitation') -export class InvitationController - implements - CrudControllerInterface< - InvitationInterface, - InvitationCreateInviteInterface, - never - > -{ - constructor( - private readonly invitationCrudService: InvitationCrudService, - private readonly invitationSendService: InvitationSendService, - ) {} - - @CrudReadMany() - @AccessControlReadMany(InvitationResource.Many) - @ApiOperation({ - summary: 'Get many invitation using given criteria.', - }) - async getMany(@CrudRequest() crudRequest: CrudRequestInterface) { - return this.invitationCrudService.getMany(crudRequest); - } - - @CrudReadOne() - @AccessControlReadOne(InvitationResource.One) - @ApiOperation({ - summary: 'Get one invitation by id.', - }) - async getOne(@CrudRequest() crudRequest: CrudRequestInterface) { - return this.invitationCrudService.getOne(crudRequest); - } - - @CrudCreateOne() - @AccessControlCreateOne(InvitationResource.One) - @ApiOperation({ - summary: 'Create one invitation.', - }) - async createInvite( - @CrudRequest() _crudRequest: CrudRequestInterface, - @CrudBody() invitationCreateInviteDto: InvitationCreateInviteDto, - ) { - let invite: InvitationSendInviteInterface | undefined; - - try { - invite = await this.invitationSendService.create( - invitationCreateInviteDto, - ); - - if (invite) { - await this.invitationSendService.send(invite); - } else { - throw new InvitationException({ - message: 'User and/or invite not defined', - }); - } - - return invite; - } catch (e: unknown) { - throw new InvitationException({ originalError: e }); - } - } - - @CrudDeleteOne() - @AccessControlDeleteOne(InvitationResource.One) - @ApiOperation({ - summary: 'Delete one invitation.', - }) - async deleteOne(@CrudRequest() crudRequest: CrudRequestInterface) { - try { - return this.invitationCrudService.deleteOne(crudRequest); - } catch (e: unknown) { - throw new InvitationException({ originalError: e }); - } - } -} diff --git a/packages/nestjs-invitation/src/__fixtures__/email/mailer.service.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/email/mailer.service.fixture.ts deleted file mode 100644 index fd842639e..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/email/mailer.service.fixture.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - EmailSendInterface, - EmailSendOptionsInterface, -} from '@concepta/nestjs-common'; - -@Injectable() -export class MailerServiceFixture implements EmailSendInterface { - sendMail(_sendMailOptions: EmailSendOptionsInterface): Promise { - throw new Error('Method not implemented.'); - } -} diff --git a/packages/nestjs-invitation/src/__fixtures__/invitation-crud.service.ts b/packages/nestjs-invitation/src/__fixtures__/invitation-crud.service.ts deleted file mode 100644 index 245eb89f4..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/invitation-crud.service.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { InvitationEntityInterface } from '@concepta/nestjs-common'; -import { CrudService } from '@concepta/nestjs-crud'; -import { CrudAdapter } from '@concepta/nestjs-crud/dist/crud/adapters/crud.adapter'; - -import { InvitationTypeOrmCrudAdapter } from './invitation-typeorm-crud.adapter'; - -@Injectable() -export class InvitationCrudService extends CrudService { - constructor( - @Inject(InvitationTypeOrmCrudAdapter) - crudAdapter: CrudAdapter, - ) { - super(crudAdapter); - } -} diff --git a/packages/nestjs-invitation/src/__fixtures__/invitation-typeorm-crud.adapter.ts b/packages/nestjs-invitation/src/__fixtures__/invitation-typeorm-crud.adapter.ts deleted file mode 100644 index a8540b543..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/invitation-typeorm-crud.adapter.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - InjectDynamicRepository, - InvitationEntityInterface, -} from '@concepta/nestjs-common'; -import { TypeOrmCrudAdapter } from '@concepta/nestjs-crud'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { INVITATION_MODULE_INVITATION_ENTITY_KEY } from '../invitation.constants'; - -@Injectable() -export class InvitationTypeOrmCrudAdapter extends TypeOrmCrudAdapter { - constructor( - @InjectDynamicRepository(INVITATION_MODULE_INVITATION_ENTITY_KEY) - invitationRepoAdapter: TypeOrmRepositoryAdapter, - ) { - super(invitationRepoAdapter); - } -} diff --git a/packages/nestjs-invitation/src/__fixtures__/invitation/entities/invitation-local.module.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/invitation/entities/invitation-local.module.fixture.ts deleted file mode 100644 index c6825df72..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/invitation/entities/invitation-local.module.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { InvitationSendServiceFixture } from './invitation-send.service.fixture'; - -@Global() -@Module({ - providers: [InvitationSendServiceFixture], - exports: [InvitationSendServiceFixture], -}) -export class InvitationLocalModuleFixture {} diff --git a/packages/nestjs-invitation/src/__fixtures__/invitation/entities/invitation-send.service.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/invitation/entities/invitation-send.service.fixture.ts deleted file mode 100644 index b1e481d5b..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/invitation/entities/invitation-send.service.fixture.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - InvitationInterface, - InvitationUserInterface, -} from '@concepta/nestjs-common'; - -import { InvitationCreateInviteInterface } from '../../../interfaces/domain/invitation-create-invite.interface'; -import { InvitationSendInviteInterface } from '../../../interfaces/domain/invitation-send-invite.interface'; -import { InvitationSendInvitationEmailOptionsInterface } from '../../../interfaces/options/invitation-send-invitation-email-options.interface'; -import { InvitationSendServiceInterface } from '../../../interfaces/services/invitation-send-service.interface'; - -@Injectable() -export class InvitationSendServiceFixture - implements InvitationSendServiceInterface -{ - create( - _createInviteDto: InvitationCreateInviteInterface, - ): Promise { - return Promise.resolve({ - id: 'test-id', - category: 'foo', - code: 'bar', - userId: 'test-user-id', - }); - } - - send(_invitation: InvitationSendInviteInterface): Promise { - return Promise.resolve(); - } - - getUser( - _options: Pick & - Partial>, - ): Promise { - return Promise.resolve({ - id: '', - email: '', - username: '', - }); - } - - async sendInvitationEmail( - _options: InvitationSendInvitationEmailOptionsInterface, - ): Promise {} -} diff --git a/packages/nestjs-invitation/src/__fixtures__/invitation/entities/invitation.entity.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/invitation/entities/invitation.entity.fixture.ts deleted file mode 100644 index c3e18d791..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/invitation/entities/invitation.entity.fixture.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Entity } from 'typeorm'; - -import { InvitationSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -@Entity() -export class InvitationEntityFixture extends InvitationSqliteEntity {} diff --git a/packages/nestjs-invitation/src/__fixtures__/ormconfig.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/ormconfig.fixture.ts deleted file mode 100644 index ab4e91142..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/ormconfig.fixture.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DataSourceOptions } from 'typeorm'; - -import { InvitationEntityFixture } from './invitation/entities/invitation.entity.fixture'; -import { UserOtpEntityFixture } from './user/entities/user-otp.entity.fixture'; -import { UserEntityFixture } from './user/entities/user.entity.fixture'; - -const config: DataSourceOptions = { - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [InvitationEntityFixture, UserEntityFixture, UserOtpEntityFixture], -}; - -export default config; diff --git a/packages/nestjs-invitation/src/__fixtures__/otp/otp.module.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/otp/otp.module.fixture.ts deleted file mode 100644 index db0b5e982..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/otp/otp.module.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { OtpServiceFixture } from './otp.service.fixture'; - -@Global() -@Module({ - providers: [OtpServiceFixture], - exports: [OtpServiceFixture], -}) -export class OtpModuleFixture {} diff --git a/packages/nestjs-invitation/src/__fixtures__/otp/otp.service.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/otp/otp.service.fixture.ts deleted file mode 100644 index b68f860a7..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/otp/otp.service.fixture.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { Injectable } from '@nestjs/common'; - -import { - AssigneeRelationInterface, - OtpCreateParamsInterface, - OtpInterface, -} from '@concepta/nestjs-common'; - -import { InvitationOtpServiceInterface } from '../../interfaces/services/invitation-otp-service.interface'; -import { UserFixture } from '../user/user.fixture'; - -@Injectable() -export class OtpServiceFixture implements InvitationOtpServiceInterface { - async create({ otp }: OtpCreateParamsInterface): Promise { - const { assigneeId, category, type } = otp; - return { - id: randomUUID(), - category, - type, - assigneeId, - active: true, - passcode: 'GOOD_PASSCODE', - expirationDate: new Date(), - dateCreated: new Date(), - dateUpdated: new Date(), - dateDeleted: null, - version: 1, - }; - } - - async validate( - _assignment: string, - otp: Pick, - _deleteIfValid: boolean, - ): Promise { - return otp.passcode === 'GOOD_PASSCODE' - ? { assigneeId: UserFixture.id } - : null; - } - - async clear( - _assignment: string, - _otp: Pick, - ): Promise { - return; - } -} diff --git a/packages/nestjs-invitation/src/__fixtures__/user/entities/user-otp.entity.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/user/entities/user-otp.entity.fixture.ts deleted file mode 100644 index 180101235..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/user/entities/user-otp.entity.fixture.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Column, Entity } from 'typeorm'; - -import { ReferenceId, OtpInterface } from '@concepta/nestjs-common'; -import { CommonSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * Otp Entity Fixture - */ -@Entity() -export class UserOtpEntityFixture - extends CommonSqliteEntity - implements OtpInterface -{ - @Column() - category!: string; - - @Column({ nullable: true }) - type!: string; - - @Column() - passcode!: string; - - @Column({ default: true }) - active!: boolean; - - @Column({ type: 'datetime' }) - expirationDate!: Date; - - @Column() - assigneeId!: ReferenceId; -} diff --git a/packages/nestjs-invitation/src/__fixtures__/user/entities/user.entity.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/user/entities/user.entity.fixture.ts deleted file mode 100644 index 6e42bcbf1..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/user/entities/user.entity.fixture.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Entity } from 'typeorm'; - -import { UserSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * User Entity Fixture - */ -@Entity() -export class UserEntityFixture extends UserSqliteEntity {} diff --git a/packages/nestjs-invitation/src/__fixtures__/user/services/invitation-send.service.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/user/services/invitation-send.service.fixture.ts deleted file mode 100644 index d9c509c5d..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/user/services/invitation-send.service.fixture.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { InvitationUserInterface } from '@concepta/nestjs-common'; - -import { InvitationCreateInviteInterface } from '../../../interfaces/domain/invitation-create-invite.interface'; -import { InvitationSendInviteInterface } from '../../../interfaces/domain/invitation-send-invite.interface'; -import { InvitationSendInvitationEmailOptionsInterface } from '../../../interfaces/options/invitation-send-invitation-email-options.interface'; -import { InvitationSendServiceInterface } from '../../../interfaces/services/invitation-send-service.interface'; - -@Injectable() -export class InvitationSendServiceFixture - implements InvitationSendServiceInterface -{ - async create( - _createDto: InvitationCreateInviteInterface, - ): Promise { - return { - id: 'test-id', - userId: 'test-user-id', - category: 'foo', - code: 'bar', - }; - } - - async send(_invitation: InvitationSendInviteInterface): Promise {} - - async getUser( - _options: InvitationUserInterface, - ): Promise { - return {} as InvitationUserInterface; - } - - async sendInvitationEmail( - _options: InvitationSendInvitationEmailOptionsInterface, - ): Promise {} -} diff --git a/packages/nestjs-invitation/src/__fixtures__/user/services/user-model.service.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/user/services/user-model.service.fixture.ts deleted file mode 100644 index 8c0b0ef09..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/user/services/user-model.service.fixture.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ReferenceEmail, - UserCreatableInterface, -} from '@concepta/nestjs-common'; - -import { InvitationUserModelServiceInterface } from '../../../interfaces/services/invitation-user-model.service.interface'; -import { UserFixture } from '../user.fixture'; - -@Injectable() -export class UserModelServiceFixture - implements InvitationUserModelServiceInterface -{ - async byId( - id: string, - ): ReturnType { - if (id === UserFixture.id) { - return UserFixture; - } else { - throw new Error(); - } - } - - async byEmail( - email: ReferenceEmail, - ): ReturnType { - return email === UserFixture.email ? UserFixture : null; - } - - async create( - _object: UserCreatableInterface, - ): ReturnType { - return UserFixture; - } -} diff --git a/packages/nestjs-invitation/src/__fixtures__/user/user.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/user/user.fixture.ts deleted file mode 100644 index 9eae02f84..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/user/user.fixture.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const UserFixture = { - id: 'abc', - email: 'me@dispostable.com', - username: 'me@dispostable.com', -}; diff --git a/packages/nestjs-invitation/src/__fixtures__/user/user.module.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/user/user.module.fixture.ts deleted file mode 100644 index 980ef90c4..000000000 --- a/packages/nestjs-invitation/src/__fixtures__/user/user.module.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Global, Module } from '@nestjs/common'; - -import { UserModelServiceFixture } from './services/user-model.service.fixture'; - -@Global() -@Module({ - providers: [UserModelServiceFixture], - exports: [UserModelServiceFixture], -}) -export class UserModuleFixture {} diff --git a/packages/nestjs-invitation/src/__tests__/exception-fault.spec.ts b/packages/nestjs-invitation/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..9c70bff74 --- /dev/null +++ b/packages/nestjs-invitation/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,73 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { InvitationNotFoundException } from '../application/exceptions/invitation-not-found.exception.js'; +import { InvitationUserUndefinedException } from '../application/exceptions/invitation-user-undefined.exception.js'; +import { InvitationAlreadyAcceptedException } from '../domain/exceptions/invitation-already-accepted.exception.js'; +import { InvitationRevokedException } from '../domain/exceptions/invitation-revoked.exception.js'; +import { InvitationException } from '../domain/exceptions/invitation.exception.js'; +import { InvitationNotAcceptedException } from '../gateways/exceptions/invitation-not-accepted.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'InvitationException (default)', + build: () => new InvitationException(), + fault: 'internal', + }, + { + name: 'InvitationAlreadyAcceptedException', + build: () => new InvitationAlreadyAcceptedException(), + fault: 'client', + }, + { + name: 'InvitationRevokedException', + build: () => new InvitationRevokedException(), + fault: 'client', + }, + { + name: 'InvitationNotFoundException', + build: () => new InvitationNotFoundException('id'), + fault: 'client', + }, + { + name: 'InvitationUserUndefinedException', + build: () => new InvitationUserUndefinedException(), + fault: 'usage', + }, + { + name: 'InvitationNotAcceptedException', + build: () => new InvitationNotAcceptedException(), + fault: 'internal', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-invitation/src/__tests__/helpers/mock.helpers.ts b/packages/nestjs-invitation/src/__tests__/helpers/mock.helpers.ts new file mode 100644 index 000000000..969568320 --- /dev/null +++ b/packages/nestjs-invitation/src/__tests__/helpers/mock.helpers.ts @@ -0,0 +1,56 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { + createMockCommandBus, + createMockEventPublisher, +} from '@concepta/nestjs-core/testing'; +import { createMockTransaction } from '@concepta/nestjs-repository/testing'; + +import { type Invitation } from '../../domain/aggregates/invitation.js'; +import { type InvitationService } from '../../domain/services/invitation.service.js'; +import { type InvitationEntityInterface } from '../../infrastructure/persistence/interfaces/invitation-entity.interface.js'; +import { InvitationMapper } from '../../infrastructure/persistence/invitation.mapper.js'; +import { type InvitationRepository } from '../../infrastructure/persistence/invitation.repository.js'; + +export { + createMockCommandBus, + createMockEventPublisher, + createMockTransaction, +}; +export type { MockTransactionHandle } from '@concepta/nestjs-repository/testing'; + +export function createMockInvitationService(): DeepMockProxy { + return mockDeep(); +} + +export function createMockInvitationRepository(): DeepMockProxy { + return mockDeep(); +} + +export function createMockInvitationEntity( + overrides: Partial = {}, +): InvitationEntityInterface { + return { + id: 'test-id', + code: 'test-code', + category: 'user', + userId: 'test-user-id', + active: true, + constraints: undefined, + dateAccepted: null, + dateRevoked: null, + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + ...overrides, + }; +} + +const invitationMapper = new InvitationMapper(); + +export function toInvitationDomain( + entity: InvitationEntityInterface, +): Invitation { + return invitationMapper.toDomain(entity); +} diff --git a/packages/nestjs-invitation/src/application/commands/handlers/__tests__/accept-invitation.handler.spec.ts b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/accept-invitation.handler.spec.ts new file mode 100644 index 000000000..a1222a65d --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/accept-invitation.handler.spec.ts @@ -0,0 +1,73 @@ +import { createMockInvitationService } from '../../../../__tests__/helpers/mock.helpers.js'; +import { Invitation } from '../../../../domain/aggregates/invitation.js'; +import { AcceptInvitationCommand } from '../../impl/accept-invitation.command.js'; +import { AcceptInvitationHandler } from '../accept-invitation.handler.js'; + +describe(AcceptInvitationHandler.name, () => { + const ctx = {}; + let mockService: ReturnType; + let handler: AcceptInvitationHandler; + + const mockInvitation = new Invitation('inv-id', { + code: 'test-code', + category: 'user', + userId: 'test-user-id', + constraints: undefined, + dateAccepted: new Date(), + dateRevoked: null, + }); + + beforeEach(() => { + mockService = createMockInvitationService(); + handler = new AcceptInvitationHandler(mockService); + }); + + it('should delegate to InvitationService.accept', async () => { + mockService.accept.mockResolvedValue(mockInvitation); + + const result = await handler.execute( + new AcceptInvitationCommand(ctx, 'test-code', { + passcode: 'abc123', + }), + ); + + expect(result).toBeInstanceOf(Invitation); + expect(mockService.accept).toHaveBeenCalledWith( + ctx, + 'test-code', + 'abc123', + undefined, + ); + }); + + it('should pass payload to service', async () => { + mockService.accept.mockResolvedValue(mockInvitation); + const payload = { extra: 'data' }; + + await handler.execute( + new AcceptInvitationCommand(ctx, 'test-code', { + passcode: 'abc123', + payload, + }), + ); + + expect(mockService.accept).toHaveBeenCalledWith( + ctx, + 'test-code', + 'abc123', + payload, + ); + }); + + it('should return null when service returns null', async () => { + mockService.accept.mockResolvedValue(null); + + const result = await handler.execute( + new AcceptInvitationCommand(ctx, 'test-code', { + passcode: 'bad', + }), + ); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/nestjs-invitation/src/application/commands/handlers/__tests__/create-invitation-by-email.handler.spec.ts b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/create-invitation-by-email.handler.spec.ts new file mode 100644 index 000000000..f489087c3 --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/create-invitation-by-email.handler.spec.ts @@ -0,0 +1,37 @@ +import { + createMockInvitationService, + createMockInvitationEntity, + toInvitationDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Invitation } from '../../../../domain/aggregates/invitation.js'; +import { CreateInvitationByEmailCommand } from '../../impl/create-invitation-by-email.command.js'; +import { CreateInvitationByEmailHandler } from '../create-invitation-by-email.handler.js'; + +describe(CreateInvitationByEmailHandler.name, () => { + const ctx = {}; + let mockService: ReturnType; + let handler: CreateInvitationByEmailHandler; + + beforeEach(() => { + mockService = createMockInvitationService(); + handler = new CreateInvitationByEmailHandler(mockService); + }); + + it('should delegate to InvitationService.createByEmail', async () => { + const invitation = toInvitationDomain(createMockInvitationEntity()); + mockService.createByEmail.mockResolvedValue(invitation); + + const dto = { + email: 'test@example.com', + category: 'user', + constraints: { role: 'admin' }, + }; + + const result = await handler.execute( + new CreateInvitationByEmailCommand(ctx, dto), + ); + + expect(result).toBeInstanceOf(Invitation); + expect(mockService.createByEmail).toHaveBeenCalledWith(ctx, dto); + }); +}); diff --git a/packages/nestjs-invitation/src/application/commands/handlers/__tests__/create-invitation.handler.spec.ts b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/create-invitation.handler.spec.ts new file mode 100644 index 000000000..184aec5f6 --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/create-invitation.handler.spec.ts @@ -0,0 +1,39 @@ +import { createMockInvitationService } from '../../../../__tests__/helpers/mock.helpers.js'; +import { Invitation } from '../../../../domain/aggregates/invitation.js'; +import { CreateInvitationCommand } from '../../impl/create-invitation.command.js'; +import { CreateInvitationHandler } from '../create-invitation.handler.js'; + +describe(CreateInvitationHandler.name, () => { + const ctx = {}; + let mockService: ReturnType; + let handler: CreateInvitationHandler; + + beforeEach(() => { + mockService = createMockInvitationService(); + handler = new CreateInvitationHandler(mockService); + }); + + it('should delegate to InvitationService.create', async () => { + const dto = { + code: 'test-code', + category: 'user', + userId: 'test-user-id', + constraints: undefined, + }; + + const mockInvitation = new Invitation('inv-id', { + code: 'test-code', + category: 'user', + userId: 'test-user-id', + constraints: undefined, + dateAccepted: null, + dateRevoked: null, + }); + mockService.create.mockResolvedValue(mockInvitation); + + const result = await handler.execute(new CreateInvitationCommand(ctx, dto)); + + expect(result).toBeInstanceOf(Invitation); + expect(mockService.create).toHaveBeenCalledWith(ctx, dto); + }); +}); diff --git a/packages/nestjs-invitation/src/application/commands/handlers/__tests__/remove-invitation.handler.spec.ts b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/remove-invitation.handler.spec.ts new file mode 100644 index 000000000..0f975029a --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/remove-invitation.handler.spec.ts @@ -0,0 +1,31 @@ +import { + createMockInvitationService, + createMockInvitationEntity, + toInvitationDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Invitation } from '../../../../domain/aggregates/invitation.js'; +import { RemoveInvitationCommand } from '../../impl/remove-invitation.command.js'; +import { RemoveInvitationHandler } from '../remove-invitation.handler.js'; + +describe(RemoveInvitationHandler.name, () => { + const ctx = {}; + let mockService: ReturnType; + let handler: RemoveInvitationHandler; + + beforeEach(() => { + mockService = createMockInvitationService(); + handler = new RemoveInvitationHandler(mockService); + }); + + it('should delegate to InvitationService.remove', async () => { + const invitation = toInvitationDomain(createMockInvitationEntity()); + mockService.remove.mockResolvedValue(invitation); + + const result = await handler.execute( + new RemoveInvitationCommand(ctx, 'test-id'), + ); + + expect(result).toBeInstanceOf(Invitation); + expect(mockService.remove).toHaveBeenCalledWith(ctx, 'test-id'); + }); +}); diff --git a/packages/nestjs-invitation/src/application/commands/handlers/__tests__/revoke-invitations.handler.spec.ts b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/revoke-invitations.handler.spec.ts new file mode 100644 index 000000000..7539758d2 --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/revoke-invitations.handler.spec.ts @@ -0,0 +1,28 @@ +import { createMockInvitationService } from '../../../../__tests__/helpers/mock.helpers.js'; +import { RevokeInvitationsCommand } from '../../impl/revoke-invitations.command.js'; +import { RevokeInvitationsHandler } from '../revoke-invitations.handler.js'; + +describe(RevokeInvitationsHandler.name, () => { + const ctx = {}; + let mockService: ReturnType; + let handler: RevokeInvitationsHandler; + + beforeEach(() => { + mockService = createMockInvitationService(); + handler = new RevokeInvitationsHandler(mockService); + }); + + it('should delegate to InvitationService.revokeByEmail', async () => { + mockService.revokeByEmail.mockResolvedValue(undefined); + + await handler.execute( + new RevokeInvitationsCommand(ctx, 'test@example.com', 'user'), + ); + + expect(mockService.revokeByEmail).toHaveBeenCalledWith( + ctx, + 'test@example.com', + 'user', + ); + }); +}); diff --git a/packages/nestjs-invitation/src/application/commands/handlers/__tests__/send-invitation.handler.spec.ts b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/send-invitation.handler.spec.ts new file mode 100644 index 000000000..249a5eee8 --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/__tests__/send-invitation.handler.spec.ts @@ -0,0 +1,22 @@ +import { createMockInvitationService } from '../../../../__tests__/helpers/mock.helpers.js'; +import { SendInvitationCommand } from '../../impl/send-invitation.command.js'; +import { SendInvitationHandler } from '../send-invitation.handler.js'; + +describe(SendInvitationHandler.name, () => { + const ctx = {}; + let mockService: ReturnType; + let handler: SendInvitationHandler; + + beforeEach(() => { + mockService = createMockInvitationService(); + handler = new SendInvitationHandler(mockService); + }); + + it('should delegate to InvitationService.sendById', async () => { + mockService.sendById.mockResolvedValue(undefined); + + await handler.execute(new SendInvitationCommand(ctx, 'test-id')); + + expect(mockService.sendById).toHaveBeenCalledWith(ctx, 'test-id'); + }); +}); diff --git a/packages/nestjs-invitation/src/application/commands/handlers/accept-invitation.handler.ts b/packages/nestjs-invitation/src/application/commands/handlers/accept-invitation.handler.ts new file mode 100644 index 000000000..d56b5aa17 --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/accept-invitation.handler.ts @@ -0,0 +1,15 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { Invitation } from '../../../domain/aggregates/invitation.js'; +import { InvitationService } from '../../../domain/services/invitation.service.js'; +import { AcceptInvitationCommand } from '../impl/accept-invitation.command.js'; + +@CommandHandler(AcceptInvitationCommand) +export class AcceptInvitationHandler implements ICommandHandler { + constructor(private readonly invitationService: InvitationService) {} + + async execute(command: AcceptInvitationCommand): Promise { + const { ctx, code, dto } = command; + return this.invitationService.accept(ctx, code, dto.passcode, dto.payload); + } +} diff --git a/packages/nestjs-invitation/src/application/commands/handlers/create-invitation-by-email.handler.ts b/packages/nestjs-invitation/src/application/commands/handlers/create-invitation-by-email.handler.ts new file mode 100644 index 000000000..0221b270c --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/create-invitation-by-email.handler.ts @@ -0,0 +1,15 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { Invitation } from '../../../domain/aggregates/invitation.js'; +import { InvitationService } from '../../../domain/services/invitation.service.js'; +import { CreateInvitationByEmailCommand } from '../impl/create-invitation-by-email.command.js'; + +@CommandHandler(CreateInvitationByEmailCommand) +export class CreateInvitationByEmailHandler implements ICommandHandler { + constructor(private readonly invitationService: InvitationService) {} + + async execute(command: CreateInvitationByEmailCommand): Promise { + const { ctx, dto } = command; + return this.invitationService.createByEmail(ctx, dto); + } +} diff --git a/packages/nestjs-invitation/src/application/commands/handlers/create-invitation.handler.ts b/packages/nestjs-invitation/src/application/commands/handlers/create-invitation.handler.ts new file mode 100644 index 000000000..5baf569a9 --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/create-invitation.handler.ts @@ -0,0 +1,15 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { Invitation } from '../../../domain/aggregates/invitation.js'; +import { InvitationService } from '../../../domain/services/invitation.service.js'; +import { CreateInvitationCommand } from '../impl/create-invitation.command.js'; + +@CommandHandler(CreateInvitationCommand) +export class CreateInvitationHandler implements ICommandHandler { + constructor(private readonly invitationService: InvitationService) {} + + async execute(command: CreateInvitationCommand): Promise { + const { ctx, dto } = command; + return this.invitationService.create(ctx, dto); + } +} diff --git a/packages/nestjs-invitation/src/application/commands/handlers/remove-invitation.handler.ts b/packages/nestjs-invitation/src/application/commands/handlers/remove-invitation.handler.ts new file mode 100644 index 000000000..4eb05ef4c --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/remove-invitation.handler.ts @@ -0,0 +1,15 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { Invitation } from '../../../domain/aggregates/invitation.js'; +import { InvitationService } from '../../../domain/services/invitation.service.js'; +import { RemoveInvitationCommand } from '../impl/remove-invitation.command.js'; + +@CommandHandler(RemoveInvitationCommand) +export class RemoveInvitationHandler implements ICommandHandler { + constructor(private readonly invitationService: InvitationService) {} + + async execute(command: RemoveInvitationCommand): Promise { + const { ctx, id } = command; + return this.invitationService.remove(ctx, id); + } +} diff --git a/packages/nestjs-invitation/src/application/commands/handlers/revoke-invitations.handler.ts b/packages/nestjs-invitation/src/application/commands/handlers/revoke-invitations.handler.ts new file mode 100644 index 000000000..e41aa3179 --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/revoke-invitations.handler.ts @@ -0,0 +1,14 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { InvitationService } from '../../../domain/services/invitation.service.js'; +import { RevokeInvitationsCommand } from '../impl/revoke-invitations.command.js'; + +@CommandHandler(RevokeInvitationsCommand) +export class RevokeInvitationsHandler implements ICommandHandler { + constructor(private readonly invitationService: InvitationService) {} + + async execute(command: RevokeInvitationsCommand): Promise { + const { ctx, email, category } = command; + return this.invitationService.revokeByEmail(ctx, email, category); + } +} diff --git a/packages/nestjs-invitation/src/application/commands/handlers/send-invitation.handler.ts b/packages/nestjs-invitation/src/application/commands/handlers/send-invitation.handler.ts new file mode 100644 index 000000000..6329cae40 --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/handlers/send-invitation.handler.ts @@ -0,0 +1,14 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { InvitationService } from '../../../domain/services/invitation.service.js'; +import { SendInvitationCommand } from '../impl/send-invitation.command.js'; + +@CommandHandler(SendInvitationCommand) +export class SendInvitationHandler implements ICommandHandler { + constructor(private readonly invitationService: InvitationService) {} + + async execute(command: SendInvitationCommand): Promise { + const { ctx, id } = command; + return this.invitationService.sendById(ctx, id); + } +} diff --git a/packages/nestjs-invitation/src/application/commands/impl/accept-invitation.command.ts b/packages/nestjs-invitation/src/application/commands/impl/accept-invitation.command.ts new file mode 100644 index 000000000..d03dfbaac --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/impl/accept-invitation.command.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type Invitation } from '../../../domain/aggregates/invitation.js'; +import { type InvitationAcceptableInterface } from '../../../domain/interfaces/invitation-acceptable.interface.js'; + +export class AcceptInvitationCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly code: string, + public readonly dto: InvitationAcceptableInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-invitation/src/application/commands/impl/create-invitation-by-email.command.ts b/packages/nestjs-invitation/src/application/commands/impl/create-invitation-by-email.command.ts new file mode 100644 index 000000000..53784b16e --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/impl/create-invitation-by-email.command.ts @@ -0,0 +1,14 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type Invitation } from '../../../domain/aggregates/invitation.js'; +import { type InvitationCreatableByEmailInterface } from '../../../domain/interfaces/invitation-creatable-by-email.interface.js'; + +export class CreateInvitationByEmailCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly dto: InvitationCreatableByEmailInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-invitation/src/application/commands/impl/create-invitation.command.ts b/packages/nestjs-invitation/src/application/commands/impl/create-invitation.command.ts new file mode 100644 index 000000000..9a0e9e79f --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/impl/create-invitation.command.ts @@ -0,0 +1,14 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type Invitation } from '../../../domain/aggregates/invitation.js'; +import { type InvitationCreatableInterface } from '../../../domain/interfaces/invitation-creatable.interface.js'; + +export class CreateInvitationCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly dto: InvitationCreatableInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-invitation/src/application/commands/impl/remove-invitation.command.ts b/packages/nestjs-invitation/src/application/commands/impl/remove-invitation.command.ts new file mode 100644 index 000000000..cbfc97023 --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/impl/remove-invitation.command.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Invitation } from '../../../domain/aggregates/invitation.js'; + +export class RemoveInvitationCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-invitation/src/application/commands/impl/revoke-invitations.command.ts b/packages/nestjs-invitation/src/application/commands/impl/revoke-invitations.command.ts new file mode 100644 index 000000000..e4682f55e --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/impl/revoke-invitations.command.ts @@ -0,0 +1,12 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +export class RevokeInvitationsCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly email: string, + public readonly category: string, + ) { + super(); + } +} diff --git a/packages/nestjs-invitation/src/application/commands/impl/send-invitation.command.ts b/packages/nestjs-invitation/src/application/commands/impl/send-invitation.command.ts new file mode 100644 index 000000000..adb738a97 --- /dev/null +++ b/packages/nestjs-invitation/src/application/commands/impl/send-invitation.command.ts @@ -0,0 +1,13 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +export class SendInvitationCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-invitation/src/application/exceptions/invitation-not-found.exception.ts b/packages/nestjs-invitation/src/application/exceptions/invitation-not-found.exception.ts new file mode 100644 index 000000000..9d675cf86 --- /dev/null +++ b/packages/nestjs-invitation/src/application/exceptions/invitation-not-found.exception.ts @@ -0,0 +1,27 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeException } from '@concepta/nestjs-core'; + +import { InvitationException } from '../../domain/exceptions/invitation.exception.js'; + +export class InvitationNotFoundException extends InvitationException { + declare context: RuntimeException['context'] & { + id: string; + }; + + constructor(id: string, message = 'Invitation not found for id=%s') { + super({ + httpStatus: HttpStatus.NOT_FOUND, + message, + messageParams: [id], + fault: 'client', + }); + + this.errorCode = 'INVITATION_NOT_FOUND_ERROR'; + + this.context = { + ...this.context, + id, + }; + } +} diff --git a/packages/nestjs-invitation/src/application/exceptions/invitation-user-undefined.exception.ts b/packages/nestjs-invitation/src/application/exceptions/invitation-user-undefined.exception.ts new file mode 100644 index 000000000..f3c73501c --- /dev/null +++ b/packages/nestjs-invitation/src/application/exceptions/invitation-user-undefined.exception.ts @@ -0,0 +1,20 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { InvitationException } from '../../domain/exceptions/invitation.exception.js'; + +/** + * Thrown when a user cannot be resolved from the user port. + */ +export class InvitationUserUndefinedException extends InvitationException { + static errorMessage = + "Can't resolve a valid user from the user port. Check invitation module port configuration."; + + constructor(options?: RuntimeExceptionOptions) { + super({ + message: InvitationUserUndefinedException.errorMessage, + fault: 'usage', + ...options, + }); + this.errorCode = 'INVITATION_USER_UNDEFINED_ERROR'; + } +} diff --git a/packages/nestjs-invitation/src/application/listeners/__tests__/invitation-accepted.listener.spec.ts b/packages/nestjs-invitation/src/application/listeners/__tests__/invitation-accepted.listener.spec.ts new file mode 100644 index 000000000..5a179c515 --- /dev/null +++ b/packages/nestjs-invitation/src/application/listeners/__tests__/invitation-accepted.listener.spec.ts @@ -0,0 +1,46 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { createTestEventContext } from '@concepta/nestjs-core/testing'; + +import { type InvitationEventPayloadInterface } from '../../../domain/events/interfaces/invitation-event-payload.interface.js'; +import { InvitationAcceptedEvent } from '../../../domain/events/invitation-accepted.event.js'; +import { type InvitationNotificationPort } from '../../../domain/ports/invitation-notification.port.js'; +import { InvitationAcceptedListener } from '../invitation-accepted.listener.js'; + +describe(InvitationAcceptedListener.name, () => { + let listener: InvitationAcceptedListener; + let notificationPort: DeepMockProxy; + + beforeEach(() => { + notificationPort = mockDeep(); + listener = new InvitationAcceptedListener(notificationPort); + }); + + it('should call notificationPort.sendAccepted with the invitation from the event', async () => { + const eventContext = createTestEventContext({}, {}); + + const invitation: InvitationEventPayloadInterface = { + id: 'inv-1', + code: 'code-1', + category: 'user', + userId: 'user-1', + constraints: undefined, + dateAccepted: new Date(), + dateRevoked: null, + version: 1, + dateCreated: new Date(), + dateUpdated: new Date(), + dateDeleted: null, + }; + + const event = new InvitationAcceptedEvent(eventContext, invitation); + + await listener.handle(event); + + expect(notificationPort.sendAccepted).toHaveBeenCalledTimes(1); + expect(notificationPort.sendAccepted).toHaveBeenCalledWith( + expect.anything(), + invitation, + ); + }); +}); diff --git a/packages/nestjs-invitation/src/application/listeners/__tests__/invitation-dispatched.listener.spec.ts b/packages/nestjs-invitation/src/application/listeners/__tests__/invitation-dispatched.listener.spec.ts new file mode 100644 index 000000000..31cca1882 --- /dev/null +++ b/packages/nestjs-invitation/src/application/listeners/__tests__/invitation-dispatched.listener.spec.ts @@ -0,0 +1,52 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { createTestEventContext } from '@concepta/nestjs-core/testing'; + +import { type InvitationEventPayloadInterface } from '../../../domain/events/interfaces/invitation-event-payload.interface.js'; +import { InvitationDispatchedEvent } from '../../../domain/events/invitation-dispatched.event.js'; +import { type InvitationNotificationPort } from '../../../domain/ports/invitation-notification.port.js'; +import { InvitationDispatchedListener } from '../invitation-dispatched.listener.js'; + +describe(InvitationDispatchedListener.name, () => { + let listener: InvitationDispatchedListener; + let notificationPort: DeepMockProxy; + + beforeEach(() => { + notificationPort = mockDeep(); + listener = new InvitationDispatchedListener(notificationPort); + }); + + it('should call notificationPort.sendInvitation with invitation and OTP data from meta', async () => { + const tokenExp = new Date('2026-02-01'); + + const eventContext = createTestEventContext( + {}, + { passcode: 'abc123', tokenExp }, + ); + + const invitation: InvitationEventPayloadInterface = { + id: 'inv-1', + code: 'code-1', + category: 'user', + userId: 'user-1', + constraints: undefined, + dateAccepted: null, + dateRevoked: null, + version: 1, + dateCreated: new Date(), + dateUpdated: new Date(), + dateDeleted: null, + }; + + const event = new InvitationDispatchedEvent(eventContext, invitation); + + await listener.handle(event); + + expect(notificationPort.sendInvitation).toHaveBeenCalledTimes(1); + expect(notificationPort.sendInvitation).toHaveBeenCalledWith( + expect.anything(), + invitation, + { passcode: 'abc123', tokenExp }, + ); + }); +}); diff --git a/packages/nestjs-invitation/src/application/listeners/__tests__/invitation-revoked.listener.spec.ts b/packages/nestjs-invitation/src/application/listeners/__tests__/invitation-revoked.listener.spec.ts new file mode 100644 index 000000000..96389f66a --- /dev/null +++ b/packages/nestjs-invitation/src/application/listeners/__tests__/invitation-revoked.listener.spec.ts @@ -0,0 +1,73 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { type AppContextHost, CorrelationCtx } from '@concepta/nestjs-core'; +import { createTestEventContext } from '@concepta/nestjs-core/testing'; + +import { InvitationRevokedEvent } from '../../../domain/events/invitation-revoked.event.js'; +import { type InvitationOtpPort } from '../../../domain/ports/invitation-otp.port.js'; +import { InvitationRevokedListener } from '../invitation-revoked.listener.js'; + +describe(InvitationRevokedListener.name, () => { + let listener: InvitationRevokedListener; + let otpPort: DeepMockProxy; + + beforeEach(() => { + otpPort = mockDeep(); + listener = new InvitationRevokedListener(otpPort); + }); + + it('should call otpPort.clear with category and userId from event', async () => { + const eventContext = createTestEventContext({}, {}); + + const event = new InvitationRevokedEvent(eventContext, { + id: 'inv-1', + code: 'code-1', + category: 'user', + userId: 'user-1', + constraints: undefined, + dateAccepted: null, + dateRevoked: new Date(), + version: 1, + dateCreated: new Date(), + dateUpdated: new Date(), + dateDeleted: null, + }); + + await listener.handle(event); + + expect(otpPort.clear).toHaveBeenCalledTimes(1); + expect(otpPort.clear).toHaveBeenCalledWith( + expect.anything(), + 'user', + 'user-1', + ); + }); + + it('should forward correlationId and advance causationId to the event own causationId', async () => { + const eventContext = createTestEventContext({}, {}); + + const event = new InvitationRevokedEvent(eventContext, { + id: 'inv-1', + code: 'code-1', + category: 'user', + userId: 'user-1', + constraints: undefined, + dateAccepted: null, + dateRevoked: new Date(), + version: 1, + dateCreated: new Date(), + dateUpdated: new Date(), + dateDeleted: null, + }); + + await listener.handle(event); + + const forwardedCtx = otpPort.clear.mock.calls[0][0] as AppContextHost; + const correlation = forwardedCtx.with(CorrelationCtx); + + expect(correlation).toEqual({ + correlationId: eventContext.getHeader('correlationId'), + causationId: eventContext.getHeader('causationId'), + }); + }); +}); diff --git a/packages/nestjs-invitation/src/application/listeners/invitation-accepted.listener.ts b/packages/nestjs-invitation/src/application/listeners/invitation-accepted.listener.ts new file mode 100644 index 000000000..558e91736 --- /dev/null +++ b/packages/nestjs-invitation/src/application/listeners/invitation-accepted.listener.ts @@ -0,0 +1,13 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; + +import { InvitationAcceptedEvent } from '../../domain/events/invitation-accepted.event.js'; +import { InvitationNotificationPort } from '../../domain/ports/invitation-notification.port.js'; + +@EventsHandler(InvitationAcceptedEvent) +export class InvitationAcceptedListener implements IEventHandler { + constructor(private readonly notificationPort: InvitationNotificationPort) {} + + async handle(event: InvitationAcceptedEvent): Promise { + await this.notificationPort.sendAccepted({}, event.invitation); + } +} diff --git a/packages/nestjs-invitation/src/application/listeners/invitation-dispatched.listener.ts b/packages/nestjs-invitation/src/application/listeners/invitation-dispatched.listener.ts new file mode 100644 index 000000000..6d639fb04 --- /dev/null +++ b/packages/nestjs-invitation/src/application/listeners/invitation-dispatched.listener.ts @@ -0,0 +1,20 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; + +import { InvitationDispatchedEvent } from '../../domain/events/invitation-dispatched.event.js'; +import { InvitationNotificationPort } from '../../domain/ports/invitation-notification.port.js'; + +@EventsHandler(InvitationDispatchedEvent) +export class InvitationDispatchedListener implements IEventHandler { + constructor(private readonly notificationPort: InvitationNotificationPort) {} + + async handle(event: InvitationDispatchedEvent): Promise { + const { invitation, eventContext } = event; + const passcode = eventContext.getMeta('passcode'); + const tokenExp = eventContext.getMeta('tokenExp'); + + await this.notificationPort.sendInvitation({}, invitation, { + passcode, + tokenExp, + }); + } +} diff --git a/packages/nestjs-invitation/src/application/listeners/invitation-revoked.listener.ts b/packages/nestjs-invitation/src/application/listeners/invitation-revoked.listener.ts new file mode 100644 index 000000000..82e2bd940 --- /dev/null +++ b/packages/nestjs-invitation/src/application/listeners/invitation-revoked.listener.ts @@ -0,0 +1,27 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; + +import { AppContextHost, CorrelationCtx } from '@concepta/nestjs-core'; + +import { InvitationRevokedEvent } from '../../domain/events/invitation-revoked.event.js'; +import { InvitationOtpPort } from '../../domain/ports/invitation-otp.port.js'; + +@EventsHandler(InvitationRevokedEvent) +export class InvitationRevokedListener implements IEventHandler { + constructor(private readonly otpPort: InvitationOtpPort) {} + + async handle(event: InvitationRevokedEvent): Promise { + const { category, userId } = event.invitation; + + const appCtx = new AppContextHost(); + appCtx.defineOverlay(CorrelationCtx, { + correlationId: event.eventContext.getHeader('correlationId'), + // this listener reacting to the event is itself now the origin of a + // new inbound operation — its causationId is whatever caused the + // event it's reacting to, copied down one level (Rails Event Store's + // rule), not the event's own correlationId. + causationId: event.eventContext.getHeader('causationId'), + }); + + await this.otpPort.clear(appCtx, category, userId); + } +} diff --git a/packages/nestjs-invitation/src/application/queries/handlers/__tests__/find-invitation-by-code.handler.spec.ts b/packages/nestjs-invitation/src/application/queries/handlers/__tests__/find-invitation-by-code.handler.spec.ts new file mode 100644 index 000000000..695742297 --- /dev/null +++ b/packages/nestjs-invitation/src/application/queries/handlers/__tests__/find-invitation-by-code.handler.spec.ts @@ -0,0 +1,42 @@ +import { + createMockInvitationRepository, + createMockInvitationEntity, + toInvitationDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Invitation } from '../../../../domain/aggregates/invitation.js'; +import { FindInvitationByCodeQuery } from '../../impl/find-invitation-by-code.query.js'; +import { FindInvitationByCodeHandler } from '../find-invitation-by-code.handler.js'; + +describe(FindInvitationByCodeHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: FindInvitationByCodeHandler; + + beforeEach(() => { + mockRepo = createMockInvitationRepository(); + handler = new FindInvitationByCodeHandler(mockRepo); + }); + + it('should return the Invitation when found', async () => { + mockRepo.findOneByCode.mockResolvedValue( + toInvitationDomain(createMockInvitationEntity()), + ); + + const result = await handler.execute( + new FindInvitationByCodeQuery(ctx, 'test-code'), + ); + + expect(result).toBeInstanceOf(Invitation); + expect(result?.code).toBe('test-code'); + }); + + it('should return null when not found', async () => { + mockRepo.findOneByCode.mockResolvedValue(null); + + const result = await handler.execute( + new FindInvitationByCodeQuery(ctx, 'missing-code'), + ); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/nestjs-invitation/src/application/queries/handlers/__tests__/get-invitation.handler.spec.ts b/packages/nestjs-invitation/src/application/queries/handlers/__tests__/get-invitation.handler.spec.ts new file mode 100644 index 000000000..9eb344152 --- /dev/null +++ b/packages/nestjs-invitation/src/application/queries/handlers/__tests__/get-invitation.handler.spec.ts @@ -0,0 +1,42 @@ +import { + createMockInvitationRepository, + createMockInvitationEntity, + toInvitationDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Invitation } from '../../../../domain/aggregates/invitation.js'; +import { GetInvitationQuery } from '../../impl/get-invitation.query.js'; +import { GetInvitationHandler } from '../get-invitation.handler.js'; + +describe(GetInvitationHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: GetInvitationHandler; + + beforeEach(() => { + mockRepo = createMockInvitationRepository(); + handler = new GetInvitationHandler(mockRepo); + }); + + it('should return the Invitation when found', async () => { + mockRepo.get.mockResolvedValue( + toInvitationDomain(createMockInvitationEntity()), + ); + + const result = await handler.execute( + new GetInvitationQuery(ctx, 'test-id'), + ); + + expect(result).toBeInstanceOf(Invitation); + expect(result!.id).toBe('test-id'); + }); + + it('should return null when not found', async () => { + mockRepo.get.mockResolvedValue(null); + + const result = await handler.execute( + new GetInvitationQuery(ctx, 'missing-id'), + ); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/nestjs-invitation/src/application/queries/handlers/find-invitation-by-code.handler.ts b/packages/nestjs-invitation/src/application/queries/handlers/find-invitation-by-code.handler.ts new file mode 100644 index 000000000..bac1b820d --- /dev/null +++ b/packages/nestjs-invitation/src/application/queries/handlers/find-invitation-by-code.handler.ts @@ -0,0 +1,21 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { Invitation } from '../../../domain/aggregates/invitation.js'; +import { InvitationRepositoryInterface } from '../../../domain/repositories/invitation-repository.interface.js'; +import { INVITATION_MODULE_REPOSITORY_TOKEN } from '../../../invitation.constants.js'; +import { FindInvitationByCodeQuery } from '../impl/find-invitation-by-code.query.js'; + +@QueryHandler(FindInvitationByCodeQuery) +export class FindInvitationByCodeHandler implements IQueryHandler { + constructor( + @Inject(INVITATION_MODULE_REPOSITORY_TOKEN) + private readonly invitationRepo: InvitationRepositoryInterface, + ) {} + + async execute(query: FindInvitationByCodeQuery): Promise { + const { ctx, code } = query; + + return this.invitationRepo.findOneByCode(ctx, code); + } +} diff --git a/packages/nestjs-invitation/src/application/queries/handlers/get-invitation.handler.ts b/packages/nestjs-invitation/src/application/queries/handlers/get-invitation.handler.ts new file mode 100644 index 000000000..e955818f3 --- /dev/null +++ b/packages/nestjs-invitation/src/application/queries/handlers/get-invitation.handler.ts @@ -0,0 +1,21 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { Invitation } from '../../../domain/aggregates/invitation.js'; +import { InvitationRepositoryInterface } from '../../../domain/repositories/invitation-repository.interface.js'; +import { INVITATION_MODULE_REPOSITORY_TOKEN } from '../../../invitation.constants.js'; +import { GetInvitationQuery } from '../impl/get-invitation.query.js'; + +@QueryHandler(GetInvitationQuery) +export class GetInvitationHandler implements IQueryHandler { + constructor( + @Inject(INVITATION_MODULE_REPOSITORY_TOKEN) + private readonly invitationRepo: InvitationRepositoryInterface, + ) {} + + async execute(query: GetInvitationQuery): Promise { + const { ctx, id } = query; + + return this.invitationRepo.get(ctx, id); + } +} diff --git a/packages/nestjs-invitation/src/application/queries/impl/find-invitation-by-code.query.ts b/packages/nestjs-invitation/src/application/queries/impl/find-invitation-by-code.query.ts new file mode 100644 index 000000000..34a9d093f --- /dev/null +++ b/packages/nestjs-invitation/src/application/queries/impl/find-invitation-by-code.query.ts @@ -0,0 +1,13 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type Invitation } from '../../../domain/aggregates/invitation.js'; + +export class FindInvitationByCodeQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly code: string, + ) { + super(); + } +} diff --git a/packages/nestjs-invitation/src/application/queries/impl/get-invitation.query.ts b/packages/nestjs-invitation/src/application/queries/impl/get-invitation.query.ts new file mode 100644 index 000000000..1300167aa --- /dev/null +++ b/packages/nestjs-invitation/src/application/queries/impl/get-invitation.query.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Invitation } from '../../../domain/aggregates/invitation.js'; + +export class GetInvitationQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-invitation/src/application/utils/__tests__/assert-invitation-code.util.spec.ts b/packages/nestjs-invitation/src/application/utils/__tests__/assert-invitation-code.util.spec.ts new file mode 100644 index 000000000..07972c26d --- /dev/null +++ b/packages/nestjs-invitation/src/application/utils/__tests__/assert-invitation-code.util.spec.ts @@ -0,0 +1,45 @@ +import { HttpStatus } from '@nestjs/common'; + +import { InvitationException } from '../../../domain/exceptions/invitation.exception.js'; +import { assertInvitationCode } from '../assert-invitation-code.util.js'; + +describe('assertInvitationCode', () => { + it('should not throw for a valid string code', () => { + expect(() => assertInvitationCode('abc-123')).not.toThrow(); + }); + + it('should throw InvitationException for an empty string', () => { + expect(() => assertInvitationCode('')).toThrow(InvitationException); + }); + + it('should throw InvitationException for a whitespace-only string', () => { + expect(() => assertInvitationCode(' ')).toThrow(InvitationException); + }); + + it('should throw InvitationException for undefined', () => { + expect(() => assertInvitationCode(undefined)).toThrow(InvitationException); + }); + + it('should throw InvitationException for null', () => { + expect(() => assertInvitationCode(null)).toThrow(InvitationException); + }); + + it('should throw InvitationException for a number', () => { + expect(() => assertInvitationCode(42)).toThrow(InvitationException); + }); + + it('should throw with httpStatus BAD_REQUEST and a safe message', () => { + try { + assertInvitationCode(42); + throw new Error('Expected InvitationException'); + } catch (e) { + expect(e).toBeInstanceOf(InvitationException); + expect((e as InvitationException).httpStatus).toBe( + HttpStatus.BAD_REQUEST, + ); + expect((e as InvitationException).safeMessage).toBe( + 'Invalid invitation code', + ); + } + }); +}); diff --git a/packages/nestjs-invitation/src/application/utils/__tests__/assert-invitation-id.util.spec.ts b/packages/nestjs-invitation/src/application/utils/__tests__/assert-invitation-id.util.spec.ts new file mode 100644 index 000000000..26fb07fc6 --- /dev/null +++ b/packages/nestjs-invitation/src/application/utils/__tests__/assert-invitation-id.util.spec.ts @@ -0,0 +1,43 @@ +import { HttpStatus } from '@nestjs/common'; + +import { InvitationException } from '../../../domain/exceptions/invitation.exception.js'; +import { assertInvitationId } from '../assert-invitation-id.util.js'; + +describe('assertInvitationId', () => { + it('should not throw for a valid string id', () => { + expect(() => assertInvitationId('abc-123')).not.toThrow(); + }); + + it('should throw InvitationException for an empty string', () => { + expect(() => assertInvitationId('')).toThrow(InvitationException); + }); + + it('should throw InvitationException for a whitespace-only string', () => { + expect(() => assertInvitationId(' ')).toThrow(InvitationException); + }); + + it('should throw InvitationException for undefined', () => { + expect(() => assertInvitationId(undefined)).toThrow(InvitationException); + }); + + it('should throw InvitationException for null', () => { + expect(() => assertInvitationId(null)).toThrow(InvitationException); + }); + + it('should throw InvitationException for a number', () => { + expect(() => assertInvitationId(42)).toThrow(InvitationException); + }); + + it('should throw with httpStatus BAD_REQUEST and a safe message', () => { + try { + assertInvitationId(42); + throw new Error('Expected InvitationException'); + } catch (e) { + expect(e).toBeInstanceOf(InvitationException); + expect((e as InvitationException).httpStatus).toBe( + HttpStatus.BAD_REQUEST, + ); + expect((e as InvitationException).safeMessage).toBe('Invalid id'); + } + }); +}); diff --git a/packages/nestjs-invitation/src/application/utils/assert-invitation-code.util.ts b/packages/nestjs-invitation/src/application/utils/assert-invitation-code.util.ts new file mode 100644 index 000000000..1c021096d --- /dev/null +++ b/packages/nestjs-invitation/src/application/utils/assert-invitation-code.util.ts @@ -0,0 +1,18 @@ +import { HttpStatus } from '@nestjs/common'; + +import { InvitationException } from '../../domain/exceptions/invitation.exception.js'; + +/** + * Asserts that `value` is a non-empty string invitation code. + */ +export function assertInvitationCode(value: unknown): asserts value is string { + if (typeof value !== 'string' || value.trim() === '') { + throw new InvitationException({ + message: 'Expected invitation code to be a non-empty string, got %s', + messageParams: [typeof value], + safeMessage: 'Invalid invitation code', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } +} diff --git a/packages/nestjs-invitation/src/application/utils/assert-invitation-id.util.ts b/packages/nestjs-invitation/src/application/utils/assert-invitation-id.util.ts new file mode 100644 index 000000000..f44f68748 --- /dev/null +++ b/packages/nestjs-invitation/src/application/utils/assert-invitation-id.util.ts @@ -0,0 +1,28 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { InvitationException } from '../../domain/exceptions/invitation.exception.js'; + +/** + * Asserts that `value` is a non-empty string id. + * + * Classified `fault: 'client'` for the common case of a caller sending a + * malformed id directly. A controller whose id param is configured with + * `type: 'number'` (see `CrudParams`) will also route through here on every + * request — that's a module wiring mistake, not a client one, but the + * distinction isn't visible from inside this assertion. + */ +export function assertInvitationId( + value: unknown, +): asserts value is ReferenceId { + if (typeof value !== 'string' || value.trim() === '') { + throw new InvitationException({ + message: 'Expected invitation id to be a non-empty string, got %s', + messageParams: [typeof value], + safeMessage: 'Invalid id', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } +} diff --git a/packages/nestjs-invitation/src/assets/templates/email/invitation-accepted.template.hbs b/packages/nestjs-invitation/src/assets/templates/email/invitation-accepted.template.hbs deleted file mode 100644 index 31e5c4289..000000000 --- a/packages/nestjs-invitation/src/assets/templates/email/invitation-accepted.template.hbs +++ /dev/null @@ -1,6 +0,0 @@ -

- Logo -

-

-Congratulations you were successfully joined app. -

diff --git a/packages/nestjs-invitation/src/assets/templates/email/invitation.template.hbs b/packages/nestjs-invitation/src/assets/templates/email/invitation.template.hbs deleted file mode 100644 index 0f43a3809..000000000 --- a/packages/nestjs-invitation/src/assets/templates/email/invitation.template.hbs +++ /dev/null @@ -1,18 +0,0 @@ -

- Logo -

-

-You were invited to join the app. -

- -

-Please click on the link below to accept the invitation. -

- -

-Click Here -

- -

-This link will expire at {{tokenExp}} -

diff --git a/packages/nestjs-invitation/src/config/invitation-default.config.ts b/packages/nestjs-invitation/src/config/invitation-default.config.ts index 5d670a3cb..ca13b2b08 100644 --- a/packages/nestjs-invitation/src/config/invitation-default.config.ts +++ b/packages/nestjs-invitation/src/config/invitation-default.config.ts @@ -1,32 +1,21 @@ import { registerAs } from '@nestjs/config'; -import { InvitationSettingsInterface } from '../interfaces/options/invitation-settings.interface'; -import { INVITATION_MODULE_DEFAULT_SETTINGS_TOKEN } from '../invitation.constants'; +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { type InvitationSettingsInterface } from '../interfaces/options/invitation-settings.interface.js'; +import { INVITATION_MODULE_DEFAULT_SETTINGS_TOKEN } from '../invitation.constants.js'; /** * Default configuration for invitation. + * + * External port command/query types must be provided by the consumer + * through module options. These defaults only cover scalar settings. */ export const invitationDefaultConfig = registerAs( INVITATION_MODULE_DEFAULT_SETTINGS_TOKEN, - (): InvitationSettingsInterface => ({ - email: { - from: 'no-reply@dispostable.com', - baseUrl: 'http://localhost:3000', - templates: { - invitation: { - logo: 'public/logo.svg', - fileName: __dirname + '/../assets/invitation.template.hbs', - subject: 'Access Invitation', - }, - invitationAccepted: { - logo: 'public/logo.svg', - fileName: __dirname + '/../assets/invitation-accepted.template.hbs', - subject: 'Invitation Accepted', - }, - }, - }, + (): DeepPartial => ({ otp: { - assignment: 'user-otp', + namespace: 'user-otp', type: 'uuid', expiresIn: '7d', clearOtpOnCreate: process.env.INVITATION_OTP_CLEAR_ON_CREATE diff --git a/packages/nestjs-invitation/src/controllers/invitation.controller.e2e-spec.ts b/packages/nestjs-invitation/src/controllers/invitation.controller.e2e-spec.ts deleted file mode 100644 index 50c4da27b..000000000 --- a/packages/nestjs-invitation/src/controllers/invitation.controller.e2e-spec.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { plainToInstance } from 'class-transformer'; -import supertest from 'supertest'; - -import { INestApplication } from '@nestjs/common'; -import { ConfigService, ConfigType } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { - INVITATION_MODULE_CATEGORY_ORG_KEY, - INVITATION_MODULE_CATEGORY_USER_KEY, - OtpInterface, - UserInterface, - InvitationEntityInterface, -} from '@concepta/nestjs-common'; -import { EmailService } from '@concepta/nestjs-email'; -import { OtpService } from '@concepta/nestjs-otp'; -import { UserFactory } from '@concepta/nestjs-user/src/seeding'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { invitationDefaultConfig } from '../config/invitation-default.config'; -import { InvitationAcceptInviteDto } from '../dto/invitation-accept-invite.dto'; -import { InvitationCreateInviteDto } from '../dto/invitation-create-invite.dto'; -import { InvitationDto } from '../dto/invitation.dto'; -import { InvitationSettingsInterface } from '../interfaces/options/invitation-settings.interface'; -import { INVITATION_MODULE_DEFAULT_SETTINGS_TOKEN } from '../invitation.constants'; -import { InvitationFactory } from '../seeding/invitation.factory'; - -import { AppCrudModuleFixture } from '../__fixtures__/app-crud.module.fixture'; -import { InvitationEntityFixture } from '../__fixtures__/invitation/entities/invitation.entity.fixture'; -import { UserEntityFixture } from '../__fixtures__/user/entities/user.entity.fixture'; - -describe('InvitationController (e2e)', () => { - const userCategory = INVITATION_MODULE_CATEGORY_USER_KEY; - const orgCategory = INVITATION_MODULE_CATEGORY_ORG_KEY; - const constraints = { moreData: 'foo' }; - - let app: INestApplication; - let invitationFactory: InvitationFactory; - let seedingSource: SeedingSource; - let user: UserEntityFixture; - let otpService: OtpService; - let configService: ConfigService; - let config: ConfigType; - - const expectInvitationMatch = ( - createDto: InvitationCreateInviteDto, - response: InvitationDto, - ) => { - expect(response.category).toEqual(createDto.category); - // TODO: this needs another call to find user - // expect(response.user.email).toEqual(createDto.email); - }; - - beforeEach(async () => { - jest - .spyOn(EmailService.prototype, 'sendMail') - .mockImplementation(async () => undefined); - - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppCrudModuleFixture], - }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - - otpService = moduleFixture.get(OtpService); - configService = moduleFixture.get(ConfigService); - config = configService.get( - INVITATION_MODULE_DEFAULT_SETTINGS_TOKEN, - ) as InvitationSettingsInterface; - - seedingSource = new SeedingSource({ - dataSource: moduleFixture.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - const userFactory = new UserFactory({ - entity: UserEntityFixture, - seedingSource, - }); - - invitationFactory = new InvitationFactory({ - entity: InvitationEntityFixture, - seedingSource, - }); - - user = await userFactory.create(); - }); - - afterEach(async () => { - jest.clearAllMocks(); - if (app) await app.close(); - }); - - describe('Type: org', () => { - let invitation: InvitationEntityInterface; - - beforeEach(async () => { - invitation = await invitationFactory.create({ - category: orgCategory, - userId: user.id, - }); - }); - - it('POST invitation', async () => { - await createInvite(app, { - email: user.email, - category: orgCategory, - constraints, - }); - }); - - it('PATCH invitation-acceptance', async () => { - const { code } = invitation; - - const otp = await createOtp(config, otpService, user, orgCategory); - - const { passcode } = otp; - - await supertest(app.getHttpServer()) - .patch(`/invitation-acceptance/${code}`) - .send({ - passcode, - payload: { newPassword: 'hOdv2A2h%' }, - } as InvitationAcceptInviteDto) - .expect(200); - }); - }); - - describe('Type: user', () => { - let invitation: InvitationEntityInterface; - - beforeEach(async () => { - invitation = await invitationFactory.create({ - category: userCategory, - userId: user.id, - }); - }); - - it('POST invitation', async () => { - await createInvite(app, { - email: user.email, - category: userCategory, - constraints, - }); - }); - - it('POST invitation (create new user)', async () => { - await createInvite(app, { - email: 'test@mail.com', - category: userCategory, - constraints, - }); - }); - - it('POST invitation reattempt', async () => { - const invitationDto = await createInvite(app, { - email: 'test@mail.com', - category: userCategory, - constraints, - }); - - await supertest(app.getHttpServer()) - .post(`/invitation-reattempt/${invitationDto.code}`) - .expect(201); - }); - - it('PATCH invitation-acceptance', async () => { - const { code } = invitation; - - const otp = await createOtp(config, otpService, user, userCategory); - - const { passcode } = otp; - - await supertest(app.getHttpServer()) - .patch(`/invitation-acceptance/${code}`) - .send({ - passcode, - payload: { newPassword: 'hOdv2A2h%' }, - } as InvitationAcceptInviteDto) - .expect(200); - }); - - it('GET invitation-acceptance', async () => { - const { code } = invitation; - - const otp = await createOtp(config, otpService, user, userCategory); - - const { passcode } = otp; - - await supertest(app.getHttpServer()) - .get(`/invitation-acceptance/${code}?passcode=${passcode}`) - .expect(200); - }); - - it('GET invitation', async () => { - const response = await supertest(app.getHttpServer()) - .get('/invitation') - .expect(200); - - const invitationResponse = response.body.data as InvitationDto[]; - - expect(invitationResponse.length).toEqual(1); - }); - - it('GET invitation/:id', async () => { - const createInviteDto = plainToInstance(InvitationCreateInviteDto, { - email: user.email, - category: userCategory, - constraints, - }); - - const invitation = await createInvite(app, createInviteDto); - - const response = await supertest(app.getHttpServer()) - .get(`/invitation/${invitation.id}`) - .expect(200); - - const invitationResponse = response.body as InvitationDto; - expectInvitationMatch(createInviteDto, invitationResponse); - }); - - it('DELETE invitation/:id', async () => { - const invitation = await createInvite(app, { - email: user.email, - category: userCategory, - constraints, - }); - - await supertest(app.getHttpServer()) - .delete(`/invitation/${invitation.id}`) - .expect(200); - - await supertest(app.getHttpServer()) - .get(`/invitation/${invitation.id}`) - .expect(404); - }); - }); -}); - -const createInvite = async ( - app: INestApplication, - invitationCreateDto: InvitationCreateInviteDto, -): Promise => { - const response = await supertest(app.getHttpServer()) - .post('/invitation') - .send(invitationCreateDto) - .expect(201); - - return response.body as InvitationDto; -}; - -const createOtp = async ( - config: ConfigType, - otpService: OtpService, - user: UserInterface, - category: string, - clearOnCreate?: boolean, -): Promise => { - const { assignment, type, expiresIn } = config.otp; - - return await otpService.create({ - assignment, - otp: { - category, - type, - expiresIn, - assigneeId: user.id, - }, - clearOnCreate, - }); -}; diff --git a/packages/nestjs-invitation/src/domain/aggregates/__tests__/invitation.spec.ts b/packages/nestjs-invitation/src/domain/aggregates/__tests__/invitation.spec.ts new file mode 100644 index 000000000..4b5098884 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/aggregates/__tests__/invitation.spec.ts @@ -0,0 +1,227 @@ +import { createTestEventContext } from '@concepta/nestjs-core/testing'; + +import { InvitationAlreadyAcceptedException } from '../../exceptions/invitation-already-accepted.exception.js'; +import { InvitationRevokedException } from '../../exceptions/invitation-revoked.exception.js'; +import { type InvitationCreatableInterface } from '../../interfaces/invitation-creatable.interface.js'; +import { Invitation } from '../invitation.js'; + +describe(Invitation.name, () => { + const eventContext = createTestEventContext({}, {}); + + const validCreateDto: InvitationCreatableInterface = { + code: 'test-code', + category: 'user', + userId: 'test-user-id', + constraints: { role: 'admin' }, + }; + + describe('constructor', () => { + it('should set all properties from constructor args', () => { + const invitation = new Invitation( + 'test-id', + { + code: 'abc', + category: 'user', + userId: 'user-1', + constraints: undefined, + dateAccepted: null, + dateRevoked: null, + }, + 2, + { + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + }, + ); + + expect(invitation.id).toBe('test-id'); + expect(invitation.code).toBe('abc'); + expect(invitation.category).toBe('user'); + expect(invitation.userId).toBe('user-1'); + expect(invitation.active).toBe(true); + expect(invitation.isAccepted).toBe(false); + expect(invitation.isRevoked).toBe(false); + expect(invitation.dateAccepted).toBeNull(); + expect(invitation.dateRevoked).toBeNull(); + expect(invitation.constraints).toBeUndefined(); + expect(invitation.version).toBe(2); + expect(invitation.meta.dateCreated).toEqual(new Date('2026-01-01')); + expect(invitation.meta.dateUpdated).toEqual(new Date('2026-01-01')); + expect(invitation.meta.dateDeleted).toBeNull(); + }); + }); + + describe('create', () => { + it('should create an Invitation with active=true and null dates', () => { + const invitation = Invitation.create(eventContext, validCreateDto); + + expect(invitation).toBeInstanceOf(Invitation); + expect(invitation.code).toBe('test-code'); + expect(invitation.category).toBe('user'); + expect(invitation.userId).toBe('test-user-id'); + expect(invitation.active).toBe(true); + expect(invitation.isAccepted).toBe(false); + expect(invitation.isRevoked).toBe(false); + expect(invitation.dateAccepted).toBeNull(); + expect(invitation.dateRevoked).toBeNull(); + expect(invitation.constraints).toEqual({ role: 'admin' }); + expect(invitation.version).toBe(1); + }); + + it('should generate a uuid for id', () => { + const invitation = Invitation.create(eventContext, validCreateDto); + + expect(invitation.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it('should default constraints to undefined when not provided', () => { + const dto: InvitationCreatableInterface = { + code: 'test-code', + category: 'user', + userId: 'test-user-id', + }; + + const invitation = Invitation.create(eventContext, dto); + + expect(invitation.constraints).toBeUndefined(); + }); + }); + + describe('createWithId', () => { + it('should use the provided id', () => { + const invitation = Invitation.createWithId( + eventContext, + 'custom-id', + validCreateDto, + ); + + expect(invitation.id).toBe('custom-id'); + }); + }); + + describe('accept', () => { + it('should set dateAccepted and mark as accepted', () => { + const invitation = Invitation.create(eventContext, validCreateDto); + + invitation.accept(eventContext); + + expect(invitation.isAccepted).toBe(true); + expect(invitation.active).toBe(false); + expect(invitation.dateAccepted).toBeInstanceOf(Date); + }); + + it('should throw InvitationAlreadyAcceptedException if already accepted', () => { + const invitation = Invitation.create(eventContext, validCreateDto); + invitation.accept(eventContext); + + expect(() => invitation.accept(eventContext)).toThrow( + InvitationAlreadyAcceptedException, + ); + }); + + it('should throw InvitationRevokedException if revoked', () => { + const invitation = Invitation.create(eventContext, validCreateDto); + invitation.revoke(eventContext); + + expect(() => invitation.accept(eventContext)).toThrow( + InvitationRevokedException, + ); + }); + }); + + describe('dispatch', () => { + it('should not change any state', () => { + const invitation = Invitation.create(eventContext, validCreateDto); + + const dispatchContext = createTestEventContext( + {}, + { passcode: 'abc', tokenExp: new Date() }, + ); + + invitation.dispatch(dispatchContext); + + expect(invitation.active).toBe(true); + expect(invitation.isAccepted).toBe(false); + expect(invitation.isRevoked).toBe(false); + }); + }); + + describe('revoke', () => { + it('should set dateRevoked and mark as revoked', () => { + const invitation = Invitation.create(eventContext, validCreateDto); + + invitation.revoke(eventContext); + + expect(invitation.isRevoked).toBe(true); + expect(invitation.active).toBe(false); + expect(invitation.dateRevoked).toBeInstanceOf(Date); + }); + + it('should be idempotent if already revoked', () => { + const invitation = Invitation.create(eventContext, validCreateDto); + invitation.revoke(eventContext); + + const dateRevoked = invitation.dateRevoked; + + invitation.revoke(eventContext); + + expect(invitation.dateRevoked).toBe(dateRevoked); + }); + + it('should throw InvitationAlreadyAcceptedException if accepted', () => { + const invitation = Invitation.create(eventContext, validCreateDto); + invitation.accept(eventContext); + + expect(() => invitation.revoke(eventContext)).toThrow( + InvitationAlreadyAcceptedException, + ); + }); + }); + + describe('toPlain', () => { + it('should return a snapshot with id, version, props, and meta', () => { + const invitation = new Invitation( + 'test-id', + { + code: 'abc', + category: 'user', + userId: 'user-1', + constraints: undefined, + dateAccepted: null, + dateRevoked: null, + }, + 1, + { + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + }, + ); + + const plain = invitation.toPlain(); + + expect(plain).toEqual({ + id: 'test-id', + version: 1, + code: 'abc', + category: 'user', + userId: 'user-1', + constraints: undefined, + dateAccepted: null, + dateRevoked: null, + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + }); + }); + + it('should return a new object each time', () => { + const invitation = Invitation.create(eventContext, validCreateDto); + + expect(invitation.toPlain()).not.toBe(invitation.toPlain()); + }); + }); +}); diff --git a/packages/nestjs-invitation/src/domain/aggregates/invitation.ts b/packages/nestjs-invitation/src/domain/aggregates/invitation.ts new file mode 100644 index 000000000..db73397de --- /dev/null +++ b/packages/nestjs-invitation/src/domain/aggregates/invitation.ts @@ -0,0 +1,144 @@ +import { randomUUID } from 'crypto'; + +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type DomainFactory, + type EventContextHost, +} from '@concepta/nestjs-core'; +import { + type AggregateMetaInterface, + DomainAggregate, +} from '@concepta/nestjs-core/aggregate'; + +import { type InvitationDispatchedMetadataInterface } from '../events/interfaces/invitation-dispatched-metadata.interface.js'; +import { type InvitationEventHeaderInterface } from '../events/interfaces/invitation-event-header.interface.js'; +import { InvitationAcceptedEvent } from '../events/invitation-accepted.event.js'; +import { InvitationCreatedEvent } from '../events/invitation-created.event.js'; +import { InvitationDispatchedEvent } from '../events/invitation-dispatched.event.js'; +import { InvitationRemovedEvent } from '../events/invitation-removed.event.js'; +import { InvitationRevokedEvent } from '../events/invitation-revoked.event.js'; +import { InvitationAlreadyAcceptedException } from '../exceptions/invitation-already-accepted.exception.js'; +import { InvitationRevokedException } from '../exceptions/invitation-revoked.exception.js'; +import { type InvitationCreatableInterface } from '../interfaces/invitation-creatable.interface.js'; +import { type InvitationInterface } from '../interfaces/invitation.interface.js'; + +export class Invitation extends DomainAggregate { + constructor( + id: string, + props: InvitationInterface, + version?: number, + meta?: AggregateMetaInterface, + ) { + super(id, props, version, meta); + } + + get code() { + return this.props.code; + } + get category() { + return this.props.category; + } + get userId() { + return this.props.userId; + } + get constraints() { + return this.props.constraints; + } + get dateAccepted() { + return this.props.dateAccepted; + } + get dateRevoked() { + return this.props.dateRevoked; + } + + get active(): boolean { + return this.props.dateAccepted === null && this.props.dateRevoked === null; + } + + get isAccepted(): boolean { + return this.props.dateAccepted !== null; + } + + get isRevoked(): boolean { + return this.props.dateRevoked !== null; + } + + static create( + eventContext: EventContextHost, + dto: InvitationCreatableInterface, + ): Invitation { + return Invitation.createWithId(eventContext, randomUUID(), dto); + } + + static createWithId( + eventContext: EventContextHost, + id: string, + dto: InvitationCreatableInterface, + ): Invitation { + const { code, category, userId, constraints } = dto; + + const invitation = new Invitation(id, { + code, + category, + userId, + constraints: constraints ?? undefined, + dateAccepted: null, + dateRevoked: null, + }); + + invitation.apply( + new InvitationCreatedEvent(eventContext, invitation.toPlain()), + ); + + return invitation; + } + + accept( + eventContext: EventContextHost, + payload?: PlainLiteralObject, + ): void { + if (this.isAccepted) { + throw new InvitationAlreadyAcceptedException(); + } + + if (this.isRevoked) { + throw new InvitationRevokedException(); + } + + this.props.dateAccepted = new Date(); + + this.apply( + new InvitationAcceptedEvent(eventContext, this.toPlain(), payload), + ); + } + + dispatch( + eventContext: EventContextHost< + InvitationEventHeaderInterface, + InvitationDispatchedMetadataInterface + >, + ): void { + this.apply(new InvitationDispatchedEvent(eventContext, this.toPlain())); + } + + remove(eventContext: EventContextHost): void { + this.apply(new InvitationRemovedEvent(eventContext, this.toPlain())); + } + + revoke(eventContext: EventContextHost): void { + if (this.isRevoked) { + return; + } + + if (this.isAccepted) { + throw new InvitationAlreadyAcceptedException(); + } + + this.props.dateRevoked = new Date(); + + this.apply(new InvitationRevokedEvent(eventContext, this.toPlain())); + } +} + +Invitation satisfies DomainFactory; diff --git a/packages/nestjs-invitation/src/domain/events/interfaces/invitation-dispatched-metadata.interface.ts b/packages/nestjs-invitation/src/domain/events/interfaces/invitation-dispatched-metadata.interface.ts new file mode 100644 index 000000000..3c773c5ff --- /dev/null +++ b/packages/nestjs-invitation/src/domain/events/interfaces/invitation-dispatched-metadata.interface.ts @@ -0,0 +1,6 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +export interface InvitationDispatchedMetadataInterface extends PlainLiteralObject { + passcode: string; + tokenExp: Date; +} diff --git a/packages/nestjs-invitation/src/domain/events/interfaces/invitation-event-header.interface.ts b/packages/nestjs-invitation/src/domain/events/interfaces/invitation-event-header.interface.ts new file mode 100644 index 000000000..35f20d568 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/events/interfaces/invitation-event-header.interface.ts @@ -0,0 +1,3 @@ +import { type EventContextHeadersInterface } from '@concepta/nestjs-core'; + +export interface InvitationEventHeaderInterface extends EventContextHeadersInterface {} diff --git a/packages/nestjs-invitation/src/domain/events/interfaces/invitation-event-payload.interface.ts b/packages/nestjs-invitation/src/domain/events/interfaces/invitation-event-payload.interface.ts new file mode 100644 index 000000000..0cbcda825 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/events/interfaces/invitation-event-payload.interface.ts @@ -0,0 +1,14 @@ +import { + type AuditInterface, + type ReferenceIdInterface, + type ReferenceVersionInterface, +} from '@concepta/nestjs-core'; + +import { type InvitationInterface } from '../../interfaces/invitation.interface.js'; + +export interface InvitationEventPayloadInterface + extends + ReferenceIdInterface, + ReferenceVersionInterface, + InvitationInterface, + AuditInterface {} diff --git a/packages/nestjs-invitation/src/domain/events/invitation-accepted.event.ts b/packages/nestjs-invitation/src/domain/events/invitation-accepted.event.ts new file mode 100644 index 000000000..6081eb124 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/events/invitation-accepted.event.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type InvitationEventHeaderInterface } from './interfaces/invitation-event-header.interface.js'; +import { type InvitationEventPayloadInterface } from './interfaces/invitation-event-payload.interface.js'; + +export class InvitationAcceptedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly invitation: InvitationEventPayloadInterface, + public readonly payload?: PlainLiteralObject, + ) {} +} diff --git a/packages/nestjs-invitation/src/domain/events/invitation-created.event.ts b/packages/nestjs-invitation/src/domain/events/invitation-created.event.ts new file mode 100644 index 000000000..97e7cf10d --- /dev/null +++ b/packages/nestjs-invitation/src/domain/events/invitation-created.event.ts @@ -0,0 +1,13 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type InvitationEventHeaderInterface } from './interfaces/invitation-event-header.interface.js'; +import { type InvitationEventPayloadInterface } from './interfaces/invitation-event-payload.interface.js'; + +export class InvitationCreatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly invitation: InvitationEventPayloadInterface, + ) {} +} diff --git a/packages/nestjs-invitation/src/domain/events/invitation-dispatched.event.ts b/packages/nestjs-invitation/src/domain/events/invitation-dispatched.event.ts new file mode 100644 index 000000000..a1c98b1a6 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/events/invitation-dispatched.event.ts @@ -0,0 +1,17 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type InvitationDispatchedMetadataInterface } from './interfaces/invitation-dispatched-metadata.interface.js'; +import { type InvitationEventHeaderInterface } from './interfaces/invitation-event-header.interface.js'; +import { type InvitationEventPayloadInterface } from './interfaces/invitation-event-payload.interface.js'; + +export class InvitationDispatchedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost< + InvitationEventHeaderInterface, + InvitationDispatchedMetadataInterface + >, + public readonly invitation: InvitationEventPayloadInterface, + ) {} +} diff --git a/packages/nestjs-invitation/src/domain/events/invitation-removed.event.ts b/packages/nestjs-invitation/src/domain/events/invitation-removed.event.ts new file mode 100644 index 000000000..cb8557bd1 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/events/invitation-removed.event.ts @@ -0,0 +1,13 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type InvitationEventHeaderInterface } from './interfaces/invitation-event-header.interface.js'; +import { type InvitationEventPayloadInterface } from './interfaces/invitation-event-payload.interface.js'; + +export class InvitationRemovedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly invitation: InvitationEventPayloadInterface, + ) {} +} diff --git a/packages/nestjs-invitation/src/domain/events/invitation-revoked.event.ts b/packages/nestjs-invitation/src/domain/events/invitation-revoked.event.ts new file mode 100644 index 000000000..951dc9797 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/events/invitation-revoked.event.ts @@ -0,0 +1,13 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type InvitationEventHeaderInterface } from './interfaces/invitation-event-header.interface.js'; +import { type InvitationEventPayloadInterface } from './interfaces/invitation-event-payload.interface.js'; + +export class InvitationRevokedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly invitation: InvitationEventPayloadInterface, + ) {} +} diff --git a/packages/nestjs-invitation/src/domain/exceptions/__tests__/invitation-already-accepted.exception.spec.ts b/packages/nestjs-invitation/src/domain/exceptions/__tests__/invitation-already-accepted.exception.spec.ts new file mode 100644 index 000000000..aac8e7384 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/exceptions/__tests__/invitation-already-accepted.exception.spec.ts @@ -0,0 +1,26 @@ +import { HttpStatus } from '@nestjs/common'; + +import { InvitationAlreadyAcceptedException } from '../invitation-already-accepted.exception.js'; +import { InvitationException } from '../invitation.exception.js'; + +describe(InvitationAlreadyAcceptedException.name, () => { + it('should be an instance of InvitationException', () => { + const exception = new InvitationAlreadyAcceptedException(); + expect(exception).toBeInstanceOf(InvitationException); + }); + + it('should have httpStatus CONFLICT', () => { + const exception = new InvitationAlreadyAcceptedException(); + expect(exception.httpStatus).toBe(HttpStatus.CONFLICT); + }); + + it('should have fault client', () => { + const exception = new InvitationAlreadyAcceptedException(); + expect(exception.fault).toBe('client'); + }); + + it('should have errorCode INVITATION_ALREADY_ACCEPTED_ERROR', () => { + const exception = new InvitationAlreadyAcceptedException(); + expect(exception.errorCode).toBe('INVITATION_ALREADY_ACCEPTED_ERROR'); + }); +}); diff --git a/packages/nestjs-invitation/src/domain/exceptions/__tests__/invitation-revoked.exception.spec.ts b/packages/nestjs-invitation/src/domain/exceptions/__tests__/invitation-revoked.exception.spec.ts new file mode 100644 index 000000000..1b3bde8e3 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/exceptions/__tests__/invitation-revoked.exception.spec.ts @@ -0,0 +1,26 @@ +import { HttpStatus } from '@nestjs/common'; + +import { InvitationRevokedException } from '../invitation-revoked.exception.js'; +import { InvitationException } from '../invitation.exception.js'; + +describe(InvitationRevokedException.name, () => { + it('should be an instance of InvitationException', () => { + const exception = new InvitationRevokedException(); + expect(exception).toBeInstanceOf(InvitationException); + }); + + it('should have httpStatus CONFLICT', () => { + const exception = new InvitationRevokedException(); + expect(exception.httpStatus).toBe(HttpStatus.CONFLICT); + }); + + it('should have fault client', () => { + const exception = new InvitationRevokedException(); + expect(exception.fault).toBe('client'); + }); + + it('should have errorCode INVITATION_REVOKED_ERROR', () => { + const exception = new InvitationRevokedException(); + expect(exception.errorCode).toBe('INVITATION_REVOKED_ERROR'); + }); +}); diff --git a/packages/nestjs-invitation/src/domain/exceptions/invitation-already-accepted.exception.ts b/packages/nestjs-invitation/src/domain/exceptions/invitation-already-accepted.exception.ts new file mode 100644 index 000000000..4d610e7b0 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/exceptions/invitation-already-accepted.exception.ts @@ -0,0 +1,15 @@ +import { HttpStatus } from '@nestjs/common'; + +import { InvitationException } from './invitation.exception.js'; + +export class InvitationAlreadyAcceptedException extends InvitationException { + constructor() { + super({ + message: 'Invitation has already been accepted', + httpStatus: HttpStatus.CONFLICT, + fault: 'client', + }); + + this.errorCode = 'INVITATION_ALREADY_ACCEPTED_ERROR'; + } +} diff --git a/packages/nestjs-invitation/src/domain/exceptions/invitation-revoked.exception.ts b/packages/nestjs-invitation/src/domain/exceptions/invitation-revoked.exception.ts new file mode 100644 index 000000000..b10739828 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/exceptions/invitation-revoked.exception.ts @@ -0,0 +1,15 @@ +import { HttpStatus } from '@nestjs/common'; + +import { InvitationException } from './invitation.exception.js'; + +export class InvitationRevokedException extends InvitationException { + constructor() { + super({ + message: 'Invitation has been revoked', + httpStatus: HttpStatus.CONFLICT, + fault: 'client', + }); + + this.errorCode = 'INVITATION_REVOKED_ERROR'; + } +} diff --git a/packages/nestjs-invitation/src/exceptions/invitation.exception.ts b/packages/nestjs-invitation/src/domain/exceptions/invitation.exception.ts similarity index 79% rename from packages/nestjs-invitation/src/exceptions/invitation.exception.ts rename to packages/nestjs-invitation/src/domain/exceptions/invitation.exception.ts index b95907321..807c80d3c 100644 --- a/packages/nestjs-invitation/src/exceptions/invitation.exception.ts +++ b/packages/nestjs-invitation/src/domain/exceptions/invitation.exception.ts @@ -1,7 +1,7 @@ import { RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; /** * Generic invitation exception. diff --git a/packages/nestjs-invitation/src/domain/interfaces/invitation-acceptable.interface.ts b/packages/nestjs-invitation/src/domain/interfaces/invitation-acceptable.interface.ts new file mode 100644 index 000000000..41297290a --- /dev/null +++ b/packages/nestjs-invitation/src/domain/interfaces/invitation-acceptable.interface.ts @@ -0,0 +1,6 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +export interface InvitationAcceptableInterface { + passcode: string; + payload?: PlainLiteralObject; +} diff --git a/packages/nestjs-invitation/src/domain/interfaces/invitation-creatable-by-email.interface.ts b/packages/nestjs-invitation/src/domain/interfaces/invitation-creatable-by-email.interface.ts new file mode 100644 index 000000000..1e7a75849 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/interfaces/invitation-creatable-by-email.interface.ts @@ -0,0 +1,8 @@ +import { type ReferenceEmailInterface } from '@concepta/nestjs-core'; + +import { type InvitationCreatableInterface } from './invitation-creatable.interface.js'; + +export interface InvitationCreatableByEmailInterface + extends + Pick, + ReferenceEmailInterface {} diff --git a/packages/nestjs-invitation/src/domain/interfaces/invitation-creatable.interface.ts b/packages/nestjs-invitation/src/domain/interfaces/invitation-creatable.interface.ts new file mode 100644 index 000000000..0f1b83f2f --- /dev/null +++ b/packages/nestjs-invitation/src/domain/interfaces/invitation-creatable.interface.ts @@ -0,0 +1,6 @@ +import { type InvitationInterface } from './invitation.interface.js'; + +export interface InvitationCreatableInterface + extends + Pick, + Partial> {} diff --git a/packages/nestjs-invitation/src/domain/interfaces/invitation-otp-settings.interface.ts b/packages/nestjs-invitation/src/domain/interfaces/invitation-otp-settings.interface.ts new file mode 100644 index 000000000..2879b175f --- /dev/null +++ b/packages/nestjs-invitation/src/domain/interfaces/invitation-otp-settings.interface.ts @@ -0,0 +1,8 @@ +export interface InvitationOtpSettingsInterface { + type: string; + expiresIn: string; + rateSeconds?: number; + rateThreshold?: number; + namespace: string; + clearOtpOnCreate?: boolean; +} diff --git a/packages/nestjs-invitation/src/domain/interfaces/invitation-user.interface.ts b/packages/nestjs-invitation/src/domain/interfaces/invitation-user.interface.ts new file mode 100644 index 000000000..6c3580d31 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/interfaces/invitation-user.interface.ts @@ -0,0 +1,3 @@ +import { type ReferenceEmailInterface } from '@concepta/nestjs-core'; + +export interface InvitationUserInterface extends ReferenceEmailInterface {} diff --git a/packages/nestjs-invitation/src/domain/interfaces/invitation.interface.ts b/packages/nestjs-invitation/src/domain/interfaces/invitation.interface.ts new file mode 100644 index 000000000..5ec38a340 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/interfaces/invitation.interface.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +export interface InvitationUserRelationInterface< + T extends ReferenceId = ReferenceId, +> { + userId: T; +} + +export interface InvitationInterface extends InvitationUserRelationInterface { + code: string; + category: string; + constraints?: PlainLiteralObject | null; + dateAccepted: Date | null; + dateRevoked: Date | null; +} diff --git a/packages/nestjs-invitation/src/domain/policies/invitation-otp.policy.ts b/packages/nestjs-invitation/src/domain/policies/invitation-otp.policy.ts new file mode 100644 index 000000000..9bffa6300 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/policies/invitation-otp.policy.ts @@ -0,0 +1,28 @@ +import { type InvitationOtpSettingsInterface } from '../interfaces/invitation-otp-settings.interface.js'; + +export class InvitationOtpPolicy { + readonly namespace: string; + readonly type: string; + readonly expiresIn: string; + readonly clearOtpOnCreate: boolean; + readonly rateSeconds: number; + readonly rateThreshold: number; + + constructor(settings: InvitationOtpSettingsInterface) { + const { + namespace, + type, + expiresIn, + clearOtpOnCreate = false, + rateSeconds = 0, + rateThreshold = 0, + } = settings; + + this.namespace = namespace; + this.type = type; + this.expiresIn = expiresIn; + this.clearOtpOnCreate = clearOtpOnCreate; + this.rateSeconds = rateSeconds; + this.rateThreshold = rateThreshold; + } +} diff --git a/packages/nestjs-invitation/src/domain/ports/invitation-notification.port.ts b/packages/nestjs-invitation/src/domain/ports/invitation-notification.port.ts new file mode 100644 index 000000000..a93afb63f --- /dev/null +++ b/packages/nestjs-invitation/src/domain/ports/invitation-notification.port.ts @@ -0,0 +1,55 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { InvitationEventPayloadInterface } from '../events/interfaces/invitation-event-payload.interface.js'; + +export interface SendInvitationNotificationCommandInterface { + ctx: PlainLiteralObject; + invitation: InvitationEventPayloadInterface; + passcode: string; + tokenExp: Date; +} + +export interface SendAcceptedNotificationCommandInterface { + ctx: PlainLiteralObject; + invitation: InvitationEventPayloadInterface; +} + +export interface InvitationNotificationPortSettings { + sendInvitationCommand: Type; + sendAcceptedCommand: Type; +} + +@Injectable() +export class InvitationNotificationPort { + constructor( + private readonly portSettings: InvitationNotificationPortSettings, + private readonly commandBus: CommandBus, + ) {} + + async sendInvitation( + ctx: PlainLiteralObject, + invitation: InvitationEventPayloadInterface, + params: { + passcode: string; + tokenExp: Date; + }, + ): Promise { + return this.commandBus.execute( + new this.portSettings.sendInvitationCommand({ + ctx, + invitation, + ...params, + }), + ); + } + + async sendAccepted( + ctx: PlainLiteralObject, + invitation: InvitationEventPayloadInterface, + ): Promise { + return this.commandBus.execute( + new this.portSettings.sendAcceptedCommand({ ctx, invitation }), + ); + } +} diff --git a/packages/nestjs-invitation/src/domain/ports/invitation-otp.port.ts b/packages/nestjs-invitation/src/domain/ports/invitation-otp.port.ts new file mode 100644 index 000000000..1ab2f9ca9 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/ports/invitation-otp.port.ts @@ -0,0 +1,138 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; + +import { AssigneeRelationInterface, ReferenceId } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { InvitationOtpPolicy } from '../policies/invitation-otp.policy.js'; + +export interface InvitationOtpCreatableInterface { + category: string; + type: string; + assigneeId: ReferenceId; + expiresIn: string; + rateSeconds?: number; + rateThreshold?: number; +} + +export interface InvitationOtpInterface { + category: string; + type: string; + passcode: string; + expirationDate: Date; + active: boolean; + assigneeId: ReferenceId; +} + +export interface CreateOtpCommandInterface { + ctx: PlainLiteralObject; + namespace: string; + dto: InvitationOtpCreatableInterface; +} + +export interface ConsumeOtpCommandInterface { + ctx: PlainLiteralObject; + namespace: string; + otp: Pick; +} + +export interface ClearOtpsCommandInterface { + ctx: PlainLiteralObject; + namespace: string; + otp: Pick; +} + +export interface ValidateOtpQueryInterface { + ctx: PlainLiteralObject; + namespace: string; + otp: Pick; +} + +export interface InvitationOtpPortSettings { + createCommand: Type; + consumeCommand: Type; + clearCommand: Type; + validateQuery: Type; +} + +@Injectable() +export class InvitationOtpPort { + constructor( + private readonly portSettings: InvitationOtpPortSettings, + private readonly policy: InvitationOtpPolicy, + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + private readonly txScope: TransactionScope, + ) {} + + async create( + ctx: PlainLiteralObject, + category: string, + assigneeId: ReferenceId, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const { type, expiresIn, namespace, rateSeconds, rateThreshold } = + this.policy; + + const dto: InvitationOtpCreatableInterface = { + category, + type, + assigneeId, + expiresIn, + }; + + if (this.policy.clearOtpOnCreate) { + await this.clear(txCtx, category, assigneeId); + } + + return this.commandBus.execute( + new this.portSettings.createCommand(txCtx, namespace, dto, { + rateSeconds, + rateThreshold, + }), + ); + }); + } + + async consume( + ctx: PlainLiteralObject, + category: string, + passcode: string, + ): Promise { + const { namespace } = this.policy; + return this.commandBus.execute( + new this.portSettings.consumeCommand(ctx, namespace, { + category, + passcode, + }), + ); + } + + async clear( + ctx: PlainLiteralObject, + category: string, + assigneeId: ReferenceId, + ): Promise { + const { namespace } = this.policy; + return this.commandBus.execute( + new this.portSettings.clearCommand(ctx, namespace, { + assigneeId, + category, + }), + ); + } + + async validate( + ctx: PlainLiteralObject, + category: string, + passcode: string, + ): Promise { + const { namespace } = this.policy; + return this.queryBus.execute( + new this.portSettings.validateQuery(ctx, namespace, { + category, + passcode, + }), + ); + } +} diff --git a/packages/nestjs-invitation/src/domain/ports/invitation-user.port.ts b/packages/nestjs-invitation/src/domain/ports/invitation-user.port.ts new file mode 100644 index 000000000..40a116570 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/ports/invitation-user.port.ts @@ -0,0 +1,55 @@ +import { Injectable, PlainLiteralObject, Type } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; + +import { + ReferenceEmail, + ReferenceId, + ReferenceIdInterface, +} from '@concepta/nestjs-core'; + +import { InvitationUserInterface } from '../interfaces/invitation-user.interface.js'; + +export type InvitationUserResult = + | (ReferenceIdInterface & InvitationUserInterface) + | null; + +export interface GetUserByIdQueryInterface { + ctx: PlainLiteralObject; + id: ReferenceId; +} + +export interface GetUserByEmailQueryInterface { + ctx: PlainLiteralObject; + email: ReferenceEmail; +} + +export interface InvitationUserPortSettings { + getByIdQuery: Type; + getByEmailQuery: Type; +} + +@Injectable() +export class InvitationUserPort { + constructor( + private readonly portSettings: InvitationUserPortSettings, + private readonly queryBus: QueryBus, + ) {} + + async getById( + ctx: PlainLiteralObject, + userId: ReferenceId, + ): Promise { + return this.queryBus.execute( + new this.portSettings.getByIdQuery(ctx, userId), + ); + } + + async getByEmail( + ctx: PlainLiteralObject, + email: ReferenceEmail, + ): Promise { + return this.queryBus.execute( + new this.portSettings.getByEmailQuery(ctx, email), + ); + } +} diff --git a/packages/nestjs-invitation/src/domain/repositories/invitation-repository.interface.ts b/packages/nestjs-invitation/src/domain/repositories/invitation-repository.interface.ts new file mode 100644 index 000000000..a35d82360 --- /dev/null +++ b/packages/nestjs-invitation/src/domain/repositories/invitation-repository.interface.ts @@ -0,0 +1,24 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Invitation } from '../aggregates/invitation.js'; + +export interface InvitationRepositoryInterface { + get(ctx: PlainLiteralObject, id: ReferenceId): Promise; + + findOneByCode( + ctx: PlainLiteralObject, + code: string, + ): Promise; + + findAllByUserAndCategory( + ctx: PlainLiteralObject, + userId: ReferenceId, + category: string, + ): Promise; + + save(ctx: PlainLiteralObject, invitation: Invitation): Promise; + + remove(ctx: PlainLiteralObject, invitation: Invitation): Promise; +} diff --git a/packages/nestjs-invitation/src/domain/services/__tests__/invitation.service.spec.ts b/packages/nestjs-invitation/src/domain/services/__tests__/invitation.service.spec.ts new file mode 100644 index 000000000..95375105c --- /dev/null +++ b/packages/nestjs-invitation/src/domain/services/__tests__/invitation.service.spec.ts @@ -0,0 +1,334 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { HttpStatus } from '@nestjs/common'; + +import { AppContextHost } from '@concepta/nestjs-core'; + +import { + createMockInvitationRepository, + createMockInvitationEntity, + createMockEventPublisher, + createMockTransaction, + toInvitationDomain, +} from '../../../__tests__/helpers/mock.helpers.js'; +import { InvitationNotFoundException } from '../../../application/exceptions/invitation-not-found.exception.js'; +import { InvitationUserUndefinedException } from '../../../application/exceptions/invitation-user-undefined.exception.js'; +import { Invitation } from '../../aggregates/invitation.js'; +import { type InvitationOtpPort } from '../../ports/invitation-otp.port.js'; +import { type InvitationUserPort } from '../../ports/invitation-user.port.js'; +import { InvitationService } from '../invitation.service.js'; + +describe(InvitationService.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let mockOtpPort: DeepMockProxy; + let mockUserPort: DeepMockProxy; + let service: InvitationService; + let trxHandle: ReturnType['trxHandle']; + + const mockUser = { id: 'test-user-id', email: 'test@example.com' }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRepo = createMockInvitationRepository(); + + mockOtpPort = mockDeep(); + mockUserPort = mockDeep(); + + const eventPublisher = createMockEventPublisher(); + const { transaction, trxHandle: trx } = createMockTransaction(); + trxHandle = trx; + + service = new InvitationService( + mockRepo, + transaction as never, + eventPublisher as never, + mockOtpPort, + mockUserPort, + ); + }); + + describe('create', () => { + const dto = { + code: 'test-code', + category: 'user', + userId: 'test-user-id', + constraints: undefined, + }; + + beforeEach(() => { + mockUserPort.getById.mockResolvedValue(mockUser as never); + mockOtpPort.create.mockResolvedValue({ + passcode: 'abc', + expirationDate: new Date(), + } as never); + }); + + it('should create, save, and send an invitation', async () => { + const result = await service.create(ctx, dto); + + expect(result).toBeInstanceOf(Invitation); + expect(result.code).toBe('test-code'); + expect(result.category).toBe('user'); + expect(result.userId).toBe('test-user-id'); + expect(mockRepo.save).toHaveBeenCalledTimes(1); + expect(mockOtpPort.create).toHaveBeenCalledTimes(1); + }); + + it('should register onCommit and onRollback', async () => { + await service.create(ctx, dto); + + expect(trxHandle.onCommit).toHaveBeenCalled(); + expect(trxHandle.onRollback).toHaveBeenCalled(); + }); + }); + + describe('send', () => { + it('should create OTP, find user, and register commit/rollback', async () => { + const invitation = toInvitationDomain(createMockInvitationEntity()); + + const otp = { + passcode: 'abc123', + expirationDate: new Date('2026-02-01'), + }; + mockOtpPort.create.mockResolvedValue(otp as never); + mockUserPort.getById.mockResolvedValue(mockUser as never); + + await service.send(ctx, invitation); + + expect(mockOtpPort.create).toHaveBeenCalledTimes(1); + const [otpCtx, otpCategory, otpUserId] = mockOtpPort.create.mock.calls[0]; + expect(otpCtx).toBeInstanceOf(AppContextHost); + expect(otpCategory).toBe('user'); + expect(otpUserId).toBe('test-user-id'); + + expect(mockUserPort.getById).toHaveBeenCalledTimes(1); + const [userCtx, userId] = mockUserPort.getById.mock.calls[0]; + expect(userCtx).toBeInstanceOf(AppContextHost); + expect(userId).toBe('test-user-id'); + expect(trxHandle.onCommit).toHaveBeenCalledTimes(1); + expect(trxHandle.onRollback).toHaveBeenCalledTimes(1); + }); + + it('should throw InvitationUserUndefinedException when user not found', async () => { + const invitation = toInvitationDomain(createMockInvitationEntity()); + + mockUserPort.getById.mockResolvedValue(null); + + await expect(service.send(ctx, invitation)).rejects.toThrow( + InvitationUserUndefinedException, + ); + }); + + it('should classify a dangling userId as internal/usage, not client', async () => { + const invitation = toInvitationDomain(createMockInvitationEntity()); + + mockUserPort.getById.mockResolvedValue(null); + + try { + await service.send(ctx, invitation); + throw new Error('Expected InvitationUserUndefinedException'); + } catch (e) { + expect(e).toBeInstanceOf(InvitationUserUndefinedException); + expect((e as InvitationUserUndefinedException).fault).toBe('usage'); + expect((e as InvitationUserUndefinedException).httpStatus).toBe( + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + }); + }); + + describe('sendById', () => { + it('should fetch invitation and delegate to send', async () => { + const invitation = toInvitationDomain(createMockInvitationEntity()); + mockRepo.get.mockResolvedValue(invitation); + mockUserPort.getById.mockResolvedValue(mockUser as never); + mockOtpPort.create.mockResolvedValue({ + passcode: 'abc', + expirationDate: new Date(), + } as never); + + await service.sendById(ctx, 'test-id'); + + expect(mockRepo.get).toHaveBeenCalledWith( + expect.any(AppContextHost), + 'test-id', + ); + expect(mockOtpPort.create).toHaveBeenCalledTimes(1); + }); + + it('should throw InvitationNotFoundException when not found', async () => { + mockRepo.get.mockResolvedValue(null); + + await expect(service.sendById(ctx, 'missing-id')).rejects.toThrow( + InvitationNotFoundException, + ); + }); + }); + + describe('accept', () => { + it('should return the accepted invitation when OTP is valid', async () => { + const invitation = toInvitationDomain(createMockInvitationEntity()); + mockRepo.findOneByCode.mockResolvedValue(invitation); + mockUserPort.getById.mockResolvedValue(mockUser as never); + mockOtpPort.consume.mockResolvedValue({ + assigneeId: 'test-user-id', + } as never); + + // revokeByUserId will find invitations directly (no user lookup) + mockRepo.findAllByUserAndCategory.mockResolvedValue([]); + + const result = await service.accept(ctx, 'test-code', 'abc123'); + + expect(result).toBeInstanceOf(Invitation); + expect(mockOtpPort.consume).toHaveBeenCalledTimes(1); + const [consumeCtx, consumeCategory, consumePasscode] = + mockOtpPort.consume.mock.calls[0]; + expect(consumeCtx).toBeInstanceOf(AppContextHost); + expect(consumeCategory).toBe('user'); + expect(consumePasscode).toBe('abc123'); + expect(mockRepo.save).toHaveBeenCalledTimes(1); + }); + + it('should return null when OTP returns null', async () => { + const invitation = toInvitationDomain(createMockInvitationEntity()); + mockRepo.findOneByCode.mockResolvedValue(invitation); + mockUserPort.getById.mockResolvedValue(mockUser as never); + mockOtpPort.consume.mockResolvedValue(null); + + const result = await service.accept(ctx, 'test-code', 'bad'); + + expect(result).toBeNull(); + expect(mockRepo.save).not.toHaveBeenCalled(); + }); + + it('should throw InvitationNotFoundException when not found', async () => { + mockRepo.findOneByCode.mockResolvedValue(null); + + await expect(service.accept(ctx, 'missing', 'abc')).rejects.toThrow( + InvitationNotFoundException, + ); + }); + + it('should revoke sibling invitations after acceptance', async () => { + const invitation = toInvitationDomain(createMockInvitationEntity()); + const sibling = toInvitationDomain( + createMockInvitationEntity({ id: 'sibling-id', code: 'other-code' }), + ); + + mockRepo.findOneByCode.mockResolvedValue(invitation); + mockUserPort.getById.mockResolvedValue(mockUser as never); + mockOtpPort.consume.mockResolvedValue({ + assigneeId: 'test-user-id', + } as never); + mockRepo.findAllByUserAndCategory.mockResolvedValue([sibling]); + + await service.accept(ctx, 'test-code', 'abc123'); + + // 1 save for accept + 1 save for sibling revoke + expect(mockRepo.save).toHaveBeenCalledTimes(2); + }); + }); + + describe('revokeByEmail', () => { + it('should revoke active invitations for user+category', async () => { + mockUserPort.getByEmail.mockResolvedValue(mockUser as never); + + const inv1 = toInvitationDomain( + createMockInvitationEntity({ id: 'inv-1' }), + ); + const inv2 = toInvitationDomain( + createMockInvitationEntity({ id: 'inv-2' }), + ); + mockRepo.findAllByUserAndCategory.mockResolvedValue([inv1, inv2]); + + await service.revokeByEmail(ctx, 'test@example.com', 'user'); + + expect(mockUserPort.getByEmail).toHaveBeenCalledWith( + expect.any(AppContextHost), + 'test@example.com', + ); + expect(mockRepo.findAllByUserAndCategory).toHaveBeenCalledWith( + expect.any(AppContextHost), + 'test-user-id', + 'user', + ); + expect(mockRepo.save).toHaveBeenCalledTimes(2); + expect(trxHandle.onCommit).toHaveBeenCalledTimes(2); + expect(trxHandle.onRollback).toHaveBeenCalledTimes(2); + }); + + it('should return early when no invitations found', async () => { + mockUserPort.getByEmail.mockResolvedValue(mockUser as never); + mockRepo.findAllByUserAndCategory.mockResolvedValue([]); + + await service.revokeByEmail(ctx, 'test@example.com', 'user'); + + expect(mockRepo.save).not.toHaveBeenCalled(); + }); + + it('should throw InvitationUserUndefinedException when user not found', async () => { + mockUserPort.getByEmail.mockResolvedValue(null); + + await expect( + service.revokeByEmail(ctx, 'unknown@example.com', 'user'), + ).rejects.toThrow(InvitationUserUndefinedException); + }); + + it('should classify no-user-for-email as client/BAD_REQUEST', async () => { + mockUserPort.getByEmail.mockResolvedValue(null); + + try { + await service.revokeByEmail(ctx, 'unknown@example.com', 'user'); + throw new Error('Expected InvitationUserUndefinedException'); + } catch (e) { + expect(e).toBeInstanceOf(InvitationUserUndefinedException); + expect((e as InvitationUserUndefinedException).fault).toBe('client'); + expect((e as InvitationUserUndefinedException).httpStatus).toBe( + HttpStatus.BAD_REQUEST, + ); + } + }); + }); + + describe('revokeByUserId', () => { + it('should revoke active invitations for userId+category', async () => { + const inv1 = toInvitationDomain( + createMockInvitationEntity({ id: 'inv-1' }), + ); + const inv2 = toInvitationDomain( + createMockInvitationEntity({ id: 'inv-2' }), + ); + mockRepo.findAllByUserAndCategory.mockResolvedValue([inv1, inv2]); + + await service.revokeByUserId(ctx, 'test-user-id', 'user'); + + expect(mockRepo.findAllByUserAndCategory).toHaveBeenCalledWith( + expect.any(AppContextHost), + 'test-user-id', + 'user', + ); + expect(mockRepo.save).toHaveBeenCalledTimes(2); + expect(trxHandle.onCommit).toHaveBeenCalledTimes(2); + expect(trxHandle.onRollback).toHaveBeenCalledTimes(2); + }); + + it('should return early when no active invitations', async () => { + mockRepo.findAllByUserAndCategory.mockResolvedValue([]); + + await service.revokeByUserId(ctx, 'test-user-id', 'user'); + + expect(mockRepo.save).not.toHaveBeenCalled(); + }); + + it('should not require a user lookup', async () => { + mockRepo.findAllByUserAndCategory.mockResolvedValue([]); + + await service.revokeByUserId(ctx, 'test-user-id', 'user'); + + expect(mockUserPort.getByEmail).not.toHaveBeenCalled(); + expect(mockUserPort.getById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/nestjs-invitation/src/domain/services/invitation.service.ts b/packages/nestjs-invitation/src/domain/services/invitation.service.ts new file mode 100644 index 000000000..34fe0de9c --- /dev/null +++ b/packages/nestjs-invitation/src/domain/services/invitation.service.ts @@ -0,0 +1,272 @@ +import { randomUUID } from 'crypto'; + +import { + HttpStatus, + Inject, + Injectable, + PlainLiteralObject, +} from '@nestjs/common'; +import { EventPublisher } from '@nestjs/cqrs'; + +import { + createEventContext, + EventContextHeadersInterface, + EventContextHost, + ReferenceId, +} from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { InvitationNotFoundException } from '../../application/exceptions/invitation-not-found.exception.js'; +import { InvitationUserUndefinedException } from '../../application/exceptions/invitation-user-undefined.exception.js'; +import { INVITATION_MODULE_REPOSITORY_TOKEN } from '../../invitation.constants.js'; +import { Invitation } from '../aggregates/invitation.js'; +import { InvitationDispatchedMetadataInterface } from '../events/interfaces/invitation-dispatched-metadata.interface.js'; +import { InvitationException } from '../exceptions/invitation.exception.js'; +import { InvitationCreatableByEmailInterface } from '../interfaces/invitation-creatable-by-email.interface.js'; +import { InvitationCreatableInterface } from '../interfaces/invitation-creatable.interface.js'; +import { InvitationOtpPort } from '../ports/invitation-otp.port.js'; +import { InvitationUserPort } from '../ports/invitation-user.port.js'; +import { InvitationRepositoryInterface } from '../repositories/invitation-repository.interface.js'; + +@Injectable() +export class InvitationService { + constructor( + @Inject(INVITATION_MODULE_REPOSITORY_TOKEN) + private readonly invitationRepo: InvitationRepositoryInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + private readonly otpPort: InvitationOtpPort, + private readonly userPort: InvitationUserPort, + ) {} + + async create( + ctx: PlainLiteralObject, + dto: InvitationCreatableInterface, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const eventContext = createEventContext(txCtx, {}, {}); + + const invitation = this.eventPublisher.mergeObjectContext( + Invitation.create(eventContext, dto), + ); + + await this.invitationRepo.save(txCtx, invitation); + + await this.send(txCtx, invitation); + + txCtx.trx.onCommit(() => invitation.commit()); + txCtx.trx.onRollback(() => invitation.uncommit()); + + return invitation; + }); + } + + async createByEmail( + ctx: PlainLiteralObject, + dto: InvitationCreatableByEmailInterface, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const { email, category, constraints } = dto; + + const user = await this.userPort.getByEmail(txCtx, email); + + if (!user) { + // No user for a caller-supplied email — a client mistake, not a + // wiring problem. + throw new InvitationUserUndefinedException({ + safeMessage: 'No user found for the given email', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } + + return this.create(txCtx, { + userId: user.id, + code: randomUUID(), + category, + constraints, + }); + }); + } + + async send(ctx: PlainLiteralObject, invitation: Invitation): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const { category, userId } = invitation; + + const user = await this.userPort.getById(txCtx, userId); + + if (!user) { + // userId came from the invitation row, not caller input — a + // dangling reference is a wiring/data problem. Leave fault at the + // class default ('usage'). + throw new InvitationUserUndefinedException(); + } + + const otp = await this.otpPort.create(txCtx, category, userId); + + const eventContext = createEventContext< + PlainLiteralObject, + InvitationDispatchedMetadataInterface + >( + txCtx, + {}, + { + passcode: otp.passcode, + tokenExp: otp.expirationDate, + }, + ); + + const merged = this.eventPublisher.mergeObjectContext(invitation); + merged.dispatch(eventContext); + + txCtx.trx.onCommit(() => merged.commit()); + txCtx.trx.onRollback(() => merged.uncommit()); + }); + } + + async sendById( + ctx: PlainLiteralObject, + invitationId: ReferenceId, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const invitation = await this.invitationRepo.get(txCtx, invitationId); + + if (!invitation) { + throw new InvitationNotFoundException(invitationId); + } + + await this.send(txCtx, invitation); + }); + } + + async accept( + ctx: PlainLiteralObject, + code: string, + passcode: string, + payload?: PlainLiteralObject, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + let invitation; + + try { + invitation = await this.invitationRepo.findOneByCode(txCtx, code); + } catch (e: unknown) { + throw new InvitationException({ originalError: e }); + } + + if (!invitation) { + throw new InvitationNotFoundException( + code, + 'Invitation not found for code=%s', + ); + } + + const { category } = invitation; + + const otp = await this.otpPort.consume(txCtx, category, passcode); + + if (!otp) { + return null; + } + + const eventContext = createEventContext(txCtx, {}, {}); + + const merged = this.eventPublisher.mergeObjectContext(invitation); + merged.accept(eventContext, payload); + + await this.invitationRepo.save(txCtx, merged); + + // revoke all other active invitations for this user+category + await this.revokeByUserId(txCtx, invitation.userId, category); + + txCtx.trx.onCommit(() => merged.commit()); + txCtx.trx.onRollback(() => merged.uncommit()); + + return merged; + }); + } + + async remove(ctx: PlainLiteralObject, id: ReferenceId): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const invitation = await this.invitationRepo.get(txCtx, id); + + if (!invitation) { + throw new InvitationNotFoundException(String(id)); + } + + const eventContext = createEventContext(txCtx, {}, {}); + + const merged = this.eventPublisher.mergeObjectContext(invitation); + merged.remove(eventContext); + + await this.invitationRepo.remove(txCtx, merged); + + txCtx.trx.onCommit(() => merged.commit()); + txCtx.trx.onRollback(() => merged.uncommit()); + + return merged; + }); + } + + async revokeByEmail( + ctx: PlainLiteralObject, + email: string, + category: string, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const user = await this.userPort.getByEmail(txCtx, email); + + if (!user) { + // No user for a caller-supplied email — a client mistake, not a + // wiring problem. + throw new InvitationUserUndefinedException({ + safeMessage: 'No user found for the given email', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } + + await this.revokeByUserId(txCtx, user.id, category); + }); + } + + async revokeByUserId( + ctx: PlainLiteralObject, + userId: ReferenceId, + category: string, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const invitations = await this.invitationRepo.findAllByUserAndCategory( + txCtx, + userId, + category, + ); + + const activeInvitations = invitations.filter((inv) => inv.active); + + if (activeInvitations.length === 0) { + return; + } + + const eventContext = createEventContext(txCtx, {}, {}); + + await this.revokeActive(txCtx, eventContext, activeInvitations); + }); + } + + protected async revokeActive( + ctx: PlainLiteralObject, + eventContext: EventContextHost, + invitations: Invitation[], + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + for (const invitation of invitations) { + const merged = this.eventPublisher.mergeObjectContext(invitation); + merged.revoke(eventContext); + await this.invitationRepo.save(txCtx, merged); + txCtx.trx.onCommit(() => merged.commit()); + txCtx.trx.onRollback(() => merged.uncommit()); + } + }); + } +} diff --git a/packages/nestjs-invitation/src/dto/invitation-accept-invite.dto.ts b/packages/nestjs-invitation/src/dto/invitation-accept-invite.dto.ts deleted file mode 100644 index 8b0048d8b..000000000 --- a/packages/nestjs-invitation/src/dto/invitation-accept-invite.dto.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { IsObject, IsOptional, IsString } from 'class-validator'; - -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -import { LiteralObject } from '@concepta/nestjs-common'; - -export class InvitationAcceptInviteDto { - @ApiProperty({ - title: 'passcode activate invitation', - type: 'string', - description: 'Passcode used to activate account', - }) - @IsString() - passcode = ''; - - @ApiPropertyOptional({ - title: 'Payload', - type: 'object', - description: - 'Payload content that will be passed through another module ir order to complete the activation.' + - ' This payload will have necessary info to target module complete the activation e.g. new password or what ever required info.' + - ' The object not have any strong type defined on purpose because the target moules will have object different signatures', - additionalProperties: true, - }) - @IsObject() - @IsOptional() - payload?: LiteralObject; -} diff --git a/packages/nestjs-invitation/src/dto/invitation-create-invite.dto.ts b/packages/nestjs-invitation/src/dto/invitation-create-invite.dto.ts deleted file mode 100644 index 38ba02252..000000000 --- a/packages/nestjs-invitation/src/dto/invitation-create-invite.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsEmail } from 'class-validator'; - -import { ApiProperty, PickType } from '@nestjs/swagger'; - -import { InvitationCreateInviteInterface } from '../interfaces/domain/invitation-create-invite.interface'; - -import { InvitationCreateDto } from './invitation-create.dto'; - -@Exclude() -export class InvitationCreateInviteDto - extends PickType(InvitationCreateDto, ['category', 'constraints'] as const) - implements InvitationCreateInviteInterface -{ - @Expose() - @ApiProperty({ - type: 'string', - description: 'Email that the invitation will be sent to', - }) - @IsEmail() - email = ''; -} diff --git a/packages/nestjs-invitation/src/dto/invitation-create.dto.ts b/packages/nestjs-invitation/src/dto/invitation-create.dto.ts deleted file mode 100644 index 59bc2c65f..000000000 --- a/packages/nestjs-invitation/src/dto/invitation-create.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { IntersectionType, PartialType, PickType } from '@nestjs/swagger'; - -import { InvitationCreatableInterface } from '../interfaces/domain/invitation-creatable.interface'; - -import { InvitationDto } from './invitation.dto'; - -@Exclude() -export class InvitationCreateDto - extends IntersectionType( - PickType(InvitationDto, ['category', 'userId', 'code'] as const), - PartialType(PickType(InvitationDto, ['constraints'] as const)), - ) - implements InvitationCreatableInterface {} diff --git a/packages/nestjs-invitation/src/dto/invitation-paginated.dto.ts b/packages/nestjs-invitation/src/dto/invitation-paginated.dto.ts deleted file mode 100644 index 84c33c071..000000000 --- a/packages/nestjs-invitation/src/dto/invitation-paginated.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { InvitationInterface } from '@concepta/nestjs-common'; -import { CrudResponsePaginatedDto } from '@concepta/nestjs-crud'; - -import { InvitationDto } from './invitation.dto'; - -/** - * User paginated DTO - */ -@Exclude() -export class InvitationPaginatedDto extends CrudResponsePaginatedDto { - @Expose() - @ApiProperty({ - type: InvitationDto, - isArray: true, - description: 'Array of Invitations', - }) - @Type(() => InvitationDto) - data: InvitationDto[] = []; -} diff --git a/packages/nestjs-invitation/src/dto/invitation-user.dto.ts b/packages/nestjs-invitation/src/dto/invitation-user.dto.ts deleted file mode 100644 index 4a30f578b..000000000 --- a/packages/nestjs-invitation/src/dto/invitation-user.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsEmail } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { - ReferenceIdDto, - InvitationUserInterface, -} from '@concepta/nestjs-common'; - -@Exclude() -export class InvitationUserDto - extends ReferenceIdDto - implements InvitationUserInterface -{ - @Expose() - @ApiProperty({ - type: 'string', - description: 'Email address', - }) - @IsEmail() - email = ''; -} diff --git a/packages/nestjs-invitation/src/dto/invitation.dto.ts b/packages/nestjs-invitation/src/dto/invitation.dto.ts deleted file mode 100644 index fd8d6cd39..000000000 --- a/packages/nestjs-invitation/src/dto/invitation.dto.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { - LiteralObject, - CommonEntityDto, - InvitationInterface, -} from '@concepta/nestjs-common'; - -@Exclude() -export class InvitationDto - extends CommonEntityDto - implements InvitationInterface -{ - @Expose() - @ApiProperty({ - type: 'boolean', - description: 'True if Invitation is active', - }) - @IsBoolean() - active = true; - - @Expose() - @ApiProperty({ - type: 'string', - description: 'Code claim invitation', - }) - @IsString() - code = ''; - - @Expose() - @ApiProperty({ - type: 'string', - description: - 'Category of invitation that refers the following table name: user, org...', - }) - @IsString() - category = ''; - - @Expose() - @ApiProperty({ - title: 'Payload', - type: 'object', - description: - 'Payload content that will be passed through another module ir order to complete the invitation.' + - ' This payload will have necessary info to target module complete the invitation e.g. new password or what ever required info.' + - ' The object not have any strong type defined on purpose because the target moules will have object different signatures', - additionalProperties: true, - }) - @IsObject() - @IsOptional() - constraints!: LiteralObject; - - @Expose() - @ApiProperty({ - type: 'string', - description: 'The invited user ID.', - }) - @IsString() - userId!: string; -} diff --git a/packages/nestjs-invitation/src/events/invitation-accepted.event.ts b/packages/nestjs-invitation/src/events/invitation-accepted.event.ts deleted file mode 100644 index ea636a30a..000000000 --- a/packages/nestjs-invitation/src/events/invitation-accepted.event.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { InvitationAcceptedEventPayloadInterface } from '@concepta/nestjs-common'; -import { EventAsync } from '@concepta/nestjs-event'; - -export class InvitationAcceptedEventAsync extends EventAsync< - InvitationAcceptedEventPayloadInterface, - boolean -> {} diff --git a/packages/nestjs-invitation/src/exceptions/invitation-missing-entities-options.exception.ts b/packages/nestjs-invitation/src/exceptions/invitation-missing-entities-options.exception.ts deleted file mode 100644 index e8c2a8efb..000000000 --- a/packages/nestjs-invitation/src/exceptions/invitation-missing-entities-options.exception.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { InvitationException } from './invitation.exception'; - -export class InvitationMissingEntitiesOptionsException extends InvitationException { - constructor() { - super({ - message: 'You must provide the entities option', - }); - this.errorCode = 'INVITATION_MISSING_ENTITIES_OPTION'; - } -} diff --git a/packages/nestjs-invitation/src/exceptions/invitation-not-accepted.exception.ts b/packages/nestjs-invitation/src/exceptions/invitation-not-accepted.exception.ts deleted file mode 100644 index ef9b9c4ff..000000000 --- a/packages/nestjs-invitation/src/exceptions/invitation-not-accepted.exception.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { InvitationException } from './invitation.exception'; - -/** - * Generic invitation exception. - */ -export class InvitationNotAcceptedException extends InvitationException { - constructor(options?: RuntimeExceptionOptions) { - super({ - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - this.errorCode = 'INVITATION_NOT_ACCEPTED_ERROR'; - } -} diff --git a/packages/nestjs-invitation/src/exceptions/invitation-not-found.exception.ts b/packages/nestjs-invitation/src/exceptions/invitation-not-found.exception.ts deleted file mode 100644 index a94777c93..000000000 --- a/packages/nestjs-invitation/src/exceptions/invitation-not-found.exception.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { InvitationException } from './invitation.exception'; - -/** - * Generic invitation exception. - */ -export class InvitationNotFoundException extends InvitationException { - constructor(options?: RuntimeExceptionOptions) { - super({ - httpStatus: HttpStatus.NOT_FOUND, - ...options, - }); - this.errorCode = 'INVITATION_NOT_FOUND_ERROR'; - } -} diff --git a/packages/nestjs-invitation/src/exceptions/invitation-send-mail.exception.ts b/packages/nestjs-invitation/src/exceptions/invitation-send-mail.exception.ts deleted file mode 100644 index 6d6daf6c7..000000000 --- a/packages/nestjs-invitation/src/exceptions/invitation-send-mail.exception.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { InvitationException } from './invitation.exception'; - -/** - * Thrown when an error occurs while attempting to deliver email. - */ -export class InvitationSendMailException extends InvitationException { - context: RuntimeException['context'] & { - emailAddress: string; - }; - - // TODO: this is receiving email, but not using, should update - // message or remove email from constructor - constructor(emailAddress: string, options?: RuntimeExceptionOptions) { - super({ - message: 'Error while trying to send invitation related email', - ...options, - }); - this.errorCode = 'INVITATION_SEND_MAIL_ERROR'; - this.context = { - ...super.context, - emailAddress, - }; - } -} diff --git a/packages/nestjs-invitation/src/exceptions/invitation-user-undefined.exception.ts b/packages/nestjs-invitation/src/exceptions/invitation-user-undefined.exception.ts deleted file mode 100644 index 8230a46db..000000000 --- a/packages/nestjs-invitation/src/exceptions/invitation-user-undefined.exception.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { InvitationException } from './invitation.exception'; - -/** - * Generic invitation exception. - */ -export class InvitationUserUndefinedException extends InvitationException { - static errorMessage = - 'Cant receive a valid user from user user module. Check invitation and user module configuration'; - - constructor(options?: RuntimeExceptionOptions) { - super({ - message: InvitationUserUndefinedException.errorMessage, - ...options, - }); - this.errorCode = 'INVITATION_USER_UNDEFINED_ERROR'; - } -} diff --git a/packages/nestjs-invitation/src/gateways/exceptions/invitation-not-accepted.exception.ts b/packages/nestjs-invitation/src/gateways/exceptions/invitation-not-accepted.exception.ts new file mode 100644 index 000000000..157b7f2ac --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/exceptions/invitation-not-accepted.exception.ts @@ -0,0 +1,22 @@ +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { InvitationException } from '../../domain/exceptions/invitation.exception.js'; + +/** + * Thrown when an invitation acceptance fails. + * + * Defaults to `internal`/500: the unqualified constructor is used to wrap an + * unexpected error from the accept command. The caller-actionable case — + * `otpPort.consume()` finding no match, i.e. a wrong or expired passcode — + * is a distinct client mistake and overrides both fields at its throw site + * in `AcceptInvitationRequestHandler`. + */ +export class InvitationNotAcceptedException extends InvitationException { + constructor(options?: RuntimeExceptionOptions) { + super({ + fault: 'internal', + ...options, + }); + this.errorCode = 'INVITATION_NOT_ACCEPTED_ERROR'; + } +} diff --git a/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts new file mode 100644 index 000000000..6c3dfe141 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts @@ -0,0 +1,175 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Operation } from '@concepta/nestjs-core'; +import { CrudCqrsResolver, CrudModule } from '@concepta/nestjs-crud'; +import { + CreateOtpCommand, + ConsumeOtpCommand, + ClearOtpsCommand, + ValidateOtpQuery, + OtpModule, +} from '@concepta/nestjs-otp'; +import { + PasswordModule, + CreatePasswordCommand, + ValidateCurrentPasswordCommand, +} from '@concepta/nestjs-password'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; +import { + GetUserQuery, + GetUserByEmailQuery, + UserModule, +} from '@concepta/nestjs-user'; + +import { InvitationInterface } from '../../../../domain/interfaces/invitation.interface.js'; +import { invitationCreateSchema } from '../../../../infrastructure/schemas/invitation-create.schema.js'; +import { invitationPaginatedSchema } from '../../../../infrastructure/schemas/invitation-paginated.schema.js'; +import { invitationSchema } from '../../../../infrastructure/schemas/invitation.schema.js'; +import { INVITATION_MODULE_DEFAULT_ENTITY_KEY } from '../../../../invitation.constants.js'; +import { InvitationModule } from '../../../../invitation.module.js'; +import { AcceptInvitationRequestHandler } from '../../commands/handlers/accept-invitation-request.handler.js'; +import { CreateInvitationRequestHandler } from '../../commands/handlers/create-invitation-request.handler.js'; +import { DeleteInvitationRequestHandler } from '../../commands/handlers/delete-invitation-request.handler.js'; +import { CreateInvitationRequest } from '../../commands/impl/create-invitation.request.js'; +import { DeleteInvitationRequest } from '../../commands/impl/delete-invitation.request.js'; +import { ListInvitationsRequestHandler } from '../../queries/handlers/list-invitations-request.handler.js'; +import { ReadInvitationRequestHandler } from '../../queries/handlers/read-invitation-request.handler.js'; +import { ListInvitationsRequest } from '../../queries/impl/list-invitations.request.js'; +import { ReadInvitationRequest } from '../../queries/impl/read-invitation.request.js'; + +import { InvitationEntityFixture } from './entities/invitation.entity.fixture.js'; +import { UserCredentialEntityFixture } from './entities/user-credential.entity.fixture.js'; +import { UserOtpEntityFixture } from './entities/user-otp.entity.fixture.js'; +import { UserEntityFixture } from './entities/user.entity.fixture.js'; +import { InvitationAcceptanceController } from './invitation-acceptance.controller.js'; +import { + NoopSendInvitationNotificationCommand, + NoopSendAcceptedNotificationCommand, +} from './notification/noop-notification.command.js'; +import { + NoopSendInvitationNotificationHandler, + NoopSendAcceptedNotificationHandler, +} from './notification/noop-notification.handler.js'; + +const USER_ENTITY_KEY = 'user'; +const USER_CREDENTIALS_ENTITY_KEY = 'user-credentials'; +const USER_OTP_ENTITY_KEY = 'user-otp'; + +@Module({ + imports: [ + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [ + InvitationEntityFixture, + UserEntityFixture, + UserCredentialEntityFixture, + UserOtpEntityFixture, + ], + }), + CqrsModule.forRoot(), + RepositoryModule.forRoot({}), + CrudModule.forRoot({ + defaultResolver: CrudCqrsResolver, + }), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: INVITATION_MODULE_DEFAULT_ENTITY_KEY, + entity: InvitationEntityFixture, + }, + { key: USER_ENTITY_KEY, entity: UserEntityFixture }, + { + key: USER_CREDENTIALS_ENTITY_KEY, + entity: UserCredentialEntityFixture, + }, + { key: USER_OTP_ENTITY_KEY, entity: UserOtpEntityFixture }, + ], + }), + PasswordModule.forRoot({}), + OtpModule.forRoot({}), + OtpModule.forFeature([USER_OTP_ENTITY_KEY]), + UserModule.forRoot({ + entities: { + user: USER_ENTITY_KEY, + credentials: USER_CREDENTIALS_ENTITY_KEY, + }, + ports: { + password: { + createCommand: CreatePasswordCommand, + validateCurrentCommand: ValidateCurrentPasswordCommand, + }, + }, + }), + InvitationModule.registerAsync({ + useFactory: () => ({ + ports: { + otp: { + createCommand: CreateOtpCommand, + consumeCommand: ConsumeOtpCommand, + clearCommand: ClearOtpsCommand, + validateQuery: ValidateOtpQuery, + }, + user: { + getByIdQuery: GetUserQuery, + getByEmailQuery: GetUserByEmailQuery, + }, + notification: { + sendInvitationCommand: NoopSendInvitationNotificationCommand, + sendAcceptedCommand: NoopSendAcceptedNotificationCommand, + }, + }, + }), + }), + CrudModule.forFeature({ + crud: { + controller: { + entity: INVITATION_MODULE_DEFAULT_ENTITY_KEY, + path: 'invitation', + resolver: CrudCqrsResolver, + transactional: true, + request: { body: invitationCreateSchema }, + response: { + resource: invitationSchema, + paginated: invitationPaginatedSchema, + }, + }, + operations: [ + { + operation: Operation.List, + query: ListInvitationsRequest, + queryHandler: ListInvitationsRequestHandler, + }, + { + operation: Operation.Read, + query: ReadInvitationRequest, + queryHandler: ReadInvitationRequestHandler, + }, + { + operation: Operation.Create, + request: { body: invitationCreateSchema }, + command: CreateInvitationRequest, + commandHandler: CreateInvitationRequestHandler, + }, + { + operation: Operation.Delete, + command: DeleteInvitationRequest, + commandHandler: DeleteInvitationRequestHandler, + }, + ], + }, + }), + ], + providers: [ + NoopSendInvitationNotificationHandler, + NoopSendAcceptedNotificationHandler, + AcceptInvitationRequestHandler, + ], + controllers: [InvitationAcceptanceController], +}) +export class AppCrudModuleFixture {} diff --git a/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/invitation.entity.fixture.ts b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/invitation.entity.fixture.ts new file mode 100644 index 000000000..35a4ce341 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/invitation.entity.fixture.ts @@ -0,0 +1,6 @@ +import { Entity } from 'typeorm'; + +import { InvitationSqliteEntity } from '../../../../../infrastructure/persistence/typeorm/invitation-sqlite.entity.js'; + +@Entity() +export class InvitationEntityFixture extends InvitationSqliteEntity {} diff --git a/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/user-credential.entity.fixture.ts b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/user-credential.entity.fixture.ts new file mode 100644 index 000000000..004558744 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/user-credential.entity.fixture.ts @@ -0,0 +1,6 @@ +import { Entity } from 'typeorm'; + +import { UserCredentialSqliteEntity } from '@concepta/nestjs-user/optional/typeorm'; + +@Entity() +export class UserCredentialEntityFixture extends UserCredentialSqliteEntity {} diff --git a/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/user-otp.entity.fixture.ts b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/user-otp.entity.fixture.ts new file mode 100644 index 000000000..0012c9f66 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/user-otp.entity.fixture.ts @@ -0,0 +1,6 @@ +import { Entity } from 'typeorm'; + +import { OtpSqliteEntity } from '@concepta/nestjs-otp/optional/typeorm'; + +@Entity() +export class UserOtpEntityFixture extends OtpSqliteEntity {} diff --git a/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/user.entity.fixture.ts b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/user.entity.fixture.ts new file mode 100644 index 000000000..f3bed00f8 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/entities/user.entity.fixture.ts @@ -0,0 +1,6 @@ +import { Entity } from 'typeorm'; + +import { UserSqliteEntity } from '@concepta/nestjs-user/optional/typeorm'; + +@Entity() +export class UserEntityFixture extends UserSqliteEntity {} diff --git a/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/invitation-acceptance.controller.ts b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/invitation-acceptance.controller.ts new file mode 100644 index 000000000..0814d35d1 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/invitation-acceptance.controller.ts @@ -0,0 +1,42 @@ +import { CommandBus } from '@nestjs/cqrs'; + +import { Ctx } from '@concepta/nestjs-core'; +import { + CrudBody, + CrudContextInterface, + CrudController, + CrudCtx, + CrudUpdate, +} from '@concepta/nestjs-crud'; + +import { InvitationAcceptableInterface } from '../../../../domain/interfaces/invitation-acceptable.interface.js'; +import { invitationAcceptSchema } from '../../../../infrastructure/schemas/invitation-accept.schema.js'; +import { INVITATION_MODULE_DEFAULT_ENTITY_KEY } from '../../../../invitation.constants.js'; +import { AcceptInvitationRequestHandler } from '../../commands/handlers/accept-invitation-request.handler.js'; +import { AcceptInvitationRequest } from '../../commands/impl/accept-invitation.request.js'; + +@CrudController({ + path: 'invitation-acceptance', + entity: INVITATION_MODULE_DEFAULT_ENTITY_KEY, + request: { + params: { + code: { field: 'code', type: 'string' }, + }, + }, +}) +export class InvitationAcceptanceController { + constructor(private readonly commandBus: CommandBus) {} + + @CrudUpdate({ + path: ':code', + command: AcceptInvitationRequest, + commandHandler: AcceptInvitationRequestHandler, + request: { body: invitationAcceptSchema }, + }) + async acceptInvitation( + @Ctx(CrudCtx) context: CrudContextInterface, + @CrudBody() dto: InvitationAcceptableInterface, + ): Promise { + await this.commandBus.execute(new AcceptInvitationRequest(context, dto)); + } +} diff --git a/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/notification/noop-notification.command.ts b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/notification/noop-notification.command.ts new file mode 100644 index 000000000..ee29150d3 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/notification/noop-notification.command.ts @@ -0,0 +1,27 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type InvitationEventPayloadInterface } from '../../../../../domain/events/interfaces/invitation-event-payload.interface.js'; +import { + type SendAcceptedNotificationCommandInterface, + type SendInvitationNotificationCommandInterface, +} from '../../../../../domain/ports/invitation-notification.port.js'; + +export class NoopSendInvitationNotificationCommand implements SendInvitationNotificationCommandInterface { + constructor(params: SendInvitationNotificationCommandInterface) { + Object.assign(this, params); + } + + ctx!: PlainLiteralObject; + invitation!: InvitationEventPayloadInterface; + passcode!: string; + tokenExp!: Date; +} + +export class NoopSendAcceptedNotificationCommand implements SendAcceptedNotificationCommandInterface { + constructor(params: SendAcceptedNotificationCommandInterface) { + Object.assign(this, params); + } + + ctx!: PlainLiteralObject; + invitation!: InvitationEventPayloadInterface; +} diff --git a/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/notification/noop-notification.handler.ts b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/notification/noop-notification.handler.ts new file mode 100644 index 000000000..ced18a891 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/__tests__/fixtures/notification/noop-notification.handler.ts @@ -0,0 +1,20 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { + NoopSendInvitationNotificationCommand, + NoopSendAcceptedNotificationCommand, +} from './noop-notification.command.js'; + +@CommandHandler(NoopSendInvitationNotificationCommand) +export class NoopSendInvitationNotificationHandler implements ICommandHandler { + async execute(): Promise { + // noop — notification not actually sent in tests + } +} + +@CommandHandler(NoopSendAcceptedNotificationCommand) +export class NoopSendAcceptedNotificationHandler implements ICommandHandler { + async execute(): Promise { + // noop — notification not actually sent in tests + } +} diff --git a/packages/nestjs-invitation/src/gateways/http/__tests__/invitation.controller.e2e-spec.ts b/packages/nestjs-invitation/src/gateways/http/__tests__/invitation.controller.e2e-spec.ts new file mode 100644 index 000000000..946223c9f --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/__tests__/invitation.controller.e2e-spec.ts @@ -0,0 +1,241 @@ +import { randomUUID } from 'crypto'; + +import supertest from 'supertest'; + +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; + +import { SeedingSource } from '@concepta/typeorm-seeding'; + +import { type InvitationAcceptableInterface } from '../../../domain/interfaces/invitation-acceptable.interface.js'; +import { type InvitationCreatableInterface } from '../../../domain/interfaces/invitation-creatable.interface.js'; +import { InvitationOtpPort } from '../../../domain/ports/invitation-otp.port.js'; +import { type InvitationEntityInterface } from '../../../infrastructure/persistence/interfaces/invitation-entity.interface.js'; +import { InvitationFactory } from '../../../seeding/invitation.factory.js'; + +import { AppCrudModuleFixture } from './fixtures/app-crud.module.fixture.js'; +import { InvitationEntityFixture } from './fixtures/entities/invitation.entity.fixture.js'; +import { UserEntityFixture } from './fixtures/entities/user.entity.fixture.js'; + +describe('InvitationController (e2e)', () => { + const userCategory = 'user'; + const orgCategory = 'org'; + const constraints = { moreData: 'foo' }; + + let app: INestApplication; + let invitationFactory: InvitationFactory; + let seedingSource: SeedingSource; + let user: UserEntityFixture; + let otpPort: InvitationOtpPort; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppCrudModuleFixture], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + + otpPort = moduleFixture.get(InvitationOtpPort); + + seedingSource = new SeedingSource({ + dataSource: moduleFixture.get(getDataSourceToken()), + }); + + await seedingSource.initialize(); + + invitationFactory = new InvitationFactory({ + entity: InvitationEntityFixture, + seedingSource, + }); + + // Seed a user directly via TypeORM + const dataSource = moduleFixture.get(getDataSourceToken()); + const userRepo = dataSource.getRepository(UserEntityFixture); + user = await userRepo.save( + userRepo.create({ + email: `test-${Date.now()}@example.com`, + username: `testuser-${Date.now()}`, + active: true, + }), + ); + }); + + afterEach(async () => { + vi.clearAllMocks(); + if (app) await app.close(); + }); + + describe('Type: org', () => { + let invitation: InvitationEntityInterface; + + beforeEach(async () => { + invitation = await invitationFactory.create({ + category: orgCategory, + userId: user.id, + }); + }); + + it('POST /invitation', async () => { + await createInvitation(app, { + category: orgCategory, + userId: user.id, + code: randomUUID(), + constraints, + }); + }); + + it('PATCH /invitation-acceptance/:code', async () => { + const { code } = invitation; + const otp = await otpPort.create({}, orgCategory, user.id); + + const body: InvitationAcceptableInterface = { + passcode: otp.passcode, + payload: { newPassword: 'hOdv2A2h%' }, + }; + + await supertest(app.getHttpServer()) + .patch(`/invitation-acceptance/${code}`) + .send(body) + .expect(200); + }); + + it('PATCH /invitation-acceptance/:code (wrong passcode is a 400)', async () => { + const { code } = invitation; + await otpPort.create({}, orgCategory, user.id); + + const body: InvitationAcceptableInterface = { + passcode: 'wrong-passcode', + payload: { newPassword: 'hOdv2A2h%' }, + }; + + await supertest(app.getHttpServer()) + .patch(`/invitation-acceptance/${code}`) + .send(body) + .expect(400); + }); + }); + + describe('Type: user', () => { + let invitation: InvitationEntityInterface; + + beforeEach(async () => { + invitation = await invitationFactory.create({ + category: userCategory, + userId: user.id, + }); + }); + + it('POST /invitation', async () => { + await createInvitation(app, { + category: userCategory, + userId: user.id, + code: randomUUID(), + constraints, + }); + }); + + it('PATCH /invitation-acceptance/:code', async () => { + const { code } = invitation; + const otp = await otpPort.create({}, userCategory, user.id); + + const body: InvitationAcceptableInterface = { + passcode: otp.passcode, + payload: { newPassword: 'hOdv2A2h%' }, + }; + + await supertest(app.getHttpServer()) + .patch(`/invitation-acceptance/${code}`) + .send(body) + .expect(200); + }); + + // Regression: the acceptance controller uses a bare `@CrudBody()` and + // relies on the operation decorator's `request.body` schema to wire + // validation — an invalid payload must 400, not reach the handler. + it('PATCH /invitation-acceptance/:code (invalid body is rejected)', async () => { + const { code } = invitation; + + const response = await supertest(app.getHttpServer()) + .patch(`/invitation-acceptance/${code}`) + .send({ payload: { newPassword: 'hOdv2A2h%' } }) + .expect(400); + + expect(response.body.message).toEqual([ + expect.stringContaining('passcode'), + ]); + }); + + it('GET /invitation', async () => { + const response = await supertest(app.getHttpServer()) + .get('/invitation') + .expect(200); + + const invitationResponse: InvitationEntityInterface[] = + response.body.data; + + expect(invitationResponse.length).toEqual(1); + }); + + // regression: the seeded invitation never sets `constraints`, so the + // persisted (nullable) column reads back as `null`, not `undefined` — + // the response schema must accept `null` here or the fail-closed + // serializer 500s on every list/read of a constraints-less invitation. + it('GET /invitation (constraints column is null, not undefined)', async () => { + const response = await supertest(app.getHttpServer()) + .get('/invitation') + .expect(200); + + const invitationResponse: InvitationEntityInterface[] = + response.body.data; + + expect(invitationResponse[0]?.constraints).toBeNull(); + }); + + it('GET /invitation/:id', async () => { + const created = await createInvitation(app, { + category: userCategory, + userId: user.id, + code: randomUUID(), + constraints, + }); + + const response = await supertest(app.getHttpServer()) + .get(`/invitation/${created.id}`) + .expect(200); + + const invitationResponse: InvitationEntityInterface = response.body; + expect(invitationResponse.category).toEqual(userCategory); + }); + + it('DELETE /invitation/:id', async () => { + const created = await createInvitation(app, { + category: userCategory, + userId: user.id, + code: randomUUID(), + constraints, + }); + + await supertest(app.getHttpServer()) + .delete(`/invitation/${created.id}`) + .expect(204); + + await supertest(app.getHttpServer()) + .get(`/invitation/${created.id}`) + .expect(404); + }); + }); +}); + +const createInvitation = async ( + app: INestApplication, + dto: InvitationCreatableInterface, +): Promise => { + const response = await supertest(app.getHttpServer()) + .post('/invitation') + .send(dto) + .expect(201); + + const invitation: InvitationEntityInterface = response.body; + return invitation; +}; diff --git a/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/accept-invitation-request.handler.spec.ts b/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/accept-invitation-request.handler.spec.ts new file mode 100644 index 000000000..9b8599221 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/accept-invitation-request.handler.spec.ts @@ -0,0 +1,140 @@ +import { HttpStatus } from '@nestjs/common'; + +import { createMockCommandBus } from '@concepta/nestjs-core/testing'; + +import { + createMockInvitationEntity, + toInvitationDomain, +} from '../../../../../__tests__/helpers/mock.helpers.js'; +import { InvitationNotFoundException } from '../../../../../application/exceptions/invitation-not-found.exception.js'; +import { type InvitationAcceptableInterface } from '../../../../../domain/interfaces/invitation-acceptable.interface.js'; +import { InvitationNotAcceptedException } from '../../../../exceptions/invitation-not-accepted.exception.js'; +import { AcceptInvitationRequest } from '../../impl/accept-invitation.request.js'; +import { AcceptInvitationRequestHandler } from '../accept-invitation-request.handler.js'; + +describe(AcceptInvitationRequestHandler.name, () => { + let commandBus: ReturnType; + let handler: AcceptInvitationRequestHandler; + + beforeEach(() => { + commandBus = createMockCommandBus(); + handler = new AcceptInvitationRequestHandler(commandBus as never); + }); + + it('should not throw when acceptance succeeds', async () => { + const invitation = toInvitationDomain(createMockInvitationEntity()); + commandBus.execute.mockResolvedValue(invitation); + + const context = { + entity: 'invitation', + params: { code: 'test-code' }, + } as never; + const dto: InvitationAcceptableInterface = { + passcode: 'test-passcode', + payload: { newPassword: 'secret123' }, + }; + + await expect( + handler.execute(new AcceptInvitationRequest(context, dto)), + ).resolves.not.toThrow(); + + expect(commandBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw InvitationNotAcceptedException when acceptance returns null', async () => { + commandBus.execute.mockResolvedValue(null); + + const context = { + entity: 'invitation', + params: { code: 'test-code' }, + } as never; + const dto: InvitationAcceptableInterface = { + passcode: 'wrong-passcode', + }; + + await expect( + handler.execute(new AcceptInvitationRequest(context, dto)), + ).rejects.toThrow(InvitationNotAcceptedException); + }); + + it('should throw a client-fault BAD_REQUEST when acceptance returns null', async () => { + commandBus.execute.mockResolvedValue(null); + + const context = { + entity: 'invitation', + params: { code: 'test-code' }, + } as never; + const dto: InvitationAcceptableInterface = { + passcode: 'wrong-passcode', + }; + + try { + await handler.execute(new AcceptInvitationRequest(context, dto)); + throw new Error('Expected InvitationNotAcceptedException'); + } catch (e) { + expect(e).toBeInstanceOf(InvitationNotAcceptedException); + expect((e as InvitationNotAcceptedException).httpStatus).toBe( + HttpStatus.BAD_REQUEST, + ); + expect((e as InvitationNotAcceptedException).fault).toBe('client'); + } + }); + + it('should throw InvitationNotAcceptedException with originalError when command throws', async () => { + const originalError = new Error('domain error'); + commandBus.execute.mockRejectedValue(originalError); + + const context = { + entity: 'invitation', + params: { code: 'test-code' }, + } as never; + const dto: InvitationAcceptableInterface = { + passcode: 'test-passcode', + }; + + await expect( + handler.execute(new AcceptInvitationRequest(context, dto)), + ).rejects.toThrow(InvitationNotAcceptedException); + }); + + it('should throw an internal-fault 500 when the command throws an unexpected error', async () => { + commandBus.execute.mockRejectedValue(new Error('domain error')); + + const context = { + entity: 'invitation', + params: { code: 'test-code' }, + } as never; + const dto: InvitationAcceptableInterface = { + passcode: 'test-passcode', + }; + + try { + await handler.execute(new AcceptInvitationRequest(context, dto)); + throw new Error('Expected InvitationNotAcceptedException'); + } catch (e) { + expect(e).toBeInstanceOf(InvitationNotAcceptedException); + expect((e as InvitationNotAcceptedException).httpStatus).toBe( + HttpStatus.INTERNAL_SERVER_ERROR, + ); + expect((e as InvitationNotAcceptedException).fault).toBe('internal'); + } + }); + + it('should propagate an HttpException thrown by the command unchanged', async () => { + commandBus.execute.mockRejectedValue( + new InvitationNotFoundException('test-code'), + ); + + const context = { + entity: 'invitation', + params: { code: 'test-code' }, + } as never; + const dto: InvitationAcceptableInterface = { + passcode: 'test-passcode', + }; + + await expect( + handler.execute(new AcceptInvitationRequest(context, dto)), + ).rejects.toThrow(InvitationNotFoundException); + }); +}); diff --git a/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/create-invitation-by-email-request.handler.spec.ts b/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/create-invitation-by-email-request.handler.spec.ts new file mode 100644 index 000000000..277c12adc --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/create-invitation-by-email-request.handler.spec.ts @@ -0,0 +1,41 @@ +import { + createMockCommandBus, + createMockInvitationEntity, + toInvitationDomain, +} from '../../../../../__tests__/helpers/mock.helpers.js'; +import { CreateInvitationByEmailCommand } from '../../../../../application/commands/impl/create-invitation-by-email.command.js'; +import { CreateInvitationByEmailRequest } from '../../impl/create-invitation-by-email.request.js'; +import { CreateInvitationByEmailRequestHandler } from '../create-invitation-by-email-request.handler.js'; + +describe(CreateInvitationByEmailRequestHandler.name, () => { + let commandBus: ReturnType; + let handler: CreateInvitationByEmailRequestHandler; + + beforeEach(() => { + commandBus = createMockCommandBus(); + handler = new CreateInvitationByEmailRequestHandler(commandBus as never); + }); + + it('should return a plain object from toPlain()', async () => { + const entity = createMockInvitationEntity(); + commandBus.execute.mockResolvedValue(toInvitationDomain(entity)); + + const context = { entity: 'invitation' } as never; + const dto = { + email: 'test@example.com', + category: 'user', + constraints: { role: 'admin' }, + }; + + const result = await handler.execute( + new CreateInvitationByEmailRequest(context, dto), + ); + + expect(result.id).toBe('test-id'); + expect(result.code).toBe('test-code'); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(CreateInvitationByEmailCommand), + ); + }); +}); diff --git a/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/create-invitation-request.handler.spec.ts b/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/create-invitation-request.handler.spec.ts new file mode 100644 index 000000000..2d9336e01 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/create-invitation-request.handler.spec.ts @@ -0,0 +1,42 @@ +import { + createMockCommandBus, + createMockInvitationEntity, + toInvitationDomain, +} from '../../../../../__tests__/helpers/mock.helpers.js'; +import { CreateInvitationCommand } from '../../../../../application/commands/impl/create-invitation.command.js'; +import { type InvitationCreatableInterface } from '../../../../../domain/interfaces/invitation-creatable.interface.js'; +import { CreateInvitationRequest } from '../../impl/create-invitation.request.js'; +import { CreateInvitationRequestHandler } from '../create-invitation-request.handler.js'; + +describe(CreateInvitationRequestHandler.name, () => { + let commandBus: ReturnType; + let handler: CreateInvitationRequestHandler; + + beforeEach(() => { + commandBus = createMockCommandBus(); + handler = new CreateInvitationRequestHandler(commandBus as never); + }); + + it('should return a plain object from toPlain()', async () => { + const entity = createMockInvitationEntity(); + commandBus.execute.mockResolvedValue(toInvitationDomain(entity)); + + const context = { entity: 'Invitation' } as never; + const dto: InvitationCreatableInterface = { + code: 'test-code', + category: 'user', + userId: 'test-user-id', + }; + + const result = await handler.execute( + new CreateInvitationRequest(context, dto), + ); + + expect(result.id).toBe('test-id'); + expect(result.code).toBe('test-code'); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(CreateInvitationCommand), + ); + }); +}); diff --git a/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/delete-invitation-request.handler.spec.ts b/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/delete-invitation-request.handler.spec.ts new file mode 100644 index 000000000..76422fa82 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/delete-invitation-request.handler.spec.ts @@ -0,0 +1,76 @@ +import { Operation } from '@concepta/nestjs-core'; + +import { + createMockCommandBus, + createMockInvitationEntity, + toInvitationDomain, +} from '../../../../../__tests__/helpers/mock.helpers.js'; +import { RemoveInvitationCommand } from '../../../../../application/commands/impl/remove-invitation.command.js'; +import { DeleteInvitationRequest } from '../../impl/delete-invitation.request.js'; +import { DeleteInvitationRequestHandler } from '../delete-invitation-request.handler.js'; + +describe(DeleteInvitationRequestHandler.name, () => { + let commandBus: ReturnType; + let handler: DeleteInvitationRequestHandler; + + beforeEach(() => { + commandBus = createMockCommandBus(); + handler = new DeleteInvitationRequestHandler(commandBus as never); + }); + + it('should return null when returnDeleted is false', async () => { + commandBus.execute.mockResolvedValue( + toInvitationDomain(createMockInvitationEntity()), + ); + + const context = { + entity: 'Invitation', + params: { id: 'test-id' }, + operation: Operation.Delete, + options: { route: { returnDeleted: false } }, + } as never; + + const result = await handler.execute(new DeleteInvitationRequest(context)); + + expect(result).toBeNull(); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(RemoveInvitationCommand), + ); + }); + + it('should return plain object when returnDeleted is true', async () => { + commandBus.execute.mockResolvedValue( + toInvitationDomain(createMockInvitationEntity()), + ); + + const context = { + entity: 'Invitation', + params: { id: 'test-id' }, + operation: Operation.Delete, + options: { route: { returnDeleted: true } }, + } as never; + + const result = await handler.execute(new DeleteInvitationRequest(context)); + + expect(result).not.toBeNull(); + expect(result!.id).toBe('test-id'); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + expect(commandBus.execute).toHaveBeenCalledWith( + expect.any(RemoveInvitationCommand), + ); + }); + + it('should throw when id is not a string', async () => { + const context = { + entity: 'Invitation', + params: { id: 42 }, + operation: Operation.Delete, + options: { route: { returnDeleted: false } }, + } as never; + + await expect( + handler.execute(new DeleteInvitationRequest(context)), + ).rejects.toThrow(); + }); +}); diff --git a/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/send-invitation-request.handler.spec.ts b/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/send-invitation-request.handler.spec.ts new file mode 100644 index 000000000..c58a686e4 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/handlers/__tests__/send-invitation-request.handler.spec.ts @@ -0,0 +1,27 @@ +import { createMockCommandBus } from '@concepta/nestjs-core/testing'; + +import { SendInvitationRequest } from '../../impl/send-invitation.request.js'; +import { SendInvitationRequestHandler } from '../send-invitation-request.handler.js'; + +describe(SendInvitationRequestHandler.name, () => { + let commandBus: ReturnType; + let handler: SendInvitationRequestHandler; + + beforeEach(() => { + commandBus = createMockCommandBus(); + handler = new SendInvitationRequestHandler(commandBus as never); + }); + + it('should dispatch SendInvitationCommand with the invitation id', async () => { + commandBus.execute.mockResolvedValue(undefined); + + const context = { + entity: 'invitation', + params: { id: 'test-id' }, + } as never; + + await handler.execute(new SendInvitationRequest(context)); + + expect(commandBus.execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nestjs-invitation/src/gateways/http/commands/handlers/accept-invitation-request.handler.ts b/packages/nestjs-invitation/src/gateways/http/commands/handlers/accept-invitation-request.handler.ts new file mode 100644 index 000000000..da7e3e83d --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/handlers/accept-invitation-request.handler.ts @@ -0,0 +1,47 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { AcceptInvitationCommand } from '../../../../application/commands/impl/accept-invitation.command.js'; +import { assertInvitationCode } from '../../../../application/utils/assert-invitation-code.util.js'; +import { Invitation } from '../../../../domain/aggregates/invitation.js'; +import { InvitationNotAcceptedException } from '../../../exceptions/invitation-not-accepted.exception.js'; +import { AcceptInvitationRequest } from '../impl/accept-invitation.request.js'; + +@CommandHandler(AcceptInvitationRequest) +export class AcceptInvitationRequestHandler implements ICommandHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: AcceptInvitationRequest): Promise { + const { context, dto } = command; + const { code } = context.params; + + assertInvitationCode(code); + + let invitation: Invitation | null = null; + + try { + invitation = await this.commandBus.execute( + new AcceptInvitationCommand(context, code, dto), + ); + } catch (e: unknown) { + if (e instanceof HttpException) { + throw e; + } + + throw new InvitationNotAcceptedException({ + originalError: e, + }); + } + + if (!invitation) { + // otpPort.consume() found no match: a wrong or expired passcode. + throw new InvitationNotAcceptedException({ + safeMessage: 'Invitation could not be accepted', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } + + return null; + } +} diff --git a/packages/nestjs-invitation/src/gateways/http/commands/handlers/create-invitation-by-email-request.handler.ts b/packages/nestjs-invitation/src/gateways/http/commands/handlers/create-invitation-by-email-request.handler.ts new file mode 100644 index 000000000..02ba12d24 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/handlers/create-invitation-by-email-request.handler.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { CreateInvitationByEmailCommand } from '../../../../application/commands/impl/create-invitation-by-email.command.js'; +import { Invitation } from '../../../../domain/aggregates/invitation.js'; +import { CreateInvitationByEmailRequest } from '../impl/create-invitation-by-email.request.js'; + +@Injectable() +export class CreateInvitationByEmailRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: CreateInvitationByEmailRequest) { + const { context, dto } = command; + + const invitation = await this.commandBus.execute< + CreateInvitationByEmailCommand, + Invitation + >(new CreateInvitationByEmailCommand(context, dto)); + + return invitation.toPlain(); + } +} diff --git a/packages/nestjs-invitation/src/gateways/http/commands/handlers/create-invitation-request.handler.ts b/packages/nestjs-invitation/src/gateways/http/commands/handlers/create-invitation-request.handler.ts new file mode 100644 index 000000000..1b48f9cce --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/handlers/create-invitation-request.handler.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { CreateInvitationCommand } from '../../../../application/commands/impl/create-invitation.command.js'; +import { CreateInvitationRequest } from '../impl/create-invitation.request.js'; + +@Injectable() +export class CreateInvitationRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: CreateInvitationRequest) { + const { context, dto } = command; + + const invitation = await this.commandBus.execute( + new CreateInvitationCommand(context, dto), + ); + + return invitation.toPlain(); + } +} diff --git a/packages/nestjs-invitation/src/gateways/http/commands/handlers/delete-invitation-request.handler.ts b/packages/nestjs-invitation/src/gateways/http/commands/handlers/delete-invitation-request.handler.ts new file mode 100644 index 000000000..ae2ea0eb9 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/handlers/delete-invitation-request.handler.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { RemoveInvitationCommand } from '../../../../application/commands/impl/remove-invitation.command.js'; +import { assertInvitationId } from '../../../../application/utils/assert-invitation-id.util.js'; +import { DeleteInvitationRequest } from '../impl/delete-invitation.request.js'; + +@Injectable() +export class DeleteInvitationRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: DeleteInvitationRequest) { + const { context } = command; + const { id } = context.params; + const { returnDeleted = false } = context.options?.route ?? {}; + + assertInvitationId(id); + + // Invitations are always hard-deleted (no archive/soft-delete) + const invitation = await this.commandBus.execute( + new RemoveInvitationCommand(context, id), + ); + + return returnDeleted ? invitation.toPlain() : null; + } +} diff --git a/packages/nestjs-invitation/src/gateways/http/commands/handlers/send-invitation-request.handler.ts b/packages/nestjs-invitation/src/gateways/http/commands/handlers/send-invitation-request.handler.ts new file mode 100644 index 000000000..15ccad597 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/handlers/send-invitation-request.handler.ts @@ -0,0 +1,19 @@ +import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { SendInvitationCommand } from '../../../../application/commands/impl/send-invitation.command.js'; +import { assertInvitationId } from '../../../../application/utils/assert-invitation-id.util.js'; +import { SendInvitationRequest } from '../impl/send-invitation.request.js'; + +@CommandHandler(SendInvitationRequest) +export class SendInvitationRequestHandler implements ICommandHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: SendInvitationRequest): Promise { + const { context } = command; + const { id } = context.params; + + assertInvitationId(id); + + await this.commandBus.execute(new SendInvitationCommand(context, id)); + } +} diff --git a/packages/nestjs-invitation/src/gateways/http/commands/impl/accept-invitation.request.ts b/packages/nestjs-invitation/src/gateways/http/commands/impl/accept-invitation.request.ts new file mode 100644 index 000000000..53088ad4d --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/impl/accept-invitation.request.ts @@ -0,0 +1,8 @@ +import { CrudUpdateCommand } from '@concepta/nestjs-crud'; + +import { type InvitationAcceptableInterface } from '../../../../domain/interfaces/invitation-acceptable.interface.js'; + +export class AcceptInvitationRequest extends CrudUpdateCommand< + InvitationAcceptableInterface, + InvitationAcceptableInterface +> {} diff --git a/packages/nestjs-invitation/src/gateways/http/commands/impl/create-invitation-by-email.request.ts b/packages/nestjs-invitation/src/gateways/http/commands/impl/create-invitation-by-email.request.ts new file mode 100644 index 000000000..51eb7ce05 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/impl/create-invitation-by-email.request.ts @@ -0,0 +1,9 @@ +import { CrudCreateCommand } from '@concepta/nestjs-crud'; + +import { type InvitationCreatableByEmailInterface } from '../../../../domain/interfaces/invitation-creatable-by-email.interface.js'; +import { type InvitationInterface } from '../../../../domain/interfaces/invitation.interface.js'; + +export class CreateInvitationByEmailRequest extends CrudCreateCommand< + InvitationInterface, + InvitationCreatableByEmailInterface +> {} diff --git a/packages/nestjs-invitation/src/gateways/http/commands/impl/create-invitation.request.ts b/packages/nestjs-invitation/src/gateways/http/commands/impl/create-invitation.request.ts new file mode 100644 index 000000000..91cbc9402 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/impl/create-invitation.request.ts @@ -0,0 +1,9 @@ +import { CrudCreateCommand } from '@concepta/nestjs-crud'; + +import { type InvitationCreatableInterface } from '../../../../domain/interfaces/invitation-creatable.interface.js'; +import { type InvitationInterface } from '../../../../domain/interfaces/invitation.interface.js'; + +export class CreateInvitationRequest extends CrudCreateCommand< + InvitationInterface, + InvitationCreatableInterface +> {} diff --git a/packages/nestjs-invitation/src/gateways/http/commands/impl/delete-invitation.request.ts b/packages/nestjs-invitation/src/gateways/http/commands/impl/delete-invitation.request.ts new file mode 100644 index 000000000..4a3a5f7c7 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/impl/delete-invitation.request.ts @@ -0,0 +1,5 @@ +import { CrudDeleteCommand } from '@concepta/nestjs-crud'; + +import { type InvitationInterface } from '../../../../domain/interfaces/invitation.interface.js'; + +export class DeleteInvitationRequest extends CrudDeleteCommand {} diff --git a/packages/nestjs-invitation/src/gateways/http/commands/impl/send-invitation.request.ts b/packages/nestjs-invitation/src/gateways/http/commands/impl/send-invitation.request.ts new file mode 100644 index 000000000..6e117ef52 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/commands/impl/send-invitation.request.ts @@ -0,0 +1,9 @@ +import { Command } from '@nestjs/cqrs'; + +import { type CrudContextInterface } from '@concepta/nestjs-crud'; + +export class SendInvitationRequest extends Command { + constructor(public readonly context: CrudContextInterface) { + super(); + } +} diff --git a/packages/nestjs-invitation/src/gateways/http/queries/handlers/list-invitations-request.handler.ts b/packages/nestjs-invitation/src/gateways/http/queries/handlers/list-invitations-request.handler.ts new file mode 100644 index 000000000..7efa14ba8 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/queries/handlers/list-invitations-request.handler.ts @@ -0,0 +1,5 @@ +import { CrudListHandler } from '@concepta/nestjs-crud'; + +import { type InvitationInterface } from '../../../../domain/interfaces/invitation.interface.js'; + +export class ListInvitationsRequestHandler extends CrudListHandler {} diff --git a/packages/nestjs-invitation/src/gateways/http/queries/handlers/read-invitation-request.handler.ts b/packages/nestjs-invitation/src/gateways/http/queries/handlers/read-invitation-request.handler.ts new file mode 100644 index 000000000..7c792b9c6 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/queries/handlers/read-invitation-request.handler.ts @@ -0,0 +1,5 @@ +import { CrudReadHandler } from '@concepta/nestjs-crud'; + +import { type InvitationInterface } from '../../../../domain/interfaces/invitation.interface.js'; + +export class ReadInvitationRequestHandler extends CrudReadHandler {} diff --git a/packages/nestjs-invitation/src/gateways/http/queries/impl/list-invitations.request.ts b/packages/nestjs-invitation/src/gateways/http/queries/impl/list-invitations.request.ts new file mode 100644 index 000000000..a53fe9d11 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/queries/impl/list-invitations.request.ts @@ -0,0 +1,5 @@ +import { CrudListQuery } from '@concepta/nestjs-crud'; + +import { type InvitationInterface } from '../../../../domain/interfaces/invitation.interface.js'; + +export class ListInvitationsRequest extends CrudListQuery {} diff --git a/packages/nestjs-invitation/src/gateways/http/queries/impl/read-invitation.request.ts b/packages/nestjs-invitation/src/gateways/http/queries/impl/read-invitation.request.ts new file mode 100644 index 000000000..b85133478 --- /dev/null +++ b/packages/nestjs-invitation/src/gateways/http/queries/impl/read-invitation.request.ts @@ -0,0 +1,5 @@ +import { CrudReadQuery } from '@concepta/nestjs-crud'; + +import { type InvitationInterface } from '../../../../domain/interfaces/invitation.interface.js'; + +export class ReadInvitationRequest extends CrudReadQuery {} diff --git a/packages/nestjs-invitation/src/index.ts b/packages/nestjs-invitation/src/index.ts index a54f88381..314018d7d 100644 --- a/packages/nestjs-invitation/src/index.ts +++ b/packages/nestjs-invitation/src/index.ts @@ -1,20 +1,100 @@ -export { InvitationModule } from './invitation.module'; -export { InvitationService } from './services/invitation.service'; -export { InvitationAcceptedEventAsync } from './events/invitation-accepted.event'; +export { InvitationModule } from './invitation.module.js'; -export { InvitationCreateInviteInterface } from './interfaces/domain/invitation-create-invite.interface'; -export { InvitationServiceInterface } from './interfaces/services/invitation-service.interface'; -export { InvitationSendServiceInterface } from './interfaces/services/invitation-send-service.interface'; -export { InvitationSendInvitationEmailOptionsInterface } from './interfaces/options/invitation-send-invitation-email-options.interface'; +// aggregate +export { Invitation } from './domain/aggregates/invitation.js'; -export { InvitationModelService } from './services/invitation-model.service'; -export { InvitationAcceptOptionsInterface } from './interfaces/options/invitation-accept-options.interface'; +// commands +export { CreateInvitationCommand } from './application/commands/impl/create-invitation.command.js'; +export { CreateInvitationByEmailCommand } from './application/commands/impl/create-invitation-by-email.command.js'; +export { SendInvitationCommand } from './application/commands/impl/send-invitation.command.js'; +export { AcceptInvitationCommand } from './application/commands/impl/accept-invitation.command.js'; +export { RevokeInvitationsCommand } from './application/commands/impl/revoke-invitations.command.js'; +export { RemoveInvitationCommand } from './application/commands/impl/remove-invitation.command.js'; + +// queries +export { GetInvitationQuery } from './application/queries/impl/get-invitation.query.js'; +export { FindInvitationByCodeQuery } from './application/queries/impl/find-invitation-by-code.query.js'; + +// events +export { InvitationCreatedEvent } from './domain/events/invitation-created.event.js'; +export { InvitationRemovedEvent } from './domain/events/invitation-removed.event.js'; +export { InvitationRevokedEvent } from './domain/events/invitation-revoked.event.js'; +export { InvitationAcceptedEvent } from './domain/events/invitation-accepted.event.js'; +export { InvitationDispatchedEvent } from './domain/events/invitation-dispatched.event.js'; +export { InvitationDispatchedMetadataInterface } from './domain/events/interfaces/invitation-dispatched-metadata.interface.js'; +export { InvitationEventPayloadInterface } from './domain/events/interfaces/invitation-event-payload.interface.js'; + +// policies +export { InvitationOtpPolicy } from './domain/policies/invitation-otp.policy.js'; + +// ports +export { InvitationOtpPort } from './domain/ports/invitation-otp.port.js'; +export { + InvitationOtpPortSettings, + CreateOtpCommandInterface, + ConsumeOtpCommandInterface, + ClearOtpsCommandInterface, + ValidateOtpQueryInterface, +} from './domain/ports/invitation-otp.port.js'; +export { InvitationUserPort } from './domain/ports/invitation-user.port.js'; +export { + InvitationUserPortSettings, + GetUserByIdQueryInterface, + GetUserByEmailQueryInterface, + InvitationUserResult, +} from './domain/ports/invitation-user.port.js'; +export { InvitationNotificationPort } from './domain/ports/invitation-notification.port.js'; +export { + InvitationNotificationPortSettings, + SendInvitationNotificationCommandInterface, + SendAcceptedNotificationCommandInterface, +} from './domain/ports/invitation-notification.port.js'; +export { InvitationPortsInterface } from './interfaces/options/invitation-options.interface.js'; + +// repository +export { InvitationRepository } from './infrastructure/persistence/invitation.repository.js'; +export { InvitationMapper } from './infrastructure/persistence/invitation.mapper.js'; +export { InvitationEntityInterface } from './infrastructure/persistence/interfaces/invitation-entity.interface.js'; + +// domain interfaces +export { InvitationInterface } from './domain/interfaces/invitation.interface.js'; +export { InvitationUserInterface } from './domain/interfaces/invitation-user.interface.js'; +export { InvitationCreatableInterface } from './domain/interfaces/invitation-creatable.interface.js'; +export { InvitationCreatableByEmailInterface } from './domain/interfaces/invitation-creatable-by-email.interface.js'; +export { InvitationAcceptableInterface } from './domain/interfaces/invitation-acceptable.interface.js'; +export { InvitationOtpSettingsInterface } from './domain/interfaces/invitation-otp-settings.interface.js'; +export { InvitationSettingsInterface } from './interfaces/options/invitation-settings.interface.js'; +export { InvitationOptionsInterface } from './interfaces/options/invitation-options.interface.js'; + +// schemas (Zod / Standard Schema) +export { invitationSchema } from './infrastructure/schemas/invitation.schema.js'; +export { invitationPaginatedSchema } from './infrastructure/schemas/invitation-paginated.schema.js'; +export { invitationCreateSchema } from './infrastructure/schemas/invitation-create.schema.js'; +export { invitationCreateByEmailSchema } from './infrastructure/schemas/invitation-create-by-email.schema.js'; +export { invitationAcceptSchema } from './infrastructure/schemas/invitation-accept.schema.js'; // exceptions -export { InvitationException } from './exceptions/invitation.exception'; -export { InvitationUserUndefinedException } from './exceptions/invitation-user-undefined.exception'; -export { InvitationNotFoundException } from './exceptions/invitation-not-found.exception'; -export { InvitationNotAcceptedException } from './exceptions/invitation-not-accepted.exception'; +export { InvitationException } from './domain/exceptions/invitation.exception.js'; +export { InvitationAlreadyAcceptedException } from './domain/exceptions/invitation-already-accepted.exception.js'; +export { InvitationRevokedException } from './domain/exceptions/invitation-revoked.exception.js'; +export { InvitationUserUndefinedException } from './application/exceptions/invitation-user-undefined.exception.js'; +export { InvitationNotAcceptedException } from './gateways/exceptions/invitation-not-accepted.exception.js'; +export { InvitationNotFoundException } from './application/exceptions/invitation-not-found.exception.js'; + +// gateway commands +export { CreateInvitationRequest } from './gateways/http/commands/impl/create-invitation.request.js'; +export { CreateInvitationRequestHandler } from './gateways/http/commands/handlers/create-invitation-request.handler.js'; +export { DeleteInvitationRequest } from './gateways/http/commands/impl/delete-invitation.request.js'; +export { DeleteInvitationRequestHandler } from './gateways/http/commands/handlers/delete-invitation-request.handler.js'; +export { AcceptInvitationRequest } from './gateways/http/commands/impl/accept-invitation.request.js'; +export { AcceptInvitationRequestHandler } from './gateways/http/commands/handlers/accept-invitation-request.handler.js'; +export { SendInvitationRequest } from './gateways/http/commands/impl/send-invitation.request.js'; +export { SendInvitationRequestHandler } from './gateways/http/commands/handlers/send-invitation-request.handler.js'; +export { CreateInvitationByEmailRequest } from './gateways/http/commands/impl/create-invitation-by-email.request.js'; +export { CreateInvitationByEmailRequestHandler } from './gateways/http/commands/handlers/create-invitation-by-email-request.handler.js'; -export { InvitationMissingEntitiesOptionsException } from './exceptions/invitation-missing-entities-options.exception'; -export { InvitationSendMailException } from './exceptions/invitation-send-mail.exception'; +// gateway queries +export { ListInvitationsRequest } from './gateways/http/queries/impl/list-invitations.request.js'; +export { ListInvitationsRequestHandler } from './gateways/http/queries/handlers/list-invitations-request.handler.js'; +export { ReadInvitationRequest } from './gateways/http/queries/impl/read-invitation.request.js'; +export { ReadInvitationRequestHandler } from './gateways/http/queries/handlers/read-invitation-request.handler.js'; diff --git a/packages/nestjs-invitation/src/infrastructure/persistence/interfaces/invitation-entity.interface.ts b/packages/nestjs-invitation/src/infrastructure/persistence/interfaces/invitation-entity.interface.ts new file mode 100644 index 000000000..73b6c9e6a --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/persistence/interfaces/invitation-entity.interface.ts @@ -0,0 +1,16 @@ +import { + type AuditInterface, + type ReferenceActiveInterface, + type ReferenceIdInterface, + type ReferenceVersionInterface, +} from '@concepta/nestjs-core'; + +import { type InvitationInterface } from '../../../domain/interfaces/invitation.interface.js'; + +export interface InvitationEntityInterface + extends + ReferenceIdInterface, + ReferenceVersionInterface, + ReferenceActiveInterface, + InvitationInterface, + AuditInterface {} diff --git a/packages/nestjs-invitation/src/infrastructure/persistence/invitation.mapper.ts b/packages/nestjs-invitation/src/infrastructure/persistence/invitation.mapper.ts new file mode 100644 index 000000000..759bd8453 --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/persistence/invitation.mapper.ts @@ -0,0 +1,37 @@ +import { DomainMapper } from '@concepta/nestjs-core/aggregate'; + +import { Invitation } from '../../domain/aggregates/invitation.js'; +import { type InvitationInterface } from '../../domain/interfaces/invitation.interface.js'; + +import { type InvitationEntityInterface } from './interfaces/invitation-entity.interface.js'; + +export class InvitationMapper extends DomainMapper< + InvitationEntityInterface, + InvitationInterface, + Invitation +> { + createAggregate(entity: InvitationEntityInterface): Invitation { + const { + id, + version, + active: _active, + dateCreated, + dateUpdated, + dateDeleted, + ...props + } = entity; + + return new Invitation(id, props, version, { + dateCreated, + dateUpdated, + dateDeleted, + }); + } + + toPersistence(aggregate: Invitation) { + return { + ...super.toPersistence(aggregate), + active: aggregate.active, + }; + } +} diff --git a/packages/nestjs-invitation/src/infrastructure/persistence/invitation.repository.ts b/packages/nestjs-invitation/src/infrastructure/persistence/invitation.repository.ts new file mode 100644 index 000000000..39d53b283 --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/persistence/invitation.repository.ts @@ -0,0 +1,73 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; +import { type RepositoryInterface, Where } from '@concepta/nestjs-repository'; + +import { type Invitation } from '../../domain/aggregates/invitation.js'; +import { type InvitationRepositoryInterface } from '../../domain/repositories/invitation-repository.interface.js'; + +import { type InvitationEntityInterface } from './interfaces/invitation-entity.interface.js'; +import { type InvitationMapper } from './invitation.mapper.js'; + +export class InvitationRepository implements InvitationRepositoryInterface { + constructor( + protected readonly repository: RepositoryInterface, + private readonly mapper: InvitationMapper, + ) {} + + async get( + ctx: PlainLiteralObject, + id: ReferenceId, + ): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.eq('id', id), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findOneByCode( + ctx: PlainLiteralObject, + code: string, + ): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.eq('code', code), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findAllByUserAndCategory( + ctx: PlainLiteralObject, + userId: ReferenceId, + category: string, + ): Promise { + const w = Where.for(); + + const entities = await this.repository.find({ + where: w.and(w.eq('userId', userId), w.eq('category', category)), + ctx, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async save(ctx: PlainLiteralObject, invitation: Invitation): Promise { + invitation.stampUpdated(); + await this.repository.upsert(this.mapper.toPersistence(invitation), { + ctx, + }); + } + + async remove(ctx: PlainLiteralObject, invitation: Invitation): Promise { + await this.repository.delete(this.mapper.toPersistence(invitation), { + ctx, + }); + } +} diff --git a/packages/nestjs-invitation/src/infrastructure/persistence/typeorm/invitation-postgres.entity.ts b/packages/nestjs-invitation/src/infrastructure/persistence/typeorm/invitation-postgres.entity.ts new file mode 100644 index 000000000..557be7217 --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/persistence/typeorm/invitation-postgres.entity.ts @@ -0,0 +1,34 @@ +import { Column } from 'typeorm'; + +import { PlainLiteralObject } from '@nestjs/common'; + +import { ReferenceActive, ReferenceId } from '@concepta/nestjs-core'; +import { CommonPostgresEntity } from '@concepta/nestjs-repository-typeorm'; + +import { InvitationEntityInterface } from '../interfaces/invitation-entity.interface.js'; + +export abstract class InvitationPostgresEntity + extends CommonPostgresEntity + implements InvitationEntityInterface +{ + @Column('boolean', { default: true }) + active!: ReferenceActive; + + @Column() + code!: string; + + @Column() + category!: string; + + @Column({ type: 'jsonb', nullable: true }) + constraints!: PlainLiteralObject; + + @Column({ type: 'uuid' }) + userId!: ReferenceId; + + @Column({ type: 'timestamptz', nullable: true, default: null }) + dateAccepted!: Date | null; + + @Column({ type: 'timestamptz', nullable: true, default: null }) + dateRevoked!: Date | null; +} diff --git a/packages/nestjs-invitation/src/infrastructure/persistence/typeorm/invitation-sqlite.entity.ts b/packages/nestjs-invitation/src/infrastructure/persistence/typeorm/invitation-sqlite.entity.ts new file mode 100644 index 000000000..4fa066b48 --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/persistence/typeorm/invitation-sqlite.entity.ts @@ -0,0 +1,34 @@ +import { Column } from 'typeorm'; + +import { PlainLiteralObject } from '@nestjs/common'; + +import { ReferenceActive, ReferenceId } from '@concepta/nestjs-core'; +import { CommonSqliteEntity } from '@concepta/nestjs-repository-typeorm'; + +import { InvitationEntityInterface } from '../interfaces/invitation-entity.interface.js'; + +export abstract class InvitationSqliteEntity + extends CommonSqliteEntity + implements InvitationEntityInterface +{ + @Column('boolean', { default: true }) + active!: ReferenceActive; + + @Column() + code!: string; + + @Column() + category!: string; + + @Column({ type: 'simple-json', nullable: true }) + constraints!: PlainLiteralObject; + + @Column({ type: 'uuid' }) + userId!: ReferenceId; + + @Column({ type: 'datetime', nullable: true, default: null }) + dateAccepted!: Date | null; + + @Column({ type: 'datetime', nullable: true, default: null }) + dateRevoked!: Date | null; +} diff --git a/packages/nestjs-invitation/src/infrastructure/schemas/invitation-accept.schema.ts b/packages/nestjs-invitation/src/infrastructure/schemas/invitation-accept.schema.ts new file mode 100644 index 000000000..f906c94ed --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/schemas/invitation-accept.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type InvitationAcceptableInterface } from '../../domain/interfaces/invitation-acceptable.interface.js'; + +export const invitationAcceptSchema = withOpenApi( + conformsTo()( + z.object({ + passcode: z.string().meta({ description: 'Passcode' }), + payload: z + .record(z.string(), z.unknown()) + .optional() + .meta({ description: 'Payload' }), + }), + ), +); diff --git a/packages/nestjs-invitation/src/infrastructure/schemas/invitation-create-by-email.schema.ts b/packages/nestjs-invitation/src/infrastructure/schemas/invitation-create-by-email.schema.ts new file mode 100644 index 000000000..c0a04bd52 --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/schemas/invitation-create-by-email.schema.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type InvitationCreatableByEmailInterface } from '../../domain/interfaces/invitation-creatable-by-email.interface.js'; + +import { invitationCreateSchema } from './invitation-create.schema.js'; + +export const invitationCreateByEmailSchema = withOpenApi( + conformsTo()( + invitationCreateSchema.pick({ category: true, constraints: true }).extend({ + email: z + .string() + .email() + .meta({ description: 'Email that the invitation will be sent to' }), + }), + ), +); diff --git a/packages/nestjs-invitation/src/infrastructure/schemas/invitation-create.schema.ts b/packages/nestjs-invitation/src/infrastructure/schemas/invitation-create.schema.ts new file mode 100644 index 000000000..b506c3057 --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/schemas/invitation-create.schema.ts @@ -0,0 +1,16 @@ +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type InvitationCreatableInterface } from '../../domain/interfaces/invitation-creatable.interface.js'; + +import { invitationSchema } from './invitation.schema.js'; + +export const invitationCreateSchema = withOpenApi( + conformsTo()( + invitationSchema.pick({ + category: true, + userId: true, + code: true, + constraints: true, + }), + ), +); diff --git a/packages/nestjs-invitation/src/infrastructure/schemas/invitation-paginated.schema.ts b/packages/nestjs-invitation/src/infrastructure/schemas/invitation-paginated.schema.ts new file mode 100644 index 000000000..cf4942bd6 --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/schemas/invitation-paginated.schema.ts @@ -0,0 +1,9 @@ +import { withNamedComponent } from '@concepta/nestjs-core'; +import { paginatedSchema } from '@concepta/nestjs-crud'; + +import { invitationSchema } from './invitation.schema.js'; + +export const invitationPaginatedSchema = withNamedComponent( + paginatedSchema(invitationSchema), + 'InvitationPaginated', +); diff --git a/packages/nestjs-invitation/src/infrastructure/schemas/invitation.schema.spec.ts b/packages/nestjs-invitation/src/infrastructure/schemas/invitation.schema.spec.ts new file mode 100644 index 000000000..6cd7497b9 --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/schemas/invitation.schema.spec.ts @@ -0,0 +1,122 @@ +import { invitationAcceptSchema } from './invitation-accept.schema.js'; +import { invitationCreateByEmailSchema } from './invitation-create-by-email.schema.js'; +import { invitationCreateSchema } from './invitation-create.schema.js'; +import { invitationPaginatedSchema } from './invitation-paginated.schema.js'; +import { invitationSchema } from './invitation.schema.js'; + +const validInvitation = { + id: 'abc', + version: 1, + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-02'), + dateDeleted: null, + active: true, + code: 'code-123', + category: 'user', + constraints: { foo: 'bar' }, + userId: 'user-abc', + dateAccepted: null, + dateRevoked: null, +}; + +describe('invitationSchema', () => { + it('accepts a valid invitation entity', () => { + expect(invitationSchema.parse(validInvitation)).toEqual(validInvitation); + }); + + it('defaults active to true when omitted (matching legacy class-property default)', () => { + const { active: _active, ...rest } = validInvitation; + expect(invitationSchema.parse(rest)).toEqual(validInvitation); + }); + + it('accepts an omitted constraints field', () => { + const { constraints: _constraints, ...rest } = validInvitation; + const result = invitationSchema.parse(rest); + expect(result).not.toHaveProperty('constraints'); + }); + + it('strips unknown keys', () => { + const result = invitationSchema.parse({ + ...validInvitation, + _internal: 'x', + }); + expect(result).not.toHaveProperty('_internal'); + }); +}); + +describe('invitationCreateSchema', () => { + const validCreate = { + category: 'user', + userId: 'user-abc', + code: 'code-123', + constraints: { foo: 'bar' }, + }; + + it('accepts a valid create payload', () => { + expect(invitationCreateSchema.parse(validCreate)).toEqual(validCreate); + }); + + it('accepts an omitted constraints field', () => { + const { constraints: _constraints, ...rest } = validCreate; + expect(invitationCreateSchema.parse(rest)).toEqual(rest); + }); + + it('rejects a missing category', () => { + const { category: _category, ...rest } = validCreate; + expect(invitationCreateSchema.safeParse(rest).success).toBe(false); + }); +}); + +describe('invitationCreateByEmailSchema', () => { + const validCreate = { + category: 'user', + email: 'invitee@example.com', + constraints: { foo: 'bar' }, + }; + + it('accepts a valid create-by-email payload', () => { + expect(invitationCreateByEmailSchema.parse(validCreate)).toEqual( + validCreate, + ); + }); + + it('rejects a malformed email', () => { + expect( + invitationCreateByEmailSchema.safeParse({ + ...validCreate, + email: 'not-an-email', + }).success, + ).toBe(false); + }); +}); + +describe('invitationAcceptSchema', () => { + it('accepts a passcode with no payload', () => { + expect(invitationAcceptSchema.parse({ passcode: '123456' })).toEqual({ + passcode: '123456', + }); + }); + + it('accepts a passcode with a payload', () => { + const payload = { passcode: '123456', payload: { newPassword: 'x' } }; + expect(invitationAcceptSchema.parse(payload)).toEqual(payload); + }); + + it('rejects a missing passcode', () => { + expect(invitationAcceptSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('invitationPaginatedSchema', () => { + it('accepts a paginated list of invitation entities', () => { + const payload = { + data: [validInvitation], + limit: 10, + count: 1, + total: 1, + page: 1, + pageCount: 1, + }; + expect(invitationPaginatedSchema.parse(payload)).toEqual(payload); + }); +}); diff --git a/packages/nestjs-invitation/src/infrastructure/schemas/invitation.schema.ts b/packages/nestjs-invitation/src/infrastructure/schemas/invitation.schema.ts new file mode 100644 index 000000000..eef8f8c91 --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/schemas/invitation.schema.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +import { conformsTo, withNamedComponent } from '@concepta/nestjs-core'; +import { domainAggregateSchema } from '@concepta/nestjs-core/aggregate'; + +import { type InvitationInterface } from '../../domain/interfaces/invitation.interface.js'; + +/** + * `active` is not part of `InvitationInterface` — it's a derived getter on + * the `Invitation` aggregate (true when both `dateAccepted` and + * `dateRevoked` are null), never included in `aggregate.toPlain()`. The + * legacy `InvitationDto` declared it anyway with a `= true` class-property + * default, so Create/Delete responses (built from `toPlain()`) always + * rendered the class default `true`, while List/Read responses (built from + * the real persisted entity column) rendered the actual value. + * `.default(true)` reproduces that exact behavior faithfully — not fixed + * here, since no response has ever exercised a revoked/accepted invitation + * through the Create/Delete path. + */ +export const invitationSchema = withNamedComponent( + conformsTo()( + domainAggregateSchema.extend({ + active: z + .boolean() + .default(true) + .meta({ description: 'Whether the invitation is still active' }), + code: z.string().meta({ description: 'Invitation code' }), + category: z.string().meta({ description: 'Category of the invitation' }), + // `.nullish()` (not just `.optional()`) because the persisted column + // is nullable — List/Read responses (built from the raw entity) can + // genuinely carry `null`, while Create/Delete responses (built from + // `aggregate.toPlain()`) only ever carry the object or `undefined`. + constraints: z + .record(z.string(), z.unknown()) + .nullish() + .meta({ description: 'Constraints for the invitation' }), + userId: z.string().meta({ description: 'User the invitation is for' }), + dateAccepted: z + .date() + .nullable() + .meta({ description: 'Date the invitation was accepted' }), + dateRevoked: z + .date() + .nullable() + .meta({ description: 'Date the invitation was revoked' }), + }), + ), + 'Invitation', +); diff --git a/packages/nestjs-invitation/src/infrastructure/utils/create-invitation-otp-policy-provider.ts b/packages/nestjs-invitation/src/infrastructure/utils/create-invitation-otp-policy-provider.ts new file mode 100644 index 000000000..34a400395 --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/utils/create-invitation-otp-policy-provider.ts @@ -0,0 +1,14 @@ +import { type Provider } from '@nestjs/common'; + +import { InvitationOtpPolicy } from '../../domain/policies/invitation-otp.policy.js'; +import { type InvitationSettingsInterface } from '../../interfaces/options/invitation-settings.interface.js'; +import { INVITATION_MODULE_SETTINGS_TOKEN } from '../../invitation.constants.js'; + +export function createInvitationOtpPolicyProvider(): Provider { + return { + provide: InvitationOtpPolicy, + inject: [INVITATION_MODULE_SETTINGS_TOKEN], + useFactory: (settings: InvitationSettingsInterface) => + new InvitationOtpPolicy(settings.otp), + }; +} diff --git a/packages/nestjs-invitation/src/infrastructure/utils/create-invitation-repository-provider.ts b/packages/nestjs-invitation/src/infrastructure/utils/create-invitation-repository-provider.ts new file mode 100644 index 000000000..5bcec3a7f --- /dev/null +++ b/packages/nestjs-invitation/src/infrastructure/utils/create-invitation-repository-provider.ts @@ -0,0 +1,37 @@ +import { type Provider, type Type } from '@nestjs/common'; + +import { + getDynamicRepositoryToken, + type RepositoryInterface, +} from '@concepta/nestjs-repository'; + +import { type InvitationRepositoryInterface } from '../../domain/repositories/invitation-repository.interface.js'; +import { INVITATION_MODULE_REPOSITORY_TOKEN } from '../../invitation.constants.js'; +import { type InvitationEntityInterface } from '../persistence/interfaces/invitation-entity.interface.js'; +import { InvitationMapper } from '../persistence/invitation.mapper.js'; +import { InvitationRepository } from '../persistence/invitation.repository.js'; + +export function createInvitationRepositoryProvider( + entityKey: string, + customRepository?: Type, +): Provider[] { + if (customRepository) { + return [ + { + provide: INVITATION_MODULE_REPOSITORY_TOKEN, + useClass: customRepository, + }, + ]; + } + + return [ + { + provide: INVITATION_MODULE_REPOSITORY_TOKEN, + inject: [getDynamicRepositoryToken(entityKey), InvitationMapper], + useFactory: ( + repository: RepositoryInterface, + mapper: InvitationMapper, + ) => new InvitationRepository(repository, mapper), + }, + ]; +} diff --git a/packages/nestjs-invitation/src/interfaces/domain/invitation-creatable.interface.ts b/packages/nestjs-invitation/src/interfaces/domain/invitation-creatable.interface.ts deleted file mode 100644 index ec26ac83d..000000000 --- a/packages/nestjs-invitation/src/interfaces/domain/invitation-creatable.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { InvitationInterface } from '@concepta/nestjs-common'; - -export interface InvitationCreatableInterface - extends Pick, - Partial> {} diff --git a/packages/nestjs-invitation/src/interfaces/domain/invitation-create-invite.interface.ts b/packages/nestjs-invitation/src/interfaces/domain/invitation-create-invite.interface.ts deleted file mode 100644 index d004932ce..000000000 --- a/packages/nestjs-invitation/src/interfaces/domain/invitation-create-invite.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ReferenceEmailInterface } from '@concepta/nestjs-common'; - -import { InvitationCreatableInterface } from './invitation-creatable.interface'; - -export interface InvitationCreateInviteInterface - extends Pick, - ReferenceEmailInterface {} diff --git a/packages/nestjs-invitation/src/interfaces/domain/invitation-send-invite.interface.ts b/packages/nestjs-invitation/src/interfaces/domain/invitation-send-invite.interface.ts deleted file mode 100644 index 2111e8776..000000000 --- a/packages/nestjs-invitation/src/interfaces/domain/invitation-send-invite.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { InvitationInterface } from '@concepta/nestjs-common'; - -export interface InvitationSendInviteInterface - extends Pick {} diff --git a/packages/nestjs-invitation/src/interfaces/options/invitation-accept-options.interface.ts b/packages/nestjs-invitation/src/interfaces/options/invitation-accept-options.interface.ts deleted file mode 100644 index 1511f6506..000000000 --- a/packages/nestjs-invitation/src/interfaces/options/invitation-accept-options.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { InvitationInterface, LiteralObject } from '@concepta/nestjs-common'; - -export interface InvitationAcceptOptionsInterface - extends Pick { - passcode: string; - payload?: LiteralObject; -} diff --git a/packages/nestjs-invitation/src/interfaces/options/invitation-entities-options.interface.ts b/packages/nestjs-invitation/src/interfaces/options/invitation-entities-options.interface.ts deleted file mode 100644 index 8e5ee1d42..000000000 --- a/packages/nestjs-invitation/src/interfaces/options/invitation-entities-options.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - InvitationEntityInterface, - RepositoryEntityOptionInterface, -} from '@concepta/nestjs-common'; - -import { INVITATION_MODULE_INVITATION_ENTITY_KEY } from '../../invitation.constants'; - -export interface InvitationEntitiesOptionsInterface { - [INVITATION_MODULE_INVITATION_ENTITY_KEY]: RepositoryEntityOptionInterface; -} diff --git a/packages/nestjs-invitation/src/interfaces/options/invitation-options-extras.interface.ts b/packages/nestjs-invitation/src/interfaces/options/invitation-options-extras.interface.ts index 7c8b46110..339c16d87 100644 --- a/packages/nestjs-invitation/src/interfaces/options/invitation-options-extras.interface.ts +++ b/packages/nestjs-invitation/src/interfaces/options/invitation-options-extras.interface.ts @@ -1,4 +1,15 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule, type Type } from '@nestjs/common'; -export interface InvitationOptionsExtrasInterface - extends Pick {} +import { type InvitationRepositoryInterface } from '../../domain/repositories/invitation-repository.interface.js'; + +export interface InvitationOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> { + entities?: { + invitation?: string; + }; + repositories?: { + invitation?: Type; + }; +} diff --git a/packages/nestjs-invitation/src/interfaces/options/invitation-options.interface.ts b/packages/nestjs-invitation/src/interfaces/options/invitation-options.interface.ts index f91318ef7..5d6659e83 100644 --- a/packages/nestjs-invitation/src/interfaces/options/invitation-options.interface.ts +++ b/packages/nestjs-invitation/src/interfaces/options/invitation-options.interface.ts @@ -1,17 +1,18 @@ -import { ModuleOptionsControllerInterface } from '@concepta/nestjs-common'; +import { type ModuleOptionsControllerInterface } from '@concepta/nestjs-core'; -import { InvitationEmailServiceInterface } from '../services/invitation-email-service.interface'; -import { InvitationOtpServiceInterface } from '../services/invitation-otp-service.interface'; -import { InvitationSendServiceInterface } from '../services/invitation-send-service.interface'; -import { InvitationUserModelServiceInterface } from '../services/invitation-user-model.service.interface'; +import { type InvitationNotificationPortSettings } from '../../domain/ports/invitation-notification.port.js'; +import { type InvitationOtpPortSettings } from '../../domain/ports/invitation-otp.port.js'; +import { type InvitationUserPortSettings } from '../../domain/ports/invitation-user.port.js'; -import { InvitationSettingsInterface } from './invitation-settings.interface'; +import { type InvitationSettingsInterface } from './invitation-settings.interface.js'; -export interface InvitationOptionsInterface - extends ModuleOptionsControllerInterface { +export interface InvitationPortsInterface { + otp: InvitationOtpPortSettings; + user: InvitationUserPortSettings; + notification: InvitationNotificationPortSettings; +} + +export interface InvitationOptionsInterface extends ModuleOptionsControllerInterface { settings?: InvitationSettingsInterface; - otpService: InvitationOtpServiceInterface; - emailService: InvitationEmailServiceInterface; - userModelService: InvitationUserModelServiceInterface; - invitationSendService?: InvitationSendServiceInterface; + ports: InvitationPortsInterface; } diff --git a/packages/nestjs-invitation/src/interfaces/options/invitation-otp-settings.interface.ts b/packages/nestjs-invitation/src/interfaces/options/invitation-otp-settings.interface.ts deleted file mode 100644 index 516bb3925..000000000 --- a/packages/nestjs-invitation/src/interfaces/options/invitation-otp-settings.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { - ReferenceAssignment, - OtpCreatableInterface, -} from '@concepta/nestjs-common'; - -export interface InvitationOtpSettingsInterface - extends Pick, - Partial> { - assignment: ReferenceAssignment; - clearOtpOnCreate?: boolean; -} diff --git a/packages/nestjs-invitation/src/interfaces/options/invitation-revoke-options.interface.ts b/packages/nestjs-invitation/src/interfaces/options/invitation-revoke-options.interface.ts deleted file mode 100644 index 5f805efdf..000000000 --- a/packages/nestjs-invitation/src/interfaces/options/invitation-revoke-options.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { - InvitationInterface, - ReferenceEmailInterface, -} from '@concepta/nestjs-common'; - -export interface InvitationRevokeOptionsInterface - extends Pick, - ReferenceEmailInterface {} diff --git a/packages/nestjs-invitation/src/interfaces/options/invitation-send-invitation-email-options.interface.ts b/packages/nestjs-invitation/src/interfaces/options/invitation-send-invitation-email-options.interface.ts deleted file mode 100644 index 5471dbb9d..000000000 --- a/packages/nestjs-invitation/src/interfaces/options/invitation-send-invitation-email-options.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { - InvitationInterface, - ReferenceEmailInterface, -} from '@concepta/nestjs-common'; - -export interface InvitationSendInvitationEmailOptionsInterface - extends Pick, - ReferenceEmailInterface { - passcode: string; - resetTokenExp: Date; -} diff --git a/packages/nestjs-invitation/src/interfaces/options/invitation-settings.interface.ts b/packages/nestjs-invitation/src/interfaces/options/invitation-settings.interface.ts index d7dc3a192..f3b16743a 100644 --- a/packages/nestjs-invitation/src/interfaces/options/invitation-settings.interface.ts +++ b/packages/nestjs-invitation/src/interfaces/options/invitation-settings.interface.ts @@ -1,21 +1,5 @@ -import { InvitationOtpSettingsInterface } from './invitation-otp-settings.interface'; +import { type InvitationOtpSettingsInterface } from '../../domain/interfaces/invitation-otp-settings.interface.js'; export interface InvitationSettingsInterface { - email: { - from: string; - baseUrl: string; - templates: { - invitation: { - logo: string; - fileName: string; - subject: string; - }; - invitationAccepted: { - logo: string; - fileName: string; - subject: string; - }; - }; - }; otp: InvitationOtpSettingsInterface; } diff --git a/packages/nestjs-invitation/src/interfaces/services/invitation-attempt-service.interface.ts b/packages/nestjs-invitation/src/interfaces/services/invitation-attempt-service.interface.ts deleted file mode 100644 index 03b5d092b..000000000 --- a/packages/nestjs-invitation/src/interfaces/services/invitation-attempt-service.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface InvitationAttemptServiceInterface { - send(code: string): Promise; -} diff --git a/packages/nestjs-invitation/src/interfaces/services/invitation-email-service.interface.ts b/packages/nestjs-invitation/src/interfaces/services/invitation-email-service.interface.ts deleted file mode 100644 index ebfb5b982..000000000 --- a/packages/nestjs-invitation/src/interfaces/services/invitation-email-service.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { EmailSendInterface } from '@concepta/nestjs-common'; - -export interface InvitationEmailServiceInterface extends EmailSendInterface {} diff --git a/packages/nestjs-invitation/src/interfaces/services/invitation-model-service.interface.ts b/packages/nestjs-invitation/src/interfaces/services/invitation-model-service.interface.ts deleted file mode 100644 index 2a2e758ed..000000000 --- a/packages/nestjs-invitation/src/interfaces/services/invitation-model-service.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - ByIdInterface, - CreateOneInterface, - InvitationEntityInterface, -} from '@concepta/nestjs-common'; - -import { InvitationCreatableInterface } from '../domain/invitation-creatable.interface'; - -export interface InvitationModelServiceInterface - extends ByIdInterface, - CreateOneInterface< - InvitationCreatableInterface, - InvitationEntityInterface - > {} diff --git a/packages/nestjs-invitation/src/interfaces/services/invitation-otp-service.interface.ts b/packages/nestjs-invitation/src/interfaces/services/invitation-otp-service.interface.ts deleted file mode 100644 index 14819c891..000000000 --- a/packages/nestjs-invitation/src/interfaces/services/invitation-otp-service.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - OtpClearInterface, - OtpCreateInterface, - OtpValidateInterface, -} from '@concepta/nestjs-common'; - -export interface InvitationOtpServiceInterface - extends OtpCreateInterface, - OtpValidateInterface, - OtpClearInterface {} diff --git a/packages/nestjs-invitation/src/interfaces/services/invitation-send-service.interface.ts b/packages/nestjs-invitation/src/interfaces/services/invitation-send-service.interface.ts deleted file mode 100644 index f1086e307..000000000 --- a/packages/nestjs-invitation/src/interfaces/services/invitation-send-service.interface.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { InvitationUserInterface } from '@concepta/nestjs-common'; - -import { InvitationCreateInviteInterface } from '../domain/invitation-create-invite.interface'; -import { InvitationSendInviteInterface } from '../domain/invitation-send-invite.interface'; -import { InvitationSendInvitationEmailOptionsInterface } from '../options/invitation-send-invitation-email-options.interface'; - -export interface InvitationSendServiceInterface { - /** - * Create a new invitation - * - * @param createInviteDto - The invitation creation data - * @returns Promise resolving to the created invitation with id and user - */ - create( - createInviteDto: InvitationCreateInviteInterface, - ): Promise; - - /** - * Send an invitation to a user - * - * @param invitation - The invitation details including category, user, email and code - */ - send(invitation: InvitationSendInviteInterface): Promise; - - /** - * Get user details for an invitation - * - * @param options - The user find options including email and optional constraints - * @returns Promise resolving to the user details response - */ - getUser( - options: Pick, - ): Promise; - - /** - * Send an invitation email - * - * @param options - The email options containing recipient email, invitation code, - * passcode and expiration - * @returns Promise resolving when email is sent - */ - sendInvitationEmail( - options: InvitationSendInvitationEmailOptionsInterface, - ): Promise; -} diff --git a/packages/nestjs-invitation/src/interfaces/services/invitation-service.interface.ts b/packages/nestjs-invitation/src/interfaces/services/invitation-service.interface.ts deleted file mode 100644 index ce9aabbe3..000000000 --- a/packages/nestjs-invitation/src/interfaces/services/invitation-service.interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { InvitationInterface } from '@concepta/nestjs-common'; - -import { InvitationCreateInviteInterface } from '../domain/invitation-create-invite.interface'; -import { InvitationAcceptOptionsInterface } from '../options/invitation-accept-options.interface'; -import { InvitationRevokeOptionsInterface } from '../options/invitation-revoke-options.interface'; - -export interface InvitationServiceInterface { - create( - createInviteDto: InvitationCreateInviteInterface, - ): Promise>>; - - send(invitation: Pick): Promise; - - accept(options: InvitationAcceptOptionsInterface): Promise; - - revokeAll(options: InvitationRevokeOptionsInterface): Promise; -} diff --git a/packages/nestjs-invitation/src/interfaces/services/invitation-user-model.service.interface.ts b/packages/nestjs-invitation/src/interfaces/services/invitation-user-model.service.interface.ts deleted file mode 100644 index de0a2fea3..000000000 --- a/packages/nestjs-invitation/src/interfaces/services/invitation-user-model.service.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { - InvitationUserInterface, - ByEmailInterface, - ByIdInterface, - ReferenceId, - CreateOneInterface, - UserCreatableInterface, -} from '@concepta/nestjs-common'; - -export interface InvitationUserModelServiceInterface - extends ByIdInterface, - ByEmailInterface, - CreateOneInterface {} diff --git a/packages/nestjs-invitation/src/invitation.constants.ts b/packages/nestjs-invitation/src/invitation.constants.ts index b5a90af07..b1b0d3160 100644 --- a/packages/nestjs-invitation/src/invitation.constants.ts +++ b/packages/nestjs-invitation/src/invitation.constants.ts @@ -4,16 +4,7 @@ export const INVITATION_MODULE_SETTINGS_TOKEN = export const INVITATION_MODULE_DEFAULT_SETTINGS_TOKEN = 'INVITATION_MODULE_DEFAULT_SETTINGS_TOKEN'; -export const INVITATION_MODULE_SERVICE_TOKEN = - 'INVITATION_MODULE_SERVICE_TOKEN'; +export const INVITATION_MODULE_DEFAULT_ENTITY_KEY = 'invitation'; -export const INVITATION_MODULE_OTP_SERVICE_TOKEN = - 'INVITATION_MODULE_OTP_SERVICE_TOKEN'; - -export const INVITATION_MODULE_EMAIL_SERVICE_TOKEN = - 'INVITATION_MODULE_EMAIL_SERVICE_TOKEN'; - -export const INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN = - 'INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN'; - -export const INVITATION_MODULE_INVITATION_ENTITY_KEY = 'invitation'; +export const INVITATION_MODULE_REPOSITORY_TOKEN = + 'INVITATION_MODULE_REPOSITORY_TOKEN'; diff --git a/packages/nestjs-invitation/src/invitation.module-definition.ts b/packages/nestjs-invitation/src/invitation.module-definition.ts index 5c020b5a8..5b63606a3 100644 --- a/packages/nestjs-invitation/src/invitation.module-definition.ts +++ b/packages/nestjs-invitation/src/invitation.module-definition.ts @@ -1,31 +1,41 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { CommandBus, CqrsModule, QueryBus } from '@nestjs/cqrs'; -import { createSettingsProvider } from '@concepta/nestjs-common'; +import { createSettingsProvider } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; -import { invitationDefaultConfig } from './config/invitation-default.config'; -import { InvitationOptionsExtrasInterface } from './interfaces/options/invitation-options-extras.interface'; -import { InvitationOptionsInterface } from './interfaces/options/invitation-options.interface'; -import { InvitationSettingsInterface } from './interfaces/options/invitation-settings.interface'; -import { InvitationEmailServiceInterface } from './interfaces/services/invitation-email-service.interface'; -import { InvitationOtpServiceInterface } from './interfaces/services/invitation-otp-service.interface'; -import { InvitationUserModelServiceInterface } from './interfaces/services/invitation-user-model.service.interface'; +import { AcceptInvitationHandler } from './application/commands/handlers/accept-invitation.handler.js'; +import { CreateInvitationByEmailHandler } from './application/commands/handlers/create-invitation-by-email.handler.js'; +import { CreateInvitationHandler } from './application/commands/handlers/create-invitation.handler.js'; +import { RemoveInvitationHandler } from './application/commands/handlers/remove-invitation.handler.js'; +import { RevokeInvitationsHandler } from './application/commands/handlers/revoke-invitations.handler.js'; +import { SendInvitationHandler } from './application/commands/handlers/send-invitation.handler.js'; +import { InvitationAcceptedListener } from './application/listeners/invitation-accepted.listener.js'; +import { InvitationDispatchedListener } from './application/listeners/invitation-dispatched.listener.js'; +import { InvitationRevokedListener } from './application/listeners/invitation-revoked.listener.js'; +import { FindInvitationByCodeHandler } from './application/queries/handlers/find-invitation-by-code.handler.js'; +import { GetInvitationHandler } from './application/queries/handlers/get-invitation.handler.js'; +import { invitationDefaultConfig } from './config/invitation-default.config.js'; +import { InvitationOtpPolicy } from './domain/policies/invitation-otp.policy.js'; +import { InvitationNotificationPort } from './domain/ports/invitation-notification.port.js'; +import { InvitationOtpPort } from './domain/ports/invitation-otp.port.js'; +import { InvitationUserPort } from './domain/ports/invitation-user.port.js'; +import { InvitationService } from './domain/services/invitation.service.js'; +import { InvitationMapper } from './infrastructure/persistence/invitation.mapper.js'; +import { createInvitationOtpPolicyProvider } from './infrastructure/utils/create-invitation-otp-policy-provider.js'; +import { createInvitationRepositoryProvider } from './infrastructure/utils/create-invitation-repository-provider.js'; +import { type InvitationOptionsExtrasInterface } from './interfaces/options/invitation-options-extras.interface.js'; +import { type InvitationOptionsInterface } from './interfaces/options/invitation-options.interface.js'; +import { type InvitationSettingsInterface } from './interfaces/options/invitation-settings.interface.js'; import { - INVITATION_MODULE_EMAIL_SERVICE_TOKEN, - INVITATION_MODULE_OTP_SERVICE_TOKEN, + INVITATION_MODULE_DEFAULT_ENTITY_KEY, INVITATION_MODULE_SETTINGS_TOKEN, - INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN, -} from './invitation.constants'; -import { InvitationAcceptanceService } from './services/invitation-acceptance.service'; -import { InvitationAttemptService } from './services/invitation-attempt.service'; -import { InvitationModelService } from './services/invitation-model.service'; -import { InvitationRevocationService } from './services/invitation-revocation.service'; -import { InvitationSendService } from './services/invitation-send.service'; -import { InvitationService } from './services/invitation.service'; +} from './invitation.constants.js'; const RAW_OPTIONS_TOKEN = Symbol('__INVITATION_MODULE_RAW_OPTIONS_TOKEN__'); @@ -38,7 +48,10 @@ export const { optionsInjectionToken: RAW_OPTIONS_TOKEN, }) .setExtras( - { global: false }, + { + global: false, + entities: { invitation: INVITATION_MODULE_DEFAULT_ENTITY_KEY }, + }, definitionTransform, ) .build(); @@ -54,18 +67,20 @@ function definitionTransform( extras: InvitationOptionsExtrasInterface, ): DynamicModule { const { imports = [], providers = [] } = definition; - const { global = false } = extras; + const { global = false, entities, repositories } = extras; + const entityKey = + entities?.invitation ?? INVITATION_MODULE_DEFAULT_ENTITY_KEY; return { ...definition, global, imports: createInvitationImports({ imports }), - providers: createInvitationProviders({ providers }), - exports: [ - ConfigModule, - RAW_OPTIONS_TOKEN, - ...(createInvitationExports() ?? []), - ], + providers: createInvitationProviders({ + providers, + entityKey, + repositories, + }), + exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createInvitationExports()], }; } @@ -75,40 +90,49 @@ export function createInvitationImports(options: { return [ ...(options.imports || []), ConfigModule.forFeature(invitationDefaultConfig), + CqrsModule.forRoot(), ]; } -export function createInvitationExports(): DynamicModule['exports'] { - return [ - INVITATION_MODULE_SETTINGS_TOKEN, - INVITATION_MODULE_OTP_SERVICE_TOKEN, - INVITATION_MODULE_EMAIL_SERVICE_TOKEN, - INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN, - InvitationService, - InvitationModelService, - InvitationAcceptanceService, - InvitationRevocationService, - InvitationAttemptService, - InvitationSendService, - ]; +export function createInvitationExports(): Required< + Pick +>['exports'] { + return [INVITATION_MODULE_SETTINGS_TOKEN, InvitationMapper]; } export function createInvitationProviders(options: { overrides?: InvitationOptions; providers?: Provider[]; + entityKey: string; + repositories?: InvitationOptionsExtrasInterface['repositories']; }): Provider[] { return [ ...(options.providers ?? []), InvitationService, - InvitationAcceptanceService, - InvitationRevocationService, - InvitationModelService, - InvitationAttemptService, createInvitationSettingsProvider(options.overrides), - createInvitationOtpServiceProvider(options.overrides), - createInvitationEmailServiceProvider(options.overrides), - createInvitationUserModelServiceProvider(options.overrides), - createInvitationSendServiceProvider(options.overrides), + ...createInvitationRepositoryProvider( + options.entityKey, + options.repositories?.invitation, + ), + createInvitationOtpPolicyProvider(), + createInvitationOtpPortProvider(), + createInvitationUserPortProvider(), + createInvitationNotificationPortProvider(), + InvitationMapper, + // command handlers + CreateInvitationHandler, + CreateInvitationByEmailHandler, + SendInvitationHandler, + AcceptInvitationHandler, + RevokeInvitationsHandler, + RemoveInvitationHandler, + // query handlers + GetInvitationHandler, + FindInvitationByCodeHandler, + // event listeners + InvitationDispatchedListener, + InvitationRevokedListener, + InvitationAcceptedListener, ]; } @@ -126,68 +150,47 @@ export function createInvitationSettingsProvider( }); } -export function createInvitationOtpServiceProvider( - optionsOverrides?: InvitationOptions, -): Provider { +function createInvitationOtpPortProvider(): Provider { return { - provide: INVITATION_MODULE_OTP_SERVICE_TOKEN, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: InvitationOptionsInterface) => - optionsOverrides?.otpService ?? options.otpService, - }; -} - -export function createInvitationEmailServiceProvider( - optionsOverrides?: InvitationOptions, -): Provider { - return { - provide: INVITATION_MODULE_EMAIL_SERVICE_TOKEN, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: InvitationOptionsInterface) => - optionsOverrides?.emailService ?? options.emailService, + provide: InvitationOtpPort, + inject: [ + RAW_OPTIONS_TOKEN, + InvitationOtpPolicy, + CommandBus, + QueryBus, + TransactionScope, + ], + useFactory: ( + options: InvitationOptionsInterface, + otpPolicy: InvitationOtpPolicy, + commandBus: CommandBus, + queryBus: QueryBus, + txScope: TransactionScope, + ) => + new InvitationOtpPort( + options.ports.otp, + otpPolicy, + commandBus, + queryBus, + txScope, + ), }; } -export function createInvitationUserModelServiceProvider( - optionsOverrides?: InvitationOptions, -): Provider { +function createInvitationUserPortProvider(): Provider { return { - provide: INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN, - inject: [RAW_OPTIONS_TOKEN], - useFactory: async (options: InvitationOptionsInterface) => - optionsOverrides?.userModelService ?? options.userModelService, + provide: InvitationUserPort, + inject: [RAW_OPTIONS_TOKEN, QueryBus], + useFactory: (options: InvitationOptionsInterface, queryBus: QueryBus) => + new InvitationUserPort(options.ports.user, queryBus), }; } -export function createInvitationSendServiceProvider( - optionsOverrides?: InvitationOptions, -): Provider { +function createInvitationNotificationPortProvider(): Provider { return { - provide: InvitationSendService, - inject: [ - RAW_OPTIONS_TOKEN, - INVITATION_MODULE_SETTINGS_TOKEN, - INVITATION_MODULE_EMAIL_SERVICE_TOKEN, - INVITATION_MODULE_OTP_SERVICE_TOKEN, - INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN, - InvitationModelService, - ], - useFactory: async ( - options: InvitationOptionsInterface, - settings: InvitationSettingsInterface, - emailService: InvitationEmailServiceInterface, - otpService: InvitationOtpServiceInterface, - userModelService: InvitationUserModelServiceInterface, - invitationModelService: InvitationModelService, - ) => - optionsOverrides?.invitationSendService ?? - options.invitationSendService ?? - new InvitationSendService( - settings, - emailService, - otpService, - userModelService, - invitationModelService, - ), + provide: InvitationNotificationPort, + inject: [RAW_OPTIONS_TOKEN, CommandBus], + useFactory: (options: InvitationOptionsInterface, commandBus: CommandBus) => + new InvitationNotificationPort(options.ports.notification, commandBus), }; } diff --git a/packages/nestjs-invitation/src/invitation.module.spec.ts b/packages/nestjs-invitation/src/invitation.module.spec.ts index a9359d671..4691ade14 100644 --- a/packages/nestjs-invitation/src/invitation.module.spec.ts +++ b/packages/nestjs-invitation/src/invitation.module.spec.ts @@ -1,282 +1,56 @@ -import { mock } from 'jest-mock-extended'; +import { Test, type TestingModule } from '@nestjs/testing'; -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { InvitationNotificationPort } from './domain/ports/invitation-notification.port.js'; +import { InvitationOtpPort } from './domain/ports/invitation-otp.port.js'; +import { InvitationUserPort } from './domain/ports/invitation-user.port.js'; +import { InvitationService } from './domain/services/invitation.service.js'; +import { AppCrudModuleFixture } from './gateways/http/__tests__/fixtures/app-crud.module.fixture.js'; +import { InvitationMapper } from './infrastructure/persistence/invitation.mapper.js'; +import { InvitationModule } from './invitation.module.js'; -import { CrudModule } from '@concepta/nestjs-crud'; -import { EmailModule, EmailService } from '@concepta/nestjs-email'; -import { EventModule } from '@concepta/nestjs-event'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { InvitationEmailServiceInterface } from './interfaces/services/invitation-email-service.interface'; -import { InvitationOtpServiceInterface } from './interfaces/services/invitation-otp-service.interface'; -import { InvitationSendServiceInterface } from './interfaces/services/invitation-send-service.interface'; -import { InvitationServiceInterface } from './interfaces/services/invitation-service.interface'; -import { InvitationUserModelServiceInterface } from './interfaces/services/invitation-user-model.service.interface'; -import { - INVITATION_MODULE_OTP_SERVICE_TOKEN, - INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN, -} from './invitation.constants'; -import { InvitationModule } from './invitation.module'; -import { InvitationAcceptanceService } from './services/invitation-acceptance.service'; -import { InvitationRevocationService } from './services/invitation-revocation.service'; -import { InvitationSendService } from './services/invitation-send.service'; -import { InvitationService } from './services/invitation.service'; - -import { MailerServiceFixture } from './__fixtures__/email/mailer.service.fixture'; -import { InvitationLocalModuleFixture } from './__fixtures__/invitation/entities/invitation-local.module.fixture'; -import { InvitationSendServiceFixture } from './__fixtures__/invitation/entities/invitation-send.service.fixture'; -import { InvitationEntityFixture } from './__fixtures__/invitation/entities/invitation.entity.fixture'; -import { default as ormConfig } from './__fixtures__/ormconfig.fixture'; -import { OtpModuleFixture } from './__fixtures__/otp/otp.module.fixture'; -import { OtpServiceFixture } from './__fixtures__/otp/otp.service.fixture'; -import { UserModelServiceFixture } from './__fixtures__/user/services/user-model.service.fixture'; -import { UserModuleFixture } from './__fixtures__/user/user.module.fixture'; - -describe(InvitationModule, () => { +describe(InvitationModule.name, () => { let testModule: TestingModule; - let invitationModule: InvitationModule; - let otpService: InvitationOtpServiceInterface; - let emailService: InvitationEmailServiceInterface; - let userModelService: InvitationUserModelServiceInterface; - let invitationService: InvitationServiceInterface; - let invitationSendService: InvitationSendServiceInterface; - let invitationAcceptanceService: InvitationAcceptanceService; - let invitationRevocationService: InvitationRevocationService; - - const mockEmailService = mock(); - - describe(InvitationModule.forRoot, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - TypeOrmExtModule.forFeature({ - invitation: { - entity: InvitationEntityFixture, - }, - }), - InvitationModule.forRoot({ - emailService: mockEmailService, - otpService: new OtpServiceFixture(), - userModelService: new UserModelServiceFixture(), - invitationSendService: new InvitationSendServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); - }); - - describe(InvitationModule.forRoot, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - TypeOrmExtModule.forFeature({ - invitation: { - entity: InvitationEntityFixture, - }, - }), - InvitationModule.forRoot({ - emailService: mockEmailService, - otpService: new OtpServiceFixture(), - userModelService: new UserModelServiceFixture(), - }), - ]), - ).compile(); - }); - it('check send service type for default send service', async () => { - invitationSendService = testModule.get( - InvitationSendService, - ); - // check the default - expect(invitationSendService).toBeInstanceOf(InvitationSendService); - }); + beforeEach(async () => { + testModule = await Test.createTestingModule({ + imports: [AppCrudModuleFixture], + }).compile(); }); afterEach(async () => { - if (testModule) await testModule.close(); + vi.clearAllMocks(); + await testModule.close(); }); - describe(InvitationModule.register, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - TypeOrmExtModule.forFeature({ - invitation: { - entity: InvitationEntityFixture, - }, - }), - InvitationModule.register({ - emailService: mockEmailService, - otpService: new OtpServiceFixture(), - userModelService: new UserModelServiceFixture(), - invitationSendService: new InvitationSendServiceFixture(), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); + it('should be loaded', () => { + const module = testModule.get(InvitationModule); + expect(module).toBeInstanceOf(InvitationModule); }); - afterEach(async () => { - if (testModule) await testModule.close(); + it('should resolve InvitationService', () => { + const service = testModule.get(InvitationService); + expect(service).toBeInstanceOf(InvitationService); }); - describe(InvitationModule.forRootAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - InvitationModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - invitation: { - entity: InvitationEntityFixture, - }, - }), - ], - inject: [ - UserModelServiceFixture, - OtpServiceFixture, - EmailService, - InvitationSendServiceFixture, - ], - useFactory: ( - userModelService, - otpService, - emailService, - invitationSendService, - ) => ({ - userModelService, - otpService, - emailService, - invitationSendService, - }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); + it('should resolve InvitationOtpPort', () => { + const port = testModule.get(InvitationOtpPort); + expect(port).toBeInstanceOf(InvitationOtpPort); }); - afterEach(async () => { - if (testModule) await testModule.close(); + it('should resolve InvitationUserPort', () => { + const port = testModule.get(InvitationUserPort); + expect(port).toBeInstanceOf(InvitationUserPort); }); - describe(InvitationModule.registerAsync, () => { - beforeEach(async () => { - testModule = await Test.createTestingModule( - testModuleFactory([ - InvitationModule.registerAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - invitation: { - entity: InvitationEntityFixture, - }, - }), - ], - inject: [ - UserModelServiceFixture, - OtpServiceFixture, - EmailService, - InvitationSendServiceFixture, - ], - useFactory: ( - userModelService, - otpService, - emailService, - invitationSendService, - ) => ({ - userModelService, - otpService, - emailService, - invitationSendService, - }), - }), - ]), - ).compile(); - }); - - it('module should be loaded', async () => { - commonVars(); - commonTests(); - }); + it('should resolve InvitationNotificationPort', () => { + const port = testModule.get( + InvitationNotificationPort, + ); + expect(port).toBeInstanceOf(InvitationNotificationPort); }); - afterEach(async () => { - if (testModule) await testModule.close(); + it('should resolve InvitationMapper', () => { + const mapper = testModule.get(InvitationMapper); + expect(mapper).toBeInstanceOf(InvitationMapper); }); - - function commonVars() { - invitationModule = testModule.get(InvitationModule); - - emailService = - testModule.get(EmailService); - - otpService = testModule.get( - INVITATION_MODULE_OTP_SERVICE_TOKEN, - ); - - userModelService = testModule.get( - INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN, - ); - - invitationService = testModule.get(InvitationService); - - invitationSendService = testModule.get( - InvitationSendService, - ); - - invitationAcceptanceService = testModule.get( - InvitationAcceptanceService, - ); - - invitationRevocationService = testModule.get( - InvitationRevocationService, - ); - } - - function commonTests() { - expect(invitationModule).toBeInstanceOf(InvitationModule); - expect(otpService).toBeInstanceOf(OtpServiceFixture); - expect(emailService).toBeInstanceOf(EmailService); - expect(userModelService).toBeInstanceOf(UserModelServiceFixture); - expect(invitationService).toBeInstanceOf(InvitationService); - expect(invitationSendService).toBeInstanceOf(InvitationSendServiceFixture); - expect(invitationAcceptanceService).toBeInstanceOf( - InvitationAcceptanceService, - ); - expect(invitationRevocationService).toBeInstanceOf( - InvitationRevocationService, - ); - } }); - -function testModuleFactory( - extraImports: DynamicModule['imports'] = [], -): ModuleMetadata { - return { - imports: [ - TypeOrmExtModule.forRoot(ormConfig), - EventModule.forRoot({}), - CrudModule.forRoot({}), - UserModuleFixture, - OtpModuleFixture, - InvitationLocalModuleFixture, - EmailModule.forRoot({ mailerService: new MailerServiceFixture() }), - ...extraImports, - ], - }; -} diff --git a/packages/nestjs-invitation/src/invitation.module.ts b/packages/nestjs-invitation/src/invitation.module.ts index 8a865e9c8..2571144f2 100644 --- a/packages/nestjs-invitation/src/invitation.module.ts +++ b/packages/nestjs-invitation/src/invitation.module.ts @@ -4,7 +4,7 @@ import { InvitationAsyncOptions, InvitationModuleClass, InvitationOptions, -} from './invitation.module-definition'; +} from './invitation.module-definition.js'; /** * Invitation module diff --git a/packages/nestjs-invitation/src/invitation.types.ts b/packages/nestjs-invitation/src/invitation.types.ts deleted file mode 100644 index bda5bdc75..000000000 --- a/packages/nestjs-invitation/src/invitation.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum InvitationResource { - 'One' = 'invitation', - 'Many' = 'invitation-list', -} diff --git a/packages/nestjs-invitation/src/optional-seeding.ts b/packages/nestjs-invitation/src/optional-seeding.ts new file mode 100644 index 000000000..b8c4d65cf --- /dev/null +++ b/packages/nestjs-invitation/src/optional-seeding.ts @@ -0,0 +1,6 @@ +/** + * These exports allow you to import seeding related classes + * and tools without loading the entire module which + * runs all of its decorators and meta data. + */ +export { InvitationFactory } from './seeding/invitation.factory.js'; diff --git a/packages/nestjs-invitation/src/optional-typeorm.ts b/packages/nestjs-invitation/src/optional-typeorm.ts new file mode 100644 index 000000000..773112aa6 --- /dev/null +++ b/packages/nestjs-invitation/src/optional-typeorm.ts @@ -0,0 +1,2 @@ +export { InvitationSqliteEntity } from './infrastructure/persistence/typeorm/invitation-sqlite.entity.js'; +export { InvitationPostgresEntity } from './infrastructure/persistence/typeorm/invitation-postgres.entity.js'; diff --git a/packages/nestjs-invitation/src/seeding.ts b/packages/nestjs-invitation/src/seeding.ts deleted file mode 100644 index 1d38baf81..000000000 --- a/packages/nestjs-invitation/src/seeding.ts +++ /dev/null @@ -1 +0,0 @@ -export { InvitationFactory } from './seeding/invitation.factory'; diff --git a/packages/nestjs-invitation/src/seeding/invitation.factory.ts b/packages/nestjs-invitation/src/seeding/invitation.factory.ts index 34e85c699..c2b624921 100644 --- a/packages/nestjs-invitation/src/seeding/invitation.factory.ts +++ b/packages/nestjs-invitation/src/seeding/invitation.factory.ts @@ -2,9 +2,10 @@ import { randomUUID } from 'crypto'; import { faker } from '@faker-js/faker'; -import { InvitationEntityInterface } from '@concepta/nestjs-common'; import { Factory } from '@concepta/typeorm-seeding'; +import { type InvitationEntityInterface } from '../infrastructure/persistence/interfaces/invitation-entity.interface.js'; + export class InvitationFactory extends Factory { protected async entity( invitation: InvitationEntityInterface, diff --git a/packages/nestjs-invitation/src/services/invitation-acceptance.service.spec.ts b/packages/nestjs-invitation/src/services/invitation-acceptance.service.spec.ts deleted file mode 100644 index 4cbd77556..000000000 --- a/packages/nestjs-invitation/src/services/invitation-acceptance.service.spec.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { - INVITATION_MODULE_CATEGORY_USER_KEY, - OtpInterface, - UserInterface, - UserEntityInterface, - InvitationEntityInterface, -} from '@concepta/nestjs-common'; -import { EmailService } from '@concepta/nestjs-email'; -import { OtpService } from '@concepta/nestjs-otp'; -import { UserFactory } from '@concepta/nestjs-user/src/seeding'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { InvitationAcceptedEventAsync } from '../events/invitation-accepted.event'; -import { InvitationSettingsInterface } from '../interfaces/options/invitation-settings.interface'; -import { INVITATION_MODULE_SETTINGS_TOKEN } from '../invitation.constants'; -import { InvitationFactory } from '../seeding/invitation.factory'; - -import { InvitationAcceptanceService } from './invitation-acceptance.service'; - -import { AppModuleFixture } from '../__fixtures__/app.module.fixture'; -import { InvitationEntityFixture } from '../__fixtures__/invitation/entities/invitation.entity.fixture'; -import { UserEntityFixture } from '../__fixtures__/user/entities/user.entity.fixture'; - -describe(InvitationAcceptanceService, () => { - const category = INVITATION_MODULE_CATEGORY_USER_KEY; - - let spyEmailService: jest.SpyInstance; - let spyAcceptEventEmit: jest.SpyInstance; - - let app: INestApplication; - let seedingSource: SeedingSource; - let otpService: OtpService; - let invitationAcceptanceService: InvitationAcceptanceService; - let settings: InvitationSettingsInterface; - - let testUser: UserEntityInterface; - let testInvitation: InvitationEntityInterface; - - beforeEach(async () => { - spyEmailService = jest - .spyOn(EmailService.prototype, 'sendMail') - .mockImplementation(async () => undefined); - - const testingModule: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - app = testingModule.createNestApplication(); - await app.init(); - - invitationAcceptanceService = - testingModule.get( - InvitationAcceptanceService, - ); - - otpService = testingModule.get(OtpService); - - settings = testingModule.get( - INVITATION_MODULE_SETTINGS_TOKEN, - ); - - spyAcceptEventEmit = jest.spyOn( - InvitationAcceptedEventAsync.prototype, - 'emit', - ); - - seedingSource = new SeedingSource({ - dataSource: testingModule.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - const userFactory = new UserFactory({ - entity: UserEntityFixture, - seedingSource, - }); - - const invitationFactory = new InvitationFactory({ - entity: InvitationEntityFixture, - seedingSource, - }); - - testUser = await userFactory.create(); - testInvitation = await invitationFactory.create({ - userId: testUser.id, - category, - }); - }); - - afterEach(async () => { - jest.clearAllMocks(); - if (app) await app.close(); - }); - - it('Validate passcode', async () => { - const otp = await createOtp(settings, otpService, testUser, category); - - const validOtp = await invitationAcceptanceService.validatePasscode( - otp.passcode, - category, - ); - expect(validOtp?.assigneeId).toEqual(testUser.id); - }); - - it('Validate passcode (invalid)', async () => { - const invalidOtp = await invitationAcceptanceService.validatePasscode( - 'FAKE_PASSCODE', - category, - ); - - expect(invalidOtp).toBeNull(); - }); - - it('Accept invite and update password', async () => { - const otp = await createOtp(settings, otpService, testUser, category); - - const inviteAccepted = await invitationAcceptanceService.accept({ - code: testInvitation.code, - passcode: otp.passcode, - payload: { - newPassword: 'hOdv2A2h%', - }, - }); - - expect(spyEmailService).toHaveBeenCalledTimes(1); - expect(spyAcceptEventEmit).toHaveBeenCalledTimes(1); - expect(inviteAccepted).toEqual(true); - }); - - it('Accept invite and update password (fail)', async () => { - const inviteAccepted = await invitationAcceptanceService.accept({ - code: testInvitation.code, - passcode: 'FAKE_PASSCODE', - }); - - expect(spyEmailService).toHaveBeenCalledTimes(0); - expect(spyAcceptEventEmit).toHaveBeenCalledTimes(0); - expect(inviteAccepted).toEqual(false); - }); -}); - -const createOtp = async ( - settings: InvitationSettingsInterface, - otpService: OtpService, - user: UserInterface, - category: string, - clearOnCreate?: boolean, -): Promise => { - const { assignment, type, expiresIn } = settings.otp; - - const otp = await otpService.create({ - assignment, - otp: { - category, - type, - expiresIn, - assigneeId: user.id, - }, - clearOnCreate, - }); - - expect(otp).toBeTruthy(); - expect(otp.passcode).toBeTruthy(); - expect(otp.expirationDate).toBeTruthy(); - expect(otp.category).toEqual(category); - expect(otp.type).toEqual(type); - expect(otp.assigneeId).toEqual(user.id); - - return otp; -}; diff --git a/packages/nestjs-invitation/src/services/invitation-acceptance.service.ts b/packages/nestjs-invitation/src/services/invitation-acceptance.service.ts deleted file mode 100644 index 688be897f..000000000 --- a/packages/nestjs-invitation/src/services/invitation-acceptance.service.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { Inject, Logger } from '@nestjs/common'; - -import { - AssigneeRelationInterface, - InvitationInterface, - LiteralObject, - RepositoryInterface, - InjectDynamicRepository, - InvitationEntityInterface, -} from '@concepta/nestjs-common'; - -import { InvitationAcceptedEventAsync } from '../events/invitation-accepted.event'; -import { InvitationNotFoundException } from '../exceptions/invitation-not-found.exception'; -import { InvitationSendMailException } from '../exceptions/invitation-send-mail.exception'; -import { InvitationUserUndefinedException } from '../exceptions/invitation-user-undefined.exception'; -import { InvitationException } from '../exceptions/invitation.exception'; -import { InvitationAcceptOptionsInterface } from '../interfaces/options/invitation-accept-options.interface'; -import { InvitationSettingsInterface } from '../interfaces/options/invitation-settings.interface'; -import { InvitationEmailServiceInterface } from '../interfaces/services/invitation-email-service.interface'; -import { InvitationOtpServiceInterface } from '../interfaces/services/invitation-otp-service.interface'; -import { InvitationUserModelServiceInterface } from '../interfaces/services/invitation-user-model.service.interface'; -import { - INVITATION_MODULE_EMAIL_SERVICE_TOKEN, - INVITATION_MODULE_INVITATION_ENTITY_KEY, - INVITATION_MODULE_OTP_SERVICE_TOKEN, - INVITATION_MODULE_SETTINGS_TOKEN, - INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN, -} from '../invitation.constants'; - -import { InvitationRevocationService } from './invitation-revocation.service'; - -export class InvitationAcceptanceService { - constructor( - @Inject(INVITATION_MODULE_SETTINGS_TOKEN) - private readonly settings: InvitationSettingsInterface, - @InjectDynamicRepository(INVITATION_MODULE_INVITATION_ENTITY_KEY) - protected readonly invitationRepo: RepositoryInterface, - @Inject(INVITATION_MODULE_EMAIL_SERVICE_TOKEN) - private readonly emailService: InvitationEmailServiceInterface, - @Inject(INVITATION_MODULE_OTP_SERVICE_TOKEN) - private readonly otpService: InvitationOtpServiceInterface, - private readonly invitationRevocationService: InvitationRevocationService, - @Inject(INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN) - private readonly userModelService: InvitationUserModelServiceInterface, - ) {} - - /** - * Activate user's account by providing its OTP passcode and the new password. - */ - async accept(options: InvitationAcceptOptionsInterface): Promise { - const { code, passcode, payload } = options; - - let invitation: InvitationInterface | null; - let category: string | undefined = undefined; - let email: string | undefined = undefined; - - // get the invitation - try { - invitation = await this.getOneByCode(code); - } catch (e: unknown) { - throw new InvitationException({ originalError: e }); - } - - if (!invitation) { - throw new InvitationNotFoundException(); - } - - // get the user - const user = await this.userModelService.byId(invitation.userId); - - if (!user) { - throw new InvitationUserUndefinedException(); - } - - if (invitation) { - category = invitation.category; - email = user.email; - } else { - throw new InvitationNotFoundException(); - } - - // get otp by passcode, but no delete it until all workflow pass - const otp = await this.validatePasscode(passcode, category, true); - - // did we get an otp? - if (otp) { - const success = await this.dispatchEvent(invitation, payload); - - if (success) { - await this.invitationRevocationService.revokeAll({ - email, - category, - }); - - await this.sendEmail(email); - - return true; - } - } - - return false; - } - - protected async dispatchEvent( - invitation: InvitationInterface, - payload?: LiteralObject, - ): Promise { - const invitationAcceptedEventAsync = new InvitationAcceptedEventAsync({ - invitation, - data: payload, - }); - - const eventResult = await invitationAcceptedEventAsync.emit(); - - return eventResult.every((it) => it === true); - } - - /** - * Send the invitation accepted email. - * - * @param email - Email - */ - async sendEmail(email: string): Promise { - const { from, baseUrl } = this.settings.email; - const { subject, fileName, logo } = - this.settings.email.templates.invitationAccepted; - - try { - await this.emailService.sendMail({ - from, - subject, - to: email, - template: fileName, - context: { - logo: `${baseUrl}/${logo}`, - }, - }); - } catch (e: unknown) { - throw new InvitationSendMailException(email, { - originalError: e, - }); - } - } - - /** - * Get one invitation by code. - * - * @param code - Pass code string - */ - async getOneByCode(code: string): Promise { - return this.invitationRepo.findOne({ - where: { code }, - }); - } - - /** - * Validate passcode and return it's user. - * - * @param passcode - User's passcode - * @param category - Category - * @param deleteIfValid - Flag to delete if valid or not - */ - async validatePasscode( - passcode: string, - category: string, - deleteIfValid = false, - ): Promise { - try { - // extract required properties - const { assignment } = this.settings.otp; - - // validate passcode return passcode's user was found - return this.otpService.validate( - assignment, - { category, passcode }, - deleteIfValid, - ); - } catch (e) { - Logger.error(e); - return null; - } - } - - async validate(code: string, passcode: string): Promise { - let invitation: InvitationInterface | null | undefined; - - try { - invitation = await this.getOneByCode(code); - } catch (e) { - Logger.error(e); - } - - if (!invitation) { - throw new InvitationNotFoundException(); - } - - const { category } = invitation; - - const otp = await this.validatePasscode(passcode, category); - - if (!otp) { - throw new InvitationNotFoundException(); - } - } -} diff --git a/packages/nestjs-invitation/src/services/invitation-attempt.service.spec.ts b/packages/nestjs-invitation/src/services/invitation-attempt.service.spec.ts deleted file mode 100644 index 1589780f5..000000000 --- a/packages/nestjs-invitation/src/services/invitation-attempt.service.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { InvitationInterface } from '@concepta/nestjs-common'; - -import { InvitationNotFoundException } from '../exceptions/invitation-not-found.exception'; - -import { InvitationAcceptanceService } from './invitation-acceptance.service'; -import { InvitationAttemptService } from './invitation-attempt.service'; -import { InvitationSendService } from './invitation-send.service'; - -describe('InvitationAttemptService', () => { - let service: InvitationAttemptService; - let invitationAcceptanceService: jest.Mocked; - let invitationSendService: jest.Mocked; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - InvitationAttemptService, - { - provide: InvitationAcceptanceService, - useValue: { - getOneByCode: jest.fn(), - }, - }, - { - provide: InvitationSendService, - useValue: { - send: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get(InvitationAttemptService); - invitationAcceptanceService = module.get(InvitationAcceptanceService); - invitationSendService = module.get(InvitationSendService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('send', () => { - it('should successfully send an invitation', async () => { - const mockInvitation = { - id: 'test-id', - userId: 'test-user-id', - code: 'test-code', - category: 'test-category', - active: true, - constraints: {}, - dateCreated: new Date(), - dateUpdated: new Date(), - dateDeleted: null, - } as InvitationInterface; - - invitationAcceptanceService.getOneByCode.mockResolvedValue( - mockInvitation, - ); - invitationSendService.send.mockResolvedValue(undefined); - - await service.send('test-code'); - - expect(invitationAcceptanceService.getOneByCode).toHaveBeenCalledWith( - 'test-code', - ); - expect(invitationSendService.send).toHaveBeenCalledWith({ - id: 'test-id', - userId: 'test-user-id', - code: 'test-code', - category: 'test-category', - }); - }); - - it('should throw InvitationNotFoundException when invitation is not found', async () => { - invitationAcceptanceService.getOneByCode.mockResolvedValue(null); - - await expect(service.send('invalid-code')).rejects.toThrow( - InvitationNotFoundException, - ); - expect(invitationAcceptanceService.getOneByCode).toHaveBeenCalledWith( - 'invalid-code', - ); - expect(invitationSendService.send).not.toHaveBeenCalled(); - }); - - it('should throw InvitationNotFoundException when getOneByCode throws an error', async () => { - invitationAcceptanceService.getOneByCode.mockRejectedValue( - new Error('Test error'), - ); - - await expect(service.send('test-code')).rejects.toThrow( - InvitationNotFoundException, - ); - expect(invitationAcceptanceService.getOneByCode).toHaveBeenCalledWith( - 'test-code', - ); - expect(invitationSendService.send).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/nestjs-invitation/src/services/invitation-attempt.service.ts b/packages/nestjs-invitation/src/services/invitation-attempt.service.ts deleted file mode 100644 index 226f9b646..000000000 --- a/packages/nestjs-invitation/src/services/invitation-attempt.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { InvitationInterface } from '@concepta/nestjs-common'; - -import { InvitationNotFoundException } from '../exceptions/invitation-not-found.exception'; -import { InvitationAttemptServiceInterface } from '../interfaces/services/invitation-attempt-service.interface'; - -import { InvitationAcceptanceService } from './invitation-acceptance.service'; -import { InvitationSendService } from './invitation-send.service'; - -@Injectable() -export class InvitationAttemptService - implements InvitationAttemptServiceInterface -{ - constructor( - private readonly invitationAcceptanceService: InvitationAcceptanceService, - private readonly invitationSendService: InvitationSendService, - ) {} - - async send(code: string): Promise { - let invitation: InvitationInterface | null | undefined; - - try { - invitation = await this.invitationAcceptanceService.getOneByCode(code); - } catch (e: unknown) { - Logger.error(e); - } - - if (!invitation) { - throw new InvitationNotFoundException(); - } - - const { id, category, userId } = invitation; - - await this.invitationSendService.send({ - id, - userId, - code, - category, - }); - } -} diff --git a/packages/nestjs-invitation/src/services/invitation-model.service.ts b/packages/nestjs-invitation/src/services/invitation-model.service.ts deleted file mode 100644 index aaf6912bf..000000000 --- a/packages/nestjs-invitation/src/services/invitation-model.service.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ModelService, - RepositoryInterface, - InjectDynamicRepository, - InvitationEntityInterface, -} from '@concepta/nestjs-common'; - -import { InvitationCreateDto } from '../dto/invitation-create.dto'; -import { InvitationCreatableInterface } from '../interfaces/domain/invitation-creatable.interface'; -import { InvitationModelServiceInterface } from '../interfaces/services/invitation-model-service.interface'; -import { INVITATION_MODULE_INVITATION_ENTITY_KEY } from '../invitation.constants'; - -/** - * Invitation model service - */ -@Injectable() -export class InvitationModelService - extends ModelService< - InvitationEntityInterface, - InvitationCreatableInterface, - never - > - implements InvitationModelServiceInterface -{ - /** - * Constructor - * - * @param repo - instance of the invitation repo - */ - constructor( - @InjectDynamicRepository(INVITATION_MODULE_INVITATION_ENTITY_KEY) - repo: RepositoryInterface, - ) { - super(repo); - } - - protected createDto = InvitationCreateDto; - protected updateDto!: never; -} diff --git a/packages/nestjs-invitation/src/services/invitation-revocation.service.spec.ts b/packages/nestjs-invitation/src/services/invitation-revocation.service.spec.ts deleted file mode 100644 index b5b205c61..000000000 --- a/packages/nestjs-invitation/src/services/invitation-revocation.service.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { - RepositoryInterface, - UserEntityInterface, - INVITATION_MODULE_CATEGORY_USER_KEY, - getDynamicRepositoryToken, - InvitationEntityInterface, -} from '@concepta/nestjs-common'; -import { OtpService } from '@concepta/nestjs-otp'; -import { UserFactory } from '@concepta/nestjs-user/src/seeding'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { INVITATION_MODULE_INVITATION_ENTITY_KEY } from '../invitation.constants'; -import { InvitationFactory } from '../seeding/invitation.factory'; - -import { InvitationRevocationService } from './invitation-revocation.service'; - -import { AppModuleFixture } from '../__fixtures__/app.module.fixture'; -import { InvitationEntityFixture } from '../__fixtures__/invitation/entities/invitation.entity.fixture'; -import { UserEntityFixture } from '../__fixtures__/user/entities/user.entity.fixture'; - -describe(InvitationRevocationService, () => { - const category = INVITATION_MODULE_CATEGORY_USER_KEY; - - let app: INestApplication; - let seedingSource: SeedingSource; - let otpService: OtpService; - let invitationRepo: RepositoryInterface; - let invitationRevocationService: InvitationRevocationService; - - let testUser: UserEntityInterface; - let invitationFactory: InvitationFactory; - - beforeEach(async () => { - const testingModule: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - app = testingModule.createNestApplication(); - await app.init(); - - invitationRepo = testingModule.get< - RepositoryInterface - >(getDynamicRepositoryToken(INVITATION_MODULE_INVITATION_ENTITY_KEY)); - - invitationRevocationService = - testingModule.get( - InvitationRevocationService, - ); - - otpService = testingModule.get(OtpService); - - seedingSource = new SeedingSource({ - dataSource: testingModule.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - const userFactory = new UserFactory({ - entity: UserEntityFixture, - seedingSource, - }); - - invitationFactory = new InvitationFactory({ - entity: InvitationEntityFixture, - seedingSource, - }); - - testUser = await userFactory.create(); - }); - - afterEach(async () => { - jest.clearAllMocks(); - if (app) await app.close(); - }); - - describe(InvitationRevocationService.prototype.revokeAll, () => { - it('Should revoke all user invites', async () => { - const spyOtpClear = jest.spyOn(otpService, 'clear'); - - await invitationFactory.create({ - userId: testUser.id, - category, - }); - - const invitations = await invitationRepo.find({ - where: { - userId: testUser.id, - }, - }); - - expect(invitations.length).toEqual(1); - expect(invitations[0].userId).toEqual(testUser.id); - - await invitationRevocationService.revokeAll({ - email: testUser.email, - category, - }); - - // TODO: TYPEORM - review if we need count - const allInvitations = await invitationRepo.find(); - - expect(allInvitations.length).toEqual(0); - expect(spyOtpClear).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/nestjs-invitation/src/services/invitation-revocation.service.ts b/packages/nestjs-invitation/src/services/invitation-revocation.service.ts deleted file mode 100644 index c378f506d..000000000 --- a/packages/nestjs-invitation/src/services/invitation-revocation.service.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Inject } from '@nestjs/common'; - -import { - ReferenceIdInterface, - RepositoryInterface, - InjectDynamicRepository, - InvitationEntityInterface, -} from '@concepta/nestjs-common'; - -import { InvitationException } from '../exceptions/invitation.exception'; -import { InvitationRevokeOptionsInterface } from '../interfaces/options/invitation-revoke-options.interface'; -import { InvitationSettingsInterface } from '../interfaces/options/invitation-settings.interface'; -import { InvitationOtpServiceInterface } from '../interfaces/services/invitation-otp-service.interface'; -import { InvitationUserModelServiceInterface } from '../interfaces/services/invitation-user-model.service.interface'; -import { - INVITATION_MODULE_INVITATION_ENTITY_KEY, - INVITATION_MODULE_OTP_SERVICE_TOKEN, - INVITATION_MODULE_SETTINGS_TOKEN, - INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN, -} from '../invitation.constants'; - -export class InvitationRevocationService { - constructor( - @Inject(INVITATION_MODULE_SETTINGS_TOKEN) - private readonly settings: InvitationSettingsInterface, - @InjectDynamicRepository(INVITATION_MODULE_INVITATION_ENTITY_KEY) - protected readonly invitationRepo: RepositoryInterface, - @Inject(INVITATION_MODULE_OTP_SERVICE_TOKEN) - private readonly otpService: InvitationOtpServiceInterface, - @Inject(INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN) - private readonly userModelService: InvitationUserModelServiceInterface, - ) {} - - /** - * Revoke all invitations for a given email address in a specific category. - * - * @param options - The revocation options containing email and category - */ - async revokeAll(options: InvitationRevokeOptionsInterface): Promise { - const { email, category } = options; - // get the user by email - const user = await this.userModelService.byEmail(email); - - // did we find a user? - if (user) { - // delete all invitations - await this.deleteAllInvitations(user, category); - // clear all otps - await this.clearAllOtps(user, category); - } - } - - /** - * Clear all user OTPs by category - * - * @param user - User object - * @param category - Category - */ - protected async clearAllOtps(user: ReferenceIdInterface, category: string) { - // extract required otp properties - const { assignment } = this.settings.otp; - - if (!assignment) { - throw new InvitationException({ - message: 'OPT assignment setting was not defined', - }); - } - - // clear all user's otps in DB - return this.otpService.clear(assignment, { - category, - assigneeId: user.id, - }); - } - - /** - * Delete all user invitations by category. - * - * @param user - User object - * @param category - Category - */ - protected async deleteAllInvitations( - user: ReferenceIdInterface, - category: string, - ) { - let invitations: InvitationEntityInterface[]; - - try { - invitations = await this.invitationRepo.find({ - where: { - userId: user.id, - category, - }, - }); - } catch (e: unknown) { - throw new InvitationException({ - message: 'Fatal error while looking up invitations to delete.', - originalError: e, - }); - } - - // remove the invitations - try { - return await this.invitationRepo.remove(invitations); - } catch (e: unknown) { - throw new InvitationException({ - message: 'Fatal error while removing invitations.', - originalError: e, - }); - } - } -} diff --git a/packages/nestjs-invitation/src/services/invitation-send.service.spec.ts b/packages/nestjs-invitation/src/services/invitation-send.service.spec.ts deleted file mode 100644 index 12f4ded0c..000000000 --- a/packages/nestjs-invitation/src/services/invitation-send.service.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { - RepositoryInterface, - INVITATION_MODULE_CATEGORY_USER_KEY, - getDynamicRepositoryToken, - UserEntityInterface, -} from '@concepta/nestjs-common'; -import { EmailService } from '@concepta/nestjs-email'; -import { UserFactory } from '@concepta/nestjs-user/src/seeding'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { InvitationSettingsInterface } from '../interfaces/options/invitation-settings.interface'; -import { INVITATION_MODULE_SETTINGS_TOKEN } from '../invitation.constants'; - -import { InvitationSendService } from './invitation-send.service'; - -import { AppModuleFixture } from '../__fixtures__/app.module.fixture'; -import { UserOtpEntityFixture } from '../__fixtures__/user/entities/user-otp.entity.fixture'; -import { UserEntityFixture } from '../__fixtures__/user/entities/user.entity.fixture'; - -describe(InvitationSendService, () => { - let spyEmailService: jest.SpyInstance; - - let app: INestApplication; - let seedingSource: SeedingSource; - let settings: InvitationSettingsInterface; - let userOtpRepo: RepositoryInterface; - let invitationSendService: InvitationSendService; - - let testUser: UserEntityInterface; - - beforeEach(async () => { - spyEmailService = jest - .spyOn(EmailService.prototype, 'sendMail') - .mockImplementation(async () => undefined); - - const testingModule: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - app = testingModule.createNestApplication(); - await app.init(); - - settings = testingModule.get( - INVITATION_MODULE_SETTINGS_TOKEN, - ); - - userOtpRepo = testingModule.get>( - getDynamicRepositoryToken('user-otp'), - ); - - invitationSendService = testingModule.get( - InvitationSendService, - ); - - seedingSource = new SeedingSource({ - dataSource: testingModule.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - const userFactory = new UserFactory({ - entity: UserEntityFixture, - seedingSource, - }); - - testUser = await userFactory.create(); - }); - - afterEach(async () => { - jest.clearAllMocks(); - if (app) await app.close(); - }); - - describe(InvitationSendService.prototype.send, () => { - it('Should send invitation email', async () => { - const inviteCode = randomUUID(); - - await invitationSendService.send({ - id: 'abcdefg', - userId: testUser.id, - code: inviteCode, - category: INVITATION_MODULE_CATEGORY_USER_KEY, - }); - - const otps = await userOtpRepo.find({ - where: { assigneeId: testUser.id }, - }); - - expect(otps.length).toEqual(1); - expect(otps[0].category).toEqual(INVITATION_MODULE_CATEGORY_USER_KEY); - expect(spyEmailService).toHaveBeenCalledTimes(1); - - const { passcode, expirationDate } = otps[0]; - - expect(spyEmailService).toHaveBeenCalledWith({ - to: testUser.email, - from: settings.email.from, - context: { - logo: `${settings.email.baseUrl}/${settings.email.templates.invitation.logo}`, - tokenUrl: `${settings.email.baseUrl}/?code=${inviteCode}&passcode=${passcode}`, - tokenExp: expirationDate, - }, - subject: settings.email.templates.invitation.subject, - template: settings.email.templates.invitation.fileName, - }); - }); - }); -}); diff --git a/packages/nestjs-invitation/src/services/invitation-send.service.ts b/packages/nestjs-invitation/src/services/invitation-send.service.ts deleted file mode 100644 index c2577469b..000000000 --- a/packages/nestjs-invitation/src/services/invitation-send.service.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { Inject } from '@nestjs/common'; - -import { - InvitationInterface, - InvitationUserInterface, -} from '@concepta/nestjs-common'; - -import { InvitationNotFoundException } from '../exceptions/invitation-not-found.exception'; -import { InvitationSendMailException } from '../exceptions/invitation-send-mail.exception'; -import { InvitationUserUndefinedException } from '../exceptions/invitation-user-undefined.exception'; -import { InvitationCreateInviteInterface } from '../interfaces/domain/invitation-create-invite.interface'; -import { InvitationSendInviteInterface } from '../interfaces/domain/invitation-send-invite.interface'; -import { InvitationSendInvitationEmailOptionsInterface } from '../interfaces/options/invitation-send-invitation-email-options.interface'; -import { InvitationSettingsInterface } from '../interfaces/options/invitation-settings.interface'; -import { InvitationEmailServiceInterface } from '../interfaces/services/invitation-email-service.interface'; -import { InvitationOtpServiceInterface } from '../interfaces/services/invitation-otp-service.interface'; -import { InvitationSendServiceInterface } from '../interfaces/services/invitation-send-service.interface'; -import { InvitationUserModelServiceInterface } from '../interfaces/services/invitation-user-model.service.interface'; -import { - INVITATION_MODULE_EMAIL_SERVICE_TOKEN, - INVITATION_MODULE_OTP_SERVICE_TOKEN, - INVITATION_MODULE_SETTINGS_TOKEN, - INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN, -} from '../invitation.constants'; - -import { InvitationModelService } from './invitation-model.service'; - -export class InvitationSendService implements InvitationSendServiceInterface { - constructor( - @Inject(INVITATION_MODULE_SETTINGS_TOKEN) - protected readonly settings: InvitationSettingsInterface, - @Inject(INVITATION_MODULE_EMAIL_SERVICE_TOKEN) - protected readonly emailService: InvitationEmailServiceInterface, - @Inject(INVITATION_MODULE_OTP_SERVICE_TOKEN) - protected readonly otpService: InvitationOtpServiceInterface, - @Inject(INVITATION_MODULE_USER_MODEL_SERVICE_TOKEN) - protected readonly userModelService: InvitationUserModelServiceInterface, - protected readonly invitationModelService: InvitationModelService, - ) {} - - /** - * Creates a new invitation - * - * @param createInviteDto - The invitation creation data transfer object containing email and - * optional constraints - * @returns A promise that resolves to the created invitation with id, user, code and - * category - */ - async create( - createInviteDto: InvitationCreateInviteInterface, - ): Promise { - const { email } = createInviteDto; - const user = await this.getUser({ - email, - }); - - const invite = await this.invitationModelService.create({ - ...createInviteDto, - userId: user.id, - code: randomUUID(), - }); - - return invite; - } - - async send( - invitation: Pick | InvitationSendInviteInterface, - ): Promise { - const { - assignment, - type, - expiresIn, - clearOtpOnCreate, - rateSeconds, - rateThreshold, - } = this.settings.otp; - - let theInvitation: InvitationSendInviteInterface | null; - - if (invitation && 'category' in invitation) { - theInvitation = invitation; - } else { - theInvitation = await this.invitationModelService.byId(invitation.id); - } - - if (!theInvitation) { - throw new InvitationNotFoundException(); - } - - const { category, userId, code } = theInvitation; - - // create an OTP for this invite - const otp = await this.otpService.create({ - assignment, - otp: { - category, - type, - expiresIn, - assigneeId: userId, - }, - clearOnCreate: clearOtpOnCreate, - rateSeconds, - rateThreshold, - }); - - // find the user by id - const user = await this.userModelService.byId(userId); - - if (!user) { - throw new InvitationUserUndefinedException(); - } - - // send the invite email - await this.sendInvitationEmail({ - email: user.email, - code, - passcode: otp.passcode, - resetTokenExp: otp.expirationDate, - }); - } - - async getUser( - options: Pick, - ): Promise { - const { email } = options; - let user = await this.userModelService.byEmail(email); - - if (!user) { - user = await this.userModelService.create({ - email, - username: email, - }); - } - - return user; - } - - async sendInvitationEmail( - options: InvitationSendInvitationEmailOptionsInterface, - ): Promise { - const { email, code, passcode, resetTokenExp } = options; - const { from, baseUrl } = this.settings.email; - const { subject, fileName, logo } = - this.settings.email.templates.invitation; - - try { - await this.emailService.sendMail({ - from, - subject, - to: email, - template: fileName, - context: { - logo: `${baseUrl}/${logo}`, - tokenUrl: `${baseUrl}/?code=${code}&passcode=${passcode}`, - tokenExp: resetTokenExp, - }, - }); - } catch (e: unknown) { - throw new InvitationSendMailException(email, { - originalError: e, - }); - } - } -} diff --git a/packages/nestjs-invitation/src/services/invitation.service.spec.ts b/packages/nestjs-invitation/src/services/invitation.service.spec.ts deleted file mode 100644 index a4519be19..000000000 --- a/packages/nestjs-invitation/src/services/invitation.service.spec.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { InvitationInterface } from '@concepta/nestjs-common'; - -import { InvitationCreateInviteInterface } from '../interfaces/domain/invitation-create-invite.interface'; -import { InvitationAcceptOptionsInterface } from '../interfaces/options/invitation-accept-options.interface'; -import { InvitationRevokeOptionsInterface } from '../interfaces/options/invitation-revoke-options.interface'; - -import { InvitationAcceptanceService } from './invitation-acceptance.service'; -import { InvitationRevocationService } from './invitation-revocation.service'; -import { InvitationSendService } from './invitation-send.service'; -import { InvitationService } from './invitation.service'; - -describe(InvitationService.name, () => { - let service: InvitationService; - let invitationSendService: jest.Mocked; - let invitationAcceptanceService: jest.Mocked; - let invitationRevocationService: jest.Mocked; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - InvitationService, - { - provide: InvitationSendService, - useValue: { - create: jest.fn(), - send: jest.fn(), - }, - }, - { - provide: InvitationAcceptanceService, - useValue: { - accept: jest.fn(), - }, - }, - { - provide: InvitationRevocationService, - useValue: { - revokeAll: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get(InvitationService); - invitationSendService = module.get(InvitationSendService); - invitationAcceptanceService = module.get(InvitationAcceptanceService); - invitationRevocationService = module.get(InvitationRevocationService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe(InvitationService.prototype.create.name, () => { - it('should create an invitation using the send service', async () => { - // Arrange - const createInviteDto: InvitationCreateInviteInterface = { - email: 'test@example.com', - category: 'test-category', - }; - const expectedInvitation = { id: '123' } as InvitationInterface; - invitationSendService.create.mockResolvedValue(expectedInvitation); - - // Act - const result = await service.create(createInviteDto); - - // Assert - expect(invitationSendService.create).toHaveBeenCalledWith( - createInviteDto, - ); - expect(result).toBe(expectedInvitation); - }); - }); - - describe(InvitationService.prototype.send.name, () => { - it('should send an invitation using the send service', async () => { - // Arrange - const invitation = { id: '123' } as Pick; - invitationSendService.send.mockResolvedValue(undefined); - - // Act - await service.send(invitation); - - // Assert - expect(invitationSendService.send).toHaveBeenCalledWith(invitation); - }); - }); - - describe(InvitationService.prototype.accept.name, () => { - it('should accept an invitation using the acceptance service', async () => { - // Arrange - const options: InvitationAcceptOptionsInterface = { - code: '123456', - passcode: '123456', - }; - const expectedResult = true; - invitationAcceptanceService.accept.mockResolvedValue(expectedResult); - - // Act - const result = await service.accept(options); - - // Assert - expect(invitationAcceptanceService.accept).toHaveBeenCalledWith(options); - expect(result).toBe(expectedResult); - }); - }); - - describe(InvitationService.prototype.revokeAll.name, () => { - it('should revoke all invitations using the revocation service', async () => { - // Arrange - const options: InvitationRevokeOptionsInterface = { - email: 'test@example.com', - category: 'test-category', - }; - invitationRevocationService.revokeAll.mockResolvedValue(undefined); - - // Act - await service.revokeAll(options); - - // Assert - expect(invitationRevocationService.revokeAll).toHaveBeenCalledWith( - options, - ); - }); - }); -}); diff --git a/packages/nestjs-invitation/src/services/invitation.service.ts b/packages/nestjs-invitation/src/services/invitation.service.ts deleted file mode 100644 index ba9ed31d2..000000000 --- a/packages/nestjs-invitation/src/services/invitation.service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { InvitationInterface } from '@concepta/nestjs-common'; - -import { InvitationCreateInviteInterface } from '../interfaces/domain/invitation-create-invite.interface'; -import { InvitationAcceptOptionsInterface } from '../interfaces/options/invitation-accept-options.interface'; -import { InvitationRevokeOptionsInterface } from '../interfaces/options/invitation-revoke-options.interface'; -import { InvitationServiceInterface } from '../interfaces/services/invitation-service.interface'; - -import { InvitationAcceptanceService } from './invitation-acceptance.service'; -import { InvitationRevocationService } from './invitation-revocation.service'; -import { InvitationSendService } from './invitation-send.service'; - -@Injectable() -export class InvitationService implements InvitationServiceInterface { - constructor( - private readonly invitationSendService: InvitationSendService, - private readonly invitationAcceptanceService: InvitationAcceptanceService, - private readonly invitationRevocationService: InvitationRevocationService, - ) {} - async create(createInviteDto: InvitationCreateInviteInterface) { - return this.invitationSendService.create(createInviteDto); - } - - async send(invitation: Pick): Promise { - return this.invitationSendService.send(invitation); - } - - /** - * Activate user's account by providing its OTP passcode and the new password. - */ - async accept(options: InvitationAcceptOptionsInterface): Promise { - return this.invitationAcceptanceService.accept(options); - } - - /** - * Revoke all invitations for a given email address in a specific category. - * - * @param options - The revocation options containing email and category - */ - async revokeAll(options: InvitationRevokeOptionsInterface): Promise { - return this.invitationRevocationService.revokeAll(options); - } -} diff --git a/packages/nestjs-invitation/tsconfig.json b/packages/nestjs-invitation/tsconfig.json index d7686b0f4..b187498ea 100644 --- a/packages/nestjs-invitation/tsconfig.json +++ b/packages/nestjs-invitation/tsconfig.json @@ -4,10 +4,13 @@ "composite": true, "rootDir": "./src", "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", "typeRoots": [ "./node_modules/@types", - "../../node_modules/@types" - ] + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] }, "include": [ "src/**/*.ts" diff --git a/packages/nestjs-jwt/README.md b/packages/nestjs-jwt/README.md deleted file mode 100644 index 1c88d5fe5..000000000 --- a/packages/nestjs-jwt/README.md +++ /dev/null @@ -1,419 +0,0 @@ -# Rockets NestJS JWT - -A flexible JWT utilities module for signing and validating tokens. - -This module extends/wraps the [@nestjs/jwt](https://www.npmjs.com/package/@nestjs/jwt) -module. - -## Project - -[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-jwt)](https://www.npmjs.com/package/@concepta/nestjs-jwt) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-jwt)](https://www.npmjs.com/package/@concepta/nestjs-jwt) -[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) -[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) - -## Table of Contents - -- [Tutorials](#tutorials) - - [Introduction](#introduction) - - [Overview of the Library](#overview-of-the-library) - - [Purpose and Key Features](#purpose-and-key-features) - - [Installation](#installation) - - [Basic Setup](#basic-setup) - - [Creating custom jwtIssueService](#creating-custom-jwtissueservice) - - [Environment Variables](#environment-variables) -- [How to Guides](#how-to-guides) - - [1. How to Set Up JwtModule with forRoot](#1-how-to-set-up-jwtmodule-with-forroot) - - [2. How to Configure JwtModule Settings](#2-how-to-configure-jwtmodule-settings) - - [3. Overriding Defaults](#3-overriding-defaults) - - [JwtAccessService](#jwtaccessservice) - - [JwtRefreshService](#jwtrefreshservice) - - [JwtIssueTokenService](#jwtissuetokenservice) - - [JwtVerifyTokenService](#jwtverifytokenservice) - - [JwtService](#jwtservice) -- [Explanation](#explanation) - - [Conceptual Overview](#conceptual-overview) - - [What is This Library?](#what-is-this-library) - - [Benefits of Using This Library](#benefits-of-using-this-library) - - [Design Choices](#design-choices) - - [Global, Synchronous vs Asynchronous Registration](#global-synchronous-vs-asynchronous-registration) - - [Integration Details](#integration-details) - - [Integrating with Other Modules](#integrating-with-other-modules) - -# Tutorials - -## Introduction - -### Overview of the Library - -This module is designed to manage JWT authentication processes within a NestJS -application. It includes services for issuing JWTs, validating user credentials, -and verifying tokens. The services handle the generation of access and refresh -tokens, ensure users are active and meet authentication criteria, and perform -token validity checks, including additional validations if necessary. This -comprehensive approach ensures secure user authentication and efficient token -management. - -### Purpose and Key Features - -- **Secure Token Management**: Provides robust mechanisms for issuing and - managing access and refresh tokens, ensuring secure and efficient token - lifecycle management. -- **Abstract User Validation Service**: Offers an abstract service to validate - user credentials and check user activity status, ensuring that only eligible - users can authenticate. This abstract nature requires implementations to - define specific validation logic, allowing flexibility across different user - models and authentication requirements. -- **Token Verification**: Includes capabilities to verify the authenticity and - validity of tokens, with support for additional custom validations to meet - specific security requirements. -- **Customizable and Extensible**: Designed to be flexible, allowing - customization of token generation, user validation, and token verification - processes to suit different application needs. -- **Integration with NestJS Ecosystem**: Seamlessly integrates with other - NestJS modules and services, leveraging the framework's features for enhanced - functionality and performance. - -### Installation - -To get started, install the `JwtModule` package: - -```sh -yarn add @concepta/nestjs-jwt -``` - -## Basic Setup - -To set up the `JwtModule`, follow the basic setup tutorial in the -[nestjs-authentication README](https://github.com/conceptadev/nestjs-authentication). - -## Creating custom jwtIssueService - -Here we will cover how to override the default services in `JwtModule`. -For example, to override the `JwtIssueTokenService`, follow the steps below: - -1. Create a custom implementation of `JwtIssueTokenService`: - -```ts -import { Injectable } from '@nestjs/common'; -import { - JwtService, - JwtSignServiceInterface, - JwtIssueTokenServiceInterface, -} from '@concepta/nest-jwt'; - -@Injectable() -export class CustomJwtIssueTokenService implements JwtIssueTokenServiceInterface { - constructor( - private readonly jwtAccessService: JwtSignServiceInterface, - private readonly jwtRefreshService: JwtSignServiceInterface, - ) {} - - async accessToken(...args: Parameters) { - return this.jwtAccessService.signAsync(...args); - } - - async refreshToken(...args: Parameters) { - return this.jwtRefreshService.signAsync(...args); - } -} -``` - -1. Provide the custom implementation in your module configuration: - -```ts -import { Module } from '@nestjs/common'; -import { JwtModule } from '@concepta/nestjs-jwt'; -import { CustomJwtIssueTokenService } from './custom-jwt-issue-token.service'; - -@Module({ - imports: [ - JwtModule.forRoot({ - jwtIssueService: CustomJwtIssueTokenService, - settings: { - access: { - secret: 'your-secret-key', - signOptions: { expiresIn: '60s' }, - }, - }, - }), - ], - providers: [CustomJwtIssueTokenService], -}) -export class AppModule {} -``` - -This example shows how to customize the `JwtIssueTokenService` with a custom -implementation. Similar steps can be followed to override other services in -`JwtModule`. - -### Environment Variables - -Configurations available via environment. - -| Variable | Type | Default | | -| ------------------------------- | -------------------- | -------------------------- | ------------------------------- | -| `JWT_MODULE_ACCESS_SECRET` | `` | `randomUUID()` \* see note | Access token secret | -| `JWT_MODULE_ACCESS_EXPIRES_IN` | `` | `'1h'` | Access token expiration length | -| `JWT_MODULE_REFRESH_SECRET` | `` | copied from access secret | Refresh token secret | -| `JWT_MODULE_REFRESH_EXPIRES_IN` | `` | `'1y'` | Refresh token expiration length | - -> \* For security reasons, a random UUID will only be generated for -> the default secret when `NODE_ENV !== 'production'`. - -# How to Guides - -## 1. How to Set Up JwtModule with forRoot - -To set up the `JwtModule`, follow these steps: - -```ts -import { Module } from '@nestjs/common'; -import { JwtModule } from '@concepta/nestjs-jwt'; - -@Module({ - imports: [ - JwtModule.forRoot({}), - ], -}) -export class AppModule {} -``` - -This setup configures the `JwtModule` with global settings and integrates the -`JwtModule` for JWT-based authentication. - -## 2. How to Configure JwtModule Settings - -The `JwtModule` provides several configurable settings to customize its -behavior. Each setting can be defined in the module configuration and will -create default services to be used in other modules. - -### Settings Example - -Here is an example of how to configure each property of the settings: - -```ts -import { Module } from '@nestjs/common'; -import { JwtModule } from '@concepta/nestjs-jwt'; - -@Module({ - imports: [ - JwtModule.forRoot({ - settings: { - access: { - secret: 'your-secret-key', - signOptions: { expiresIn: '60s' }, - }, - }, - }), - ], -}) -export class AppModule {} -``` - -### 3. Overriding Defaults - -To override the default services, you can provide custom implementations for -any of the services. - -#### JwtAccessService - -```ts -@Injectable() -import { JwtService, JwtSignOptions } from '@concepta/nest-jwt'; -class CustomJwtAccessService extends JwtService { - sign(_payload: string, _options?: JwtSignOptions): string { - return 'foo'; - } -} -``` - -#### JwtRefreshService - -```ts -@Injectable() -import { JwtService, JwtSignOptions } from '@concepta/nest-jwt'; -class CustomRefreshJwtAccessService extends JwtService { - sign(_payload: string, _options?: JwtSignOptions): string { - return 'foo'; - } -} -``` - -#### JwtIssueTokenService - -```ts -import { Injectable } from '@nestjs/common'; -import { - JwtService, - JwtSignServiceInterface, - JwtIssueTokenServiceInterface, -} from '@concepta/nest-jwt'; - -@Injectable() -export class CustomJwtIssueTokenService implements JwtIssueTokenServiceInterface { - constructor( - private readonly jwtAccessService: JwtSignServiceInterface, - private readonly jwtRefreshService: JwtSignServiceInterface, - ) {} - - async accessToken(...args: Parameters) { - // Custom implementation - return this.jwtAccessService.signAsync(...args); - } - - async refreshToken(...args: Parameters) { - // Custom implementation - return this.jwtRefreshService.signAsync(...args); - } -} -``` - -#### JwtVerifyTokenService - -```ts -import { Injectable } from '@nestjs/common'; -import { - JwtService, - JwtVerifyServiceInterface, - JwtVerifyTokenServiceInterface, -} from '@concepta/nest-jwt'; - -@Injectable() -export class CustomJwtVerifyTokenService implements JwtVerifyTokenServiceInterface { - constructor(private readonly jwtVerifyService: JwtVerifyServiceInterface) {} - - async accessToken(...args: Parameters) { - // Custom implementation - return this.jwtVerifyService.verifyAsync('access', ...args); - } - - async refreshToken(...args: Parameters) { - // Custom implementation - return this.jwtVerifyService.verifyAsync('refresh', ...args); - } -} -``` - -#### JwtService - -```ts -import { Inject, Injectable } from '@nestjs/common'; -import { JwtServiceInterface } from '@concepta/nest-jwt'; - -@Injectable() -export class CustomJwtService implements JwtServiceInterface { - async signAsync( - ...rest: Parameters - ) { - // custom logic - } - - async verifyAsync( - ...rest: Parameters - ) { - // custom logic - } - - decode(tokenType: JwtTokenType, ...rest: Parameters) { - // custom logic - } -} - -``` - -1. Provide the custom implementations in your module configuration: - -```ts -import { Module } from '@nestjs/common'; -import { JwtModule } from '@concepta/nestjs-jwt'; -import { CustomJwtIssueTokenService } from './custom-jwt-issue-token.service'; -import { CustomJwtVerifyTokenService } from './custom-jwt-verify-token.service'; - -@Module({ - imports: [ - JwtModule.forRoot({ - jwtIssueService: CustomJwtIssueTokenService, - jwtVerifyService: CustomJwtVerifyTokenService, - settings: { - access: { - secret: 'your-secret-key', - signOptions: { expiresIn: '60s' }, - }, - }, - }), - ], - providers: [CustomJwtIssueTokenService, CustomJwtVerifyTokenService], -}) -export class AppModule {} -``` - -This example shows how to customize the `JwtIssueTokenService` and `JwtVerifyTokenService` -with custom implementations. Similar steps can be followed to override other -services in `JwtModule`. - -# Explanation - -## Conceptual Overview - -### What is This Library? - -The `nestjs-jwt` library is a comprehensive solution for managing authentication -processes within a NestJS application. It provides services for issuing JWTs, -validating user credentials, and verifying tokens. The library integrates -seamlessly with other modules in the `nestjs-auth` suite, making it a versatile -choice for various authentication needs. - -### Benefits of Using This Library - -- **Secure Token Management**: Robust mechanisms for issuing and managing access - and refresh tokens. -- **Abstract User Validation Service**: Flexible user validation service that - can be customized to meet specific requirements. -- **Token Verification**: Capabilities to verify the authenticity and validity - of tokens, with support for additional custom validations. -- **Customizable and Extensible**: Designed to be flexible, allowing - customization of token generation, user validation, and token verification - processes. -- **Integration with NestJS Ecosystem**: Seamlessly integrates with other - NestJS modules and services, leveraging the framework's features for enhanced - functionality and performance. - -### Design Choices - -#### Global, Synchronous vs Asynchronous Registration - -The `nestjs-jwt` module supports both synchronous and asynchronous registration: - -- **Global Registration**: Makes the module available throughout the entire - application. This approach is useful when JWT authentication is required - across all or most routes in the application. -- **Synchronous Registration**: This method is used when the configuration - options are static and available at application startup. It simplifies the - setup process and is suitable for most use cases where configuration values do - not depend on external services. -- **Asynchronous Registration**: This method is beneficial when configuration - options need to be retrieved from external sources, such as a database or an - external API, at runtime. It allows for more flexible and dynamic configuration - but requires an asynchronous factory function. - -### Integration Details - -#### Integrating with Other Modules - -The `nestjs-jwt` module integrates smoothly with other modules in the -`nestjs-auth` suite. Here are some integration details: - -- **@concepta/nestjs-auth-jwt**: Use `@concepta/nestjs-auth-jwt` for JWT-based - authentication. Configure it to handle the issuance and verification of JWT - tokens. -- **@concepta/nestjs-auth-local**: Use `@concepta/nestjs-auth-local` for local - authentication strategies such as username and password. -- **@concepta/nestjs-auth-recovery**: Use `@concepta/nestjs-auth-recovery` for - account recovery processes like password reset. -- **@concepta/nestjs-auth-refresh**: Use `@concepta/nestjs-auth-refresh` for - handling token refresh mechanisms. - -By combining these modules, you can create a comprehensive authentication system -that meets various security requirements and user needs. diff --git a/packages/nestjs-jwt/package.json b/packages/nestjs-jwt/package.json deleted file mode 100644 index 1c5e727db..000000000 --- a/packages/nestjs-jwt/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@concepta/nestjs-jwt", - "version": "7.0.0-alpha.10", - "description": "Rockets NestJS JWT Utility", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "license": "BSD-3-Clause", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" - ], - "dependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/jwt": "^11.0.1", - "jsonwebtoken": "^9.0.2", - "passport-jwt": "^4.0.1", - "passport-strategy": "^1.0.0" - }, - "devDependencies": { - "@nestjs/testing": "^11.1.9", - "@types/jsonwebtoken": "9.0.10", - "@types/passport-jwt": "^3.0.13", - "@types/passport-strategy": "^0.2.38", - "express-serve-static-core": "^0.1.1", - "jest-mock-extended": "^4.0.0" - } -} diff --git a/packages/nestjs-jwt/src/config/jwt-default.config.ts b/packages/nestjs-jwt/src/config/jwt-default.config.ts deleted file mode 100644 index c4944e71c..000000000 --- a/packages/nestjs-jwt/src/config/jwt-default.config.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { InternalServerErrorException, Logger } from '@nestjs/common'; -import { registerAs } from '@nestjs/config'; - -import { toMilliseconds } from '@concepta/nestjs-common'; - -import { JwtConfigUndefinedException } from '../exceptions/jwt-config-undefined.exception'; -import { JwtFallbackConfigUndefinedException } from '../exceptions/jwt-fallback-config-undefined.exception'; -import { JwtSettingsInterface } from '../interfaces/jwt-settings.interface'; -import { JWT_MODULE_DEFAULT_SETTINGS_TOKEN } from '../jwt.constants'; - -/** - * Settings defaults. - * - * TODO: need to also get defaults from ENV - */ -export const jwtDefaultConfig = registerAs( - JWT_MODULE_DEFAULT_SETTINGS_TOKEN, - (): JwtSettingsInterface => { - // the default options - const options: JwtSettingsInterface = { - default: { - signOptions: { - expiresIn: toMilliseconds( - process.env?.JWT_MODULE_DEFAULT_EXPIRES_IN, - '1h', - ), - }, - }, - access: { - signOptions: { - expiresIn: toMilliseconds( - process.env?.JWT_MODULE_ACCESS_EXPIRES_IN ?? - process.env?.JWT_MODULE_DEFAULT_EXPIRES_IN, - '1h', - ), - }, - }, - refresh: { - signOptions: { - expiresIn: toMilliseconds( - process.env?.JWT_MODULE_REFRESH_EXPIRES_IN, - '99y', - ), - }, - }, - }; - - configureAccessSecret(options.access); - configureRefreshSecret(options.refresh, options.access); - - return options; - }, -); - -/** - * @internal - */ -function configureAccessSecret(options: JwtSettingsInterface['access']) { - if (!options) { - throw new JwtConfigUndefinedException(); - } - // was an access secret provided? - if (process.env?.JWT_MODULE_ACCESS_SECRET) { - // yes, use it - options.secret = process.env.JWT_MODULE_ACCESS_SECRET; - } else if (process.env?.NODE_ENV === 'production') { - // we are in production, this is now allowed - throw new InternalServerErrorException( - 'A secret key must be set when NODE_ENV=production', - ); - } else { - // wae are not in production, log a warning - Logger.warn( - 'No default access token secret was provided to the JWT module.' + - ' Since NODE_ENV is not production, a random string will be generated.' + - ' It will not persist past this instance of the module.', - ); - // generate one for this module instance only - options.secret = randomUUID(); - } -} - -/** - * @internal - */ -function configureRefreshSecret( - options: JwtSettingsInterface['refresh'], - fallbackOptions: JwtSettingsInterface['access'], -) { - if (!options) { - throw new JwtConfigUndefinedException(); - } - if (!fallbackOptions) { - throw new JwtFallbackConfigUndefinedException(); - } - // was a refresh secret provided? - if (process.env?.JWT_MODULE_REFRESH_SECRET) { - // yes, use it - options.secret = process.env.JWT_MODULE_REFRESH_SECRET; - } else { - // log a warning - Logger.log( - 'No default refresh token secret was provided to the JWT module.' + - ' Copying the secret from the access token configuration.', - ); - // use the same one as the access - options.secret = fallbackOptions['secret']; - } -} diff --git a/packages/nestjs-jwt/src/exceptions/jwt-config-undefined.exception.ts b/packages/nestjs-jwt/src/exceptions/jwt-config-undefined.exception.ts deleted file mode 100644 index d04013c4d..000000000 --- a/packages/nestjs-jwt/src/exceptions/jwt-config-undefined.exception.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { JwtException } from './jwt.exception'; - -export class JwtConfigUndefinedException extends JwtException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: 'Config options is not defined', - ...options, - }); - this.errorCode = 'JWT_CONFIG_UNDEFINED'; - } -} diff --git a/packages/nestjs-jwt/src/exceptions/jwt-fallback-config-undefined.exception.ts b/packages/nestjs-jwt/src/exceptions/jwt-fallback-config-undefined.exception.ts deleted file mode 100644 index 83e5a0f05..000000000 --- a/packages/nestjs-jwt/src/exceptions/jwt-fallback-config-undefined.exception.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { JwtException } from './jwt.exception'; - -export class JwtFallbackConfigUndefinedException extends JwtException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: 'Fallback options is not defined', - ...options, - }); - this.errorCode = 'JWT_FALLBACK_CONFIG_UNDEFINED'; - } -} diff --git a/packages/nestjs-jwt/src/exceptions/jwt-verify.exception.ts b/packages/nestjs-jwt/src/exceptions/jwt-verify.exception.ts deleted file mode 100644 index 17cf94d09..000000000 --- a/packages/nestjs-jwt/src/exceptions/jwt-verify.exception.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { JwtException } from './jwt.exception'; - -/** - * Generic exception. - */ -export class JwtVerifyException extends JwtException { - constructor(options?: RuntimeExceptionOptions) { - super({ - safeMessage: 'Error on JWT verification', - httpStatus: HttpStatus.UNAUTHORIZED, - ...options, - }); - this.errorCode = 'JWT_VERIFY_ERROR'; - } -} diff --git a/packages/nestjs-jwt/src/index.ts b/packages/nestjs-jwt/src/index.ts deleted file mode 100644 index 7ef60f04c..000000000 --- a/packages/nestjs-jwt/src/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -// types -export { - JwtVerifyTokenCallback, - JwtSignOptions, - JwtSignStringOptions, -} from './jwt.types'; - -// interfaces -export { JwtOptionsInterface } from './interfaces/jwt-options.interface'; -export { JwtSettingsInterface } from './interfaces/jwt-settings.interface'; -export { JwtStrategyOptionsInterface } from './interfaces/jwt-strategy-options.interface'; -export { JwtServiceInterface } from './interfaces/jwt-service.interface'; -export { JwtSignServiceInterface } from './interfaces/jwt-sign-service.interface'; -export { JwtVerifyServiceInterface } from './interfaces/jwt-verify-service.interface'; -export { JwtIssueTokenServiceInterface } from './interfaces/jwt-issue-token-service.interface'; -export { JwtIssueAccessTokenServiceInterface } from './interfaces/jwt-issue-access-token-service.interface'; -export { JwtIssueRefreshTokenServiceInterface } from './interfaces/jwt-issue-refresh-token-service.interface'; -export { JwtVerifyTokenServiceInterface } from './interfaces/jwt-verify-token-service.interface'; -export { JwtVerifyAccessTokenInterface } from './interfaces/jwt-verify-access-token.interface'; -export { JwtVerifyRefreshTokenInterface } from './interfaces/jwt-verify-refresh-token.interface'; - -// service tokens -export { JwtAccessService, JwtRefreshService } from './jwt.constants'; - -// classes -export { JwtModule } from './jwt.module'; -export { JwtService } from './services/jwt.service'; -export { JwtIssueTokenService } from './services/jwt-issue-token.service'; -export { JwtVerifyTokenService } from './services/jwt-verify-token.service'; - -// strategy exports -export { ExtractJwt, JwtFromRequestFunction } from 'passport-jwt'; -export { JwtStrategy } from './jwt.strategy'; - -// utils -export { createVerifyAccessTokenCallback } from './utils/create-verify-access-token-callback.util'; -export { createVerifyRefreshTokenCallback } from './utils/create-verify-refresh-token-callback.util'; - -// exceptions -export { JwtException } from './exceptions/jwt.exception'; -export { JwtVerifyException } from './exceptions/jwt-verify.exception'; -export { JwtConfigUndefinedException } from './exceptions/jwt-config-undefined.exception'; -export { JwtFallbackConfigUndefinedException } from './exceptions/jwt-fallback-config-undefined.exception'; diff --git a/packages/nestjs-jwt/src/interfaces/jwt-issue-access-token-service.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-issue-access-token-service.interface.ts deleted file mode 100644 index dc374f083..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-issue-access-token-service.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { JwtSignOptions, JwtSignStringOptions } from '../jwt.types'; - -export interface JwtIssueAccessTokenServiceInterface { - accessToken(payload: string, options?: JwtSignStringOptions): Promise; - - accessToken( - payload: Buffer | object, - options?: JwtSignOptions, - ): Promise; -} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-issue-refresh-token-service.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-issue-refresh-token-service.interface.ts deleted file mode 100644 index 5f9e47230..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-issue-refresh-token-service.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { JwtSignOptions, JwtSignStringOptions } from '../jwt.types'; - -export interface JwtIssueRefreshTokenServiceInterface { - refreshToken( - payload: string, - options?: JwtSignStringOptions, - ): Promise; - - refreshToken( - payload: Buffer | object, - options?: JwtSignOptions, - ): Promise; -} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-issue-token-service.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-issue-token-service.interface.ts deleted file mode 100644 index 365f5216f..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-issue-token-service.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { JwtIssueAccessTokenServiceInterface } from './jwt-issue-access-token-service.interface'; -import { JwtIssueRefreshTokenServiceInterface } from './jwt-issue-refresh-token-service.interface'; - -export interface JwtIssueTokenServiceInterface - extends JwtIssueAccessTokenServiceInterface, - JwtIssueRefreshTokenServiceInterface {} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-options-extras.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-options-extras.interface.ts deleted file mode 100644 index fb1439ab8..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-options-extras.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface JwtOptionsExtrasInterface - extends Pick {} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-options.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-options.interface.ts deleted file mode 100644 index 2c47c7edb..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-options.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { JwtIssueTokenServiceInterface } from './jwt-issue-token-service.interface'; -import { JwtServiceInterface } from './jwt-service.interface'; -import { JwtSettingsInterface } from './jwt-settings.interface'; -import { JwtVerifyTokenServiceInterface } from './jwt-verify-token-service.interface'; - -/** - * JWT module configuration options interface - */ -export interface JwtOptionsInterface { - jwtService?: JwtServiceInterface; - jwtAccessService?: JwtServiceInterface; - jwtRefreshService?: JwtServiceInterface; - jwtIssueTokenService?: JwtIssueTokenServiceInterface; - jwtVerifyTokenService?: JwtVerifyTokenServiceInterface; - settings?: JwtSettingsInterface; -} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-service.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-service.interface.ts deleted file mode 100644 index 7b7f64970..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-service.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { JwtSignServiceInterface } from './jwt-sign-service.interface'; -import { JwtVerifyServiceInterface } from './jwt-verify-service.interface'; - -export interface JwtServiceInterface - extends JwtSignServiceInterface, - JwtVerifyServiceInterface {} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-settings.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-settings.interface.ts deleted file mode 100644 index 8dcbf89c0..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-settings.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NestJwtModuleOptions } from '../jwt.externals'; - -/** - * JWT module settings interface - */ -export interface JwtSettingsInterface { - default?: Omit; - access?: Omit; - refresh?: Omit; -} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-sign-service.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-sign-service.interface.ts deleted file mode 100644 index 3f5dd0d85..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-sign-service.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { JwtSignOptions, JwtSignStringOptions } from '../jwt.types'; - -export interface JwtSignServiceInterface { - signAsync(payload: string, options?: JwtSignStringOptions): Promise; - - signAsync( - payload: Buffer | object, - options?: JwtSignOptions, - ): Promise; -} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-strategy-options.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-strategy-options.interface.ts deleted file mode 100644 index 778a90eaa..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-strategy-options.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { StrategyOptions } from 'passport-jwt'; - -import { JwtVerifyTokenCallback } from '../jwt.types'; - -export interface JwtStrategyOptionsInterface - extends Pick { - verifyToken: JwtVerifyTokenCallback; -} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-verify-access-token.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-verify-access-token.interface.ts deleted file mode 100644 index 904795723..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-verify-access-token.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NestJwtService } from '../jwt.externals'; - -export interface JwtVerifyAccessTokenInterface { - accessToken( - ...args: Parameters - ): ReturnType; -} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-verify-refresh-token.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-verify-refresh-token.interface.ts deleted file mode 100644 index f68c76fd1..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-verify-refresh-token.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NestJwtService } from '../jwt.externals'; - -export interface JwtVerifyRefreshTokenInterface { - refreshToken( - ...args: Parameters - ): ReturnType; -} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-verify-service.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-verify-service.interface.ts deleted file mode 100644 index b143bb562..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-verify-service.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NestJwtService } from '../jwt.externals'; - -export interface JwtVerifyServiceInterface { - verifyAsync: ( - ...rest: Parameters - ) => Promise; - - decode: (...rest: Parameters) => T; -} diff --git a/packages/nestjs-jwt/src/interfaces/jwt-verify-token-service.interface.ts b/packages/nestjs-jwt/src/interfaces/jwt-verify-token-service.interface.ts deleted file mode 100644 index ee77a2bf4..000000000 --- a/packages/nestjs-jwt/src/interfaces/jwt-verify-token-service.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { JwtVerifyAccessTokenInterface } from './jwt-verify-access-token.interface'; -import { JwtVerifyRefreshTokenInterface } from './jwt-verify-refresh-token.interface'; - -export interface JwtVerifyTokenServiceInterface - extends JwtVerifyAccessTokenInterface, - JwtVerifyRefreshTokenInterface {} diff --git a/packages/nestjs-jwt/src/jwt.constants.ts b/packages/nestjs-jwt/src/jwt.constants.ts deleted file mode 100644 index 9871291fc..000000000 --- a/packages/nestjs-jwt/src/jwt.constants.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * The token to which all JWT module settings are set. - */ -export const JWT_MODULE_SETTINGS_TOKEN = 'ROCKTS_JWT_MODULE_SETTINGS_TOKEN'; - -/** - * JWT module default settings token - */ -export const JWT_MODULE_DEFAULT_SETTINGS_TOKEN = - 'ROCKTS_JWT_MODULE_DEFAULT_SETTINGS_TOKEN'; - -/** - * The token to which the jwt service (for access) is set. - */ -export const JwtAccessService = Symbol( - '__JWT_MODULE_JWT_ACCESS_SERVICE_TOKEN__', -); - -/** - * The token to which the jwt service (for refresh) is set. - */ -export const JwtRefreshService = Symbol( - '__JWT_MODULE_JWT_REFRESH_SERVICE_TOKEN__', -); diff --git a/packages/nestjs-jwt/src/jwt.externals.ts b/packages/nestjs-jwt/src/jwt.externals.ts deleted file mode 100644 index 1736bf091..000000000 --- a/packages/nestjs-jwt/src/jwt.externals.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as jwt from 'jsonwebtoken'; - -import { JwtSignOptions as NestJwtSignOptions } from '@nestjs/jwt'; - -export { - JwtModule as NestJwtModule, - JwtModuleOptions as NestJwtModuleOptions, - JwtService as NestJwtService, -} from '@nestjs/jwt'; - -export { NestJwtSignOptions }; - -export type NestJwtSignStringOptions = Omit< - NestJwtSignOptions, - keyof jwt.SignOptions ->; diff --git a/packages/nestjs-jwt/src/jwt.module-definition.ts b/packages/nestjs-jwt/src/jwt.module-definition.ts deleted file mode 100644 index a75bcd8ed..000000000 --- a/packages/nestjs-jwt/src/jwt.module-definition.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { - ConfigurableModuleBuilder, - DynamicModule, - Provider, -} from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; - -import { createSettingsProvider } from '@concepta/nestjs-common'; - -import { jwtDefaultConfig } from './config/jwt-default.config'; -import { JwtOptionsExtrasInterface } from './interfaces/jwt-options-extras.interface'; -import { JwtOptionsInterface } from './interfaces/jwt-options.interface'; -import { JwtServiceInterface } from './interfaces/jwt-service.interface'; -import { JwtSettingsInterface } from './interfaces/jwt-settings.interface'; -import { - JWT_MODULE_SETTINGS_TOKEN, - JwtAccessService, - JwtRefreshService, -} from './jwt.constants'; -import { NestJwtModule } from './jwt.externals'; -import { JwtIssueTokenService } from './services/jwt-issue-token.service'; -import { JwtVerifyTokenService } from './services/jwt-verify-token.service'; -import { JwtService } from './services/jwt.service'; - -const RAW_OPTIONS_TOKEN = Symbol('__JWT_MODULE_RAW_OPTIONS_TOKEN__'); - -export const { - ConfigurableModuleClass: JwtModuleClass, - OPTIONS_TYPE: JWT_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: JWT_ASYNC_OPTIONS_TYPE, -} = new ConfigurableModuleBuilder({ - moduleName: 'Jwt', - optionsInjectionToken: RAW_OPTIONS_TOKEN, -}) - .setExtras({ global: false }, definitionTransform) - .build(); - -export type JwtOptions = Omit; -export type JwtAsyncOptions = Omit; - -function definitionTransform( - definition: DynamicModule, - extras: JwtOptionsExtrasInterface, -): DynamicModule { - const { providers = [] } = definition; - const { global = false, imports } = extras; - - return { - ...definition, - global, - imports: createJwtImports({ imports }), - providers: createJwtProviders({ providers }), - exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createJwtExports()], - }; -} - -export function createJwtImports( - overrides?: JwtOptions, -): DynamicModule['imports'] { - const imports = [ConfigModule.forFeature(jwtDefaultConfig)]; - - if (overrides?.imports?.length) { - return [...imports, ...overrides.imports]; - } else { - return [...imports, NestJwtModule.register({})]; - } -} - -export function createJwtExports() { - return [ - JWT_MODULE_SETTINGS_TOKEN, - JwtAccessService, - JwtRefreshService, - JwtService, - JwtIssueTokenService, - JwtVerifyTokenService, - ]; -} - -export function createJwtProviders(options: { - overrides?: JwtOptions; - providers?: Provider[]; -}): Provider[] { - return [ - ...(options.providers ?? []), - createJwtSettingsProvider(options.overrides), - createJwtServiceAccessTokenProvider(options.overrides), - createJwtServiceRefreshTokenProvider(options.overrides), - createJwtServiceProvider(options.overrides), - createJwtIssueServiceProvider(options.overrides), - createJwtVerifyServiceProvider(options.overrides), - ]; -} - -export function createJwtSettingsProvider( - optionsOverrides?: JwtOptions, -): Provider { - return createSettingsProvider({ - settingsToken: JWT_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: jwtDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createJwtServiceAccessTokenProvider( - optionsOverrides?: JwtOptions, -): Provider { - return { - provide: JwtAccessService, - inject: [RAW_OPTIONS_TOKEN, JWT_MODULE_SETTINGS_TOKEN], - useFactory: async ( - options: JwtOptionsInterface, - settings: JwtSettingsInterface, - ) => - optionsOverrides?.jwtAccessService ?? - options.jwtAccessService ?? - new JwtService(settings.access ?? {}), - }; -} - -export function createJwtServiceRefreshTokenProvider( - optionsOverrides?: JwtOptions, -): Provider { - return { - provide: JwtRefreshService, - inject: [RAW_OPTIONS_TOKEN, JWT_MODULE_SETTINGS_TOKEN], - useFactory: async ( - options: JwtOptionsInterface, - settings: JwtSettingsInterface, - ) => - optionsOverrides?.jwtRefreshService ?? - options.jwtRefreshService ?? - new JwtService(settings.refresh ?? {}), - }; -} - -export function createJwtServiceProvider( - optionsOverrides?: JwtOptions, -): Provider { - return { - provide: JwtService, - inject: [RAW_OPTIONS_TOKEN, JWT_MODULE_SETTINGS_TOKEN], - useFactory: async ( - options: JwtOptionsInterface, - settings: JwtSettingsInterface, - ) => - optionsOverrides?.jwtService ?? - options.jwtService ?? - new JwtService(settings?.default), - }; -} - -export function createJwtIssueServiceProvider( - optionsOverrides?: JwtOptions, -): Provider { - return { - provide: JwtIssueTokenService, - inject: [RAW_OPTIONS_TOKEN, JwtAccessService, JwtRefreshService], - useFactory: async ( - options: JwtOptionsInterface, - jwtAccessService: JwtServiceInterface, - jwtRefreshService: JwtServiceInterface, - ) => - optionsOverrides?.jwtIssueTokenService ?? - options.jwtIssueTokenService ?? - new JwtIssueTokenService(jwtAccessService, jwtRefreshService), - }; -} - -export function createJwtVerifyServiceProvider( - optionsOverrides?: JwtOptions, -): Provider { - return { - provide: JwtVerifyTokenService, - inject: [RAW_OPTIONS_TOKEN, JwtAccessService, JwtRefreshService], - useFactory: async ( - options: JwtOptionsInterface, - jwtAccessService: JwtServiceInterface, - jwtRefreshService: JwtServiceInterface, - ) => - optionsOverrides?.jwtVerifyTokenService ?? - options.jwtVerifyTokenService ?? - new JwtVerifyTokenService(jwtAccessService, jwtRefreshService), - }; -} diff --git a/packages/nestjs-jwt/src/jwt.module.spec.ts b/packages/nestjs-jwt/src/jwt.module.spec.ts deleted file mode 100644 index 53706b6d4..000000000 --- a/packages/nestjs-jwt/src/jwt.module.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { JwtSettingsInterface } from './interfaces/jwt-settings.interface'; -import { - JWT_MODULE_SETTINGS_TOKEN, - JwtAccessService, - JwtRefreshService, -} from './jwt.constants'; -import { JwtModule } from './jwt.module'; -import { JwtIssueTokenService } from './services/jwt-issue-token.service'; -import { JwtVerifyTokenService } from './services/jwt-verify-token.service'; -import { JwtService } from './services/jwt.service'; - -describe(JwtModule, () => { - let jwtModule: JwtModule; - let jwtSettings: JwtSettingsInterface; - let jwtService: JwtService; - let jwtAccessService: JwtService; - let jwtRefreshService: JwtService; - let jwtIssueTokenService: JwtIssueTokenService; - let jwtVerifyTokenService: JwtVerifyTokenService; - - describe(JwtModule.register, () => { - beforeAll(async () => { - const testModule = await Test.createTestingModule({ - imports: [JwtModule.register({})], - }).compile(); - - setProviderVars(testModule); - }); - - commonProviderTests(); - }); - - describe(JwtModule.forRoot, () => { - beforeAll(async () => { - const testModule = await Test.createTestingModule({ - imports: [JwtModule.forRoot({})], - }).compile(); - - setProviderVars(testModule); - }); - - commonProviderTests(); - }); - - describe(JwtModule.registerAsync, () => { - beforeEach(async () => { - const testModule = await Test.createTestingModule({ - imports: [JwtModule.registerAsync({ useFactory: () => ({}) })], - }).compile(); - - setProviderVars(testModule); - }); - - commonProviderTests(); - }); - - describe(JwtModule.forRootAsync, () => { - beforeEach(async () => { - const testModule = await Test.createTestingModule({ - imports: [JwtModule.forRootAsync({ useFactory: () => ({}) })], - }).compile(); - - setProviderVars(testModule); - }); - - commonProviderTests(); - }); - - function setProviderVars(testModule: TestingModule) { - jwtModule = testModule.get(JwtModule); - jwtSettings = testModule.get( - JWT_MODULE_SETTINGS_TOKEN, - ); - jwtService = testModule.get(JwtService); - jwtAccessService = testModule.get(JwtAccessService); - jwtRefreshService = testModule.get(JwtRefreshService); - jwtIssueTokenService = - testModule.get(JwtIssueTokenService); - jwtVerifyTokenService = testModule.get( - JwtVerifyTokenService, - ); - } - - function commonProviderTests() { - it('providers should be loaded', async () => { - expect(jwtModule).toBeInstanceOf(JwtModule); - expect(jwtSettings).toBeInstanceOf(Object); - expect(jwtService).toBeInstanceOf(JwtService); - expect(jwtAccessService).toBeInstanceOf(JwtService); - expect(jwtRefreshService).toBeInstanceOf(JwtService); - expect(jwtIssueTokenService).toBeInstanceOf(JwtIssueTokenService); - expect(jwtVerifyTokenService).toBeInstanceOf(JwtVerifyTokenService); - }); - } -}); diff --git a/packages/nestjs-jwt/src/jwt.module.ts b/packages/nestjs-jwt/src/jwt.module.ts deleted file mode 100644 index 299a9b998..000000000 --- a/packages/nestjs-jwt/src/jwt.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DynamicModule, Module } from '@nestjs/common'; - -import { - JwtModuleClass, - JwtOptions, - JwtAsyncOptions, -} from './jwt.module-definition'; - -@Module({}) -export class JwtModule extends JwtModuleClass { - static register(options: JwtOptions): DynamicModule { - return super.register(options); - } - - static registerAsync(options: JwtAsyncOptions): DynamicModule { - return super.registerAsync(options); - } - - static forRoot(options: JwtOptions): DynamicModule { - return super.register({ ...options, global: true }); - } - - static forRootAsync(options: JwtAsyncOptions): DynamicModule { - return super.registerAsync({ ...options, global: true }); - } -} diff --git a/packages/nestjs-jwt/src/jwt.strategy.spec.ts b/packages/nestjs-jwt/src/jwt.strategy.spec.ts deleted file mode 100644 index 38dc882ef..000000000 --- a/packages/nestjs-jwt/src/jwt.strategy.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Request } from 'express-serve-static-core'; -import { mock } from 'jest-mock-extended'; -import { VerifyCallback } from 'passport-jwt'; - -import { NotAnErrorException } from '@concepta/nestjs-common'; - -import { JwtStrategyOptionsInterface } from './interfaces/jwt-strategy-options.interface'; -import { JwtStrategy } from './jwt.strategy'; - -describe(JwtStrategy, () => { - let jwtStrategyOptions: JwtStrategyOptionsInterface; - let verifyCallback: VerifyCallback; - let jwtStrategy: JwtStrategy; - - beforeEach(async () => { - jwtStrategyOptions = mock({ - jwtFromRequest: () => 'rawToken', - verifyToken: () => true, - }); - verifyCallback = mock(); - jwtStrategy = new JwtStrategy(jwtStrategyOptions, verifyCallback); - }); - - describe(JwtStrategy.prototype.authenticate, () => { - let req: Request; - it('should success', async () => { - const userResponse = jwtStrategy.authenticate(req); - expect(userResponse).toBe(true); - }); - - it('should throw exception', async () => { - jest.spyOn(jwtStrategyOptions, 'jwtFromRequest').mockReturnValue(''); - const t = async () => await jwtStrategy.authenticate(req); - await expect(t).rejects.toThrow(); - }); - - it('should throw exception', async () => { - jest - .spyOn(jwtStrategyOptions, 'verifyToken') - .mockImplementationOnce(() => { - throw new Error(); - }); - const t = async () => await jwtStrategy.authenticate(req); - await expect(t).rejects.toThrow(); - }); - - it('should throw exception', async () => { - jest - .spyOn(jwtStrategyOptions, 'verifyToken') - .mockImplementationOnce(() => { - throw new NotAnErrorException(new Error()); - }); - const t = async () => await jwtStrategy.authenticate(req); - await expect(t).rejects.toThrow(); - }); - - it('should throw exception', async () => { - const t = async () => jwtStrategy['verifyTokenCallback'](); - await expect(t).rejects.toThrow(); - }); - - it('should throw exception', async () => { - const t = async () => jwtStrategy['verifyTokenCallback'](new Error()); - await expect(t).rejects.toThrow(); - }); - - // it('should throw exception', async () => { - // const t = async () => - // await jwtStrategy['isVerifiedCallback'](new Error(), null, null); - // expect(t).rejects.toThrow(); - // }); - - // it('should throw exception', async () => { - // const t = async () => - // await jwtStrategy['isVerifiedCallback'](null, null, null); - // expect(t).rejects.toThrow(); - // }); - - // it('should success', async () => { - // await jwtStrategy['isVerifiedCallback'](null, {}, {}); - // expect(1).toBe(1); - // }); - }); -}); diff --git a/packages/nestjs-jwt/src/jwt.strategy.ts b/packages/nestjs-jwt/src/jwt.strategy.ts deleted file mode 100644 index 8279dd769..000000000 --- a/packages/nestjs-jwt/src/jwt.strategy.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Strategy, VerifyCallback } from 'passport-jwt'; -import { Strategy as PassportStrategy } from 'passport-strategy'; - -import { NotAnErrorException } from '@concepta/nestjs-common'; - -import { JwtVerifyException } from './exceptions/jwt-verify.exception'; -import { JwtStrategyOptionsInterface } from './interfaces/jwt-strategy-options.interface'; - -export class JwtStrategy extends PassportStrategy { - constructor( - private options: JwtStrategyOptionsInterface, - private verify: VerifyCallback, - ) { - super(); - - this.options = options; - this.verify = verify; - } - - authenticate(...args: Parameters) { - const [req] = args; - - const rawToken = this.options.jwtFromRequest(req); - - if (!rawToken) { - return this.fail('Missing authorization token', 401); - } - - try { - return this.options.verifyToken( - rawToken, - this.verifyTokenCallback.bind(this), - ); - } catch (e) { - const exception = new JwtVerifyException({ - originalError: e, - }); - return this.error(exception); - } - } - - private verifyTokenCallback(e?: Error, decodedToken?: unknown) { - // TODO: configure JWT module to use different access and refresh secrets - - if (e) { - return this.error(e); - } - - try { - return this.verify(decodedToken, this.isVerifiedCallback.bind(this)); - } catch (e) { - const exception = e instanceof Error ? e : new NotAnErrorException(e); - return this.error(exception); - } - } - - private isVerifiedCallback( - error: Error | null, - user: unknown, - info: unknown, - ) { - if (error) { - return this.error(error); - } else if (!user) { - return this.fail(info, 401); - } else { - return this.success(user, info); - } - } -} diff --git a/packages/nestjs-jwt/src/jwt.types.ts b/packages/nestjs-jwt/src/jwt.types.ts deleted file mode 100644 index 5ac1531ad..000000000 --- a/packages/nestjs-jwt/src/jwt.types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - NestJwtSignOptions as JwtSignOptions, - NestJwtSignStringOptions as JwtSignStringOptions, -} from './jwt.externals'; - -export type JwtVerifyTokenCallback< - ErrorType extends Error = Error, - DecodedTokenType = unknown, -> = ( - token: string, - done: (err?: ErrorType, decodedToken?: DecodedTokenType) => void, -) => void; diff --git a/packages/nestjs-jwt/src/services/jwt-issue-token.service.spec.ts b/packages/nestjs-jwt/src/services/jwt-issue-token.service.spec.ts deleted file mode 100644 index 1ddca8635..000000000 --- a/packages/nestjs-jwt/src/services/jwt-issue-token.service.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { JwtIssueTokenService } from './jwt-issue-token.service'; -import { JwtService } from './jwt.service'; - -describe(JwtIssueTokenService, () => { - let jwtService: JwtService; - let jwtIssueTokenService: JwtIssueTokenService; - const token = 'token'; - - beforeEach(() => { - jwtService = mock(); - jwtIssueTokenService = new JwtIssueTokenService(jwtService, jwtService); - }); - - describe(JwtIssueTokenService.prototype.accessToken, () => { - it('should success', async () => { - const spySignAsync = jest - .spyOn(jwtService, 'signAsync') - .mockResolvedValue(token); - const result = await jwtIssueTokenService.accessToken(token); - expect(result).toBe(token); - expect(spySignAsync).toHaveBeenCalledWith(token, undefined); - }); - it('should throw error', async () => { - jest.spyOn(jwtService, 'signAsync').mockImplementationOnce(() => { - throw new Error(); - }); - const t = async () => await jwtIssueTokenService.accessToken(token); - await expect(t).rejects.toThrow(); - }); - }); - - describe(JwtIssueTokenService.prototype.refreshToken, () => { - it('should success', async () => { - const spySignAsync = jest - .spyOn(jwtService, 'signAsync') - .mockResolvedValue(token); - const result = await jwtIssueTokenService.refreshToken(token); - expect(result).toBe(token); - expect(spySignAsync).toHaveBeenCalledWith(token, undefined); - }); - - it('should throw error', async () => { - jest.spyOn(jwtService, 'signAsync').mockImplementationOnce(() => { - throw new Error(); - }); - const t = async () => await jwtIssueTokenService.refreshToken(token); - await expect(t).rejects.toThrow(); - }); - }); -}); diff --git a/packages/nestjs-jwt/src/services/jwt-issue-token.service.ts b/packages/nestjs-jwt/src/services/jwt-issue-token.service.ts deleted file mode 100644 index e68d118b1..000000000 --- a/packages/nestjs-jwt/src/services/jwt-issue-token.service.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { JwtIssueTokenServiceInterface } from '../interfaces/jwt-issue-token-service.interface'; -import { JwtSignServiceInterface } from '../interfaces/jwt-sign-service.interface'; -import { JwtAccessService, JwtRefreshService } from '../jwt.constants'; -import { JwtSignOptions, JwtSignStringOptions } from '../jwt.types'; - -@Injectable() -export class JwtIssueTokenService implements JwtIssueTokenServiceInterface { - constructor( - @Inject(JwtAccessService) - protected readonly jwtAccessService: JwtSignServiceInterface, - @Inject(JwtRefreshService) - protected readonly jwtRefreshService: JwtSignServiceInterface, - ) {} - - accessToken(payload: string, options?: JwtSignStringOptions): Promise; - - accessToken( - payload: Buffer | object, - options?: JwtSignOptions, - ): Promise; - - async accessToken( - payload: string | Buffer | object, - options?: JwtSignOptions, - ) { - if (typeof payload === 'string') { - return this.jwtAccessService.signAsync(payload, options); - } else { - return this.jwtAccessService.signAsync(payload, options); - } - } - - refreshToken( - payload: string, - options?: JwtSignStringOptions, - ): Promise; - - refreshToken( - payload: Buffer | object, - options?: JwtSignOptions, - ): Promise; - - async refreshToken( - payload: string | Buffer | object, - options?: JwtSignOptions, - ) { - if (typeof payload === 'string') { - return this.jwtRefreshService.signAsync(payload, options); - } else { - return this.jwtRefreshService.signAsync(payload, options); - } - } -} diff --git a/packages/nestjs-jwt/src/services/jwt-verify-token.service.spec.ts b/packages/nestjs-jwt/src/services/jwt-verify-token.service.spec.ts deleted file mode 100644 index 5cef41c1b..000000000 --- a/packages/nestjs-jwt/src/services/jwt-verify-token.service.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { mock } from 'jest-mock-extended'; - -import { JwtVerifyTokenService } from './jwt-verify-token.service'; -import { JwtService } from './jwt.service'; - -describe(JwtVerifyTokenService, () => { - let jwtService: JwtService; - let jwtVerifyTokenService: JwtVerifyTokenService; - const token = 'token'; - - beforeEach(() => { - jwtService = mock(); - jwtVerifyTokenService = new JwtVerifyTokenService(jwtService, jwtService); - }); - - describe(JwtVerifyTokenService.prototype.accessToken, () => { - it('should success', async () => { - const spyAccessToken = jest - .spyOn(jwtService, 'verifyAsync') - .mockResolvedValue({ foo: 'bar' }); - const result = await jwtVerifyTokenService.accessToken('{"foo": "bar"}'); - expect(result).toEqual({ foo: 'bar' }); - expect(spyAccessToken).toHaveBeenCalledWith('{"foo": "bar"}'); - }); - - it('should throw error', async () => { - jest.spyOn(jwtService, 'verifyAsync').mockImplementationOnce(() => { - throw new Error(); - }); - const t = async () => await jwtVerifyTokenService.accessToken(token); - await expect(t).rejects.toThrow(); - }); - }); - - describe(JwtVerifyTokenService.prototype.refreshToken, () => { - it('should success', async () => { - const spyRefreshToken = jest - .spyOn(jwtService, 'verifyAsync') - .mockResolvedValue({ man: 'chu' }); - const result = await jwtVerifyTokenService.refreshToken('{"man": "chu"}'); - expect(result).toEqual({ man: 'chu' }); - expect(spyRefreshToken).toHaveBeenCalledWith('{"man": "chu"}'); - }); - - it('should throw error', async () => { - jest.spyOn(jwtService, 'verifyAsync').mockImplementationOnce(() => { - throw new Error(); - }); - const t = async () => await jwtVerifyTokenService.refreshToken(token); - await expect(t).rejects.toThrow(); - }); - }); -}); diff --git a/packages/nestjs-jwt/src/services/jwt-verify-token.service.ts b/packages/nestjs-jwt/src/services/jwt-verify-token.service.ts deleted file mode 100644 index ef68e9ee2..000000000 --- a/packages/nestjs-jwt/src/services/jwt-verify-token.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { JwtVerifyServiceInterface } from '../interfaces/jwt-verify-service.interface'; -import { JwtVerifyTokenServiceInterface } from '../interfaces/jwt-verify-token-service.interface'; -import { JwtAccessService, JwtRefreshService } from '../jwt.constants'; - -@Injectable() -export class JwtVerifyTokenService implements JwtVerifyTokenServiceInterface { - constructor( - @Inject(JwtAccessService) - protected readonly jwtAccessService: JwtVerifyServiceInterface, - @Inject(JwtRefreshService) - protected readonly jwtRefreshService: JwtVerifyServiceInterface, - ) {} - - async accessToken( - ...args: Parameters - ) { - return this.jwtAccessService.verifyAsync(...args); - } - - async refreshToken( - ...args: Parameters - ) { - return this.jwtRefreshService.verifyAsync(...args); - } -} diff --git a/packages/nestjs-jwt/src/services/jwt.service.spec.ts b/packages/nestjs-jwt/src/services/jwt.service.spec.ts deleted file mode 100644 index 6b8768dc6..000000000 --- a/packages/nestjs-jwt/src/services/jwt.service.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { JwtService } from './jwt.service'; - -describe(JwtService, () => { - const token = 'token'; - - let jwtService: JwtService; - - beforeEach(() => { - jwtService = new JwtService({}); - }); - - describe(JwtService.prototype.signAsync, () => { - it('should success', async () => { - const spySignAsync = jest - .spyOn(jwtService, 'signAsync') - .mockResolvedValue(token); - const result = await jwtService.signAsync(token); - expect(result).toBe(token); - expect(spySignAsync).toHaveBeenCalledWith(token); - }); - - it('should throw error', async () => { - jest.spyOn(jwtService, 'signAsync').mockImplementationOnce(() => { - throw new Error(); - }); - const t = async () => await jwtService.signAsync(token); - await expect(t).rejects.toThrow(); - }); - }); - - describe(JwtService.prototype.verifyAsync, () => { - it('should success', async () => { - const spyVerifyAsync = jest - .spyOn(jwtService, 'verifyAsync') - .mockResolvedValue({ token }); - const result = await jwtService.verifyAsync(token); - expect(result.token).toBe(token); - expect(spyVerifyAsync).toHaveBeenCalledWith(token); - }); - - it('should throw error', async () => { - jest.spyOn(jwtService, 'verifyAsync').mockImplementationOnce(() => { - throw new Error(); - }); - const t = async () => await jwtService.verifyAsync(token); - await expect(t).rejects.toThrow(); - }); - }); - - describe(JwtService.prototype.decode, () => { - it('should success', async () => { - const spyDecode = jest.spyOn(jwtService, 'decode'); - await jwtService.decode(token); - expect(spyDecode).toHaveBeenCalledWith(token); - }); - - it('should throw error', async () => { - jest.spyOn(jwtService, 'decode').mockImplementationOnce(() => { - throw new Error(); - }); - const t = async () => await jwtService.decode(token); - await expect(t).rejects.toThrow(); - }); - }); -}); diff --git a/packages/nestjs-jwt/src/services/jwt.service.ts b/packages/nestjs-jwt/src/services/jwt.service.ts deleted file mode 100644 index c523449d6..000000000 --- a/packages/nestjs-jwt/src/services/jwt.service.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { JwtServiceInterface } from '../interfaces/jwt-service.interface'; -import { NestJwtService } from '../jwt.externals'; - -@Injectable() -export class JwtService extends NestJwtService implements JwtServiceInterface {} diff --git a/packages/nestjs-jwt/src/utils/create-verify-access-token-callback.util.ts b/packages/nestjs-jwt/src/utils/create-verify-access-token-callback.util.ts deleted file mode 100644 index b5ec581a1..000000000 --- a/packages/nestjs-jwt/src/utils/create-verify-access-token-callback.util.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { JwtVerifyTokenServiceInterface } from '../interfaces/jwt-verify-token-service.interface'; -import { JwtVerifyTokenCallback } from '../jwt.types'; - -export const createVerifyAccessTokenCallback = ( - verifyTokenService: JwtVerifyTokenServiceInterface, -): JwtVerifyTokenCallback => { - return ( - token: string, - done: (error?: Error, decodedToken?: unknown) => void, - ): void => { - verifyTokenService - .accessToken(token) - .then((decodedToken: unknown) => done(undefined, decodedToken)) - .catch((error) => done(error)); - }; -}; diff --git a/packages/nestjs-jwt/src/utils/create-verify-refresh-token-callback.util.ts b/packages/nestjs-jwt/src/utils/create-verify-refresh-token-callback.util.ts deleted file mode 100644 index 6752d10d0..000000000 --- a/packages/nestjs-jwt/src/utils/create-verify-refresh-token-callback.util.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { JwtVerifyTokenServiceInterface } from '../interfaces/jwt-verify-token-service.interface'; -import { JwtVerifyTokenCallback } from '../jwt.types'; - -export const createVerifyRefreshTokenCallback = ( - verifyTokenService: JwtVerifyTokenServiceInterface, -): JwtVerifyTokenCallback => { - return ( - token: string, - done: (error?: Error, decodedToken?: unknown) => void, - ): void => { - verifyTokenService - .refreshToken(token) - .then((decodedToken: unknown) => done(undefined, decodedToken)) - .catch((error) => done(error)); - }; -}; diff --git a/packages/nestjs-jwt/tsconfig.json b/packages/nestjs-jwt/tsconfig.json deleted file mode 100644 index ef9980950..000000000 --- a/packages/nestjs-jwt/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": [ - "./node_modules/@types", - "../../node_modules/@types" - ] - }, - "include": [ - "src/**/*.ts" - ] -} diff --git a/packages/nestjs-jwt/typedoc.json b/packages/nestjs-jwt/typedoc.json deleted file mode 100644 index 944fda5ad..000000000 --- a/packages/nestjs-jwt/typedoc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "entryPoints": ["src/index.ts"] -} \ No newline at end of file diff --git a/packages/nestjs-logger-coralogix/src/config/logger-coralogix.config.spec.ts b/packages/nestjs-logger-coralogix/src/config/logger-coralogix.config.spec.ts index 8a74f5661..08e81caa1 100644 --- a/packages/nestjs-logger-coralogix/src/config/logger-coralogix.config.spec.ts +++ b/packages/nestjs-logger-coralogix/src/config/logger-coralogix.config.spec.ts @@ -1,7 +1,7 @@ import { ConfigModule } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; -import { LoggerCoralogixSettingsInterface } from '../interfaces/logger-coralogix-settings.interface'; +import { type LoggerCoralogixSettingsInterface } from '../interfaces/logger-coralogix-settings.interface'; import { coralogixConfig, diff --git a/packages/nestjs-logger-coralogix/src/config/logger-coralogix.config.ts b/packages/nestjs-logger-coralogix/src/config/logger-coralogix.config.ts index 8a0a93f8e..a97f98493 100644 --- a/packages/nestjs-logger-coralogix/src/config/logger-coralogix.config.ts +++ b/packages/nestjs-logger-coralogix/src/config/logger-coralogix.config.ts @@ -1,12 +1,12 @@ import { - ConfigFactory, - ConfigFactoryKeyHost, + type ConfigFactory, + type ConfigFactoryKeyHost, registerAs, } from '@nestjs/config'; import { splitLogLevel } from '@concepta/nestjs-logger'; -import { LoggerCoralogixSettingsInterface } from '../interfaces/logger-coralogix-settings.interface'; +import { type LoggerCoralogixSettingsInterface } from '../interfaces/logger-coralogix-settings.interface'; import { formatMessage, logLevelMap } from '../utils'; /** diff --git a/packages/nestjs-logger-coralogix/src/exceptions/logger-coralogix.exceptions.ts b/packages/nestjs-logger-coralogix/src/exceptions/logger-coralogix.exceptions.ts index 1c5771600..3a2388667 100644 --- a/packages/nestjs-logger-coralogix/src/exceptions/logger-coralogix.exceptions.ts +++ b/packages/nestjs-logger-coralogix/src/exceptions/logger-coralogix.exceptions.ts @@ -1,6 +1,6 @@ import { RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; export class LoggerCoralogixException extends RuntimeException { diff --git a/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-async-options.interface.ts b/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-async-options.interface.ts index c1114a483..363e2eb3d 100644 --- a/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-async-options.interface.ts +++ b/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-async-options.interface.ts @@ -1,12 +1,13 @@ -import { FactoryProvider, ModuleMetadata } from '@nestjs/common'; +import { type FactoryProvider, type ModuleMetadata } from '@nestjs/common'; -import { CoralogixOptionsInterface } from './logger-coralogix-options.interface'; +import { type CoralogixOptionsInterface } from './logger-coralogix-options.interface'; /** * Coralogix async options. */ export interface CoralogixAsyncOptionsInterface - extends Pick, + extends + Pick, Pick< FactoryProvider< CoralogixOptionsInterface | Promise diff --git a/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-config.interface.ts b/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-config.interface.ts index ecac98437..17a76e705 100644 --- a/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-config.interface.ts +++ b/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-config.interface.ts @@ -1,10 +1,11 @@ -import { LoggerConfig } from 'coralogix-logger'; +import { type LoggerConfig } from 'coralogix-logger'; /** * Interface for Coralogix configuration to define the log level * mapping to be used on Coralogix transport. */ export interface LoggerCoralogixConfigInterface - extends Pick, + extends + Pick, Partial> { category: string; } diff --git a/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-options-extras.interface.ts b/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-options-extras.interface.ts index 2a5a2b097..ffc6d5fbd 100644 --- a/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-options-extras.interface.ts +++ b/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface CoralogixOptionsExtrasInterface - extends Pick {} +export interface CoralogixOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-options.interface.ts b/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-options.interface.ts index 834694844..6b3bac3f6 100644 --- a/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-options.interface.ts +++ b/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-options.interface.ts @@ -1,4 +1,4 @@ -import { LoggerCoralogixSettingsInterface } from './logger-coralogix-settings.interface'; +import { type LoggerCoralogixSettingsInterface } from './logger-coralogix-settings.interface'; /** * Coralogix options interface. diff --git a/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-settings.interface.ts b/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-settings.interface.ts index ed65dd465..ffeffec69 100644 --- a/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-settings.interface.ts +++ b/packages/nestjs-logger-coralogix/src/interfaces/logger-coralogix-settings.interface.ts @@ -1,16 +1,17 @@ -import { Severity } from 'coralogix-logger'; +import { type Severity } from 'coralogix-logger'; import { - LoggerSettingsInterface, - LoggerTransportSettingsInterface, + type LoggerSettingsInterface, + type LoggerTransportSettingsInterface, } from '@concepta/nestjs-logger'; -import { LoggerCoralogixConfigInterface } from './logger-coralogix-config.interface'; +import { type LoggerCoralogixConfigInterface } from './logger-coralogix-config.interface'; /** * Coralogix options interface. */ export interface LoggerCoralogixSettingsInterface - extends Partial>, + extends + Partial>, LoggerTransportSettingsInterface { /** * diff --git a/packages/nestjs-logger-coralogix/src/logger-coralogix.module-definition.ts b/packages/nestjs-logger-coralogix/src/logger-coralogix.module-definition.ts index 645b48a61..fa18b6fe2 100644 --- a/packages/nestjs-logger-coralogix/src/logger-coralogix.module-definition.ts +++ b/packages/nestjs-logger-coralogix/src/logger-coralogix.module-definition.ts @@ -1,7 +1,7 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; @@ -11,9 +11,9 @@ import { LOGGER_CORALOGIX_MODULE_SETTINGS_TOKEN, coralogixConfig, } from './config/logger-coralogix.config'; -import { CoralogixOptionsExtrasInterface } from './interfaces/logger-coralogix-options-extras.interface'; -import { CoralogixOptionsInterface } from './interfaces/logger-coralogix-options.interface'; -import { LoggerCoralogixSettingsInterface } from './interfaces/logger-coralogix-settings.interface'; +import { type CoralogixOptionsExtrasInterface } from './interfaces/logger-coralogix-options-extras.interface'; +import { type CoralogixOptionsInterface } from './interfaces/logger-coralogix-options.interface'; +import { type LoggerCoralogixSettingsInterface } from './interfaces/logger-coralogix-settings.interface'; import { LoggerCoralogixTransport } from './transports/logger-coralogix.transport'; const RAW_OPTIONS_TOKEN = Symbol( diff --git a/packages/nestjs-logger-coralogix/src/logger-coralogix.module.e2e-spec.ts b/packages/nestjs-logger-coralogix/src/logger-coralogix.module.e2e-spec.ts index c01147aa1..df6de1cc3 100644 --- a/packages/nestjs-logger-coralogix/src/logger-coralogix.module.e2e-spec.ts +++ b/packages/nestjs-logger-coralogix/src/logger-coralogix.module.e2e-spec.ts @@ -1,7 +1,7 @@ import supertest from 'supertest'; -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { AppErrorModuleFixture } from './__fixture__/app-error.module.fixture'; import { AppWarnModuleFixture } from './__fixture__/app-warn.module.fixture'; diff --git a/packages/nestjs-logger-coralogix/src/logger-coralogix.module.spec.ts b/packages/nestjs-logger-coralogix/src/logger-coralogix.module.spec.ts index 1437a2762..3220ae47b 100644 --- a/packages/nestjs-logger-coralogix/src/logger-coralogix.module.spec.ts +++ b/packages/nestjs-logger-coralogix/src/logger-coralogix.module.spec.ts @@ -1,8 +1,8 @@ -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type DynamicModule, type ModuleMetadata } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; -import { LoggerCoralogixConfigInterface } from './interfaces/logger-coralogix-config.interface'; -import { LoggerCoralogixSettingsInterface } from './interfaces/logger-coralogix-settings.interface'; +import { type LoggerCoralogixConfigInterface } from './interfaces/logger-coralogix-config.interface'; +import { type LoggerCoralogixSettingsInterface } from './interfaces/logger-coralogix-settings.interface'; import { LoggerCoralogixModule } from './logger-coralogix.module'; import { LoggerCoralogixTransport } from './transports/logger-coralogix.transport'; diff --git a/packages/nestjs-logger-coralogix/src/transports/logger-coralogix.transport.spec.ts b/packages/nestjs-logger-coralogix/src/transports/logger-coralogix.transport.spec.ts index 714a0e5a0..79998e3aa 100644 --- a/packages/nestjs-logger-coralogix/src/transports/logger-coralogix.transport.spec.ts +++ b/packages/nestjs-logger-coralogix/src/transports/logger-coralogix.transport.spec.ts @@ -1,7 +1,7 @@ import { Log } from 'coralogix-logger'; -import { LoggerCoralogixConfigInterface } from '../interfaces/logger-coralogix-config.interface'; -import { LoggerCoralogixSettingsInterface } from '../interfaces/logger-coralogix-settings.interface'; +import { type LoggerCoralogixConfigInterface } from '../interfaces/logger-coralogix-config.interface'; +import { type LoggerCoralogixSettingsInterface } from '../interfaces/logger-coralogix-settings.interface'; import { formatMessage, logLevelMap } from '../utils'; import { LoggerCoralogixTransport } from './logger-coralogix.transport'; diff --git a/packages/nestjs-logger-coralogix/src/utils/index.ts b/packages/nestjs-logger-coralogix/src/utils/index.ts index f0ef028db..7328bae8f 100644 --- a/packages/nestjs-logger-coralogix/src/utils/index.ts +++ b/packages/nestjs-logger-coralogix/src/utils/index.ts @@ -1,8 +1,8 @@ import { Severity } from 'coralogix-logger'; -import { LogLevel } from '@nestjs/common'; +import { type LogLevel } from '@nestjs/common'; -import { LoggerMessageInterface } from '@concepta/nestjs-logger'; +import { type LoggerMessageInterface } from '@concepta/nestjs-logger'; export const logLevelMap = (logLevel: LogLevel): Severity => { switch (logLevel) { diff --git a/packages/nestjs-logger-sentry/src/config/logger-sentry.config.spec.ts b/packages/nestjs-logger-sentry/src/config/logger-sentry.config.spec.ts index fcd0934b2..6a63fab10 100644 --- a/packages/nestjs-logger-sentry/src/config/logger-sentry.config.spec.ts +++ b/packages/nestjs-logger-sentry/src/config/logger-sentry.config.spec.ts @@ -1,5 +1,5 @@ import { ConfigModule } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { loggerSentryConfig, diff --git a/packages/nestjs-logger-sentry/src/config/logger-sentry.config.ts b/packages/nestjs-logger-sentry/src/config/logger-sentry.config.ts index 1115e241b..d5bfcd7be 100644 --- a/packages/nestjs-logger-sentry/src/config/logger-sentry.config.ts +++ b/packages/nestjs-logger-sentry/src/config/logger-sentry.config.ts @@ -1,12 +1,12 @@ import { - ConfigFactory, - ConfigFactoryKeyHost, + type ConfigFactory, + type ConfigFactoryKeyHost, registerAs, } from '@nestjs/config'; import { splitLogLevel } from '@concepta/nestjs-logger'; -import { LoggerSentrySettingsInterface } from '../interfaces/logger-sentry-settings.interface'; +import { type LoggerSentrySettingsInterface } from '../interfaces/logger-sentry-settings.interface'; import { formatMessage, logLevelMap } from '../utils'; /** diff --git a/packages/nestjs-logger-sentry/src/exceptions/logger-sentry.exceptions.ts b/packages/nestjs-logger-sentry/src/exceptions/logger-sentry.exceptions.ts index 4fc583450..edef00ac3 100644 --- a/packages/nestjs-logger-sentry/src/exceptions/logger-sentry.exceptions.ts +++ b/packages/nestjs-logger-sentry/src/exceptions/logger-sentry.exceptions.ts @@ -1,6 +1,6 @@ import { RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; export class LoggerSentryException extends RuntimeException { diff --git a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-async-options.interface.ts b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-async-options.interface.ts index e1a8bce3b..9db3eb6aa 100644 --- a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-async-options.interface.ts +++ b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-async-options.interface.ts @@ -1,12 +1,13 @@ -import { FactoryProvider, ModuleMetadata } from '@nestjs/common'; +import { type FactoryProvider, type ModuleMetadata } from '@nestjs/common'; -import { LoggerSentryOptionsInterface } from './logger-sentry-options.interface'; +import { type LoggerSentryOptionsInterface } from './logger-sentry-options.interface'; /** * LoggerSentry async options. */ export interface LoggerSentryAsyncOptionsInterface - extends Pick, + extends + Pick, Pick< FactoryProvider< LoggerSentryOptionsInterface | Promise diff --git a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-config.interface.ts b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-config.interface.ts index 96138d80c..0eede1ec4 100644 --- a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-config.interface.ts +++ b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-config.interface.ts @@ -1,4 +1,4 @@ -import { NodeOptions as SentryNodeOptions } from '@sentry/node'; +import { type NodeOptions as SentryNodeOptions } from '@sentry/node'; /** * Interface for Sentry configuration to define the log level diff --git a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-extras.interface.ts b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-extras.interface.ts index b458146ff..b7fbb2669 100644 --- a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-extras.interface.ts +++ b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-extras.interface.ts @@ -1,9 +1,8 @@ -import { RuntimeExceptionInterface } from '@concepta/nestjs-common'; +import { type RuntimeExceptionInterface } from '@concepta/nestjs-common'; -export interface LoggerSentryExtrasInterface - extends Partial< - Pick - > { +export interface LoggerSentryExtrasInterface extends Partial< + Pick +> { statusCode?: number; message?: string | unknown; } diff --git a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-options-extras.interface.ts b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-options-extras.interface.ts index cb83fa23f..cf5ee8ab4 100644 --- a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-options-extras.interface.ts +++ b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface LoggerSentryOptionsExtrasInterface - extends Pick {} +export interface LoggerSentryOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-options.interface.ts b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-options.interface.ts index 7c10eca38..f12beded6 100644 --- a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-options.interface.ts +++ b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-options.interface.ts @@ -1,4 +1,4 @@ -import { LoggerSentrySettingsInterface } from './logger-sentry-settings.interface'; +import { type LoggerSentrySettingsInterface } from './logger-sentry-settings.interface'; /** * LoggerSentry options interface. diff --git a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-settings.interface.ts b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-settings.interface.ts index e964001df..0223c217f 100644 --- a/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-settings.interface.ts +++ b/packages/nestjs-logger-sentry/src/interfaces/logger-sentry-settings.interface.ts @@ -1,17 +1,18 @@ -import { SeverityLevel } from '@sentry/types'; +import { type SeverityLevel } from '@sentry/types'; import { - LoggerSettingsInterface, - LoggerTransportSettingsInterface, + type LoggerSettingsInterface, + type LoggerTransportSettingsInterface, } from '@concepta/nestjs-logger'; -import { LoggerSentryConfigInterface } from './logger-sentry-config.interface'; +import { type LoggerSentryConfigInterface } from './logger-sentry-config.interface'; /** * LoggerSentry options interface. */ export interface LoggerSentrySettingsInterface - extends Partial>, + extends + Partial>, LoggerTransportSettingsInterface { /** * diff --git a/packages/nestjs-logger-sentry/src/logger-sentry.module-definition.ts b/packages/nestjs-logger-sentry/src/logger-sentry.module-definition.ts index d02276c6e..4c18f6c6f 100644 --- a/packages/nestjs-logger-sentry/src/logger-sentry.module-definition.ts +++ b/packages/nestjs-logger-sentry/src/logger-sentry.module-definition.ts @@ -1,7 +1,7 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; @@ -11,9 +11,9 @@ import { LOGGER_SENTRY_MODULE_SETTINGS_TOKEN, loggerSentryConfig, } from './config/logger-sentry.config'; -import { LoggerSentryOptionsExtrasInterface } from './interfaces/logger-sentry-options-extras.interface'; -import { LoggerSentryOptionsInterface } from './interfaces/logger-sentry-options.interface'; -import { LoggerSentrySettingsInterface } from './interfaces/logger-sentry-settings.interface'; +import { type LoggerSentryOptionsExtrasInterface } from './interfaces/logger-sentry-options-extras.interface'; +import { type LoggerSentryOptionsInterface } from './interfaces/logger-sentry-options.interface'; +import { type LoggerSentrySettingsInterface } from './interfaces/logger-sentry-settings.interface'; import { LoggerSentryTransport } from './transports/logger-sentry.transport'; const RAW_OPTIONS_TOKEN = Symbol('__LOGGER_SENTRY_MODULE_RAW_OPTIONS_TOKEN__'); diff --git a/packages/nestjs-logger-sentry/src/logger-sentry.module.e2e-spec.ts b/packages/nestjs-logger-sentry/src/logger-sentry.module.e2e-spec.ts index cf6f8cf3f..2e995bc27 100644 --- a/packages/nestjs-logger-sentry/src/logger-sentry.module.e2e-spec.ts +++ b/packages/nestjs-logger-sentry/src/logger-sentry.module.e2e-spec.ts @@ -1,7 +1,7 @@ import supertest from 'supertest'; -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { AppErrorModuleFixture } from './__fixture__/app-error.module.fixture'; import { AppWarnModuleFixture } from './__fixture__/app-warn.module.fixture'; diff --git a/packages/nestjs-logger-sentry/src/logger-sentry.module.spec.ts b/packages/nestjs-logger-sentry/src/logger-sentry.module.spec.ts index 61e0a13a3..50f5ce186 100644 --- a/packages/nestjs-logger-sentry/src/logger-sentry.module.spec.ts +++ b/packages/nestjs-logger-sentry/src/logger-sentry.module.spec.ts @@ -1,9 +1,9 @@ -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type DynamicModule, type ModuleMetadata } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { LoggerModule } from '@concepta/nestjs-logger'; -import { LoggerSentrySettingsInterface } from './interfaces/logger-sentry-settings.interface'; +import { type LoggerSentrySettingsInterface } from './interfaces/logger-sentry-settings.interface'; import { LoggerSentryModule } from './logger-sentry.module'; import { LoggerSentryTransport } from './transports/logger-sentry.transport'; import { formatMessage, logLevelMap } from './utils'; diff --git a/packages/nestjs-logger-sentry/src/transports/logger-sentry.transport.spec.ts b/packages/nestjs-logger-sentry/src/transports/logger-sentry.transport.spec.ts index 201c7b239..e2bedc2e3 100644 --- a/packages/nestjs-logger-sentry/src/transports/logger-sentry.transport.spec.ts +++ b/packages/nestjs-logger-sentry/src/transports/logger-sentry.transport.spec.ts @@ -1,18 +1,18 @@ import * as Sentry from '@sentry/node'; import { isObject } from 'class-validator'; -import { BadRequestException, HttpStatus, LogLevel } from '@nestjs/common'; +import { BadRequestException, HttpStatus, type LogLevel } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { mapHttpStatus, RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; import { LOGGER_SENTRY_MODULE_SETTINGS_TOKEN } from '../config/logger-sentry.config'; -import { LoggerSentryConfigInterface } from '../interfaces/logger-sentry-config.interface'; -import { LoggerSentrySettingsInterface } from '../interfaces/logger-sentry-settings.interface'; +import { type LoggerSentryConfigInterface } from '../interfaces/logger-sentry-config.interface'; +import { type LoggerSentrySettingsInterface } from '../interfaces/logger-sentry-settings.interface'; import { LoggerSentryTransport } from './logger-sentry.transport'; diff --git a/packages/nestjs-logger-sentry/src/utils/index.ts b/packages/nestjs-logger-sentry/src/utils/index.ts index 3b30b771c..c94851c94 100644 --- a/packages/nestjs-logger-sentry/src/utils/index.ts +++ b/packages/nestjs-logger-sentry/src/utils/index.ts @@ -1,8 +1,8 @@ -import { SeverityLevel } from '@sentry/types'; +import { type SeverityLevel } from '@sentry/types'; -import { LogLevel } from '@nestjs/common'; +import { type LogLevel } from '@nestjs/common'; -import { LoggerMessageInterface } from '@concepta/nestjs-logger'; +import { type LoggerMessageInterface } from '@concepta/nestjs-logger'; /** * Mapping from log level to sentry severity * diff --git a/packages/nestjs-logger/src/config/logger.config.spec.ts b/packages/nestjs-logger/src/config/logger.config.spec.ts index 531cc2e22..a3a47d2f3 100644 --- a/packages/nestjs-logger/src/config/logger.config.spec.ts +++ b/packages/nestjs-logger/src/config/logger.config.spec.ts @@ -1,7 +1,7 @@ import { ConfigModule } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; -import { LoggerOptionsInterface } from '../interfaces/logger-options.interface'; +import { type LoggerOptionsInterface } from '../interfaces/logger-options.interface'; import { loggerConfig, LOGGER_MODULE_SETTINGS_TOKEN } from './logger.config'; diff --git a/packages/nestjs-logger/src/config/logger.config.ts b/packages/nestjs-logger/src/config/logger.config.ts index bca63a3ab..1295c5c72 100644 --- a/packages/nestjs-logger/src/config/logger.config.ts +++ b/packages/nestjs-logger/src/config/logger.config.ts @@ -1,11 +1,11 @@ -import { LogLevel } from '@nestjs/common'; +import { type LogLevel } from '@nestjs/common'; import { - ConfigFactory, - ConfigFactoryKeyHost, + type ConfigFactory, + type ConfigFactoryKeyHost, registerAs, } from '@nestjs/config'; -import { LoggerSettingsInterface } from '../interfaces/logger-settings.interface'; +import { type LoggerSettingsInterface } from '../interfaces/logger-settings.interface'; import { splitLogLevel } from '../utils/config-parser.util'; /** diff --git a/packages/nestjs-logger/src/exceptions/logger-invalid-log-level.exception.ts b/packages/nestjs-logger/src/exceptions/logger-invalid-log-level.exception.ts index e187e6444..23e286849 100644 --- a/packages/nestjs-logger/src/exceptions/logger-invalid-log-level.exception.ts +++ b/packages/nestjs-logger/src/exceptions/logger-invalid-log-level.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { LoggerException } from './logger.exceptions'; diff --git a/packages/nestjs-logger/src/exceptions/logger.exceptions.ts b/packages/nestjs-logger/src/exceptions/logger.exceptions.ts index 32b10a972..422a741ec 100644 --- a/packages/nestjs-logger/src/exceptions/logger.exceptions.ts +++ b/packages/nestjs-logger/src/exceptions/logger.exceptions.ts @@ -1,6 +1,6 @@ import { RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; export class LoggerException extends RuntimeException { diff --git a/packages/nestjs-logger/src/interfaces/logger-async-options.interface.ts b/packages/nestjs-logger/src/interfaces/logger-async-options.interface.ts index 7971aece8..61197e14d 100644 --- a/packages/nestjs-logger/src/interfaces/logger-async-options.interface.ts +++ b/packages/nestjs-logger/src/interfaces/logger-async-options.interface.ts @@ -1,12 +1,13 @@ -import { FactoryProvider, ModuleMetadata } from '@nestjs/common'; +import { type FactoryProvider, type ModuleMetadata } from '@nestjs/common'; -import { LoggerOptionsInterface } from './logger-options.interface'; +import { type LoggerOptionsInterface } from './logger-options.interface'; /** * Logger async options. */ export interface LoggerAsyncOptionsInterface - extends Pick, + extends + Pick, Pick< FactoryProvider>, 'useFactory' | 'inject' diff --git a/packages/nestjs-logger/src/interfaces/logger-message.interface.ts b/packages/nestjs-logger/src/interfaces/logger-message.interface.ts index 50f42c8ee..60e2871ac 100644 --- a/packages/nestjs-logger/src/interfaces/logger-message.interface.ts +++ b/packages/nestjs-logger/src/interfaces/logger-message.interface.ts @@ -1,4 +1,4 @@ -import { LogLevel } from '@nestjs/common'; +import { type LogLevel } from '@nestjs/common'; export interface LoggerMessageInterface { message?: string; diff --git a/packages/nestjs-logger/src/interfaces/logger-options-extras.interface.ts b/packages/nestjs-logger/src/interfaces/logger-options-extras.interface.ts index 6db204c7d..ceb445993 100644 --- a/packages/nestjs-logger/src/interfaces/logger-options-extras.interface.ts +++ b/packages/nestjs-logger/src/interfaces/logger-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface LoggerOptionsExtrasInterface - extends Pick {} +export interface LoggerOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-logger/src/interfaces/logger-options.interface.ts b/packages/nestjs-logger/src/interfaces/logger-options.interface.ts index 40e26cf42..aa9f40099 100644 --- a/packages/nestjs-logger/src/interfaces/logger-options.interface.ts +++ b/packages/nestjs-logger/src/interfaces/logger-options.interface.ts @@ -1,8 +1,8 @@ -import { NestInterceptor } from '@nestjs/common'; -import { BaseExceptionFilter } from '@nestjs/core'; +import { type NestInterceptor } from '@nestjs/common'; +import { type BaseExceptionFilter } from '@nestjs/core'; -import { LoggerSettingsInterface } from './logger-settings.interface'; -import { LoggerTransportInterface } from './logger-transport.interface'; +import { type LoggerSettingsInterface } from './logger-settings.interface'; +import { type LoggerTransportInterface } from './logger-transport.interface'; /** * Logger options interface. diff --git a/packages/nestjs-logger/src/interfaces/logger-service.interface.ts b/packages/nestjs-logger/src/interfaces/logger-service.interface.ts index d78c2f64f..c78076fd4 100644 --- a/packages/nestjs-logger/src/interfaces/logger-service.interface.ts +++ b/packages/nestjs-logger/src/interfaces/logger-service.interface.ts @@ -1,4 +1,4 @@ -import { LoggerTransportInterface } from './logger-transport.interface'; +import { type LoggerTransportInterface } from './logger-transport.interface'; /** * Logger Service Interface diff --git a/packages/nestjs-logger/src/interfaces/logger-settings.interface.ts b/packages/nestjs-logger/src/interfaces/logger-settings.interface.ts index 9575c2549..5a07cc6ee 100644 --- a/packages/nestjs-logger/src/interfaces/logger-settings.interface.ts +++ b/packages/nestjs-logger/src/interfaces/logger-settings.interface.ts @@ -1,4 +1,4 @@ -import { LogLevel } from '@nestjs/common'; +import { type LogLevel } from '@nestjs/common'; /** * Logger options interface. diff --git a/packages/nestjs-logger/src/interfaces/logger-transport-settings.interface.ts b/packages/nestjs-logger/src/interfaces/logger-transport-settings.interface.ts index 9f2ee6f2f..29d4137f6 100644 --- a/packages/nestjs-logger/src/interfaces/logger-transport-settings.interface.ts +++ b/packages/nestjs-logger/src/interfaces/logger-transport-settings.interface.ts @@ -1,6 +1,6 @@ -import { LogLevel } from '@nestjs/common'; +import { type LogLevel } from '@nestjs/common'; -import { LoggerMessageInterface } from './logger-message.interface'; +import { type LoggerMessageInterface } from './logger-message.interface'; /** * Logger options interface. diff --git a/packages/nestjs-logger/src/interfaces/logger-transport.interface.ts b/packages/nestjs-logger/src/interfaces/logger-transport.interface.ts index 7d69b109c..442d0bb53 100644 --- a/packages/nestjs-logger/src/interfaces/logger-transport.interface.ts +++ b/packages/nestjs-logger/src/interfaces/logger-transport.interface.ts @@ -1,4 +1,4 @@ -import { LogLevel } from '@nestjs/common'; +import { type LogLevel } from '@nestjs/common'; /** * Interface for 3dr party transport. diff --git a/packages/nestjs-logger/src/logger-exception.filter.spec.ts b/packages/nestjs-logger/src/logger-exception.filter.spec.ts index 716bbf624..a083ee10f 100644 --- a/packages/nestjs-logger/src/logger-exception.filter.spec.ts +++ b/packages/nestjs-logger/src/logger-exception.filter.spec.ts @@ -1,8 +1,8 @@ import { mock } from 'jest-mock-extended'; -import { ArgumentsHost, INestApplication } from '@nestjs/common'; +import { type ArgumentsHost, type INestApplication } from '@nestjs/common'; import { HttpAdapterHost } from '@nestjs/core'; -import { TestingModule, Test } from '@nestjs/testing'; +import { type TestingModule, Test } from '@nestjs/testing'; import { LoggerExceptionFilter } from './logger-exception.filter'; import { LoggerTransportService } from './logger-transport.service'; diff --git a/packages/nestjs-logger/src/logger-request.interceptor.spec.ts b/packages/nestjs-logger/src/logger-request.interceptor.spec.ts index c9128c967..ea078721b 100644 --- a/packages/nestjs-logger/src/logger-request.interceptor.spec.ts +++ b/packages/nestjs-logger/src/logger-request.interceptor.spec.ts @@ -1,9 +1,9 @@ import { - FastifyRequest as Request, - LightMyRequestResponse as Response, + type FastifyRequest as Request, + type LightMyRequestResponse as Response, } from 'fastify'; -import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { type CallHandler, type ExecutionContext } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { LoggerRequestInterceptor } from './logger-request.interceptor'; diff --git a/packages/nestjs-logger/src/logger-transport.service.spec.ts b/packages/nestjs-logger/src/logger-transport.service.spec.ts index 501a5eacd..dd2b9aa22 100644 --- a/packages/nestjs-logger/src/logger-transport.service.spec.ts +++ b/packages/nestjs-logger/src/logger-transport.service.spec.ts @@ -1,8 +1,8 @@ -import { Logger, LogLevel } from '@nestjs/common'; +import { Logger, type LogLevel } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { LOGGER_MODULE_SETTINGS_TOKEN } from './config/logger.config'; -import { LoggerTransportInterface } from './interfaces/logger-transport.interface'; +import { type LoggerTransportInterface } from './interfaces/logger-transport.interface'; import { LoggerTransportService } from './logger-transport.service'; class TestTransport implements LoggerTransportInterface { diff --git a/packages/nestjs-logger/src/logger.module-definition.ts b/packages/nestjs-logger/src/logger.module-definition.ts index 48ccb417a..8c978a193 100644 --- a/packages/nestjs-logger/src/logger.module-definition.ts +++ b/packages/nestjs-logger/src/logger.module-definition.ts @@ -1,10 +1,14 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { APP_FILTER, APP_INTERCEPTOR, BaseExceptionFilter } from '@nestjs/core'; +import { + APP_FILTER, + APP_INTERCEPTOR, + type BaseExceptionFilter, +} from '@nestjs/core'; import { createSettingsProvider } from '@concepta/nestjs-common'; @@ -12,9 +16,9 @@ import { LOGGER_MODULE_SETTINGS_TOKEN, loggerConfig, } from './config/logger.config'; -import { LoggerOptionsExtrasInterface } from './interfaces/logger-options-extras.interface'; -import { LoggerOptionsInterface } from './interfaces/logger-options.interface'; -import { LoggerSettingsInterface } from './interfaces/logger-settings.interface'; +import { type LoggerOptionsExtrasInterface } from './interfaces/logger-options-extras.interface'; +import { type LoggerOptionsInterface } from './interfaces/logger-options.interface'; +import { type LoggerSettingsInterface } from './interfaces/logger-settings.interface'; import { LoggerExceptionFilter } from './logger-exception.filter'; import { LoggerRequestInterceptor } from './logger-request.interceptor'; import { LoggerTransportService } from './logger-transport.service'; diff --git a/packages/nestjs-logger/src/logger.module.spec.ts b/packages/nestjs-logger/src/logger.module.spec.ts index 670a26383..6c5409bd3 100644 --- a/packages/nestjs-logger/src/logger.module.spec.ts +++ b/packages/nestjs-logger/src/logger.module.spec.ts @@ -1,5 +1,5 @@ -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type DynamicModule, type ModuleMetadata } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { LoggerTransportService } from './logger-transport.service'; import { LoggerModule } from './logger.module'; diff --git a/packages/nestjs-logger/src/logger.service.spec.ts b/packages/nestjs-logger/src/logger.service.spec.ts index 6ba09409d..bc2f4e45c 100644 --- a/packages/nestjs-logger/src/logger.service.spec.ts +++ b/packages/nestjs-logger/src/logger.service.spec.ts @@ -2,13 +2,13 @@ import supertest from 'supertest'; import { ConsoleLogger, - INestApplication, + type INestApplication, NotFoundException, } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { AppModuleFixture } from './__fixture__/app.module.fixture'; -import { LoggerTransportInterface } from './interfaces/logger-transport.interface'; +import { type LoggerTransportInterface } from './interfaces/logger-transport.interface'; import { LoggerTransportService } from './logger-transport.service'; import { LoggerService } from './logger.service'; diff --git a/packages/nestjs-logger/src/utils/config-parser.util.ts b/packages/nestjs-logger/src/utils/config-parser.util.ts index cea0ad0e6..c5f374ba7 100644 --- a/packages/nestjs-logger/src/utils/config-parser.util.ts +++ b/packages/nestjs-logger/src/utils/config-parser.util.ts @@ -1,4 +1,4 @@ -import { LogLevel } from '@nestjs/common'; +import { type LogLevel } from '@nestjs/common'; import { LOGGER_VALID_LOG_LEVELS } from '../config/logger.config'; import { LoggerInvalidLogLevelException } from '../exceptions/logger-invalid-log-level.exception'; diff --git a/packages/nestjs-logger/src/utils/message-format.util.spec.ts b/packages/nestjs-logger/src/utils/message-format.util.spec.ts index 9417a62d7..d6068139b 100644 --- a/packages/nestjs-logger/src/utils/message-format.util.spec.ts +++ b/packages/nestjs-logger/src/utils/message-format.util.spec.ts @@ -1,6 +1,6 @@ import { - FastifyRequest as Request, - LightMyRequestResponse as Response, + type FastifyRequest as Request, + type LightMyRequestResponse as Response, } from 'fastify'; import { mock } from 'jest-mock-extended'; diff --git a/packages/nestjs-logger/src/utils/message-format.util.ts b/packages/nestjs-logger/src/utils/message-format.util.ts index 0f22aa793..58d8e6220 100644 --- a/packages/nestjs-logger/src/utils/message-format.util.ts +++ b/packages/nestjs-logger/src/utils/message-format.util.ts @@ -1,6 +1,6 @@ import { - FastifyRequest as Request, - LightMyRequestResponse as Response, + type FastifyRequest as Request, + type LightMyRequestResponse as Response, } from 'fastify'; /** diff --git a/packages/nestjs-org/package.json b/packages/nestjs-org/package.json index 62bd4d012..8645edbc7 100644 --- a/packages/nestjs-org/package.json +++ b/packages/nestjs-org/package.json @@ -17,7 +17,7 @@ "@concepta/nestjs-event": "^7.0.0-alpha.10", "@nestjs/common": "^11.1.9", "@nestjs/config": "^4.0.2", - "@nestjs/swagger": "^11.2.2" + "@nestjs/swagger": "11.2.2" }, "devDependencies": { "@concepta/nestjs-invitation": "^7.0.0-alpha.10", diff --git a/packages/nestjs-org/src/__fixtures__/controllers/org.controller.fixture.ts b/packages/nestjs-org/src/__fixtures__/controllers/org.controller.fixture.ts index 1645faee5..83842da9d 100644 --- a/packages/nestjs-org/src/__fixtures__/controllers/org.controller.fixture.ts +++ b/packages/nestjs-org/src/__fixtures__/controllers/org.controller.fixture.ts @@ -48,14 +48,11 @@ import { OrgCrudService } from '../org-crud.service'; }, }) @ApiTags('org') -export class OrgControllerFixture - implements - CrudControllerInterface< - OrgEntityInterface, - OrgCreatableInterface, - OrgUpdatableInterface - > -{ +export class OrgControllerFixture implements CrudControllerInterface< + OrgEntityInterface, + OrgCreatableInterface, + OrgUpdatableInterface +> { /** * Constructor. * diff --git a/packages/nestjs-org/src/__fixtures__/invitation-accepted.event.ts b/packages/nestjs-org/src/__fixtures__/invitation-accepted.event.ts index ea636a30a..34789c36e 100644 --- a/packages/nestjs-org/src/__fixtures__/invitation-accepted.event.ts +++ b/packages/nestjs-org/src/__fixtures__/invitation-accepted.event.ts @@ -1,4 +1,4 @@ -import { InvitationAcceptedEventPayloadInterface } from '@concepta/nestjs-common'; +import { type InvitationAcceptedEventPayloadInterface } from '@concepta/nestjs-common'; import { EventAsync } from '@concepta/nestjs-event'; export class InvitationAcceptedEventAsync extends EventAsync< diff --git a/packages/nestjs-org/src/__fixtures__/owner-repository.fixture.ts b/packages/nestjs-org/src/__fixtures__/owner-repository.fixture.ts index 55c5f3631..63c595ebe 100644 --- a/packages/nestjs-org/src/__fixtures__/owner-repository.fixture.ts +++ b/packages/nestjs-org/src/__fixtures__/owner-repository.fixture.ts @@ -1,5 +1,5 @@ import { Repository } from 'typeorm'; -import { OwnerEntityFixture } from './owner-entity.fixture'; +import { type OwnerEntityFixture } from './owner-entity.fixture'; export class OwnerRepositoryFixture extends Repository {} diff --git a/packages/nestjs-org/src/config/org-default.config.ts b/packages/nestjs-org/src/config/org-default.config.ts index 2739e7b96..f0ba8742c 100644 --- a/packages/nestjs-org/src/config/org-default.config.ts +++ b/packages/nestjs-org/src/config/org-default.config.ts @@ -1,6 +1,6 @@ import { registerAs } from '@nestjs/config'; -import { OrgSettingsInterface } from '../interfaces/org-settings.interface'; +import { type OrgSettingsInterface } from '../interfaces/org-settings.interface'; import { ORG_MODULE_DEFAULT_SETTINGS_TOKEN } from '../org.constants'; /** diff --git a/packages/nestjs-org/src/controllers/org.controller.e2e-spec.ts b/packages/nestjs-org/src/controllers/org.controller.e2e-spec.ts index 76374efe2..a921bca07 100644 --- a/packages/nestjs-org/src/controllers/org.controller.e2e-spec.ts +++ b/packages/nestjs-org/src/controllers/org.controller.e2e-spec.ts @@ -1,7 +1,7 @@ import supertest from 'supertest'; -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; import { CrudModule } from '@concepta/nestjs-crud'; diff --git a/packages/nestjs-org/src/entities/common-postgres.entity.ts b/packages/nestjs-org/src/entities/common-postgres.entity.ts new file mode 100644 index 000000000..86db48081 --- /dev/null +++ b/packages/nestjs-org/src/entities/common-postgres.entity.ts @@ -0,0 +1,24 @@ +import { + CreateDateColumn, + DeleteDateColumn, + PrimaryGeneratedColumn, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; + +export abstract class CommonPostgresEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + dateCreated!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + dateUpdated!: Date; + + @DeleteDateColumn({ type: 'timestamptz' }) + dateDeleted!: Date | null; + + @VersionColumn({ type: 'integer' }) + version!: number; +} diff --git a/packages/nestjs-org/src/entities/common-sqlite.entity.ts b/packages/nestjs-org/src/entities/common-sqlite.entity.ts new file mode 100644 index 000000000..15e315bf7 --- /dev/null +++ b/packages/nestjs-org/src/entities/common-sqlite.entity.ts @@ -0,0 +1,24 @@ +import { + CreateDateColumn, + DeleteDateColumn, + PrimaryGeneratedColumn, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; + +export abstract class CommonSqliteEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'datetime' }) + dateCreated!: Date; + + @UpdateDateColumn({ type: 'datetime' }) + dateUpdated!: Date; + + @DeleteDateColumn({ type: 'datetime' }) + dateDeleted!: Date | null; + + @VersionColumn({ type: 'integer' }) + version!: number; +} diff --git a/packages/nestjs-typeorm-ext/src/entities/org/org-member-postgres.entity.ts b/packages/nestjs-org/src/entities/org-member-postgres.entity.ts similarity index 85% rename from packages/nestjs-typeorm-ext/src/entities/org/org-member-postgres.entity.ts rename to packages/nestjs-org/src/entities/org-member-postgres.entity.ts index 765bba4f5..171d54397 100644 --- a/packages/nestjs-typeorm-ext/src/entities/org/org-member-postgres.entity.ts +++ b/packages/nestjs-org/src/entities/org-member-postgres.entity.ts @@ -2,7 +2,7 @@ import { Column, Unique } from 'typeorm'; import { ReferenceId, OrgMemberEntityInterface } from '@concepta/nestjs-common'; -import { CommonPostgresEntity } from '../common/common-postgres.entity'; +import { CommonPostgresEntity } from './common-postgres.entity'; @Unique(['userId', 'orgId']) export abstract class OrgMemberPostgresEntity diff --git a/packages/nestjs-typeorm-ext/src/entities/org/org-member-sqlite.entity.ts b/packages/nestjs-org/src/entities/org-member-sqlite.entity.ts similarity index 86% rename from packages/nestjs-typeorm-ext/src/entities/org/org-member-sqlite.entity.ts rename to packages/nestjs-org/src/entities/org-member-sqlite.entity.ts index 03f2e0fc5..8d1877645 100644 --- a/packages/nestjs-typeorm-ext/src/entities/org/org-member-sqlite.entity.ts +++ b/packages/nestjs-org/src/entities/org-member-sqlite.entity.ts @@ -2,7 +2,7 @@ import { Column, Unique } from 'typeorm'; import { ReferenceId, OrgMemberEntityInterface } from '@concepta/nestjs-common'; -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; +import { CommonSqliteEntity } from './common-sqlite.entity'; @Unique(['userId', 'orgId']) export abstract class OrgMemberSqliteEntity diff --git a/packages/nestjs-typeorm-ext/src/entities/org/org-postgres.entity.ts b/packages/nestjs-org/src/entities/org-postgres.entity.ts similarity index 90% rename from packages/nestjs-typeorm-ext/src/entities/org/org-postgres.entity.ts rename to packages/nestjs-org/src/entities/org-postgres.entity.ts index 4325717fd..fe4475272 100644 --- a/packages/nestjs-typeorm-ext/src/entities/org/org-postgres.entity.ts +++ b/packages/nestjs-org/src/entities/org-postgres.entity.ts @@ -7,7 +7,7 @@ import { OrgEntityInterface, } from '@concepta/nestjs-common'; -import { CommonPostgresEntity } from '../common/common-postgres.entity'; +import { CommonPostgresEntity } from './common-postgres.entity'; /** * Org Postgres Entity diff --git a/packages/nestjs-typeorm-ext/src/entities/org/org-profile-postgres.entity.ts b/packages/nestjs-org/src/entities/org-profile-postgres.entity.ts similarity index 85% rename from packages/nestjs-typeorm-ext/src/entities/org/org-profile-postgres.entity.ts rename to packages/nestjs-org/src/entities/org-profile-postgres.entity.ts index eff4c8a89..c06ebb019 100644 --- a/packages/nestjs-typeorm-ext/src/entities/org/org-profile-postgres.entity.ts +++ b/packages/nestjs-org/src/entities/org-profile-postgres.entity.ts @@ -5,7 +5,7 @@ import { OrgProfileEntityInterface, } from '@concepta/nestjs-common'; -import { CommonPostgresEntity } from '../common/common-postgres.entity'; +import { CommonPostgresEntity } from './common-postgres.entity'; /** * Org Profile Postgres Entity diff --git a/packages/nestjs-typeorm-ext/src/entities/org/org-profile-sqlite.entity.ts b/packages/nestjs-org/src/entities/org-profile-sqlite.entity.ts similarity index 83% rename from packages/nestjs-typeorm-ext/src/entities/org/org-profile-sqlite.entity.ts rename to packages/nestjs-org/src/entities/org-profile-sqlite.entity.ts index 19210c15f..24a76c672 100644 --- a/packages/nestjs-typeorm-ext/src/entities/org/org-profile-sqlite.entity.ts +++ b/packages/nestjs-org/src/entities/org-profile-sqlite.entity.ts @@ -5,7 +5,7 @@ import { OrgProfileEntityInterface, } from '@concepta/nestjs-common'; -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; +import { CommonSqliteEntity } from './common-sqlite.entity'; /** * Org Profile Sqlite Entity diff --git a/packages/nestjs-typeorm-ext/src/entities/org/org-sqlite.entity.ts b/packages/nestjs-org/src/entities/org-sqlite.entity.ts similarity index 89% rename from packages/nestjs-typeorm-ext/src/entities/org/org-sqlite.entity.ts rename to packages/nestjs-org/src/entities/org-sqlite.entity.ts index 2f90770fb..ba182cc16 100644 --- a/packages/nestjs-typeorm-ext/src/entities/org/org-sqlite.entity.ts +++ b/packages/nestjs-org/src/entities/org-sqlite.entity.ts @@ -7,7 +7,7 @@ import { OrgEntityInterface, } from '@concepta/nestjs-common'; -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; +import { CommonSqliteEntity } from './common-sqlite.entity'; /** * Org Sqlite Entity diff --git a/packages/nestjs-org/src/exceptions/org-member.exception.ts b/packages/nestjs-org/src/exceptions/org-member.exception.ts index 79905874e..c0dfc26ec 100644 --- a/packages/nestjs-org/src/exceptions/org-member.exception.ts +++ b/packages/nestjs-org/src/exceptions/org-member.exception.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { OrgException } from './org.exception'; diff --git a/packages/nestjs-org/src/exceptions/org-not-found.exception.ts b/packages/nestjs-org/src/exceptions/org-not-found.exception.ts index 1c9b3ac32..ad167f6e9 100644 --- a/packages/nestjs-org/src/exceptions/org-not-found.exception.ts +++ b/packages/nestjs-org/src/exceptions/org-not-found.exception.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { OrgException } from './org.exception'; diff --git a/packages/nestjs-org/src/exceptions/org.exception.ts b/packages/nestjs-org/src/exceptions/org.exception.ts index 71cc1ab88..9d3bdac16 100644 --- a/packages/nestjs-org/src/exceptions/org.exception.ts +++ b/packages/nestjs-org/src/exceptions/org.exception.ts @@ -1,6 +1,6 @@ import { RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; export class OrgException extends RuntimeException { diff --git a/packages/nestjs-org/src/index.ts b/packages/nestjs-org/src/index.ts index 93bd45cb9..f90de0870 100644 --- a/packages/nestjs-org/src/index.ts +++ b/packages/nestjs-org/src/index.ts @@ -1,4 +1,12 @@ export { OrgModule } from './org.module'; + +// entities +export { OrgSqliteEntity } from './entities/org-sqlite.entity'; +export { OrgPostgresEntity } from './entities/org-postgres.entity'; +export { OrgMemberSqliteEntity } from './entities/org-member-sqlite.entity'; +export { OrgMemberPostgresEntity } from './entities/org-member-postgres.entity'; +export { OrgProfileSqliteEntity } from './entities/org-profile-sqlite.entity'; +export { OrgProfilePostgresEntity } from './entities/org-profile-postgres.entity'; export { OrgCrudBuilder } from './utils/org.crud-builder'; export { OrgProfileCrudBuilder } from './utils/org-profile.crud-builder'; diff --git a/packages/nestjs-org/src/interfaces/org-entities-options.interface.ts b/packages/nestjs-org/src/interfaces/org-entities-options.interface.ts index d4d459239..29014dd0d 100644 --- a/packages/nestjs-org/src/interfaces/org-entities-options.interface.ts +++ b/packages/nestjs-org/src/interfaces/org-entities-options.interface.ts @@ -1,14 +1,14 @@ import { - OrgEntityInterface, - RepositoryEntityOptionInterface, - OrgMemberEntityInterface, - OrgProfileEntityInterface, + type OrgEntityInterface, + type RepositoryEntityOptionInterface, + type OrgMemberEntityInterface, + type OrgProfileEntityInterface, } from '@concepta/nestjs-common'; import { - ORG_MODULE_ORG_MEMBER_ENTITY_KEY, - ORG_MODULE_ORG_ENTITY_KEY, - ORG_MODULE_ORG_PROFILE_ENTITY_KEY, + type ORG_MODULE_ORG_MEMBER_ENTITY_KEY, + type ORG_MODULE_ORG_ENTITY_KEY, + type ORG_MODULE_ORG_PROFILE_ENTITY_KEY, } from '../org.constants'; export interface OrgEntitiesOptionsInterface { diff --git a/packages/nestjs-org/src/interfaces/org-member-creatable.interface.ts b/packages/nestjs-org/src/interfaces/org-member-creatable.interface.ts index 75da051b4..96ffb7cda 100644 --- a/packages/nestjs-org/src/interfaces/org-member-creatable.interface.ts +++ b/packages/nestjs-org/src/interfaces/org-member-creatable.interface.ts @@ -1,4 +1,6 @@ -import { OrgMemberInterface } from '@concepta/nestjs-common'; +import { type OrgMemberInterface } from '@concepta/nestjs-common'; -export interface OrgMemberCreatableInterface - extends Pick {} +export interface OrgMemberCreatableInterface extends Pick< + OrgMemberInterface, + 'orgId' | 'userId' +> {} diff --git a/packages/nestjs-org/src/interfaces/org-member-model-service.interface.ts b/packages/nestjs-org/src/interfaces/org-member-model-service.interface.ts index dac132208..9457af735 100644 --- a/packages/nestjs-org/src/interfaces/org-member-model-service.interface.ts +++ b/packages/nestjs-org/src/interfaces/org-member-model-service.interface.ts @@ -1,15 +1,16 @@ import { - ByIdInterface, - CreateOneInterface, - ReferenceId, - RemoveOneInterface, - OrgMemberEntityInterface, + type ByIdInterface, + type CreateOneInterface, + type ReferenceId, + type RemoveOneInterface, + type OrgMemberEntityInterface, } from '@concepta/nestjs-common'; -import { OrgMemberCreatableInterface } from './org-member-creatable.interface'; +import { type OrgMemberCreatableInterface } from './org-member-creatable.interface'; export interface OrgMemberModelServiceInterface - extends ByIdInterface, + extends + ByIdInterface, CreateOneInterface, RemoveOneInterface< Pick, diff --git a/packages/nestjs-org/src/interfaces/org-member-service.interface.ts b/packages/nestjs-org/src/interfaces/org-member-service.interface.ts index 9e69b6eab..350e2e7cc 100644 --- a/packages/nestjs-org/src/interfaces/org-member-service.interface.ts +++ b/packages/nestjs-org/src/interfaces/org-member-service.interface.ts @@ -1,6 +1,6 @@ -import { OrgMemberEntityInterface } from '@concepta/nestjs-common'; +import { type OrgMemberEntityInterface } from '@concepta/nestjs-common'; -import { OrgMemberCreatableInterface } from './org-member-creatable.interface'; +import { type OrgMemberCreatableInterface } from './org-member-creatable.interface'; export interface OrgMemberServiceInterface { add( diff --git a/packages/nestjs-org/src/interfaces/org-member-updatable.interface.ts b/packages/nestjs-org/src/interfaces/org-member-updatable.interface.ts index a233f89d4..d04b24544 100644 --- a/packages/nestjs-org/src/interfaces/org-member-updatable.interface.ts +++ b/packages/nestjs-org/src/interfaces/org-member-updatable.interface.ts @@ -1,4 +1,6 @@ -import { OrgMemberInterface } from '@concepta/nestjs-common'; +import { type OrgMemberInterface } from '@concepta/nestjs-common'; -export interface OrgMemberUpdatableInterface - extends Pick {} +export interface OrgMemberUpdatableInterface extends Pick< + OrgMemberInterface, + 'id' | 'orgId' | 'userId' +> {} diff --git a/packages/nestjs-org/src/interfaces/org-model-service.interface.ts b/packages/nestjs-org/src/interfaces/org-model-service.interface.ts index f7d541428..76d18de60 100644 --- a/packages/nestjs-org/src/interfaces/org-model-service.interface.ts +++ b/packages/nestjs-org/src/interfaces/org-model-service.interface.ts @@ -1,18 +1,19 @@ import { - CreateOneInterface, - RemoveOneInterface, - ReplaceOneInterface, - UpdateOneInterface, - OrgCreatableInterface, - OrgUpdatableInterface, - ByIdInterface, - ReferenceId, - OrgReplaceableInterface, - OrgEntityInterface, + type CreateOneInterface, + type RemoveOneInterface, + type ReplaceOneInterface, + type UpdateOneInterface, + type OrgCreatableInterface, + type OrgUpdatableInterface, + type ByIdInterface, + type ReferenceId, + type OrgReplaceableInterface, + type OrgEntityInterface, } from '@concepta/nestjs-common'; export interface OrgModelServiceInterface - extends ByIdInterface, + extends + ByIdInterface, CreateOneInterface, UpdateOneInterface, ReplaceOneInterface, diff --git a/packages/nestjs-org/src/interfaces/org-options-extras.interface.ts b/packages/nestjs-org/src/interfaces/org-options-extras.interface.ts index 4905c368b..6088390e6 100644 --- a/packages/nestjs-org/src/interfaces/org-options-extras.interface.ts +++ b/packages/nestjs-org/src/interfaces/org-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface OrgOptionsExtrasInterface - extends Pick {} +export interface OrgOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-org/src/interfaces/org-options.interface.ts b/packages/nestjs-org/src/interfaces/org-options.interface.ts index 52f6567b1..d284dd1aa 100644 --- a/packages/nestjs-org/src/interfaces/org-options.interface.ts +++ b/packages/nestjs-org/src/interfaces/org-options.interface.ts @@ -1,5 +1,5 @@ -import { OrgModelServiceInterface } from './org-model-service.interface'; -import { OrgSettingsInterface } from './org-settings.interface'; +import { type OrgModelServiceInterface } from './org-model-service.interface'; +import { type OrgSettingsInterface } from './org-settings.interface'; export interface OrgOptionsInterface { settings?: OrgSettingsInterface; diff --git a/packages/nestjs-org/src/interfaces/org-settings.interface.ts b/packages/nestjs-org/src/interfaces/org-settings.interface.ts index 23ac69067..59d9be239 100644 --- a/packages/nestjs-org/src/interfaces/org-settings.interface.ts +++ b/packages/nestjs-org/src/interfaces/org-settings.interface.ts @@ -1,7 +1,7 @@ -import { InvitationAcceptedEventPayloadInterface } from '@concepta/nestjs-common'; +import { type InvitationAcceptedEventPayloadInterface } from '@concepta/nestjs-common'; import { - EventAsyncInterface, - EventClassInterface, + type EventAsyncInterface, + type EventClassInterface, } from '@concepta/nestjs-event'; export interface OrgSettingsInterface { diff --git a/packages/nestjs-org/src/listeners/invitation-accepted-listener.spec.ts b/packages/nestjs-org/src/listeners/invitation-accepted-listener.spec.ts index e29e75816..b6f6f2fd7 100644 --- a/packages/nestjs-org/src/listeners/invitation-accepted-listener.spec.ts +++ b/packages/nestjs-org/src/listeners/invitation-accepted-listener.spec.ts @@ -1,12 +1,11 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { getDataSourceToken } from '@nestjs/typeorm'; import { - INVITATION_MODULE_CATEGORY_ORG_KEY, - InvitationEntityInterface, - UserEntityInterface, - OrgEntityInterface, + type InvitationEntityInterface, + type UserEntityInterface, + type OrgEntityInterface, } from '@concepta/nestjs-common'; import { CrudModule } from '@concepta/nestjs-crud'; import { EventModule } from '@concepta/nestjs-event'; @@ -35,7 +34,7 @@ import { OwnerModuleFixture } from '../__fixtures__/owner.module.fixture'; import { UserEntityFixture } from '../__fixtures__/user-entity.fixture'; describe(InvitationAcceptedListener, () => { - const category = INVITATION_MODULE_CATEGORY_ORG_KEY; + const category = 'org'; let app: INestApplication; let seedingSource: SeedingSource; let testUser: UserEntityInterface; diff --git a/packages/nestjs-org/src/listeners/invitation-accepted-listener.ts b/packages/nestjs-org/src/listeners/invitation-accepted-listener.ts index cd0e0be1f..d1404d19e 100644 --- a/packages/nestjs-org/src/listeners/invitation-accepted-listener.ts +++ b/packages/nestjs-org/src/listeners/invitation-accepted-listener.ts @@ -1,9 +1,6 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; -import { - INVITATION_MODULE_CATEGORY_ORG_KEY, - InvitationAcceptedEventPayloadInterface, -} from '@concepta/nestjs-common'; +import { InvitationAcceptedEventPayloadInterface } from '@concepta/nestjs-common'; import { EventAsyncInterface, EventListenerOn } from '@concepta/nestjs-event'; import { OrgMemberException } from '../exceptions/org-member.exception'; @@ -39,9 +36,7 @@ export class InvitationAcceptedListener >, ) { // check only for invitation of type category - if ( - event.payload.invitation.category === INVITATION_MODULE_CATEGORY_ORG_KEY - ) { + if (event.payload.invitation.category === 'org') { const userId = event.payload.invitation.userId; const { orgId } = event?.payload?.invitation?.constraints ?? {}; diff --git a/packages/nestjs-org/src/org.module-definition.ts b/packages/nestjs-org/src/org.module-definition.ts index 8d36fdb2e..33b0419c8 100644 --- a/packages/nestjs-org/src/org.module-definition.ts +++ b/packages/nestjs-org/src/org.module-definition.ts @@ -1,21 +1,21 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { createSettingsProvider, - RepositoryInterface, + type RepositoryInterface, getDynamicRepositoryToken, - OrgEntityInterface, + type OrgEntityInterface, } from '@concepta/nestjs-common'; import { orgDefaultConfig } from './config/org-default.config'; -import { OrgOptionsExtrasInterface } from './interfaces/org-options-extras.interface'; -import { OrgOptionsInterface } from './interfaces/org-options.interface'; -import { OrgSettingsInterface } from './interfaces/org-settings.interface'; +import { type OrgOptionsExtrasInterface } from './interfaces/org-options-extras.interface'; +import { type OrgOptionsInterface } from './interfaces/org-options.interface'; +import { type OrgSettingsInterface } from './interfaces/org-settings.interface'; import { InvitationAcceptedListener } from './listeners/invitation-accepted-listener'; import { ORG_MODULE_SETTINGS_TOKEN, diff --git a/packages/nestjs-org/src/org.module.spec.ts b/packages/nestjs-org/src/org.module.spec.ts index 06a1e7a87..60faf1e50 100644 --- a/packages/nestjs-org/src/org.module.spec.ts +++ b/packages/nestjs-org/src/org.module.spec.ts @@ -1,7 +1,7 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { - RepositoryInterface, + type RepositoryInterface, getDynamicRepositoryToken, } from '@concepta/nestjs-common'; import { CrudModule } from '@concepta/nestjs-crud'; diff --git a/packages/nestjs-org/src/seeding/org-owner.factory.ts b/packages/nestjs-org/src/seeding/org-owner.factory.ts index cb7bda8ca..a8a018b14 100644 --- a/packages/nestjs-org/src/seeding/org-owner.factory.ts +++ b/packages/nestjs-org/src/seeding/org-owner.factory.ts @@ -1,4 +1,4 @@ -import { ReferenceIdInterface } from '@concepta/nestjs-common'; +import { type ReferenceIdInterface } from '@concepta/nestjs-common'; import { Factory } from '@concepta/typeorm-seeding'; /** diff --git a/packages/nestjs-org/src/seeding/org-profile.factory.ts b/packages/nestjs-org/src/seeding/org-profile.factory.ts index 00c836709..5ed677955 100644 --- a/packages/nestjs-org/src/seeding/org-profile.factory.ts +++ b/packages/nestjs-org/src/seeding/org-profile.factory.ts @@ -1,4 +1,4 @@ -import { OrgProfileEntityInterface } from '@concepta/nestjs-common'; +import { type OrgProfileEntityInterface } from '@concepta/nestjs-common'; import { Factory } from '@concepta/typeorm-seeding'; import { OrgFactory } from './org.factory'; diff --git a/packages/nestjs-org/src/seeding/org.factory.ts b/packages/nestjs-org/src/seeding/org.factory.ts index 61fdcaed3..92cac2aaa 100644 --- a/packages/nestjs-org/src/seeding/org.factory.ts +++ b/packages/nestjs-org/src/seeding/org.factory.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; -import { OrgEntityInterface } from '@concepta/nestjs-common'; +import { type OrgEntityInterface } from '@concepta/nestjs-common'; import { Factory } from '@concepta/typeorm-seeding'; import { OrgOwnerFactory } from './org-owner.factory'; diff --git a/packages/nestjs-org/src/services/org-member-service.spec.ts b/packages/nestjs-org/src/services/org-member-service.spec.ts index 45e1568be..c82456e35 100644 --- a/packages/nestjs-org/src/services/org-member-service.spec.ts +++ b/packages/nestjs-org/src/services/org-member-service.spec.ts @@ -1,13 +1,13 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { getDynamicRepositoryToken, - RepositoryInterface, - OrgMemberEntityInterface, + type RepositoryInterface, + type OrgMemberEntityInterface, } from '@concepta/nestjs-common'; import { OrgMemberException } from '../exceptions/org-member.exception'; -import { OrgMemberCreatableInterface } from '../interfaces/org-member-creatable.interface'; +import { type OrgMemberCreatableInterface } from '../interfaces/org-member-creatable.interface'; import { ORG_MODULE_ORG_MEMBER_ENTITY_KEY } from '../org.constants'; import { OrgMemberModelService } from './org-member-model.service'; diff --git a/packages/nestjs-org/src/utils/org-profile.crud-builder.e2e-spec.ts b/packages/nestjs-org/src/utils/org-profile.crud-builder.e2e-spec.ts index 356b70ed2..c143c5e11 100644 --- a/packages/nestjs-org/src/utils/org-profile.crud-builder.e2e-spec.ts +++ b/packages/nestjs-org/src/utils/org-profile.crud-builder.e2e-spec.ts @@ -1,12 +1,12 @@ import supertest from 'supertest'; -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; import { - ConfigurableCrudOptions, - ConfigurableCrudOptionsTransformer, + type ConfigurableCrudOptions, + type ConfigurableCrudOptionsTransformer, CrudModule, } from '@concepta/nestjs-crud'; import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; diff --git a/packages/nestjs-org/src/utils/org-profile.crud-builder.ts b/packages/nestjs-org/src/utils/org-profile.crud-builder.ts index 8f8d002b6..ffe0a6781 100644 --- a/packages/nestjs-org/src/utils/org-profile.crud-builder.ts +++ b/packages/nestjs-org/src/utils/org-profile.crud-builder.ts @@ -1,4 +1,4 @@ -import { PlainLiteralObject } from '@nestjs/common'; +import { type PlainLiteralObject } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { @@ -11,13 +11,13 @@ import { AccessControlUpdateOne, } from '@concepta/nestjs-access-control'; import { - DeepPartial, - OrgProfileCreatableInterface, - OrgProfileEntityInterface, + type DeepPartial, + type OrgProfileCreatableInterface, + type OrgProfileEntityInterface, } from '@concepta/nestjs-common'; import { ConfigurableCrudBuilder, - ConfigurableCrudOptions, + type ConfigurableCrudOptions, } from '@concepta/nestjs-crud'; import { OrgProfileCreateDto } from '../dto/profile/org-profile-create.dto'; @@ -31,9 +31,8 @@ import { OrgProfileTypeOrmCrudAdapter } from '../__fixtures__/org-profile-typeor export class OrgProfileCrudBuilder< Entity extends OrgProfileEntityInterface = OrgProfileEntityInterface, - Creatable extends DeepPartial & - OrgProfileCreatableInterface = DeepPartial & - OrgProfileCreatableInterface, + Creatable extends DeepPartial & OrgProfileCreatableInterface = + DeepPartial & OrgProfileCreatableInterface, Updatable extends DeepPartial = DeepPartial, Replaceable extends Creatable = Creatable, ExtraOptions extends PlainLiteralObject = PlainLiteralObject, diff --git a/packages/nestjs-org/src/utils/org.crud-builder.ts b/packages/nestjs-org/src/utils/org.crud-builder.ts index 8310f3c7a..e13f83379 100644 --- a/packages/nestjs-org/src/utils/org.crud-builder.ts +++ b/packages/nestjs-org/src/utils/org.crud-builder.ts @@ -1,4 +1,4 @@ -import { PlainLiteralObject } from '@nestjs/common'; +import { type PlainLiteralObject } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { @@ -12,14 +12,14 @@ import { AccessControlUpdateOne, } from '@concepta/nestjs-access-control'; import { - DeepPartial, - OrgCreatableInterface, - OrgUpdatableInterface, - OrgEntityInterface, + type DeepPartial, + type OrgCreatableInterface, + type OrgUpdatableInterface, + type OrgEntityInterface, } from '@concepta/nestjs-common'; import { ConfigurableCrudBuilder, - ConfigurableCrudOptions, + type ConfigurableCrudOptions, } from '@concepta/nestjs-crud'; import { OrgCreateManyDto } from '../dto/org-create-many.dto'; @@ -34,10 +34,10 @@ import { OrgTypeOrmCrudAdapter } from '../__fixtures__/org-typeorm-crud.adapter' export class OrgCrudBuilder< Entity extends OrgEntityInterface = OrgEntityInterface, - Creatable extends DeepPartial & - OrgCreatableInterface = DeepPartial & OrgCreatableInterface, - Updatable extends DeepPartial & - OrgUpdatableInterface = DeepPartial & OrgUpdatableInterface, + Creatable extends DeepPartial & OrgCreatableInterface = + DeepPartial & OrgCreatableInterface, + Updatable extends DeepPartial & OrgUpdatableInterface = + DeepPartial & OrgUpdatableInterface, Replaceable extends Creatable = Creatable, ExtraOptions extends PlainLiteralObject = PlainLiteralObject, > extends ConfigurableCrudBuilder< diff --git a/packages/nestjs-otp/README.md b/packages/nestjs-otp/README.md index 0e5153753..d54ebd080 100644 --- a/packages/nestjs-otp/README.md +++ b/packages/nestjs-otp/README.md @@ -1,64 +1,624 @@ -# Rockets NestJS Otp +# @concepta/nestjs-otp -A module for managing a basic Otp entity, including controller -with full CRUD, DTOs, sample data factory and seeder. +OTP management module for NestJS using DDD/CQRS. Provides one-time passcode +generation, validation, and consumption with rate limiting, configurable +duplicate strategies, and automatic history cleanup. ## Project -[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-user)](https://www.npmjs.com/package/@concepta/nestjs-user) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-user)](https://www.npmjs.com/package/@concepta/nestjs-user) +[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-otp)](https://www.npmjs.com/package/@concepta/nestjs-otp) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-otp)](https://www.npmjs.com/package/@concepta/nestjs-otp) [![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) [![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-otp%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +## Table of Contents + +- [Installation](#installation) +- [Module Registration](#module-registration) +- [Architecture Overview](#architecture-overview) +- [App Context](#app-context) +- [Commands](#commands) +- [Queries](#queries) +- [Domain Events](#domain-events) +- [Otp Aggregate](#otp-aggregate) +- [Otp Policy](#otp-policy) +- [Repository](#repository) +- [Context Overlay](#context-overlay) +- [Schemas](#schemas) +- [Exceptions](#exceptions) +- [HTTP Controller with CRUD Module](#http-controller-with-crud-module) +- [Entry Points](#entry-points) +- [Seeding](#seeding) +- [Environment Variables](#environment-variables) ## Installation -`yarn add @concepta/nestjs-otp` +```sh +yarn add @concepta/nestjs-otp @nestjs/common @nestjs/config @nestjs/core +``` + +This package is ESM-only and requires Node.js >= 22.12 and NestJS 12. + +### Dependencies + +`@standard-schema/spec` and `zod` are direct dependencies — request/response +shapes are Zod v4 (Standard Schema) schemas. + +### Peer Dependencies + +| Package | Required | Notes | +| --- | --- | --- | +| `@nestjs/common` | Yes | NestJS 12 framework | +| `@nestjs/core` | Yes | Module reference and reflection | +| `@nestjs/config` | Yes | Settings/config loading | +| `@nestjs/cqrs` | No | Optional peer — required in practice for `CommandBus`, `QueryBus`, `EventBus` | +| `rxjs` | Yes | Required by NestJS interceptors | +| `typeorm` | No | Only if using the TypeORM repository adapter | +| `@concepta/typeorm-seeding` | No | Only for database seeding | +| `@faker-js/faker` | No | Only for database seeding | + +`@concepta/nestjs-crud` is NOT a dependency of this package — it is only +needed if you choose to wire OTP operations into REST endpoints yourself +(see [HTTP Controller with CRUD Module](#http-controller-with-crud-module)). + +## Module Registration -## Usage +### Synchronous ```ts -import { Module } from '@nestjs/common'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { OtpModule } from '@concepta/nestjs-user'; -import { CrudModule } from '@concepta/nestjs-crud'; +import { OtpModule } from '@concepta/nestjs-otp'; @Module({ imports: [ - TypeOrmExtModule.forRoot({ - type: 'postgres', - url: 'postgres://user:pass@localhost:5432/postgres', + OtpModule.register({ + settings: { + types: { + uuid: { generator: uuidGenerator, validator: uuidValidator }, + }, + duplicateStrategy: 'DEACTIVATE', + }, }), - CrudModule.forRoot({}), - OtpModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - otp: { - entity: YourOtpEntity, + ], +}) +export class AppModule {} +``` + +### Asynchronous + +```ts +@Module({ + imports: [ + OtpModule.registerAsync({ + useFactory: async () => ({ + settings: { + types: { + uuid: { generator: uuidGenerator, validator: uuidValidator }, }, - }), - ], - useFactory: () => ({}), - entities: ['otp'], + duplicateStrategy: 'DEACTIVATE', + }, + }), }), ], }) export class AppModule {} ``` -## Configuration +`register()` / `registerAsync()` register the module **locally** (scoped to +the importing module). -- [Seeding](#seeding) - - [ENV](#env) +`forRoot()` / `forRootAsync()` register the module **globally**. This is +required when using `forFeature()` in other modules, since `forFeature()` +injects tokens exported by the core module. + +### forFeature + +Use `forFeature()` to register dynamic `OtpRepository` providers for each +entity key. + +```ts +@Module({ + imports: [ + OtpModule.forFeature(['userOtp', 'emailOtp']), + ], +}) +export class UserModule {} +``` + +Each entity key maps to an `OtpRepository` instance resolved at runtime by +`OtpRepositoryResolver`. + +### Options + +`forRoot()` and `registerAsync()` accept `OtpOptionsInterface` merged with +`OtpExtrasInterface` (extras are passed to `setExtras` on the +`ConfigurableModuleBuilder`): + +```ts +interface OtpExtrasInterface { + global?: boolean; + providers?: Provider[]; + repositories?: { + otp?: Type; + }; +} + +interface OtpOptionsInterface { + settings?: OtpSettingsInterface; +} + +interface OtpSettingsInterface { + types: Record; + duplicateStrategy: 'ALLOW' | 'DEACTIVATE'; + keepHistoryDays?: number; + rateSeconds?: number; + rateThreshold?: number; +} +``` + +`forFeature()` accepts an array of entity key strings. Each key creates a +dynamic `OtpRepository` provider: + +```ts +OtpModule.forFeature(entityKeys: string[]) +``` + +- **`types`** -- map of OTP type strategies. Each type provides a `generator()` + that returns a new passcode and a `validator(a, b)` that checks equality. + The default `uuid` type uses `crypto.randomUUID()`. +- **`duplicateStrategy`** -- `'DEACTIVATE'` deactivates existing active OTPs + for the same assignee and category before creating a new one. `'ALLOW'` + permits multiple active OTPs simultaneously. +- **`keepHistoryDays`** -- when set, consumed/deactivated OTPs are retained for + N days then cleaned up automatically. When unset, OTPs are hard-deleted + immediately. +- **`rateSeconds`** / **`rateThreshold`** -- rate limiting window (in seconds) + and maximum creation attempts within that window. Exceeding the threshold + throws `OtpLimitReachedException`. + +Pass `repositories.otp` to override the default `OtpRepository` with a +custom implementation. + +## Architecture Overview + +The module follows a DDD/CQRS architecture: + +```text +Application (Commands / Queries / Listeners) + | +Domain (Otp aggregate, Events, Services) + | +Infrastructure (Repository, Mapper, Schemas, Config) +``` + +- **Domain** -- `Otp` aggregate extending `DomainAggregate`, + 3 domain events, history cleanup service, domain policy (`OtpPolicy`) +- **Application** -- 6 commands and 4 queries dispatched via `@nestjs/cqrs`, + 1 built-in event listener +- **Infrastructure** -- `OtpRepository` with ctx-first signatures, + `OtpMapper` for entity-to-aggregate conversion (DI-injected), + `OtpRepositoryResolver`, Zod schemas, config + +The module ships no HTTP controllers or request handlers of its own, but it +DOES export gateway context-overlay helpers (`OtpContextOverlay`, `OtpCtx`, +`OtpNamespace` — see [Context Overlay](#context-overlay)). See +[HTTP Controller with CRUD Module](#http-controller-with-crud-module) for how +to expose OTP operations as REST endpoints. + +## App Context + +Commands, queries, and repository methods accept a `PlainLiteralObject` as +their `ctx` argument. This context is threaded through the transaction scope +and repository layer automatically. In HTTP contexts the gateway provides +the context; for programmatic use, pass any plain object: + +```ts +const otp = await this.commandBus.execute( + new CreateOtpCommand({}, 'userOtp', dto), +); +``` + +## Commands + +| Command | Description | +| --- | --- | +| `CreateOtpCommand` | Create a new OTP (with rate limiting and duplicate strategy) | +| `ConsumeOtpCommand` | Validate and consume an active OTP by category and passcode | +| `DeactivateOtpCommand` | Deactivate the active OTP for an assignee and category | +| `RemoveOtpCommand` | Hard delete an OTP by assignee, category, and passcode | +| `ClearOtpsCommand` | Remove all OTPs for an assignee and category | +| `ClearOtpHistoryCommand` | Clean up old OTP history by retention days | + +### Dispatching a Command + +```ts +import { CommandBus } from '@nestjs/cqrs'; +import { CreateOtpCommand, Otp } from '@concepta/nestjs-otp'; + +const otp = await this.commandBus.execute( + new CreateOtpCommand(ctx, 'userOtp', { + category: 'email-verification', + type: 'uuid', + expiresIn: '15m', + assigneeId: userId, + }), +); +``` + +`CreateOtpCommand` accepts an optional fourth argument for per-request +overrides: + +```ts +new CreateOtpCommand(ctx, 'userOtp', dto, { + duplicateStrategy: 'ALLOW', + rateSeconds: 60, + rateThreshold: 3, +}); +``` + +## Queries + +| Query | Description | +| --- | --- | +| `GetOtpQuery` | Get by ID (throws `OtpNotFoundException`) | +| `FindActiveOtpQuery` | Find active OTP by category and passcode (returns null) | +| `FindAssignedOtpsQuery` | Find all OTPs for an assignee and category | +| `ValidateOtpQuery` | Validate passcode without consuming (returns assignee or null) | + +### Dispatching a Query + +```ts +import { QueryBus } from '@nestjs/cqrs'; +import { ValidateOtpQuery } from '@concepta/nestjs-otp'; +import { AssigneeRelationInterface } from '@concepta/nestjs-core'; + +const result = await this.queryBus.execute< + ValidateOtpQuery, + AssigneeRelationInterface | null +>( + new ValidateOtpQuery(ctx, 'userOtp', { + category: 'email-verification', + passcode, + }), +); +``` + +## Domain Events + +All events carry an `eventContext` and a plain `OtpInterface` snapshot. + +| Event | Emitted When | +| --- | --- | +| `OtpCreatedEvent` | New OTP created | +| `OtpConsumedEvent` | OTP consumed/used | +| `OtpDeactivatedEvent` | OTP deactivated | + +### Handling an Event + +```ts +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { OtpCreatedEvent } from '@concepta/nestjs-otp'; + +@EventsHandler(OtpCreatedEvent) +export class OtpCreatedListener implements IEventHandler { + handle(event: OtpCreatedEvent): void { + const { eventContext, otp } = event; + // react to OTP creation + } +} +``` + +`OtpHistoryCleanupListener` is a built-in listener that reacts to +`OtpCreatedEvent` and automatically cleans up old history when +`keepHistoryDays` is configured. + +## Otp Aggregate -### Seeding +The `Otp` class extends `DomainAggregate` and encapsulates all +OTP domain logic. -Configurations specific to (optional) database seeding. +### Factory Methods -#### ENV +```ts +// Create with auto-generated UUID +const otp = Otp.create(eventContext, { + category: 'email-verification', + type: 'uuid', + assigneeId: userId, + passcode, + expirationDate, +}); + +// Create with a specific ID +const otp = Otp.createWithId(eventContext, id, props); +``` + +Reconstitution from a database entity is handled by `OtpMapper` (see +[Repository](#repository)). + +### Operations + +```ts +// Deactivate the OTP (sets active to false) +otp.deactivate(eventContext); + +// Mark OTP as consumed +otp.consume(eventContext); + +// Check if the OTP has expired +otp.isExpired(); + +// Convert to plain OtpInterface object (inherited from DomainAggregate) +const plain = otp.toPlain(); +``` + +## Otp Policy + +`OtpPolicy` (exported with its `OtpPolicySettings` interface) fronts access +to OTP settings — type-service resolution, duplicate strategy, history +retention, and rate limiting. It is constructed from the module settings and +provided in DI, and exported from the core module so consumers can inject it +directly instead of the raw settings token. + +```ts +interface OtpPolicySettings { + types?: { [key: string]: OtpTypeServiceInterface }; + duplicateStrategy?: 'ALLOW' | 'DEACTIVATE'; + keepHistoryDays?: number; + rateSeconds?: number; + rateThreshold?: number; +} + +class OtpPolicy { + constructor(settings?: OtpPolicySettings); + resolveTypeService(type: string): OtpTypeServiceInterface; + resolveDuplicateStrategy(override?: 'ALLOW' | 'DEACTIVATE'): 'ALLOW' | 'DEACTIVATE'; + resolveKeepHistoryDays(override?: number): number | undefined; + resolveRateLimit(overrides?: { + rateSeconds?: number; + rateThreshold?: number; + }): { rateSeconds: number; rateThreshold: number } | undefined; +} +``` + +`resolveTypeService(type)` throws `OtpTypeNotDefinedException` when no +type service is registered for `type`. Every `resolve*` method accepts a +per-call override that takes precedence over the module-level setting — this +is how command/query handlers apply request-level rate-limit or +duplicate-strategy overrides without bypassing the module default. All five +command/query handlers and the history-cleanup listener resolve settings +through `OtpPolicy` rather than reading the settings token directly. + +## Repository + +`OtpRepository` uses a ctx-first calling convention. All methods take +`PlainLiteralObject` as the first argument. + +The repository receives a DI-injected `OtpMapper` that converts database +entities to `Otp` aggregates via `toDomain()` and aggregates back to +persistence form via `toPersistence()`. + +| Method | Signature | +| --- | --- | +| `get` | `(ctx, id) => Promise` | +| `findActiveByPasscode` | `(ctx, { category, passcode }) => Promise` | +| `findByPasscode` | `(ctx, { category, passcode }) => Promise` | +| `findActiveByAssignee` | `(ctx, { assigneeId, category }) => Promise` | +| `findAllByAssigneeAndCategory` | `(ctx, { assigneeId, category }) => Promise` | +| `countCreatedSince` | `(ctx, { assigneeId, category, since }) => Promise` | +| `findOlderThan` | `(ctx, { assigneeId, category, cutoff }) => Promise` | +| `save` | `(ctx, otp) => Promise` | +| `remove` | `(ctx, otp) => Promise` | +| `removeAll` | `(ctx, otps) => Promise` | + +### Repository Resolution + +```ts +const otpRepo = this.repositoryResolver.resolve('userOtp'); +const otp = await otpRepo.get(ctx, id); +``` + +`OtpRepositoryResolver` looks up the repository by entity key. Entity keys +are registered via `OtpModule.forFeature()`. + +## Context Overlay + +While the module ships no HTTP controllers, it exports a context overlay for +resolving the OTP entity namespace per HTTP request when you build your own +gateway: + +- **`OtpNamespace`** -- decorator: apply `@OtpNamespace({ name })` to a + controller (or pass via `extraDecorators` on a generated CRUD controller) + to associate it with an OTP entity key +- **`OtpContextOverlay`** -- extends `ContextOverlayInterceptor`; register it + as a global `APP_INTERCEPTOR`. Its `attach()` reads the `@OtpNamespace` + metadata via `Reflector` and calls `ctx.defineOverlay(OtpCtx, { namespace })` +- **`OtpCtx`** -- the `OverlayRef`; request handlers read the namespace via + `@Ctx(OtpCtx)` (or `ctx.with(OtpCtx)`) and pass it to commands/queries + +## Schemas + +Schemas are Zod v4 objects (Standard Schema compatible), replacing the legacy +class-validator DTO classes. + +| Schema | Fields | +| --- | --- | +| `otpCreateSchema` | `category`, `type`, `expiresIn`, `rateSeconds?` (int >= 0), `rateThreshold?` (int >= 1), `assigneeId` | + +The `expiresIn` field accepts time span strings: `'60'`, `'2 days'`, `'10h'`, +`'7d'`. + +`otpCreateSchema` is programmatic-only: it carries no OpenAPI wrapper +(`withOpenApi`/`withNamedComponent`) because the module has no HTTP surface +of its own. `CreateOtpHandler` validates every incoming dto against it and +throws `OtpValidationException` when validation fails. + +## Exceptions + +| Exception | Description | +| --- | --- | +| `OtpNotFoundException` | OTP ID not found (HTTP 404) | +| `OtpEntityNotFoundException` | Entity key not registered via `forFeature()` | +| `OtpTypeNotDefinedException` | OTP type not configured in settings | +| `OtpLimitReachedException` | Rate limit exceeded (HTTP 429) | +| `OtpInvalidExpirationDateException` | Invalid `expiresIn` format | +| `OtpValidationException` | Schema validation failed (HTTP 400, error code `OTP_VALIDATION_ERROR`, context `{ schemaName, validationErrors }`) | +| `OtpException` | Base OTP exception | + +All exceptions extend `OtpException`, which extends `RuntimeException` from +`@concepta/nestjs-core`. `RuntimeException` extends NestJS's +`HttpException`, so no exception filter registration is needed — errors +serialize over the wire as `{ statusCode, message, errorCode, error? }` +(no `timestamp`). + +## HTTP Controller with CRUD Module + +The OTP module ships no controllers or request handlers of its own. To expose +OTP operations as REST endpoints via `@concepta/nestjs-crud`, create custom +request and handler classes that bridge CRUD operations to domain commands. + +### Custom Request Class + +Extend a CRUD command to define the request type: + +```ts +import { CrudCreateCommand } from '@concepta/nestjs-crud'; +import { OtpCreatableInterface, OtpInterface } from '@concepta/nestjs-otp'; + +export class CreateOtpRequest extends CrudCreateCommand< + OtpInterface, + OtpCreatableInterface +> {} +``` + +### Custom Request Handler + +The handler receives the CRUD command and dispatches the domain command: + +```ts +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { CrudCreateCommand } from '@concepta/nestjs-crud'; +import { CreateOtpCommand, Otp, OtpCreatableInterface, OtpInterface } from '@concepta/nestjs-otp'; + +@Injectable() +export class CreateOtpRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute( + command: CrudCreateCommand, + ): Promise { + const { context, dto } = command; + const otp = await this.commandBus.execute( + new CreateOtpCommand(context, 'userOtp', dto), + ); + return otp.toPlain(); + } +} +``` + +(To avoid hard-coding the namespace, register `OtpContextOverlay` and read it +from the context via `OtpCtx` — see [Context Overlay](#context-overlay).) + +### Response Schema + +`otpCreateSchema` is a request schema — do not reuse it as a response +resource. Author a small response schema yourself with the OpenAPI helpers +from `@concepta/nestjs-core`: + +```ts +import { z } from 'zod'; +import { withNamedComponent } from '@concepta/nestjs-core'; + +// Deliberately omits `passcode` so it is never serialized to clients. +export const otpResponseSchema = withNamedComponent( + z.object({ + assigneeId: z.string(), + category: z.string(), + type: z.string(), + expirationDate: z.date(), + active: z.boolean(), + }), + 'Otp', +); +``` + +### Module Wiring + +```ts +import { Module } from '@nestjs/common'; +import { Operation } from '@concepta/nestjs-core'; +import { CrudCqrsResolver, CrudModule } from '@concepta/nestjs-crud'; +import { OtpInterface, OtpModule, otpCreateSchema } from '@concepta/nestjs-otp'; + +import { CreateOtpRequest } from './create-otp.request'; +import { CreateOtpRequestHandler } from './create-otp-request.handler'; +import { otpResponseSchema } from './otp-response.schema'; + +@Module({ + imports: [ + OtpModule.forFeature(['userOtp']), + CrudModule.forFeature({ + crud: { + controller: { + entity: 'userOtp', + path: 'otp/user', + resolver: CrudCqrsResolver, + transactional: true, + request: { body: otpCreateSchema }, + response: { resource: otpResponseSchema }, + }, + operations: [ + { + operation: Operation.Create, + request: { body: otpCreateSchema }, + command: CreateOtpRequest, + commandHandler: CreateOtpRequestHandler, + }, + ], + }, + }), + ], +}) +export class UserOtpModule {} +``` + +Builder-generated controllers derive request body validation from +`operations[].request.body` automatically; a handwritten `@CrudController` +class would need an explicit `@CrudBody({ schema })` for runtime validation. +Note that `otpCreateSchema` has no OpenAPI wrapper (it is programmatic-only), +so wrap it with `withOpenApi` from `@concepta/nestjs-core` if you want the +request body documented in generated Swagger output. + +This is a minimal example showing a single Create operation. Add more +operations (Read, List, Delete, etc.) by creating additional request/handler +pairs following the same pattern. See the `@concepta/nestjs-crud` documentation +for the full API. + +`OtpModule.forRoot()` (or `forRootAsync()`) must be registered globally +in a parent module for `forFeature()` to resolve its dependencies. + +## Entry Points + +| Import Path | Contents | +| --- | --- | +| `@concepta/nestjs-otp` | Module, aggregate, commands, queries, events, handlers, `OtpPolicy` / `OtpPolicySettings`, `otpCreateSchema`, repository, context overlay (`OtpContextOverlay`, `OtpCtx`, `OtpNamespace`), exceptions, domain interfaces | +| `@concepta/nestjs-otp/optional/typeorm` | `OtpSqliteEntity`, `OtpPostgresEntity` | +| `@concepta/nestjs-otp/optional/seeding` | `OtpFactory` | + +## Seeding + +An `OtpFactory` is available for test seeding: + +```ts +import { OtpFactory } from '@concepta/nestjs-otp/optional/seeding'; +``` -Configurations available via environment. +## Environment Variables -| Variable | Type | Default | | -| -------------------------- | ---------- | ------- | ------------------------------------ | -| `ORG_MODULE_SEEDER_AMOUNT` | `` | `50` | number of additional users to create | +| Variable | Default | Description | +| --- | --- | --- | +| `OTP_DUPLICATE_STRATEGY` | `DEACTIVATE` | `'DEACTIVATE'` or `'ALLOW'` | +| `OTP_KEEP_HISTORY_DAYS` | `null` | Days to retain OTP history | +| `OTP_RATE_SECONDS` | `null` | Rate limit window in seconds | +| `OTP_RATE_THRESHOLD` | `null` | Max creation attempts in rate window | diff --git a/packages/nestjs-otp/package.json b/packages/nestjs-otp/package.json index dcdedea1a..789fe092c 100644 --- a/packages/nestjs-otp/package.json +++ b/packages/nestjs-otp/package.json @@ -1,30 +1,73 @@ { "name": "@concepta/nestjs-otp", - "version": "7.0.0-alpha.10", - "description": "Rockets NestJS User", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "version": "8.0.0-alpha.10", + "description": "Rockets NestJS OTP", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./optional/typeorm": { + "types": "./dist/optional-typeorm.d.ts", + "default": "./dist/optional-typeorm.js" + }, + "./optional/seeding": { + "types": "./dist/optional-seeding.d.ts", + "default": "./dist/optional-seeding.js" + } + }, "license": "BSD-3-Clause", "publishConfig": { "access": "public" }, "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" ], "dependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2" + "@concepta/nestjs-core": "^8.0.0-alpha.10", + "@concepta/nestjs-repository": "8.0.0-alpha.10", + "@standard-schema/spec": "^1.0.0", + "zod": "^4.4.3" }, "devDependencies": { - "@concepta/nestjs-typeorm-ext": "^7.0.0-alpha.10", + "@concepta/nestjs-repository-typeorm": "8.0.0-alpha.10", "@concepta/typeorm-seeding": "^4.0.0", - "@nestjs/testing": "^11.1.9", - "@nestjs/typeorm": "^11.0.0" + "@faker-js/faker": "^8.4.1", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/testing": "^12.0.1", + "@nestjs/typeorm": "^12.0.1", + "vitest-mock-extended": "^4.0.0" }, "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", + "@concepta/typeorm-seeding": "^4.0.0", + "@faker-js/faker": "^8.4.1", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "rxjs": "^7.1.0", "typeorm": "^0.3.0" + }, + "peerDependenciesMeta": { + "@concepta/typeorm-seeding": { + "optional": true + }, + "@faker-js/faker": { + "optional": true + }, + "@nestjs/cqrs": { + "optional": true + }, + "typeorm": { + "optional": true + } } } diff --git a/packages/nestjs-otp/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-otp/src/__fixtures__/app.module.fixture.ts deleted file mode 100644 index b4a418bc4..000000000 --- a/packages/nestjs-otp/src/__fixtures__/app.module.fixture.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { OtpEntitiesOptionsInterface } from '../interfaces/otp-entities-options.interface'; -import { OtpModule } from '../otp.module'; - -import { UserEntityFixture } from './entities/user-entity.fixture'; -import { UserOtpEntityFixture } from './entities/user-otp-entity.fixture'; - -@Module({ - imports: [ - TypeOrmExtModule.forRoot({ - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [UserEntityFixture, UserOtpEntityFixture], - }), - OtpModule.registerAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - userOtp: { - entity: UserOtpEntityFixture, - }, - }), - ], - useFactory: () => ({}), - entities: ['userOtp'], - }), - ], -}) -export class AppModuleFixture {} diff --git a/packages/nestjs-otp/src/__fixtures__/entities/user-entity.fixture.ts b/packages/nestjs-otp/src/__fixtures__/entities/user-entity.fixture.ts deleted file mode 100644 index 9129b0070..000000000 --- a/packages/nestjs-otp/src/__fixtures__/entities/user-entity.fixture.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; - -import { ReferenceIdInterface } from '@concepta/nestjs-common'; - -/** - * User Entity Fixture - */ -@Entity() -export class UserEntityFixture implements ReferenceIdInterface { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ default: false }) - isActive!: boolean; -} diff --git a/packages/nestjs-otp/src/__fixtures__/entities/user-otp-entity.fixture.ts b/packages/nestjs-otp/src/__fixtures__/entities/user-otp-entity.fixture.ts deleted file mode 100644 index f7903e72b..000000000 --- a/packages/nestjs-otp/src/__fixtures__/entities/user-otp-entity.fixture.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Entity } from 'typeorm'; - -import { OtpSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * Otp Entity Fixture - */ -@Entity() -export class UserOtpEntityFixture extends OtpSqliteEntity {} diff --git a/packages/nestjs-otp/src/__fixtures__/factories/user.factory.fixture.ts b/packages/nestjs-otp/src/__fixtures__/factories/user.factory.fixture.ts deleted file mode 100644 index 0d0651dda..000000000 --- a/packages/nestjs-otp/src/__fixtures__/factories/user.factory.fixture.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Factory } from '@concepta/typeorm-seeding'; - -import { UserEntityFixture } from '../entities/user-entity.fixture'; - -export class UserFactoryFixture extends Factory { - options = { - entity: UserEntityFixture, - }; -} diff --git a/packages/nestjs-otp/src/__tests__/exception-fault.spec.ts b/packages/nestjs-otp/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..81ab07633 --- /dev/null +++ b/packages/nestjs-otp/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,79 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { OtpNotFoundException } from '../application/exceptions/otp-not-found.exception.js'; +import { OtpInvalidExpirationDateException } from '../domain/exceptions/otp-invalid-expiration-date.exception.js'; +import { OtpLimitReachedException } from '../domain/exceptions/otp-limit-reached.exception.js'; +import { OtpTypeNotDefinedException } from '../domain/exceptions/otp-type-not-defined.exception.js'; +import { OtpValidationException } from '../domain/exceptions/otp-validation.exception.js'; +import { OtpException } from '../domain/exceptions/otp.exception.js'; +import { OtpEntityNotFoundException } from '../infrastructure/exceptions/otp-entity-not-found.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'OtpException (default)', + build: () => new OtpException(), + fault: 'internal', + }, + { + name: 'OtpInvalidExpirationDateException', + build: () => new OtpInvalidExpirationDateException(), + fault: 'client', + }, + { + name: 'OtpLimitReachedException', + build: () => new OtpLimitReachedException(), + fault: 'client', + }, + { + name: 'OtpTypeNotDefinedException', + build: () => new OtpTypeNotDefinedException('someType'), + fault: 'usage', + }, + { + name: 'OtpValidationException', + build: () => new OtpValidationException('someSchema', []), + fault: 'client', + }, + { + name: 'OtpNotFoundException', + build: () => new OtpNotFoundException({ id: 'id' }), + fault: 'client', + }, + { + name: 'OtpEntityNotFoundException', + build: () => new OtpEntityNotFoundException('SomeEntity'), + fault: 'usage', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-otp/src/__tests__/fixtures/app.module.fixture.ts b/packages/nestjs-otp/src/__tests__/fixtures/app.module.fixture.ts new file mode 100644 index 000000000..cca41b8e2 --- /dev/null +++ b/packages/nestjs-otp/src/__tests__/fixtures/app.module.fixture.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { OtpModule } from '../../otp.module.js'; + +import { UserEntityFixture } from './entities/user-entity.fixture.js'; +import { UserOtpEntityFixture } from './entities/user-otp-entity.fixture.js'; + +@Module({ + imports: [ + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [UserEntityFixture, UserOtpEntityFixture], + }), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: 'userOtp', + entity: UserOtpEntityFixture, + }, + ], + }), + OtpModule.forRoot({}), + OtpModule.forFeature(['userOtp']), + ], +}) +export class AppModuleFixture {} diff --git a/packages/nestjs-otp/src/__tests__/fixtures/entities/user-entity.fixture.ts b/packages/nestjs-otp/src/__tests__/fixtures/entities/user-entity.fixture.ts new file mode 100644 index 000000000..a010efc68 --- /dev/null +++ b/packages/nestjs-otp/src/__tests__/fixtures/entities/user-entity.fixture.ts @@ -0,0 +1,15 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +import { ReferenceIdInterface } from '@concepta/nestjs-core'; + +/** + * User Entity Fixture + */ +@Entity() +export class UserEntityFixture implements ReferenceIdInterface { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ default: false }) + isActive!: boolean; +} diff --git a/packages/nestjs-otp/src/__tests__/fixtures/entities/user-otp-entity.fixture.ts b/packages/nestjs-otp/src/__tests__/fixtures/entities/user-otp-entity.fixture.ts new file mode 100644 index 000000000..7271f5544 --- /dev/null +++ b/packages/nestjs-otp/src/__tests__/fixtures/entities/user-otp-entity.fixture.ts @@ -0,0 +1,9 @@ +import { Entity } from 'typeorm'; + +import { OtpSqliteEntity } from '../../../infrastructure/persistence/typeorm/otp-sqlite.entity.js'; + +/** + * Otp Entity Fixture + */ +@Entity() +export class UserOtpEntityFixture extends OtpSqliteEntity {} diff --git a/packages/nestjs-otp/src/__fixtures__/factories/user-otp.factory.fixture.ts b/packages/nestjs-otp/src/__tests__/fixtures/factories/user-otp.factory.fixture.ts similarity index 97% rename from packages/nestjs-otp/src/__fixtures__/factories/user-otp.factory.fixture.ts rename to packages/nestjs-otp/src/__tests__/fixtures/factories/user-otp.factory.fixture.ts index 707e5b9e1..581631ca1 100644 --- a/packages/nestjs-otp/src/__fixtures__/factories/user-otp.factory.fixture.ts +++ b/packages/nestjs-otp/src/__tests__/fixtures/factories/user-otp.factory.fixture.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'; import { Factory } from '@concepta/typeorm-seeding'; -import { UserOtpEntityFixture } from '../entities/user-otp-entity.fixture'; +import { UserOtpEntityFixture } from '../entities/user-otp-entity.fixture.js'; export class UserOtpFactoryFixture extends Factory { protected options = { diff --git a/packages/nestjs-otp/src/__tests__/fixtures/factories/user.factory.fixture.ts b/packages/nestjs-otp/src/__tests__/fixtures/factories/user.factory.fixture.ts new file mode 100644 index 000000000..e034a6a62 --- /dev/null +++ b/packages/nestjs-otp/src/__tests__/fixtures/factories/user.factory.fixture.ts @@ -0,0 +1,9 @@ +import { Factory } from '@concepta/typeorm-seeding'; + +import { UserEntityFixture } from '../entities/user-entity.fixture.js'; + +export class UserFactoryFixture extends Factory { + options = { + entity: UserEntityFixture, + }; +} diff --git a/packages/nestjs-otp/src/__tests__/fixtures/otp.seeder.fixture.ts b/packages/nestjs-otp/src/__tests__/fixtures/otp.seeder.fixture.ts new file mode 100644 index 000000000..199fa024a --- /dev/null +++ b/packages/nestjs-otp/src/__tests__/fixtures/otp.seeder.fixture.ts @@ -0,0 +1,21 @@ +import { Seeder } from '@concepta/typeorm-seeding'; + +import { OtpFactory } from '../../infrastructure/persistence/otp.factory.js'; + +/** + * Otp seeder fixture + */ +export class OtpSeederFixture extends Seeder { + /** + * Runner + */ + public async run(): Promise { + const createAmount = process.env?.OTP_MODULE_SEEDER_AMOUNT + ? Number(process.env.OTP_MODULE_SEEDER_AMOUNT) + : 50; + + const otpFactory = this.factory(OtpFactory); + + await otpFactory.createMany(createAmount); + } +} diff --git a/packages/nestjs-otp/src/__tests__/helpers/mock.helpers.ts b/packages/nestjs-otp/src/__tests__/helpers/mock.helpers.ts new file mode 100644 index 000000000..c9cba65f9 --- /dev/null +++ b/packages/nestjs-otp/src/__tests__/helpers/mock.helpers.ts @@ -0,0 +1,85 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { + createTestEventContext, + createMockCommandBus, + createMockEventPublisher, + createMockQueryBus, +} from '@concepta/nestjs-core/testing'; +import { createMockTransaction } from '@concepta/nestjs-repository/testing'; + +import { type Otp } from '../../domain/aggregates/otp.js'; +import { type OtpSettingsInterface } from '../../infrastructure/config/interfaces/otp-settings.interface.js'; +import { type OtpEntityInterface } from '../../infrastructure/persistence/interfaces/otp-entity.interface.js'; +import { type OtpRepositoryResolver } from '../../infrastructure/persistence/otp-repository.resolver.js'; +import { OtpMapper } from '../../infrastructure/persistence/otp.mapper.js'; +import { type OtpRepository } from '../../infrastructure/persistence/otp.repository.js'; + +export const DEFAULT_OTP_NAMESPACE = 'userOtp'; + +export { + createMockCommandBus, + createMockEventPublisher, + createMockQueryBus, + createMockTransaction, +}; +export type { MockTransactionHandle } from '@concepta/nestjs-repository/testing'; + +export function createMockOtpRepository(): DeepMockProxy { + return mockDeep(); +} + +export function createMockRepositoryResolver( + repo: OtpRepository, +): DeepMockProxy { + const resolver = mockDeep(); + resolver.resolve.mockReturnValue(repo); + return resolver; +} + +export function createMockEventContext(namespace = DEFAULT_OTP_NAMESPACE) { + return createTestEventContext({ namespace }, {}); +} + +export function createMockOtpEntity( + overrides: Partial = {}, +): OtpEntityInterface { + return { + id: 'test-id', + category: 'test-category', + type: 'uuid', + passcode: 'test-passcode', + assigneeId: 'test-assignee', + expirationDate: new Date('2027-01-01'), + active: true, + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + ...overrides, + }; +} + +const otpMapper = new OtpMapper(); + +export function toOtpDomain(entity: OtpEntityInterface): Otp { + return otpMapper.toDomain(entity); +} + +export function createMockOtpSettings( + overrides: Partial = {}, +): OtpSettingsInterface { + return { + types: { + uuid: { + generator: vi.fn().mockReturnValue('generated-passcode'), + validator: vi.fn().mockReturnValue(true), + }, + }, + duplicateStrategy: 'ALLOW', + keepHistoryDays: undefined, + rateSeconds: undefined, + rateThreshold: undefined, + ...overrides, + }; +} diff --git a/packages/nestjs-otp/src/__tests__/index.spec.ts b/packages/nestjs-otp/src/__tests__/index.spec.ts new file mode 100644 index 000000000..2fb541b2c --- /dev/null +++ b/packages/nestjs-otp/src/__tests__/index.spec.ts @@ -0,0 +1,15 @@ +import { OtpModule, otpCreateSchema, Otp } from '../index.js'; + +describe('index', () => { + it('should be an instance of Function', () => { + expect(OtpModule).toBeInstanceOf(Function); + }); + + it('should export otpCreateSchema', () => { + expect(otpCreateSchema.meta).toBeInstanceOf(Function); + }); + + it('should be an instance of Function', () => { + expect(Otp).toBeInstanceOf(Function); + }); +}); diff --git a/packages/nestjs-otp/src/__tests__/otp.module.spec.ts b/packages/nestjs-otp/src/__tests__/otp.module.spec.ts new file mode 100644 index 000000000..63692181f --- /dev/null +++ b/packages/nestjs-otp/src/__tests__/otp.module.spec.ts @@ -0,0 +1,84 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { type OtpRepositoryResolverInterface } from '../domain/repositories/otp-repository-resolver.interface.js'; +import { OtpRepository } from '../infrastructure/persistence/otp.repository.js'; +import { OTP_REPOSITORY_RESOLVER_TOKEN } from '../otp.constants.js'; +import { OtpModule } from '../otp.module.js'; + +import { AppModuleFixture } from './fixtures/app.module.fixture.js'; + +describe(OtpModule.name, () => { + let otpModule: OtpModule; + + beforeEach(async () => { + const testModule: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + otpModule = testModule.get(OtpModule); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('module', () => { + it('should be loaded', async () => { + expect(otpModule).toBeInstanceOf(OtpModule); + }); + }); + + describe('register', () => { + it('should return a dynamic module', () => { + const result = OtpModule.register({}); + expect(result.module).toBe(OtpModule); + expect(result.imports).toHaveLength(1); + }); + }); + + describe('registerAsync', () => { + it('should return a dynamic module', () => { + const result = OtpModule.registerAsync({}); + expect(result.module).toBe(OtpModule); + expect(result.imports).toHaveLength(1); + }); + }); + + describe('forRoot', () => { + it('should return a global dynamic module', () => { + const result = OtpModule.forRoot({}); + expect(result.module).toBe(OtpModule); + expect(result.imports).toHaveLength(1); + }); + }); + + describe('forRootAsync', () => { + it('should return a global dynamic module', () => { + const result = OtpModule.forRootAsync({}); + expect(result.module).toBe(OtpModule); + expect(result.imports).toHaveLength(1); + }); + }); + + describe('forFeature', () => { + it('should return providers for each entity key', () => { + const result = OtpModule.forFeature(['userOtp', 'emailOtp']); + expect(result.module).toBe(OtpModule); + expect(result.providers).toHaveLength(2); + expect(result.exports).toHaveLength(2); + }); + + it('should resolve OtpRepository via OtpRepositoryResolver', async () => { + const testModule: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + const resolver = testModule.get( + OTP_REPOSITORY_RESOLVER_TOKEN, + ); + const repo = resolver.resolve('userOtp'); + + expect(repo).toBeInstanceOf(OtpRepository); + }); + }); +}); diff --git a/packages/nestjs-otp/src/application/commands/handlers/__tests__/clear-otp-history.handler.spec.ts b/packages/nestjs-otp/src/application/commands/handlers/__tests__/clear-otp-history.handler.spec.ts new file mode 100644 index 000000000..426b17e25 --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/__tests__/clear-otp-history.handler.spec.ts @@ -0,0 +1,142 @@ +import { + createMockOtpRepository, + createMockOtpSettings, + createMockRepositoryResolver, + createMockTransaction, + DEFAULT_OTP_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { OtpPolicy } from '../../../../domain/policies/otp.policy.js'; +import { OtpHistoryCleanupService } from '../../../../domain/services/otp-history-cleanup.service.js'; +import { type OtpSettingsInterface } from '../../../../infrastructure/config/interfaces/otp-settings.interface.js'; +import { ClearOtpHistoryCommand } from '../../impl/clear-otp-history.command.js'; +import { ClearOtpHistoryHandler } from '../clear-otp-history.handler.js'; + +describe(ClearOtpHistoryHandler.name, () => { + let mockTx: ReturnType; + let historyCleanup: OtpHistoryCleanupService; + + const ctx = {}; + + function makeHandler( + settingsOverrides: Partial = {}, + ): ClearOtpHistoryHandler { + const settings = createMockOtpSettings(settingsOverrides); + return new ClearOtpHistoryHandler( + mockTx.transaction, + historyCleanup, + new OtpPolicy(settings), + ); + } + + beforeEach(() => { + const mockRepo = createMockOtpRepository(); + const mockResolver = createMockRepositoryResolver(mockRepo); + mockTx = createMockTransaction(); + historyCleanup = new OtpHistoryCleanupService(mockResolver); + vi.spyOn(historyCleanup, 'cleanup').mockResolvedValue(); + }); + + it('should call cleanup when keepHistoryDays is set', async () => { + const handler = makeHandler({ keepHistoryDays: 30 }); + + const command = new ClearOtpHistoryCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + }); + + await handler.execute(command); + + expect(historyCleanup.cleanup).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + assigneeId: 'test-assignee', + category: 'test-category', + keepHistoryDays: 30, + }), + ); + }); + + it('should call cleanup when keepHistoryDays is 0', async () => { + const handler = makeHandler({ keepHistoryDays: 0 }); + + const command = new ClearOtpHistoryCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + }); + + await handler.execute(command); + + expect(historyCleanup.cleanup).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + keepHistoryDays: 0, + }), + ); + }); + + it('should early return when keepHistoryDays is undefined', async () => { + const handler = makeHandler({ keepHistoryDays: undefined }); + + const command = new ClearOtpHistoryCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + }); + + await handler.execute(command); + + expect(historyCleanup.cleanup).not.toHaveBeenCalled(); + }); + + it('should use command keepHistoryDays override over settings', async () => { + const handler = makeHandler({ keepHistoryDays: 30 }); + + const command = new ClearOtpHistoryCommand( + ctx, + DEFAULT_OTP_NAMESPACE, + { assigneeId: 'test-assignee', category: 'test-category' }, + { keepHistoryDays: 7 }, + ); + + await handler.execute(command); + + expect(historyCleanup.cleanup).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + keepHistoryDays: 7, + }), + ); + }); + + it('should use command keepHistoryDays of 0 to override settings', async () => { + const handler = makeHandler({ keepHistoryDays: 30 }); + + const command = new ClearOtpHistoryCommand( + ctx, + DEFAULT_OTP_NAMESPACE, + { assigneeId: 'test-assignee', category: 'test-category' }, + { keepHistoryDays: 0 }, + ); + + await handler.execute(command); + + expect(historyCleanup.cleanup).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + keepHistoryDays: 0, + }), + ); + }); + + it('should fall back to settings when command keepHistoryDays is undefined', async () => { + const handler = makeHandler({ keepHistoryDays: undefined }); + + const command = new ClearOtpHistoryCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + }); + + await handler.execute(command); + + expect(historyCleanup.cleanup).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nestjs-otp/src/application/commands/handlers/__tests__/clear-otps.handler.spec.ts b/packages/nestjs-otp/src/application/commands/handlers/__tests__/clear-otps.handler.spec.ts new file mode 100644 index 000000000..26589ca5e --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/__tests__/clear-otps.handler.spec.ts @@ -0,0 +1,62 @@ +import { + createMockOtpEntity, + createMockOtpRepository, + createMockRepositoryResolver, + createMockTransaction, + DEFAULT_OTP_NAMESPACE, + toOtpDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { ClearOtpsCommand } from '../../impl/clear-otps.command.js'; +import { ClearOtpsHandler } from '../clear-otps.handler.js'; + +describe(ClearOtpsHandler.name, () => { + let handler: ClearOtpsHandler; + let mockRepo: ReturnType; + + const ctx = {}; + + beforeEach(() => { + mockRepo = createMockOtpRepository(); + const mockResolver = createMockRepositoryResolver(mockRepo); + const mockTx = createMockTransaction(); + + handler = new ClearOtpsHandler(mockResolver, mockTx.transaction); + }); + + it('should find and remove all OTPs for assignee and category', async () => { + const otps = [ + toOtpDomain(createMockOtpEntity({ id: '1' })), + toOtpDomain(createMockOtpEntity({ id: '2' })), + ]; + mockRepo.findAllByAssigneeAndCategory.mockResolvedValue(otps); + + const command = new ClearOtpsCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + }); + + await handler.execute(command); + + expect(mockRepo.findAllByAssigneeAndCategory).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + assigneeId: 'test-assignee', + category: 'test-category', + }), + ); + expect(mockRepo.removeAll).toHaveBeenCalledWith(expect.anything(), otps); + }); + + it('should not call removeAll when no OTPs found', async () => { + mockRepo.findAllByAssigneeAndCategory.mockResolvedValue([]); + + const command = new ClearOtpsCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + }); + + await handler.execute(command); + + expect(mockRepo.removeAll).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nestjs-otp/src/application/commands/handlers/__tests__/consume-otp.handler.spec.ts b/packages/nestjs-otp/src/application/commands/handlers/__tests__/consume-otp.handler.spec.ts new file mode 100644 index 000000000..9db3be229 --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/__tests__/consume-otp.handler.spec.ts @@ -0,0 +1,162 @@ +import { type Mock } from 'vitest'; + +import { + createMockEventPublisher, + createMockOtpEntity, + createMockOtpRepository, + createMockOtpSettings, + createMockRepositoryResolver, + createMockTransaction, + DEFAULT_OTP_NAMESPACE, + toOtpDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { OtpTypeNotDefinedException } from '../../../../domain/exceptions/otp-type-not-defined.exception.js'; +import { OtpPolicy } from '../../../../domain/policies/otp.policy.js'; +import { type OtpSettingsInterface } from '../../../../infrastructure/config/interfaces/otp-settings.interface.js'; +import { ConsumeOtpCommand } from '../../impl/consume-otp.command.js'; +import { ConsumeOtpHandler } from '../consume-otp.handler.js'; + +describe(ConsumeOtpHandler.name, () => { + let handler: ConsumeOtpHandler; + let mockRepo: ReturnType; + let mockTx: ReturnType; + let mockSettings: OtpSettingsInterface; + + const ctx = {}; + + beforeEach(() => { + mockRepo = createMockOtpRepository(); + const mockResolver = createMockRepositoryResolver(mockRepo); + mockTx = createMockTransaction(); + const mockPublisher = createMockEventPublisher(); + mockSettings = createMockOtpSettings(); + + handler = new ConsumeOtpHandler( + mockResolver, + mockTx.transaction, + mockPublisher, + new OtpPolicy(mockSettings), + ); + }); + + it('should return assigneeId and delete OTP when valid and active', async () => { + const otp = toOtpDomain( + createMockOtpEntity({ expirationDate: new Date('2099-01-01') }), + ); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + + const command = new ConsumeOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'test-passcode', + }); + + const result = await handler.execute(command); + + expect(result).toEqual({ assigneeId: 'test-assignee' }); + expect(mockRepo.remove).toHaveBeenCalledWith(expect.anything(), otp); + }); + + it('should call the configured validator for the OTP type', async () => { + const otp = toOtpDomain( + createMockOtpEntity({ expirationDate: new Date('2099-01-01') }), + ); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + + const command = new ConsumeOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'test-passcode', + }); + + await handler.execute(command); + + expect(mockSettings.types['uuid'].validator).toHaveBeenCalledWith( + 'test-passcode', + 'test-passcode', + ); + }); + + it('should register onCommit and onRollback callbacks', async () => { + const otp = toOtpDomain( + createMockOtpEntity({ expirationDate: new Date('2099-01-01') }), + ); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + + const command = new ConsumeOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'test-passcode', + }); + + await handler.execute(command); + + expect(mockTx.trxHandle.onCommit).toHaveBeenCalled(); + expect(mockTx.trxHandle.onRollback).toHaveBeenCalled(); + }); + + it('should return null and not delete when no active OTP found', async () => { + mockRepo.findActiveByPasscode.mockResolvedValue(null); + + const command = new ConsumeOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'missing', + }); + + const result = await handler.execute(command); + + expect(result).toBeNull(); + expect(mockRepo.remove).not.toHaveBeenCalled(); + }); + + it('should return null and not delete when OTP is expired', async () => { + const otp = toOtpDomain( + createMockOtpEntity({ expirationDate: new Date('2020-01-01') }), + ); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + + const command = new ConsumeOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'test-passcode', + }); + + const result = await handler.execute(command); + + expect(result).toBeNull(); + expect(mockRepo.remove).not.toHaveBeenCalled(); + }); + + it('should return null and not delete when validator returns false', async () => { + const otp = toOtpDomain( + createMockOtpEntity({ expirationDate: new Date('2099-01-01') }), + ); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + (mockSettings.types['uuid'].validator as Mock).mockReturnValue(false); + + const command = new ConsumeOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'wrong-passcode', + }); + + const result = await handler.execute(command); + + expect(result).toBeNull(); + expect(mockRepo.remove).not.toHaveBeenCalled(); + }); + + it('should throw OtpTypeNotDefinedException when type is not configured', async () => { + const otp = toOtpDomain( + createMockOtpEntity({ + type: 'unknown', + expirationDate: new Date('2099-01-01'), + }), + ); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + + const command = new ConsumeOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'test-passcode', + }); + + await expect(handler.execute(command)).rejects.toThrow( + OtpTypeNotDefinedException, + ); + }); +}); diff --git a/packages/nestjs-otp/src/application/commands/handlers/__tests__/create-otp.handler.spec.ts b/packages/nestjs-otp/src/application/commands/handlers/__tests__/create-otp.handler.spec.ts new file mode 100644 index 000000000..a8b9bcd75 --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/__tests__/create-otp.handler.spec.ts @@ -0,0 +1,202 @@ +import { + createMockEventPublisher, + createMockOtpEntity, + createMockOtpRepository, + createMockOtpSettings, + createMockRepositoryResolver, + createMockTransaction, + DEFAULT_OTP_NAMESPACE, + toOtpDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Otp } from '../../../../domain/aggregates/otp.js'; +import { OtpLimitReachedException } from '../../../../domain/exceptions/otp-limit-reached.exception.js'; +import { OtpTypeNotDefinedException } from '../../../../domain/exceptions/otp-type-not-defined.exception.js'; +import { OtpPolicy } from '../../../../domain/policies/otp.policy.js'; +import { type OtpSettingsInterface } from '../../../../infrastructure/config/interfaces/otp-settings.interface.js'; +import { CreateOtpCommand } from '../../impl/create-otp.command.js'; +import { CreateOtpHandler } from '../create-otp.handler.js'; + +describe(CreateOtpHandler.name, () => { + let handler: CreateOtpHandler; + let mockRepo: ReturnType; + let mockResolver: ReturnType; + let mockTx: ReturnType; + let mockPublisher: ReturnType; + let mockSettings: ReturnType; + + const ctx = {}; + + const validDto = { + category: 'test-category', + type: 'uuid', + assigneeId: 'test-assignee', + expiresIn: '1h', + }; + + function makeHandler( + settingsOverrides: Partial = {}, + ): CreateOtpHandler { + return new CreateOtpHandler( + mockResolver, + mockTx.transaction, + mockPublisher, + new OtpPolicy({ ...mockSettings, ...settingsOverrides }), + ); + } + + beforeEach(() => { + mockRepo = createMockOtpRepository(); + mockResolver = createMockRepositoryResolver(mockRepo); + mockTx = createMockTransaction(); + mockPublisher = createMockEventPublisher(); + mockSettings = createMockOtpSettings(); + + handler = makeHandler(); + }); + + it('should create an OTP and save it', async () => { + const command = new CreateOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, validDto); + + const result = await handler.execute(command); + + expect(result).toBeInstanceOf(Otp); + expect(result.category).toBe('test-category'); + expect(result.type).toBe('uuid'); + expect(result.assigneeId).toBe('test-assignee'); + expect(result.passcode).toBe('generated-passcode'); + expect(mockRepo.save).toHaveBeenCalled(); + }); + + it('should throw OtpTypeNotDefinedException for unknown type', async () => { + const command = new CreateOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + ...validDto, + type: 'unknown', + }); + + await expect(handler.execute(command)).rejects.toThrow( + OtpTypeNotDefinedException, + ); + }); + + it('should register onCommit and onRollback callbacks', async () => { + const command = new CreateOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, validDto); + + await handler.execute(command); + + expect(mockTx.trxHandle.onCommit).toHaveBeenCalled(); + expect(mockTx.trxHandle.onRollback).toHaveBeenCalled(); + }); + + describe('duplicateStrategy', () => { + it('should deactivate existing active OTP when duplicateStrategy is DEACTIVATE', async () => { + const existingOtp = toOtpDomain( + createMockOtpEntity({ id: 'existing', passcode: 'old' }), + ); + mockRepo.findActiveByAssignee.mockResolvedValue(existingOtp); + + const command = new CreateOtpCommand( + ctx, + DEFAULT_OTP_NAMESPACE, + validDto, + { + duplicateStrategy: 'DEACTIVATE', + }, + ); + await handler.execute(command); + + expect(mockRepo.findActiveByAssignee).toHaveBeenCalled(); + // save called twice: once for deactivation, once for new OTP + expect(mockRepo.save).toHaveBeenCalledTimes(2); + }); + + it('should not deactivate when duplicateStrategy is ALLOW', async () => { + const command = new CreateOtpCommand( + ctx, + DEFAULT_OTP_NAMESPACE, + validDto, + { + duplicateStrategy: 'ALLOW', + }, + ); + await handler.execute(command); + + expect(mockRepo.findActiveByAssignee).not.toHaveBeenCalled(); + }); + + it('should use settings.duplicateStrategy when not specified in command', async () => { + handler = makeHandler({ duplicateStrategy: 'DEACTIVATE' }); + mockRepo.findActiveByAssignee.mockResolvedValue(null); + + const command = new CreateOtpCommand( + ctx, + DEFAULT_OTP_NAMESPACE, + validDto, + ); + await handler.execute(command); + + expect(mockRepo.findActiveByAssignee).toHaveBeenCalled(); + }); + }); + + describe('rate limiting', () => { + it('should throw OtpLimitReachedException when rate limit exceeded', async () => { + mockRepo.countCreatedSince.mockResolvedValue(5); + + const command = new CreateOtpCommand( + ctx, + DEFAULT_OTP_NAMESPACE, + validDto, + { + rateSeconds: 60, + rateThreshold: 3, + }, + ); + + await expect(handler.execute(command)).rejects.toThrow( + OtpLimitReachedException, + ); + }); + + it('should not throw when under rate limit', async () => { + mockRepo.countCreatedSince.mockResolvedValue(1); + + const command = new CreateOtpCommand( + ctx, + DEFAULT_OTP_NAMESPACE, + validDto, + { + rateSeconds: 60, + rateThreshold: 3, + }, + ); + + await expect(handler.execute(command)).resolves.toBeInstanceOf(Otp); + }); + + it('should skip rate limiting when rateSeconds is not set', async () => { + const command = new CreateOtpCommand( + ctx, + DEFAULT_OTP_NAMESPACE, + validDto, + ); + + await handler.execute(command); + + expect(mockRepo.countCreatedSince).not.toHaveBeenCalled(); + }); + + it('should use settings rate values when command does not specify them', async () => { + handler = makeHandler({ rateSeconds: 60, rateThreshold: 5 }); + mockRepo.countCreatedSince.mockResolvedValue(0); + + const command = new CreateOtpCommand( + ctx, + DEFAULT_OTP_NAMESPACE, + validDto, + ); + await handler.execute(command); + + expect(mockRepo.countCreatedSince).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/nestjs-otp/src/application/commands/handlers/__tests__/deactivate-otp.handler.spec.ts b/packages/nestjs-otp/src/application/commands/handlers/__tests__/deactivate-otp.handler.spec.ts new file mode 100644 index 000000000..aebc568ee --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/__tests__/deactivate-otp.handler.spec.ts @@ -0,0 +1,82 @@ +import { + createMockOtpEntity, + createMockEventPublisher, + createMockOtpRepository, + createMockRepositoryResolver, + createMockTransaction, + DEFAULT_OTP_NAMESPACE, + toOtpDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { DeactivateOtpCommand } from '../../impl/deactivate-otp.command.js'; +import { DeactivateOtpHandler } from '../deactivate-otp.handler.js'; + +describe(DeactivateOtpHandler.name, () => { + let handler: DeactivateOtpHandler; + let mockRepo: ReturnType; + let mockTx: ReturnType; + + const ctx = {}; + + beforeEach(() => { + mockRepo = createMockOtpRepository(); + const mockResolver = createMockRepositoryResolver(mockRepo); + mockTx = createMockTransaction(); + const mockPublisher = createMockEventPublisher(); + + handler = new DeactivateOtpHandler( + mockResolver, + mockTx.transaction, + mockPublisher, + ); + }); + + it('should deactivate an active OTP and save it', async () => { + const activeOtp = toOtpDomain(createMockOtpEntity({ active: true })); + mockRepo.findActiveByAssignee.mockResolvedValue(activeOtp); + + const command = new DeactivateOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + }); + + await handler.execute(command); + + expect(mockRepo.findActiveByAssignee).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + assigneeId: 'test-assignee', + category: 'test-category', + }), + ); + expect(mockRepo.save).toHaveBeenCalled(); + }); + + it('should register onCommit and onRollback when OTP is found', async () => { + const activeOtp = toOtpDomain(createMockOtpEntity({ active: true })); + mockRepo.findActiveByAssignee.mockResolvedValue(activeOtp); + + const command = new DeactivateOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + }); + + await handler.execute(command); + + expect(mockTx.trxHandle.onCommit).toHaveBeenCalled(); + expect(mockTx.trxHandle.onRollback).toHaveBeenCalled(); + }); + + it('should do nothing when no active OTP exists', async () => { + mockRepo.findActiveByAssignee.mockResolvedValue(null); + + const command = new DeactivateOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + }); + + await handler.execute(command); + + expect(mockRepo.save).not.toHaveBeenCalled(); + expect(mockTx.trxHandle.onCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nestjs-otp/src/application/commands/handlers/__tests__/remove-otp.handler.spec.ts b/packages/nestjs-otp/src/application/commands/handlers/__tests__/remove-otp.handler.spec.ts new file mode 100644 index 000000000..a6a29822e --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/__tests__/remove-otp.handler.spec.ts @@ -0,0 +1,76 @@ +import { + createMockOtpEntity, + createMockOtpRepository, + createMockRepositoryResolver, + createMockTransaction, + DEFAULT_OTP_NAMESPACE, + toOtpDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { RemoveOtpCommand } from '../../impl/remove-otp.command.js'; +import { RemoveOtpHandler } from '../remove-otp.handler.js'; + +describe(RemoveOtpHandler.name, () => { + let handler: RemoveOtpHandler; + let mockRepo: ReturnType; + let mockTx: ReturnType; + + const ctx = {}; + + beforeEach(() => { + mockRepo = createMockOtpRepository(); + const mockResolver = createMockRepositoryResolver(mockRepo); + mockTx = createMockTransaction(); + + handler = new RemoveOtpHandler(mockResolver, mockTx.transaction); + }); + + it('should find and remove an OTP by passcode', async () => { + const found = toOtpDomain(createMockOtpEntity()); + mockRepo.findByPasscode.mockResolvedValue(found); + + const command = new RemoveOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + passcode: 'test-passcode', + }); + + await handler.execute(command); + + expect(mockRepo.findByPasscode).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + category: 'test-category', + passcode: 'test-passcode', + }), + ); + expect(mockRepo.remove).toHaveBeenCalledWith(expect.anything(), found); + }); + + it('should not call remove when OTP is not found', async () => { + mockRepo.findByPasscode.mockResolvedValue(null); + + const command = new RemoveOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + passcode: 'missing', + }); + + await handler.execute(command); + + expect(mockRepo.remove).not.toHaveBeenCalled(); + }); + + it('should run within a transaction', async () => { + mockRepo.findByPasscode.mockResolvedValue(null); + + const command = new RemoveOtpCommand(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'a', + category: 'c', + passcode: 'p', + }); + + await handler.execute(command); + + expect(mockTx.transaction.run).toHaveBeenCalled(); + }); +}); diff --git a/packages/nestjs-otp/src/application/commands/handlers/clear-otp-history.handler.ts b/packages/nestjs-otp/src/application/commands/handlers/clear-otp-history.handler.ts new file mode 100644 index 000000000..aea31bc0b --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/clear-otp-history.handler.ts @@ -0,0 +1,36 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { OtpPolicy } from '../../../domain/policies/otp.policy.js'; +import { OtpHistoryCleanupService } from '../../../domain/services/otp-history-cleanup.service.js'; +import { ClearOtpHistoryCommand } from '../impl/clear-otp-history.command.js'; + +@CommandHandler(ClearOtpHistoryCommand) +export class ClearOtpHistoryHandler implements ICommandHandler { + constructor( + private readonly txScope: TransactionScope, + private readonly historyCleanup: OtpHistoryCleanupService, + private readonly policy: OtpPolicy, + ) {} + + async execute(command: ClearOtpHistoryCommand): Promise { + const { ctx, namespace, otp } = command; + const { assigneeId, category } = otp; + + const keepHistoryDays = this.policy.resolveKeepHistoryDays( + command.keepHistoryDays, + ); + + if (keepHistoryDays === undefined) return; + + return this.txScope.run(ctx, async (txCtx) => { + await this.historyCleanup.cleanup(txCtx, { + namespace, + assigneeId, + category, + keepHistoryDays, + }); + }); + } +} diff --git a/packages/nestjs-otp/src/application/commands/handlers/clear-otps.handler.ts b/packages/nestjs-otp/src/application/commands/handlers/clear-otps.handler.ts new file mode 100644 index 000000000..05fea76d4 --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/clear-otps.handler.ts @@ -0,0 +1,34 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { OtpRepositoryResolverInterface } from '../../../domain/repositories/otp-repository-resolver.interface.js'; +import { OTP_REPOSITORY_RESOLVER_TOKEN } from '../../../otp.constants.js'; +import { ClearOtpsCommand } from '../impl/clear-otps.command.js'; + +@CommandHandler(ClearOtpsCommand) +export class ClearOtpsHandler implements ICommandHandler { + constructor( + @Inject(OTP_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: OtpRepositoryResolverInterface, + private readonly txScope: TransactionScope, + ) {} + + async execute(command: ClearOtpsCommand): Promise { + const { ctx, namespace, otp } = command; + + const otpRepo = this.repositoryResolver.resolve(namespace); + + return this.txScope.run(ctx, async (txCtx) => { + const otps = await otpRepo.findAllByAssigneeAndCategory(txCtx, { + assigneeId: otp.assigneeId, + category: otp.category, + }); + + if (otps.length > 0) { + await otpRepo.removeAll(txCtx, otps); + } + }); + } +} diff --git a/packages/nestjs-otp/src/application/commands/handlers/consume-otp.handler.ts b/packages/nestjs-otp/src/application/commands/handlers/consume-otp.handler.ts new file mode 100644 index 000000000..eee2243bb --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/consume-otp.handler.ts @@ -0,0 +1,71 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { + AssigneeRelationInterface, + createEventContext, +} from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { OtpPolicy } from '../../../domain/policies/otp.policy.js'; +import { OtpRepositoryResolverInterface } from '../../../domain/repositories/otp-repository-resolver.interface.js'; +import { OTP_REPOSITORY_RESOLVER_TOKEN } from '../../../otp.constants.js'; +import { ConsumeOtpCommand } from '../impl/consume-otp.command.js'; + +@CommandHandler(ConsumeOtpCommand) +export class ConsumeOtpHandler implements ICommandHandler< + ConsumeOtpCommand, + AssigneeRelationInterface | null +> { + constructor( + @Inject(OTP_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: OtpRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + private readonly policy: OtpPolicy, + ) {} + + async execute( + command: ConsumeOtpCommand, + ): Promise { + const { ctx, namespace, otp } = command; + const { category, passcode } = otp; + + const otpRepo = this.repositoryResolver.resolve(namespace); + + let result: AssigneeRelationInterface | null = null; + + await this.txScope.run(ctx, async (txCtx) => { + const activeOtp = await otpRepo.findActiveByPasscode(txCtx, { + category, + passcode, + }); + + if (!activeOtp || activeOtp.isExpired()) { + return; + } + + const { type } = activeOtp; + const typeService = this.policy.resolveTypeService(type); + + if (!typeService.validator(passcode, activeOtp.passcode)) { + return; + } + + const eventContext = createEventContext(txCtx, { namespace }, {}); + + this.eventPublisher.mergeObjectContext(activeOtp); + + activeOtp.consume(eventContext); + + await otpRepo.remove(txCtx, activeOtp); + + txCtx.trx.onCommit(() => activeOtp.commit()); + txCtx.trx.onRollback(() => activeOtp.uncommit()); + + result = { assigneeId: activeOtp.assigneeId }; + }); + + return result; + } +} diff --git a/packages/nestjs-otp/src/application/commands/handlers/create-otp.handler.ts b/packages/nestjs-otp/src/application/commands/handlers/create-otp.handler.ts new file mode 100644 index 000000000..46be6304b --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/create-otp.handler.ts @@ -0,0 +1,131 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { + TransactionContextInterface, + TransactionScope, +} from '@concepta/nestjs-repository'; + +import { Otp } from '../../../domain/aggregates/otp.js'; +import { OtpLimitReachedException } from '../../../domain/exceptions/otp-limit-reached.exception.js'; +import { OtpCreatableInterface } from '../../../domain/interfaces/otp-creatable.interface.js'; +import { OtpPolicy } from '../../../domain/policies/otp.policy.js'; +import { OtpRepositoryResolverInterface } from '../../../domain/repositories/otp-repository-resolver.interface.js'; +import { OtpRepositoryInterface } from '../../../domain/repositories/otp-repository.interface.js'; +import { otpCreateSchema } from '../../../infrastructure/schemas/otp-create.schema.js'; +import { OTP_REPOSITORY_RESOLVER_TOKEN } from '../../../otp.constants.js'; +import { validateOtpSchema } from '../../utils/validate-otp-schema.util.js'; +import { CreateOtpCommand } from '../impl/create-otp.command.js'; + +@CommandHandler(CreateOtpCommand) +export class CreateOtpHandler implements ICommandHandler { + constructor( + @Inject(OTP_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: OtpRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + private readonly policy: OtpPolicy, + ) {} + + async execute(command: CreateOtpCommand): Promise { + const { + ctx, + namespace, + dto, + duplicateStrategy, + rateSeconds, + rateThreshold, + } = command; + const typeService = this.policy.resolveTypeService(dto.type); + + const validatedDto = await validateOtpSchema( + 'OtpCreate', + otpCreateSchema, + dto, + ); + const { assigneeId, category, type, expiresIn } = validatedDto; + + const otpRepo = this.repositoryResolver.resolve(namespace); + + const passcode = typeService.generator(); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + await this.validateRateLimit({ + otpRepo, + dto: validatedDto, + ctx: txCtx, + rateSeconds, + rateThreshold, + }); + + const resolvedDuplicateStrategy = + this.policy.resolveDuplicateStrategy(duplicateStrategy); + + if (resolvedDuplicateStrategy === 'DEACTIVATE') { + const activeOtp = await otpRepo.findActiveByAssignee(txCtx, { + assigneeId, + category, + }); + if (activeOtp) { + const mergedActiveOtp = + this.eventPublisher.mergeObjectContext(activeOtp); + mergedActiveOtp.deactivate(eventContext); + await otpRepo.save(txCtx, mergedActiveOtp); + + txCtx.trx.onCommit(() => mergedActiveOtp.commit()); + txCtx.trx.onRollback(() => mergedActiveOtp.uncommit()); + } + } + + const otp = this.eventPublisher.mergeObjectContext( + Otp.create(eventContext, { + category, + type, + assigneeId, + passcode, + expiresIn, + }), + ); + + await otpRepo.save(txCtx, otp); + + txCtx.trx.onCommit(() => otp.commit()); + txCtx.trx.onRollback(() => otp.uncommit()); + + return otp; + }); + } + + protected async validateRateLimit(params: { + otpRepo: OtpRepositoryInterface; + dto: OtpCreatableInterface; + ctx: TransactionContextInterface; + rateSeconds?: number; + rateThreshold?: number; + }): Promise { + const { otpRepo, dto, ctx, rateSeconds, rateThreshold } = params; + + const rateLimit = this.policy.resolveRateLimit({ + rateSeconds, + rateThreshold, + }); + + if (rateLimit) { + const cutoffDate = new Date(); + cutoffDate.setSeconds(cutoffDate.getSeconds() - rateLimit.rateSeconds); + + const recentCount = await otpRepo.countCreatedSince(ctx, { + assigneeId: dto.assigneeId, + category: dto.category, + cutoffDate, + }); + + if (recentCount >= rateLimit.rateThreshold) { + throw new OtpLimitReachedException(); + } + } + } +} diff --git a/packages/nestjs-otp/src/application/commands/handlers/deactivate-otp.handler.ts b/packages/nestjs-otp/src/application/commands/handlers/deactivate-otp.handler.ts new file mode 100644 index 000000000..16d09b960 --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/deactivate-otp.handler.ts @@ -0,0 +1,43 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { OtpRepositoryResolverInterface } from '../../../domain/repositories/otp-repository-resolver.interface.js'; +import { OTP_REPOSITORY_RESOLVER_TOKEN } from '../../../otp.constants.js'; +import { DeactivateOtpCommand } from '../impl/deactivate-otp.command.js'; + +@CommandHandler(DeactivateOtpCommand) +export class DeactivateOtpHandler implements ICommandHandler { + constructor( + @Inject(OTP_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: OtpRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: DeactivateOtpCommand): Promise { + const { ctx, namespace, otp } = command; + + const otpRepo = this.repositoryResolver.resolve(namespace); + + return this.txScope.run(ctx, async (txCtx) => { + const activeOtp = await otpRepo.findActiveByAssignee(txCtx, { + assigneeId: otp.assigneeId, + category: otp.category, + }); + + if (activeOtp) { + const eventContext = createEventContext(txCtx, { namespace }, {}); + + const aggregate = this.eventPublisher.mergeObjectContext(activeOtp); + aggregate.deactivate(eventContext); + await otpRepo.save(txCtx, aggregate); + + txCtx.trx.onCommit(() => aggregate.commit()); + txCtx.trx.onRollback(() => aggregate.uncommit()); + } + }); + } +} diff --git a/packages/nestjs-otp/src/application/commands/handlers/remove-otp.handler.ts b/packages/nestjs-otp/src/application/commands/handlers/remove-otp.handler.ts new file mode 100644 index 000000000..88ff3042e --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/handlers/remove-otp.handler.ts @@ -0,0 +1,34 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { OtpRepositoryResolverInterface } from '../../../domain/repositories/otp-repository-resolver.interface.js'; +import { OTP_REPOSITORY_RESOLVER_TOKEN } from '../../../otp.constants.js'; +import { RemoveOtpCommand } from '../impl/remove-otp.command.js'; + +@CommandHandler(RemoveOtpCommand) +export class RemoveOtpHandler implements ICommandHandler { + constructor( + @Inject(OTP_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: OtpRepositoryResolverInterface, + private readonly txScope: TransactionScope, + ) {} + + async execute(command: RemoveOtpCommand): Promise { + const { ctx, namespace, otp } = command; + + const otpRepo = this.repositoryResolver.resolve(namespace); + + return this.txScope.run(ctx, async (txCtx) => { + const found = await otpRepo.findByPasscode(txCtx, { + category: otp.category, + passcode: otp.passcode, + }); + + if (found) { + await otpRepo.remove(txCtx, found); + } + }); + } +} diff --git a/packages/nestjs-otp/src/application/commands/impl/clear-otp-history.command.ts b/packages/nestjs-otp/src/application/commands/impl/clear-otp-history.command.ts new file mode 100644 index 000000000..f6faa061e --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/impl/clear-otp-history.command.ts @@ -0,0 +1,22 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type OtpInterface } from '../../../domain/interfaces/otp.interface.js'; + +interface ClearOtpHistoryCommandOptions { + keepHistoryDays?: number; +} + +export class ClearOtpHistoryCommand extends Command { + public readonly keepHistoryDays?: number; + + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly otp: Pick, + options?: ClearOtpHistoryCommandOptions, + ) { + super(); + this.keepHistoryDays = options?.keepHistoryDays; + } +} diff --git a/packages/nestjs-otp/src/application/commands/impl/clear-otps.command.ts b/packages/nestjs-otp/src/application/commands/impl/clear-otps.command.ts new file mode 100644 index 000000000..1b762e664 --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/impl/clear-otps.command.ts @@ -0,0 +1,14 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type OtpInterface } from '../../../domain/interfaces/otp.interface.js'; + +export class ClearOtpsCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly otp: Pick, + ) { + super(); + } +} diff --git a/packages/nestjs-otp/src/application/commands/impl/consume-otp.command.ts b/packages/nestjs-otp/src/application/commands/impl/consume-otp.command.ts new file mode 100644 index 000000000..78372902c --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/impl/consume-otp.command.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type AssigneeRelationInterface } from '@concepta/nestjs-core'; + +import { type OtpInterface } from '../../../domain/interfaces/otp.interface.js'; + +export class ConsumeOtpCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly otp: Pick, + ) { + super(); + } +} diff --git a/packages/nestjs-otp/src/application/commands/impl/create-otp.command.ts b/packages/nestjs-otp/src/application/commands/impl/create-otp.command.ts new file mode 100644 index 000000000..d12ab5594 --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/impl/create-otp.command.ts @@ -0,0 +1,29 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type Otp } from '../../../domain/aggregates/otp.js'; +import { type OtpCreatableInterface } from '../../../domain/interfaces/otp-creatable.interface.js'; + +interface CreateOtpCommandOptions { + duplicateStrategy?: 'ALLOW' | 'DEACTIVATE'; + rateSeconds?: number; + rateThreshold?: number; +} + +export class CreateOtpCommand extends Command { + public readonly duplicateStrategy?: CreateOtpCommandOptions['duplicateStrategy']; + public readonly rateSeconds?: number; + public readonly rateThreshold?: number; + + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly dto: OtpCreatableInterface, + options?: CreateOtpCommandOptions, + ) { + super(); + this.duplicateStrategy = options?.duplicateStrategy; + this.rateSeconds = options?.rateSeconds; + this.rateThreshold = options?.rateThreshold; + } +} diff --git a/packages/nestjs-otp/src/application/commands/impl/deactivate-otp.command.ts b/packages/nestjs-otp/src/application/commands/impl/deactivate-otp.command.ts new file mode 100644 index 000000000..0db8a96fd --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/impl/deactivate-otp.command.ts @@ -0,0 +1,14 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type OtpInterface } from '../../../domain/interfaces/otp.interface.js'; + +export class DeactivateOtpCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly otp: Pick, + ) { + super(); + } +} diff --git a/packages/nestjs-otp/src/application/commands/impl/remove-otp.command.ts b/packages/nestjs-otp/src/application/commands/impl/remove-otp.command.ts new file mode 100644 index 000000000..f1477061b --- /dev/null +++ b/packages/nestjs-otp/src/application/commands/impl/remove-otp.command.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type OtpInterface } from '../../../domain/interfaces/otp.interface.js'; + +export class RemoveOtpCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly otp: Pick< + OtpInterface, + 'assigneeId' | 'category' | 'passcode' + >, + ) { + super(); + } +} diff --git a/packages/nestjs-otp/src/application/exceptions/otp-not-found.exception.ts b/packages/nestjs-otp/src/application/exceptions/otp-not-found.exception.ts new file mode 100644 index 000000000..d6d163ded --- /dev/null +++ b/packages/nestjs-otp/src/application/exceptions/otp-not-found.exception.ts @@ -0,0 +1,29 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeException } from '@concepta/nestjs-core'; + +import { OtpException } from '../../domain/exceptions/otp.exception.js'; + +export class OtpNotFoundException extends OtpException { + declare context: RuntimeException['context'] & { + id: string; + }; + + constructor(options: { id: string; message?: string }) { + const { id, message = 'OTP not found for id=%s' } = options; + + super({ + httpStatus: HttpStatus.NOT_FOUND, + message, + messageParams: [id], + fault: 'client', + }); + + this.errorCode = 'OTP_NOT_FOUND_ERROR'; + + this.context = { + ...this.context, + id, + }; + } +} diff --git a/packages/nestjs-otp/src/application/listeners/__tests__/otp-history-cleanup.listener.spec.ts b/packages/nestjs-otp/src/application/listeners/__tests__/otp-history-cleanup.listener.spec.ts new file mode 100644 index 000000000..1d1cdedc5 --- /dev/null +++ b/packages/nestjs-otp/src/application/listeners/__tests__/otp-history-cleanup.listener.spec.ts @@ -0,0 +1,68 @@ +import { + createMockEventContext, + createMockOtpEntity, + createMockOtpRepository, + createMockOtpSettings, + createMockRepositoryResolver, + createMockTransaction, +} from '../../../__tests__/helpers/mock.helpers.js'; +import { OtpCreatedEvent } from '../../../domain/events/otp-created.event.js'; +import { OtpPolicy } from '../../../domain/policies/otp.policy.js'; +import { OtpHistoryCleanupService } from '../../../domain/services/otp-history-cleanup.service.js'; +import { type OtpSettingsInterface } from '../../../infrastructure/config/interfaces/otp-settings.interface.js'; +import { OtpHistoryCleanupListener } from '../otp-history-cleanup.listener.js'; + +describe(OtpHistoryCleanupListener.name, () => { + let mockTx: ReturnType; + let historyCleanup: OtpHistoryCleanupService; + + function makeListener( + settingsOverrides: Partial = {}, + ): OtpHistoryCleanupListener { + const settings = createMockOtpSettings(settingsOverrides); + return new OtpHistoryCleanupListener( + mockTx.transaction, + historyCleanup, + new OtpPolicy(settings), + ); + } + + beforeEach(() => { + const mockRepo = createMockOtpRepository(); + const mockResolver = createMockRepositoryResolver(mockRepo); + mockTx = createMockTransaction(); + historyCleanup = new OtpHistoryCleanupService(mockResolver); + vi.spyOn(historyCleanup, 'cleanup').mockResolvedValue(); + }); + + it('should call cleanup when keepHistoryDays is set', async () => { + const listener = makeListener({ keepHistoryDays: 30 }); + + const eventContext = createMockEventContext(); + const otp = createMockOtpEntity(); + const event = new OtpCreatedEvent(eventContext, otp); + + await listener.handle(event); + + expect(historyCleanup.cleanup).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + assigneeId: otp.assigneeId, + category: otp.category, + keepHistoryDays: 30, + }), + ); + }); + + it('should skip cleanup when keepHistoryDays is undefined', async () => { + const listener = makeListener({ keepHistoryDays: undefined }); + + const eventContext = createMockEventContext(); + const otp = createMockOtpEntity(); + const event = new OtpCreatedEvent(eventContext, otp); + + await listener.handle(event); + + expect(historyCleanup.cleanup).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nestjs-otp/src/application/listeners/otp-history-cleanup.listener.ts b/packages/nestjs-otp/src/application/listeners/otp-history-cleanup.listener.ts new file mode 100644 index 000000000..2b228e863 --- /dev/null +++ b/packages/nestjs-otp/src/application/listeners/otp-history-cleanup.listener.ts @@ -0,0 +1,43 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; + +import { AppContextHost, CorrelationCtx } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { OtpCreatedEvent } from '../../domain/events/otp-created.event.js'; +import { OtpPolicy } from '../../domain/policies/otp.policy.js'; +import { OtpHistoryCleanupService } from '../../domain/services/otp-history-cleanup.service.js'; + +@EventsHandler(OtpCreatedEvent) +export class OtpHistoryCleanupListener implements IEventHandler { + constructor( + private readonly txScope: TransactionScope, + private readonly historyCleanup: OtpHistoryCleanupService, + private readonly policy: OtpPolicy, + ) {} + + async handle(event: OtpCreatedEvent): Promise { + const { + eventContext, + otp: { assigneeId, category }, + } = event; + const { namespace } = eventContext.headers; + const keepHistoryDays = this.policy.resolveKeepHistoryDays(); + + if (keepHistoryDays === undefined) return; + + const appCtx = new AppContextHost(); + appCtx.defineOverlay(CorrelationCtx, { + correlationId: eventContext.getHeader('correlationId'), + causationId: eventContext.getHeader('causationId'), + }); + + await this.txScope.run(appCtx, async (txCtx) => { + await this.historyCleanup.cleanup(txCtx, { + namespace, + assigneeId, + category, + keepHistoryDays, + }); + }); + } +} diff --git a/packages/nestjs-otp/src/application/queries/handlers/__tests__/find-active-otp.handler.spec.ts b/packages/nestjs-otp/src/application/queries/handlers/__tests__/find-active-otp.handler.spec.ts new file mode 100644 index 000000000..643a00e0e --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/handlers/__tests__/find-active-otp.handler.spec.ts @@ -0,0 +1,52 @@ +import { + createMockOtpEntity, + createMockOtpRepository, + createMockRepositoryResolver, + DEFAULT_OTP_NAMESPACE, + toOtpDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Otp } from '../../../../domain/aggregates/otp.js'; +import { FindActiveOtpQuery } from '../../impl/find-active-otp.query.js'; +import { FindActiveOtpHandler } from '../find-active-otp.handler.js'; + +describe(FindActiveOtpHandler.name, () => { + let handler: FindActiveOtpHandler; + let mockRepo: ReturnType; + + const ctx = {}; + + beforeEach(() => { + mockRepo = createMockOtpRepository(); + const mockResolver = createMockRepositoryResolver(mockRepo); + + handler = new FindActiveOtpHandler(mockResolver); + }); + + it('should return an active OTP when found', async () => { + const otp = toOtpDomain(createMockOtpEntity()); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + + const query = new FindActiveOtpQuery(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'test-passcode', + }); + + const result = await handler.execute(query); + + expect(result).toBeInstanceOf(Otp); + expect(result?.id).toBe('test-id'); + }); + + it('should return null when no active OTP found', async () => { + mockRepo.findActiveByPasscode.mockResolvedValue(null); + + const query = new FindActiveOtpQuery(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'missing', + }); + + const result = await handler.execute(query); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/nestjs-otp/src/application/queries/handlers/__tests__/find-assigned-otps.handler.spec.ts b/packages/nestjs-otp/src/application/queries/handlers/__tests__/find-assigned-otps.handler.spec.ts new file mode 100644 index 000000000..4220af12c --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/handlers/__tests__/find-assigned-otps.handler.spec.ts @@ -0,0 +1,55 @@ +import { + createMockOtpEntity, + createMockOtpRepository, + createMockRepositoryResolver, + DEFAULT_OTP_NAMESPACE, + toOtpDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Otp } from '../../../../domain/aggregates/otp.js'; +import { FindAssignedOtpsQuery } from '../../impl/find-assigned-otps.query.js'; +import { FindAssignedOtpsHandler } from '../find-assigned-otps.handler.js'; + +describe(FindAssignedOtpsHandler.name, () => { + let handler: FindAssignedOtpsHandler; + let mockRepo: ReturnType; + + const ctx = {}; + + beforeEach(() => { + mockRepo = createMockOtpRepository(); + const mockResolver = createMockRepositoryResolver(mockRepo); + + handler = new FindAssignedOtpsHandler(mockResolver); + }); + + it('should return all OTPs for assignee and category', async () => { + const otps = [ + toOtpDomain(createMockOtpEntity({ id: '1' })), + toOtpDomain(createMockOtpEntity({ id: '2' })), + ]; + mockRepo.findAllByAssigneeAndCategory.mockResolvedValue(otps); + + const query = new FindAssignedOtpsQuery(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + }); + + const result = await handler.execute(query); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(Otp); + }); + + it('should return empty array when none found', async () => { + mockRepo.findAllByAssigneeAndCategory.mockResolvedValue([]); + + const query = new FindAssignedOtpsQuery(ctx, DEFAULT_OTP_NAMESPACE, { + assigneeId: 'test-assignee', + category: 'test-category', + }); + + const result = await handler.execute(query); + + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/nestjs-otp/src/application/queries/handlers/__tests__/get-otp.handler.spec.ts b/packages/nestjs-otp/src/application/queries/handlers/__tests__/get-otp.handler.spec.ts new file mode 100644 index 000000000..1ec3bab18 --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/handlers/__tests__/get-otp.handler.spec.ts @@ -0,0 +1,46 @@ +import { + createMockOtpEntity, + createMockOtpRepository, + createMockRepositoryResolver, + DEFAULT_OTP_NAMESPACE, + toOtpDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Otp } from '../../../../domain/aggregates/otp.js'; +import { OtpNotFoundException } from '../../../exceptions/otp-not-found.exception.js'; +import { GetOtpQuery } from '../../impl/get-otp.query.js'; +import { GetOtpHandler } from '../get-otp.handler.js'; + +describe(GetOtpHandler.name, () => { + let handler: GetOtpHandler; + let mockRepo: ReturnType; + + const ctx = {}; + + beforeEach(() => { + mockRepo = createMockOtpRepository(); + const mockResolver = createMockRepositoryResolver(mockRepo); + + handler = new GetOtpHandler(mockResolver); + }); + + it('should return an OTP by id', async () => { + const otp = toOtpDomain(createMockOtpEntity()); + mockRepo.get.mockResolvedValue(otp); + + const query = new GetOtpQuery(ctx, DEFAULT_OTP_NAMESPACE, 'test-id'); + + const result = await handler.execute(query); + + expect(result).toBeInstanceOf(Otp); + expect(result.id).toBe('test-id'); + expect(mockRepo.get).toHaveBeenCalledWith(expect.anything(), 'test-id'); + }); + + it('should throw OtpNotFoundException when not found', async () => { + mockRepo.get.mockResolvedValue(null); + + const query = new GetOtpQuery(ctx, DEFAULT_OTP_NAMESPACE, 'missing'); + + await expect(handler.execute(query)).rejects.toThrow(OtpNotFoundException); + }); +}); diff --git a/packages/nestjs-otp/src/application/queries/handlers/__tests__/validate-otp.handler.spec.ts b/packages/nestjs-otp/src/application/queries/handlers/__tests__/validate-otp.handler.spec.ts new file mode 100644 index 000000000..418ae291e --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/handlers/__tests__/validate-otp.handler.spec.ts @@ -0,0 +1,131 @@ +import { type Mock } from 'vitest'; + +import { + createMockOtpEntity, + createMockOtpRepository, + createMockOtpSettings, + createMockRepositoryResolver, + DEFAULT_OTP_NAMESPACE, + toOtpDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { OtpTypeNotDefinedException } from '../../../../domain/exceptions/otp-type-not-defined.exception.js'; +import { OtpPolicy } from '../../../../domain/policies/otp.policy.js'; +import { type OtpSettingsInterface } from '../../../../infrastructure/config/interfaces/otp-settings.interface.js'; +import { ValidateOtpQuery } from '../../impl/validate-otp.query.js'; +import { ValidateOtpHandler } from '../validate-otp.handler.js'; + +describe(ValidateOtpHandler.name, () => { + let handler: ValidateOtpHandler; + let mockRepo: ReturnType; + let mockSettings: OtpSettingsInterface; + + const ctx = {}; + + beforeEach(() => { + mockRepo = createMockOtpRepository(); + const mockResolver = createMockRepositoryResolver(mockRepo); + mockSettings = createMockOtpSettings(); + + handler = new ValidateOtpHandler(mockResolver, new OtpPolicy(mockSettings)); + }); + + it('should return assigneeId when OTP is valid and active', async () => { + const otp = toOtpDomain( + createMockOtpEntity({ expirationDate: new Date('2099-01-01') }), + ); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + + const query = new ValidateOtpQuery(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'test-passcode', + }); + + const result = await handler.execute(query); + + expect(result).toEqual({ assigneeId: 'test-assignee' }); + }); + + it('should call the configured validator for the OTP type', async () => { + const otp = toOtpDomain( + createMockOtpEntity({ expirationDate: new Date('2099-01-01') }), + ); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + + const query = new ValidateOtpQuery(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'test-passcode', + }); + + await handler.execute(query); + + expect(mockSettings.types['uuid'].validator).toHaveBeenCalledWith( + 'test-passcode', + 'test-passcode', + ); + }); + + it('should return null when configured validator returns false', async () => { + const otp = toOtpDomain( + createMockOtpEntity({ expirationDate: new Date('2099-01-01') }), + ); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + (mockSettings.types['uuid'].validator as Mock).mockReturnValue(false); + + const query = new ValidateOtpQuery(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'wrong-passcode', + }); + + const result = await handler.execute(query); + + expect(result).toBeNull(); + }); + + it('should throw OtpTypeNotDefinedException when type is not configured', async () => { + const otp = toOtpDomain( + createMockOtpEntity({ + type: 'unknown', + expirationDate: new Date('2099-01-01'), + }), + ); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + + const query = new ValidateOtpQuery(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'test-passcode', + }); + + await expect(handler.execute(query)).rejects.toThrow( + OtpTypeNotDefinedException, + ); + }); + + it('should return null when no active OTP found', async () => { + mockRepo.findActiveByPasscode.mockResolvedValue(null); + + const query = new ValidateOtpQuery(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'missing', + }); + + const result = await handler.execute(query); + + expect(result).toBeNull(); + }); + + it('should return null when OTP is expired', async () => { + const otp = toOtpDomain( + createMockOtpEntity({ expirationDate: new Date('2020-01-01') }), + ); + mockRepo.findActiveByPasscode.mockResolvedValue(otp); + + const query = new ValidateOtpQuery(ctx, DEFAULT_OTP_NAMESPACE, { + category: 'test-category', + passcode: 'test-passcode', + }); + + const result = await handler.execute(query); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/nestjs-otp/src/application/queries/handlers/find-active-otp.handler.ts b/packages/nestjs-otp/src/application/queries/handlers/find-active-otp.handler.ts new file mode 100644 index 000000000..2f6fb8942 --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/handlers/find-active-otp.handler.ts @@ -0,0 +1,26 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { Otp } from '../../../domain/aggregates/otp.js'; +import { OtpRepositoryResolverInterface } from '../../../domain/repositories/otp-repository-resolver.interface.js'; +import { OTP_REPOSITORY_RESOLVER_TOKEN } from '../../../otp.constants.js'; +import { FindActiveOtpQuery } from '../impl/find-active-otp.query.js'; + +@QueryHandler(FindActiveOtpQuery) +export class FindActiveOtpHandler implements IQueryHandler { + constructor( + @Inject(OTP_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: OtpRepositoryResolverInterface, + ) {} + + async execute(query: FindActiveOtpQuery): Promise { + const { ctx, namespace, otp } = query; + + const otpRepo = this.repositoryResolver.resolve(namespace); + + return otpRepo.findActiveByPasscode(ctx, { + category: otp.category, + passcode: otp.passcode, + }); + } +} diff --git a/packages/nestjs-otp/src/application/queries/handlers/find-assigned-otps.handler.ts b/packages/nestjs-otp/src/application/queries/handlers/find-assigned-otps.handler.ts new file mode 100644 index 000000000..426ec2d71 --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/handlers/find-assigned-otps.handler.ts @@ -0,0 +1,26 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { Otp } from '../../../domain/aggregates/otp.js'; +import { OtpRepositoryResolverInterface } from '../../../domain/repositories/otp-repository-resolver.interface.js'; +import { OTP_REPOSITORY_RESOLVER_TOKEN } from '../../../otp.constants.js'; +import { FindAssignedOtpsQuery } from '../impl/find-assigned-otps.query.js'; + +@QueryHandler(FindAssignedOtpsQuery) +export class FindAssignedOtpsHandler implements IQueryHandler { + constructor( + @Inject(OTP_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: OtpRepositoryResolverInterface, + ) {} + + async execute(query: FindAssignedOtpsQuery): Promise { + const { ctx, namespace, otp } = query; + + const otpRepo = this.repositoryResolver.resolve(namespace); + + return otpRepo.findAllByAssigneeAndCategory(ctx, { + assigneeId: otp.assigneeId, + category: otp.category, + }); + } +} diff --git a/packages/nestjs-otp/src/application/queries/handlers/get-otp.handler.ts b/packages/nestjs-otp/src/application/queries/handlers/get-otp.handler.ts new file mode 100644 index 000000000..7fbceffd1 --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/handlers/get-otp.handler.ts @@ -0,0 +1,30 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { Otp } from '../../../domain/aggregates/otp.js'; +import { OtpRepositoryResolverInterface } from '../../../domain/repositories/otp-repository-resolver.interface.js'; +import { OTP_REPOSITORY_RESOLVER_TOKEN } from '../../../otp.constants.js'; +import { OtpNotFoundException } from '../../exceptions/otp-not-found.exception.js'; +import { GetOtpQuery } from '../impl/get-otp.query.js'; + +@QueryHandler(GetOtpQuery) +export class GetOtpHandler implements IQueryHandler { + constructor( + @Inject(OTP_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: OtpRepositoryResolverInterface, + ) {} + + async execute(query: GetOtpQuery): Promise { + const { ctx, namespace, id } = query; + + const otpRepo = this.repositoryResolver.resolve(namespace); + + const otp = await otpRepo.get(ctx, id); + + if (!otp) { + throw new OtpNotFoundException({ id }); + } + + return otp; + } +} diff --git a/packages/nestjs-otp/src/application/queries/handlers/validate-otp.handler.ts b/packages/nestjs-otp/src/application/queries/handlers/validate-otp.handler.ts new file mode 100644 index 000000000..cdb010212 --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/handlers/validate-otp.handler.ts @@ -0,0 +1,45 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { AssigneeRelationInterface } from '@concepta/nestjs-core'; + +import { OtpPolicy } from '../../../domain/policies/otp.policy.js'; +import { OtpRepositoryResolverInterface } from '../../../domain/repositories/otp-repository-resolver.interface.js'; +import { OTP_REPOSITORY_RESOLVER_TOKEN } from '../../../otp.constants.js'; +import { ValidateOtpQuery } from '../impl/validate-otp.query.js'; + +@QueryHandler(ValidateOtpQuery) +export class ValidateOtpHandler implements IQueryHandler { + constructor( + @Inject(OTP_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: OtpRepositoryResolverInterface, + private readonly policy: OtpPolicy, + ) {} + + async execute( + query: ValidateOtpQuery, + ): Promise { + const { ctx, namespace, otp } = query; + const { category, passcode } = otp; + + const otpRepo = this.repositoryResolver.resolve(namespace); + + const activeOtp = await otpRepo.findActiveByPasscode(ctx, { + category, + passcode, + }); + + if (!activeOtp || activeOtp.isExpired()) { + return null; + } + + const { type } = activeOtp; + const typeService = this.policy.resolveTypeService(type); + + if (!typeService.validator(passcode, activeOtp.passcode)) { + return null; + } + + return { assigneeId: activeOtp.assigneeId }; + } +} diff --git a/packages/nestjs-otp/src/application/queries/impl/find-active-otp.query.ts b/packages/nestjs-otp/src/application/queries/impl/find-active-otp.query.ts new file mode 100644 index 000000000..441ed9688 --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/impl/find-active-otp.query.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type Otp } from '../../../domain/aggregates/otp.js'; +import { type OtpInterface } from '../../../domain/interfaces/otp.interface.js'; + +export class FindActiveOtpQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly otp: Pick, + ) { + super(); + } +} diff --git a/packages/nestjs-otp/src/application/queries/impl/find-assigned-otps.query.ts b/packages/nestjs-otp/src/application/queries/impl/find-assigned-otps.query.ts new file mode 100644 index 000000000..7b20e5650 --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/impl/find-assigned-otps.query.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type Otp } from '../../../domain/aggregates/otp.js'; +import { type OtpInterface } from '../../../domain/interfaces/otp.interface.js'; + +export class FindAssignedOtpsQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly otp: Pick, + ) { + super(); + } +} diff --git a/packages/nestjs-otp/src/application/queries/impl/get-otp.query.ts b/packages/nestjs-otp/src/application/queries/impl/get-otp.query.ts new file mode 100644 index 000000000..5b51586b8 --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/impl/get-otp.query.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Otp } from '../../../domain/aggregates/otp.js'; + +export class GetOtpQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-otp/src/application/queries/impl/validate-otp.query.ts b/packages/nestjs-otp/src/application/queries/impl/validate-otp.query.ts new file mode 100644 index 000000000..671e930b2 --- /dev/null +++ b/packages/nestjs-otp/src/application/queries/impl/validate-otp.query.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type AssigneeRelationInterface } from '@concepta/nestjs-core'; + +import { type OtpInterface } from '../../../domain/interfaces/otp.interface.js'; + +export class ValidateOtpQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly otp: Pick, + ) { + super(); + } +} diff --git a/packages/nestjs-otp/src/application/utils/__tests__/validate-otp-schema.util.spec.ts b/packages/nestjs-otp/src/application/utils/__tests__/validate-otp-schema.util.spec.ts new file mode 100644 index 000000000..7ffccf71e --- /dev/null +++ b/packages/nestjs-otp/src/application/utils/__tests__/validate-otp-schema.util.spec.ts @@ -0,0 +1,49 @@ +import { HttpStatus } from '@nestjs/common'; + +import { OtpValidationException } from '../../../domain/exceptions/otp-validation.exception.js'; +import { otpCreateSchema } from '../../../infrastructure/schemas/otp-create.schema.js'; +import { validateOtpSchema } from '../validate-otp-schema.util.js'; + +describe('validateOtpSchema', () => { + const validData = { + category: 'test-category', + type: 'uuid', + expiresIn: '1h', + assigneeId: 'test-assignee', + }; + + it('returns the parsed value for valid data', async () => { + const result = await validateOtpSchema( + 'OtpCreate', + otpCreateSchema, + validData, + ); + + expect(result).toEqual(validData); + }); + + it('throws OtpValidationException for invalid data', async () => { + const { assigneeId: _assigneeId, ...invalidData } = validData; + + await expect( + validateOtpSchema('OtpCreate', otpCreateSchema, invalidData), + ).rejects.toThrow(OtpValidationException); + }); + + it('includes the schemaName and validation issues in the exception context', async () => { + const { assigneeId: _assigneeId, ...invalidData } = validData; + + try { + await validateOtpSchema('OtpCreate', otpCreateSchema, invalidData); + throw new Error('Expected OtpValidationException'); + } catch (error) { + if (!(error instanceof OtpValidationException)) { + throw error; + } + + expect(error.context.schemaName).toBe('OtpCreate'); + expect(error.context.validationErrors.length).toBeGreaterThan(0); + expect(error.getStatus()).toBe(HttpStatus.BAD_REQUEST); + } + }); +}); diff --git a/packages/nestjs-otp/src/application/utils/validate-otp-schema.util.ts b/packages/nestjs-otp/src/application/utils/validate-otp-schema.util.ts new file mode 100644 index 000000000..e59a5ab72 --- /dev/null +++ b/packages/nestjs-otp/src/application/utils/validate-otp-schema.util.ts @@ -0,0 +1,17 @@ +import { type StandardSchemaV1 } from '@standard-schema/spec'; + +import { OtpValidationException } from '../../domain/exceptions/otp-validation.exception.js'; + +export async function validateOtpSchema( + schemaName: string, + schema: Schema, + data: unknown, +): Promise> { + const result = await schema['~standard'].validate(data); + + if (result.issues) { + throw new OtpValidationException(schemaName, result.issues); + } + + return result.value; +} diff --git a/packages/nestjs-otp/src/config/otp-default.config.ts b/packages/nestjs-otp/src/config/otp-default.config.ts deleted file mode 100644 index 12bcaf005..000000000 --- a/packages/nestjs-otp/src/config/otp-default.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { OtpSettingsInterface } from '../interfaces/otp-settings.interface'; -import { OTP_MODULE_DEFAULT_SETTINGS_TOKEN } from '../otp.constants'; -import { uuidGeneratorUtil } from '../utils/uuid-generator.util'; -import { uuidValidatorUtil } from '../utils/uuid-validator.util'; - -/** - * Default configuration for Otp module. - */ -export const otpDefaultConfig = registerAs( - OTP_MODULE_DEFAULT_SETTINGS_TOKEN, - (): OtpSettingsInterface => ({ - types: { - uuid: { - generator: uuidGeneratorUtil, - validator: uuidValidatorUtil, - }, - }, - clearOnCreate: process.env.OTP_CLEAR_ON_CREATE == 'true' ? true : false, - keepHistoryDays: process.env.OTP_KEEP_HISTORY_DAYS - ? Number.parseInt(process.env.OTP_KEEP_HISTORY_DAYS) - : undefined, - rateSeconds: process.env.OTP_RATE_SECONDS - ? Number.parseInt(process.env.OTP_RATE_SECONDS) - : undefined, - rateThreshold: process.env.OTP_RATE_THRESHOLD - ? Number.parseInt(process.env.OTP_RATE_THRESHOLD) - : undefined, - }), -); diff --git a/packages/nestjs-otp/src/domain/aggregates/__tests__/otp.spec.ts b/packages/nestjs-otp/src/domain/aggregates/__tests__/otp.spec.ts new file mode 100644 index 000000000..83db70715 --- /dev/null +++ b/packages/nestjs-otp/src/domain/aggregates/__tests__/otp.spec.ts @@ -0,0 +1,183 @@ +import { createMockEventContext } from '../../../__tests__/helpers/mock.helpers.js'; +import { type OtpEntityInterface } from '../../../infrastructure/persistence/interfaces/otp-entity.interface.js'; +import { OtpMapper } from '../../../infrastructure/persistence/otp.mapper.js'; +import { OtpCreatedEvent } from '../../events/otp-created.event.js'; +import { OtpDeactivatedEvent } from '../../events/otp-deactivated.event.js'; +import { Otp, type OtpCreateProps } from '../otp.js'; + +const otpMapper = new OtpMapper(); + +describe(Otp.name, () => { + const eventContext = createMockEventContext(); + + const validProps: OtpCreateProps = { + category: 'auth', + type: 'uuid', + assigneeId: 'user-1', + passcode: 'abc-123', + expiresIn: '1h', + }; + + const validEntity: OtpEntityInterface = { + id: 'otp-1', + category: 'auth', + type: 'uuid', + assigneeId: 'user-1', + passcode: 'abc-123', + expirationDate: new Date('2027-01-01'), + active: true, + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + }; + + describe('create', () => { + it('should create an OTP with a generated UUID id', () => { + const otp = Otp.create(eventContext, validProps); + + expect(otp.id).toBeDefined(); + expect(typeof otp.id).toBe('string'); + expect(otp.id.length).toBeGreaterThan(0); + expect(otp.category).toBe('auth'); + expect(otp.type).toBe('uuid'); + expect(otp.assigneeId).toBe('user-1'); + expect(otp.passcode).toBe('abc-123'); + expect(otp.active).toBe(true); + expect(otp.version).toBe(1); + expect(otp.meta.dateDeleted).toBeNull(); + }); + + it('should set expirationDate in the future', () => { + const before = new Date(); + const otp = Otp.create(eventContext, validProps); + expect(otp.expirationDate.getTime()).toBeGreaterThan(before.getTime()); + }); + + it('should apply OtpCreatedEvent', () => { + const otp = Otp.create(eventContext, validProps); + const events = otp.getUncommittedEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(OtpCreatedEvent); + }); + + it('should throw for invalid expiresIn', () => { + expect(() => + Otp.create(eventContext, { ...validProps, expiresIn: 'invalid' }), + ).toThrow(); + }); + }); + + describe('createWithId', () => { + it('should create an OTP with the given id', () => { + const otp = Otp.createWithId(eventContext, 'custom-id', validProps); + + expect(otp.id).toBe('custom-id'); + expect(otp.category).toBe('auth'); + }); + + it('should use the same now for dateCreated and expirationDate', () => { + const otp = Otp.createWithId(eventContext, 'id-1', { + ...validProps, + expiresIn: '1h', + }); + + const diff = + otp.expirationDate.getTime() - otp.meta.dateCreated.getTime(); + // 1 hour = 3600000ms + expect(diff).toBe(3600000); + }); + }); + + describe('constructor', () => { + it('should create an Otp from an entity without events', () => { + const otp = otpMapper.toDomain(validEntity); + + expect(otp.id).toBe('otp-1'); + expect(otp.category).toBe('auth'); + expect(otp.active).toBe(true); + expect(otp.getUncommittedEvents()).toHaveLength(0); + }); + }); + + describe('toPlain', () => { + it('should return a plain copy of the entity', () => { + const otp = otpMapper.toDomain(validEntity); + const plain = otp.toPlain(); + + expect(plain).toEqual(validEntity); + expect(plain).not.toBe(validEntity); + }); + }); + + describe('deactivate', () => { + it('should set active to false', () => { + const otp = otpMapper.toDomain(validEntity); + otp.deactivate(eventContext); + expect(otp.active).toBe(false); + }); + + it('should increment version', () => { + const otp = otpMapper.toDomain(validEntity); + otp.deactivate(eventContext); + expect(otp.version).toBe(2); + }); + + it('should apply OtpDeactivatedEvent', () => { + const otp = otpMapper.toDomain(validEntity); + otp.deactivate(eventContext); + const events = otp.getUncommittedEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(OtpDeactivatedEvent); + }); + }); + + describe('isExpired', () => { + it('should return false when expiration is in the future', () => { + const otp = otpMapper.toDomain({ + ...validEntity, + expirationDate: new Date('2099-01-01'), + }); + expect(otp.isExpired()).toBe(false); + }); + + it('should return true when expiration is in the past', () => { + const otp = otpMapper.toDomain({ + ...validEntity, + expirationDate: new Date('2020-01-01'), + }); + expect(otp.isExpired()).toBe(true); + }); + + it('should accept a custom now parameter', () => { + const otp = otpMapper.toDomain({ + ...validEntity, + expirationDate: new Date('2026-06-01'), + }); + + const beforeExpiry = new Date('2026-05-01'); + const afterExpiry = new Date('2026-07-01'); + + expect(otp.isExpired(beforeExpiry)).toBe(false); + expect(otp.isExpired(afterExpiry)).toBe(true); + }); + }); + + describe('getters', () => { + it('should expose all entity properties', () => { + const otp = otpMapper.toDomain(validEntity); + + expect(otp.id).toBe(validEntity.id); + expect(otp.category).toBe(validEntity.category); + expect(otp.type).toBe(validEntity.type); + expect(otp.passcode).toBe(validEntity.passcode); + expect(otp.assigneeId).toBe(validEntity.assigneeId); + expect(otp.expirationDate).toEqual(validEntity.expirationDate); + expect(otp.active).toBe(validEntity.active); + expect(otp.meta.dateCreated).toEqual(validEntity.dateCreated); + expect(otp.meta.dateUpdated).toEqual(validEntity.dateUpdated); + expect(otp.meta.dateDeleted).toBe(validEntity.dateDeleted); + expect(otp.version).toBe(validEntity.version); + }); + }); +}); diff --git a/packages/nestjs-otp/src/domain/aggregates/otp.ts b/packages/nestjs-otp/src/domain/aggregates/otp.ts new file mode 100644 index 000000000..e0d38a7a6 --- /dev/null +++ b/packages/nestjs-otp/src/domain/aggregates/otp.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'crypto'; + +import { + type DomainFactory, + type EventContextHost, +} from '@concepta/nestjs-core'; +import { DomainAggregate } from '@concepta/nestjs-core/aggregate'; + +import { type OtpEventHeaderInterface } from '../events/interfaces/otp-event-header.interface.js'; +import { OtpConsumedEvent } from '../events/otp-consumed.event.js'; +import { OtpCreatedEvent } from '../events/otp-created.event.js'; +import { OtpDeactivatedEvent } from '../events/otp-deactivated.event.js'; +import { type OtpInterface } from '../interfaces/otp.interface.js'; +import { getExpirationDate } from '../utils/get-expiration-date.util.js'; + +export interface OtpCreateProps { + category: string; + type: string; + assigneeId: string; + passcode: string; + expiresIn: string; +} + +export class Otp extends DomainAggregate { + get category() { + return this.props.category; + } + + get type() { + return this.props.type; + } + + get passcode() { + return this.props.passcode; + } + + get assigneeId() { + return this.props.assigneeId; + } + + get expirationDate() { + return this.props.expirationDate; + } + + get active() { + return this.props.active; + } + + static create( + eventContext: EventContextHost, + props: OtpCreateProps, + ): Otp { + return Otp.createWithId(eventContext, randomUUID(), props); + } + + static createWithId( + eventContext: EventContextHost, + id: string, + props: OtpCreateProps, + ): Otp { + const { category, type, assigneeId, passcode, expiresIn } = props; + const now = new Date(); + + const otp = new Otp(id, { + category, + type, + assigneeId, + passcode, + expirationDate: getExpirationDate(expiresIn, now), + active: true, + }); + + otp.apply(new OtpCreatedEvent(eventContext, otp.toPlain())); + + return otp; + } + + deactivate(eventContext: EventContextHost): void { + this.props = { + ...this.props, + active: false, + }; + + this.incrementVersion(); + + this.apply(new OtpDeactivatedEvent(eventContext, this.toPlain())); + } + + consume(eventContext: EventContextHost): void { + this.apply(new OtpConsumedEvent(eventContext, this.toPlain())); + } + + isExpired(now: Date = new Date()): boolean { + return now > this.props.expirationDate; + } +} + +Otp satisfies DomainFactory; diff --git a/packages/nestjs-otp/src/domain/events/interfaces/otp-event-header.interface.ts b/packages/nestjs-otp/src/domain/events/interfaces/otp-event-header.interface.ts new file mode 100644 index 000000000..b7f4ccca6 --- /dev/null +++ b/packages/nestjs-otp/src/domain/events/interfaces/otp-event-header.interface.ts @@ -0,0 +1,5 @@ +import { type EventContextHeadersInterface } from '@concepta/nestjs-core'; + +export interface OtpEventHeaderInterface extends EventContextHeadersInterface { + namespace: string; +} diff --git a/packages/nestjs-otp/src/domain/events/otp-consumed.event.ts b/packages/nestjs-otp/src/domain/events/otp-consumed.event.ts new file mode 100644 index 000000000..77e6ab1e7 --- /dev/null +++ b/packages/nestjs-otp/src/domain/events/otp-consumed.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type OtpInterface } from '../interfaces/otp.interface.js'; + +import { type OtpEventHeaderInterface } from './interfaces/otp-event-header.interface.js'; + +export class OtpConsumedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly otp: OtpInterface, + ) {} +} diff --git a/packages/nestjs-otp/src/domain/events/otp-created.event.ts b/packages/nestjs-otp/src/domain/events/otp-created.event.ts new file mode 100644 index 000000000..597f2f6c5 --- /dev/null +++ b/packages/nestjs-otp/src/domain/events/otp-created.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type OtpInterface } from '../interfaces/otp.interface.js'; + +import { type OtpEventHeaderInterface } from './interfaces/otp-event-header.interface.js'; + +export class OtpCreatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly otp: OtpInterface, + ) {} +} diff --git a/packages/nestjs-otp/src/domain/events/otp-deactivated.event.ts b/packages/nestjs-otp/src/domain/events/otp-deactivated.event.ts new file mode 100644 index 000000000..32336dca7 --- /dev/null +++ b/packages/nestjs-otp/src/domain/events/otp-deactivated.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type OtpInterface } from '../interfaces/otp.interface.js'; + +import { type OtpEventHeaderInterface } from './interfaces/otp-event-header.interface.js'; + +export class OtpDeactivatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly otp: OtpInterface, + ) {} +} diff --git a/packages/nestjs-otp/src/domain/exceptions/otp-invalid-expiration-date.exception.ts b/packages/nestjs-otp/src/domain/exceptions/otp-invalid-expiration-date.exception.ts new file mode 100644 index 000000000..fa350cb01 --- /dev/null +++ b/packages/nestjs-otp/src/domain/exceptions/otp-invalid-expiration-date.exception.ts @@ -0,0 +1,14 @@ +import { HttpStatus } from '@nestjs/common'; + +import { OtpException } from './otp.exception.js'; + +export class OtpInvalidExpirationDateException extends OtpException { + constructor() { + super({ + message: 'Invalid expiresIn', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + this.errorCode = 'OTP_INVALID_EXPIRES_IN'; + } +} diff --git a/packages/nestjs-otp/src/domain/exceptions/otp-limit-reached.exception.ts b/packages/nestjs-otp/src/domain/exceptions/otp-limit-reached.exception.ts new file mode 100644 index 000000000..e33b2352c --- /dev/null +++ b/packages/nestjs-otp/src/domain/exceptions/otp-limit-reached.exception.ts @@ -0,0 +1,18 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { OtpException } from './otp.exception.js'; + +export class OtpLimitReachedException extends OtpException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'OTP creation limit reached for the time window.', + httpStatus: HttpStatus.TOO_MANY_REQUESTS, + fault: 'client', + ...options, + }); + + this.errorCode = 'OTP_LIMIT_REACHED_ERROR'; + } +} diff --git a/packages/nestjs-otp/src/domain/exceptions/otp-type-not-defined.exception.ts b/packages/nestjs-otp/src/domain/exceptions/otp-type-not-defined.exception.ts new file mode 100644 index 000000000..24693a8e1 --- /dev/null +++ b/packages/nestjs-otp/src/domain/exceptions/otp-type-not-defined.exception.ts @@ -0,0 +1,28 @@ +import { + type RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +import { OtpException } from './otp.exception.js'; + +export class OtpTypeNotDefinedException extends OtpException { + declare context: RuntimeException['context'] & { + type: string; + }; + + constructor(type: string, options?: RuntimeExceptionOptions) { + super({ + message: 'Type %s was not defined to be used. please check config.', + messageParams: [type], + fault: 'usage', + ...options, + }); + + this.context = { + ...this.context, + type, + }; + + this.errorCode = 'OTP_TYPE_NOT_DEFINED_ERROR'; + } +} diff --git a/packages/nestjs-otp/src/domain/exceptions/otp-validation.exception.ts b/packages/nestjs-otp/src/domain/exceptions/otp-validation.exception.ts new file mode 100644 index 000000000..822b0d0b5 --- /dev/null +++ b/packages/nestjs-otp/src/domain/exceptions/otp-validation.exception.ts @@ -0,0 +1,31 @@ +import { type StandardSchemaV1 } from '@standard-schema/spec'; + +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { OtpException } from './otp.exception.js'; + +export class OtpValidationException extends OtpException { + declare context: OtpException['context'] & { + schemaName: string; + validationErrors: readonly StandardSchemaV1.Issue[]; + }; + + constructor( + schemaName: string, + validationErrors: readonly StandardSchemaV1.Issue[], + options?: RuntimeExceptionOptions, + ) { + super({ + message: 'Data for the %s schema is not valid', + messageParams: [schemaName], + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + + this.context = { ...this.context, schemaName, validationErrors }; + this.errorCode = 'OTP_VALIDATION_ERROR'; + } +} diff --git a/packages/nestjs-otp/src/exceptions/otp.exception.ts b/packages/nestjs-otp/src/domain/exceptions/otp.exception.ts similarity index 75% rename from packages/nestjs-otp/src/exceptions/otp.exception.ts rename to packages/nestjs-otp/src/domain/exceptions/otp.exception.ts index c312c58a4..0771d9832 100644 --- a/packages/nestjs-otp/src/exceptions/otp.exception.ts +++ b/packages/nestjs-otp/src/domain/exceptions/otp.exception.ts @@ -1,7 +1,7 @@ import { RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; export class OtpException extends RuntimeException { constructor(options?: RuntimeExceptionOptions) { diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-creatable.interface.ts b/packages/nestjs-otp/src/domain/interfaces/otp-creatable.interface.ts similarity index 76% rename from packages/nestjs-common/src/domain/otp/interfaces/otp-creatable.interface.ts rename to packages/nestjs-otp/src/domain/interfaces/otp-creatable.interface.ts index c7095bd25..2a87efb10 100644 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp-creatable.interface.ts +++ b/packages/nestjs-otp/src/domain/interfaces/otp-creatable.interface.ts @@ -1,7 +1,9 @@ -import { OtpInterface } from './otp.interface'; +import { type OtpInterface } from './otp.interface.js'; -export interface OtpCreatableInterface - extends Pick { +export interface OtpCreatableInterface extends Pick< + OtpInterface, + 'category' | 'type' | 'assigneeId' +> { expiresIn: string; /** * The minimum number of seconds that must pass between OTP generation requests. diff --git a/packages/nestjs-otp/src/domain/interfaces/otp-type-service.interface.ts b/packages/nestjs-otp/src/domain/interfaces/otp-type-service.interface.ts new file mode 100644 index 000000000..5d628a0d4 --- /dev/null +++ b/packages/nestjs-otp/src/domain/interfaces/otp-type-service.interface.ts @@ -0,0 +1,10 @@ +/** + * Interface for OTP type services. + * + * Each OTP type (e.g., email, SMS) must implement this interface to define + * how OTPs are generated and validated for that specific type. + */ +export interface OtpTypeServiceInterface { + generator(): string; + validator(a: unknown, b: unknown): boolean; +} diff --git a/packages/nestjs-otp/src/domain/interfaces/otp.interface.ts b/packages/nestjs-otp/src/domain/interfaces/otp.interface.ts new file mode 100644 index 000000000..cf2c350ad --- /dev/null +++ b/packages/nestjs-otp/src/domain/interfaces/otp.interface.ts @@ -0,0 +1,28 @@ +import { type AssigneeRelationInterface } from '@concepta/nestjs-core'; + +export interface OtpInterface extends AssigneeRelationInterface { + /** + * Name + */ + category: string; + + /** + * Type of the passcode + */ + type: string; + + /** + * Passcode + */ + passcode: string; + + /** + * Date it will expire + */ + expirationDate: Date; + + /** + * is active status + */ + active: boolean; +} diff --git a/packages/nestjs-otp/src/domain/policies/__tests__/otp.policy.spec.ts b/packages/nestjs-otp/src/domain/policies/__tests__/otp.policy.spec.ts new file mode 100644 index 000000000..4956c17f5 --- /dev/null +++ b/packages/nestjs-otp/src/domain/policies/__tests__/otp.policy.spec.ts @@ -0,0 +1,118 @@ +import { OtpTypeNotDefinedException } from '../../exceptions/otp-type-not-defined.exception.js'; +import { type OtpTypeServiceInterface } from '../../interfaces/otp-type-service.interface.js'; +import { OtpPolicy } from '../otp.policy.js'; + +describe(OtpPolicy.name, () => { + const uuidType: OtpTypeServiceInterface = { + generator: () => 'generated-passcode', + validator: () => true, + }; + + describe('resolveTypeService', () => { + it('should return the registered type service', () => { + const policy = new OtpPolicy({ types: { uuid: uuidType } }); + + expect(policy.resolveTypeService('uuid')).toBe(uuidType); + }); + + it('should throw OtpTypeNotDefinedException when the type is not registered', () => { + const policy = new OtpPolicy({ types: {} }); + + expect(() => policy.resolveTypeService('unknown')).toThrow( + OtpTypeNotDefinedException, + ); + }); + + it('should throw OtpTypeNotDefinedException when no types are configured at all', () => { + const policy = new OtpPolicy(); + + expect(() => policy.resolveTypeService('uuid')).toThrow( + OtpTypeNotDefinedException, + ); + }); + }); + + describe('resolveDuplicateStrategy', () => { + it('should default to DEACTIVATE when nothing is configured', () => { + const policy = new OtpPolicy(); + + expect(policy.resolveDuplicateStrategy()).toBe('DEACTIVATE'); + }); + + it('should use the configured setting when no override is given', () => { + const policy = new OtpPolicy({ duplicateStrategy: 'ALLOW' }); + + expect(policy.resolveDuplicateStrategy()).toBe('ALLOW'); + }); + + it('should let an override win over the configured setting', () => { + const policy = new OtpPolicy({ duplicateStrategy: 'ALLOW' }); + + expect(policy.resolveDuplicateStrategy('DEACTIVATE')).toBe('DEACTIVATE'); + }); + }); + + describe('resolveKeepHistoryDays', () => { + it('should return undefined when nothing is configured', () => { + const policy = new OtpPolicy(); + + expect(policy.resolveKeepHistoryDays()).toBeUndefined(); + }); + + it('should return the configured setting when no override is given', () => { + const policy = new OtpPolicy({ keepHistoryDays: 30 }); + + expect(policy.resolveKeepHistoryDays()).toBe(30); + }); + + it('should return 0 rather than falling through to the configured setting', () => { + const policy = new OtpPolicy({ keepHistoryDays: 30 }); + + expect(policy.resolveKeepHistoryDays(0)).toBe(0); + }); + + it('should let an override win over the configured setting', () => { + const policy = new OtpPolicy({ keepHistoryDays: 30 }); + + expect(policy.resolveKeepHistoryDays(7)).toBe(7); + }); + }); + + describe('resolveRateLimit', () => { + it('should return undefined when nothing is configured', () => { + const policy = new OtpPolicy(); + + expect(policy.resolveRateLimit()).toBeUndefined(); + }); + + it('should return the configured rate limit when no overrides are given', () => { + const policy = new OtpPolicy({ rateSeconds: 60, rateThreshold: 3 }); + + expect(policy.resolveRateLimit()).toEqual({ + rateSeconds: 60, + rateThreshold: 3, + }); + }); + + it('should return undefined when the configured rateSeconds is 0', () => { + const policy = new OtpPolicy({ rateSeconds: 0, rateThreshold: 3 }); + + expect(policy.resolveRateLimit()).toBeUndefined(); + }); + + it('should return undefined when an override sets rateThreshold to 0', () => { + const policy = new OtpPolicy({ rateSeconds: 60, rateThreshold: 3 }); + + expect(policy.resolveRateLimit({ rateThreshold: 0 })).toBeUndefined(); + }); + + it('should let overrides win over configured settings independently', () => { + const policy = new OtpPolicy({ rateSeconds: 60, rateThreshold: 3 }); + + expect(policy.resolveRateLimit({ rateSeconds: 120 })).toEqual({ + rateSeconds: 120, + rateThreshold: 3, + }); + }); + }); +}); diff --git a/packages/nestjs-otp/src/domain/policies/otp.policy.ts b/packages/nestjs-otp/src/domain/policies/otp.policy.ts new file mode 100644 index 000000000..f345ab90d --- /dev/null +++ b/packages/nestjs-otp/src/domain/policies/otp.policy.ts @@ -0,0 +1,83 @@ +import { OtpTypeNotDefinedException } from '../exceptions/otp-type-not-defined.exception.js'; +import { type OtpTypeServiceInterface } from '../interfaces/otp-type-service.interface.js'; + +export interface OtpPolicySettings { + types?: { [key: string]: OtpTypeServiceInterface }; + duplicateStrategy?: 'ALLOW' | 'DEACTIVATE'; + keepHistoryDays?: number; + rateSeconds?: number; + rateThreshold?: number; +} + +export interface OtpRateLimit { + rateSeconds: number; + rateThreshold: number; +} + +export class OtpPolicy { + private readonly types: { [key: string]: OtpTypeServiceInterface }; + + private readonly duplicateStrategy: 'ALLOW' | 'DEACTIVATE'; + + private readonly keepHistoryDays?: number; + + private readonly rateSeconds?: number; + + private readonly rateThreshold?: number; + + constructor(settings?: OtpPolicySettings) { + const { + types = {}, + duplicateStrategy = 'DEACTIVATE', + keepHistoryDays, + rateSeconds, + rateThreshold, + } = settings ?? {}; + + this.types = types; + this.duplicateStrategy = duplicateStrategy; + this.keepHistoryDays = keepHistoryDays; + this.rateSeconds = rateSeconds; + this.rateThreshold = rateThreshold; + } + + resolveTypeService(type: string): OtpTypeServiceInterface { + const typeService = this.types[type]; + + if (!typeService) { + throw new OtpTypeNotDefinedException(type); + } + + return typeService; + } + + resolveDuplicateStrategy( + override?: 'ALLOW' | 'DEACTIVATE', + ): 'ALLOW' | 'DEACTIVATE' { + return override ?? this.duplicateStrategy; + } + + resolveKeepHistoryDays(override?: number): number | undefined { + return override !== undefined ? override : this.keepHistoryDays; + } + + resolveRateLimit(overrides?: { + rateSeconds?: number; + rateThreshold?: number; + }): OtpRateLimit | undefined { + const rateSeconds = + overrides?.rateSeconds !== undefined + ? overrides.rateSeconds + : this.rateSeconds; + const rateThreshold = + overrides?.rateThreshold !== undefined + ? overrides.rateThreshold + : this.rateThreshold; + + if (rateSeconds && rateThreshold) { + return { rateSeconds, rateThreshold }; + } + + return undefined; + } +} diff --git a/packages/nestjs-otp/src/domain/repositories/otp-repository-resolver.interface.ts b/packages/nestjs-otp/src/domain/repositories/otp-repository-resolver.interface.ts new file mode 100644 index 000000000..cec6eedb3 --- /dev/null +++ b/packages/nestjs-otp/src/domain/repositories/otp-repository-resolver.interface.ts @@ -0,0 +1,5 @@ +import { type OtpRepositoryInterface } from './otp-repository.interface.js'; + +export interface OtpRepositoryResolverInterface { + resolve(entityKey: string): OtpRepositoryInterface; +} diff --git a/packages/nestjs-otp/src/domain/repositories/otp-repository.interface.ts b/packages/nestjs-otp/src/domain/repositories/otp-repository.interface.ts new file mode 100644 index 000000000..5158ac004 --- /dev/null +++ b/packages/nestjs-otp/src/domain/repositories/otp-repository.interface.ts @@ -0,0 +1,45 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Otp } from '../aggregates/otp.js'; + +export interface OtpRepositoryInterface { + get(ctx: PlainLiteralObject, id: ReferenceId): Promise; + + findActiveByPasscode( + ctx: PlainLiteralObject, + options: { category: string; passcode: string }, + ): Promise; + + findByPasscode( + ctx: PlainLiteralObject, + options: { category: string; passcode: string }, + ): Promise; + + findActiveByAssignee( + ctx: PlainLiteralObject, + options: { assigneeId: string; category: string }, + ): Promise; + + findAllByAssigneeAndCategory( + ctx: PlainLiteralObject, + options: { assigneeId: string; category: string }, + ): Promise; + + countCreatedSince( + ctx: PlainLiteralObject, + options: { assigneeId: string; category: string; cutoffDate: Date }, + ): Promise; + + findOlderThan( + ctx: PlainLiteralObject, + options: { assigneeId: string; category: string; cutoffDate: Date }, + ): Promise; + + save(ctx: PlainLiteralObject, otp: Otp): Promise; + + remove(ctx: PlainLiteralObject, otp: Otp): Promise; + + removeAll(ctx: PlainLiteralObject, otps: Otp[]): Promise; +} diff --git a/packages/nestjs-otp/src/domain/services/otp-history-cleanup.service.ts b/packages/nestjs-otp/src/domain/services/otp-history-cleanup.service.ts new file mode 100644 index 000000000..28a236442 --- /dev/null +++ b/packages/nestjs-otp/src/domain/services/otp-history-cleanup.service.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable, PlainLiteralObject } from '@nestjs/common'; + +import { OTP_REPOSITORY_RESOLVER_TOKEN } from '../../otp.constants.js'; +import { OtpRepositoryResolverInterface } from '../repositories/otp-repository-resolver.interface.js'; + +@Injectable() +export class OtpHistoryCleanupService { + constructor( + @Inject(OTP_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: OtpRepositoryResolverInterface, + ) {} + + async cleanup( + ctx: PlainLiteralObject, + options: { + namespace: string; + assigneeId: string; + category: string; + keepHistoryDays: number; + }, + ): Promise { + const { namespace, assigneeId, category, keepHistoryDays } = options; + + const otpRepo = this.repositoryResolver.resolve(namespace); + + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - keepHistoryDays); + + const oldOtps = await otpRepo.findOlderThan(ctx, { + assigneeId, + category, + cutoffDate, + }); + + if (oldOtps.length > 0) { + await otpRepo.removeAll(ctx, oldOtps); + } + } +} diff --git a/packages/nestjs-otp/src/domain/utils/__tests__/get-expiration-date.util.spec.ts b/packages/nestjs-otp/src/domain/utils/__tests__/get-expiration-date.util.spec.ts new file mode 100644 index 000000000..2182e6b2a --- /dev/null +++ b/packages/nestjs-otp/src/domain/utils/__tests__/get-expiration-date.util.spec.ts @@ -0,0 +1,21 @@ +import { type RuntimeException } from '@concepta/nestjs-core'; + +import { getExpirationDate } from '../get-expiration-date.util.js'; + +describe('getExpirationDate', () => { + it('should return a future Date for a valid duration string', () => { + const now = new Date(); + const result = getExpirationDate('1h', now); + + expect(result.getTime()).toBe(now.getTime() + 60 * 60 * 1000); + }); + + it('should classify an unparseable expiresIn as client fault', () => { + try { + getExpirationDate('not-a-duration'); + throw new Error('Expected a throw'); + } catch (e) { + expect((e as RuntimeException).fault).toBe('client'); + } + }); +}); diff --git a/packages/nestjs-otp/src/domain/utils/get-expiration-date.util.ts b/packages/nestjs-otp/src/domain/utils/get-expiration-date.util.ts new file mode 100644 index 000000000..3a2b3e3aa --- /dev/null +++ b/packages/nestjs-otp/src/domain/utils/get-expiration-date.util.ts @@ -0,0 +1,19 @@ +import { toMilliseconds } from '@concepta/nestjs-core'; + +import { OtpInvalidExpirationDateException } from '../exceptions/otp-invalid-expiration-date.exception.js'; + +export const getExpirationDate = ( + expiresIn: string, + now: Date = new Date(), +): Date => { + // expiresIn is a required, client-supplied schema field with no + // module-configured fallback — an unparseable value is the caller's + // mistake. + const expires = toMilliseconds(expiresIn, undefined, 'client'); + + if (!expires) { + throw new OtpInvalidExpirationDateException(); + } + + return new Date(now.getTime() + expires); +}; diff --git a/packages/nestjs-otp/src/dto/otp-create.dto.ts b/packages/nestjs-otp/src/dto/otp-create.dto.ts deleted file mode 100644 index 3075ed183..000000000 --- a/packages/nestjs-otp/src/dto/otp-create.dto.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsOptional, IsString } from 'class-validator'; - -import { OtpCreatableInterface } from '@concepta/nestjs-common'; - -/** - * Otp Create DTO - */ -@Exclude() -export class OtpCreateDto implements OtpCreatableInterface { - /** - * category - */ - @Expose() - @IsString() - category = ''; - - /** - * type - */ - @Expose() - @IsString() - type = ''; - - /** - * Expressed in seconds or a string describing a time span [zeit/ms](https://github.com/zeit/ms.js). - * - * Eg: 60, "2 days", "10h", "7d" - */ - @Expose() - @IsString() - expiresIn = ''; - - /** - * The minimum number of seconds that must pass between OTP generation requests. - * This helps prevent abuse by rate limiting how frequently new OTPs can be created. - */ - @Expose() - @IsOptional() - rateSeconds?: number; - - /** - * How many attempts before the user is blocked within the rateSeconds time window. - * For example, if rateSeconds is 60 and rateThreshold is 3, the user will be blocked - * after 3 failed attempts within 60 seconds. - */ - @Expose() - @IsOptional() - rateThreshold?: number; - - /** - * Assignee - */ - @Expose() - @IsString() - assigneeId!: string; -} diff --git a/packages/nestjs-otp/src/exceptions/otp-entity-not-found.exception.spec.ts b/packages/nestjs-otp/src/exceptions/otp-entity-not-found.exception.spec.ts deleted file mode 100644 index 71df6df19..000000000 --- a/packages/nestjs-otp/src/exceptions/otp-entity-not-found.exception.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { OtpEntityNotFoundException } from './otp-entity-not-found.exception'; - -describe(OtpEntityNotFoundException.name, () => { - it('should create an instance of EntityNotFoundException', () => { - const exception = new OtpEntityNotFoundException('TestEntity'); - expect(exception).toBeInstanceOf(OtpEntityNotFoundException); - }); - - it('should have the correct error message', () => { - const exception = new OtpEntityNotFoundException('TestEntity'); - expect(exception.message).toBe( - 'Entity TestEntity was not registered to be used.', - ); - }); - - it('should have the correct context', () => { - const exception = new OtpEntityNotFoundException('TestEntity'); - expect(exception.context).toEqual({ entityName: 'TestEntity' }); - }); - - it('should have the correct error code', () => { - const exception = new OtpEntityNotFoundException('TestEntity'); - expect(exception.errorCode).toBe('OTP_ENTITY_NOT_FOUND_ERROR'); - }); -}); diff --git a/packages/nestjs-otp/src/exceptions/otp-entity-not-found.exception.ts b/packages/nestjs-otp/src/exceptions/otp-entity-not-found.exception.ts deleted file mode 100644 index 6745a9738..000000000 --- a/packages/nestjs-otp/src/exceptions/otp-entity-not-found.exception.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { OtpException } from './otp.exception'; - -export class OtpEntityNotFoundException extends OtpException { - context: RuntimeException['context'] & { - entityName: string; - }; - - constructor(entityName: string, options?: RuntimeExceptionOptions) { - super({ - message: 'Entity %s was not registered to be used.', - messageParams: [entityName], - ...options, - }); - - this.context = { - ...super.context, - entityName, - }; - - this.errorCode = 'OTP_ENTITY_NOT_FOUND_ERROR'; - } -} diff --git a/packages/nestjs-otp/src/exceptions/otp-limit-reached.exception.ts b/packages/nestjs-otp/src/exceptions/otp-limit-reached.exception.ts deleted file mode 100644 index 92fac411e..000000000 --- a/packages/nestjs-otp/src/exceptions/otp-limit-reached.exception.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { OtpException } from './otp.exception'; - -export class OtpLimitReachedException extends OtpException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: 'OTP creation limit reached for the time window.', - ...options, - }); - - this.errorCode = 'OTP_LIMIT_REACHED_ERROR'; - } -} diff --git a/packages/nestjs-otp/src/exceptions/otp-missing-entities-options.exception.ts b/packages/nestjs-otp/src/exceptions/otp-missing-entities-options.exception.ts deleted file mode 100644 index 3d4bea2f4..000000000 --- a/packages/nestjs-otp/src/exceptions/otp-missing-entities-options.exception.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { OtpException } from './otp.exception'; - -export class OtpMissingEntitiesOptionsException extends OtpException { - constructor() { - super({ - message: 'You must provide the entities option', - }); - this.errorCode = 'OTP_MISSING_ENTITIES_OPTION'; - } -} diff --git a/packages/nestjs-otp/src/exceptions/otp-type-not-defined.exception.spec.ts b/packages/nestjs-otp/src/exceptions/otp-type-not-defined.exception.spec.ts deleted file mode 100644 index 5902fa98e..000000000 --- a/packages/nestjs-otp/src/exceptions/otp-type-not-defined.exception.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { OtpTypeNotDefinedException } from './otp-type-not-defined.exception'; - -describe(OtpTypeNotDefinedException.name, () => { - it('should create an instance of OtpTypeNotDefinedException', () => { - const exception = new OtpTypeNotDefinedException('test'); - expect(exception).toBeInstanceOf(OtpTypeNotDefinedException); - }); - - it('should have the correct error code', () => { - const exception = new OtpTypeNotDefinedException('test'); - expect(exception.errorCode).toBe('OTP_TYPE_NOT_DEFINED_ERROR'); - }); - - it('should have the correct context', () => { - const exception = new OtpTypeNotDefinedException('test'); - expect(exception.context).toEqual({ type: 'test' }); - }); - - it('should have the correct message', () => { - const exception = new OtpTypeNotDefinedException('test'); - expect(exception.message).toBe( - 'Type test was not defined to be used. please check config.', - ); - }); -}); diff --git a/packages/nestjs-otp/src/exceptions/otp-type-not-defined.exception.ts b/packages/nestjs-otp/src/exceptions/otp-type-not-defined.exception.ts deleted file mode 100644 index 1e6719402..000000000 --- a/packages/nestjs-otp/src/exceptions/otp-type-not-defined.exception.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { OtpException } from './otp.exception'; - -export class OtpTypeNotDefinedException extends OtpException { - context: RuntimeException['context'] & { - type: string; - }; - - constructor(type: string, options?: RuntimeExceptionOptions) { - super({ - message: 'Type %s was not defined to be used. please check config.', - messageParams: [type], - ...options, - }); - - this.context = { - ...super.context, - type, - }; - - this.errorCode = 'OTP_TYPE_NOT_DEFINED_ERROR'; - } -} diff --git a/packages/nestjs-otp/src/gateways/__tests__/otp-context.overlay.spec.ts b/packages/nestjs-otp/src/gateways/__tests__/otp-context.overlay.spec.ts new file mode 100644 index 000000000..c0697c271 --- /dev/null +++ b/packages/nestjs-otp/src/gateways/__tests__/otp-context.overlay.spec.ts @@ -0,0 +1,62 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { type ExecutionContext } from '@nestjs/common'; +import { type Reflector } from '@nestjs/core'; + +import { getAppContext } from '@concepta/nestjs-core'; + +import { OtpCtx, OtpContextOverlay } from '../otp-context.overlay.js'; + +describe('OtpContextOverlay', () => { + let reflector: DeepMockProxy; + let overlay: OtpContextOverlay; + let mockContext: ExecutionContext; + let mockRequest: Record; + + beforeEach(() => { + reflector = mockDeep(); + + overlay = new OtpContextOverlay(reflector); + + mockRequest = {}; + const handler = vi.fn(); + const target = class TestController {}; + mockContext = { + getHandler: () => handler, + getClass: () => target, + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + } as unknown as ExecutionContext; + }); + + it('should have ref name "withOtp"', () => { + expect(overlay.ref.name).toBe('withOtp'); + }); + + it('should resolve namespace from decorator metadata via attach', () => { + reflector.getAllAndOverride.mockReturnValue({ name: 'userOtp' }); + + overlay.attach(mockContext); + + const ctx = getAppContext(mockRequest); + const result = ctx.with(OtpCtx); + + expect(result).toEqual({ namespace: 'userOtp' }); + expect(reflector.getAllAndOverride).toHaveBeenCalledWith('OTP_NAMESPACE', [ + mockContext.getHandler(), + mockContext.getClass(), + ]); + }); + + it('should return empty namespace when no decorator metadata', () => { + reflector.getAllAndOverride.mockReturnValue(undefined); + + overlay.attach(mockContext); + + const ctx = getAppContext(mockRequest); + const result = ctx.with(OtpCtx); + + expect(result).toEqual({ namespace: '' }); + }); +}); diff --git a/packages/nestjs-otp/src/gateways/decorators/otp-namespace.decorator.ts b/packages/nestjs-otp/src/gateways/decorators/otp-namespace.decorator.ts new file mode 100644 index 000000000..1a79c6e22 --- /dev/null +++ b/packages/nestjs-otp/src/gateways/decorators/otp-namespace.decorator.ts @@ -0,0 +1,10 @@ +import { SetMetadata } from '@nestjs/common'; + +export const OTP_NAMESPACE_KEY = 'OTP_NAMESPACE'; + +export interface OtpNamespaceOptions { + name: string; +} + +export const OtpNamespace = (options: OtpNamespaceOptions) => + SetMetadata(OTP_NAMESPACE_KEY, options); diff --git a/packages/nestjs-otp/src/gateways/interfaces/otp-context.interface.ts b/packages/nestjs-otp/src/gateways/interfaces/otp-context.interface.ts new file mode 100644 index 000000000..17bb10487 --- /dev/null +++ b/packages/nestjs-otp/src/gateways/interfaces/otp-context.interface.ts @@ -0,0 +1,3 @@ +export interface OtpContextInterface { + namespace: string; +} diff --git a/packages/nestjs-otp/src/gateways/otp-context.overlay.ts b/packages/nestjs-otp/src/gateways/otp-context.overlay.ts new file mode 100644 index 000000000..8074278f8 --- /dev/null +++ b/packages/nestjs-otp/src/gateways/otp-context.overlay.ts @@ -0,0 +1,40 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { + ContextOverlayInterceptor, + getAppContext, + OverlayRef, +} from '@concepta/nestjs-core'; + +import { + OTP_NAMESPACE_KEY, + OtpNamespaceOptions, +} from './decorators/otp-namespace.decorator.js'; +import { OtpContextInterface } from './interfaces/otp-context.interface.js'; + +export const OtpCtx = new OverlayRef<'withOtp', OtpContextInterface>('withOtp'); + +@Injectable() +export class OtpContextOverlay extends ContextOverlayInterceptor { + readonly ref = OtpCtx; + + constructor(private readonly reflector: Reflector) { + super(); + } + + attach(context: ExecutionContext): void { + const request = context.switchToHttp().getRequest(); + const ctx = getAppContext(request); + const resolved = this.resolve(context); + ctx.defineOverlay(OtpCtx, resolved); + } + + private resolve(context: ExecutionContext): OtpContextInterface { + const options = this.reflector.getAllAndOverride( + OTP_NAMESPACE_KEY, + [context.getHandler(), context.getClass()], + ); + return { namespace: options?.name ?? '' }; + } +} diff --git a/packages/nestjs-otp/src/index.spec.ts b/packages/nestjs-otp/src/index.spec.ts deleted file mode 100644 index c5f956419..000000000 --- a/packages/nestjs-otp/src/index.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { OtpModule, OtpService, OtpCreateDto } from './index'; - -describe('index', () => { - it('should be an instance of Function', () => { - expect(OtpModule).toBeInstanceOf(Function); - }); - - it('should be an instance of Function', () => { - expect(OtpService).toBeInstanceOf(Function); - }); - - it('should be an instance of Function', () => { - expect(OtpCreateDto).toBeInstanceOf(Function); - }); -}); diff --git a/packages/nestjs-otp/src/index.ts b/packages/nestjs-otp/src/index.ts index dd44e7a77..fd59918c9 100644 --- a/packages/nestjs-otp/src/index.ts +++ b/packages/nestjs-otp/src/index.ts @@ -1,19 +1,82 @@ -export { OtpModule } from './otp.module'; -export { OtpService } from './services/otp.service'; +// module +export { OtpModule } from './otp.module.js'; -export { OtpCreateDto } from './dto/otp-create.dto'; +// domain aggregate +export { Otp } from './domain/aggregates/otp.js'; -// interfaces -export { OtpOptionsInterface } from './interfaces/otp-options.interface'; -export { OtpOptionsExtrasInterface } from './interfaces/otp-options-extras.interface'; -export { OtpSettingsInterface } from './interfaces/otp-settings.interface'; -export { OtpServiceInterface } from './interfaces/otp-service.interface'; -export { OtpTypeServiceInterface } from './interfaces/otp-type-service.interface'; +// repositories +export { OtpRepository } from './infrastructure/persistence/otp.repository.js'; +export { OtpRepositoryResolver } from './infrastructure/persistence/otp-repository.resolver.js'; +export { OtpRepositoryInterface } from './domain/repositories/otp-repository.interface.js'; +export { OtpRepositoryResolverInterface } from './domain/repositories/otp-repository-resolver.interface.js'; + +// schemas (Zod / Standard Schema) +export { otpCreateSchema } from './infrastructure/schemas/otp-create.schema.js'; + +// commands +export { ConsumeOtpCommand } from './application/commands/impl/consume-otp.command.js'; +export { CreateOtpCommand } from './application/commands/impl/create-otp.command.js'; +export { RemoveOtpCommand } from './application/commands/impl/remove-otp.command.js'; +export { ClearOtpsCommand } from './application/commands/impl/clear-otps.command.js'; +export { ClearOtpHistoryCommand } from './application/commands/impl/clear-otp-history.command.js'; +export { DeactivateOtpCommand } from './application/commands/impl/deactivate-otp.command.js'; + +// events +export { OtpConsumedEvent } from './domain/events/otp-consumed.event.js'; +export { OtpCreatedEvent } from './domain/events/otp-created.event.js'; +export { OtpDeactivatedEvent } from './domain/events/otp-deactivated.event.js'; + +// queries +export { FindActiveOtpQuery } from './application/queries/impl/find-active-otp.query.js'; +export { FindAssignedOtpsQuery } from './application/queries/impl/find-assigned-otps.query.js'; +export { GetOtpQuery } from './application/queries/impl/get-otp.query.js'; +export { ValidateOtpQuery } from './application/queries/impl/validate-otp.query.js'; + +// command handlers +export { ConsumeOtpHandler } from './application/commands/handlers/consume-otp.handler.js'; +export { CreateOtpHandler } from './application/commands/handlers/create-otp.handler.js'; +export { RemoveOtpHandler } from './application/commands/handlers/remove-otp.handler.js'; +export { ClearOtpsHandler } from './application/commands/handlers/clear-otps.handler.js'; +export { ClearOtpHistoryHandler } from './application/commands/handlers/clear-otp-history.handler.js'; +export { DeactivateOtpHandler } from './application/commands/handlers/deactivate-otp.handler.js'; + +// event listeners +export { OtpHistoryCleanupListener } from './application/listeners/otp-history-cleanup.listener.js'; + +// domain services +export { OtpHistoryCleanupService } from './domain/services/otp-history-cleanup.service.js'; + +// query handlers +export { FindActiveOtpHandler } from './application/queries/handlers/find-active-otp.handler.js'; +export { FindAssignedOtpsHandler } from './application/queries/handlers/find-assigned-otps.handler.js'; +export { GetOtpHandler } from './application/queries/handlers/get-otp.handler.js'; +export { ValidateOtpHandler } from './application/queries/handlers/validate-otp.handler.js'; + +// context overlay +export { OtpContextOverlay, OtpCtx } from './gateways/otp-context.overlay.js'; +export { OtpNamespace } from './gateways/decorators/otp-namespace.decorator.js'; + +// domain interfaces +export { OtpInterface } from './domain/interfaces/otp.interface.js'; +export { OtpCreatableInterface } from './domain/interfaces/otp-creatable.interface.js'; +export { OtpTypeServiceInterface } from './domain/interfaces/otp-type-service.interface.js'; + +// domain policies +export { OtpPolicy, OtpPolicySettings } from './domain/policies/otp.policy.js'; + +// persistence interfaces +export { OtpEntityInterface } from './infrastructure/persistence/interfaces/otp-entity.interface.js'; + +// config interfaces +export { OtpExtrasInterface } from './infrastructure/config/interfaces/otp-extras.interface.js'; +export { OtpOptionsInterface } from './infrastructure/config/interfaces/otp-options.interface.js'; +export { OtpSettingsInterface } from './infrastructure/config/interfaces/otp-settings.interface.js'; // exceptions -export { OtpException } from './exceptions/otp.exception'; -export { OtpEntityNotFoundException } from './exceptions/otp-entity-not-found.exception'; -export { OtpTypeNotDefinedException } from './exceptions/otp-type-not-defined.exception'; -export { OtpMissingEntitiesOptionsException } from './exceptions/otp-missing-entities-options.exception'; -export { OtpLimitReachedException } from './exceptions/otp-limit-reached.exception'; -export { OtpEntitiesOptionsInterface } from './interfaces/otp-entities-options.interface'; +export { OtpException } from './domain/exceptions/otp.exception.js'; +export { OtpEntityNotFoundException } from './infrastructure/exceptions/otp-entity-not-found.exception.js'; +export { OtpTypeNotDefinedException } from './domain/exceptions/otp-type-not-defined.exception.js'; +export { OtpLimitReachedException } from './domain/exceptions/otp-limit-reached.exception.js'; +export { OtpNotFoundException } from './application/exceptions/otp-not-found.exception.js'; +export { OtpInvalidExpirationDateException } from './domain/exceptions/otp-invalid-expiration-date.exception.js'; +export { OtpValidationException } from './domain/exceptions/otp-validation.exception.js'; diff --git a/packages/nestjs-otp/src/infrastructure/config/interfaces/otp-extras.interface.ts b/packages/nestjs-otp/src/infrastructure/config/interfaces/otp-extras.interface.ts new file mode 100644 index 000000000..62b56107a --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/config/interfaces/otp-extras.interface.ts @@ -0,0 +1,10 @@ +import { type DynamicModule, type Provider, type Type } from '@nestjs/common'; + +import { type OtpRepositoryInterface } from '../../../domain/repositories/otp-repository.interface.js'; + +export interface OtpExtrasInterface extends Pick { + providers?: Provider[]; + repositories?: { + otp?: Type; + }; +} diff --git a/packages/nestjs-otp/src/infrastructure/config/interfaces/otp-options.interface.ts b/packages/nestjs-otp/src/infrastructure/config/interfaces/otp-options.interface.ts new file mode 100644 index 000000000..8ae54e2de --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/config/interfaces/otp-options.interface.ts @@ -0,0 +1,5 @@ +import { type OtpSettingsInterface } from './otp-settings.interface.js'; + +export interface OtpOptionsInterface { + settings?: OtpSettingsInterface; +} diff --git a/packages/nestjs-otp/src/infrastructure/config/interfaces/otp-settings.interface.ts b/packages/nestjs-otp/src/infrastructure/config/interfaces/otp-settings.interface.ts new file mode 100644 index 000000000..99a76631f --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/config/interfaces/otp-settings.interface.ts @@ -0,0 +1,42 @@ +import { type OtpTypeServiceInterface } from '../../../domain/interfaces/otp-type-service.interface.js'; + +export interface OtpSettingsInterface { + types: { [key: string]: OtpTypeServiceInterface }; + + /** + * Strategy for handling duplicate OTPs for the same assignee and category. + * + * Options: + * - 'ALLOW': Allow multiple active OTPs to exist simultaneously. + * - 'DEACTIVATE': Automatically deactivate any existing active OTPs before + * creating a new one. + * + * If undefined, the default behavior is 'DEACTIVATE', meaning only one active + * OTP will be allowed per assignee and category. + * + * This helps prevent confusion and potential security issues from having multiple + * valid OTPs at the same time. + */ + duplicateStrategy: 'ALLOW' | 'DEACTIVATE'; + + /** + * Number of days to retain OTP history. When set, OTPs will be marked inactive + * instead of deleted. + * + * If undefined, OTPs will be permanently deleted rather than retained. + */ + keepHistoryDays?: number; + + /** + * The minimum number of seconds that must pass between OTP generation requests. + * This helps prevent abuse by rate limiting how frequently new OTPs can be created. + */ + rateSeconds?: number; + + /** + * How many attempts before the user is blocked within the rateSeconds time window. + * For example, if rateSeconds is 60 and rateThreshold is 3, the user will be blocked + * after 3 failed attempts within 60 seconds. + */ + rateThreshold?: number; +} diff --git a/packages/nestjs-otp/src/infrastructure/config/otp-default.config.ts b/packages/nestjs-otp/src/infrastructure/config/otp-default.config.ts new file mode 100644 index 000000000..5ffc45132 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/config/otp-default.config.ts @@ -0,0 +1,49 @@ +import { registerAs } from '@nestjs/config'; + +import { OTP_MODULE_DEFAULT_SETTINGS_TOKEN } from '../../otp.constants.js'; +import { uuidGeneratorUtil } from '../utils/uuid-generator.util.js'; +import { uuidValidatorUtil } from '../utils/uuid-validator.util.js'; + +import { type OtpSettingsInterface } from './interfaces/otp-settings.interface.js'; + +/** + * Default configuration for Otp module. + */ +export const otpDefaultConfig = registerAs( + OTP_MODULE_DEFAULT_SETTINGS_TOKEN, + (): OtpSettingsInterface => ({ + types: { + uuid: { + generator: uuidGeneratorUtil, + validator: uuidValidatorUtil, + }, + }, + duplicateStrategy: parseDuplicateStrategy( + process.env.OTP_DUPLICATE_STRATEGY ?? 'DEACTIVATE', + ), + keepHistoryDays: process.env.OTP_KEEP_HISTORY_DAYS + ? Number.parseInt(process.env.OTP_KEEP_HISTORY_DAYS, 10) + : undefined, + rateSeconds: process.env.OTP_RATE_SECONDS + ? Number.parseInt(process.env.OTP_RATE_SECONDS, 10) + : undefined, + rateThreshold: process.env.OTP_RATE_THRESHOLD + ? Number.parseInt(process.env.OTP_RATE_THRESHOLD, 10) + : undefined, + }), +); + +function parseDuplicateStrategy(value: unknown): 'DEACTIVATE' | 'ALLOW' { + const upperValue = String(value).toUpperCase(); + + switch (upperValue) { + case 'DEACTIVATE': + return 'DEACTIVATE'; + case 'ALLOW': + return 'ALLOW'; + } + + throw new Error( + `Invalid OTP_DUPLICATE_STRATEGY value: ${value}. Must be 'DEACTIVATE' or 'ALLOW'.`, + ); +} diff --git a/packages/nestjs-otp/src/infrastructure/exceptions/otp-entity-not-found.exception.ts b/packages/nestjs-otp/src/infrastructure/exceptions/otp-entity-not-found.exception.ts new file mode 100644 index 000000000..8cf7d9be2 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/exceptions/otp-entity-not-found.exception.ts @@ -0,0 +1,28 @@ +import { + type RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +import { OtpException } from '../../domain/exceptions/otp.exception.js'; + +export class OtpEntityNotFoundException extends OtpException { + declare context: RuntimeException['context'] & { + entityName: string; + }; + + constructor(entityName: string, options?: RuntimeExceptionOptions) { + super({ + message: 'Entity %s was not registered to be used.', + messageParams: [entityName], + fault: 'usage', + ...options, + }); + + this.context = { + ...this.context, + entityName, + }; + + this.errorCode = 'OTP_ENTITY_NOT_FOUND_ERROR'; + } +} diff --git a/packages/nestjs-otp/src/infrastructure/persistence/__tests__/otp-repository.resolver.spec.ts b/packages/nestjs-otp/src/infrastructure/persistence/__tests__/otp-repository.resolver.spec.ts new file mode 100644 index 000000000..305d2a6c7 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/persistence/__tests__/otp-repository.resolver.spec.ts @@ -0,0 +1,39 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { type ModuleRef } from '@nestjs/core'; + +import { OtpEntityNotFoundException } from '../../exceptions/otp-entity-not-found.exception.js'; +import { OtpRepositoryResolver } from '../otp-repository.resolver.js'; +import { type OtpRepository } from '../otp.repository.js'; + +describe(OtpRepositoryResolver.name, () => { + let resolver: OtpRepositoryResolver; + let mockModuleRef: DeepMockProxy; + const mockRepo = {} as OtpRepository; + + beforeEach(() => { + mockModuleRef = mockDeep(); + resolver = new OtpRepositoryResolver(mockModuleRef); + }); + + it('should resolve a repository by entity key', () => { + mockModuleRef.get.mockReturnValue(mockRepo); + + const result = resolver.resolve('userOtp'); + + expect(result).toBe(mockRepo); + expect(mockModuleRef.get).toHaveBeenCalledWith('OTP_REPOSITORY_USEROTP', { + strict: false, + }); + }); + + it('should throw OtpEntityNotFoundException when entity is not registered', () => { + mockModuleRef.get.mockImplementation(() => { + throw new Error('not found'); + }); + + expect(() => resolver.resolve('unknown')).toThrow( + OtpEntityNotFoundException, + ); + }); +}); diff --git a/packages/nestjs-otp/src/infrastructure/persistence/__tests__/otp.repository.spec.ts b/packages/nestjs-otp/src/infrastructure/persistence/__tests__/otp.repository.spec.ts new file mode 100644 index 000000000..93d1905af --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/persistence/__tests__/otp.repository.spec.ts @@ -0,0 +1,302 @@ +import { Where } from '@concepta/nestjs-repository'; +import { createMockRepository } from '@concepta/nestjs-repository/testing'; + +import { + createMockOtpEntity, + toOtpDomain, +} from '../../../__tests__/helpers/mock.helpers.js'; +import { Otp } from '../../../domain/aggregates/otp.js'; +import { type OtpEntityInterface } from '../interfaces/otp-entity.interface.js'; +import { OtpMapper } from '../otp.mapper.js'; +import { OtpRepository } from '../otp.repository.js'; + +const mapper = new OtpMapper(); + +describe(OtpRepository.name, () => { + let repo: OtpRepository; + let mockRepository: ReturnType< + typeof createMockRepository + >; + + const w = Where.for(); + const entity = createMockOtpEntity(); + const ctx = {}; + + beforeEach(() => { + mockRepository = createMockRepository(); + repo = new OtpRepository(mockRepository, new OtpMapper()); + }); + + describe('get', () => { + it('should query by id and return an Otp', async () => { + mockRepository.findOne.mockResolvedValue(entity); + + const result = await repo.get(ctx, 'test-id'); + + expect(result).toBeInstanceOf(Otp); + expect(result!.id).toBe('test-id'); + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: w.eq('id', 'test-id'), + ctx, + }); + }); + + it('should return null when entity is not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + const result = await repo.get(ctx, 'missing'); + + expect(result).toBeNull(); + }); + + it('should pass ctx to repository', async () => { + mockRepository.findOne.mockResolvedValue(entity); + const specificCtx = {}; + + await repo.get(specificCtx, 'test-id'); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: w.eq('id', 'test-id'), + ctx: specificCtx, + }); + }); + }); + + describe('findActiveByPasscode', () => { + it('should query by category, passcode, and active=true', async () => { + mockRepository.findOne.mockResolvedValue(entity); + + const result = await repo.findActiveByPasscode(ctx, { + category: 'auth', + passcode: 'abc', + }); + + expect(result).toBeInstanceOf(Otp); + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: w.and( + w.eq('category', 'auth'), + w.eq('passcode', 'abc'), + w.eq('active', true), + ), + ctx, + }); + }); + + it('should return null when no match', async () => { + mockRepository.findOne.mockResolvedValue(null); + + const result = await repo.findActiveByPasscode(ctx, { + category: 'auth', + passcode: 'missing', + }); + + expect(result).toBeNull(); + }); + }); + + describe('findByPasscode', () => { + it('should query by category and passcode', async () => { + mockRepository.findOne.mockResolvedValue(entity); + + const result = await repo.findByPasscode(ctx, { + category: 'auth', + passcode: 'abc', + }); + + expect(result).toBeInstanceOf(Otp); + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: w.and(w.eq('category', 'auth'), w.eq('passcode', 'abc')), + ctx, + }); + }); + + it('should return null when not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + const result = await repo.findByPasscode(ctx, { + category: 'auth', + passcode: 'missing', + }); + + expect(result).toBeNull(); + }); + }); + + describe('findActiveByAssignee', () => { + it('should query by assigneeId, category, and active=true', async () => { + mockRepository.findOne.mockResolvedValue(entity); + + const result = await repo.findActiveByAssignee(ctx, { + assigneeId: 'user-1', + category: 'auth', + }); + + expect(result).toBeInstanceOf(Otp); + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: w.and( + w.eq('assigneeId', 'user-1'), + w.eq('category', 'auth'), + w.eq('active', true), + ), + ctx, + }); + }); + + it('should return null when no active OTP exists', async () => { + mockRepository.findOne.mockResolvedValue(null); + + const result = await repo.findActiveByAssignee(ctx, { + assigneeId: 'user-1', + category: 'auth', + }); + + expect(result).toBeNull(); + }); + }); + + describe('findAllByAssigneeAndCategory', () => { + it('should query by assigneeId and category', async () => { + mockRepository.find.mockResolvedValue([entity, entity]); + + const result = await repo.findAllByAssigneeAndCategory(ctx, { + assigneeId: 'user-1', + category: 'auth', + }); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(Otp); + expect(mockRepository.find).toHaveBeenCalledWith({ + where: w.and(w.eq('assigneeId', 'user-1'), w.eq('category', 'auth')), + ctx, + }); + }); + + it('should return empty array when none found', async () => { + mockRepository.find.mockResolvedValue([]); + + const result = await repo.findAllByAssigneeAndCategory(ctx, { + assigneeId: 'user-1', + category: 'auth', + }); + + expect(result).toHaveLength(0); + }); + }); + + describe('countCreatedSince', () => { + it('should count by assigneeId, category, and dateCreated >= cutoff', async () => { + mockRepository.count.mockResolvedValue(5); + const cutoffDate = new Date('2026-01-01'); + + const result = await repo.countCreatedSince(ctx, { + assigneeId: 'user-1', + category: 'auth', + cutoffDate, + }); + + expect(result).toBe(5); + expect(mockRepository.count).toHaveBeenCalledWith({ + where: w.and( + w.eq('assigneeId', 'user-1'), + w.eq('category', 'auth'), + w.gte('dateCreated', cutoffDate), + ), + ctx, + }); + }); + + it('should pass ctx to repository', async () => { + mockRepository.count.mockResolvedValue(0); + const specificCtx = {}; + + await repo.countCreatedSince(specificCtx, { + assigneeId: 'user-1', + category: 'auth', + cutoffDate: new Date(), + }); + + expect(mockRepository.count).toHaveBeenCalledWith( + expect.objectContaining({ ctx: specificCtx }), + ); + }); + }); + + describe('findOlderThan', () => { + it('should query by assigneeId, category, and dateCreated <= cutoff', async () => { + mockRepository.find.mockResolvedValue([entity]); + const cutoffDate = new Date('2026-01-01'); + + const result = await repo.findOlderThan(ctx, { + assigneeId: 'user-1', + category: 'auth', + cutoffDate, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(Otp); + expect(mockRepository.find).toHaveBeenCalledWith({ + where: w.and( + w.eq('assigneeId', 'user-1'), + w.eq('category', 'auth'), + w.lte('dateCreated', cutoffDate), + ), + ctx, + }); + }); + }); + + describe('save', () => { + it('should stamp and upsert the plain entity', async () => { + mockRepository.upsert.mockResolvedValue(entity); + + const otp = toOtpDomain(entity); + const stampSpy = vi.spyOn(otp, 'stampUpdated'); + + await repo.save(ctx, otp); + + expect(stampSpy).toHaveBeenCalledTimes(1); + expect(mockRepository.upsert).toHaveBeenCalledWith( + mapper.toPersistence(otp), + { ctx }, + ); + }); + }); + + describe('remove', () => { + it('should delete the plain entity', async () => { + mockRepository.delete.mockResolvedValue(entity); + + const otp = toOtpDomain(entity); + await repo.remove(ctx, otp); + + expect(mockRepository.delete).toHaveBeenCalledWith( + mapper.toPersistence(otp), + { ctx }, + ); + }); + }); + + describe('removeAll', () => { + it('should delete all OTPs in a single batch', async () => { + mockRepository.deleteMany.mockResolvedValue([entity, entity]); + + const otp1 = toOtpDomain(entity); + const otp2 = toOtpDomain({ ...entity, id: 'otp-2' }); + + await repo.removeAll(ctx, [otp1, otp2]); + + expect(mockRepository.deleteMany).toHaveBeenCalledWith( + [mapper.toPersistence(otp1), mapper.toPersistence(otp2)], + { ctx }, + ); + }); + + it('should handle empty array', async () => { + mockRepository.deleteMany.mockResolvedValue([]); + + await repo.removeAll(ctx, []); + + expect(mockRepository.deleteMany).toHaveBeenCalledWith([], { ctx }); + }); + }); +}); diff --git a/packages/nestjs-otp/src/infrastructure/persistence/interfaces/otp-entity.interface.ts b/packages/nestjs-otp/src/infrastructure/persistence/interfaces/otp-entity.interface.ts new file mode 100644 index 000000000..416d7caf7 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/persistence/interfaces/otp-entity.interface.ts @@ -0,0 +1,14 @@ +import { + type AuditInterface, + type ReferenceIdInterface, + type ReferenceVersionInterface, +} from '@concepta/nestjs-core'; + +import { type OtpInterface } from '../../../domain/interfaces/otp.interface.js'; + +export interface OtpEntityInterface + extends + ReferenceIdInterface, + ReferenceVersionInterface, + OtpInterface, + AuditInterface {} diff --git a/packages/nestjs-otp/src/infrastructure/persistence/otp-repository.resolver.ts b/packages/nestjs-otp/src/infrastructure/persistence/otp-repository.resolver.ts new file mode 100644 index 000000000..fc91e2f27 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/persistence/otp-repository.resolver.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; + +import { OtpRepositoryResolverInterface } from '../../domain/repositories/otp-repository-resolver.interface.js'; +import { OtpRepositoryInterface } from '../../domain/repositories/otp-repository.interface.js'; +import { OtpEntityNotFoundException } from '../exceptions/otp-entity-not-found.exception.js'; +import { getDynamicOtpRepositoryToken } from '../utils/create-otp-repository-provider.js'; + +@Injectable() +export class OtpRepositoryResolver implements OtpRepositoryResolverInterface { + constructor(private readonly moduleRef: ModuleRef) {} + + resolve(entityKey: string): OtpRepositoryInterface { + const token = getDynamicOtpRepositoryToken(entityKey); + + try { + return this.moduleRef.get(token, { + strict: false, + }); + } catch (error) { + throw new OtpEntityNotFoundException(entityKey, { + originalError: error, + }); + } + } +} diff --git a/packages/nestjs-otp/src/infrastructure/persistence/otp.factory.ts b/packages/nestjs-otp/src/infrastructure/persistence/otp.factory.ts new file mode 100644 index 000000000..ab71b7bc8 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/persistence/otp.factory.ts @@ -0,0 +1,28 @@ +import { randomUUID } from 'crypto'; + +import { faker } from '@faker-js/faker'; + +import { Factory } from '@concepta/typeorm-seeding'; + +import { type OtpInterface } from '../../domain/interfaces/otp.interface.js'; + +/** + * Otp factory + */ +export class OtpFactory extends Factory { + /** + * List of used categories. + */ + categories: string[] = ['one', 'two', 'three']; + + /** + * Factory callback function. + */ + protected async entity(otp: OtpInterface): Promise { + otp.category = faker.helpers.arrayElement(this.categories); + otp.type = 'uuid'; + otp.passcode = randomUUID(); + + return otp; + } +} diff --git a/packages/nestjs-otp/src/infrastructure/persistence/otp.mapper.ts b/packages/nestjs-otp/src/infrastructure/persistence/otp.mapper.ts new file mode 100644 index 000000000..0aa9b63d1 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/persistence/otp.mapper.ts @@ -0,0 +1,23 @@ +import { DomainMapper } from '@concepta/nestjs-core/aggregate'; + +import { Otp } from '../../domain/aggregates/otp.js'; +import { type OtpInterface } from '../../domain/interfaces/otp.interface.js'; + +import { type OtpEntityInterface } from './interfaces/otp-entity.interface.js'; + +export class OtpMapper extends DomainMapper< + OtpEntityInterface, + OtpInterface, + Otp +> { + createAggregate(entity: OtpEntityInterface): Otp { + const { id, version, dateCreated, dateUpdated, dateDeleted, ...props } = + entity; + + return new Otp(id, props, version, { + dateCreated, + dateUpdated, + dateDeleted, + }); + } +} diff --git a/packages/nestjs-otp/src/infrastructure/persistence/otp.repository.ts b/packages/nestjs-otp/src/infrastructure/persistence/otp.repository.ts new file mode 100644 index 000000000..ac2d2c5c0 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/persistence/otp.repository.ts @@ -0,0 +1,148 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; +import { type RepositoryInterface, Where } from '@concepta/nestjs-repository'; + +import { type Otp } from '../../domain/aggregates/otp.js'; +import { type OtpRepositoryInterface } from '../../domain/repositories/otp-repository.interface.js'; + +import { type OtpEntityInterface } from './interfaces/otp-entity.interface.js'; +import { type OtpMapper } from './otp.mapper.js'; + +export class OtpRepository implements OtpRepositoryInterface { + constructor( + protected readonly repository: RepositoryInterface, + private readonly mapper: OtpMapper, + ) {} + + async get(ctx: PlainLiteralObject, id: ReferenceId): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.eq('id', id), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findActiveByPasscode( + ctx: PlainLiteralObject, + options: { category: string; passcode: string }, + ): Promise { + const { category, passcode } = options; + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.and( + w.eq('category', category), + w.eq('passcode', passcode), + w.eq('active', true), + ), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByPasscode( + ctx: PlainLiteralObject, + options: { category: string; passcode: string }, + ): Promise { + const { category, passcode } = options; + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.and(w.eq('category', category), w.eq('passcode', passcode)), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findActiveByAssignee( + ctx: PlainLiteralObject, + options: { assigneeId: string; category: string }, + ): Promise { + const { assigneeId, category } = options; + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.and( + w.eq('assigneeId', assigneeId), + w.eq('category', category), + w.eq('active', true), + ), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findAllByAssigneeAndCategory( + ctx: PlainLiteralObject, + options: { assigneeId: string; category: string }, + ): Promise { + const { assigneeId, category } = options; + const w = Where.for(); + + const entities = await this.repository.find({ + where: w.and(w.eq('assigneeId', assigneeId), w.eq('category', category)), + ctx, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async countCreatedSince( + ctx: PlainLiteralObject, + options: { assigneeId: string; category: string; cutoffDate: Date }, + ): Promise { + const { assigneeId, category, cutoffDate } = options; + const w = Where.for(); + + return this.repository.count({ + where: w.and( + w.eq('assigneeId', assigneeId), + w.eq('category', category), + w.gte('dateCreated', cutoffDate), + ), + ctx, + }); + } + + async findOlderThan( + ctx: PlainLiteralObject, + options: { assigneeId: string; category: string; cutoffDate: Date }, + ): Promise { + const { assigneeId, category, cutoffDate } = options; + const w = Where.for(); + + const entities = await this.repository.find({ + where: w.and( + w.eq('assigneeId', assigneeId), + w.eq('category', category), + w.lte('dateCreated', cutoffDate), + ), + ctx, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async save(ctx: PlainLiteralObject, otp: Otp): Promise { + otp.stampUpdated(); + await this.repository.upsert(this.mapper.toPersistence(otp), { ctx }); + } + + async remove(ctx: PlainLiteralObject, otp: Otp): Promise { + await this.repository.delete(this.mapper.toPersistence(otp), { ctx }); + } + + async removeAll(ctx: PlainLiteralObject, otps: Otp[]): Promise { + await this.repository.deleteMany( + otps.map((otp) => this.mapper.toPersistence(otp)), + { ctx }, + ); + } +} diff --git a/packages/nestjs-otp/src/infrastructure/persistence/typeorm/otp-postgres.entity.ts b/packages/nestjs-otp/src/infrastructure/persistence/typeorm/otp-postgres.entity.ts new file mode 100644 index 000000000..323d17eb2 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/persistence/typeorm/otp-postgres.entity.ts @@ -0,0 +1,32 @@ +import { Column } from 'typeorm'; + +import { ReferenceId } from '@concepta/nestjs-core'; +import { CommonPostgresEntity } from '@concepta/nestjs-repository-typeorm'; + +import { OtpInterface } from '../../../domain/interfaces/otp.interface.js'; + +/** + * Otp Postgres Entity + */ +export abstract class OtpPostgresEntity + extends CommonPostgresEntity + implements OtpInterface +{ + @Column() + category!: string; + + @Column({ nullable: true }) + type!: string; + + @Column() + passcode!: string; + + @Column({ type: 'timestamptz' }) + expirationDate!: Date; + + @Column({ default: true }) + active!: boolean; + + @Column({ type: 'uuid' }) + assigneeId!: ReferenceId; +} diff --git a/packages/nestjs-otp/src/infrastructure/persistence/typeorm/otp-sqlite.entity.ts b/packages/nestjs-otp/src/infrastructure/persistence/typeorm/otp-sqlite.entity.ts new file mode 100644 index 000000000..7d7031a80 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/persistence/typeorm/otp-sqlite.entity.ts @@ -0,0 +1,32 @@ +import { Column } from 'typeorm'; + +import { ReferenceId } from '@concepta/nestjs-core'; +import { CommonSqliteEntity } from '@concepta/nestjs-repository-typeorm'; + +import { OtpInterface } from '../../../domain/interfaces/otp.interface.js'; + +/** + * Otp Sqlite Entity + */ +export abstract class OtpSqliteEntity + extends CommonSqliteEntity + implements OtpInterface +{ + @Column() + category!: string; + + @Column({ nullable: true }) + type!: string; + + @Column() + passcode!: string; + + @Column({ type: 'datetime' }) + expirationDate!: Date; + + @Column({ default: true }) + active!: boolean; + + @Column({ type: 'uuid' }) + assigneeId!: ReferenceId; +} diff --git a/packages/nestjs-otp/src/infrastructure/schemas/otp-create.schema.spec.ts b/packages/nestjs-otp/src/infrastructure/schemas/otp-create.schema.spec.ts new file mode 100644 index 000000000..7623ea782 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/schemas/otp-create.schema.spec.ts @@ -0,0 +1,50 @@ +import { otpCreateSchema } from './otp-create.schema.js'; + +const validCreate = { + category: 'test-category', + type: 'uuid', + expiresIn: '1h', + assigneeId: 'test-assignee', +}; + +describe('otpCreateSchema', () => { + it('accepts a valid create payload', () => { + expect(otpCreateSchema.parse(validCreate)).toEqual(validCreate); + }); + + it('accepts rateSeconds/rateThreshold when provided', () => { + const payload = { ...validCreate, rateSeconds: 60, rateThreshold: 3 }; + expect(otpCreateSchema.parse(payload)).toEqual(payload); + }); + + it('accepts rateSeconds/rateThreshold omitted', () => { + expect(otpCreateSchema.parse(validCreate)).toEqual(validCreate); + }); + + it('rejects a negative rateSeconds', () => { + expect( + otpCreateSchema.safeParse({ ...validCreate, rateSeconds: -1 }).success, + ).toBe(false); + }); + + it('rejects a rateThreshold below 1', () => { + expect( + otpCreateSchema.safeParse({ ...validCreate, rateThreshold: 0 }).success, + ).toBe(false); + }); + + it('rejects a missing expiresIn', () => { + const { expiresIn: _expiresIn, ...rest } = validCreate; + expect(otpCreateSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects a missing assigneeId', () => { + const { assigneeId: _assigneeId, ...rest } = validCreate; + expect(otpCreateSchema.safeParse(rest).success).toBe(false); + }); + + it('strips unknown keys', () => { + const result = otpCreateSchema.parse({ ...validCreate, _internal: 'x' }); + expect(result).not.toHaveProperty('_internal'); + }); +}); diff --git a/packages/nestjs-otp/src/infrastructure/schemas/otp-create.schema.ts b/packages/nestjs-otp/src/infrastructure/schemas/otp-create.schema.ts new file mode 100644 index 000000000..0b4a696fc --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/schemas/otp-create.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { conformsTo } from '@concepta/nestjs-core'; + +import { type OtpCreatableInterface } from '../../domain/interfaces/otp-creatable.interface.js'; + +/** + * `nestjs-otp` has no HTTP/swagger surface of its own (pure CQRS module), + * so this schema is not wrapped with `withOpenApi`/`withNamedComponent` — + * it's only ever consumed programmatically via `validateOtpSchema`. + */ +export const otpCreateSchema = conformsTo()( + z.object({ + category: z.string(), + type: z.string(), + expiresIn: z.string(), + rateSeconds: z.number().int().min(0).optional(), + rateThreshold: z.number().int().min(1).optional(), + assigneeId: z.string(), + }), +); diff --git a/packages/nestjs-otp/src/infrastructure/utils/__tests__/create-otp-repository-provider.spec.ts b/packages/nestjs-otp/src/infrastructure/utils/__tests__/create-otp-repository-provider.spec.ts new file mode 100644 index 000000000..b24cf897f --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/utils/__tests__/create-otp-repository-provider.spec.ts @@ -0,0 +1,39 @@ +import { OtpMapper } from '../../persistence/otp.mapper.js'; +import { OtpRepository } from '../../persistence/otp.repository.js'; +import { + createOtpRepositoryProvider, + getDynamicOtpRepositoryToken, +} from '../create-otp-repository-provider.js'; + +describe('getDynamicOtpRepositoryToken', () => { + it('should return an uppercased token with OTP_REPOSITORY_ prefix', () => { + expect(getDynamicOtpRepositoryToken('userOtp')).toBe( + 'OTP_REPOSITORY_USEROTP', + ); + }); +}); + +describe('createOtpRepositoryProvider', () => { + it('should return a provider with the correct token', () => { + const provider = createOtpRepositoryProvider('userOtp'); + + expect(provider).toEqual( + expect.objectContaining({ + provide: 'OTP_REPOSITORY_USEROTP', + }), + ); + }); + + it('should have a useFactory that returns an OtpRepository', () => { + const provider = createOtpRepositoryProvider('userOtp'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const factory = (provider as { useFactory: Function }).useFactory; + + const mockRepository = {} as never; + const mockMapper = new OtpMapper(); + + const result = factory(mockRepository, mockMapper); + + expect(result).toBeInstanceOf(OtpRepository); + }); +}); diff --git a/packages/nestjs-otp/src/infrastructure/utils/__tests__/uuid-generator.util.spec.ts b/packages/nestjs-otp/src/infrastructure/utils/__tests__/uuid-generator.util.spec.ts new file mode 100644 index 000000000..49e0c95a6 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/utils/__tests__/uuid-generator.util.spec.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +import { uuidGeneratorUtil } from '../uuid-generator.util.js'; + +describe(uuidGeneratorUtil.name, () => { + it('should return a valid UUID', () => { + const result = uuidGeneratorUtil(); + + expect(z.uuid({ version: 'v4' }).safeParse(result).success).toBe(true); + }); + + it('should return a unique value on each call', () => { + const a = uuidGeneratorUtil(); + const b = uuidGeneratorUtil(); + + expect(a).not.toBe(b); + }); +}); diff --git a/packages/nestjs-otp/src/infrastructure/utils/__tests__/uuid-validator.util.spec.ts b/packages/nestjs-otp/src/infrastructure/utils/__tests__/uuid-validator.util.spec.ts new file mode 100644 index 000000000..87296482b --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/utils/__tests__/uuid-validator.util.spec.ts @@ -0,0 +1,11 @@ +import { uuidValidatorUtil } from '../uuid-validator.util.js'; + +describe(uuidValidatorUtil.name, () => { + it('should return true when both strings match', () => { + expect(uuidValidatorUtil('abc', 'abc')).toBe(true); + }); + + it('should return false when strings differ', () => { + expect(uuidValidatorUtil('abc', 'xyz')).toBe(false); + }); +}); diff --git a/packages/nestjs-otp/src/infrastructure/utils/create-otp-policy-provider.ts b/packages/nestjs-otp/src/infrastructure/utils/create-otp-policy-provider.ts new file mode 100644 index 000000000..37957b91e --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/utils/create-otp-policy-provider.ts @@ -0,0 +1,13 @@ +import { type Provider } from '@nestjs/common'; + +import { OtpPolicy } from '../../domain/policies/otp.policy.js'; +import { OTP_MODULE_SETTINGS_TOKEN } from '../../otp.constants.js'; +import { type OtpSettingsInterface } from '../config/interfaces/otp-settings.interface.js'; + +export function createOtpPolicyProvider(): Provider { + return { + provide: OtpPolicy, + inject: [OTP_MODULE_SETTINGS_TOKEN], + useFactory: (settings: OtpSettingsInterface) => new OtpPolicy(settings), + }; +} diff --git a/packages/nestjs-otp/src/infrastructure/utils/create-otp-repository-provider.ts b/packages/nestjs-otp/src/infrastructure/utils/create-otp-repository-provider.ts new file mode 100644 index 000000000..7ef467387 --- /dev/null +++ b/packages/nestjs-otp/src/infrastructure/utils/create-otp-repository-provider.ts @@ -0,0 +1,40 @@ +import { type Provider, type Type } from '@nestjs/common'; + +import { + getDynamicRepositoryToken, + type RepositoryInterface, +} from '@concepta/nestjs-repository'; + +import { type OtpRepositoryInterface } from '../../domain/repositories/otp-repository.interface.js'; +import { OTP_CUSTOM_REPOSITORY_TOKEN } from '../../otp.constants.js'; +import { type OtpEntityInterface } from '../persistence/interfaces/otp-entity.interface.js'; +import { OtpMapper } from '../persistence/otp.mapper.js'; +import { OtpRepository } from '../persistence/otp.repository.js'; + +/** + * Generates a dynamic repository token for a given OTP entity key. + * + * @param entityKey - Entity key to generate the repository token for (e.g., 'confirm-email') + */ +export function getDynamicOtpRepositoryToken(entityKey: string): string { + return `OTP_REPOSITORY_${entityKey.toUpperCase()}`; +} + +export function createOtpRepositoryProvider(entityKey: string): Provider { + return { + provide: getDynamicOtpRepositoryToken(entityKey), + inject: [ + getDynamicRepositoryToken(entityKey), + OtpMapper, + { token: OTP_CUSTOM_REPOSITORY_TOKEN, optional: true }, + ], + useFactory: ( + repository: RepositoryInterface, + mapper: OtpMapper, + customRepo?: Type, + ) => { + const RepoClass = customRepo ?? OtpRepository; + return new RepoClass(repository, mapper); + }, + }; +} diff --git a/packages/nestjs-otp/src/utils/uuid-generator.util.ts b/packages/nestjs-otp/src/infrastructure/utils/uuid-generator.util.ts similarity index 100% rename from packages/nestjs-otp/src/utils/uuid-generator.util.ts rename to packages/nestjs-otp/src/infrastructure/utils/uuid-generator.util.ts diff --git a/packages/nestjs-otp/src/utils/uuid-validator.util.ts b/packages/nestjs-otp/src/infrastructure/utils/uuid-validator.util.ts similarity index 100% rename from packages/nestjs-otp/src/utils/uuid-validator.util.ts rename to packages/nestjs-otp/src/infrastructure/utils/uuid-validator.util.ts diff --git a/packages/nestjs-otp/src/interfaces/otp-entities-options.interface.ts b/packages/nestjs-otp/src/interfaces/otp-entities-options.interface.ts deleted file mode 100644 index 4e51194fd..000000000 --- a/packages/nestjs-otp/src/interfaces/otp-entities-options.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { - OtpInterface, - RepositoryEntityOptionInterface, -} from '@concepta/nestjs-common'; - -export interface OtpEntitiesOptionsInterface - extends Record> {} diff --git a/packages/nestjs-otp/src/interfaces/otp-options-extras.interface.ts b/packages/nestjs-otp/src/interfaces/otp-options-extras.interface.ts deleted file mode 100644 index e6d2d7d1c..000000000 --- a/packages/nestjs-otp/src/interfaces/otp-options-extras.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface OtpOptionsExtrasInterface - extends Pick { - /** - * Array of entity keys that will be used to look up repositories - * via getDynamicRepositoryToken() - */ - entities?: string[]; -} diff --git a/packages/nestjs-otp/src/interfaces/otp-options.interface.ts b/packages/nestjs-otp/src/interfaces/otp-options.interface.ts deleted file mode 100644 index 8369320ee..000000000 --- a/packages/nestjs-otp/src/interfaces/otp-options.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { OtpSettingsInterface } from './otp-settings.interface'; - -export interface OtpOptionsInterface { - settings?: OtpSettingsInterface; -} diff --git a/packages/nestjs-otp/src/interfaces/otp-service.interface.ts b/packages/nestjs-otp/src/interfaces/otp-service.interface.ts deleted file mode 100644 index 1f039cbb0..000000000 --- a/packages/nestjs-otp/src/interfaces/otp-service.interface.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { - OtpCreateInterface, - OtpValidateInterface, - OtpDeleteInterface, - OtpClearInterface, -} from '@concepta/nestjs-common'; - -export interface OtpServiceInterface - extends OtpCreateInterface, - OtpValidateInterface, - OtpDeleteInterface, - OtpClearInterface {} diff --git a/packages/nestjs-otp/src/interfaces/otp-settings.interface.ts b/packages/nestjs-otp/src/interfaces/otp-settings.interface.ts deleted file mode 100644 index e6618db19..000000000 --- a/packages/nestjs-otp/src/interfaces/otp-settings.interface.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { LiteralObject } from '@concepta/nestjs-common'; - -import { OtpTypeServiceInterface } from './otp-type-service.interface'; - -export interface OtpSettingsInterface { - types: LiteralObject; - clearOnCreate: boolean; - - /** - * Number of days to retain OTP history. When set, OTPs will be marked inactive instead of deleted. - * If undefined, OTPs will be permanently deleted rather than retained. - */ - keepHistoryDays?: number; - - /** - * The minimum number of seconds that must pass between OTP generation requests. - * This helps prevent abuse by rate limiting how frequently new OTPs can be created. - */ - rateSeconds?: number; - - /** - * How many attempts before the user is blocked within the rateSeconds time window. - * For example, if rateSeconds is 60 and rateThreshold is 3, the user will be blocked - * after 3 failed attempts within 60 seconds. - */ - rateThreshold?: number; -} diff --git a/packages/nestjs-otp/src/interfaces/otp-type-service.interface.ts b/packages/nestjs-otp/src/interfaces/otp-type-service.interface.ts deleted file mode 100644 index 8765835b0..000000000 --- a/packages/nestjs-otp/src/interfaces/otp-type-service.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface OtpTypeServiceInterface { - generator(): string; - validator(a: unknown, b: unknown): boolean; -} diff --git a/packages/nestjs-otp/src/optional-seeding.ts b/packages/nestjs-otp/src/optional-seeding.ts new file mode 100644 index 000000000..6c5bd3112 --- /dev/null +++ b/packages/nestjs-otp/src/optional-seeding.ts @@ -0,0 +1,6 @@ +/** + * These exports allow you to import seeding related classes + * and tools without loading the entire module which + * runs all of its decorators and meta data. + */ +export { OtpFactory } from './infrastructure/persistence/otp.factory.js'; diff --git a/packages/nestjs-otp/src/optional-typeorm.ts b/packages/nestjs-otp/src/optional-typeorm.ts new file mode 100644 index 000000000..f2c918e8f --- /dev/null +++ b/packages/nestjs-otp/src/optional-typeorm.ts @@ -0,0 +1,2 @@ +export { OtpSqliteEntity } from './infrastructure/persistence/typeorm/otp-sqlite.entity.js'; +export { OtpPostgresEntity } from './infrastructure/persistence/typeorm/otp-postgres.entity.js'; diff --git a/packages/nestjs-otp/src/otp-core.module-definition.ts b/packages/nestjs-otp/src/otp-core.module-definition.ts new file mode 100644 index 000000000..8471def99 --- /dev/null +++ b/packages/nestjs-otp/src/otp-core.module-definition.ts @@ -0,0 +1,137 @@ +import { + ConfigurableModuleBuilder, + type DynamicModule, + type Provider, +} from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { createSettingsProvider } from '@concepta/nestjs-core'; + +import { ClearOtpHistoryHandler } from './application/commands/handlers/clear-otp-history.handler.js'; +import { ClearOtpsHandler } from './application/commands/handlers/clear-otps.handler.js'; +import { ConsumeOtpHandler } from './application/commands/handlers/consume-otp.handler.js'; +import { CreateOtpHandler } from './application/commands/handlers/create-otp.handler.js'; +import { DeactivateOtpHandler } from './application/commands/handlers/deactivate-otp.handler.js'; +import { RemoveOtpHandler } from './application/commands/handlers/remove-otp.handler.js'; +import { OtpHistoryCleanupListener } from './application/listeners/otp-history-cleanup.listener.js'; +import { FindActiveOtpHandler } from './application/queries/handlers/find-active-otp.handler.js'; +import { FindAssignedOtpsHandler } from './application/queries/handlers/find-assigned-otps.handler.js'; +import { GetOtpHandler } from './application/queries/handlers/get-otp.handler.js'; +import { ValidateOtpHandler } from './application/queries/handlers/validate-otp.handler.js'; +import { OtpPolicy } from './domain/policies/otp.policy.js'; +import { OtpHistoryCleanupService } from './domain/services/otp-history-cleanup.service.js'; +import { OtpContextOverlay } from './gateways/otp-context.overlay.js'; +import { type OtpExtrasInterface } from './infrastructure/config/interfaces/otp-extras.interface.js'; +import { type OtpOptionsInterface } from './infrastructure/config/interfaces/otp-options.interface.js'; +import { type OtpSettingsInterface } from './infrastructure/config/interfaces/otp-settings.interface.js'; +import { otpDefaultConfig } from './infrastructure/config/otp-default.config.js'; +import { OtpRepositoryResolver } from './infrastructure/persistence/otp-repository.resolver.js'; +import { OtpMapper } from './infrastructure/persistence/otp.mapper.js'; +import { createOtpPolicyProvider } from './infrastructure/utils/create-otp-policy-provider.js'; +import { + OTP_CUSTOM_REPOSITORY_TOKEN, + OTP_MODULE_SETTINGS_TOKEN, + OTP_REPOSITORY_RESOLVER_TOKEN, +} from './otp.constants.js'; + +const RAW_OPTIONS_TOKEN = Symbol('__OTP_MODULE_RAW_OPTIONS_TOKEN__'); + +export const { + ConfigurableModuleClass: OtpCoreModuleClass, + OPTIONS_TYPE: OTP_CORE_OPTIONS_TYPE, + ASYNC_OPTIONS_TYPE: OTP_CORE_ASYNC_OPTIONS_TYPE, +} = new ConfigurableModuleBuilder({ + moduleName: 'OtpCore', + optionsInjectionToken: RAW_OPTIONS_TOKEN, +}) + .setExtras({ global: true }, definitionTransform) + .build(); + +export type OtpCoreOptions = typeof OTP_CORE_OPTIONS_TYPE; +export type OtpCoreAsyncOptions = typeof OTP_CORE_ASYNC_OPTIONS_TYPE; + +function definitionTransform( + definition: DynamicModule, + { global, providers: overrideProviders, repositories }: OtpExtrasInterface, +): DynamicModule { + const { providers = [], imports = [] } = definition; + + return { + ...definition, + global, + imports: createOtpImports({ imports }), + providers: createOtpProviders({ + providers: [...providers, ...(overrideProviders ?? [])], + repositories, + }), + exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createOtpExports()], + }; +} + +export function createOtpImports(options: { + imports: DynamicModule['imports']; +}): DynamicModule['imports'] { + return [ + ...(options.imports || []), + ConfigModule.forFeature(otpDefaultConfig), + CqrsModule.forRoot(), + ]; +} + +export function createOtpProviders(options: { + overrides?: OtpCoreOptions; + providers?: Provider[]; + repositories?: OtpExtrasInterface['repositories']; +}): Provider[] { + return [ + createOtpSettingsProvider(options.overrides), + createOtpPolicyProvider(), + OtpMapper, + { + provide: OTP_CUSTOM_REPOSITORY_TOKEN, + useValue: options.repositories?.otp ?? null, + }, + { + provide: OTP_REPOSITORY_RESOLVER_TOKEN, + useClass: OtpRepositoryResolver, + }, + // Command handlers + ConsumeOtpHandler, + CreateOtpHandler, + RemoveOtpHandler, + ClearOtpsHandler, + ClearOtpHistoryHandler, + DeactivateOtpHandler, + // Event listeners + OtpHistoryCleanupListener, + // Domain services + OtpHistoryCleanupService, + // Query handlers + FindActiveOtpHandler, + FindAssignedOtpsHandler, + GetOtpHandler, + ValidateOtpHandler, + // Context overlays + { provide: APP_INTERCEPTOR, useClass: OtpContextOverlay }, + ...(options.providers ?? []), + ]; +} + +export function createOtpExports(): Required< + Pick +>['exports'] { + return [OTP_MODULE_SETTINGS_TOKEN, OtpPolicy, OtpMapper]; +} + +export function createOtpSettingsProvider( + optionsOverrides?: OtpCoreOptions, +): Provider { + return createSettingsProvider({ + settingsToken: OTP_MODULE_SETTINGS_TOKEN, + optionsToken: RAW_OPTIONS_TOKEN, + settingsKey: otpDefaultConfig.KEY, + optionsOverrides, + }); +} diff --git a/packages/nestjs-otp/src/otp.constants.ts b/packages/nestjs-otp/src/otp.constants.ts index 21e2989b1..66be84a42 100644 --- a/packages/nestjs-otp/src/otp.constants.ts +++ b/packages/nestjs-otp/src/otp.constants.ts @@ -1,4 +1,6 @@ export const OTP_MODULE_SETTINGS_TOKEN = 'OTP_MODULE_SETTINGS_TOKEN'; -export const OTP_MODULE_REPOSITORIES_TOKEN = 'OTP_MODULE_REPOSITORIES_TOKEN'; export const OTP_MODULE_DEFAULT_SETTINGS_TOKEN = 'OTP_MODULE_DEFAULT_SETTINGS_TOKEN'; + +export const OTP_REPOSITORY_RESOLVER_TOKEN = 'OTP_REPOSITORY_RESOLVER_TOKEN'; +export const OTP_CUSTOM_REPOSITORY_TOKEN = 'OTP_CUSTOM_REPOSITORY_TOKEN'; diff --git a/packages/nestjs-otp/src/otp.factory.ts b/packages/nestjs-otp/src/otp.factory.ts deleted file mode 100644 index ef68b5593..000000000 --- a/packages/nestjs-otp/src/otp.factory.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { OtpInterface } from '@concepta/nestjs-common'; -import { Factory } from '@concepta/typeorm-seeding'; - -/** - * Otp factory - */ -export class OtpFactory extends Factory { - /** - * List of used names. - */ - categories: string[] = ['one', 'two', 'three']; - - /** - * Factory callback function. - */ - protected async entity(otp: OtpInterface): Promise { - // set the name - otp.category = this.randomCategory(); - otp.type = 'uuid'; - otp.passcode = randomUUID(); - - // return the new otp - return otp; - } - - /** - * Get a random category. - */ - protected randomCategory(): string { - // random index - const randomIdx = Math.floor(Math.random() * this.categories.length); - - // return it - return this.categories[randomIdx]; - } -} diff --git a/packages/nestjs-otp/src/otp.module-definition.ts b/packages/nestjs-otp/src/otp.module-definition.ts deleted file mode 100644 index 04e27702d..000000000 --- a/packages/nestjs-otp/src/otp.module-definition.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { - ConfigurableModuleBuilder, - DynamicModule, - Provider, -} from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; - -import { - createSettingsProvider, - getDynamicRepositoryToken, -} from '@concepta/nestjs-common'; - -import { otpDefaultConfig } from './config/otp-default.config'; -import { OtpMissingEntitiesOptionsException } from './exceptions/otp-missing-entities-options.exception'; -import { OtpOptionsExtrasInterface } from './interfaces/otp-options-extras.interface'; -import { OtpOptionsInterface } from './interfaces/otp-options.interface'; -import { OtpSettingsInterface } from './interfaces/otp-settings.interface'; -import { - OTP_MODULE_REPOSITORIES_TOKEN, - OTP_MODULE_SETTINGS_TOKEN, -} from './otp.constants'; -import { OtpService } from './services/otp.service'; - -const RAW_OPTIONS_TOKEN = Symbol('__OTP_MODULE_RAW_OPTIONS_TOKEN__'); - -export const { - ConfigurableModuleClass: OtpModuleClass, - OPTIONS_TYPE: OTP_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: OTP_ASYNC_OPTIONS_TYPE, -} = new ConfigurableModuleBuilder({ - moduleName: 'Otp', - optionsInjectionToken: RAW_OPTIONS_TOKEN, -}) - .setExtras({ global: false }, definitionTransform) - .build(); - -export type OtpOptions = Omit; -export type OtpAsyncOptions = Omit; - -function definitionTransform( - definition: DynamicModule, - extras: OtpOptionsExtrasInterface, -): DynamicModule { - const { providers = [], imports = [] } = definition; - const { global = false, entities } = extras; - - if (!entities || entities.length === 0) { - throw new OtpMissingEntitiesOptionsException(); - } - - return { - ...definition, - global, - imports: createOtpImports({ imports }), - providers: createOtpProviders({ entities, providers }), - exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createOtpExports()], - }; -} - -export function createOtpImports(options: { - imports: DynamicModule['imports']; -}): DynamicModule['imports'] { - return [ - ...(options.imports || []), - ConfigModule.forFeature(otpDefaultConfig), - ]; -} - -export function createOtpProviders(options: { - entities: string[]; - overrides?: OtpOptions; - providers?: Provider[]; -}): Provider[] { - return [ - ...(options.providers ?? []), - OtpService, - createOtpSettingsProvider(options.overrides), - createOtpRepositoriesProvider({ - entities: options.entities, - }), - ]; -} - -export function createOtpExports(): Required< - Pick ->['exports'] { - return [OTP_MODULE_SETTINGS_TOKEN, OTP_MODULE_REPOSITORIES_TOKEN, OtpService]; -} - -export function createOtpSettingsProvider( - optionsOverrides?: OtpOptions, -): Provider { - return createSettingsProvider({ - settingsToken: OTP_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: otpDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createOtpRepositoriesProvider(options: { - entities: string[]; -}): Provider { - const { entities } = options; - - const reposToInject = []; - const keyTracker: Record = {}; - - let entityIdx = 0; - - for (const entityKey of entities) { - reposToInject[entityIdx] = getDynamicRepositoryToken(entityKey); - keyTracker[entityKey] = entityIdx++; - } - - return { - provide: OTP_MODULE_REPOSITORIES_TOKEN, - useFactory: (...args: string[]) => { - const repoInstances: Record = {}; - - for (const entityKey of entities) { - repoInstances[entityKey] = args[keyTracker[entityKey]]; - } - - return repoInstances; - }, - inject: reposToInject, - }; -} diff --git a/packages/nestjs-otp/src/otp.module.spec.ts b/packages/nestjs-otp/src/otp.module.spec.ts deleted file mode 100644 index 4fdfe7ff1..000000000 --- a/packages/nestjs-otp/src/otp.module.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { OtpInterface, RepositoryInterface } from '@concepta/nestjs-common'; - -import { OTP_MODULE_REPOSITORIES_TOKEN } from './otp.constants'; -import { OtpModule } from './otp.module'; -import { OtpService } from './services/otp.service'; - -import { AppModuleFixture } from './__fixtures__/app.module.fixture'; - -describe(OtpModule.name, () => { - let otpModule: OtpModule; - let otpService: OtpService; - let otpDynamicRepo: Record>; - - beforeEach(async () => { - const testModule: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - - otpModule = testModule.get(OtpModule); - otpService = testModule.get(OtpService); - otpDynamicRepo = testModule.get< - Record> - >(OTP_MODULE_REPOSITORIES_TOKEN); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('module', () => { - it('should be loaded', async () => { - expect(otpModule).toBeInstanceOf(OtpModule); - expect(otpService).toBeInstanceOf(OtpService); - expect(otpDynamicRepo).toBeDefined(); - }); - }); - - describe('OtpModule functions', () => { - const spyRegister = jest - .spyOn(OtpModule, 'register') - .mockImplementation(() => { - return {} as DynamicModule; - }); - - const spyRegisterAsync = jest - .spyOn(OtpModule, 'registerAsync') - .mockImplementation(() => { - return {} as DynamicModule; - }); - - it('should call super.register in register method', () => { - OtpModule.register({}); - expect(spyRegister).toHaveBeenCalled(); - }); - - it('should call super.registerAsync in register method', () => { - OtpModule.registerAsync({}); - expect(spyRegisterAsync).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/nestjs-otp/src/otp.module.ts b/packages/nestjs-otp/src/otp.module.ts index a0279c1d8..0dc03fb07 100644 --- a/packages/nestjs-otp/src/otp.module.ts +++ b/packages/nestjs-otp/src/otp.module.ts @@ -1,29 +1,59 @@ import { DynamicModule, Module } from '@nestjs/common'; +import { createOtpRepositoryProvider } from './infrastructure/utils/create-otp-repository-provider.js'; import { - OtpAsyncOptions, - OtpModuleClass, - OtpOptions, -} from './otp.module-definition'; + OtpCoreAsyncOptions, + OtpCoreModuleClass, + OtpCoreOptions, +} from './otp-core.module-definition.js'; + +type OtpOptions = Omit; +type OtpAsyncOptions = Omit; /** * Otp Module */ @Module({}) -export class OtpModule extends OtpModuleClass { +export class OtpModule { static register(options: OtpOptions): DynamicModule { - return super.register(options); + return { + module: OtpModule, + imports: [OtpCoreModuleClass.register({ ...options, global: false })], + }; } static registerAsync(options: OtpAsyncOptions): DynamicModule { - return super.registerAsync(options); + return { + module: OtpModule, + imports: [ + OtpCoreModuleClass.registerAsync({ ...options, global: false }), + ], + }; } static forRoot(options: OtpOptions): DynamicModule { - return super.register({ ...options, global: true }); + return { + module: OtpModule, + imports: [OtpCoreModuleClass.register({ ...options, global: true })], + }; } static forRootAsync(options: OtpAsyncOptions): DynamicModule { - return super.registerAsync({ ...options, global: true }); + return { + module: OtpModule, + imports: [OtpCoreModuleClass.registerAsync({ ...options, global: true })], + }; + } + + static forFeature(entityKeys: string[]): DynamicModule { + const repoProviders = entityKeys.map((entityKey) => + createOtpRepositoryProvider(entityKey), + ); + + return { + module: OtpModule, + providers: [...repoProviders], + exports: repoProviders, + }; } } diff --git a/packages/nestjs-otp/src/otp.seeder.ts b/packages/nestjs-otp/src/otp.seeder.ts deleted file mode 100644 index aab61cc86..000000000 --- a/packages/nestjs-otp/src/otp.seeder.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Seeder } from '@concepta/typeorm-seeding'; - -import { OtpFactory } from './otp.factory'; - -/** - * Otp seeder - */ -export class OtpSeeder extends Seeder { - /** - * Runner - */ - public async run(): Promise { - // number of otps to create - const createAmount = process.env?.OTP_MODULE_SEEDER_AMOUNT - ? Number(process.env.OTP_MODULE_SEEDER_AMOUNT) - : 50; - - // the factory - const otpFactory = this.factory(OtpFactory); - - // create a bunch - await otpFactory.createMany(createAmount); - } -} diff --git a/packages/nestjs-otp/src/seeding.ts b/packages/nestjs-otp/src/seeding.ts deleted file mode 100644 index 41f60bc6b..000000000 --- a/packages/nestjs-otp/src/seeding.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * These exports all you to import seeding related classes - * and tools without loading the entire module which - * runs all of it's decorators and meta data. - */ -export { OtpFactory } from './otp.factory'; -export { OtpSeeder } from './otp.seeder'; diff --git a/packages/nestjs-otp/src/services/otp.service.spec.ts b/packages/nestjs-otp/src/services/otp.service.spec.ts deleted file mode 100644 index 67700f920..000000000 --- a/packages/nestjs-otp/src/services/otp.service.spec.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { - OtpInterface, - RepositoryInterface, - toMilliseconds, -} from '@concepta/nestjs-common'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { OtpLimitReachedException } from '../exceptions/otp-limit-reached.exception'; -import { OtpTypeNotDefinedException } from '../exceptions/otp-type-not-defined.exception'; -import { OTP_MODULE_REPOSITORIES_TOKEN } from '../otp.constants'; -import { OtpModule } from '../otp.module'; - -import { OtpService } from './otp.service'; - -import { UserEntityFixture } from '../__fixtures__/entities/user-entity.fixture'; -import { UserOtpEntityFixture } from '../__fixtures__/entities/user-otp-entity.fixture'; -import { UserOtpFactoryFixture } from '../__fixtures__/factories/user-otp.factory.fixture'; -import { UserFactoryFixture } from '../__fixtures__/factories/user.factory.fixture'; - -describe('OtpModule', () => { - const CATEGORY_DEFAULT = 'CATEGORY_DEFAULT'; - - let testModule: TestingModule; - let seedingSource: SeedingSource; - let otpModule: OtpModule; - let otpService: OtpService; - let repository: RepositoryInterface; - let connectionNumber = 1; - let userFactory: UserFactoryFixture; - let userOtpFactory: UserOtpFactoryFixture; - - const factoryCreateUser = async () => { - return userFactory.create(); - }; - - const factoryCreateOtp = async ( - overrides: Partial & Pick, - ) => { - const now = new Date(); - const expirationDate = new Date(now.getTime() + toMilliseconds('1d')); - - return userOtpFactory.create({ - category: CATEGORY_DEFAULT, - expirationDate: expirationDate, - ...overrides, - }); - }; - - const defaultCreateOtp = async ( - options: Pick & - Partial>, - clearOnCreate?: boolean, - rateSeconds?: number, - rateThreshold?: number, - ) => - await otpService.create({ - assignment: 'userOtp', - otp: { - type: 'uuid', - expiresIn: '1h', - category: CATEGORY_DEFAULT, - ...options, - }, - clearOnCreate, - rateSeconds, - rateThreshold, - }); - - // try to delete - const defaultDeleteOtp = async ( - otp: Pick, - ) => await otpService.delete('userOtp', otp); - - const defaultIsValidOtp = async ( - otp: Pick, - deleteIfValid?: boolean, - ) => await otpService.validate('userOtp', otp, deleteIfValid); - - beforeEach(async () => { - // process.env.OTP_CLEAR_ON_CREATE = 'true'; - // process.env.OTP_RATE_SECONDS = '10'; - // process.env.OTP_RATE_THRESHOLD = '2'; - await initModule(); - }); - - const initModule = async () => { - const connectionName = `test_${connectionNumber++}`; - testModule = await Test.createTestingModule({ - imports: [ - TypeOrmExtModule.forRoot({ - name: connectionName, - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [UserEntityFixture, UserOtpEntityFixture], - logger: 'simple-console', - }), - OtpModule.registerAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - userOtp: { - entity: UserOtpEntityFixture, - dataSource: connectionName, - }, - }), - ], - useFactory: () => ({}), - entities: ['userOtp'], - }), - ], - }).compile(); - - seedingSource = new SeedingSource({ - dataSource: testModule.get(getDataSourceToken(connectionName)), - }); - - await seedingSource.initialize(); - - userFactory = new UserFactoryFixture({ seedingSource }); - userOtpFactory = new UserOtpFactoryFixture({ seedingSource }); - - otpModule = testModule.get(OtpModule); - otpService = testModule.get(OtpService); - const allRepo = testModule.get< - Record> - >(OTP_MODULE_REPOSITORIES_TOKEN); - repository = allRepo.userOtp; - }; - - afterEach(() => { - process.env.OTP_CLEAR_ON_CREATE = undefined; - process.env.OTP_RATE_SECONDS = undefined; - process.env.OTP_RATE_THRESHOLD = undefined; - jest.clearAllMocks(); - testModule.close(); - }); - - describe('module', () => { - it('should be loaded', async () => { - expect(otpModule).toBeInstanceOf(OtpModule); - }); - it('should be have expected services', async () => { - expect(otpService).toBeInstanceOf(OtpService); - }); - }); - - describe('otpService isValid', () => { - it('check if is valid true', async () => { - const assignee = await factoryCreateUser(); - const otp = await factoryCreateOtp({ assigneeId: assignee.id }); - expect((await defaultIsValidOtp(otp))?.assigneeId).toBe(otp.assigneeId); - }); - - it('check if is valid after delete', async () => { - const assignee = await factoryCreateUser(); - const otp = await factoryCreateOtp({ assigneeId: assignee.id }); - expect((await defaultIsValidOtp(otp, true))?.assigneeId).toBe( - otp.assigneeId, - ); - expect(await defaultIsValidOtp(otp)).toBeNull(); - expect(await defaultIsValidOtp(otp, true)).toBeNull(); - }); - - it('check if is expired', async () => { - const now = new Date(); - const expirationDate = new Date(now.getTime() - toMilliseconds('1d')); - - const assignee = await factoryCreateUser(); - - const otp = await factoryCreateOtp({ - expirationDate: expirationDate, - assigneeId: assignee.id, - }); - - expect(await defaultIsValidOtp(otp)).toBeNull(); - }); - }); - - describe('otpService create', () => { - it('create with success', async () => { - const assignee = await factoryCreateUser(); - const otp = await defaultCreateOtp({ assigneeId: assignee.id }); - - expect(otp.category).toBe(CATEGORY_DEFAULT); - expect(otp.type).toBe('uuid'); - expect(typeof otp.passcode).toBe('string'); - expect(otp.passcode.length).toBeGreaterThan(0); - expect(otp.expirationDate).toBeInstanceOf(Date); - expect(otp.assigneeId).toBeTruthy(); - }); - - it('create with success and check previous otp invalid', async () => { - const assignee = await factoryCreateUser(); - const otp = await defaultCreateOtp({ assigneeId: assignee.id }); - const otp_2 = await defaultCreateOtp({ assigneeId: assignee.id }, true); - - // make sure previous was deleted - expect(await defaultIsValidOtp(otp)).toBeNull(); - // check new one - expect((await defaultIsValidOtp(otp_2, true))?.assigneeId).toBe( - otp.assigneeId, - ); - }); - - it('create with success and check previous otp valid', async () => { - const assignee = await factoryCreateUser(); - const otp = await defaultCreateOtp({ assigneeId: assignee.id }); - const otp_2 = await defaultCreateOtp({ assigneeId: assignee.id }, false); - - // make sure previous was deleted - expect((await defaultIsValidOtp(otp, true))?.assigneeId).toBe( - otp.assigneeId, - ); - expect(await defaultIsValidOtp(otp)).toBeNull(); - // check new one - expect((await defaultIsValidOtp(otp_2, true))?.assigneeId).toBe( - otp.assigneeId, - ); - }); - - it('create with success and check previous otp invalid after transaction error', async () => { - const assignee = await factoryCreateUser(); - const otp = await defaultCreateOtp({ assigneeId: assignee.id }); - - jest.spyOn(repository, 'save').mockImplementationOnce(() => { - throw new Error('Error on save'); - }); - // spy on with error - try { - await defaultCreateOtp({ assigneeId: assignee.id }); - } catch (e) { - expect(e).toBeInstanceOf(Error); - } - - // validate first one created, - expect((await defaultIsValidOtp(otp, true))?.assigneeId).toBe( - otp.assigneeId, - ); - }); - - it('create with fail', async () => { - const assignee = await factoryCreateUser(); - const otp = await defaultCreateOtp({ assigneeId: assignee.id }); - - expect(otp).toBeTruthy(); - expect( - await defaultIsValidOtp({ ...otp, passcode: 'INVALID' }), - ).toBeNull(); - }); - - it('create with fail 2', async () => { - try { - const assignee = await factoryCreateUser(); - await defaultCreateOtp({ assigneeId: assignee.id, type: 'wrongType' }); - } catch (e) { - expect(e).toBeInstanceOf(OtpTypeNotDefinedException); - } - }); - - describe('create with limit ', () => { - it('create with fail limit', async () => { - process.env.OTP_CLEAR_ON_CREATE = 'true'; - process.env.OTP_RATE_SECONDS = '10'; - process.env.OTP_RATE_THRESHOLD = '2'; - await initModule(); - const assignee = await factoryCreateUser(); - await defaultCreateOtp({ assigneeId: assignee.id }); - await defaultCreateOtp({ assigneeId: assignee.id }); - await new Promise((resolve) => setTimeout(resolve, 2000)); - try { - await defaultCreateOtp({ assigneeId: assignee.id }); - fail('Expected OtpLimitReachedException to be thrown'); - } catch (e) { - expect(e).toBeInstanceOf(OtpLimitReachedException); - } - }); - - it('create with fail limit 2', async () => { - process.env.OTP_CLEAR_ON_CREATE = 'true'; - process.env.OTP_RATE_SECONDS = '10'; - process.env.OTP_RATE_THRESHOLD = '3'; - await initModule(); - const assignee = await factoryCreateUser(); - await defaultCreateOtp({ assigneeId: assignee.id }); - await defaultCreateOtp({ assigneeId: assignee.id }); - await defaultCreateOtp({ assigneeId: assignee.id }); - try { - await defaultCreateOtp({ assigneeId: assignee.id }); - fail('Expected OtpLimitReachedException to be thrown'); - } catch (e) { - expect(e).toBeInstanceOf(OtpLimitReachedException); - } - }); - - it('create with success limit using override', async () => { - process.env.OTP_CLEAR_ON_CREATE = 'true'; - process.env.OTP_RATE_SECONDS = `10`; - process.env.OTP_RATE_THRESHOLD = '5'; - const clearOnCreate = false; - const rateSeconds = 10; - const rateThreshold = 3; - await initModule(); - const assignee = await factoryCreateUser(); - await defaultCreateOtp( - { assigneeId: assignee.id }, - clearOnCreate, - rateSeconds, - rateThreshold, - ); - await defaultCreateOtp( - { assigneeId: assignee.id }, - clearOnCreate, - rateSeconds, - rateThreshold, - ); - await defaultCreateOtp( - { assigneeId: assignee.id }, - clearOnCreate, - rateSeconds, - rateThreshold, - ); - try { - await defaultCreateOtp( - { assigneeId: assignee.id }, - clearOnCreate, - rateSeconds, - rateThreshold, - ); - fail('Expected OtpLimitReachedException to be thrown'); - } catch (e) { - expect(e).toBeInstanceOf(OtpLimitReachedException); - } - }); - - it('create with success limit', async () => { - process.env.OTP_CLEAR_ON_CREATE = 'true'; - process.env.OTP_RATE_SECONDS = '10'; - process.env.OTP_RATE_THRESHOLD = '4'; - await initModule(); - const assignee = await factoryCreateUser(); - await defaultCreateOtp({ assigneeId: assignee.id }); - await defaultCreateOtp({ assigneeId: assignee.id }); - await new Promise((resolve) => setTimeout(resolve, 2000)); - const otp = await defaultCreateOtp({ assigneeId: assignee.id }); - - expect(otp.category).toBe(CATEGORY_DEFAULT); - expect(otp.type).toBe('uuid'); - expect(typeof otp.passcode).toBe('string'); - expect(otp.passcode.length).toBeGreaterThan(0); - expect(otp.expirationDate).toBeInstanceOf(Date); - expect(otp.assigneeId).toBeTruthy(); - }); - - it('create with success with limit 2', async () => { - process.env.OTP_CLEAR_ON_CREATE = 'true'; - process.env.OTP_RATE_SECONDS = '1'; - process.env.OTP_RATE_THRESHOLD = '4'; - await initModule(); - const assignee = await factoryCreateUser(); - await defaultCreateOtp({ assigneeId: assignee.id }); - await defaultCreateOtp({ assigneeId: assignee.id }); - await new Promise((resolve) => setTimeout(resolve, 2000)); - const otp = await defaultCreateOtp({ assigneeId: assignee.id }); - - expect(otp.category).toBe(CATEGORY_DEFAULT); - expect(otp.type).toBe('uuid'); - expect(typeof otp.passcode).toBe('string'); - expect(otp.passcode.length).toBeGreaterThan(0); - expect(otp.expirationDate).toBeInstanceOf(Date); - expect(otp.assigneeId).toBeTruthy(); - }); - }); - }); - - describe('otpService delete', () => { - it('delete with success', async () => { - const assignee = await factoryCreateUser(); - const otp = await defaultCreateOtp({ assigneeId: assignee.id }); - expect(otp).toBeTruthy(); - - // try to delete - expect(await defaultDeleteOtp(otp)).toBeUndefined(); - - // check if deleted is valid - expect(await defaultIsValidOtp(otp)).toBeNull(); - }); - }); - - describe('otpService clear', () => { - it('clear with success', async () => { - const assignee = await factoryCreateUser(); - const otp = await defaultCreateOtp({ assigneeId: assignee.id }); - - expect(otp).toBeTruthy(); - expect((await defaultIsValidOtp(otp))?.assigneeId).toBe(otp.assigneeId); - - const otp2 = await defaultCreateOtp({ assigneeId: assignee.id }); - expect(otp2).toBeTruthy(); - expect((await defaultIsValidOtp(otp2))?.assigneeId).toBe(otp2.assigneeId); - - // try to clear - expect(await otpService.clear('userOtp', otp)).toBeUndefined(); - - // cleared passcodes should be invalid - // TODO: check that they were actually removed from database - expect(await defaultIsValidOtp(otp)).toBeNull(); - expect(await defaultIsValidOtp(otp2)).toBeNull(); - }); - }); -}); diff --git a/packages/nestjs-otp/src/services/otp.service.ts b/packages/nestjs-otp/src/services/otp.service.ts deleted file mode 100644 index 4feee5b33..000000000 --- a/packages/nestjs-otp/src/services/otp.service.ts +++ /dev/null @@ -1,457 +0,0 @@ -import { plainToInstance } from 'class-transformer'; -import { validate } from 'class-validator'; - -import { Inject, Injectable, Type } from '@nestjs/common'; - -import { - ReferenceAssignment, - OtpInterface, - OtpCreateParamsInterface, - OtpValidateLimitParamsInterface, - RepositoryInterface, - DeepPartial, - AssigneeRelationInterface, - RepositoryInternals, - ModelQueryException, - ModelMutateException, - ModelValidationException, - toMilliseconds, -} from '@concepta/nestjs-common'; - -import { OtpCreateDto } from '../dto/otp-create.dto'; -import { OtpEntityNotFoundException } from '../exceptions/otp-entity-not-found.exception'; -import { OtpLimitReachedException } from '../exceptions/otp-limit-reached.exception'; -import { OtpTypeNotDefinedException } from '../exceptions/otp-type-not-defined.exception'; -import { OtpServiceInterface } from '../interfaces/otp-service.interface'; -import { OtpSettingsInterface } from '../interfaces/otp-settings.interface'; -import { - OTP_MODULE_REPOSITORIES_TOKEN, - OTP_MODULE_SETTINGS_TOKEN, -} from '../otp.constants'; - -@Injectable() -export class OtpService implements OtpServiceInterface { - constructor( - @Inject(OTP_MODULE_REPOSITORIES_TOKEN) - private allOtpRepos: Record>, - @Inject(OTP_MODULE_SETTINGS_TOKEN) - protected readonly settings: OtpSettingsInterface, - ) {} - - /** - * Create a otp with a for the given assignee. - * - * @param params - The otp params - */ - async create(params: OtpCreateParamsInterface): Promise { - const { assignment, otp, clearOnCreate, rateSeconds, rateThreshold } = - params; - - if (!this.settings.types[otp.type]) - throw new OtpTypeNotDefinedException(otp.type); - - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - // validate the data - const dto = await this.validateDto(OtpCreateDto, otp); - - // generate a passcode - const passcode = this.settings.types[otp.type].generator(); - - // break out the vars - const { category, type, assigneeId, expiresIn } = dto; - - // check if amount of otp by time frame has been reached - await this.validateOtpCreationLimit({ - assignment, - assigneeId, - category, - rateSeconds, - rateThreshold, - }); - try { - // generate the expiration date - const expirationDate = this.getExpirationDate(expiresIn); - - // clear history if defined - if (this.settings.keepHistoryDays && this.settings.keepHistoryDays > 0) - this.clearHistory(assignment, otp); - - // if clearOnCreate was defined, use it, otherwise get default settings - const shouldClear = - clearOnCreate === true || clearOnCreate === false - ? clearOnCreate - : this.settings.clearOnCreate; - - if (shouldClear) { - // this should make inactive instead of delete - await this.inactivatePreviousOtp(assignment, dto); - } - - return await assignmentRepo.save({ - category, - type, - assigneeId, - passcode, - expirationDate, - active: true, - }); - } catch (e) { - throw new ModelMutateException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - private async validateOtpCreationLimit( - params: OtpValidateLimitParamsInterface, - ): Promise { - const { assignment, assigneeId, category, rateSeconds, rateThreshold } = - params; - - // check if validation config should be overridden - const finalRateSeconds = - rateSeconds && rateSeconds >= 0 ? rateSeconds : this.settings.rateSeconds; - const finalOtpLimit = - rateThreshold && rateThreshold >= 0 - ? rateThreshold - : this.settings.rateThreshold; - - // only check if it was defined - if (finalRateSeconds && finalOtpLimit) { - const cutoffDate = new Date(); - cutoffDate.setSeconds(cutoffDate.getSeconds() - finalRateSeconds); - - // get all active and inactive - const recentOtps = await this.getAssignedOtps(assignment, { - assigneeId, - category, - }); - - // get otp in the time frame - const recentOtpCount = recentOtps.filter( - (otp) => otp.dateCreated > cutoffDate, - ).length; - - if (recentOtpCount >= finalOtpLimit) { - throw new OtpLimitReachedException(); - } - } - } - - /** - * Check if otp is valid - * - * @param assignment - The otp assignment - * @param otp - The otp to validate - * @param deleteIfValid - If true, delete the otp if it is valid - */ - async validate( - assignment: ReferenceAssignment, - otp: Pick, - deleteIfValid = false, - ): Promise { - // get otp from an assigned user for a category - const assignedOtp = await this.getActiveByPasscode(assignment, { - ...otp, - active: true, - }); - - // check if otp is expired - const now = new Date(); - if (!assignedOtp || now > assignedOtp.expirationDate) return null; - - // determine if valid - const isValid = !!assignedOtp; - - // if is valid and deleteIfValid is true, delete the otp - if (isValid && deleteIfValid) { - await this.deleteOtp(assignment, assignedOtp); - } - - return assignedOtp; - } - - /** - * Delete a otp based on params - * - * @param assignment - The otp assignment - * @param otp - The otp to delete - */ - async delete( - assignment: ReferenceAssignment, - otp: Pick, - ): Promise { - // get otp from an assigned user for a category - const assignedOtp = await this.getByPasscode(assignment, otp); - - if (assignedOtp) { - return this.deleteOtp(assignment, assignedOtp); - } - } - - /** - * Clear all otps for assign in given category. - * - * @param assignment - The assignment of the repository - * @param otp - The otp to clear - */ - async clear( - assignment: ReferenceAssignment, - otp: Pick, - ): Promise { - // get all otps from an assigned user for a category - const assignedOtps = await this.getAssignedOtps(assignment, otp); - - if (assignedOtps.length > 0) await this.deleteOtp(assignment, assignedOtps); - } - - /** - * Delete OTP based on assignment - * - * @internal - * @param assignment - The assignment to delete id from - * @param entity - The id or ids to delete - */ - protected async deleteOtp( - assignment: ReferenceAssignment, - entity: OtpInterface | OtpInterface[], - ): Promise { - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - try { - await assignmentRepo.remove(Array.isArray(entity) ? entity : [entity]); - } catch (e) { - throw new ModelMutateException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - async clearHistory( - assignment: ReferenceAssignment, - otp: Pick, - ): Promise { - const keepHistoryDays = this.settings.keepHistoryDays; - // get only otps based on date for history days - const assignedOtps = await this.getAssignedOtps( - assignment, - otp, - keepHistoryDays, - ); - - if (assignedOtps.length > 0) await this.deleteOtp(assignment, assignedOtps); - } - - /** - * Get all OTPs for assignee. of filtered by date based on keep history days - * - * @param assignment - The assignment of the check - * @param otp - The otp to get assignments - * @param keepHistoryDays - Number of days to keep in history - */ - // TODO: recieve query in parameters - protected async getAssignedOtps( - assignment: ReferenceAssignment, - otp: Pick, - keepHistoryDays?: number, - ): Promise { - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - // break out the args - const { assigneeId, category } = otp; - - // try to find the relationships - try { - // simple query or query by date from history - const query: - | RepositoryInternals.FindOptionsWhere[] - | RepositoryInternals.FindOptionsWhere = - this.buildFindQuery(assignment, assigneeId, category, keepHistoryDays); - - // make the query - const assignments = await assignmentRepo.find({ - where: query, - }); - - // return the otps from assignee - return assignments; - } catch (e) { - throw new ModelQueryException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - private buildFindQuery( - assignment: ReferenceAssignment, - assigneeId: string, - category: string, - keepHistoryDays?: number, - ) { - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - let query: - | RepositoryInternals.FindOptionsWhere[] - | RepositoryInternals.FindOptionsWhere = { - assigneeId, - category, - }; - - // filter by date - if (keepHistoryDays) { - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - keepHistoryDays); - query = { - assigneeId, - category, - dateCreated: assignmentRepo.lte(cutoffDate), - }; - } - return query; - } - protected async getByPasscode( - assignment: ReferenceAssignment, - otp: Pick, - ): Promise { - // break out properties - const { category, passcode } = otp; - - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - // try to find the assignment - try { - // make the query - const assignment = await assignmentRepo.findOne({ - where: { - category, - passcode, - }, - }); - - // return the otps from assignee - return assignment; - } catch (e) { - throw new ModelQueryException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - protected async getActiveByPasscode( - assignment: ReferenceAssignment, - otp: Pick, - ): Promise { - // break out properties - const { category, passcode, active } = otp; - - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - // try to find the assignment - try { - // make the query - const assignment = await assignmentRepo.findOne({ - where: { - category, - passcode, - active, - }, - }); - - // return the otps from assignee - return assignment; - } catch (e) { - throw new ModelQueryException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - /** - * Get the assignment repo for the given assignment. - * - * @internal - * @param assignment - The otp assignment - */ - protected getAssignmentRepo( - assignment: ReferenceAssignment, - ): RepositoryInterface { - // repo matching assignment was injected? - if (this.allOtpRepos[assignment]) { - // yes, return it - return this.allOtpRepos[assignment]; - } else { - // bad assignment - throw new OtpEntityNotFoundException(assignment); - } - } - - // TODO: move to a separate service and reuse it on mutate service - protected async validateDto>( - type: Type, - data: T, - ): Promise { - // convert to dto - const dto = plainToInstance(type, data); - - // validate the data - const validationErrors = await validate(dto); - - // any errors? - if (validationErrors.length) { - // yes, throw error - throw new ModelValidationException( - this.constructor.name, - validationErrors, - ); - } - - return dto; - } - - protected async inactivatePreviousOtp( - assignment: ReferenceAssignment, - otp: Pick, - ): Promise { - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - // break out the args - const { assigneeId, category } = otp; - - // try to find the relationships - try { - // TODO: TYPEORM REMOVE UPDATE REPLACE FOR SAVE - // make previous inactive - const assignmentObject = await assignmentRepo.findOne({ - where: { - assigneeId, - category, - }, - }); - - if (assignmentObject) { - assignmentObject.active = false; - await assignmentRepo.save(assignmentObject); - } - } catch (e) { - throw new ModelQueryException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - // TODO: move this to a help function - private getExpirationDate(expiresIn: string) { - const now = new Date(); - - // add time in seconds to now as string format - return new Date(now.getTime() + toMilliseconds(expiresIn)); - } -} diff --git a/packages/nestjs-otp/tsconfig.json b/packages/nestjs-otp/tsconfig.json index ef9980950..edc11225e 100644 --- a/packages/nestjs-otp/tsconfig.json +++ b/packages/nestjs-otp/tsconfig.json @@ -4,10 +4,13 @@ "composite": true, "rootDir": "./src", "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", "typeRoots": [ "./node_modules/@types", - "../../node_modules/@types" - ] + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] }, "include": [ "src/**/*.ts" diff --git a/packages/nestjs-password/README.md b/packages/nestjs-password/README.md index 9cfc4e97e..44d361a83 100644 --- a/packages/nestjs-password/README.md +++ b/packages/nestjs-password/README.md @@ -1,28 +1,260 @@ -# Rockets NestJS Password +# @concepta/nestjs-password -A flexible Password utilities module that provides services for password -strength, creation and storage. +Password utilities module for NestJS using DDD/CQRS. Provides password +hashing, strength validation, current password enforcement, and history +checking via four domain services and a configurable policy. ## Project [![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-password)](https://www.npmjs.com/package/@concepta/nestjs-password) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-password)](https://www.npmjs.com/package/@concepta/nestjs-password) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-password)](https://www.npmjs.com/package/@concepta/nestjs-password) [![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) [![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-password%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) -## Overview +## Table of Contents -The module exports three services: `PasswordStrengthService`, -`PasswordCreationService`, and `PasswordStorageService`. - -The `PasswordCreationService` uses the `PasswordStrengthService` -internally for check password strength. +- [Installation](#installation) +- [Module Registration](#module-registration) +- [Architecture Overview](#architecture-overview) +- [Password Policy](#password-policy) +- [Domain Services](#domain-services) +- [Commands](#commands) +- [Exceptions](#exceptions) +- [Environment Variables](#environment-variables) +- [Entry Points](#entry-points) ## Installation -`yarn add @concepta/nestjs-password` +```sh +yarn add @concepta/nestjs-password @nestjs/common @nestjs/config @nestjs/core +``` + +Requirements: the package is **ESM-only** (no CommonJS build), targets +**Node.js >= 22.12**, and runs on **NestJS 12**. + +### Dependencies + +| Package | Notes | +| --- | --- | +| `@concepta/nestjs-core` | `RuntimeException` base class, reference types, utilities | +| `bcrypt` | Password hashing | +| `zxcvbn` | Password strength evaluation | + +### Peer Dependencies + +| Package | Required | Notes | +| --- | --- | --- | +| `@nestjs/common` | Yes | NestJS 12 framework | +| `@nestjs/core` | Yes | Required by `@nestjs/cqrs` | +| `@nestjs/config` | Yes | Configuration module | +| `@nestjs/cqrs` | No | Optional peer — required in practice, the command bus | + +## Module Registration + +### Synchronous + +```ts +import { PasswordModule, PasswordStrengthEnum } from '@concepta/nestjs-password'; + +@Module({ + imports: [ + PasswordModule.register({ + settings: { + minPasswordStrength: PasswordStrengthEnum.Strong, + requireCurrentToUpdate: true, + }, + }), + ], +}) +export class AppModule {} +``` + +### Asynchronous + +```ts +import { PasswordModule, PasswordStrengthEnum } from '@concepta/nestjs-password'; + +@Module({ + imports: [ + PasswordModule.registerAsync({ + useFactory: async () => ({ + settings: { + minPasswordStrength: PasswordStrengthEnum.Strong, + }, + }), + }), + ], +}) +export class AppModule {} +``` + +`register()` / `registerAsync()` register the module **locally** (scoped to +the importing module). + +`forRoot()` / `forRootAsync()` register the module **globally**. + +`forFeature()` creates a standalone set of password providers (policy, +services, command handlers) for use in sub-modules. + +### Options + +```ts +interface PasswordOptionsInterface { + settings?: PasswordSettingsInterface; +} + +interface PasswordSettingsInterface { + minPasswordStrength?: PasswordStrengthEnum; // Minimum zxcvbn score + requireCurrentToUpdate?: boolean; // Require current password on update +} +``` + +## Architecture Overview + +```text +Application (Commands) + | +Domain (Services, Policy, Exceptions, CryptUtil) + | +Infrastructure (Config) +``` + +- **Domain** -- `PasswordPolicy` (configurable policy), four domain services, + domain exceptions, `CryptUtil` (bcrypt abstraction; internal — not exported + from the package barrel) +- **Application** -- 4 commands dispatched via `@nestjs/cqrs` +- **Infrastructure** -- Configuration with environment variable support + +Password primitives are defined and exported by **this package**: +`PasswordPlainInterface`, `PasswordPlainCurrentInterface`, +`PasswordStorageInterface`, `PasswordUpdateInterface`, and the +`isPasswordStorage` type guard. + +## Password Policy + +`PasswordPolicy` encapsulates configurable password rules (its settings +constructor argument is typed as `PasswordPolicySettings`, also exported). It +is registered as a NestJS provider and injected into services. + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `minPasswordStrength` | `PasswordStrengthEnum` | `None` (production: `VeryStrong`) | Minimum zxcvbn score (0-4) | +| `requireCurrentToUpdate` | `boolean` | `false` | Require current password when updating | + +### PasswordStrengthEnum + +| Value | Score | Description | +| --- | --- | --- | +| `None` | 0 | No strength requirement | +| `Weak` | 1 | Weak password | +| `Medium` | 2 | Medium strength | +| `Strong` | 3 | Strong password | +| `VeryStrong` | 4 | Very strong password | + +## Domain Services + +Each service has a matching exported contract interface: +`PasswordCreationServiceInterface`, `PasswordStorageServiceInterface`, +`PasswordValidationServiceInterface`, and `PasswordStrengthServiceInterface` — +implement one of these to swap in a custom provider. + +### PasswordCreationService + +Orchestrates password creation with policy enforcement. + +| Method | Signature | Description | +| --- | --- | --- | +| `create` | `(password: string) => Promise` | Hash password after strength check | +| `validateCurrent` | `(options) => Promise` | Validate current password (throws `PasswordCurrentRequiredException` if required and missing) | +| `validateHistory` | `(options) => Promise` | Check password against history (throws `PasswordUsedRecentlyException` on match) | + +### PasswordStorageService + +Handles password hashing via bcrypt. + +| Method | Signature | Description | +| --- | --- | --- | +| `hash` | `(password: string) => Promise` | Hash a plain password | +| `hashObject` | `(object, options?) => Promise<...>` | Hash the `password` field of an object, returning the object with `passwordHash` replacing `password` | + +### PasswordValidationService + +Validates a plain password against a stored hash. + +| Method | Signature | Description | +| --- | --- | --- | +| `validate` | `(options: PasswordValidateOptionsInterface) => Promise` | Compare plain password against hash | + +### PasswordStrengthService + +Evaluates password strength using zxcvbn. + +| Method | Signature | Description | +| --- | --- | --- | +| `isStrong` | `(password: string) => boolean` | Returns `true` if zxcvbn score meets `minPasswordStrength` | + +## Commands + +| Command | Input | Returns | Description | +| --- | --- | --- | --- | +| `CreatePasswordCommand` | `password` | `PasswordStorageInterface` | Create and hash a password (with strength check) | +| `ValidatePasswordCommand` | `PasswordValidateOptionsInterface` | `boolean` | Validate plain password against hash | +| `ValidateCurrentPasswordCommand` | `password, target` | `boolean` | Validate current password against stored credentials | +| `ValidatePasswordHistoryCommand` | `password, targets[]` | `boolean` | Check password against credential history | + +### Dispatching a Command + +```ts +import { CommandBus } from '@nestjs/cqrs'; +import { + CreatePasswordCommand, + ValidatePasswordCommand, + PasswordStorageInterface, +} from '@concepta/nestjs-password'; + +// Create a hashed password +const storage = await this.commandBus.execute< + CreatePasswordCommand, + PasswordStorageInterface +>(new CreatePasswordCommand('my-secure-password')); + +// Validate a password against a hash +const isValid = await this.commandBus.execute< + ValidatePasswordCommand, + boolean +>(new ValidatePasswordCommand({ + password: 'my-secure-password', + passwordHash: storage.passwordHash, +})); +``` + +## Exceptions + +| Exception | Description | +| --- | --- | +| `PasswordException` | Base password exception | +| `PasswordNotStrongException` | Password does not meet minimum strength | +| `PasswordRequiredException` | Password field is required but missing | +| `PasswordCurrentRequiredException` | Current password required by policy but not provided | +| `PasswordUsedRecentlyException` | Password matches a recent credential in history | + +All exceptions extend `PasswordException`, which extends `RuntimeException` +from `@concepta/nestjs-core`. `RuntimeException` extends Nest's +`HttpException`, so no exception filter registration is needed — password +exceptions render on the wire as +`{ statusCode, message, errorCode, error? }` bodies (errorCode +`PASSWORD_ERROR` unless a subclass overrides it). + +## Environment Variables + +| Variable | Default | Description | +| --- | --- | --- | +| `PASSWORD_MIN_PASSWORD_STRENGTH` | `0` (production: `4`) | Minimum zxcvbn score (0-4) | +| `PASSWORD_REQUIRE_CURRENT_TO_UPDATE` | `false` | Require current password on update | -## TODO +## Entry Points -- Make all services overridable at time of registration. +| Import Path | Contents | +| --- | --- | +| `@concepta/nestjs-password` | Module, policy, services, commands, command handlers, exceptions, enums, interfaces | diff --git a/packages/nestjs-password/package.json b/packages/nestjs-password/package.json index 9dd4eb66d..82b1e63d6 100644 --- a/packages/nestjs-password/package.json +++ b/packages/nestjs-password/package.json @@ -1,27 +1,49 @@ { "name": "@concepta/nestjs-password", - "version": "7.0.0-alpha.10", + "version": "8.0.0-alpha.10", "description": "Rockets NestJS Password", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "license": "BSD-3-Clause", "publishConfig": { "access": "public" }, "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" ], "dependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", + "@concepta/nestjs-core": "^8.0.0-alpha.10", "@types/zxcvbn": "^4.4.4", "bcrypt": "^5.1.1", "zxcvbn": "^4.4.2" }, "devDependencies": { - "@nestjs/testing": "^11.1.9", - "@types/bcrypt": "^5.0.2" + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/testing": "^12.0.1", + "@types/bcrypt": "^5.0.2", + "vitest-mock-extended": "^4.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/cqrs": { + "optional": true + } + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } } } diff --git a/packages/nestjs-password/src/__tests__/exception-fault.spec.ts b/packages/nestjs-password/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..89e8cd410 --- /dev/null +++ b/packages/nestjs-password/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,67 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { PasswordCurrentRequiredException } from '../domain/exceptions/password-current-required.exception.js'; +import { PasswordNotStrongException } from '../domain/exceptions/password-not-strong.exception.js'; +import { PasswordRequiredException } from '../domain/exceptions/password-required.exception.js'; +import { PasswordUsedRecentlyException } from '../domain/exceptions/password-used-recently.exception.js'; +import { PasswordException } from '../domain/exceptions/password.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'PasswordException (default)', + build: () => new PasswordException(), + fault: 'internal', + }, + { + name: 'PasswordCurrentRequiredException', + build: () => new PasswordCurrentRequiredException(), + fault: 'client', + }, + { + name: 'PasswordNotStrongException', + build: () => new PasswordNotStrongException(), + fault: 'client', + }, + { + name: 'PasswordRequiredException', + build: () => new PasswordRequiredException(), + fault: 'client', + }, + { + name: 'PasswordUsedRecentlyException', + build: () => new PasswordUsedRecentlyException(), + fault: 'client', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-password/src/application/commands/handlers/__tests__/create-password.handler.spec.ts b/packages/nestjs-password/src/application/commands/handlers/__tests__/create-password.handler.spec.ts new file mode 100644 index 000000000..999a4233b --- /dev/null +++ b/packages/nestjs-password/src/application/commands/handlers/__tests__/create-password.handler.spec.ts @@ -0,0 +1,20 @@ +import { type PasswordStorageInterface } from '../../../../domain/password/interfaces/password-storage.interface.js'; +import { CreatePasswordCommand } from '../../impl/create-password.command.js'; +import { CreatePasswordHandler } from '../create-password.handler.js'; + +import { createMockCreationService } from './mock.helpers.js'; + +describe(CreatePasswordHandler.name, () => { + it('should delegate to PasswordCreationService.create', async () => { + const creationService = createMockCreationService(); + const handler = new CreatePasswordHandler(creationService); + const result: PasswordStorageInterface = { passwordHash: 'hashed' }; + creationService.create.mockResolvedValue(result); + + const command = new CreatePasswordCommand('my-password'); + const output = await handler.execute(command); + + expect(creationService.create).toHaveBeenCalledWith('my-password'); + expect(output).toEqual(result); + }); +}); diff --git a/packages/nestjs-password/src/application/commands/handlers/__tests__/mock.helpers.ts b/packages/nestjs-password/src/application/commands/handlers/__tests__/mock.helpers.ts new file mode 100644 index 000000000..a855bfc25 --- /dev/null +++ b/packages/nestjs-password/src/application/commands/handlers/__tests__/mock.helpers.ts @@ -0,0 +1,12 @@ +import { mock } from 'vitest-mock-extended'; + +import { type PasswordCreationService } from '../../../../domain/services/password-creation.service.js'; +import { type PasswordValidationService } from '../../../../domain/services/password-validation.service.js'; + +export function createMockCreationService() { + return mock(); +} + +export function createMockValidationService() { + return mock(); +} diff --git a/packages/nestjs-password/src/application/commands/handlers/__tests__/validate-current-password.handler.spec.ts b/packages/nestjs-password/src/application/commands/handlers/__tests__/validate-current-password.handler.spec.ts new file mode 100644 index 000000000..c1b0dd210 --- /dev/null +++ b/packages/nestjs-password/src/application/commands/handlers/__tests__/validate-current-password.handler.spec.ts @@ -0,0 +1,22 @@ +import { ValidateCurrentPasswordCommand } from '../../impl/validate-current-password.command.js'; +import { ValidateCurrentPasswordHandler } from '../validate-current-password.handler.js'; + +import { createMockCreationService } from './mock.helpers.js'; + +describe(ValidateCurrentPasswordHandler.name, () => { + it('should delegate to PasswordCreationService.validateCurrent', async () => { + const creationService = createMockCreationService(); + const handler = new ValidateCurrentPasswordHandler(creationService); + creationService.validateCurrent.mockResolvedValue(true); + + const target = { passwordHash: 'hash' }; + const command = new ValidateCurrentPasswordCommand('plain', target); + const output = await handler.execute(command); + + expect(creationService.validateCurrent).toHaveBeenCalledWith({ + password: 'plain', + target, + }); + expect(output).toEqual(true); + }); +}); diff --git a/packages/nestjs-password/src/application/commands/handlers/__tests__/validate-password-history.handler.spec.ts b/packages/nestjs-password/src/application/commands/handlers/__tests__/validate-password-history.handler.spec.ts new file mode 100644 index 000000000..770d960d0 --- /dev/null +++ b/packages/nestjs-password/src/application/commands/handlers/__tests__/validate-password-history.handler.spec.ts @@ -0,0 +1,22 @@ +import { ValidatePasswordHistoryCommand } from '../../impl/validate-password-history.command.js'; +import { ValidatePasswordHistoryHandler } from '../validate-password-history.handler.js'; + +import { createMockCreationService } from './mock.helpers.js'; + +describe(ValidatePasswordHistoryHandler.name, () => { + it('should delegate to PasswordCreationService.validateHistory', async () => { + const creationService = createMockCreationService(); + const handler = new ValidatePasswordHistoryHandler(creationService); + creationService.validateHistory.mockResolvedValue(true); + + const targets = [{ passwordHash: 'h1' }, { passwordHash: 'h2' }]; + const command = new ValidatePasswordHistoryCommand('plain', targets); + const output = await handler.execute(command); + + expect(creationService.validateHistory).toHaveBeenCalledWith({ + password: 'plain', + targets, + }); + expect(output).toEqual(true); + }); +}); diff --git a/packages/nestjs-password/src/application/commands/handlers/__tests__/validate-password.handler.spec.ts b/packages/nestjs-password/src/application/commands/handlers/__tests__/validate-password.handler.spec.ts new file mode 100644 index 000000000..a3dde80ae --- /dev/null +++ b/packages/nestjs-password/src/application/commands/handlers/__tests__/validate-password.handler.spec.ts @@ -0,0 +1,19 @@ +import { ValidatePasswordCommand } from '../../impl/validate-password.command.js'; +import { ValidatePasswordHandler } from '../validate-password.handler.js'; + +import { createMockValidationService } from './mock.helpers.js'; + +describe(ValidatePasswordHandler.name, () => { + it('should delegate to PasswordValidationService.validate', async () => { + const validationService = createMockValidationService(); + const handler = new ValidatePasswordHandler(validationService); + validationService.validate.mockResolvedValue(true); + + const options = { password: 'plain', passwordHash: 'hash' }; + const command = new ValidatePasswordCommand(options); + const output = await handler.execute(command); + + expect(validationService.validate).toHaveBeenCalledWith(options); + expect(output).toEqual(true); + }); +}); diff --git a/packages/nestjs-password/src/application/commands/handlers/create-password.handler.ts b/packages/nestjs-password/src/application/commands/handlers/create-password.handler.ts new file mode 100644 index 000000000..553add16b --- /dev/null +++ b/packages/nestjs-password/src/application/commands/handlers/create-password.handler.ts @@ -0,0 +1,18 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { PasswordStorageInterface } from '../../../domain/password/interfaces/password-storage.interface.js'; +import { PasswordCreationService } from '../../../domain/services/password-creation.service.js'; +import { CreatePasswordCommand } from '../impl/create-password.command.js'; + +@CommandHandler(CreatePasswordCommand) +export class CreatePasswordHandler implements ICommandHandler { + constructor( + private readonly passwordCreationService: PasswordCreationService, + ) {} + + async execute( + command: CreatePasswordCommand, + ): Promise { + return this.passwordCreationService.create(command.password); + } +} diff --git a/packages/nestjs-password/src/application/commands/handlers/validate-current-password.handler.ts b/packages/nestjs-password/src/application/commands/handlers/validate-current-password.handler.ts new file mode 100644 index 000000000..a406dfa7e --- /dev/null +++ b/packages/nestjs-password/src/application/commands/handlers/validate-current-password.handler.ts @@ -0,0 +1,16 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { PasswordCreationService } from '../../../domain/services/password-creation.service.js'; +import { ValidateCurrentPasswordCommand } from '../impl/validate-current-password.command.js'; + +@CommandHandler(ValidateCurrentPasswordCommand) +export class ValidateCurrentPasswordHandler implements ICommandHandler { + constructor( + private readonly passwordCreationService: PasswordCreationService, + ) {} + + async execute(command: ValidateCurrentPasswordCommand): Promise { + const { password, target } = command; + return this.passwordCreationService.validateCurrent({ password, target }); + } +} diff --git a/packages/nestjs-password/src/application/commands/handlers/validate-password-history.handler.ts b/packages/nestjs-password/src/application/commands/handlers/validate-password-history.handler.ts new file mode 100644 index 000000000..5a599cd86 --- /dev/null +++ b/packages/nestjs-password/src/application/commands/handlers/validate-password-history.handler.ts @@ -0,0 +1,16 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { PasswordCreationService } from '../../../domain/services/password-creation.service.js'; +import { ValidatePasswordHistoryCommand } from '../impl/validate-password-history.command.js'; + +@CommandHandler(ValidatePasswordHistoryCommand) +export class ValidatePasswordHistoryHandler implements ICommandHandler { + constructor( + private readonly passwordCreationService: PasswordCreationService, + ) {} + + async execute(command: ValidatePasswordHistoryCommand): Promise { + const { password, targets } = command; + return this.passwordCreationService.validateHistory({ password, targets }); + } +} diff --git a/packages/nestjs-password/src/application/commands/handlers/validate-password.handler.ts b/packages/nestjs-password/src/application/commands/handlers/validate-password.handler.ts new file mode 100644 index 000000000..491303e6c --- /dev/null +++ b/packages/nestjs-password/src/application/commands/handlers/validate-password.handler.ts @@ -0,0 +1,15 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { PasswordValidationService } from '../../../domain/services/password-validation.service.js'; +import { ValidatePasswordCommand } from '../impl/validate-password.command.js'; + +@CommandHandler(ValidatePasswordCommand) +export class ValidatePasswordHandler implements ICommandHandler { + constructor( + private readonly passwordValidationService: PasswordValidationService, + ) {} + + async execute(command: ValidatePasswordCommand): Promise { + return this.passwordValidationService.validate(command.options); + } +} diff --git a/packages/nestjs-password/src/application/commands/impl/create-password.command.ts b/packages/nestjs-password/src/application/commands/impl/create-password.command.ts new file mode 100644 index 000000000..5ba945e74 --- /dev/null +++ b/packages/nestjs-password/src/application/commands/impl/create-password.command.ts @@ -0,0 +1,9 @@ +import { Command } from '@nestjs/cqrs'; + +import { type PasswordStorageInterface } from '../../../domain/password/interfaces/password-storage.interface.js'; + +export class CreatePasswordCommand extends Command { + constructor(public readonly password: string) { + super(); + } +} diff --git a/packages/nestjs-password/src/application/commands/impl/validate-current-password.command.ts b/packages/nestjs-password/src/application/commands/impl/validate-current-password.command.ts new file mode 100644 index 000000000..3afef7b87 --- /dev/null +++ b/packages/nestjs-password/src/application/commands/impl/validate-current-password.command.ts @@ -0,0 +1,12 @@ +import { Command } from '@nestjs/cqrs'; + +import { type PasswordStorageInterface } from '../../../domain/password/interfaces/password-storage.interface.js'; + +export class ValidateCurrentPasswordCommand extends Command { + constructor( + public readonly password: string, + public readonly target: PasswordStorageInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-password/src/application/commands/impl/validate-password-history.command.ts b/packages/nestjs-password/src/application/commands/impl/validate-password-history.command.ts new file mode 100644 index 000000000..b50cdfcb5 --- /dev/null +++ b/packages/nestjs-password/src/application/commands/impl/validate-password-history.command.ts @@ -0,0 +1,12 @@ +import { Command } from '@nestjs/cqrs'; + +import { type PasswordStorageInterface } from '../../../domain/password/interfaces/password-storage.interface.js'; + +export class ValidatePasswordHistoryCommand extends Command { + constructor( + public readonly password: string, + public readonly targets: PasswordStorageInterface[], + ) { + super(); + } +} diff --git a/packages/nestjs-password/src/application/commands/impl/validate-password.command.ts b/packages/nestjs-password/src/application/commands/impl/validate-password.command.ts new file mode 100644 index 000000000..fd60ee0d5 --- /dev/null +++ b/packages/nestjs-password/src/application/commands/impl/validate-password.command.ts @@ -0,0 +1,9 @@ +import { Command } from '@nestjs/cqrs'; + +import { type PasswordValidateOptionsInterface } from '../../../domain/interfaces/password-validate-options.interface.js'; + +export class ValidatePasswordCommand extends Command { + constructor(public readonly options: PasswordValidateOptionsInterface) { + super(); + } +} diff --git a/packages/nestjs-password/src/config/password-default.config.spec.ts b/packages/nestjs-password/src/config/password-default.config.spec.ts deleted file mode 100644 index 7cb36a284..000000000 --- a/packages/nestjs-password/src/config/password-default.config.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { ConfigModule } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { PasswordOptionsInterface } from '../interfaces/password-options.interface'; - -import { passwordDefaultConfig } from './password-default.config'; - -describe('password configuration', () => { - let envOriginal: NodeJS.ProcessEnv; - - beforeEach(async () => { - envOriginal = process.env; - }); - - afterEach(async () => { - process.env = envOriginal; - jest.clearAllMocks(); - }); - - describe(passwordDefaultConfig.name, () => { - let moduleRef: TestingModule; - - it('should use fallbacks', async () => { - moduleRef = await Test.createTestingModule({ - imports: [ConfigModule.forFeature(passwordDefaultConfig)], - providers: [], - }).compile(); - - const config: PasswordOptionsInterface = - moduleRef.get(passwordDefaultConfig.KEY); - - expect(config).toMatchObject({ - maxPasswordAttempts: 3, - minPasswordStrength: 0, - }); - }); - - describe('passwordConfig', () => { - it('config', async () => { - const config = await passwordDefaultConfig(); - - expect(config.maxPasswordAttempts).toBe(3); - expect(config.minPasswordStrength).toBe(0); - }); - - it('configProcessNotNull', async () => { - process.env.PASSWORD_MAX_PASSWORD_ATTEMPTS = '1'; - process.env.PASSWORD_MIN_PASSWORD_STRENGTH = '2'; - - moduleRef = await Test.createTestingModule({ - imports: [ConfigModule.forFeature(passwordDefaultConfig)], - providers: [], - }).compile(); - - const config: PasswordOptionsInterface = - moduleRef.get(passwordDefaultConfig.KEY); - - expect(config).toMatchObject({ - maxPasswordAttempts: 1, - minPasswordStrength: 2, - }); - }); - - it('configProcessNull', async () => { - process.env.PASSWORD_MAX_PASSWORD_ATTEMPTS = 'test'; - process.env.PASSWORD_MIN_PASSWORD_STRENGTH = 'test'; - - moduleRef = await Test.createTestingModule({ - imports: [ConfigModule.forFeature(passwordDefaultConfig)], - providers: [], - }).compile(); - - const config: PasswordOptionsInterface = - moduleRef.get(passwordDefaultConfig.KEY); - - expect(config).toMatchObject({ - maxPasswordAttempts: NaN, - minPasswordStrength: NaN, - }); - }); - - it('configProcessNull', async () => { - delete process.env.PASSWORD_MAX_PASSWORD_ATTEMPTS; - delete process.env.PASSWORD_MIN_PASSWORD_STRENGTH; - - moduleRef = await Test.createTestingModule({ - imports: [ConfigModule.forFeature(passwordDefaultConfig)], - providers: [], - }).compile(); - - const config: PasswordOptionsInterface = - moduleRef.get(passwordDefaultConfig.KEY); - - expect(config).toMatchObject({ - maxPasswordAttempts: 3, - minPasswordStrength: 0, - }); - }); - }); - }); -}); diff --git a/packages/nestjs-password/src/config/password-default.config.ts b/packages/nestjs-password/src/config/password-default.config.ts deleted file mode 100644 index 1f5aec5d7..000000000 --- a/packages/nestjs-password/src/config/password-default.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { PasswordSettingsInterface } from '../interfaces/password-settings.interface'; -import { PASSWORD_MODULE_DEFAULT_SETTINGS_TOKEN } from '../password.constants'; - -/** - * Default password settings configuration. - */ -export const passwordDefaultConfig = registerAs( - PASSWORD_MODULE_DEFAULT_SETTINGS_TOKEN, - (): PasswordSettingsInterface => ({ - maxPasswordAttempts: process.env.PASSWORD_MAX_PASSWORD_ATTEMPTS - ? Number.parseInt(process.env.PASSWORD_MAX_PASSWORD_ATTEMPTS) - : 3, - - minPasswordStrength: process.env.PASSWORD_MIN_PASSWORD_STRENGTH - ? Number.parseInt(process.env.PASSWORD_MIN_PASSWORD_STRENGTH) - : process.env?.NODE_ENV === 'production' - ? 4 - : 0, - - requireCurrentToUpdate: - process.env?.PASSWORD_REQUIRE_CURRENT_TO_UPDATE === 'true' ? true : false, - }), -); diff --git a/packages/nestjs-password/src/enum/password-strength.enum.ts b/packages/nestjs-password/src/domain/enum/password-strength.enum.ts similarity index 100% rename from packages/nestjs-password/src/enum/password-strength.enum.ts rename to packages/nestjs-password/src/domain/enum/password-strength.enum.ts diff --git a/packages/nestjs-password/src/domain/exceptions/password-current-required.exception.ts b/packages/nestjs-password/src/domain/exceptions/password-current-required.exception.ts new file mode 100644 index 000000000..660b230c9 --- /dev/null +++ b/packages/nestjs-password/src/domain/exceptions/password-current-required.exception.ts @@ -0,0 +1,18 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { PasswordException } from './password.exception.js'; + +export class PasswordCurrentRequiredException extends PasswordException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Current password is required', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + + this.errorCode = 'PASSWORD_CURRENT_REQUIRED_ERROR'; + } +} diff --git a/packages/nestjs-password/src/domain/exceptions/password-not-strong.exception.ts b/packages/nestjs-password/src/domain/exceptions/password-not-strong.exception.ts new file mode 100644 index 000000000..0afebffe5 --- /dev/null +++ b/packages/nestjs-password/src/domain/exceptions/password-not-strong.exception.ts @@ -0,0 +1,18 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { PasswordException } from './password.exception.js'; + +export class PasswordNotStrongException extends PasswordException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Password is not strong enough', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + + this.errorCode = 'PASSWORD_NOT_STRONG_ERROR'; + } +} diff --git a/packages/nestjs-password/src/domain/exceptions/password-required.exception.ts b/packages/nestjs-password/src/domain/exceptions/password-required.exception.ts new file mode 100644 index 000000000..93b5bef88 --- /dev/null +++ b/packages/nestjs-password/src/domain/exceptions/password-required.exception.ts @@ -0,0 +1,18 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { PasswordException } from './password.exception.js'; + +export class PasswordRequiredException extends PasswordException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Password is required for hashing, but none was provided.', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + + this.errorCode = 'PASSWORD_REQUIRED_ERROR'; + } +} diff --git a/packages/nestjs-password/src/domain/exceptions/password-used-recently.exception.ts b/packages/nestjs-password/src/domain/exceptions/password-used-recently.exception.ts new file mode 100644 index 000000000..45eba4798 --- /dev/null +++ b/packages/nestjs-password/src/domain/exceptions/password-used-recently.exception.ts @@ -0,0 +1,19 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeExceptionOptions } from '@concepta/nestjs-core'; + +import { PasswordException } from './password.exception.js'; + +export class PasswordUsedRecentlyException extends PasswordException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: + 'The new password has been used too recently, please use a different password', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + ...options, + }); + + this.errorCode = 'PASSWORD_USED_RECENTLY_ERROR'; + } +} diff --git a/packages/nestjs-password/src/exceptions/password.exception.ts b/packages/nestjs-password/src/domain/exceptions/password.exception.ts similarity index 76% rename from packages/nestjs-password/src/exceptions/password.exception.ts rename to packages/nestjs-password/src/domain/exceptions/password.exception.ts index 113bc5fd3..90c60924c 100644 --- a/packages/nestjs-password/src/exceptions/password.exception.ts +++ b/packages/nestjs-password/src/domain/exceptions/password.exception.ts @@ -1,7 +1,7 @@ import { RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; export class PasswordException extends RuntimeException { constructor(options?: RuntimeExceptionOptions) { diff --git a/packages/nestjs-password/src/domain/interfaces/password-creation-service.interface.ts b/packages/nestjs-password/src/domain/interfaces/password-creation-service.interface.ts new file mode 100644 index 000000000..c0277bd24 --- /dev/null +++ b/packages/nestjs-password/src/domain/interfaces/password-creation-service.interface.ts @@ -0,0 +1,36 @@ +import { type PasswordStorageInterface } from '../password/interfaces/password-storage.interface.js'; + +import { type PasswordCurrentPasswordInterface } from './password-current-password.interface.js'; +import { type PasswordHistoryPasswordInterface } from './password-history-password.interface.js'; + +/** + * Password Creation Service Interface + */ +export interface PasswordCreationServiceInterface { + /** + * Create a hashed password. + * + * @param password - Password to be hashed + */ + create(password: string): Promise; + + /** + * Validate the current password for the targeted object. + * + * @param options - Validate current options. + * @returns boolean + */ + validateCurrent: ( + options: Partial, + ) => Promise; + + /** + * Validate the array of password stores to check for previous usage. + * + * @param options - Validate history options. + * @returns boolean Returns true if password has NOT been used within configured range. + */ + validateHistory: ( + options: PasswordHistoryPasswordInterface, + ) => Promise; +} diff --git a/packages/nestjs-password/src/domain/interfaces/password-current-password.interface.ts b/packages/nestjs-password/src/domain/interfaces/password-current-password.interface.ts new file mode 100644 index 000000000..c1b31ea08 --- /dev/null +++ b/packages/nestjs-password/src/domain/interfaces/password-current-password.interface.ts @@ -0,0 +1,6 @@ +import { type PasswordPlainInterface } from '../password/interfaces/password-plain.interface.js'; +import { type PasswordStorageInterface } from '../password/interfaces/password-storage.interface.js'; + +export interface PasswordCurrentPasswordInterface extends PasswordPlainInterface { + target: PasswordStorageInterface; +} diff --git a/packages/nestjs-password/src/domain/interfaces/password-hash-object-options.interface.ts b/packages/nestjs-password/src/domain/interfaces/password-hash-object-options.interface.ts new file mode 100644 index 000000000..a94caf045 --- /dev/null +++ b/packages/nestjs-password/src/domain/interfaces/password-hash-object-options.interface.ts @@ -0,0 +1,6 @@ +export interface PasswordHashObjectOptionsInterface { + /** + * Set to true if password is required. + */ + required?: boolean; +} diff --git a/packages/nestjs-password/src/domain/interfaces/password-history-password.interface.ts b/packages/nestjs-password/src/domain/interfaces/password-history-password.interface.ts new file mode 100644 index 000000000..b490919f6 --- /dev/null +++ b/packages/nestjs-password/src/domain/interfaces/password-history-password.interface.ts @@ -0,0 +1,6 @@ +import { type PasswordPlainInterface } from '../password/interfaces/password-plain.interface.js'; +import { type PasswordStorageInterface } from '../password/interfaces/password-storage.interface.js'; + +export interface PasswordHistoryPasswordInterface extends PasswordPlainInterface { + targets: PasswordStorageInterface[]; +} diff --git a/packages/nestjs-password/src/domain/interfaces/password-storage-service.interface.ts b/packages/nestjs-password/src/domain/interfaces/password-storage-service.interface.ts new file mode 100644 index 000000000..0bb6089b4 --- /dev/null +++ b/packages/nestjs-password/src/domain/interfaces/password-storage-service.interface.ts @@ -0,0 +1,30 @@ +import { type PasswordPlainInterface } from '../password/interfaces/password-plain.interface.js'; +import { type PasswordStorageInterface } from '../password/interfaces/password-storage.interface.js'; + +import { type PasswordHashObjectOptionsInterface } from './password-hash-object-options.interface.js'; + +/** + * Password Storage Service Interface + */ +export interface PasswordStorageServiceInterface { + /** + * Hash a password using bcrypt. + * + * @param password - Password to be hashed + */ + hash(password: string): Promise; + + /** + * Hash password for an object. + * + * @param object - An object containing the new password to hash. + * @param options - Hash object options + * @returns A new object with the password hashed. + */ + hashObject( + object: T, + options?: PasswordHashObjectOptionsInterface, + ): Promise< + Omit | (Omit & PasswordStorageInterface) + >; +} diff --git a/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts b/packages/nestjs-password/src/domain/interfaces/password-strength-service.interface.ts similarity index 100% rename from packages/nestjs-password/src/interfaces/password-strength-service.interface.ts rename to packages/nestjs-password/src/domain/interfaces/password-strength-service.interface.ts diff --git a/packages/nestjs-password/src/domain/interfaces/password-validate-options.interface.ts b/packages/nestjs-password/src/domain/interfaces/password-validate-options.interface.ts new file mode 100644 index 000000000..dc1e7bb55 --- /dev/null +++ b/packages/nestjs-password/src/domain/interfaces/password-validate-options.interface.ts @@ -0,0 +1,5 @@ +import { type PasswordPlainInterface } from '../password/interfaces/password-plain.interface.js'; +import { type PasswordStorageInterface } from '../password/interfaces/password-storage.interface.js'; + +export interface PasswordValidateOptionsInterface + extends PasswordPlainInterface, PasswordStorageInterface {} diff --git a/packages/nestjs-password/src/domain/interfaces/password-validation-service.interface.ts b/packages/nestjs-password/src/domain/interfaces/password-validation-service.interface.ts new file mode 100644 index 000000000..b3a35ec2e --- /dev/null +++ b/packages/nestjs-password/src/domain/interfaces/password-validation-service.interface.ts @@ -0,0 +1,13 @@ +import { type PasswordValidateOptionsInterface } from './password-validate-options.interface.js'; + +/** + * Password Storage Validation Interface + */ +export interface PasswordValidationServiceInterface { + /** + * Validate if password matches and its valid. + * + * @param options - Validation options + */ + validate(options: PasswordValidateOptionsInterface): Promise; +} diff --git a/packages/nestjs-common/src/domain/password/interfaces/password-plain-current.interface.ts b/packages/nestjs-password/src/domain/password/interfaces/password-plain-current.interface.ts similarity index 100% rename from packages/nestjs-common/src/domain/password/interfaces/password-plain-current.interface.ts rename to packages/nestjs-password/src/domain/password/interfaces/password-plain-current.interface.ts diff --git a/packages/nestjs-common/src/domain/password/interfaces/password-plain.interface.ts b/packages/nestjs-password/src/domain/password/interfaces/password-plain.interface.ts similarity index 100% rename from packages/nestjs-common/src/domain/password/interfaces/password-plain.interface.ts rename to packages/nestjs-password/src/domain/password/interfaces/password-plain.interface.ts diff --git a/packages/nestjs-password/src/domain/password/interfaces/password-storage.interface.ts b/packages/nestjs-password/src/domain/password/interfaces/password-storage.interface.ts new file mode 100644 index 000000000..49056f334 --- /dev/null +++ b/packages/nestjs-password/src/domain/password/interfaces/password-storage.interface.ts @@ -0,0 +1,9 @@ +/** + * Password storage interface + */ +export interface PasswordStorageInterface { + /** + * Hashed password (bcrypt format, salt embedded) + */ + passwordHash: string; +} diff --git a/packages/nestjs-password/src/domain/password/interfaces/password-update.interface.ts b/packages/nestjs-password/src/domain/password/interfaces/password-update.interface.ts new file mode 100644 index 000000000..181178538 --- /dev/null +++ b/packages/nestjs-password/src/domain/password/interfaces/password-update.interface.ts @@ -0,0 +1,8 @@ +import { type PasswordPlainCurrentInterface } from './password-plain-current.interface.js'; +import { type PasswordPlainInterface } from './password-plain.interface.js'; + +/** + * Password update interface combining new password with optional current password. + */ +export interface PasswordUpdateInterface + extends PasswordPlainInterface, Partial {} diff --git a/packages/nestjs-password/src/domain/password/is-password-storage.typeguard.ts b/packages/nestjs-password/src/domain/password/is-password-storage.typeguard.ts new file mode 100644 index 000000000..4d3ea80f0 --- /dev/null +++ b/packages/nestjs-password/src/domain/password/is-password-storage.typeguard.ts @@ -0,0 +1,9 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type PasswordStorageInterface } from './interfaces/password-storage.interface.js'; + +export function isPasswordStorage( + target: unknown, +): target is PasswordStorageInterface { + return typeof (target as PlainLiteralObject)?.passwordHash === 'string'; +} diff --git a/packages/nestjs-password/src/domain/policies/password.policy.ts b/packages/nestjs-password/src/domain/policies/password.policy.ts new file mode 100644 index 000000000..1d8264d45 --- /dev/null +++ b/packages/nestjs-password/src/domain/policies/password.policy.ts @@ -0,0 +1,27 @@ +import { PasswordStrengthEnum } from '../enum/password-strength.enum.js'; + +export interface PasswordPolicySettings { + minPasswordStrength?: PasswordStrengthEnum; + requireCurrentToUpdate?: boolean; +} + +const DEFAULTS: Required = { + minPasswordStrength: PasswordStrengthEnum.None, + requireCurrentToUpdate: false, +}; + +export class PasswordPolicy { + private readonly settings: Required; + + constructor(settings?: PasswordPolicySettings) { + this.settings = { ...DEFAULTS, ...settings }; + } + + get minPasswordStrength(): PasswordStrengthEnum { + return this.settings.minPasswordStrength; + } + + get requireCurrentToUpdate(): boolean { + return this.settings.requireCurrentToUpdate; + } +} diff --git a/packages/nestjs-password/src/domain/services/password-creation.service.spec.ts b/packages/nestjs-password/src/domain/services/password-creation.service.spec.ts new file mode 100644 index 000000000..6ba6962ad --- /dev/null +++ b/packages/nestjs-password/src/domain/services/password-creation.service.spec.ts @@ -0,0 +1,158 @@ +import { PasswordStrengthEnum } from '../enum/password-strength.enum.js'; +import { PasswordNotStrongException } from '../exceptions/password-not-strong.exception.js'; +import { PasswordUsedRecentlyException } from '../exceptions/password-used-recently.exception.js'; +import { type PasswordStorageInterface } from '../password/interfaces/password-storage.interface.js'; +import { + PasswordPolicy, + type PasswordPolicySettings, +} from '../policies/password.policy.js'; + +import { PasswordCreationService } from './password-creation.service.js'; +import { PasswordStorageService } from './password-storage.service.js'; +import { PasswordStrengthService } from './password-strength.service.js'; +import { PasswordValidationService } from './password-validation.service.js'; + +describe(PasswordCreationService, () => { + let policySettings: PasswordPolicySettings; + let policy: PasswordPolicy; + let passwordCreationService: PasswordCreationService; + let passwordStorageService: PasswordStorageService; + let passwordValidationService: PasswordValidationService; + let passwordStrengthService: PasswordStrengthService; + + const PASSWORD_WEAK = 'secret'; + const PASSWORD_MEDIUM = 'F*h#1d*fQ@XB'; + + beforeEach(async () => { + policySettings = { + minPasswordStrength: PasswordStrengthEnum.Medium, + requireCurrentToUpdate: false, + }; + policy = new PasswordPolicy(policySettings); + + passwordStorageService = new PasswordStorageService(); + passwordValidationService = new PasswordValidationService(); + passwordStrengthService = new PasswordStrengthService(policy); + + passwordCreationService = new PasswordCreationService( + policy, + passwordStorageService, + passwordValidationService, + passwordStrengthService, + ); + }); + + it('should be defined', () => { + expect(passwordCreationService).toBeDefined(); + }); + + describe(PasswordCreationService.prototype.create, () => { + it('should create a password on object WITHOUT current password requirement', async () => { + // encrypt password + const passwordStorageObject: PasswordStorageInterface = + await passwordCreationService.create(PASSWORD_MEDIUM); + + expect(typeof passwordStorageObject.passwordHash).toEqual('string'); + }); + + it('should NOT create a password on object WITH a WEAK password', async () => { + const t = async () => { + // try to create on object with a weak password + await passwordCreationService.create(PASSWORD_WEAK); + }; + + await expect(t).rejects.toThrow(PasswordNotStrongException); + await expect(t).rejects.toThrow('Password is not strong enough'); + }); + }); + + describe(PasswordCreationService.prototype.validateCurrent, () => { + it('should be validated', async () => { + // encrypt "current" password + const passwordStorageObjectCurrent: PasswordStorageInterface = + await passwordStorageService.hashObject({ + password: 'current-password-string', + }); + + const isValid = await passwordCreationService.validateCurrent({ + password: 'current-password-string', + target: passwordStorageObjectCurrent, + }); + + expect(isValid).toEqual(true); + }); + + it('should NOT be validated', async () => { + // encrypt "current" password + const passwordStorageObjectCurrent: PasswordStorageInterface = + await passwordStorageService.hashObject({ + password: 'current-password-string', + }); + + const isValid = await passwordCreationService.validateCurrent({ + password: 'bad-current-password-string', + target: passwordStorageObjectCurrent, + }); + + expect(isValid).toEqual(false); + }); + + it('should NOT throw an error due to required current password setting', async () => { + const isValid = await passwordCreationService.validateCurrent({}); + expect(isValid).toEqual(true); + }); + + it('should throw an error due to required current password setting', async () => { + const strictPolicy = new PasswordPolicy({ + ...policySettings, + requireCurrentToUpdate: true, + }); + const strictService = new PasswordCreationService( + strictPolicy, + passwordStorageService, + passwordValidationService, + passwordStrengthService, + ); + + const t = async () => { + await strictService.validateCurrent({}); + }; + + await expect(t).rejects.toThrow(Error); + await expect(t).rejects.toThrow('Current password is required'); + }); + }); + + describe(PasswordCreationService.prototype.validateHistory, () => { + it('should return true when no targets match', async () => { + const stored = await passwordStorageService.hash('old-password'); + + const isValid = await passwordCreationService.validateHistory({ + password: 'different-password', + targets: [stored], + }); + + expect(isValid).toEqual(true); + }); + + it('should throw when password matches a target', async () => { + const stored = await passwordStorageService.hash(PASSWORD_MEDIUM); + + await expect( + passwordCreationService.validateHistory({ + password: PASSWORD_MEDIUM, + targets: [stored], + }), + ).rejects.toThrow(PasswordUsedRecentlyException); + }); + + it('should return true when targets array is empty', async () => { + const isValid = await passwordCreationService.validateHistory({ + password: PASSWORD_MEDIUM, + targets: [], + }); + + expect(isValid).toEqual(true); + }); + }); +}); diff --git a/packages/nestjs-password/src/domain/services/password-creation.service.ts b/packages/nestjs-password/src/domain/services/password-creation.service.ts new file mode 100644 index 000000000..b1570789c --- /dev/null +++ b/packages/nestjs-password/src/domain/services/password-creation.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; + +import { PasswordCurrentRequiredException } from '../exceptions/password-current-required.exception.js'; +import { PasswordNotStrongException } from '../exceptions/password-not-strong.exception.js'; +import { PasswordUsedRecentlyException } from '../exceptions/password-used-recently.exception.js'; +import { PasswordCreationServiceInterface } from '../interfaces/password-creation-service.interface.js'; +import { PasswordCurrentPasswordInterface } from '../interfaces/password-current-password.interface.js'; +import { PasswordHistoryPasswordInterface } from '../interfaces/password-history-password.interface.js'; +import { PasswordStorageInterface } from '../password/interfaces/password-storage.interface.js'; +import { PasswordPolicy } from '../policies/password.policy.js'; + +import { PasswordStorageService } from './password-storage.service.js'; +import { PasswordStrengthService } from './password-strength.service.js'; +import { PasswordValidationService } from './password-validation.service.js'; + +/** + * Service with functions related to password creation + */ +@Injectable() +export class PasswordCreationService implements PasswordCreationServiceInterface { + constructor( + private readonly policy: PasswordPolicy, + protected readonly passwordStorageService: PasswordStorageService, + protected readonly passwordValidationService: PasswordValidationService, + protected readonly passwordStrengthService: PasswordStrengthService, + ) {} + + /** + * Create a hashed password. + * + * @param password - Password to be hashed + */ + async create(password: string): Promise { + if (!this.passwordStrengthService.isStrong(password)) { + throw new PasswordNotStrongException(); + } + + return this.passwordStorageService.hash(password); + } + + public async validateCurrent( + options: Partial, + ): Promise { + const { password, target: object } = options; + + // make sure the password is a string with some length + if (typeof password === 'string' && password.length > 0 && object) { + // validate it + return this.passwordValidationService.validate({ password, ...object }); + } else { + // settings say that current password is required? + if (this.policy.requireCurrentToUpdate) { + // reqs not met, throw exception + throw new PasswordCurrentRequiredException(); + } + } + + // valid by default + return true; + } + + public async validateHistory( + options: PasswordHistoryPasswordInterface, + ): Promise { + const { password, targets } = options; + + // make sure the password is a string with some length + if ( + typeof password === 'string' && + password.length > 0 && + targets?.length + ) { + // validate each target + for (const target of targets) { + // check if historic password is valid + const isValid = await this.passwordValidationService.validate({ + password, + passwordHash: target.passwordHash, + }); + + // is valid? + if (isValid) { + throw new PasswordUsedRecentlyException(); + } + } + } + + // valid by default + return true; + } +} diff --git a/packages/nestjs-password/src/domain/services/password-storage.service.spec.ts b/packages/nestjs-password/src/domain/services/password-storage.service.spec.ts new file mode 100644 index 000000000..825302ca5 --- /dev/null +++ b/packages/nestjs-password/src/domain/services/password-storage.service.spec.ts @@ -0,0 +1,92 @@ +import { PasswordRequiredException } from '../exceptions/password-required.exception.js'; +import { type PasswordStorageInterface } from '../password/interfaces/password-storage.interface.js'; + +import { PasswordStorageService } from './password-storage.service.js'; +import { PasswordValidationService } from './password-validation.service.js'; + +describe(PasswordStorageService, () => { + let storageService: PasswordStorageService; + let validationService: PasswordValidationService; + + const PASSWORD_MEDIUM = 'AS12378'; + + beforeEach(async () => { + storageService = new PasswordStorageService(); + validationService = new PasswordValidationService(); + }); + + it('should be defined', () => { + expect(storageService).toBeDefined(); + }); + + describe(PasswordStorageService.prototype.hash, () => { + it('should generate a password hash', async () => { + const result: PasswordStorageInterface = + await storageService.hash(PASSWORD_MEDIUM); + + expect(typeof result.passwordHash).toEqual('string'); + expect(result.passwordHash.length).toBeGreaterThan(0); + + const isValid = await validationService.validate({ + password: PASSWORD_MEDIUM, + passwordHash: result.passwordHash, + }); + + expect(isValid).toEqual(true); + }); + + it('should generate different hashes for same password', async () => { + const result1 = await storageService.hash(PASSWORD_MEDIUM); + const result2 = await storageService.hash(PASSWORD_MEDIUM); + + expect(result1.passwordHash).not.toEqual(result2.passwordHash); + }); + }); + + describe(PasswordStorageService.prototype.hashObject, () => { + it('should generate a password hash on object', async () => { + const result: PasswordStorageInterface = await storageService.hashObject( + { password: PASSWORD_MEDIUM }, + { required: true }, + ); + + expect(typeof result.passwordHash).toEqual('string'); + + const isValid = await validationService.validate({ + password: PASSWORD_MEDIUM, + ...result, + }); + + expect(isValid).toEqual(true); + }); + + it('should generate a password hash on object with default options', async () => { + const result: PasswordStorageInterface = await storageService.hashObject({ + password: PASSWORD_MEDIUM, + }); + + expect(typeof result.passwordHash).toEqual('string'); + + const isValid = await validationService.validate({ + password: PASSWORD_MEDIUM, + ...result, + }); + + expect(isValid).toEqual(true); + }); + + it('should NOT generate a password on object (not provided)', async () => { + const result = await storageService.hashObject({}, { required: false }); + + expect('passwordHash' in result).toEqual(false); + }); + + it('should FAIL to generate a password on object (not provided, but required)', async () => { + const t = async () => { + await storageService.hashObject({}, { required: true }); + }; + + await expect(t).rejects.toThrow(PasswordRequiredException); + }); + }); +}); diff --git a/packages/nestjs-password/src/domain/services/password-storage.service.ts b/packages/nestjs-password/src/domain/services/password-storage.service.ts new file mode 100644 index 000000000..68669be16 --- /dev/null +++ b/packages/nestjs-password/src/domain/services/password-storage.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; + +import { PasswordRequiredException } from '../exceptions/password-required.exception.js'; +import { PasswordHashObjectOptionsInterface } from '../interfaces/password-hash-object-options.interface.js'; +import { PasswordStorageServiceInterface } from '../interfaces/password-storage-service.interface.js'; +import { PasswordPlainInterface } from '../password/interfaces/password-plain.interface.js'; +import { PasswordStorageInterface } from '../password/interfaces/password-storage.interface.js'; +import { CryptUtil } from '../utils/crypt.util.js'; + +/** + * Service with functions related to password security + */ +@Injectable() +export class PasswordStorageService implements PasswordStorageServiceInterface { + /** + * Hash a password using bcrypt. + * + * @param password - Password to be hashed + */ + async hash(password: string): Promise { + return { + passwordHash: await CryptUtil.hashPassword(password), + }; + } + + /** + * Hash password for an object. + * + * @param object - An object containing the new password to hash. + * @param options - Hash object options + * @returns A new object with the password hashed. + */ + async hashObject( + object: T, + options?: PasswordHashObjectOptionsInterface, + ): Promise & PasswordStorageInterface>; + + /** + * Hash password for an object if the password property exists. + * + * @param object - An object containing the new password to hash. + * @param options - Hash object options + * @returns A new object with the password hashed. + */ + async hashObject( + object: Partial, + options?: PasswordHashObjectOptionsInterface, + ): Promise< + Omit | (Omit & PasswordStorageInterface) + >; + + /** + * Hash password for an object. + * + * @param object - An object containing the new password to hash. + * @param options - Hash object options + * @returns A new object with the password hashed. + */ + async hashObject( + object: T, + options?: PasswordHashObjectOptionsInterface, + ): Promise< + Omit | (Omit & PasswordStorageInterface) + > { + const { required = true } = options ?? {}; + const { password, ...safeObject } = object; + + if (typeof password === 'string') { + const hashed = await this.hash(password); + return { ...safeObject, ...hashed }; + } else if (required === true) { + throw new PasswordRequiredException(); + } + + return safeObject; + } +} diff --git a/packages/nestjs-password/src/domain/services/password-strength.service.spec.ts b/packages/nestjs-password/src/domain/services/password-strength.service.spec.ts new file mode 100644 index 000000000..32ef415ac --- /dev/null +++ b/packages/nestjs-password/src/domain/services/password-strength.service.spec.ts @@ -0,0 +1,75 @@ +import { PasswordStrengthEnum } from '../enum/password-strength.enum.js'; +import { + PasswordPolicy, + type PasswordPolicySettings, +} from '../policies/password.policy.js'; + +import { PasswordStrengthService } from './password-strength.service.js'; + +const PASSWORD_NONE = 'password'; +const PASSWORD_WEAK = 'A12345678'; +const PASSWORD_MEDIUM = 'AS12378'; +const PASSWORD_STRONG = 'P@S645R78'; +const PASSWORD_VERY_STRONG = 'P@5_0d645s9'; + +function createService( + settings: PasswordPolicySettings = {}, +): PasswordStrengthService { + return new PasswordStrengthService(new PasswordPolicy(settings)); +} + +describe('PasswordStrengthService', () => { + it('should be defined', () => { + const service = createService({ + minPasswordStrength: PasswordStrengthEnum.Medium, + }); + expect(service).toBeDefined(); + }); + + it('should accept password when min strength is None', () => { + const service = createService({ + minPasswordStrength: PasswordStrengthEnum.None, + }); + expect(service.isStrong(PASSWORD_NONE)).toBe(true); + }); + + it('should accept password when min strength is Weak', () => { + const service = createService({ + minPasswordStrength: PasswordStrengthEnum.Weak, + }); + expect(service.isStrong(PASSWORD_WEAK)).toBe(true); + }); + + it('should accept password when min strength is Medium', () => { + const service = createService({ + minPasswordStrength: PasswordStrengthEnum.Medium, + }); + expect(service.isStrong(PASSWORD_MEDIUM)).toBe(true); + }); + + it('should accept password when min strength is Strong', () => { + const service = createService({ + minPasswordStrength: PasswordStrengthEnum.Strong, + }); + expect(service.isStrong(PASSWORD_STRONG)).toBe(true); + }); + + it('should accept very strong password when min strength is Strong', () => { + const service = createService({ + minPasswordStrength: PasswordStrengthEnum.Strong, + }); + expect(service.isStrong(PASSWORD_VERY_STRONG)).toBe(true); + }); + + it('should reject medium password when min strength is Strong', () => { + const service = createService({ + minPasswordStrength: PasswordStrengthEnum.Strong, + }); + expect(service.isStrong(PASSWORD_MEDIUM)).toBe(false); + }); + + it('should default to None when settings are empty', () => { + const service = createService({}); + expect(service.isStrong(PASSWORD_NONE)).toBe(true); + }); +}); diff --git a/packages/nestjs-password/src/domain/services/password-strength.service.ts b/packages/nestjs-password/src/domain/services/password-strength.service.ts new file mode 100644 index 000000000..eb231ec9b --- /dev/null +++ b/packages/nestjs-password/src/domain/services/password-strength.service.ts @@ -0,0 +1,25 @@ +import zxcvbn from 'zxcvbn'; + +import { Injectable } from '@nestjs/common'; + +import { PasswordStrengthServiceInterface } from '../interfaces/password-strength-service.interface.js'; +import { PasswordPolicy } from '../policies/password.policy.js'; + +/** + * Service to validate password strength + */ +@Injectable() +export class PasswordStrengthService implements PasswordStrengthServiceInterface { + constructor(private readonly policy: PasswordPolicy) {} + + /** + * Method to check if password is strong + * + * @param password - the plain text password + * @returns password strength + */ + isStrong(password: string): boolean { + const result = zxcvbn(password); + return result.score >= this.policy.minPasswordStrength; + } +} diff --git a/packages/nestjs-password/src/domain/services/password-validation.service.spec.ts b/packages/nestjs-password/src/domain/services/password-validation.service.spec.ts new file mode 100644 index 000000000..20db82372 --- /dev/null +++ b/packages/nestjs-password/src/domain/services/password-validation.service.spec.ts @@ -0,0 +1,55 @@ +import { type PasswordStorageInterface } from '../password/interfaces/password-storage.interface.js'; + +import { PasswordStorageService } from './password-storage.service.js'; +import { PasswordValidationService } from './password-validation.service.js'; + +describe('PasswordValidationService', () => { + let storageService: PasswordStorageService; + let validationService: PasswordValidationService; + + const PASSWORD_MEDIUM = 'AS12378'; + + beforeEach(async () => { + storageService = new PasswordStorageService(); + validationService = new PasswordValidationService(); + }); + + it('should be defined', () => { + expect(validationService).toBeDefined(); + }); + + describe(PasswordValidationService.prototype.validate, () => { + it('should successfully validate a correct password', async () => { + const stored: PasswordStorageInterface = + await storageService.hash(PASSWORD_MEDIUM); + + const isValid = await validationService.validate({ + password: PASSWORD_MEDIUM, + passwordHash: stored.passwordHash, + }); + + expect(isValid).toEqual(true); + }); + + it('should NOT validate an incorrect password', async () => { + const stored: PasswordStorageInterface = + await storageService.hash(PASSWORD_MEDIUM); + + const isValid = await validationService.validate({ + password: 'wrong-password', + passwordHash: stored.passwordHash, + }); + + expect(isValid).toEqual(false); + }); + + it('should NOT validate against an invalid hash', async () => { + const isValid = await validationService.validate({ + password: PASSWORD_MEDIUM, + passwordHash: 'not-a-valid-bcrypt-hash', + }); + + expect(isValid).toEqual(false); + }); + }); +}); diff --git a/packages/nestjs-password/src/domain/services/password-validation.service.ts b/packages/nestjs-password/src/domain/services/password-validation.service.ts new file mode 100644 index 000000000..f8b7bbd32 --- /dev/null +++ b/packages/nestjs-password/src/domain/services/password-validation.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; + +import { PasswordValidateOptionsInterface } from '../interfaces/password-validate-options.interface.js'; +import { PasswordValidationServiceInterface } from '../interfaces/password-validation-service.interface.js'; +import { CryptUtil } from '../utils/crypt.util.js'; + +/** + * Service with functions related to password validation + */ +@Injectable() +export class PasswordValidationService implements PasswordValidationServiceInterface { + async validate(options: PasswordValidateOptionsInterface): Promise { + return CryptUtil.validatePassword(options.password, options.passwordHash); + } +} diff --git a/packages/nestjs-password/src/domain/utils/crypt.util.ts b/packages/nestjs-password/src/domain/utils/crypt.util.ts new file mode 100644 index 000000000..34bf3bad7 --- /dev/null +++ b/packages/nestjs-password/src/domain/utils/crypt.util.ts @@ -0,0 +1,28 @@ +import * as bcrypt from 'bcrypt'; + +/** + * Abstract class with functions to encapsulate hash methods + */ +export abstract class CryptUtil { + /** + * Hash a password using bcrypt (salt is generated and embedded automatically). + * + * @param password - The plain text password to hash + */ + static async hashPassword(password: string): Promise { + return bcrypt.hash(password, await bcrypt.genSalt()); + } + + /** + * Validate a plain password against a bcrypt hash using constant-time comparison. + * + * @param passwordPlain - The plain password + * @param passwordHash - The bcrypt hash (salt embedded) + */ + static async validatePassword( + passwordPlain: string, + passwordHash: string, + ): Promise { + return bcrypt.compare(passwordPlain, passwordHash); + } +} diff --git a/packages/nestjs-password/src/exceptions/password-current-required.exception.ts b/packages/nestjs-password/src/exceptions/password-current-required.exception.ts deleted file mode 100644 index 2affd3dd3..000000000 --- a/packages/nestjs-password/src/exceptions/password-current-required.exception.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { PasswordException } from './password.exception'; - -export class PasswordCurrentRequiredException extends PasswordException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: 'Current password is required', - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'PASSWORD_CURRENT_REQUIRED_ERROR'; - } -} diff --git a/packages/nestjs-password/src/exceptions/password-not-strong.exception.ts b/packages/nestjs-password/src/exceptions/password-not-strong.exception.ts deleted file mode 100644 index fcd11f03b..000000000 --- a/packages/nestjs-password/src/exceptions/password-not-strong.exception.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { PasswordException } from './password.exception'; - -export class PasswordNotStrongException extends PasswordException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: 'Password is not strong enough', - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'PASSWORD_NOT_STRONG_ERROR'; - } -} diff --git a/packages/nestjs-password/src/exceptions/password-required.exception.ts b/packages/nestjs-password/src/exceptions/password-required.exception.ts deleted file mode 100644 index 429b40c17..000000000 --- a/packages/nestjs-password/src/exceptions/password-required.exception.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { PasswordException } from './password.exception'; - -export class PasswordRequiredException extends PasswordException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: 'Password is required for hashing, but non was provided.', - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'PASSWORD_REQUIRED_ERROR'; - } -} diff --git a/packages/nestjs-password/src/exceptions/password-used-recently.exception.ts b/packages/nestjs-password/src/exceptions/password-used-recently.exception.ts deleted file mode 100644 index 746103f63..000000000 --- a/packages/nestjs-password/src/exceptions/password-used-recently.exception.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { PasswordException } from './password.exception'; - -export class PasswordUsedRecentlyException extends PasswordException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: - 'The new password has been used too recently, please use a different password', - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'PASSWORD_USED_RECENTLY_ERROR'; - } -} diff --git a/packages/nestjs-password/src/index.ts b/packages/nestjs-password/src/index.ts index fcf967330..8290d5366 100644 --- a/packages/nestjs-password/src/index.ts +++ b/packages/nestjs-password/src/index.ts @@ -1,19 +1,57 @@ -export * from './password.module'; +// module +export { PasswordModule } from './password.module.js'; -export * from './enum/password-strength.enum'; +// enum +export { PasswordStrengthEnum } from './domain/enum/password-strength.enum.js'; -export * from './services/password-creation.service'; -export * from './services/password-storage.service'; -export * from './services/password-validation.service'; -export * from './services/password-strength.service'; +// password primitives +export { PasswordPlainInterface } from './domain/password/interfaces/password-plain.interface.js'; +export { PasswordPlainCurrentInterface } from './domain/password/interfaces/password-plain-current.interface.js'; +export { PasswordStorageInterface } from './domain/password/interfaces/password-storage.interface.js'; +export { PasswordUpdateInterface } from './domain/password/interfaces/password-update.interface.js'; +export { isPasswordStorage } from './domain/password/is-password-storage.typeguard.js'; -export * from './interfaces/password-options.interface'; -export * from './interfaces/password-storage-service.interface'; -export * from './interfaces/password-validation-service.interface'; -export * from './interfaces/password-creation-service.interface'; +// domain policies +export { + PasswordPolicy, + PasswordPolicySettings, +} from './domain/policies/password.policy.js'; -export { PasswordException } from './exceptions/password.exception'; -export { PasswordCurrentRequiredException } from './exceptions/password-current-required.exception'; -export { PasswordNotStrongException } from './exceptions/password-not-strong.exception'; -export { PasswordRequiredException } from './exceptions/password-required.exception'; -export { PasswordUsedRecentlyException } from './exceptions/password-used-recently.exception'; +// domain services +export { PasswordCreationService } from './domain/services/password-creation.service.js'; +export { PasswordStorageService } from './domain/services/password-storage.service.js'; +export { PasswordValidationService } from './domain/services/password-validation.service.js'; +export { PasswordStrengthService } from './domain/services/password-strength.service.js'; + +// domain interfaces +export { PasswordCreationServiceInterface } from './domain/interfaces/password-creation-service.interface.js'; +export { PasswordStorageServiceInterface } from './domain/interfaces/password-storage-service.interface.js'; +export { PasswordValidationServiceInterface } from './domain/interfaces/password-validation-service.interface.js'; +export { PasswordStrengthServiceInterface } from './domain/interfaces/password-strength-service.interface.js'; +export { PasswordHashObjectOptionsInterface } from './domain/interfaces/password-hash-object-options.interface.js'; +export { PasswordValidateOptionsInterface } from './domain/interfaces/password-validate-options.interface.js'; +export { PasswordCurrentPasswordInterface } from './domain/interfaces/password-current-password.interface.js'; +export { PasswordHistoryPasswordInterface } from './domain/interfaces/password-history-password.interface.js'; + +// config interfaces +export { PasswordOptionsInterface } from './infrastructure/config/interfaces/password-options.interface.js'; +export { PasswordSettingsInterface } from './infrastructure/config/interfaces/password-settings.interface.js'; + +// commands +export { CreatePasswordCommand } from './application/commands/impl/create-password.command.js'; +export { ValidatePasswordCommand } from './application/commands/impl/validate-password.command.js'; +export { ValidateCurrentPasswordCommand } from './application/commands/impl/validate-current-password.command.js'; +export { ValidatePasswordHistoryCommand } from './application/commands/impl/validate-password-history.command.js'; + +// command handlers +export { CreatePasswordHandler } from './application/commands/handlers/create-password.handler.js'; +export { ValidatePasswordHandler } from './application/commands/handlers/validate-password.handler.js'; +export { ValidateCurrentPasswordHandler } from './application/commands/handlers/validate-current-password.handler.js'; +export { ValidatePasswordHistoryHandler } from './application/commands/handlers/validate-password-history.handler.js'; + +// domain exceptions +export { PasswordException } from './domain/exceptions/password.exception.js'; +export { PasswordCurrentRequiredException } from './domain/exceptions/password-current-required.exception.js'; +export { PasswordNotStrongException } from './domain/exceptions/password-not-strong.exception.js'; +export { PasswordRequiredException } from './domain/exceptions/password-required.exception.js'; +export { PasswordUsedRecentlyException } from './domain/exceptions/password-used-recently.exception.js'; diff --git a/packages/nestjs-password/src/infrastructure/config/interfaces/password-options-extras.interface.ts b/packages/nestjs-password/src/infrastructure/config/interfaces/password-options-extras.interface.ts new file mode 100644 index 000000000..47c926e9e --- /dev/null +++ b/packages/nestjs-password/src/infrastructure/config/interfaces/password-options-extras.interface.ts @@ -0,0 +1,6 @@ +import { type DynamicModule } from '@nestjs/common'; + +export interface PasswordOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-password/src/infrastructure/config/interfaces/password-options.interface.ts b/packages/nestjs-password/src/infrastructure/config/interfaces/password-options.interface.ts new file mode 100644 index 000000000..524f09807 --- /dev/null +++ b/packages/nestjs-password/src/infrastructure/config/interfaces/password-options.interface.ts @@ -0,0 +1,8 @@ +import { type PasswordSettingsInterface } from './password-settings.interface.js'; + +/** + * Password module configuration options interface + */ +export interface PasswordOptionsInterface { + settings?: PasswordSettingsInterface; +} diff --git a/packages/nestjs-password/src/infrastructure/config/interfaces/password-settings.interface.ts b/packages/nestjs-password/src/infrastructure/config/interfaces/password-settings.interface.ts new file mode 100644 index 000000000..dcfd024a8 --- /dev/null +++ b/packages/nestjs-password/src/infrastructure/config/interfaces/password-settings.interface.ts @@ -0,0 +1,16 @@ +import { type PasswordStrengthEnum } from '../../../domain/enum/password-strength.enum.js'; + +/** + * Password module settings interface + */ +export interface PasswordSettingsInterface { + /** + * Min level of password strength allowed + */ + minPasswordStrength?: PasswordStrengthEnum; + + /** + * Require current password to update + */ + requireCurrentToUpdate?: boolean; +} diff --git a/packages/nestjs-password/src/infrastructure/config/password-default.config.spec.ts b/packages/nestjs-password/src/infrastructure/config/password-default.config.spec.ts new file mode 100644 index 000000000..04abcc018 --- /dev/null +++ b/packages/nestjs-password/src/infrastructure/config/password-default.config.spec.ts @@ -0,0 +1,96 @@ +import { ConfigModule } from '@nestjs/config'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { type PasswordOptionsInterface } from './interfaces/password-options.interface.js'; +import { passwordDefaultConfig } from './password-default.config.js'; + +describe('password configuration', () => { + let envOriginal: NodeJS.ProcessEnv; + + beforeEach(async () => { + envOriginal = process.env; + }); + + afterEach(async () => { + process.env = envOriginal; + vi.clearAllMocks(); + }); + + describe(passwordDefaultConfig.name, () => { + let moduleRef: TestingModule; + + it('should use fallbacks', async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forFeature(passwordDefaultConfig)], + providers: [], + }).compile(); + + const config: PasswordOptionsInterface = + moduleRef.get(passwordDefaultConfig.KEY); + + expect(config).toEqual({ + minPasswordStrength: 0, + requireCurrentToUpdate: false, + }); + }); + + describe('passwordConfig', () => { + it('should return defaults when called directly', async () => { + const config = await passwordDefaultConfig(); + + expect(config.minPasswordStrength).toBe(0); + }); + + it('should parse env vars as integers', async () => { + process.env.PASSWORD_MIN_PASSWORD_STRENGTH = '2'; + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forFeature(passwordDefaultConfig)], + providers: [], + }).compile(); + + const config: PasswordOptionsInterface = + moduleRef.get(passwordDefaultConfig.KEY); + + expect(config).toEqual({ + minPasswordStrength: 2, + requireCurrentToUpdate: false, + }); + }); + + it('should fall back to defaults for non-numeric env vars', async () => { + process.env.PASSWORD_MIN_PASSWORD_STRENGTH = 'test'; + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forFeature(passwordDefaultConfig)], + providers: [], + }).compile(); + + const config: PasswordOptionsInterface = + moduleRef.get(passwordDefaultConfig.KEY); + + expect(config).toEqual({ + minPasswordStrength: 0, + requireCurrentToUpdate: false, + }); + }); + + it('should use defaults when env vars are deleted', async () => { + delete process.env.PASSWORD_MIN_PASSWORD_STRENGTH; + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forFeature(passwordDefaultConfig)], + providers: [], + }).compile(); + + const config: PasswordOptionsInterface = + moduleRef.get(passwordDefaultConfig.KEY); + + expect(config).toEqual({ + minPasswordStrength: 0, + requireCurrentToUpdate: false, + }); + }); + }); + }); +}); diff --git a/packages/nestjs-password/src/infrastructure/config/password-default.config.ts b/packages/nestjs-password/src/infrastructure/config/password-default.config.ts new file mode 100644 index 000000000..9dc3e1d71 --- /dev/null +++ b/packages/nestjs-password/src/infrastructure/config/password-default.config.ts @@ -0,0 +1,24 @@ +import { registerAs } from '@nestjs/config'; + +import { PasswordStrengthEnum } from '../../domain/enum/password-strength.enum.js'; +import { PASSWORD_MODULE_DEFAULT_SETTINGS_TOKEN } from '../../password.constants.js'; + +import { type PasswordSettingsInterface } from './interfaces/password-settings.interface.js'; + +/** + * Default password settings configuration. + */ +export const passwordDefaultConfig = registerAs( + PASSWORD_MODULE_DEFAULT_SETTINGS_TOKEN, + (): PasswordSettingsInterface => ({ + minPasswordStrength: process.env.PASSWORD_MIN_PASSWORD_STRENGTH + ? Number.parseInt(process.env.PASSWORD_MIN_PASSWORD_STRENGTH, 10) || + PasswordStrengthEnum.None + : process.env?.NODE_ENV === 'production' + ? PasswordStrengthEnum.VeryStrong + : PasswordStrengthEnum.None, + + requireCurrentToUpdate: + process.env?.PASSWORD_REQUIRE_CURRENT_TO_UPDATE === 'true' ? true : false, + }), +); diff --git a/packages/nestjs-password/src/infrastructure/utils/create-password-policy-provider.ts b/packages/nestjs-password/src/infrastructure/utils/create-password-policy-provider.ts new file mode 100644 index 000000000..e8f7001cc --- /dev/null +++ b/packages/nestjs-password/src/infrastructure/utils/create-password-policy-provider.ts @@ -0,0 +1,14 @@ +import { type Provider } from '@nestjs/common'; + +import { PasswordPolicy } from '../../domain/policies/password.policy.js'; +import { PASSWORD_MODULE_SETTINGS_TOKEN } from '../../password.constants.js'; +import { type PasswordSettingsInterface } from '../config/interfaces/password-settings.interface.js'; + +export function createPasswordPolicyProvider(): Provider { + return { + provide: PasswordPolicy, + inject: [PASSWORD_MODULE_SETTINGS_TOKEN], + useFactory: (settings: PasswordSettingsInterface) => + new PasswordPolicy(settings), + }; +} diff --git a/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts b/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts deleted file mode 100644 index a912cc2e3..000000000 --- a/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { PasswordStorageInterface } from '@concepta/nestjs-common'; - -import { PasswordCurrentPasswordInterface } from './password-current-password.interface'; -import { PasswordHashOptionsInterface } from './password-hash-options.interface'; -import { PasswordHistoryPasswordInterface } from './password-history-password.interface'; - -/** - * Password Creation Service Interface - */ -export interface PasswordCreationServiceInterface { - /** - * Create a password using a salt, if no - * was passed, then generate one automatically. - * - * @param password - Password to be hashed - * @param options - Hash options - */ - create( - password: string, - options?: PasswordHashOptionsInterface, - ): Promise; - - /** - * Validate the current password for the targeted object. - * - * @param options - Validate current options. - * @returns boolean - */ - validateCurrent: ( - options: Partial, - ) => Promise; - - /** - * Validate the array of password stores to check for previous usage. - * - * @param options - Validate history options. - * @returns boolean Returns true if password has NOT been used withing configured range. - */ - validateHistory: ( - options: PasswordHistoryPasswordInterface, - ) => Promise; -} diff --git a/packages/nestjs-password/src/interfaces/password-current-password.interface.ts b/packages/nestjs-password/src/interfaces/password-current-password.interface.ts deleted file mode 100644 index f3730c324..000000000 --- a/packages/nestjs-password/src/interfaces/password-current-password.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - PasswordPlainInterface, - PasswordStorageInterface, -} from '@concepta/nestjs-common'; - -export interface PasswordCurrentPasswordInterface - extends PasswordPlainInterface { - target: PasswordStorageInterface; -} diff --git a/packages/nestjs-password/src/interfaces/password-hash-object-options.interface.ts b/packages/nestjs-password/src/interfaces/password-hash-object-options.interface.ts deleted file mode 100644 index 75ab68020..000000000 --- a/packages/nestjs-password/src/interfaces/password-hash-object-options.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PasswordSaltOptionInterface } from './password-salt-option.interface'; - -export interface PasswordHashObjectOptionsInterface - extends PasswordSaltOptionInterface { - /** - * Set to true if password is required. - */ - required?: boolean; -} diff --git a/packages/nestjs-password/src/interfaces/password-hash-options.interface.ts b/packages/nestjs-password/src/interfaces/password-hash-options.interface.ts deleted file mode 100644 index 3f7006cbc..000000000 --- a/packages/nestjs-password/src/interfaces/password-hash-options.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PasswordSaltOptionInterface } from './password-salt-option.interface'; - -export interface PasswordHashOptionsInterface - extends PasswordSaltOptionInterface {} diff --git a/packages/nestjs-password/src/interfaces/password-history-password.interface.ts b/packages/nestjs-password/src/interfaces/password-history-password.interface.ts deleted file mode 100644 index d0d452f71..000000000 --- a/packages/nestjs-password/src/interfaces/password-history-password.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - PasswordPlainInterface, - PasswordStorageInterface, -} from '@concepta/nestjs-common'; - -export interface PasswordHistoryPasswordInterface - extends PasswordPlainInterface { - targets: PasswordStorageInterface[]; -} diff --git a/packages/nestjs-password/src/interfaces/password-options-extras.interface.ts b/packages/nestjs-password/src/interfaces/password-options-extras.interface.ts deleted file mode 100644 index fe86030ba..000000000 --- a/packages/nestjs-password/src/interfaces/password-options-extras.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface PasswordOptionsExtrasInterface - extends Pick {} diff --git a/packages/nestjs-password/src/interfaces/password-options.interface.ts b/packages/nestjs-password/src/interfaces/password-options.interface.ts deleted file mode 100644 index 5ed1f8e25..000000000 --- a/packages/nestjs-password/src/interfaces/password-options.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { PasswordSettingsInterface } from './password-settings.interface'; - -/** - * Password module configuration options interface - */ -export interface PasswordOptionsInterface { - settings?: PasswordSettingsInterface; -} diff --git a/packages/nestjs-password/src/interfaces/password-salt-option.interface.ts b/packages/nestjs-password/src/interfaces/password-salt-option.interface.ts deleted file mode 100644 index 66413ece4..000000000 --- a/packages/nestjs-password/src/interfaces/password-salt-option.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface PasswordSaltOptionInterface { - /** - * Optional salt. If not provided, one will be generated. - */ - salt?: string; -} diff --git a/packages/nestjs-password/src/interfaces/password-settings.interface.ts b/packages/nestjs-password/src/interfaces/password-settings.interface.ts deleted file mode 100644 index 2b8ee0e6e..000000000 --- a/packages/nestjs-password/src/interfaces/password-settings.interface.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { PasswordStrengthEnum } from '../enum/password-strength.enum'; - -/** - * Password module settings interface - */ -export interface PasswordSettingsInterface { - /** - * Min level of password strength allowed - */ - minPasswordStrength?: PasswordStrengthEnum; - - /** - * Max number of password attempts allowed - */ - maxPasswordAttempts?: number; - - /** - * Require current password to update - */ - requireCurrentToUpdate?: boolean; -} diff --git a/packages/nestjs-password/src/interfaces/password-storage-service.interface.ts b/packages/nestjs-password/src/interfaces/password-storage-service.interface.ts deleted file mode 100644 index 6a9fe1b85..000000000 --- a/packages/nestjs-password/src/interfaces/password-storage-service.interface.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - PasswordPlainInterface, - PasswordStorageInterface, -} from '@concepta/nestjs-common'; - -import { PasswordHashObjectOptionsInterface } from './password-hash-object-options.interface'; -import { PasswordHashOptionsInterface } from './password-hash-options.interface'; - -/** - * Password Storage Service Interface - */ -export interface PasswordStorageServiceInterface { - /** - * Generate salt to be used to hash a password. - */ - generateSalt(): Promise; - - /** - * Hash a password using a salt, if no - * was passed, then generate one automatically. - * - * @param password - Password to be hashed - * @param options - Hash options - */ - hash( - password: string, - options?: PasswordHashOptionsInterface, - ): Promise; - - /** - * Hash password for an object. - * - * @param object - An object containing the new password to hash. - * @param options - Hash object options - * @returns A new object with the password hashed, with salt added. - */ - hashObject( - object: T, - options?: PasswordHashObjectOptionsInterface, - ): Promise< - Omit | (Omit & PasswordStorageInterface) - >; -} diff --git a/packages/nestjs-password/src/interfaces/password-validate-options.interface.ts b/packages/nestjs-password/src/interfaces/password-validate-options.interface.ts deleted file mode 100644 index e92a7160b..000000000 --- a/packages/nestjs-password/src/interfaces/password-validate-options.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { - PasswordPlainInterface, - PasswordStorageInterface, -} from '@concepta/nestjs-common'; - -export interface PasswordValidateOptionsInterface - extends PasswordPlainInterface, - PasswordStorageInterface {} diff --git a/packages/nestjs-password/src/interfaces/password-validation-service.interface.ts b/packages/nestjs-password/src/interfaces/password-validation-service.interface.ts deleted file mode 100644 index dc926bbeb..000000000 --- a/packages/nestjs-password/src/interfaces/password-validation-service.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { PasswordValidateOptionsInterface } from './password-validate-options.interface'; - -/** - * Password Storage Validation Interface - */ -export interface PasswordValidationServiceInterface { - /** - * Validate if password matches and its valid. - * - * @param options - Validation options - */ - validate(options: PasswordValidateOptionsInterface): Promise; -} diff --git a/packages/nestjs-password/src/password.constants.ts b/packages/nestjs-password/src/password.constants.ts index f0076e143..597f95b63 100644 --- a/packages/nestjs-password/src/password.constants.ts +++ b/packages/nestjs-password/src/password.constants.ts @@ -1,4 +1,3 @@ export const PASSWORD_MODULE_SETTINGS_TOKEN = 'PASSWORD_MODULE_SETTINGS_TOKEN'; export const PASSWORD_MODULE_DEFAULT_SETTINGS_TOKEN = 'PASSWORD_MODULE_DEFAULT_SETTINGS_TOKEN'; -export const PASSWORD_STORAGE_SERVICE_TOKEN = 'PASSWORD_STORAGE_SERVICE_TOKEN'; diff --git a/packages/nestjs-password/src/password.module-definition.ts b/packages/nestjs-password/src/password.module-definition.ts index e7e2553e4..bc1faed10 100644 --- a/packages/nestjs-password/src/password.module-definition.ts +++ b/packages/nestjs-password/src/password.module-definition.ts @@ -1,21 +1,28 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { CqrsModule } from '@nestjs/cqrs'; -import { createSettingsProvider } from '@concepta/nestjs-common'; +import { createSettingsProvider } from '@concepta/nestjs-core'; -import { passwordDefaultConfig } from './config/password-default.config'; -import { PasswordOptionsExtrasInterface } from './interfaces/password-options-extras.interface'; -import { PasswordOptionsInterface } from './interfaces/password-options.interface'; -import { PasswordSettingsInterface } from './interfaces/password-settings.interface'; -import { PASSWORD_MODULE_SETTINGS_TOKEN } from './password.constants'; -import { PasswordCreationService } from './services/password-creation.service'; -import { PasswordStorageService } from './services/password-storage.service'; -import { PasswordStrengthService } from './services/password-strength.service'; -import { PasswordValidationService } from './services/password-validation.service'; +import { CreatePasswordHandler } from './application/commands/handlers/create-password.handler.js'; +import { ValidateCurrentPasswordHandler } from './application/commands/handlers/validate-current-password.handler.js'; +import { ValidatePasswordHistoryHandler } from './application/commands/handlers/validate-password-history.handler.js'; +import { ValidatePasswordHandler } from './application/commands/handlers/validate-password.handler.js'; +import { PasswordPolicy } from './domain/policies/password.policy.js'; +import { PasswordCreationService } from './domain/services/password-creation.service.js'; +import { PasswordStorageService } from './domain/services/password-storage.service.js'; +import { PasswordStrengthService } from './domain/services/password-strength.service.js'; +import { PasswordValidationService } from './domain/services/password-validation.service.js'; +import { type PasswordOptionsExtrasInterface } from './infrastructure/config/interfaces/password-options-extras.interface.js'; +import { type PasswordOptionsInterface } from './infrastructure/config/interfaces/password-options.interface.js'; +import { type PasswordSettingsInterface } from './infrastructure/config/interfaces/password-settings.interface.js'; +import { passwordDefaultConfig } from './infrastructure/config/password-default.config.js'; +import { createPasswordPolicyProvider } from './infrastructure/utils/create-password-policy-provider.js'; +import { PASSWORD_MODULE_SETTINGS_TOKEN } from './password.constants.js'; const RAW_OPTIONS_TOKEN = Symbol('__PASSWORD_MODULE_RAW_OPTIONS_TOKEN__'); @@ -56,7 +63,7 @@ function definitionTransform( } export function createPasswordImports(): DynamicModule['imports'] { - return [ConfigModule.forFeature(passwordDefaultConfig)]; + return [ConfigModule.forFeature(passwordDefaultConfig), CqrsModule.forRoot()]; } export function createPasswordProviders(overrides: { @@ -66,10 +73,16 @@ export function createPasswordProviders(overrides: { return [ ...(overrides.providers ?? []), createPasswordSettingsProvider(overrides.options), + createPasswordPolicyProvider(), PasswordCreationService, PasswordStrengthService, PasswordStorageService, PasswordValidationService, + // command handlers + CreatePasswordHandler, + ValidatePasswordHandler, + ValidateCurrentPasswordHandler, + ValidatePasswordHistoryHandler, ]; } @@ -77,7 +90,7 @@ export function createPasswordExports(): Required< Pick >['exports'] { return [ - PASSWORD_MODULE_SETTINGS_TOKEN, + PasswordPolicy, PasswordCreationService, PasswordStrengthService, PasswordStorageService, diff --git a/packages/nestjs-password/src/password.module.ts b/packages/nestjs-password/src/password.module.ts index c1396500c..a4ae71c43 100644 --- a/packages/nestjs-password/src/password.module.ts +++ b/packages/nestjs-password/src/password.module.ts @@ -7,7 +7,7 @@ import { createPasswordImports, createPasswordProviders, createPasswordExports, -} from './password.module-definition'; +} from './password.module-definition.js'; @Module({}) export class PasswordModule extends PasswordModuleClass { diff --git a/packages/nestjs-password/src/services/password-creation.service.spec.ts b/packages/nestjs-password/src/services/password-creation.service.spec.ts deleted file mode 100644 index bf0b03492..000000000 --- a/packages/nestjs-password/src/services/password-creation.service.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { PasswordStorageInterface } from '@concepta/nestjs-common'; - -import { PasswordStrengthEnum } from '../enum/password-strength.enum'; -import { PasswordNotStrongException } from '../exceptions/password-not-strong.exception'; -import { PasswordSettingsInterface } from '../interfaces/password-settings.interface'; - -import { PasswordCreationService } from './password-creation.service'; -import { PasswordStorageService } from './password-storage.service'; -import { PasswordStrengthService } from './password-strength.service'; -import { PasswordValidationService } from './password-validation.service'; - -describe(PasswordCreationService, () => { - let config: PasswordSettingsInterface; - let passwordCreationService: PasswordCreationService; - let passwordStorageService: PasswordStorageService; - let passwordValidationService: PasswordValidationService; - let passwordStrengthService: PasswordStrengthService; - - const PASSWORD_WEAK = 'secret'; - const PASSWORD_MEDIUM = 'F*h#1d*fQ@XB'; - - beforeEach(async () => { - config = { - maxPasswordAttempts: 5, - minPasswordStrength: PasswordStrengthEnum.Medium, - requireCurrentToUpdate: false, - }; - - passwordStorageService = new PasswordStorageService(); - passwordValidationService = new PasswordValidationService(); - passwordStrengthService = new PasswordStrengthService(config); - - passwordCreationService = new PasswordCreationService( - config, - passwordStorageService, - passwordValidationService, - passwordStrengthService, - ); - }); - - it('should be defined', () => { - expect(passwordCreationService).toBeDefined(); - }); - - describe(PasswordCreationService.prototype.create, () => { - it('should create a password on object WITHOUT current password requirement', async () => { - // encrypt password - const passwordStorageObject: PasswordStorageInterface = - await passwordCreationService.create(PASSWORD_MEDIUM); - - expect(typeof passwordStorageObject.passwordHash).toEqual('string'); - expect(typeof passwordStorageObject.passwordSalt).toEqual('string'); - }); - - it('should NOT create a password on object WITH a WEAK password', async () => { - const t = async () => { - // try to create on object with a weak password - await passwordCreationService.create(PASSWORD_WEAK); - }; - - await expect(t).rejects.toThrow(PasswordNotStrongException); - await expect(t).rejects.toThrow('Password is not strong enough'); - }); - }); - - describe(PasswordCreationService.prototype.validateCurrent, () => { - it('should be validated', async () => { - // encrypt "current" password - const passwordStorageObjectCurrent: PasswordStorageInterface = - await passwordStorageService.hashObject({ - password: 'current-password-string', - }); - - const isValid = await passwordCreationService.validateCurrent({ - password: 'current-password-string', - target: passwordStorageObjectCurrent, - }); - - expect(isValid).toEqual(true); - }); - - it('should NOT be validated', async () => { - // encrypt "current" password - const passwordStorageObjectCurrent: PasswordStorageInterface = - await passwordStorageService.hashObject({ - password: 'current-password-string', - }); - - const isValid = await passwordCreationService.validateCurrent({ - password: 'bad-current-password-string', - target: passwordStorageObjectCurrent, - }); - - expect(isValid).toEqual(false); - }); - - it('should NOT throw an error due to required current password setting', async () => { - const isValid = await passwordCreationService.validateCurrent({}); - expect(isValid).toEqual(true); - }); - - it('should throw an error due to required current password setting', async () => { - passwordCreationService['settings'].requireCurrentToUpdate = true; - - const t = async () => { - await passwordCreationService.validateCurrent({}); - }; - - await expect(t).rejects.toThrow(Error); - await expect(t).rejects.toThrow('Current password is required'); - }); - }); -}); diff --git a/packages/nestjs-password/src/services/password-creation.service.ts b/packages/nestjs-password/src/services/password-creation.service.ts deleted file mode 100644 index 0843e5215..000000000 --- a/packages/nestjs-password/src/services/password-creation.service.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { PasswordStorageInterface } from '@concepta/nestjs-common'; - -import { PasswordCurrentRequiredException } from '../exceptions/password-current-required.exception'; -import { PasswordNotStrongException } from '../exceptions/password-not-strong.exception'; -import { PasswordUsedRecentlyException } from '../exceptions/password-used-recently.exception'; -import { PasswordCreationServiceInterface } from '../interfaces/password-creation-service.interface'; -import { PasswordCurrentPasswordInterface } from '../interfaces/password-current-password.interface'; -import { PasswordHashOptionsInterface } from '../interfaces/password-hash-options.interface'; -import { PasswordHistoryPasswordInterface } from '../interfaces/password-history-password.interface'; -import { PasswordSettingsInterface } from '../interfaces/password-settings.interface'; -import { PASSWORD_MODULE_SETTINGS_TOKEN } from '../password.constants'; - -import { PasswordStorageService } from './password-storage.service'; -import { PasswordStrengthService } from './password-strength.service'; -import { PasswordValidationService } from './password-validation.service'; - -/** - * Service with functions related to password creation - * to check if password is strong, and the number of attempts user can do to update a password - */ -@Injectable() -export class PasswordCreationService - implements PasswordCreationServiceInterface -{ - /** - * Constructor - */ - constructor( - @Inject(PASSWORD_MODULE_SETTINGS_TOKEN) - protected readonly settings: PasswordSettingsInterface, - protected readonly passwordStorageService: PasswordStorageService, - protected readonly passwordValidationService: PasswordValidationService, - protected readonly passwordStrengthService: PasswordStrengthService, - ) {} - - /** - * Create a hashed password using a salt, if no - * was passed, then generate one automatically. - * - * @param password - Password to be hashed - * @param options - Hash options - */ - create( - password: string, - options?: PasswordHashOptionsInterface, - ): Promise { - // check strength - if (!this.passwordStrengthService.isStrong(password)) { - throw new PasswordNotStrongException(); - } - - // hash it - return this.passwordStorageService.hash(password, options); - } - - public async validateCurrent( - options: Partial, - ): Promise { - const { password, target: object } = options || { - password: undefined, - target: undefined, - }; - - // make sure the password is a string with some length - if (typeof password === 'string' && password.length > 0 && object) { - // validate it - return this.passwordValidationService.validate({ password, ...object }); - } else { - // settings say that current password is required? - if (this.settings?.requireCurrentToUpdate === true) { - // reqs not met, throw exception - throw new PasswordCurrentRequiredException(); - } - } - - // valid by default - return true; - } - - public async validateHistory( - options: PasswordHistoryPasswordInterface, - ): Promise { - const { password, targets } = options || { - password: undefined, - targets: [], - }; - - // make sure the password is a string with some length - if ( - typeof password === 'string' && - password.length > 0 && - targets?.length - ) { - // validate each target - for (const target of targets) { - // check if historic password is valid - const isValid = await this.passwordValidationService.validate({ - password, - passwordHash: target.passwordHash, - passwordSalt: target.passwordSalt, - }); - - // is valid? - if (isValid) { - throw new PasswordUsedRecentlyException(); - } - } - } - - // valid by default - return true; - } -} diff --git a/packages/nestjs-password/src/services/password-storage.service.spec.ts b/packages/nestjs-password/src/services/password-storage.service.spec.ts deleted file mode 100644 index 71143a781..000000000 --- a/packages/nestjs-password/src/services/password-storage.service.spec.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { fail } from 'assert'; - -import { PasswordStorageInterface } from '@concepta/nestjs-common'; - -import { PasswordRequiredException } from '../exceptions/password-required.exception'; - -import { PasswordStorageService } from './password-storage.service'; -import { PasswordValidationService } from './password-validation.service'; - -describe(PasswordStorageService, () => { - let storageService: PasswordStorageService; - let validationService: PasswordValidationService; - - const PASSWORD_MEDIUM = 'AS12378'; - const PASSWORD_SALT = '$2b$10$aTP7AiVn2vWNiPg8/pQH3e'; - - beforeEach(async () => { - storageService = new PasswordStorageService(); - validationService = new PasswordValidationService(); - }); - - it('should be defined', () => { - expect(storageService).toBeDefined(); - }); - - describe(PasswordStorageService.prototype.generateSalt, () => { - it('salt should be a string', async () => { - const salt = await storageService.generateSalt(); - - expect(salt).not.toBeUndefined(); - expect(salt).not.toBeNull(); - expect(typeof salt).toEqual('string'); - }); - }); - - describe(PasswordStorageService.prototype.hash, () => { - it('should generate a password hash without providing a salt', async () => { - // encrypt password - const passwordStorageObject: PasswordStorageInterface = - await storageService.hash(PASSWORD_MEDIUM); - - expect(typeof passwordStorageObject.passwordSalt).toEqual('string'); - - // check if password encrypt can be decrypted - const isValid = await validationService.validate({ - password: PASSWORD_MEDIUM, - passwordHash: passwordStorageObject.passwordHash ?? '', - passwordSalt: passwordStorageObject.passwordSalt ?? '', - }); - - expect(isValid).toEqual(true); - }); - - it('should generate a password hash from provided salt', async () => { - // encrypt password - const passwordStorageObject: PasswordStorageInterface = - await storageService.hash(PASSWORD_MEDIUM, { - salt: PASSWORD_SALT, - }); - - expect(passwordStorageObject.passwordSalt).toEqual(PASSWORD_SALT); - - // check if password encrypt can be decrypted - const isValid = await validationService.validate({ - password: PASSWORD_MEDIUM, - passwordHash: passwordStorageObject.passwordHash ?? '', - passwordSalt: passwordStorageObject.passwordSalt ?? '', - }); - - expect(isValid).toEqual(true); - }); - }); - - describe(PasswordStorageService.prototype.hashObject, () => { - it('should generate a password on object without providing a salt', async () => { - // encrypt password - const passwordStorageObject: PasswordStorageInterface = - await storageService.hashObject( - { password: PASSWORD_MEDIUM }, - { - required: true, - }, - ); - - expect(typeof passwordStorageObject.passwordSalt).toEqual('string'); - - // check if password encrypt can be decrypted - const isValid = await validationService.validate({ - password: PASSWORD_MEDIUM, - ...passwordStorageObject, - }); - - expect(isValid).toEqual(true); - }); - - it('should generate a password on object with provided salt', async () => { - // encrypt password - const passwordStorageObject: PasswordStorageInterface = - await storageService.hashObject( - { - password: PASSWORD_MEDIUM, - }, - { - salt: PASSWORD_SALT, - }, - ); - - expect(passwordStorageObject.passwordSalt).toEqual(PASSWORD_SALT); - - // check if password encrypt can be decrypted - const isValid = await validationService.validate({ - password: PASSWORD_MEDIUM, - ...passwordStorageObject, - }); - - expect(isValid).toEqual(true); - }); - - it('should generate a password on object without providing a salt', async () => { - // encrypt password - const passwordStorageObject: Partial = - await storageService.hashObject({ password: PASSWORD_MEDIUM }); - - expect(typeof passwordStorageObject.passwordSalt).toEqual('string'); - - if ( - typeof passwordStorageObject.passwordHash === 'string' && - typeof passwordStorageObject.passwordSalt === 'string' - ) { - // check if password encrypt can be decrypted - const isValid = await validationService.validate({ - password: PASSWORD_MEDIUM, - ...(passwordStorageObject as PasswordStorageInterface), - }); - - expect(isValid).toEqual(true); - } else { - fail(); - } - }); - - it('should generate a password on object with provided salt', async () => { - // encrypt password - const passwordStorageObject: Partial = - await storageService.hashObject( - { password: PASSWORD_MEDIUM }, - { - salt: PASSWORD_SALT, - }, - ); - - expect(passwordStorageObject.passwordSalt).toEqual(PASSWORD_SALT); - - if ( - typeof passwordStorageObject.passwordHash === 'string' && - typeof passwordStorageObject.passwordSalt === 'string' - ) { - // check if password encrypt can be decrypted - const isValid = await validationService.validate({ - password: PASSWORD_MEDIUM, - ...(passwordStorageObject as PasswordStorageInterface), - }); - - expect(isValid).toEqual(true); - } else { - fail(); - } - }); - - it('should NOT generate a password on object (non provided)', async () => { - // encrypt password - const passwordStorageObject: Partial = - await storageService.hashObject({}, { required: false }); - - expect(typeof passwordStorageObject.passwordHash).toEqual('undefined'); - expect(typeof passwordStorageObject.passwordSalt).toEqual('undefined'); - }); - - it('should FAIL to generate a password on object (non provided, but required)', async () => { - const t = async () => { - // encrypt password - await storageService.hashObject({}, { required: true }); - }; - - await expect(t).rejects.toThrow(PasswordRequiredException); - await expect(t).rejects.toThrow( - 'Password is required for hashing, but non was provided.', - ); - }); - }); -}); diff --git a/packages/nestjs-password/src/services/password-storage.service.ts b/packages/nestjs-password/src/services/password-storage.service.ts deleted file mode 100644 index a695dfb54..000000000 --- a/packages/nestjs-password/src/services/password-storage.service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - PasswordPlainInterface, - PasswordStorageInterface, -} from '@concepta/nestjs-common'; - -import { PasswordRequiredException } from '../exceptions/password-required.exception'; -import { PasswordHashObjectOptionsInterface } from '../interfaces/password-hash-object-options.interface'; -import { PasswordHashOptionsInterface } from '../interfaces/password-hash-options.interface'; -import { PasswordStorageServiceInterface } from '../interfaces/password-storage-service.interface'; -import { CryptUtil } from '../utils/crypt.util'; - -/** - * Service with functions related to password security - */ -@Injectable() -export class PasswordStorageService implements PasswordStorageServiceInterface { - /** - * Generate Salts to safeguard passwords in storage. - */ - async generateSalt(): Promise { - return CryptUtil.generateSalt(); - } - - /** - * Hash a password using a salt, if no - * was passed, then one will be generated. - * - * @param password - Password to be hashed - * @param options - Hash options - */ - async hash( - password: string, - options?: PasswordHashOptionsInterface, - ): Promise { - let { salt } = options ?? {}; - if (!salt) salt = await this.generateSalt(); - - return { - passwordHash: await CryptUtil.hashPassword(password, salt), - passwordSalt: salt, - }; - } - - /** - * Hash password for an object. - * - * @param object - An object containing the new password to hash. - * @param options - Hash object options - * @returns A new object with the password hashed, with salt added. - */ - async hashObject( - object: T, - options?: PasswordHashObjectOptionsInterface, - ): Promise & PasswordStorageInterface>; - - /** - * Hash password for an object if the password property exists. - * - * @param object - An object containing the new password to hash. - * @param options - Hash object options - * @returns A new object with the password hashed, with salt added. - */ - async hashObject( - object: Partial, - options?: PasswordHashObjectOptionsInterface, - ): Promise< - Omit | (Omit & PasswordStorageInterface) - >; - - /** - * Hash password for an object. - * - * @param object - An object containing the new password to hash. - * @param options - Hash object options - * @returns A new object with the password hashed, with salt added. - */ - async hashObject( - object: T, - options?: PasswordHashObjectOptionsInterface, - ): Promise< - Omit | (Omit & PasswordStorageInterface) - > { - // extract password property - const { salt, required = true } = options ?? {}; - const { password, ...safeObject } = object; - - // is the password in the object? - if (typeof password === 'string') { - // hash the password - const hashed = await this.hash(password, { salt }); - - // return the object with password hashed - return { - ...safeObject, - ...hashed, - }; - } else if (required === true) { - // password is required, not good - throw new PasswordRequiredException(); - } - - return safeObject; - } -} diff --git a/packages/nestjs-password/src/services/password-strength.service.spec.ts b/packages/nestjs-password/src/services/password-strength.service.spec.ts deleted file mode 100644 index 0f56cf39c..000000000 --- a/packages/nestjs-password/src/services/password-strength.service.spec.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { PasswordStrengthEnum } from '../enum/password-strength.enum'; -import { PASSWORD_MODULE_SETTINGS_TOKEN } from '../password.constants'; - -import { PasswordStrengthService } from './password-strength.service'; - -describe('PasswordStrengthService', () => { - let service: PasswordStrengthService; - const PASSWORD_NONE = 'password'; - const PASSWORD_WEAK = 'A12345678'; - const PASSWORD_MEDIUM = 'AS12378'; - const PASSWORD_STRONG = 'P@S645R78'; - const PASSWORD_VERY_STRONG = 'P@5_0d645s9'; - - it('should be defined', async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - { - provide: PASSWORD_MODULE_SETTINGS_TOKEN, - useValue: { - maxPasswordAttempts: 5, - minPasswordStrength: PasswordStrengthEnum.Medium, - }, - }, - PasswordStrengthService, - ], - }).compile(); - - service = module.get(PasswordStrengthService); - expect(service).toBeDefined(); - }); - - it('PasswordStrengthService.isStrong-None', async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - { - provide: PASSWORD_MODULE_SETTINGS_TOKEN, - useValue: { - maxPasswordAttempts: 5, - minPasswordStrength: PasswordStrengthEnum.None, - }, - }, - PasswordStrengthService, - ], - }).compile(); - - service = module.get(PasswordStrengthService); - - const isStrong = service.isStrong(PASSWORD_NONE); - - expect(isStrong).toBe(true); - }); - - it('PasswordStrengthService.isStrong-Weak', async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - { - provide: PASSWORD_MODULE_SETTINGS_TOKEN, - useValue: { - maxPasswordAttempts: 5, - minPasswordStrength: PasswordStrengthEnum.Weak, - }, - }, - PasswordStrengthService, - ], - }).compile(); - - service = module.get(PasswordStrengthService); - - const isStrong = service.isStrong(PASSWORD_WEAK); - - expect(isStrong).toBe(true); - }); - - it('PasswordStrengthService.isStrong-Medium', async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - { - provide: PASSWORD_MODULE_SETTINGS_TOKEN, - useValue: { - minPasswordStrength: PasswordStrengthEnum.Medium, - }, - }, - PasswordStrengthService, - ], - }).compile(); - - service = module.get(PasswordStrengthService); - - const isStrong = service.isStrong(PASSWORD_MEDIUM); - - expect(isStrong).toBe(true); - }); - - it('PasswordStrengthService.isStrong-Strong', async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - { - provide: PASSWORD_MODULE_SETTINGS_TOKEN, - useValue: { - minPasswordStrength: PasswordStrengthEnum.Strong, - }, - }, - PasswordStrengthService, - ], - }).compile(); - - service = module.get(PasswordStrengthService); - - const isStrong = service.isStrong(PASSWORD_STRONG); - - expect(isStrong).toBe(true); - }); - - it('PasswordStrengthService.isStrong-VeryStrong', async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - { - provide: PASSWORD_MODULE_SETTINGS_TOKEN, - useValue: { - minPasswordStrength: PasswordStrengthEnum.Strong, - }, - }, - PasswordStrengthService, - ], - }).compile(); - - service = module.get(PasswordStrengthService); - - const isStrong = service.isStrong(PASSWORD_VERY_STRONG); - - expect(isStrong).toBe(true); - }); - - it('PasswordStrengthService.isStrong-Strong_Medium', async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - { - provide: PASSWORD_MODULE_SETTINGS_TOKEN, - useValue: { - minPasswordStrength: PasswordStrengthEnum.Strong, - }, - }, - PasswordStrengthService, - ], - }).compile(); - - service = module.get(PasswordStrengthService); - - const isStrong = service.isStrong(PASSWORD_MEDIUM); - - expect(isStrong).toBe(false); - }); - - it('PasswordStrengthService.isStrong-Strong_None', async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - { - provide: PASSWORD_MODULE_SETTINGS_TOKEN, - useValue: {}, - }, - PasswordStrengthService, - ], - }).compile(); - - service = module.get(PasswordStrengthService); - - const isStrong = service.isStrong(PASSWORD_NONE); - - expect(isStrong).toBe(true); - }); -}); diff --git a/packages/nestjs-password/src/services/password-strength.service.ts b/packages/nestjs-password/src/services/password-strength.service.ts deleted file mode 100644 index 46170f487..000000000 --- a/packages/nestjs-password/src/services/password-strength.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import zxcvbn from 'zxcvbn'; - -import { Inject, Injectable } from '@nestjs/common'; - -import { PasswordStrengthEnum } from '../enum/password-strength.enum'; -import { PasswordSettingsInterface } from '../interfaces/password-settings.interface'; -import { PasswordStrengthServiceInterface } from '../interfaces/password-strength-service.interface'; -import { PASSWORD_MODULE_SETTINGS_TOKEN } from '../password.constants'; - -/** - * Service to validate password strength - */ -@Injectable() -export class PasswordStrengthService - implements PasswordStrengthServiceInterface -{ - /** - * @param settings - Password module settings - */ - constructor( - @Inject(PASSWORD_MODULE_SETTINGS_TOKEN) - protected readonly settings: PasswordSettingsInterface, - ) {} - - /** - * Method to check if password is strong - * - * @param password - the plain text password - * @returns password strength - */ - isStrong(password: string): boolean { - // Get min password Strength - const minStrength = - this.settings?.minPasswordStrength || PasswordStrengthEnum.None; - - // check strength of the password - const result = zxcvbn(password); - - // Check if is strong based on configuration - return result.score >= minStrength; - } -} diff --git a/packages/nestjs-password/src/services/password-validation.service.spec.ts b/packages/nestjs-password/src/services/password-validation.service.spec.ts deleted file mode 100644 index 24cd76481..000000000 --- a/packages/nestjs-password/src/services/password-validation.service.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { PasswordStorageInterface } from '@concepta/nestjs-common'; - -import { PasswordStorageService } from './password-storage.service'; -import { PasswordValidationService } from './password-validation.service'; - -describe('PasswordValidationService', () => { - let storageService: PasswordStorageService; - let validationService: PasswordValidationService; - - const PASSWORD_MEDIUM = 'AS12378'; - - beforeEach(async () => { - storageService = new PasswordStorageService(); - validationService = new PasswordValidationService(); - }); - - it('should be defined', () => { - expect(validationService).toBeDefined(); - }); - - describe(PasswordValidationService.prototype.validate, () => { - it('should successfully validate a good hash/salt combination', async () => { - // Encrypt password - const passwordStorageObject: PasswordStorageInterface = - await storageService.hash(PASSWORD_MEDIUM); - - // check if password encrypt can be decrypted - const isValid = await validationService.validate({ - password: PASSWORD_MEDIUM, - passwordHash: passwordStorageObject.passwordHash ?? '', - passwordSalt: passwordStorageObject.passwordSalt ?? '', - }); - - expect(isValid).toEqual(true); - }); - - it('should NOT successfully validate a bad hash/salt combination', async () => { - // fake salt - const fakeSalt = await storageService.generateSalt(); - - // check if password encrypt can be decrypted - const isValid = await validationService.validate({ - password: PASSWORD_MEDIUM, - passwordHash: 'foo', - passwordSalt: fakeSalt, - }); - - expect(isValid).toEqual(false); - }); - }); -}); diff --git a/packages/nestjs-password/src/services/password-validation.service.ts b/packages/nestjs-password/src/services/password-validation.service.ts deleted file mode 100644 index bcdfa2d49..000000000 --- a/packages/nestjs-password/src/services/password-validation.service.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { PasswordValidateOptionsInterface } from '../interfaces/password-validate-options.interface'; -import { PasswordValidationServiceInterface } from '../interfaces/password-validation-service.interface'; -import { CryptUtil } from '../utils/crypt.util'; - -/** - * Service with functions related to password validation - */ -@Injectable() -export class PasswordValidationService - implements PasswordValidationServiceInterface -{ - async validate(options: PasswordValidateOptionsInterface): Promise { - return CryptUtil.validatePassword( - options.password, - options.passwordHash, - options.passwordSalt, - ); - } -} diff --git a/packages/nestjs-password/src/utils/crypt.util.ts b/packages/nestjs-password/src/utils/crypt.util.ts deleted file mode 100644 index 418079329..000000000 --- a/packages/nestjs-password/src/utils/crypt.util.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as bcrypt from 'bcrypt'; - -import { PasswordException } from '../exceptions/password.exception'; - -/** - * Abstract class with functions to encapsulate hash methods - */ -export abstract class CryptUtil { - /** - * Generate Salt - * - * @returns Generate - */ - static async generateSalt(): Promise { - return bcrypt.genSalt(); - } - - /** - * @param password - The plain text password to hash - * @param salt - The salt to use when hashing the password - */ - static async hashPassword(password: string, salt: string): Promise { - // must have a password - if (password.length && salt.length) { - return bcrypt.hash(password, salt); - } else { - throw new PasswordException({ - message: - 'Must have non-zero length password and salt in order to hash.', - }); - } - } - - /** - * Validate password with the hash password - * - * @param passwordPlain - The plain password - * @param passwordHash - The encrypted password - * @param passwordSalt - The salt - */ - static async validatePassword( - passwordPlain: string, - passwordHash: string, - passwordSalt: string, - ): Promise { - const hash = await this.hashPassword(passwordPlain, passwordSalt); - return hash === passwordHash; - } -} diff --git a/packages/nestjs-password/tsconfig.json b/packages/nestjs-password/tsconfig.json index ef9980950..edc11225e 100644 --- a/packages/nestjs-password/tsconfig.json +++ b/packages/nestjs-password/tsconfig.json @@ -4,10 +4,13 @@ "composite": true, "rootDir": "./src", "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", "typeRoots": [ "./node_modules/@types", - "../../node_modules/@types" - ] + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] }, "include": [ "src/**/*.ts" diff --git a/packages/nestjs-report/package.json b/packages/nestjs-report/package.json index a9362a1dd..c2a0e48d0 100644 --- a/packages/nestjs-report/package.json +++ b/packages/nestjs-report/package.json @@ -15,7 +15,7 @@ "@concepta/nestjs-file": "^7.0.0-alpha.10", "@nestjs/common": "^11.1.9", "@nestjs/config": "^4.0.2", - "@nestjs/swagger": "^11.2.2" + "@nestjs/swagger": "11.2.2" }, "devDependencies": { "@concepta/nestjs-typeorm-ext": "^7.0.0-alpha.10", diff --git a/packages/nestjs-report/src/__fixtures__/aws-storage.service.ts b/packages/nestjs-report/src/__fixtures__/aws-storage.service.ts index e35b8b98c..741393893 100644 --- a/packages/nestjs-report/src/__fixtures__/aws-storage.service.ts +++ b/packages/nestjs-report/src/__fixtures__/aws-storage.service.ts @@ -1,5 +1,5 @@ -import { FileInterface } from '@concepta/nestjs-common'; -import { FileStorageServiceInterface } from '@concepta/nestjs-file'; +import { type FileInterface } from '@concepta/nestjs-common'; +import { type FileStorageServiceInterface } from '@concepta/nestjs-file'; import { AWS_KEY_FIXTURE, diff --git a/packages/nestjs-report/src/__fixtures__/my-report-generator-short-delay.service.ts b/packages/nestjs-report/src/__fixtures__/my-report-generator-short-delay.service.ts index add6b5ffb..18791f11b 100644 --- a/packages/nestjs-report/src/__fixtures__/my-report-generator-short-delay.service.ts +++ b/packages/nestjs-report/src/__fixtures__/my-report-generator-short-delay.service.ts @@ -12,9 +12,7 @@ import { REPORT_SHORT_DELAY_KEY_FIXTURE, } from './constants.fixture'; -export class MyReportGeneratorShortDelayService - implements ReportGeneratorServiceInterface -{ +export class MyReportGeneratorShortDelayService implements ReportGeneratorServiceInterface { constructor( @Inject(FileService) private readonly fileService: FileService, diff --git a/packages/nestjs-report/src/__fixtures__/my-report-generator.service.ts b/packages/nestjs-report/src/__fixtures__/my-report-generator.service.ts index 4013329b5..23e73bd3e 100644 --- a/packages/nestjs-report/src/__fixtures__/my-report-generator.service.ts +++ b/packages/nestjs-report/src/__fixtures__/my-report-generator.service.ts @@ -8,9 +8,7 @@ import { ReportGeneratorServiceInterface } from '../interfaces/report-generator- import { AWS_KEY_FIXTURE, REPORT_KEY_FIXTURE } from './constants.fixture'; -export class MyReportGeneratorService - implements ReportGeneratorServiceInterface -{ +export class MyReportGeneratorService implements ReportGeneratorServiceInterface { constructor( @Inject(FileService) private readonly fileService: FileService, diff --git a/packages/nestjs-report/src/config/report-default.config.ts b/packages/nestjs-report/src/config/report-default.config.ts index 8eb28d515..cf5f63be0 100644 --- a/packages/nestjs-report/src/config/report-default.config.ts +++ b/packages/nestjs-report/src/config/report-default.config.ts @@ -1,6 +1,6 @@ import { registerAs } from '@nestjs/config'; -import { ReportSettingsInterface } from '../interfaces/report-settings.interface'; +import { type ReportSettingsInterface } from '../interfaces/report-settings.interface'; import { REPORT_MODULE_DEFAULT_SETTINGS_TOKEN } from '../report.constants'; /** diff --git a/packages/nestjs-report/src/entities/common-postgres.entity.ts b/packages/nestjs-report/src/entities/common-postgres.entity.ts new file mode 100644 index 000000000..86db48081 --- /dev/null +++ b/packages/nestjs-report/src/entities/common-postgres.entity.ts @@ -0,0 +1,24 @@ +import { + CreateDateColumn, + DeleteDateColumn, + PrimaryGeneratedColumn, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; + +export abstract class CommonPostgresEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + dateCreated!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + dateUpdated!: Date; + + @DeleteDateColumn({ type: 'timestamptz' }) + dateDeleted!: Date | null; + + @VersionColumn({ type: 'integer' }) + version!: number; +} diff --git a/packages/nestjs-report/src/entities/common-sqlite.entity.ts b/packages/nestjs-report/src/entities/common-sqlite.entity.ts new file mode 100644 index 000000000..15e315bf7 --- /dev/null +++ b/packages/nestjs-report/src/entities/common-sqlite.entity.ts @@ -0,0 +1,24 @@ +import { + CreateDateColumn, + DeleteDateColumn, + PrimaryGeneratedColumn, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; + +export abstract class CommonSqliteEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'datetime' }) + dateCreated!: Date; + + @UpdateDateColumn({ type: 'datetime' }) + dateUpdated!: Date; + + @DeleteDateColumn({ type: 'datetime' }) + dateDeleted!: Date | null; + + @VersionColumn({ type: 'integer' }) + version!: number; +} diff --git a/packages/nestjs-typeorm-ext/src/entities/report/report-postgres.entity.ts b/packages/nestjs-report/src/entities/report-postgres.entity.ts similarity index 90% rename from packages/nestjs-typeorm-ext/src/entities/report/report-postgres.entity.ts rename to packages/nestjs-report/src/entities/report-postgres.entity.ts index 3b3c2d3f7..09dfcee22 100644 --- a/packages/nestjs-typeorm-ext/src/entities/report/report-postgres.entity.ts +++ b/packages/nestjs-report/src/entities/report-postgres.entity.ts @@ -6,7 +6,7 @@ import { ReportEntityInterface, } from '@concepta/nestjs-common'; -import { CommonPostgresEntity } from '../common/common-postgres.entity'; +import { CommonPostgresEntity } from './common-postgres.entity'; /** * Report Postgres Entity diff --git a/packages/nestjs-typeorm-ext/src/entities/report/report-sqlite.entity.ts b/packages/nestjs-report/src/entities/report-sqlite.entity.ts similarity index 90% rename from packages/nestjs-typeorm-ext/src/entities/report/report-sqlite.entity.ts rename to packages/nestjs-report/src/entities/report-sqlite.entity.ts index c544b2f1c..6c0dd9309 100644 --- a/packages/nestjs-typeorm-ext/src/entities/report/report-sqlite.entity.ts +++ b/packages/nestjs-report/src/entities/report-sqlite.entity.ts @@ -6,7 +6,7 @@ import { ReportEntityInterface, } from '@concepta/nestjs-common'; -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; +import { CommonSqliteEntity } from './common-sqlite.entity'; /** * Report Sqlite Entity diff --git a/packages/nestjs-report/src/exceptions/report-create.exception.ts b/packages/nestjs-report/src/exceptions/report-create.exception.ts index 43ded24e1..986489d0d 100644 --- a/packages/nestjs-report/src/exceptions/report-create.exception.ts +++ b/packages/nestjs-report/src/exceptions/report-create.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { ReportException } from './report.exception'; diff --git a/packages/nestjs-report/src/exceptions/report-download-url-missing.exception.ts b/packages/nestjs-report/src/exceptions/report-download-url-missing.exception.ts index 62781e59c..d1a6082fc 100644 --- a/packages/nestjs-report/src/exceptions/report-download-url-missing.exception.ts +++ b/packages/nestjs-report/src/exceptions/report-download-url-missing.exception.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { ReportException } from './report.exception'; diff --git a/packages/nestjs-report/src/exceptions/report-duplicated.exception.ts b/packages/nestjs-report/src/exceptions/report-duplicated.exception.ts index d50fa65fc..56d5608f3 100644 --- a/packages/nestjs-report/src/exceptions/report-duplicated.exception.ts +++ b/packages/nestjs-report/src/exceptions/report-duplicated.exception.ts @@ -1,8 +1,8 @@ import { HttpStatus } from '@nestjs/common'; import { - RuntimeException, - RuntimeExceptionOptions, + type RuntimeException, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; import { ReportException } from './report.exception'; @@ -28,7 +28,7 @@ export class ReportDuplicateEntryException extends ReportException { this.errorCode = 'REPORT_DUPLICATE_ENTRY_ERROR'; this.context = { - ...super.context, + ...this.context, serviceKey, reportName, }; diff --git a/packages/nestjs-report/src/exceptions/report-generator-service-not-found.exception.ts b/packages/nestjs-report/src/exceptions/report-generator-service-not-found.exception.ts index 25efb9d1f..0dcf74583 100644 --- a/packages/nestjs-report/src/exceptions/report-generator-service-not-found.exception.ts +++ b/packages/nestjs-report/src/exceptions/report-generator-service-not-found.exception.ts @@ -1,6 +1,6 @@ import { - RuntimeException, - RuntimeExceptionOptions, + type RuntimeException, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; import { ReportException } from './report.exception'; @@ -20,7 +20,7 @@ export class ReportGeneratorServiceNotFoundException extends ReportException { this.errorCode = 'REPORT_GENERATOR_SERVICE_NOT_FOUND_ERROR'; this.context = { - ...super.context, + ...this.context, generatorServiceName, }; } diff --git a/packages/nestjs-report/src/exceptions/report-id-missing.exception.ts b/packages/nestjs-report/src/exceptions/report-id-missing.exception.ts index 165572156..319d6c116 100644 --- a/packages/nestjs-report/src/exceptions/report-id-missing.exception.ts +++ b/packages/nestjs-report/src/exceptions/report-id-missing.exception.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { ReportException } from './report.exception'; diff --git a/packages/nestjs-report/src/exceptions/report-name-missing.exception.ts b/packages/nestjs-report/src/exceptions/report-name-missing.exception.ts index 3ba71a2a9..090ef1d45 100644 --- a/packages/nestjs-report/src/exceptions/report-name-missing.exception.ts +++ b/packages/nestjs-report/src/exceptions/report-name-missing.exception.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { ReportException } from './report.exception'; diff --git a/packages/nestjs-report/src/exceptions/report-query.exception.ts b/packages/nestjs-report/src/exceptions/report-query.exception.ts index 3174ae06f..fec8b616b 100644 --- a/packages/nestjs-report/src/exceptions/report-query.exception.ts +++ b/packages/nestjs-report/src/exceptions/report-query.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { ReportException } from './report.exception'; diff --git a/packages/nestjs-report/src/exceptions/report-service-key-missing.exception.ts b/packages/nestjs-report/src/exceptions/report-service-key-missing.exception.ts index 1b91839c7..168b6f972 100644 --- a/packages/nestjs-report/src/exceptions/report-service-key-missing.exception.ts +++ b/packages/nestjs-report/src/exceptions/report-service-key-missing.exception.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { ReportException } from './report.exception'; diff --git a/packages/nestjs-report/src/exceptions/report-timeout.exception.ts b/packages/nestjs-report/src/exceptions/report-timeout.exception.ts index 1f7f14242..35e70c6a5 100644 --- a/packages/nestjs-report/src/exceptions/report-timeout.exception.ts +++ b/packages/nestjs-report/src/exceptions/report-timeout.exception.ts @@ -1,4 +1,4 @@ -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; +import { type RuntimeExceptionOptions } from '@concepta/nestjs-common'; import { ReportException } from './report.exception'; diff --git a/packages/nestjs-report/src/exceptions/report.exception.ts b/packages/nestjs-report/src/exceptions/report.exception.ts index 2e4fafdda..6bb476d46 100644 --- a/packages/nestjs-report/src/exceptions/report.exception.ts +++ b/packages/nestjs-report/src/exceptions/report.exception.ts @@ -1,6 +1,6 @@ import { RuntimeException, - RuntimeExceptionOptions, + type RuntimeExceptionOptions, } from '@concepta/nestjs-common'; export class ReportException extends RuntimeException { diff --git a/packages/nestjs-report/src/index.ts b/packages/nestjs-report/src/index.ts index 849b22fec..cfb78915c 100644 --- a/packages/nestjs-report/src/index.ts +++ b/packages/nestjs-report/src/index.ts @@ -1,5 +1,9 @@ export { ReportModule } from './report.module'; +// entities +export { ReportSqliteEntity } from './entities/report-sqlite.entity'; +export { ReportPostgresEntity } from './entities/report-postgres.entity'; + export { ReportServiceInterface } from './interfaces/report-service.interface'; export { ReportGeneratorServiceInterface } from './interfaces/report-generator-service.interface'; export { ReportGeneratorResultInterface } from './interfaces/report-generator-result.interface'; diff --git a/packages/nestjs-report/src/interfaces/report-entities-options.interface.ts b/packages/nestjs-report/src/interfaces/report-entities-options.interface.ts index 4a40d86ed..1b37cdbd7 100644 --- a/packages/nestjs-report/src/interfaces/report-entities-options.interface.ts +++ b/packages/nestjs-report/src/interfaces/report-entities-options.interface.ts @@ -1,9 +1,9 @@ import { - ReportEntityInterface, - RepositoryEntityOptionInterface, + type ReportEntityInterface, + type RepositoryEntityOptionInterface, } from '@concepta/nestjs-common'; -import { REPORT_MODULE_REPORT_ENTITY_KEY } from '../report.constants'; +import { type REPORT_MODULE_REPORT_ENTITY_KEY } from '../report.constants'; export interface ReportEntitiesOptionsInterface { [REPORT_MODULE_REPORT_ENTITY_KEY]: RepositoryEntityOptionInterface; diff --git a/packages/nestjs-report/src/interfaces/report-generator-result.interface.ts b/packages/nestjs-report/src/interfaces/report-generator-result.interface.ts index 6dfcfe5de..0618aff71 100644 --- a/packages/nestjs-report/src/interfaces/report-generator-result.interface.ts +++ b/packages/nestjs-report/src/interfaces/report-generator-result.interface.ts @@ -1,8 +1,7 @@ import { - ReportUpdatableInterface, - ReferenceIdInterface, + type ReportUpdatableInterface, + type ReferenceIdInterface, } from '@concepta/nestjs-common'; export interface ReportGeneratorResultInterface - extends ReportUpdatableInterface, - ReferenceIdInterface {} + extends ReportUpdatableInterface, ReferenceIdInterface {} diff --git a/packages/nestjs-report/src/interfaces/report-generator-service.interface.ts b/packages/nestjs-report/src/interfaces/report-generator-service.interface.ts index 0b796ea7c..649b35589 100644 --- a/packages/nestjs-report/src/interfaces/report-generator-service.interface.ts +++ b/packages/nestjs-report/src/interfaces/report-generator-service.interface.ts @@ -1,6 +1,6 @@ -import { ReportCreatableInterface } from '@concepta/nestjs-common'; +import { type ReportCreatableInterface } from '@concepta/nestjs-common'; -import { ReportGeneratorResultInterface } from './report-generator-result.interface'; +import { type ReportGeneratorResultInterface } from './report-generator-result.interface'; export interface ReportGeneratorServiceInterface { KEY: string; diff --git a/packages/nestjs-report/src/interfaces/report-model-service.interface.ts b/packages/nestjs-report/src/interfaces/report-model-service.interface.ts index 3c7948fcc..300a392de 100644 --- a/packages/nestjs-report/src/interfaces/report-model-service.interface.ts +++ b/packages/nestjs-report/src/interfaces/report-model-service.interface.ts @@ -1,17 +1,18 @@ import { - ByIdInterface, - ReferenceId, - ReportCreatableInterface, - ReportInterface, - ReportUpdatableInterface, - CreateOneInterface, - ReferenceIdInterface, - UpdateOneInterface, - ReportEntityInterface, + type ByIdInterface, + type ReferenceId, + type ReportCreatableInterface, + type ReportInterface, + type ReportUpdatableInterface, + type CreateOneInterface, + type ReferenceIdInterface, + type UpdateOneInterface, + type ReportEntityInterface, } from '@concepta/nestjs-common'; export interface ReportModelServiceInterface - extends ByIdInterface, + extends + ByIdInterface, CreateOneInterface, UpdateOneInterface { getUniqueReport( diff --git a/packages/nestjs-report/src/interfaces/report-options-extras.interface.ts b/packages/nestjs-report/src/interfaces/report-options-extras.interface.ts index 9debe7e72..428a6cd16 100644 --- a/packages/nestjs-report/src/interfaces/report-options-extras.interface.ts +++ b/packages/nestjs-report/src/interfaces/report-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface ReportOptionsExtrasInterface - extends Pick {} +export interface ReportOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-report/src/interfaces/report-options.interface.ts b/packages/nestjs-report/src/interfaces/report-options.interface.ts index 4483dafb8..efc8b0a6a 100644 --- a/packages/nestjs-report/src/interfaces/report-options.interface.ts +++ b/packages/nestjs-report/src/interfaces/report-options.interface.ts @@ -1,5 +1,5 @@ -import { ReportGeneratorServiceInterface } from './report-generator-service.interface'; -import { ReportSettingsInterface } from './report-settings.interface'; +import { type ReportGeneratorServiceInterface } from './report-generator-service.interface'; +import { type ReportSettingsInterface } from './report-settings.interface'; export interface ReportOptionsInterface { reportGeneratorServices?: ReportGeneratorServiceInterface[]; diff --git a/packages/nestjs-report/src/interfaces/report-service.interface.ts b/packages/nestjs-report/src/interfaces/report-service.interface.ts index 625dff251..c73f67079 100644 --- a/packages/nestjs-report/src/interfaces/report-service.interface.ts +++ b/packages/nestjs-report/src/interfaces/report-service.interface.ts @@ -1,7 +1,7 @@ -import { ReportInterface } from '@concepta/nestjs-common'; +import { type ReportInterface } from '@concepta/nestjs-common'; -import { ReportCreateDto } from '../dto/report-create.dto'; -import { DoneCallback } from '../report.types'; +import { type ReportCreateDto } from '../dto/report-create.dto'; +import { type DoneCallback } from '../report.types'; export interface ReportServiceInterface { generate(report: ReportCreateDto): Promise; diff --git a/packages/nestjs-report/src/interfaces/report-status.interface.ts b/packages/nestjs-report/src/interfaces/report-status.interface.ts index fa9448f68..fc809efc2 100644 --- a/packages/nestjs-report/src/interfaces/report-status.interface.ts +++ b/packages/nestjs-report/src/interfaces/report-status.interface.ts @@ -1,4 +1,6 @@ -import { ReportInterface } from '@concepta/nestjs-common'; +import { type ReportInterface } from '@concepta/nestjs-common'; -export interface ReportStatusInterface - extends Pick {} +export interface ReportStatusInterface extends Pick< + ReportInterface, + 'status' +> {} diff --git a/packages/nestjs-report/src/interfaces/report-strategy-service.interface.ts b/packages/nestjs-report/src/interfaces/report-strategy-service.interface.ts index 96e5bbc37..dbba63fa3 100644 --- a/packages/nestjs-report/src/interfaces/report-strategy-service.interface.ts +++ b/packages/nestjs-report/src/interfaces/report-strategy-service.interface.ts @@ -1,6 +1,6 @@ -import { ReportCreatableInterface } from '@concepta/nestjs-common'; +import { type ReportCreatableInterface } from '@concepta/nestjs-common'; -import { ReportGeneratorResultInterface } from './report-generator-result.interface'; +import { type ReportGeneratorResultInterface } from './report-generator-result.interface'; export interface ReportStrategyServiceInterface { generate( diff --git a/packages/nestjs-report/src/report.module-definition.ts b/packages/nestjs-report/src/report.module-definition.ts index 75f873db0..7d5b0c991 100644 --- a/packages/nestjs-report/src/report.module-definition.ts +++ b/packages/nestjs-report/src/report.module-definition.ts @@ -1,16 +1,16 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { createSettingsProvider } from '@concepta/nestjs-common'; import { reportDefaultConfig } from './config/report-default.config'; -import { ReportOptionsExtrasInterface } from './interfaces/report-options-extras.interface'; -import { ReportOptionsInterface } from './interfaces/report-options.interface'; -import { ReportSettingsInterface } from './interfaces/report-settings.interface'; +import { type ReportOptionsExtrasInterface } from './interfaces/report-options-extras.interface'; +import { type ReportOptionsInterface } from './interfaces/report-options.interface'; +import { type ReportSettingsInterface } from './interfaces/report-settings.interface'; import { REPORT_MODULE_SETTINGS_TOKEN, REPORT_STRATEGY_SERVICE_KEY, diff --git a/packages/nestjs-report/src/report.module.spec.ts b/packages/nestjs-report/src/report.module.spec.ts index c72826a9b..0f625ba6e 100644 --- a/packages/nestjs-report/src/report.module.spec.ts +++ b/packages/nestjs-report/src/report.module.spec.ts @@ -1,11 +1,11 @@ -import { DynamicModule, ModuleMetadata } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type DynamicModule, type ModuleMetadata } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { ReportStatusEnum, getDynamicRepositoryToken, - ReportEntityInterface, - RepositoryInterface, + type ReportEntityInterface, + type RepositoryInterface, } from '@concepta/nestjs-common'; import { FileModule } from '@concepta/nestjs-file'; import { diff --git a/packages/nestjs-report/src/report.types.ts b/packages/nestjs-report/src/report.types.ts index 885fb3279..60c7bee51 100644 --- a/packages/nestjs-report/src/report.types.ts +++ b/packages/nestjs-report/src/report.types.ts @@ -1,4 +1,4 @@ -import { ReportGeneratorResultInterface } from './interfaces/report-generator-result.interface'; +import { type ReportGeneratorResultInterface } from './interfaces/report-generator-result.interface'; export type DoneCallback = ( report: ReportGeneratorResultInterface, diff --git a/packages/nestjs-report/src/services/report-strategy.service.spec.ts b/packages/nestjs-report/src/services/report-strategy.service.spec.ts index 2cfe074a2..212ae2c2b 100644 --- a/packages/nestjs-report/src/services/report-strategy.service.spec.ts +++ b/packages/nestjs-report/src/services/report-strategy.service.spec.ts @@ -3,13 +3,13 @@ import { randomUUID } from 'crypto'; import { mock } from 'jest-mock-extended'; import { - ReportCreatableInterface, - ReportInterface, + type ReportCreatableInterface, + type ReportInterface, ReportStatusEnum, } from '@concepta/nestjs-common'; import { ReportCreateDto } from '../dto/report-create.dto'; -import { ReportGeneratorServiceInterface } from '../interfaces/report-generator-service.interface'; +import { type ReportGeneratorServiceInterface } from '../interfaces/report-generator-service.interface'; import { ReportStrategyService } from './report-strategy.service'; diff --git a/packages/nestjs-report/src/services/report.service.spec.ts b/packages/nestjs-report/src/services/report.service.spec.ts index 9e97ebc22..b0d39eb25 100644 --- a/packages/nestjs-report/src/services/report.service.spec.ts +++ b/packages/nestjs-report/src/services/report.service.spec.ts @@ -1,21 +1,21 @@ import { randomUUID } from 'crypto'; -import { mock, MockProxy } from 'jest-mock-extended'; +import { mock, type MockProxy } from 'jest-mock-extended'; import { - ReportCreatableInterface, + type ReportCreatableInterface, ReportStatusEnum, - ReportEntityInterface, - RepositoryInterface, + type ReportEntityInterface, + type RepositoryInterface, } from '@concepta/nestjs-common'; -import { ReportCreateDto } from '../dto/report-create.dto'; +import { type ReportCreateDto } from '../dto/report-create.dto'; import { ReportDuplicateEntryException } from '../exceptions/report-duplicated.exception'; import { ReportQueryException } from '../exceptions/report-query.exception'; -import { ReportModelServiceInterface } from '../interfaces/report-model-service.interface'; +import { type ReportModelServiceInterface } from '../interfaces/report-model-service.interface'; import { ReportModelService } from './report-model.service'; -import { ReportStrategyService } from './report-strategy.service'; +import { type ReportStrategyService } from './report-strategy.service'; import { ReportService } from './report.service'; const mockReport: ReportEntityInterface = { diff --git a/packages/nestjs-repository-typeorm/README.md b/packages/nestjs-repository-typeorm/README.md new file mode 100644 index 000000000..a8729e636 --- /dev/null +++ b/packages/nestjs-repository-typeorm/README.md @@ -0,0 +1,444 @@ +# @concepta/nestjs-repository-typeorm + +TypeORM driver for `@concepta/nestjs-repository`. Provides `TypeOrmRepository` +(extending `RepositoryAdapter`), `TypeOrmTransaction` / `TypeOrmTransactionFactory` +for automatic transaction management, WhereClause-to-TypeORM translation, and +database-specific base entities for Postgres and SQLite. + +## Project + +[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-repository-typeorm)](https://www.npmjs.com/package/@concepta/nestjs-repository-typeorm) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-repository-typeorm)](https://www.npmjs.com/package/@concepta/nestjs-repository-typeorm) +[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) +[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-repository-typeorm%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +## Table of Contents + +- [Installation](#installation) +- [Module Registration](#module-registration) +- [TypeOrmRepository](#typeormrepository) +- [WhereClause Translation](#whereclause-translation) +- [Transaction Support](#transaction-support) +- [Repository Hooks](#repository-hooks) +- [Base Entities](#base-entities) +- [Exceptions](#exceptions) +- [Entry Points](#entry-points) + +## Installation + +```sh +yarn add @concepta/nestjs-repository-typeorm @nestjs/common typeorm +``` + +### Requirements + +ESM-only — no CJS build is published. Requires Node `>= 22.12` and +NestJS 12. + +### Dependencies + +| Package | Notes | +| --- | --- | +| `@concepta/nestjs-core` | Core interfaces, utilities, and hook system | +| `@concepta/nestjs-repository` | Abstract repository layer (`RepositoryAdapter`) | +| `@nestjs/typeorm` | TypeORM integration for NestJS | +| `@tsyche/membrane` | Hook pipeline (`Permeator`/`Membrane`) | + +### Peer Dependencies + +| Package | Required | Notes | +| --- | --- | --- | +| `@nestjs/common` | Yes | NestJS core — install explicitly, no longer bundled | +| `typeorm` | Yes | TypeORM ^0.3.0 | + +## Module Registration + +### With RepositoryModule (recommended) + +Use `RepositoryModule.forFeature()` to register entities through the +TypeORM driver. This provides transaction management, repository hooks, +and duplicate key detection. + +```ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +@Module({ + imports: [ + TypeOrmModule.forRoot({ + type: 'postgres', + url: 'postgres://user:pass@localhost:5432/mydb', + entities: [OrderEntity, CustomerEntity], + }), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: 'orders', entity: OrderEntity }, + { key: 'customers', entity: CustomerEntity }, + ], + }), + ], +}) +export class AppModule {} +``` + +Each entity key creates a `TypeOrmRepository` instance injectable via +`@InjectDynamicRepository(key)`. + +### Direct Usage + +`TypeOrmRepositoryModule` can also be used directly without `RepositoryModule`: + +```ts +@Module({ + imports: [ + TypeOrmModule.forRoot({ /* ... */ }), + TypeOrmRepositoryModule.forFeature([ + { key: 'orders', entity: OrderEntity }, + { key: 'customers', entity: CustomerEntity, dataSource: 'secondary' }, + { key: 'audit', entity: AuditLog, factory: createAuditRepository }, + ]), + ], +}) +export class AppModule {} +``` + +### Provider Options + +```ts +interface TypeOrmProviderOptionsInterface extends RepositoryProviderOptions { + key: string; // Injection key + entity: Type; // TypeORM entity class + dataSource?: TypeOrmDataSourceToken; // Data source (default: 'default') + factory?: (dataSource: DataSource) => Repository; // Custom repository factory +} +``` + +- **`key`** -- string key used with `@InjectDynamicRepository(key)` +- **`entity`** -- TypeORM entity class +- **`dataSource`** -- optional data source name, `DataSource` instance, or + `DataSourceOptions`; defaults to `'default'` +- **`factory`** -- optional factory for custom TypeORM repositories; receives + `DataSource`, returns `Repository` + +### Injecting Repositories + +```ts +import { Injectable } from '@nestjs/common'; +import { InjectDynamicRepository } from '@concepta/nestjs-repository'; +import { TypeOrmRepository } from '@concepta/nestjs-repository-typeorm'; + +@Injectable() +export class OrderService { + constructor( + @InjectDynamicRepository('orders') + private readonly orderRepo: TypeOrmRepository, + ) {} + + async findAll(): Promise { + return this.orderRepo.find(); + } +} +``` + +## TypeOrmRepository + +`TypeOrmRepository` extends `RepositoryAdapter` from +`@concepta/nestjs-repository` and implements the protected `do*` template +methods using TypeORM; the public methods below are inherited concrete +wrappers that run the hook pipeline. Every operation is transaction-aware, +runs repository hooks, and wraps opaque driver errors in +`RepositoryQueryException`. Purpose-built `RuntimeException` subclasses such +as `OptimisticLockException` propagate unwrapped so callers can catch them +by type. + +### Methods + +| Category | Method | Signature | +| --- | --- | --- | +| Query | `find` | `(options?) => Promise` | +| Query | `findOne` | `(options) => Promise` | +| Query | `count` | `(options?) => Promise` | +| Query | `findAndCount` | `(options?) => Promise<[Entity[], number]>` | +| Create | `create` | `(entity, options?) => Promise` | +| Create | `createMany` | `(entities, options?) => Promise` | +| Update | `update` | `(entity, data, options?) => Promise` | +| Update | `upsert` | `(entity, options?) => Promise` | +| Update | `replace` | `(entity, data, options?) => Promise` | +| Delete | `delete` | `(entity, options?) => Promise` | +| Delete | `deleteMany` | `(entities, options?) => Promise` | +| Delete | `softDelete` | `(entity, options?) => Promise` | +| Lifecycle | `restore` | `(entity, options?) => Promise` | +| Utility | `transform` | `(entityLike) => Entity` | +| Utility | `merge` | `(mergeIntoEntity, ...entityLikes) => Entity` | +| Utility | `prepare` | `(dto) => Entity \| undefined` | + +All query and mutation methods accept an `options` parameter that includes +an optional `ctx` (repository context) for transaction and hook support. + +### Optimistic Locking + +`update`/`replace` automatically enforce optimistic locking whenever the +target entity carries a TypeORM `@VersionColumn` — which includes every +entity extending `AuditPostgresEntity`, `AuditSqliteEntity`, +`CommonPostgresEntity`, or `CommonSqliteEntity` (see +[Base Entities](#base-entities)), plus any entity that declares one itself. +The check derives entirely from the `entity` argument the caller already +passes in: its version is compared, atomically, against the row's current +version at write time, and a stale write — one based on an `entity` fetched +before someone else already updated it — is rejected with +`OptimisticLockException` (HTTP 409) instead of silently overwriting the +concurrent change. No extra API surface and no opt-in required, but two +behaviors do change for versioned entities. First, the write is applied to +a freshly re-read row rather than to the `entity` you passed: your `entity` +instance is no longer mutated in place, the returned entity is a different +object with no relations loaded, and any in-memory changes you made to +`entity` that aren't also in `data` are discarded — read the result back +from the return value. Second, because the guard runs inside a +`TransactionScope.run()` (see below), a conflict dooms the enclosing +transaction — catching `OptimisticLockException` and continuing does not +rescue it; retry the whole transaction from outside, re-reading the entity +first. + +The check runs inside a transaction, opening one scoped to just that call +if the caller isn't already inside one (e.g. via `@Transactional()`), so a +third writer can't interleave between the version check and the field +write — **when `RepositoryModule.forRoot()` is imported**, since it's the +one that provides `TransactionScope`. If `TypeOrmRepositoryModule` is used +directly without it (see [Module Registration](#module-registration)) and +the caller isn't already inside their own active transaction, `update`/ +`replace` on a versioned entity throws immediately — a `RuntimeException` +whose message names the entity and points at `RepositoryModule.forRoot()` — +rather than silently running the guard and the write as two separate, +unprotected statements. This is a configuration error surfaced at call +time, not a runtime conflict; it is not an `OptimisticLockException`. +A version value supplied by the caller in `data` is always ignored — only +the version read from the `entity` argument, and the row's own +auto-incrementing column, ever determine the real version. + +Entities without a version column are unaffected — `update`/`replace` +behave exactly as before. + +### Transaction Awareness + +When a `PlainLiteralObject` context with an active `trx` is provided, +`TypeOrmRepository` automatically: + +1. Resolves the TypeORM transaction via `ctx.trx.getOrStart(transactionKey)` +2. Uses the transactional `EntityManager` for all operations + +## WhereClause Translation + +`TypeOrmRepository` translates the ORM-agnostic `WhereClause` AST from +`@concepta/nestjs-repository` into TypeORM `FindOptionsWhere` objects. + +### Supported Operators + +| WhereOperator | TypeORM Translation | Description | +| --- | --- | --- | +| `eq` | `Equal(value)` | Equal | +| `ne` | `Not(Equal(value))` | Not equal | +| `gt` | `MoreThan(value)` | Greater than | +| `gte` | `MoreThanOrEqual(value)` | Greater than or equal | +| `lt` | `LessThan(value)` | Less than | +| `lte` | `LessThanOrEqual(value)` | Less than or equal | +| `contains` | `Like('%value%')` | Contains substring | +| `ncontains` | `Not(Like('%value%'))` | Does not contain substring | +| `starts` | `Like('value%')` | Starts with | +| `nstarts` | `Not(Like('value%'))` | Does not start with | +| `ends` | `Like('%value')` | Ends with | +| `nends` | `Not(Like('%value'))` | Does not end with | +| `in` | `In(values)` | In array | +| `nin` | `Not(In(values))` | Not in array | +| `null` | `IsNull()` | Is null | +| `nnull` | `Not(IsNull())` | Is not null | +| `between` | `Between(from, to)` | Between range | + +### Compound Operators + +| Operator | Description | +| --- | --- | +| `and` | All conditions must match | +| `or` | Any condition must match | + +### Using the Where Builder + +The `Where` helper from `@concepta/nestjs-repository` builds `WhereClause` +objects that `TypeOrmRepository` translates automatically: + +```ts +import { Where } from '@concepta/nestjs-repository'; + +// Static API +const orders = await orderRepo.find( + Where.where( + Where.and( + Where.eq('status', 'active'), + Where.gt('total', 100), + ), + ), +); + +// Typed builder API +const w = Where.for(); +const orders = await orderRepo.find( + w.where( + w.and( + w.eq('status', 'active'), + w.or( + w.gte('total', 1000), + w.contains('notes', 'priority'), + ), + ), + ), +); +``` + +### Translation Process + +1. The `WhereClause` AST is flattened into Disjunctive Normal Form (DNF) + using `toDnf()` from `RepositoryAdapter` +2. Each AND-branch is translated to a TypeORM `FindOptionsWhere` object +3. Same-field conditions within a branch are merged using TypeORM `And()` +4. The resulting array of `FindOptionsWhere` objects represents the OR + of all branches + +## Transaction Support + +This module provides `TypeOrmTransaction` and `TypeOrmTransactionFactory` +for integration with `@concepta/nestjs-repository`'s transaction layer. + +### TypeOrmTransaction + +Wraps a TypeORM `QueryRunner` to manage transaction lifecycle: + +```ts +const tx = new TypeOrmTransaction(dataSource); +await tx.start(); + +const manager = tx.getClient(); +await manager.save(entity); + +await tx.commit(); +``` + +| Property / Method | Description | +| --- | --- | +| `isActive` | Whether the transaction is currently active | +| `start()` | Create a QueryRunner and begin a transaction | +| `commit()` | Commit the transaction and release the QueryRunner | +| `rollback()` | Rollback the transaction and release the QueryRunner | +| `getClient()` | Get the transactional `EntityManager` | + +### TypeOrmTransactionFactory + +Factory for creating `TypeOrmTransaction` instances. Automatically registered +with the `TransactionFactoryRegistry` when using `RepositoryModule.forFeature()`. + +The transaction key follows the pattern `typeorm:` (e.g., +`typeorm:default`). + +### Automatic Transaction Integration + +When `TypeOrmRepositoryModule` is used via `RepositoryModule.forFeature()`, +transaction factories are registered automatically. The `TypeOrmRepository` +joins transactions from the context: + +```ts +import { TransactionScope } from '@concepta/nestjs-repository'; + +@Injectable() +export class OrderService { + constructor( + private readonly txScope: TransactionScope, + @InjectDynamicRepository('orders') + private readonly orderRepo: TypeOrmRepository, + ) {} + + async createOrder(ctx: PlainLiteralObject, dto: DeepPartial) { + return this.txScope.run(ctx, async (txCtx) => { + // TypeOrmRepository automatically uses the transactional EntityManager + return this.orderRepo.create(dto, { ctx: txCtx }); + }); + } +} +``` + +## Repository Hooks + +`TypeOrmRepository` runs repository hooks from `@concepta/nestjs-repository` +at each operation lifecycle stage. Both high-level semantic hooks and +fine-grained hooks fire automatically. + +| Operation | Before Hooks | After Hooks | +| --- | --- | --- | +| `find` | `beforeRead` -> `beforeFind` | `afterFind` -> `afterRead` | +| `findOne` | `beforeRead` -> `beforeFindOne` | `afterFindOne` -> `afterRead` | +| `count` | `beforeRead` -> `beforeCount` | `afterCount` | +| `findAndCount` | `beforeRead` -> `beforeFindAndCount` | `afterFindAndCount` | +| `create` | `beforeWrite` -> `beforeCreate` | `afterCreate` -> `afterWrite` | +| `createMany` | `beforeWrite` -> `beforeCreateMany` | `afterCreateMany` -> `afterWrite` | +| `update` | `beforeWrite` -> `beforeUpdate` | `afterUpdate` -> `afterWrite` | +| `upsert` | `beforeWrite` -> `beforeUpsert` | `afterUpsert` -> `afterWrite` | +| `replace` | `beforeWrite` -> `beforeReplace` | `afterReplace` -> `afterWrite` | +| `delete` | `beforeDestroy` -> `beforeDelete` | `afterDelete` -> `afterDestroy` | +| `deleteMany` | `beforeDestroy` -> `beforeDeleteMany` | `afterDeleteMany` -> `afterDestroy` | +| `softDelete` | `beforeTransition` -> `beforeSoftDelete` | `afterSoftDelete` -> `afterTransition` | +| `restore` | `beforeTransition` -> `beforeRestore` | `afterRestore` -> `afterTransition` | + +Hooks are resolved via `HookResolverService` from `@concepta/nestjs-core`. +The hook resolver is optional -- `TypeOrmRepository` works without it. + +## Base Entities + +The module provides abstract base entities for Postgres and SQLite with +audit fields and optimistic locking. + +### Core Base Entities + +| Entity | Database | Extends | Key Fields | +| --- | --- | --- | --- | +| `AuditPostgresEntity` | Postgres | -- | dateCreated, dateUpdated, dateDeleted (`timestamptz`), version | +| `AuditSqliteEntity` | SQLite | -- | dateCreated, dateUpdated, dateDeleted, version | +| `CommonPostgresEntity` | Postgres | `AuditPostgresEntity` | id (UUID primary key) | +| `CommonSqliteEntity` | SQLite | `AuditSqliteEntity` | id (UUID primary key) | + +`AuditPostgresEntity` uses `@CreateDateColumn`, `@UpdateDateColumn`, +`@DeleteDateColumn` (for soft deletes), and `@VersionColumn` (for +optimistic locking). The Postgres variant uses `timestamptz` column types. + +### Using Base Entities + +```ts +import { Entity, Column } from 'typeorm'; +import { CommonPostgresEntity } from '@concepta/nestjs-repository-typeorm'; + +@Entity() +export class OrderEntity extends CommonPostgresEntity { + @Column() + status!: string; + + @Column('uuid') + customerId!: string; +} +``` + +This gives `OrderEntity` the `id`, `dateCreated`, `dateUpdated`, +`dateDeleted`, and `version` fields automatically. + +## Exceptions + +| Exception | Package | Description | +| --- | --- | --- | +| `RepositoryQueryException` | `@concepta/nestjs-repository` | Repository query error (wraps original error) | +| `OptimisticLockException` | `@concepta/nestjs-repository` | An `update`/`replace` targeted a stale version — see [Optimistic Locking](#optimistic-locking) | + +## Entry Points + +| Import Path | Contents | +| --- | --- | +| `@concepta/nestjs-repository-typeorm` | `TypeOrmRepositoryModule`, `TypeOrmRepository`, `TypeOrmProviderOptionsInterface`, `TypeOrmTransaction`, `TypeOrmTransactionFactory`, `AuditPostgresEntity`, `AuditSqliteEntity`, `CommonPostgresEntity`, `CommonSqliteEntity` | diff --git a/packages/nestjs-repository-typeorm/package.json b/packages/nestjs-repository-typeorm/package.json new file mode 100644 index 000000000..2851c68a8 --- /dev/null +++ b/packages/nestjs-repository-typeorm/package.json @@ -0,0 +1,41 @@ +{ + "name": "@concepta/nestjs-repository-typeorm", + "version": "8.0.0-alpha.10", + "description": "Rockets NestJS TypeORM Repository Module", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" + ], + "dependencies": { + "@concepta/nestjs-core": "^8.0.0-alpha.10", + "@concepta/nestjs-repository": "8.0.0-alpha.10", + "@nestjs/typeorm": "^12.0.1", + "@tsyche/membrane": "^0.7.0" + }, + "devDependencies": { + "@concepta/typeorm-seeding": "^4.0.0", + "@faker-js/faker": "^8.4.1", + "@nestjs/common": "^12.0.1", + "@nestjs/testing": "^12.0.1", + "sqlite3": "^5.1.4", + "vitest-mock-extended": "^4.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.1", + "typeorm": "^0.3.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } +} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/ormconfig.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/ormconfig.fixture.ts new file mode 100644 index 000000000..39ecb800e --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/ormconfig.fixture.ts @@ -0,0 +1,10 @@ +import { type DataSourceOptions } from 'typeorm'; + +import { TestEntityFixture } from '../entity/test.entity.fixture.js'; + +export const ormConfig: DataSourceOptions = { + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [TestEntityFixture], +}; diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/relation-ormconfig.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/relation-ormconfig.fixture.ts new file mode 100644 index 000000000..e732ed755 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/relation-ormconfig.fixture.ts @@ -0,0 +1,12 @@ +import { type DataSourceOptions } from 'typeorm'; + +import { AuthorEntityFixture } from '../entity/author.entity.fixture.js'; +import { PostEntityFixture } from '../entity/post.entity.fixture.js'; +import { TagEntityFixture } from '../entity/tag.entity.fixture.js'; + +export const relationOrmConfig: DataSourceOptions = { + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [AuthorEntityFixture, PostEntityFixture, TagEntityFixture], +}; diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/relation.constants.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/relation.constants.fixture.ts new file mode 100644 index 000000000..970ed43ee --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/relation.constants.fixture.ts @@ -0,0 +1,3 @@ +export const AUTHOR_ENTITY_TOKEN = 'author-entity'; +export const POST_ENTITY_TOKEN = 'post-entity'; +export const TAG_ENTITY_TOKEN = 'tag-entity'; diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/test.constants.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/test.constants.fixture.ts new file mode 100644 index 000000000..2f0e797df --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/config/test.constants.fixture.ts @@ -0,0 +1 @@ +export const TEST_ENTITY_TOKEN = 'test-entity'; diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/author.entity.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/author.entity.fixture.ts new file mode 100644 index 000000000..6c23d3f18 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/author.entity.fixture.ts @@ -0,0 +1,14 @@ +import { Column, Entity, OneToMany } from 'typeorm'; + +import { CommonSqliteEntity } from '../../../entities/common/common-sqlite.entity.js'; + +import { PostEntityFixture } from './post.entity.fixture.js'; + +@Entity() +export class AuthorEntityFixture extends CommonSqliteEntity { + @Column() + name!: string; + + @OneToMany(() => PostEntityFixture, (post) => post.author) + posts!: PostEntityFixture[]; +} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/post.entity.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/post.entity.fixture.ts new file mode 100644 index 000000000..91974200f --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/post.entity.fixture.ts @@ -0,0 +1,22 @@ +import { Column, Entity, JoinColumn, ManyToMany, ManyToOne } from 'typeorm'; + +import { CommonSqliteEntity } from '../../../entities/common/common-sqlite.entity.js'; + +import { AuthorEntityFixture } from './author.entity.fixture.js'; +import { TagEntityFixture } from './tag.entity.fixture.js'; + +@Entity() +export class PostEntityFixture extends CommonSqliteEntity { + @Column() + title!: string; + + @ManyToOne(() => AuthorEntityFixture, (author) => author.posts) + @JoinColumn({ name: 'authorId' }) + author!: AuthorEntityFixture; + + @Column() + authorId!: string; + + @ManyToMany(() => TagEntityFixture, (tag) => tag.posts) + tags!: TagEntityFixture[]; +} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/tag.entity.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/tag.entity.fixture.ts new file mode 100644 index 000000000..1764a50b7 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/tag.entity.fixture.ts @@ -0,0 +1,15 @@ +import { Column, Entity, JoinTable, ManyToMany } from 'typeorm'; + +import { CommonSqliteEntity } from '../../../entities/common/common-sqlite.entity.js'; + +import { PostEntityFixture } from './post.entity.fixture.js'; + +@Entity() +export class TagEntityFixture extends CommonSqliteEntity { + @Column() + label!: string; + + @ManyToMany(() => PostEntityFixture, (post) => post.tags) + @JoinTable({ name: 'post_tags' }) + posts!: PostEntityFixture[]; +} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/test.entity.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/test.entity.fixture.ts new file mode 100644 index 000000000..0cd8a679d --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/entity/test.entity.fixture.ts @@ -0,0 +1,16 @@ +import { Column, Entity } from 'typeorm'; + +import { CommonSqliteEntity } from '../../../entities/common/common-sqlite.entity.js'; +import { TestInterfaceFixture } from '../interface/test-entity.interface.fixture.js'; + +@Entity() +export class TestEntityFixture + extends CommonSqliteEntity + implements TestInterfaceFixture +{ + @Column() + firstName!: string; + + @Column({ nullable: true }) + lastName!: string; +} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/author.factory.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/author.factory.fixture.ts new file mode 100644 index 000000000..7e286f453 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/author.factory.fixture.ts @@ -0,0 +1,14 @@ +import { faker } from '@faker-js/faker'; + +import { Factory } from '@concepta/typeorm-seeding'; + +import { type AuthorEntityFixture } from '../entity/author.entity.fixture.js'; + +export class AuthorFactoryFixture extends Factory { + protected async entity( + author: AuthorEntityFixture, + ): Promise { + author.name = faker.person.firstName(); + return author; + } +} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/post.factory.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/post.factory.fixture.ts new file mode 100644 index 000000000..3c57f04c4 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/post.factory.fixture.ts @@ -0,0 +1,12 @@ +import { faker } from '@faker-js/faker'; + +import { Factory } from '@concepta/typeorm-seeding'; + +import { type PostEntityFixture } from '../entity/post.entity.fixture.js'; + +export class PostFactoryFixture extends Factory { + protected async entity(post: PostEntityFixture): Promise { + post.title = faker.lorem.sentence(); + return post; + } +} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/tag.factory.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/tag.factory.fixture.ts new file mode 100644 index 000000000..729961e40 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/tag.factory.fixture.ts @@ -0,0 +1,12 @@ +import { faker } from '@faker-js/faker'; + +import { Factory } from '@concepta/typeorm-seeding'; + +import { type TagEntityFixture } from '../entity/tag.entity.fixture.js'; + +export class TagFactoryFixture extends Factory { + protected async entity(tag: TagEntityFixture): Promise { + tag.label = faker.word.noun(); + return tag; + } +} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.factory.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/test.factory.fixture.ts similarity index 85% rename from packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.factory.fixture.ts rename to packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/test.factory.fixture.ts index 6931acf08..be54263c1 100644 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.factory.fixture.ts +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/factory/test.factory.fixture.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; import { Factory } from '@concepta/typeorm-seeding'; -import { TestEntityFixture } from './test.entity.fixture'; +import { type TestEntityFixture } from '../entity/test.entity.fixture.js'; /** * Test factory diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/interface/test-creatable.interface.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/interface/test-creatable.interface.fixture.ts new file mode 100644 index 000000000..cce012098 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/interface/test-creatable.interface.fixture.ts @@ -0,0 +1,6 @@ +import { type TestInterfaceFixture } from './test-entity.interface.fixture.js'; + +export interface TestCreatableInterfaceFixture extends Pick< + TestInterfaceFixture, + 'firstName' | 'lastName' +> {} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/interface/test-entity.interface.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/interface/test-entity.interface.fixture.ts new file mode 100644 index 000000000..b63076943 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/interface/test-entity.interface.fixture.ts @@ -0,0 +1,10 @@ +import { + type AuditInterface, + type ReferenceIdInterface, +} from '@concepta/nestjs-core'; + +export interface TestInterfaceFixture + extends ReferenceIdInterface, AuditInterface { + firstName: string; + lastName?: string; +} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/interface/test-updatable.interface.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/interface/test-updatable.interface.fixture.ts new file mode 100644 index 000000000..0870f7dcd --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/interface/test-updatable.interface.fixture.ts @@ -0,0 +1,6 @@ +import { type TestInterfaceFixture } from './test-entity.interface.fixture.js'; + +export interface TestUpdatableInterfaceFixture + extends + Pick, + Partial> {} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/mock/relation-metadata.mock.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/mock/relation-metadata.mock.ts new file mode 100644 index 000000000..2d411ff4a --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/mock/relation-metadata.mock.ts @@ -0,0 +1,29 @@ +import { + type TypeOrmRelationMetadata, + type TypeOrmInverseRelation, +} from '../../../repository/typeorm-metadata.types.js'; + +type RelationOverrides = Partial< + Omit +> & { + inverseRelation?: TypeOrmInverseRelation; +}; + +export function mockRelationMetadata( + overrides: RelationOverrides, +): TypeOrmRelationMetadata { + const { inverseRelation, ...rest } = overrides; + return { + propertyName: 'relation', + inverseEntityMetadata: { name: 'Unknown' }, + isOneToMany: false, + isManyToMany: false, + isManyToManyOwner: false, + isOwning: false, + joinColumns: [], + inverseJoinColumns: [], + junctionEntityMetadata: undefined, + inverseRelation, + ...rest, + }; +} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/mock/typeorm-repository.mock.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/mock/typeorm-repository.mock.ts new file mode 100644 index 000000000..06b326675 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/mock/typeorm-repository.mock.ts @@ -0,0 +1,32 @@ +import { type Repository } from 'typeorm'; + +import { TypeOrmRepository } from '../../../repository/typeorm-repository.js'; + +interface TestEntity { + id: string; +} + +class TestEntityClass { + id!: string; +} + +export function mockTypeOrmRepository(): TypeOrmRepository { + const repo = { + metadata: { + name: 'TestEntity', + targetName: 'TestEntity', + columns: [ + { + propertyName: 'id', + isPrimary: true, + isDeleteDate: false, + isVersion: false, + }, + ], + relations: [], + }, + target: TestEntityClass, + } as unknown as Repository; + + return new TypeOrmRepository(repo, { entityKey: 'test-entity' }); +} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/app.module.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/app.module.fixture.ts new file mode 100644 index 000000000..cc2e82d23 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/app.module.fixture.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { RepositoryModule } from '@concepta/nestjs-repository'; + +import { ormConfig } from '../config/ormconfig.fixture.js'; + +import { TestModuleFixture } from './test.module.fixture.js'; + +@Module({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + RepositoryModule.forRoot({}), + TestModuleFixture, + ], +}) +export class AppModuleFixture {} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/relation-app.module.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/relation-app.module.fixture.ts new file mode 100644 index 000000000..a6801b086 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/relation-app.module.fixture.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { RepositoryModule } from '@concepta/nestjs-repository'; + +import { relationOrmConfig } from '../config/relation-ormconfig.fixture.js'; + +import { RelationTestModuleFixture } from './relation-test.module.fixture.js'; + +@Module({ + imports: [ + TypeOrmModule.forRoot(relationOrmConfig), + RepositoryModule.forRoot({}), + RelationTestModuleFixture, + ], +}) +export class RelationAppModuleFixture {} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/relation-test.module.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/relation-test.module.fixture.ts new file mode 100644 index 000000000..ab7bf4a84 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/relation-test.module.fixture.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; + +import { RepositoryModule } from '@concepta/nestjs-repository'; + +import { TypeOrmRepositoryModule } from '../../../typeorm-repository.module.js'; +import { + AUTHOR_ENTITY_TOKEN, + POST_ENTITY_TOKEN, + TAG_ENTITY_TOKEN, +} from '../config/relation.constants.fixture.js'; +import { AuthorEntityFixture } from '../entity/author.entity.fixture.js'; +import { PostEntityFixture } from '../entity/post.entity.fixture.js'; +import { TagEntityFixture } from '../entity/tag.entity.fixture.js'; + +@Module({ + imports: [ + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: AUTHOR_ENTITY_TOKEN, entity: AuthorEntityFixture }, + { key: POST_ENTITY_TOKEN, entity: PostEntityFixture }, + { key: TAG_ENTITY_TOKEN, entity: TagEntityFixture }, + ], + }), + ], +}) +export class RelationTestModuleFixture {} diff --git a/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/test.module.fixture.ts b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/test.module.fixture.ts new file mode 100644 index 000000000..2ff28547d --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__fixtures__/repository/module/test.module.fixture.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; + +import { RepositoryModule } from '@concepta/nestjs-repository'; + +import { TypeOrmRepositoryModule } from '../../../typeorm-repository.module.js'; +import { TEST_ENTITY_TOKEN } from '../config/test.constants.fixture.js'; +import { TestEntityFixture } from '../entity/test.entity.fixture.js'; + +@Module({ + imports: [ + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [{ key: TEST_ENTITY_TOKEN, entity: TestEntityFixture }], + }), + ], +}) +export class TestModuleFixture {} diff --git a/packages/nestjs-repository-typeorm/src/__tests__/exception-fault.spec.ts b/packages/nestjs-repository-typeorm/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..4d7f47fb4 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,43 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { TypeOrmEntityNameException } from '../exceptions/typeorm-entity-name.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'TypeOrmEntityNameException', + build: () => new TypeOrmEntityNameException(), + fault: 'usage', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-typeorm-ext/src/entities/audit/audit-postgres.entity.ts b/packages/nestjs-repository-typeorm/src/entities/audit/audit-postgres.entity.ts similarity index 92% rename from packages/nestjs-typeorm-ext/src/entities/audit/audit-postgres.entity.ts rename to packages/nestjs-repository-typeorm/src/entities/audit/audit-postgres.entity.ts index 7b2a4a2bc..e2100e596 100644 --- a/packages/nestjs-typeorm-ext/src/entities/audit/audit-postgres.entity.ts +++ b/packages/nestjs-repository-typeorm/src/entities/audit/audit-postgres.entity.ts @@ -11,10 +11,10 @@ import { AuditDateUpdated, AuditInterface, AuditVersion, -} from '@concepta/nestjs-common'; +} from '@concepta/nestjs-core'; /** - * Audit Postgres Embed + * Audit Postgres */ export abstract class AuditPostgresEntity implements AuditInterface { /** diff --git a/packages/nestjs-typeorm-ext/src/entities/audit/audit-sqlite.entity.ts b/packages/nestjs-repository-typeorm/src/entities/audit/audit-sqlite.entity.ts similarity index 83% rename from packages/nestjs-typeorm-ext/src/entities/audit/audit-sqlite.entity.ts rename to packages/nestjs-repository-typeorm/src/entities/audit/audit-sqlite.entity.ts index 36a9332e4..239bf2e9f 100644 --- a/packages/nestjs-typeorm-ext/src/entities/audit/audit-sqlite.entity.ts +++ b/packages/nestjs-repository-typeorm/src/entities/audit/audit-sqlite.entity.ts @@ -11,12 +11,12 @@ import { AuditDateUpdated, AuditInterface, AuditVersion, -} from '@concepta/nestjs-common'; +} from '@concepta/nestjs-core'; /** - * Audit SqlLite Embed + * Audit Sqlite */ -export abstract class AuditSqlLiteEntity implements AuditInterface { +export abstract class AuditSqliteEntity implements AuditInterface { /** * Date created. */ diff --git a/packages/nestjs-repository-typeorm/src/entities/common/common-postgres.entity.ts b/packages/nestjs-repository-typeorm/src/entities/common/common-postgres.entity.ts new file mode 100644 index 000000000..eb491a62c --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/entities/common/common-postgres.entity.ts @@ -0,0 +1,13 @@ +import { PrimaryGeneratedColumn } from 'typeorm'; + +import { AuditInterface, ReferenceIdInterface } from '@concepta/nestjs-core'; + +import { AuditPostgresEntity } from '../audit/audit-postgres.entity.js'; + +export abstract class CommonPostgresEntity + extends AuditPostgresEntity + implements ReferenceIdInterface, AuditInterface +{ + @PrimaryGeneratedColumn('uuid') + id!: string; +} diff --git a/packages/nestjs-repository-typeorm/src/entities/common/common-sqlite.entity.ts b/packages/nestjs-repository-typeorm/src/entities/common/common-sqlite.entity.ts new file mode 100644 index 000000000..2e85fe6b9 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/entities/common/common-sqlite.entity.ts @@ -0,0 +1,13 @@ +import { PrimaryGeneratedColumn } from 'typeorm'; + +import { AuditInterface, ReferenceIdInterface } from '@concepta/nestjs-core'; + +import { AuditSqliteEntity } from '../audit/audit-sqlite.entity.js'; + +export abstract class CommonSqliteEntity + extends AuditSqliteEntity + implements ReferenceIdInterface, AuditInterface +{ + @PrimaryGeneratedColumn('uuid') + id!: string; +} diff --git a/packages/nestjs-repository-typeorm/src/exceptions/typeorm-entity-name.exception.ts b/packages/nestjs-repository-typeorm/src/exceptions/typeorm-entity-name.exception.ts new file mode 100644 index 000000000..b01a29edf --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/exceptions/typeorm-entity-name.exception.ts @@ -0,0 +1,19 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +/** + * Exception thrown when entity name cannot be resolved from TypeORM metadata. + */ +export class TypeOrmEntityNameException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Unable to resolve entity name from TypeORM repository metadata', + fault: 'usage', + ...options, + }); + + this.errorCode = 'TYPEORM_ENTITY_NAME_RESOLUTION'; + } +} diff --git a/packages/nestjs-repository-typeorm/src/index.ts b/packages/nestjs-repository-typeorm/src/index.ts new file mode 100644 index 000000000..020ad3cd4 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/index.ts @@ -0,0 +1,16 @@ +// Module +export { TypeOrmRepositoryModule } from './typeorm-repository.module.js'; + +// Repository +export { TypeOrmRepository } from './repository/typeorm-repository.js'; +export { TypeOrmProviderOptionsInterface } from './repository/typeorm-provider-options.interface.js'; + +// Transaction +export { TypeOrmTransaction } from './transaction/typeorm-transaction.js'; +export { TypeOrmTransactionFactory } from './transaction/typeorm-transaction.factory.js'; + +// base entities +export { AuditPostgresEntity } from './entities/audit/audit-postgres.entity.js'; +export { AuditSqliteEntity } from './entities/audit/audit-sqlite.entity.js'; +export { CommonPostgresEntity } from './entities/common/common-postgres.entity.js'; +export { CommonSqliteEntity } from './entities/common/common-sqlite.entity.js'; diff --git a/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository-relation-actions.e2e-spec.ts b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository-relation-actions.e2e-spec.ts new file mode 100644 index 000000000..ca1ddc3e1 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository-relation-actions.e2e-spec.ts @@ -0,0 +1,178 @@ +import { Module } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; + +import { + getDynamicRepositoryToken, + Where, + RepositoryModule, +} from '@concepta/nestjs-repository'; +import { SeedingSource } from '@concepta/typeorm-seeding'; + +import { relationOrmConfig } from '../../__fixtures__/repository/config/relation-ormconfig.fixture.js'; +import { + AUTHOR_ENTITY_TOKEN, + POST_ENTITY_TOKEN, + TAG_ENTITY_TOKEN, +} from '../../__fixtures__/repository/config/relation.constants.fixture.js'; +import { AuthorEntityFixture } from '../../__fixtures__/repository/entity/author.entity.fixture.js'; +import { PostEntityFixture } from '../../__fixtures__/repository/entity/post.entity.fixture.js'; +import { TagEntityFixture } from '../../__fixtures__/repository/entity/tag.entity.fixture.js'; +import { AuthorFactoryFixture } from '../../__fixtures__/repository/factory/author.factory.fixture.js'; +import { PostFactoryFixture } from '../../__fixtures__/repository/factory/post.factory.fixture.js'; +import { TypeOrmRepositoryModule } from '../../typeorm-repository.module.js'; +import { TypeOrmRepository } from '../typeorm-repository.js'; + +describe('TypeOrmRepository relation actions (e2e)', () => { + describe('metadata population', () => { + let moduleFixture: TestingModule; + let authorRepo: TypeOrmRepository; + let postRepo: TypeOrmRepository; + + beforeEach(async () => { + @Module({ + imports: [ + TypeOrmModule.forRoot(relationOrmConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: AUTHOR_ENTITY_TOKEN, + entity: AuthorEntityFixture, + relations: { + posts: { onDelete: 'delegate' }, + }, + }, + { key: POST_ENTITY_TOKEN, entity: PostEntityFixture }, + { key: TAG_ENTITY_TOKEN, entity: TagEntityFixture }, + ], + }), + ], + }) + class TestModule {} + + moduleFixture = await Test.createTestingModule({ + imports: [TestModule], + }).compile(); + + authorRepo = moduleFixture.get>( + getDynamicRepositoryToken(AUTHOR_ENTITY_TOKEN), + ); + + postRepo = moduleFixture.get>( + getDynamicRepositoryToken(POST_ENTITY_TOKEN), + ); + }); + + afterEach(async () => { + vi.clearAllMocks(); + await moduleFixture.close(); + }); + + it('should store onDelete in relation metadata', () => { + const postsRel = authorRepo.metadata.relations?.find( + (r) => r.name === 'posts', + ); + expect(postsRel).toBeDefined(); + expect(postsRel!.onDelete).toBe('delegate'); + }); + + it('should leave onDelete undefined when not configured', () => { + const authorRel = postRepo.metadata.relations?.find( + (r) => r.name === 'author', + ); + expect(authorRel).toBeDefined(); + expect(authorRel!.onDelete).toBeUndefined(); + }); + + it('should leave onUpdate undefined when not configured', () => { + const postsRel = authorRepo.metadata.relations?.find( + (r) => r.name === 'posts', + ); + expect(postsRel).toBeDefined(); + expect(postsRel!.onUpdate).toBeUndefined(); + }); + }); + + describe('delegate behavior', () => { + let moduleFixture: TestingModule; + let authorRepo: TypeOrmRepository; + let seedingSource: SeedingSource; + let authorFactory: AuthorFactoryFixture; + let postFactory: PostFactoryFixture; + + beforeEach(async () => { + @Module({ + imports: [ + TypeOrmModule.forRoot(relationOrmConfig), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: AUTHOR_ENTITY_TOKEN, + entity: AuthorEntityFixture, + relations: { + posts: { onDelete: 'delegate' }, + }, + }, + { key: POST_ENTITY_TOKEN, entity: PostEntityFixture }, + { key: TAG_ENTITY_TOKEN, entity: TagEntityFixture }, + ], + }), + ], + }) + class TestModule {} + + moduleFixture = await Test.createTestingModule({ + imports: [TestModule], + }).compile(); + + authorRepo = moduleFixture.get>( + getDynamicRepositoryToken(AUTHOR_ENTITY_TOKEN), + ); + + seedingSource = new SeedingSource({ + dataSource: moduleFixture.get(getDataSourceToken()), + }); + + await seedingSource.initialize(); + + authorFactory = new AuthorFactoryFixture({ + entity: AuthorEntityFixture, + seedingSource, + }); + + postFactory = new PostFactoryFixture({ + entity: PostEntityFixture, + seedingSource, + }); + }); + + afterEach(async () => { + vi.clearAllMocks(); + await moduleFixture.close(); + }); + + it('should delete entity without related records', async () => { + const author = await authorFactory.create({ name: 'Solo' }); + const deleted = await authorRepo.delete(author); + expect(deleted.name).toBe('Solo'); + + const found = await authorRepo.findOne({ + where: Where.eq('id', author.id), + }); + expect(found).toBeNull(); + }); + + it('should fail when native schema has no cascade configured', async () => { + const author = await authorFactory.create({ name: 'HasPosts' }); + await postFactory.create({ title: 'Post1', authorId: author.id }); + + // PostEntityFixture has no onDelete: 'CASCADE' on its FK, + // so delegate defers to native schema which rejects the delete. + await expect(authorRepo.delete(author)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository-relations.e2e-spec.ts b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository-relations.e2e-spec.ts new file mode 100644 index 000000000..9a2814b92 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository-relations.e2e-spec.ts @@ -0,0 +1,516 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; + +import { RuntimeException } from '@concepta/nestjs-core'; +import { getDynamicRepositoryToken, Where } from '@concepta/nestjs-repository'; +import { SeedingSource } from '@concepta/typeorm-seeding'; + +import { + AUTHOR_ENTITY_TOKEN, + POST_ENTITY_TOKEN, + TAG_ENTITY_TOKEN, +} from '../../__fixtures__/repository/config/relation.constants.fixture.js'; +import { AuthorEntityFixture } from '../../__fixtures__/repository/entity/author.entity.fixture.js'; +import { PostEntityFixture } from '../../__fixtures__/repository/entity/post.entity.fixture.js'; +import { TagEntityFixture } from '../../__fixtures__/repository/entity/tag.entity.fixture.js'; +import { AuthorFactoryFixture } from '../../__fixtures__/repository/factory/author.factory.fixture.js'; +import { PostFactoryFixture } from '../../__fixtures__/repository/factory/post.factory.fixture.js'; +import { TagFactoryFixture } from '../../__fixtures__/repository/factory/tag.factory.fixture.js'; +import { RelationAppModuleFixture } from '../../__fixtures__/repository/module/relation-app.module.fixture.js'; +import { type TypeOrmRepository } from '../typeorm-repository.js'; + +describe('TypeOrmRepository (relations)', () => { + let authorRepo: TypeOrmRepository; + let postRepo: TypeOrmRepository; + let tagRepo: TypeOrmRepository; + let seedingSource: SeedingSource; + let authorFactory: AuthorFactoryFixture; + let postFactory: PostFactoryFixture; + let tagFactory: TagFactoryFixture; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [RelationAppModuleFixture], + }).compile(); + + authorRepo = moduleFixture.get>( + getDynamicRepositoryToken(AUTHOR_ENTITY_TOKEN), + ); + + postRepo = moduleFixture.get>( + getDynamicRepositoryToken(POST_ENTITY_TOKEN), + ); + + tagRepo = moduleFixture.get>( + getDynamicRepositoryToken(TAG_ENTITY_TOKEN), + ); + + seedingSource = new SeedingSource({ + dataSource: moduleFixture.get(getDataSourceToken()), + }); + + await seedingSource.initialize(); + + authorFactory = new AuthorFactoryFixture({ + entity: AuthorEntityFixture, + seedingSource, + }); + + postFactory = new PostFactoryFixture({ + entity: PostEntityFixture, + seedingSource, + }); + + tagFactory = new TagFactoryFixture({ + entity: TagEntityFixture, + seedingSource, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('resolveJoinClauses', () => { + it('should pass through valid join clauses', () => { + const input = [{ relation: 'posts' }]; + const resolved = authorRepo['resolveJoinClauses'](input); + expect(resolved).toBe(input); + }); + + it('should throw RuntimeException for unknown relation', () => { + expect(() => { + authorRepo['resolveJoinClauses']([{ relation: 'nonexistent' }]); + }).toThrow(RuntimeException); + }); + + it('should return undefined for empty array', () => { + expect(authorRepo['resolveJoinClauses']([])).toBeUndefined(); + }); + + it('should return undefined for undefined input', () => { + expect(authorRepo['resolveJoinClauses'](undefined)).toBeUndefined(); + }); + }); + + describe('find with join', () => { + let author: AuthorEntityFixture; + let post1: PostEntityFixture; + + beforeEach(async () => { + author = await authorFactory.create({ name: 'Alice' }); + + post1 = await postFactory.create({ + title: 'First Post', + authorId: author.id, + }); + + await postFactory.create({ + title: 'Second Post', + authorId: author.id, + }); + }); + + it('should return author with posts populated via join', async () => { + const results = await authorRepo.find({ + where: Where.eq('id', author.id), + join: [{ relation: 'posts' }], + }); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe(author.id); + expect(results[0].posts).toHaveLength(2); + + const titles = results[0].posts.map((p) => p.title).sort(); + expect(titles).toEqual(['First Post', 'Second Post']); + }); + + it('should return post with author populated via join', async () => { + const result = await postRepo.findOne({ + where: Where.eq('id', post1.id), + join: [{ relation: 'author' }], + }); + + expect(result).toBeDefined(); + expect(result!.id).toBe(post1.id); + expect(result!.author).toBeDefined(); + expect(result!.author.id).toBe(author.id); + expect(result!.author.name).toBe('Alice'); + }); + + it('should not populate relations without join', async () => { + const results = await authorRepo.find({ + where: Where.eq('id', author.id), + }); + + expect(results).toHaveLength(1); + expect(results[0].posts).toBeUndefined(); + }); + + it('should return author with empty posts when no posts exist', async () => { + const lonelyAuthor = await authorFactory.create({ name: 'Bob' }); + + const results = await authorRepo.find({ + where: Where.eq('id', lonelyAuthor.id), + join: [{ relation: 'posts' }], + }); + + expect(results).toHaveLength(1); + expect(results[0].posts).toEqual([]); + }); + + it('should work with findOne and join', async () => { + const result = await authorRepo.findOne({ + where: Where.eq('id', author.id), + join: [{ relation: 'posts' }], + }); + + expect(result).toBeDefined(); + expect(result!.posts).toHaveLength(2); + }); + + it('should filter on root entity while populating join', async () => { + const otherAuthor = await authorFactory.create({ name: 'Charlie' }); + await postFactory.create({ + title: 'Other Post', + authorId: otherAuthor.id, + }); + + const results = await authorRepo.find({ + where: Where.eq('name', 'Alice'), + join: [{ relation: 'posts' }], + }); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Alice'); + expect(results[0].posts).toHaveLength(2); + }); + + it('should return multiple posts for an author via post repo', async () => { + const results = await postRepo.find({ + where: Where.eq('authorId', author.id), + join: [{ relation: 'author' }], + }); + + expect(results).toHaveLength(2); + const titles = results.map((p) => p.title).sort(); + expect(titles).toEqual(['First Post', 'Second Post']); + expect(results[0].author.id).toBe(author.id); + expect(results[1].author.id).toBe(author.id); + }); + + it('should filter on relation field using Where.rel()', async () => { + const results = await postRepo.find({ + where: Where.rel('author', Where.eq('name', 'Alice')), + join: [{ relation: 'author' }], + }); + + expect(results).toHaveLength(2); + const titles = results.map((p) => p.title).sort(); + expect(titles).toEqual(['First Post', 'Second Post']); + expect(results[0].author.name).toBe('Alice'); + }); + + it('should return empty when relation filter matches nothing', async () => { + const results = await postRepo.find({ + where: Where.rel('author', Where.eq('name', 'Nobody')), + join: [{ relation: 'author' }], + }); + + expect(results).toHaveLength(0); + }); + + it('should combine root and relation filters', async () => { + const results = await postRepo.find({ + where: Where.and( + Where.eq('title', 'First Post'), + Where.rel('author', Where.eq('name', 'Alice')), + ), + join: [{ relation: 'author' }], + }); + + expect(results).toHaveLength(1); + expect(results[0].title).toBe('First Post'); + expect(results[0].author.name).toBe('Alice'); + }); + + it('should work with findAndCount and join', async () => { + const [results, count] = await authorRepo.findAndCount({ + join: [{ relation: 'posts' }], + }); + + expect(count).toBe(1); + expect(results).toHaveLength(1); + expect(results[0].posts).toHaveLength(2); + }); + }); + + describe('many-to-many join', () => { + let author: AuthorEntityFixture; + let post1: PostEntityFixture; + let post2: PostEntityFixture; + let tag1: TagEntityFixture; + let tag2: TagEntityFixture; + + beforeEach(async () => { + author = await authorFactory.create({ name: 'Alice' }); + + post1 = await postFactory.create({ + title: 'First Post', + authorId: author.id, + }); + + post2 = await postFactory.create({ + title: 'Second Post', + authorId: author.id, + }); + + tag1 = await tagFactory.create({ label: 'TypeScript' }); + tag2 = await tagFactory.create({ label: 'NestJS' }); + + // Associate tags with posts via owning side (Tag) + tag1.posts = [post1, post2]; + await tagRepo.create(tag1); + + tag2.posts = [post1]; + await tagRepo.create(tag2); + }); + + it('should return tag with posts populated via M2M join (owning side)', async () => { + const results = await tagRepo.find({ + where: Where.eq('id', tag1.id), + join: [{ relation: 'posts' }], + }); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe(tag1.id); + expect(results[0].posts).toHaveLength(2); + + const titles = results[0].posts.map((p) => p.title).sort(); + expect(titles).toEqual(['First Post', 'Second Post']); + }); + + it('should return post with tags populated via M2M join (non-owning side)', async () => { + const result = await postRepo.findOne({ + where: Where.eq('id', post1.id), + join: [{ relation: 'tags' }], + }); + + expect(result).toBeDefined(); + expect(result!.tags).toHaveLength(2); + + const labels = result!.tags.map((t) => t.label).sort(); + expect(labels).toEqual(['NestJS', 'TypeScript']); + }); + + it('should return empty tags when post has none', async () => { + const lonelyPost = await postFactory.create({ + title: 'No Tags', + authorId: author.id, + }); + + const result = await postRepo.findOne({ + where: Where.eq('id', lonelyPost.id), + join: [{ relation: 'tags' }], + }); + + expect(result).toBeDefined(); + expect(result!.tags).toEqual([]); + }); + + it('should not populate M2M relations without join', async () => { + const results = await tagRepo.find({ + where: Where.eq('id', tag1.id), + }); + + expect(results).toHaveLength(1); + expect(results[0].posts).toBeUndefined(); + }); + + it('should filter on M2M relation using Where.rel()', async () => { + const results = await postRepo.find({ + where: Where.rel('tags', Where.eq('label', 'TypeScript')), + join: [{ relation: 'tags' }], + }); + + expect(results).toHaveLength(2); + const titles = results.map((p) => p.title).sort(); + expect(titles).toEqual(['First Post', 'Second Post']); + }); + + it('should work with findAndCount and M2M join', async () => { + const [results, count] = await tagRepo.findAndCount({ + join: [{ relation: 'posts' }], + }); + + expect(count).toBe(2); + expect(results).toHaveLength(2); + + const tag1Result = results.find((t) => t.id === tag1.id); + const tag2Result = results.find((t) => t.id === tag2.id); + expect(tag1Result!.posts).toHaveLength(2); + expect(tag2Result!.posts).toHaveLength(1); + }); + }); + + describe('multi-join', () => { + let author: AuthorEntityFixture; + let post1: PostEntityFixture; + let post2: PostEntityFixture; + let tag1: TagEntityFixture; + let tag2: TagEntityFixture; + + beforeEach(async () => { + author = await authorFactory.create({ name: 'Alice' }); + + post1 = await postFactory.create({ + title: 'First Post', + authorId: author.id, + }); + + post2 = await postFactory.create({ + title: 'Second Post', + authorId: author.id, + }); + + tag1 = await tagFactory.create({ label: 'TypeScript' }); + tag2 = await tagFactory.create({ label: 'NestJS' }); + + tag1.posts = [post1, post2]; + await tagRepo.create(tag1); + + tag2.posts = [post1]; + await tagRepo.create(tag2); + }); + + it('should populate both author and tags via two joins on find', async () => { + const results = await postRepo.find({ + where: Where.eq('id', post1.id), + join: [{ relation: 'author' }, { relation: 'tags' }], + }); + + expect(results).toHaveLength(1); + expect(results[0].author).toBeDefined(); + expect(results[0].author.name).toBe('Alice'); + expect(results[0].tags).toHaveLength(2); + const labels = results[0].tags.map((t) => t.label).sort(); + expect(labels).toEqual(['NestJS', 'TypeScript']); + }); + + it('should populate both relations via two joins on findOne', async () => { + const result = await postRepo.findOne({ + where: Where.eq('id', post2.id), + join: [{ relation: 'author' }, { relation: 'tags' }], + }); + + expect(result).toBeDefined(); + expect(result!.author.id).toBe(author.id); + expect(result!.tags).toHaveLength(1); + expect(result!.tags[0].label).toBe('TypeScript'); + }); + + it('should populate both relations via two joins on findAndCount', async () => { + const [results, count] = await postRepo.findAndCount({ + where: Where.eq('authorId', author.id), + join: [{ relation: 'author' }, { relation: 'tags' }], + }); + + expect(count).toBe(2); + expect(results).toHaveLength(2); + for (const post of results) { + expect(post.author.name).toBe('Alice'); + expect(post.tags.length).toBeGreaterThanOrEqual(1); + } + }); + + it('should filter on one relation while joining both', async () => { + const results = await postRepo.find({ + where: Where.rel('tags', Where.eq('label', 'NestJS')), + join: [{ relation: 'author' }, { relation: 'tags' }], + }); + + expect(results).toHaveLength(1); + expect(results[0].title).toBe('First Post'); + expect(results[0].author.name).toBe('Alice'); + expect(results[0].tags.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('relation sort', () => { + let authorAlice: AuthorEntityFixture; + let authorZara: AuthorEntityFixture; + let alicePost: PostEntityFixture; + let _zaraPost: PostEntityFixture; + + beforeEach(async () => { + authorAlice = await authorFactory.create({ name: 'Alice' }); + authorZara = await authorFactory.create({ name: 'Zara' }); + + alicePost = await postFactory.create({ + title: 'Alice-Post', + authorId: authorAlice.id, + }); + + _zaraPost = await postFactory.create({ + title: 'Zara-Post', + authorId: authorZara.id, + }); + }); + + it('should sort by relation field ASC', async () => { + const results = await postRepo.find({ + join: [{ relation: 'author' }], + order: [{ field: 'name', order: 'ASC', relation: 'author' }], + }); + + expect(results).toHaveLength(2); + expect(results[0].title).toBe('Alice-Post'); + expect(results[1].title).toBe('Zara-Post'); + }); + + it('should sort by relation field DESC', async () => { + const results = await postRepo.find({ + join: [{ relation: 'author' }], + order: [{ field: 'name', order: 'DESC', relation: 'author' }], + }); + + expect(results).toHaveLength(2); + expect(results[0].title).toBe('Zara-Post'); + expect(results[1].title).toBe('Alice-Post'); + }); + + it('should combine root sort with relation sort', async () => { + await postFactory.create({ + title: 'Alice-Second', + authorId: authorAlice.id, + }); + + const results = await postRepo.find({ + join: [{ relation: 'author' }], + order: [ + { field: 'name', order: 'ASC', relation: 'author' }, + { field: 'title', order: 'DESC' }, + ], + }); + + expect(results).toHaveLength(3); + // Alice's posts first (author ASC), then sorted by title DESC + expect(results[0].title).toBe('Alice-Second'); + expect(results[1].title).toBe('Alice-Post'); + // Zara's post last + expect(results[2].title).toBe('Zara-Post'); + }); + + it('should return entity with relation via findOne with order', async () => { + const result = await postRepo.findOne({ + where: Where.eq('id', alicePost.id), + join: [{ relation: 'author' }], + order: [{ field: 'name', order: 'ASC', relation: 'author' }], + }); + + expect(result).toBeDefined(); + expect(result!.title).toBe('Alice-Post'); + expect(result!.author.name).toBe('Alice'); + }); + }); +}); diff --git a/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository-relations.spec.ts b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository-relations.spec.ts new file mode 100644 index 000000000..68155f329 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository-relations.spec.ts @@ -0,0 +1,455 @@ +import { mockRelationMetadata } from '../../__fixtures__/repository/mock/relation-metadata.mock.js'; +import { mockTypeOrmRepository } from '../../__fixtures__/repository/mock/typeorm-repository.mock.js'; +import { buildRelations } from '../typeorm-options.schema.js'; + +// ═══════════════════════════════════════════════════════════════════════════ +// buildRelations — pure function, mocked RelationMetadata +// ═══════════════════════════════════════════════════════════════════════════ + +describe('buildRelations', () => { + it('should map owning-side ManyToOne relation', () => { + const rel = mockRelationMetadata({ + propertyName: 'author', + inverseEntityMetadata: { name: 'AuthorEntity' }, + isOwning: true, + joinColumns: [ + { + propertyName: 'authorId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }); + + const result = buildRelations([rel]); + expect(result).toEqual([ + { + name: 'author', + targetEntity: 'AuthorEntity', + cardinality: 'one', + on: { from: 'authorId', to: 'id' }, + }, + ]); + }); + + it('should map non-owning OneToMany relation with swapped on', () => { + const rel = mockRelationMetadata({ + propertyName: 'posts', + inverseEntityMetadata: { name: 'PostEntity' }, + isOneToMany: true, + isOwning: false, + inverseRelation: { + joinColumns: [ + { + propertyName: 'authorId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }, + }); + + const result = buildRelations([rel]); + expect(result).toEqual([ + { + name: 'posts', + targetEntity: 'PostEntity', + cardinality: 'many', + on: { from: 'id', to: 'authorId' }, + }, + ]); + }); + + it('should map owning-side ManyToMany relation with through', () => { + const rel = mockRelationMetadata({ + propertyName: 'tags', + inverseEntityMetadata: { name: 'TagEntity' }, + isManyToMany: true, + isManyToManyOwner: true, + junctionEntityMetadata: { name: 'post_tags' }, + joinColumns: [ + { + propertyName: 'postId', + referencedColumn: { propertyName: 'id' }, + }, + ], + inverseJoinColumns: [ + { + propertyName: 'tagId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }); + + const result = buildRelations([rel]); + expect(result).toEqual([ + { + name: 'tags', + targetEntity: 'TagEntity', + cardinality: 'many', + on: { from: 'id', to: 'id' }, + through: { + relation: 'post_tags', + fromKey: 'postId', + toKey: 'tagId', + }, + }, + ]); + }); + + it('should map non-owning ManyToMany relation with swapped through', () => { + const rel = mockRelationMetadata({ + propertyName: 'posts', + inverseEntityMetadata: { name: 'PostEntity' }, + isManyToMany: true, + isManyToManyOwner: false, + inverseRelation: { + junctionEntityMetadata: { name: 'post_tags' }, + joinColumns: [ + { + propertyName: 'postId', + referencedColumn: { propertyName: 'id' }, + }, + ], + inverseJoinColumns: [ + { + propertyName: 'tagId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }, + }); + + const result = buildRelations([rel]); + expect(result).toEqual([ + { + name: 'posts', + targetEntity: 'PostEntity', + cardinality: 'many', + on: { from: 'id', to: 'id' }, + through: { + relation: 'post_tags', + fromKey: 'tagId', + toKey: 'postId', + }, + }, + ]); + }); + + it('should skip owning relation with no joinColumns', () => { + const rel = mockRelationMetadata({ + propertyName: 'broken', + inverseEntityMetadata: { name: 'OtherEntity' }, + isOwning: true, + joinColumns: [], + }); + + expect(buildRelations([rel])).toEqual([]); + }); + + it('should skip non-owning relation with no inverseRelation', () => { + const rel = mockRelationMetadata({ + propertyName: 'broken', + inverseEntityMetadata: { name: 'OtherEntity' }, + isOneToMany: true, + isOwning: false, + inverseRelation: undefined, + }); + + expect(buildRelations([rel])).toEqual([]); + }); + + it('should skip M2M owning relation with no junctionEntityMetadata', () => { + const rel = mockRelationMetadata({ + propertyName: 'tags', + inverseEntityMetadata: { name: 'TagEntity' }, + isManyToMany: true, + isManyToManyOwner: true, + junctionEntityMetadata: undefined, + }); + + expect(buildRelations([rel])).toEqual([]); + }); + + it('should skip M2M owning relation with no inverseJoinColumns', () => { + const rel = mockRelationMetadata({ + propertyName: 'tags', + inverseEntityMetadata: { name: 'TagEntity' }, + isManyToMany: true, + isManyToManyOwner: true, + junctionEntityMetadata: { name: 'post_tags' }, + joinColumns: [ + { + propertyName: 'postId', + referencedColumn: { propertyName: 'id' }, + }, + ], + inverseJoinColumns: [], + }); + + expect(buildRelations([rel])).toEqual([]); + }); + + it('should skip M2M non-owning relation with no inverseRelation', () => { + const rel = mockRelationMetadata({ + propertyName: 'posts', + inverseEntityMetadata: { name: 'PostEntity' }, + isManyToMany: true, + isManyToManyOwner: false, + inverseRelation: undefined, + }); + + expect(buildRelations([rel])).toEqual([]); + }); + + it('should return empty array for empty input', () => { + expect(buildRelations([])).toEqual([]); + }); + + it('should merge relationsConfig onDelete into mapped relation', () => { + const rel = mockRelationMetadata({ + propertyName: 'posts', + inverseEntityMetadata: { name: 'PostEntity' }, + isOneToMany: true, + isOwning: false, + inverseRelation: { + joinColumns: [ + { + propertyName: 'authorId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }, + }); + + const result = buildRelations([rel], { + posts: { onDelete: 'delegate' }, + }); + + expect(result).toEqual([ + { + name: 'posts', + targetEntity: 'PostEntity', + cardinality: 'many', + on: { from: 'id', to: 'authorId' }, + onDelete: 'delegate', + onUpdate: undefined, + }, + ]); + }); + + it('should merge relationsConfig onUpdate into mapped relation', () => { + const rel = mockRelationMetadata({ + propertyName: 'author', + inverseEntityMetadata: { name: 'AuthorEntity' }, + isOwning: true, + joinColumns: [ + { + propertyName: 'authorId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }); + + const result = buildRelations([rel], { + author: { onUpdate: 'delegate' }, + }); + + expect(result).toEqual([ + { + name: 'author', + targetEntity: 'AuthorEntity', + cardinality: 'one', + on: { from: 'authorId', to: 'id' }, + onDelete: undefined, + onUpdate: 'delegate', + }, + ]); + }); + + it('should not apply config to relations not in relationsConfig', () => { + const rel = mockRelationMetadata({ + propertyName: 'author', + inverseEntityMetadata: { name: 'AuthorEntity' }, + isOwning: true, + joinColumns: [ + { + propertyName: 'authorId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }); + + const result = buildRelations([rel], { + posts: { onDelete: 'delegate' }, + }); + + expect(result).toEqual([ + { + name: 'author', + targetEntity: 'AuthorEntity', + cardinality: 'one', + on: { from: 'authorId', to: 'id' }, + }, + ]); + }); + + it('should apply config only to matching relations in mixed set', () => { + const owning = mockRelationMetadata({ + propertyName: 'author', + inverseEntityMetadata: { name: 'AuthorEntity' }, + isOwning: true, + joinColumns: [ + { + propertyName: 'authorId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }); + + const nonOwning = mockRelationMetadata({ + propertyName: 'comments', + inverseEntityMetadata: { name: 'CommentEntity' }, + isOneToMany: true, + isOwning: false, + inverseRelation: { + joinColumns: [ + { + propertyName: 'postId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }, + }); + + const result = buildRelations([owning, nonOwning], { + comments: { onDelete: 'delegate' }, + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + name: 'author', + targetEntity: 'AuthorEntity', + cardinality: 'one', + on: { from: 'authorId', to: 'id' }, + }); + expect(result[1]).toEqual({ + name: 'comments', + targetEntity: 'CommentEntity', + cardinality: 'many', + on: { from: 'id', to: 'postId' }, + onDelete: 'delegate', + onUpdate: undefined, + }); + }); + + it('should merge relationsConfig federation settings into mapped relation', () => { + const rel = mockRelationMetadata({ + propertyName: 'posts', + inverseEntityMetadata: { name: 'PostEntity' }, + isOneToMany: true, + isOwning: false, + inverseRelation: { + joinColumns: [ + { + propertyName: 'authorId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }, + }); + + const result = buildRelations([rel], { + posts: { + federated: true, + distinctFilter: { + field: 'published', + operator: 'eq', + value: true, + }, + }, + }); + + expect(result).toEqual([ + { + name: 'posts', + targetEntity: 'PostEntity', + cardinality: 'many', + on: { from: 'id', to: 'authorId' }, + onDelete: undefined, + onUpdate: undefined, + federated: true, + distinctFilter: { + field: 'published', + operator: 'eq', + value: true, + }, + }, + ]); + }); + + it('should map multiple relations', () => { + const owning = mockRelationMetadata({ + propertyName: 'author', + inverseEntityMetadata: { name: 'AuthorEntity' }, + isOwning: true, + joinColumns: [ + { + propertyName: 'authorId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }); + + const nonOwning = mockRelationMetadata({ + propertyName: 'comments', + inverseEntityMetadata: { name: 'CommentEntity' }, + isOneToMany: true, + isOwning: false, + inverseRelation: { + joinColumns: [ + { + propertyName: 'postId', + referencedColumn: { propertyName: 'id' }, + }, + ], + }, + }); + + const result = buildRelations([owning, nonOwning]); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('author'); + expect(result[1].name).toBe('comments'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// translateJoin — minimal mock TypeORM repo +// ═══════════════════════════════════════════════════════════════════════════ + +describe('translateJoin', () => { + let typeormRepo: ReturnType; + + beforeAll(() => { + typeormRepo = mockTypeOrmRepository(); + }); + + it('should translate single join to relations object', () => { + const result = typeormRepo['translateJoin']([{ relation: 'posts' }]); + expect(result).toEqual({ posts: true }); + }); + + it('should translate multiple joins', () => { + const result = typeormRepo['translateJoin']([ + { relation: 'author' }, + { relation: 'tags' }, + ]); + expect(result).toEqual({ author: true, tags: true }); + }); + + it('should return undefined for empty array', () => { + expect(typeormRepo['translateJoin']([])).toBeUndefined(); + }); + + it('should return undefined for undefined input', () => { + expect(typeormRepo['translateJoin'](undefined)).toBeUndefined(); + }); +}); diff --git a/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository.e2e-spec.ts b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository.e2e-spec.ts new file mode 100644 index 000000000..ecb0a1ea0 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository.e2e-spec.ts @@ -0,0 +1,712 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; + +import { RuntimeException } from '@concepta/nestjs-core'; +import { + getDynamicRepositoryToken, + OptimisticLockException, + RepositoryQueryException, + TransactionScope, + Where, +} from '@concepta/nestjs-repository'; +import { SeedingSource } from '@concepta/typeorm-seeding'; + +import { TEST_ENTITY_TOKEN } from '../../__fixtures__/repository/config/test.constants.fixture.js'; +import { TestEntityFixture } from '../../__fixtures__/repository/entity/test.entity.fixture.js'; +import { TestFactoryFixture } from '../../__fixtures__/repository/factory/test.factory.fixture.js'; +import { AppModuleFixture } from '../../__fixtures__/repository/module/app.module.fixture.js'; +import { TypeOrmRepository } from '../typeorm-repository.js'; + +describe(TypeOrmRepository, () => { + let moduleFixture: TestingModule; + let testRepository: TypeOrmRepository; + let seedingSource: SeedingSource; + let testFactory: TestFactoryFixture; + + beforeEach(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + // Get the TypeOrmRepository via public token + testRepository = moduleFixture.get>( + getDynamicRepositoryToken(TEST_ENTITY_TOKEN), + ); + + seedingSource = new SeedingSource({ + dataSource: moduleFixture.get(getDataSourceToken()), + }); + + await seedingSource.initialize(); + + testFactory = new TestFactoryFixture({ + entity: TestEntityFixture, + seedingSource, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should be loaded', () => { + expect(testRepository).toBeInstanceOf(TypeOrmRepository); + }); + + describe('entityName', () => { + it('should return the entity name', () => { + expect(testRepository.metadata.name).toBe('TestEntityFixture'); + }); + }); + + describe('metadata.columns', () => { + it('should mark the version column as isVersion', () => { + const versionColumn = testRepository.metadata.columns.find( + (c) => c.name === 'version', + ); + expect(versionColumn?.isVersion).toBe(true); + }); + + it('should not mark other columns as isVersion', () => { + const otherColumns = testRepository.metadata.columns.filter( + (c) => c.name !== 'version', + ); + expect(otherColumns.length).toBeGreaterThan(0); + expect(otherColumns.every((c) => c.isVersion === false)).toBe(true); + }); + }); + + describe('find', () => { + it('should return empty array when no entities', async () => { + const result = await testRepository.find(); + expect(result).toEqual([]); + }); + + it('should return entities', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + + const result = await testRepository.find(); + const firstNames = result.map((e) => e.firstName).sort(); + expect(firstNames).toEqual(['Alice', 'Bob']); + }); + + it('should apply where conditions', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + + const result = await testRepository.find({ + where: Where.eq('firstName', 'Alice'), + }); + expect(result).toEqual([expect.objectContaining({ firstName: 'Alice' })]); + }); + + describe('where operators', () => { + it('eq - should match equal values', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + + const result = await testRepository.find({ + where: Where.eq('firstName', 'Alice'), + }); + expect(result).toHaveLength(1); + expect(result[0].firstName).toBe('Alice'); + }); + + it('ne - should exclude matching values', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + + const result = await testRepository.find({ + where: Where.ne('firstName', 'Alice'), + }); + expect(result).toHaveLength(1); + expect(result[0].firstName).toBe('Bob'); + }); + + it('gt - should match greater than', async () => { + await testFactory.create({ firstName: 'Alice' }); + const bob = await testFactory.create({ firstName: 'Bob' }); + await testRepository.update(bob, { lastName: 'Updated' }); + + const result = await testRepository.find({ + where: Where.gt('version', 1), + }); + expect(result).toHaveLength(1); + expect(result[0].firstName).toBe('Bob'); + }); + + it('gte - should match greater than or equal', async () => { + await testFactory.create({ firstName: 'Alice' }); + const bob = await testFactory.create({ firstName: 'Bob' }); + await testRepository.update(bob, { lastName: 'Updated' }); + + const result = await testRepository.find({ + where: Where.gte('version', 2), + }); + expect(result).toHaveLength(1); + expect(result[0].firstName).toBe('Bob'); + }); + + it('lt - should match less than', async () => { + await testFactory.create({ firstName: 'Alice' }); + const bob = await testFactory.create({ firstName: 'Bob' }); + await testRepository.update(bob, { lastName: 'Updated' }); + + const result = await testRepository.find({ + where: Where.lt('version', 2), + }); + expect(result).toHaveLength(1); + expect(result[0].firstName).toBe('Alice'); + }); + + it('lte - should match less than or equal', async () => { + await testFactory.create({ firstName: 'Alice' }); + const bob = await testFactory.create({ firstName: 'Bob' }); + await testRepository.update(bob, { lastName: 'Updated' }); + + const result = await testRepository.find({ + where: Where.lte('version', 1), + }); + expect(result).toHaveLength(1); + expect(result[0].firstName).toBe('Alice'); + }); + + it('contains - should match containing substring', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Alicia' }); + await testFactory.create({ firstName: 'Bob' }); + + const result = await testRepository.find({ + where: Where.contains('firstName', 'Ali'), + }); + expect(result).toHaveLength(2); + expect(result.map((e) => e.firstName).sort()).toEqual([ + 'Alice', + 'Alicia', + ]); + }); + + it('starts - should match prefix', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + await testFactory.create({ firstName: 'Alicia' }); + + const result = await testRepository.find({ + where: Where.starts('firstName', 'Ali'), + }); + expect(result).toHaveLength(2); + expect(result.map((e) => e.firstName).sort()).toEqual([ + 'Alice', + 'Alicia', + ]); + }); + + it('ends - should match suffix', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Janice' }); + await testFactory.create({ firstName: 'Bob' }); + + const result = await testRepository.find({ + where: Where.ends('firstName', 'ice'), + }); + expect(result).toHaveLength(2); + expect(result.map((e) => e.firstName).sort()).toEqual([ + 'Alice', + 'Janice', + ]); + }); + + it('in - should match values in array', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + await testFactory.create({ firstName: 'Charlie' }); + + const result = await testRepository.find({ + where: Where.in('firstName', ['Alice', 'Charlie']), + }); + expect(result).toHaveLength(2); + expect(result.map((e) => e.firstName).sort()).toEqual([ + 'Alice', + 'Charlie', + ]); + }); + + it('isNull - should match null values', async () => { + await testFactory.create({ firstName: 'Alice', lastName: 'Smith' }); + await testRepository.create({ + firstName: 'Bob', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + lastName: null as any, + }); + + const result = await testRepository.find({ + where: Where.isNull('lastName'), + }); + expect(result).toHaveLength(1); + expect(result[0].firstName).toBe('Bob'); + }); + + it('between - should match values in range', async () => { + // Alice: version 1 (created) + await testFactory.create({ firstName: 'Alice' }); + // Bob: version 2 (created + 1 update) + const bob = await testFactory.create({ firstName: 'Bob' }); + await testRepository.update(bob, { lastName: 'Updated' }); + // Charlie: version 3 (created + 2 updates) + const charlie = await testFactory.create({ firstName: 'Charlie' }); + const charlie2 = await testRepository.update(charlie, { + lastName: 'First', + }); + await testRepository.update(charlie2, { lastName: 'Second' }); + + const result = await testRepository.find({ + where: Where.between('version', 2, 3), + }); + expect(result).toHaveLength(2); + expect(result.map((e) => e.firstName).sort()).toEqual([ + 'Bob', + 'Charlie', + ]); + }); + + it('not - should negate condition', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Alicia' }); + await testFactory.create({ firstName: 'Bob' }); + + const result = await testRepository.find({ + where: Where.notContains('firstName', 'Ali'), + }); + expect(result).toHaveLength(1); + expect(result[0].firstName).toBe('Bob'); + }); + + it('and - should combine conditions', async () => { + await testFactory.create({ firstName: 'Alice', lastName: 'Smith' }); + await testFactory.create({ firstName: 'Alice', lastName: 'Jones' }); + await testFactory.create({ firstName: 'Bob', lastName: 'Smith' }); + + const result = await testRepository.find({ + where: Where.and( + Where.eq('firstName', 'Alice'), + Where.eq('lastName', 'Smith'), + ), + }); + expect(result).toHaveLength(1); + expect(result[0].firstName).toBe('Alice'); + expect(result[0].lastName).toBe('Smith'); + }); + + it('or - should match either condition', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + await testFactory.create({ firstName: 'Charlie' }); + + const result = await testRepository.find({ + where: Where.or( + Where.eq('firstName', 'Alice'), + Where.eq('firstName', 'Charlie'), + ), + }); + expect(result).toHaveLength(2); + expect(result.map((e) => e.firstName).sort()).toEqual([ + 'Alice', + 'Charlie', + ]); + }); + }); + + it('should throw RepositoryQueryException on error', async () => { + vi.spyOn(testRepository['repo'], 'find').mockImplementationOnce(() => { + throw new Error(); + }); + + await expect(testRepository.find()).rejects.toThrow( + RepositoryQueryException, + ); + }); + }); + + describe('findOne', () => { + it('should return null when not found', async () => { + const result = await testRepository.findOne({ + where: Where.eq('firstName', 'NotFound'), + }); + expect(result).toBeNull(); + }); + + it('should return entity when found', async () => { + const created = await testFactory.create({ firstName: 'Alice' }); + + const result = await testRepository.findOne({ + where: Where.eq('id', created.id), + }); + expect(result).not.toBeNull(); + expect(result?.firstName).toBe('Alice'); + }); + + it('should throw RepositoryQueryException on error', async () => { + vi.spyOn(testRepository['repo'], 'findOne').mockImplementationOnce(() => { + throw new Error(); + }); + + await expect(testRepository.findOne({})).rejects.toThrow( + RepositoryQueryException, + ); + }); + }); + + describe('count', () => { + it('should return 0 when no entities', async () => { + const result = await testRepository.count(); + expect(result).toBe(0); + }); + + it('should return count of entities', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + + const result = await testRepository.count(); + expect(result).toBe(2); + }); + + it('should apply where conditions', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + + const result = await testRepository.count({ + where: Where.eq('firstName', 'Alice'), + }); + expect(result).toBe(1); + }); + }); + + describe('findAndCount', () => { + it('should return empty array and 0 when no entities', async () => { + const [entities, count] = await testRepository.findAndCount(); + expect(entities).toEqual([]); + expect(count).toBe(0); + }); + + it('should return entities and count', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + + const [entities, count] = await testRepository.findAndCount(); + expect(entities.length).toBe(2); + expect(count).toBe(2); + }); + + it('should apply where conditions', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + + const [entities, count] = await testRepository.findAndCount({ + where: Where.eq('firstName', 'Alice'), + }); + expect(entities.length).toBe(1); + expect(count).toBe(1); + expect(entities[0].firstName).toBe('Alice'); + }); + + it('should apply pagination with correct total', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + await testFactory.create({ firstName: 'Charlie' }); + + const [entities, count] = await testRepository.findAndCount({ + take: 2, + }); + expect(entities.length).toBe(2); + expect(count).toBe(3); + }); + }); + + describe('transform', () => { + it('should create entity instance without persisting', () => { + const entity = testRepository.transform({ firstName: 'Alice' }); + expect(entity).toBeInstanceOf(TestEntityFixture); + expect(entity.firstName).toBe('Alice'); + expect(entity.id).toBeUndefined(); + }); + }); + + describe('create', () => { + it('should create single entity', async () => { + const created = await testRepository.create({ firstName: 'Alice' }); + + expect(created.id).toBeDefined(); + expect(created.firstName).toBe('Alice'); + }); + }); + + describe('createMany', () => { + it('should create multiple entities', async () => { + const created = await testRepository.createMany([ + { firstName: 'Alice' }, + { firstName: 'Bob' }, + ]); + + expect(created).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + firstName: 'Alice', + id: expect.any(String), + }), + expect.objectContaining({ firstName: 'Bob', id: expect.any(String) }), + ]), + ); + }); + }); + + describe('update', () => { + it('should update entity by merging data', async () => { + const entity = await testFactory.create({ + firstName: 'Alice', + lastName: 'Smith', + }); + const updated = await testRepository.update(entity, { firstName: 'Bob' }); + + expect(updated.firstName).toBe('Bob'); + expect(updated.lastName).toBe('Smith'); + }); + + it('should increment version by exactly 1 on a successful update', async () => { + const entity = await testFactory.create({ + firstName: 'Alice', + lastName: 'Smith', + }); + const updated = await testRepository.update(entity, { firstName: 'Bob' }); + + expect(updated.version).toBe(entity.version + 1); + }); + + it('should throw OptimisticLockException when the entity was concurrently modified since it was read', async () => { + const entity = await testFactory.create({ + firstName: 'Alice', + lastName: 'Smith', + }); + // A concurrent writer updates first, using the same originally-read entity. + await testRepository.update(entity, { firstName: 'Concurrent' }); + + // Our caller still holds the stale, pre-update `entity` — its version + // no longer matches the row. + await expect( + testRepository.update(entity, { firstName: 'Bob' }), + ).rejects.toThrow(OptimisticLockException); + }); + + it('should not let a client-supplied version in data override the real version', async () => { + const entity = await testFactory.create({ + firstName: 'Alice', + lastName: 'Smith', + }); + const updated = await testRepository.update(entity, { + firstName: 'Bob', + version: 999, // deliberately spoofing version via the DTO + }); + + expect(updated.version).toBe(entity.version + 1); + }); + }); + + describe('replace', () => { + it('should replace entity data', async () => { + const entity = await testFactory.create({ + firstName: 'Alice', + lastName: 'Smith', + }); + const replaced = await testRepository.replace(entity, { + firstName: 'Bob', + lastName: 'Jones', + }); + + expect(replaced.firstName).toBe('Bob'); + expect(replaced.lastName).toBe('Jones'); + }); + + it('should increment version by exactly 1 on a successful replace', async () => { + const entity = await testFactory.create({ + firstName: 'Alice', + lastName: 'Smith', + }); + const replaced = await testRepository.replace(entity, { + firstName: 'Bob', + lastName: 'Jones', + }); + + expect(replaced.version).toBe(entity.version + 1); + }); + + it('should throw OptimisticLockException when the entity was concurrently modified since it was read', async () => { + const entity = await testFactory.create({ + firstName: 'Alice', + lastName: 'Smith', + }); + await testRepository.replace(entity, { + firstName: 'Concurrent', + lastName: 'Writer', + }); + + await expect( + testRepository.replace(entity, { firstName: 'Bob', lastName: 'Jones' }), + ).rejects.toThrow(OptimisticLockException); + }); + }); + + describe('upsert', () => { + it('should create entity when it does not exist', async () => { + const id = 'new-upsert-id'; + const result = await testRepository.upsert({ id, firstName: 'Alice' }); + + expect(result.id).toBe(id); + expect(result.firstName).toBe('Alice'); + }); + + it('should update entity when it exists', async () => { + const entity = await testFactory.create({ firstName: 'Alice' }); + + const result = await testRepository.upsert({ + id: entity.id, + firstName: 'Bob', + }); + + expect(result.id).toBe(entity.id); + expect(result.firstName).toBe('Bob'); + }); + }); + + describe('delete', () => { + it('should delete single entity', async () => { + const created = await testFactory.create({ firstName: 'Alice' }); + await testRepository.delete(created); + + const result = await testRepository.findOne({ + where: Where.eq('id', created.id), + }); + expect(result).toBeNull(); + }); + }); + + describe('softDelete', () => { + it('should soft delete single entity', async () => { + const created = await testFactory.create({ firstName: 'Alice' }); + await testRepository.softDelete(created); + + // Entity should not be found with default query + const result = await testRepository.findOne({ + where: Where.eq('id', created.id), + }); + expect(result).toBeNull(); + + // Entity should still exist with soft-deleted records + const withDeleted = await testRepository.findOne({ + where: Where.eq('id', created.id), + withDeleted: true, + }); + expect(withDeleted).not.toBeNull(); + expect(withDeleted?.dateDeleted).not.toBeNull(); + }); + }); + + describe('restore', () => { + it('should recover single soft-deleted entity', async () => { + const created = await testFactory.create({ firstName: 'Alice' }); + await testRepository.softDelete(created); + + // Verify it's soft deleted + const deleted = await testRepository.findOne({ + where: Where.eq('id', created.id), + withDeleted: true, + }); + expect(deleted?.dateDeleted).not.toBeNull(); + + // Recover + await testRepository.restore(deleted!); + + // Should now be findable + const recovered = await testRepository.findOne({ + where: Where.eq('id', created.id), + }); + expect(recovered).not.toBeNull(); + expect(recovered?.dateDeleted).toBeNull(); + }); + }); + + describe('merge', () => { + it('should merge entities', async () => { + const entity = await testFactory.create({ + firstName: 'Alice', + lastName: 'Smith', + }); + const merged = testRepository.merge(entity, { firstName: 'Bob' }); + + expect(merged.firstName).toBe('Bob'); + expect(merged.lastName).toBe('Smith'); + }); + }); + + describe('prepare', () => { + it('should transform an empty object to an entity instance (#466)', () => { + const result = testRepository.prepare({}); + expect(result).toBeInstanceOf(TestEntityFixture); + }); + + it('should return undefined for non-object', () => { + const result = testRepository.prepare( + 'not-an-object' as unknown as object, + ); + expect(result).toBeUndefined(); + }); + + it('should transform plain object to entity instance', () => { + const result = testRepository.prepare({ firstName: 'Alice' }); + expect(result).toBeInstanceOf(TestEntityFixture); + expect(result?.firstName).toBe('Alice'); + }); + + it('should return entity instance as-is', () => { + const entity = new TestEntityFixture(); + entity.firstName = 'Alice'; + const result = testRepository.prepare(entity); + expect(result).toBe(entity); + }); + }); + + describe('optimistic locking without TransactionScope wired', () => { + // Mirrors TypeOrmRepositoryModule used directly, without + // RepositoryModule.forRoot() — TransactionScope is only provided by + // the latter, so a repository constructed this way never receives one. + let bareRepository: TypeOrmRepository; + let transactionScope: TransactionScope; + + beforeEach(() => { + const dataSource = moduleFixture.get(getDataSourceToken()); + bareRepository = new TypeOrmRepository( + dataSource.getRepository(TestEntityFixture), + { entityKey: 'bare-test-entity' }, + ); + transactionScope = moduleFixture.get(TransactionScope); + }); + + it('should throw rather than silently run unprotected when no transaction is active', async () => { + const entity = await testFactory.create({ firstName: 'Alice' }); + + await expect( + bareRepository.update(entity, { firstName: 'Bob' }), + ).rejects.toThrow(RuntimeException); + }); + + it('should still work correctly when the caller already provides an active transaction', async () => { + const entity = await testFactory.create({ firstName: 'Alice' }); + + const updated = await transactionScope.run({}, async (txCtx) => + bareRepository.update(entity, { firstName: 'Bob' }, { ctx: txCtx }), + ); + + expect(updated.firstName).toBe('Bob'); + expect(updated.version).toBe(entity.version + 1); + }); + }); +}); diff --git a/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository.hooks.spec.ts b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository.hooks.spec.ts new file mode 100644 index 000000000..d89098128 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository.hooks.spec.ts @@ -0,0 +1,985 @@ +import { Type } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; + +import { + AppContextHost, + DeepPartial, + CoreModule, + HooksCtx, +} from '@concepta/nestjs-core'; +import { + RepositoryModule, + RepoHook, + // Read operation decorators + BeforeRead, + AfterRead, + BeforeFind, + AfterFind, + BeforeFindOne, + AfterFindOne, + BeforeCount, + AfterCount, + BeforeFindAndCount, + AfterFindAndCount, + // Create operation decorators + BeforeWrite, + AfterWrite, + BeforeCreate, + AfterCreate, + BeforeCreateMany, + AfterCreateMany, + // Update operation decorators + BeforeUpdate, + AfterUpdate, + BeforeUpsert, + AfterUpsert, + BeforeReplace, + AfterReplace, + // Delete operation decorators + BeforeDestroy, + AfterDestroy, + BeforeDelete, + AfterDelete, + // Lifecycle operation decorators + BeforeTransition, + AfterTransition, + BeforeSoftDelete, + AfterSoftDelete, + BeforeRestore, + AfterRestore, + // Method types + BeforeFindMethod, + AfterFindMethod, + BeforeFindOneMethod, + AfterFindOneMethod, + BeforeCountMethod, + AfterCountMethod, + BeforeFindAndCountMethod, + AfterFindAndCountMethod, + BeforeCreateMethod, + AfterCreateMethod, + BeforeCreateManyMethod, + AfterCreateManyMethod, + BeforeUpdateMethod, + AfterUpdateMethod, + BeforeUpsertMethod, + AfterUpsertMethod, + BeforeReplaceMethod, + AfterReplaceMethod, + BeforeDeleteMethod, + AfterDeleteMethod, + BeforeSoftDeleteMethod, + AfterSoftDeleteMethod, + BeforeRestoreMethod, + AfterRestoreMethod, + BeforeReadMethod, + AfterReadMethod, + BeforeWriteMethod, + AfterWriteMethod, + BeforeTransitionMethod, + AfterTransitionMethod, + BeforeDestroyMethod, + AfterDestroyMethod, + // Repository types (moved from nestjs-common) + RepositoryFindOptions, + RepositoryFindOneOptions, + Where, + getDynamicRepositoryToken, +} from '@concepta/nestjs-repository'; +import { SeedingSource } from '@concepta/typeorm-seeding'; + +import { ormConfig } from '../../__fixtures__/repository/config/ormconfig.fixture.js'; +import { TEST_ENTITY_TOKEN } from '../../__fixtures__/repository/config/test.constants.fixture.js'; +import { TestEntityFixture } from '../../__fixtures__/repository/entity/test.entity.fixture.js'; +import { TestFactoryFixture } from '../../__fixtures__/repository/factory/test.factory.fixture.js'; +import { TypeOrmRepositoryModule } from '../../typeorm-repository.module.js'; +import { TypeOrmRepository } from '../typeorm-repository.js'; + +// ============================================================================= +// Comprehensive Hook Interface - All Repository Hooks +// ============================================================================= + +interface AllHooksInterface { + // High-level semantic hooks + beforeRead: BeforeReadMethod; + afterRead: AfterReadMethod; + beforeWrite: BeforeWriteMethod; + afterWrite: AfterWriteMethod; + beforeTransition: BeforeTransitionMethod; + afterTransition: AfterTransitionMethod; + beforeDestroy: BeforeDestroyMethod; + afterDestroy: AfterDestroyMethod; + + // Fine-grained read hooks + beforeFind: BeforeFindMethod; + afterFind: AfterFindMethod; + beforeFindOne: BeforeFindOneMethod; + afterFindOne: AfterFindOneMethod; + beforeCount: BeforeCountMethod; + afterCount: AfterCountMethod; + beforeFindAndCount: BeforeFindAndCountMethod; + afterFindAndCount: AfterFindAndCountMethod; + + // Fine-grained create hooks + beforeCreate: BeforeCreateMethod; + afterCreate: AfterCreateMethod; + beforeCreateMany: BeforeCreateManyMethod; + afterCreateMany: AfterCreateManyMethod; + + // Fine-grained update hooks + beforeUpdate: BeforeUpdateMethod; + afterUpdate: AfterUpdateMethod; + beforeUpsert: BeforeUpsertMethod; + afterUpsert: AfterUpsertMethod; + beforeReplace: BeforeReplaceMethod; + afterReplace: AfterReplaceMethod; + + // Fine-grained delete hooks + beforeDelete: BeforeDeleteMethod; + afterDelete: AfterDeleteMethod; + + // Fine-grained lifecycle hooks + beforeSoftDelete: BeforeSoftDeleteMethod; + afterSoftDelete: AfterSoftDeleteMethod; + beforeRestore: BeforeRestoreMethod; + afterRestore: AfterRestoreMethod; +} + +// ============================================================================= +// Hook Implementation +// ============================================================================= + +@RepoHook() +class AllHooks implements AllHooksInterface { + callLog: string[] = []; + + // High-level semantic hooks + @BeforeRead() + async beforeRead(options: RepositoryFindOptions) { + this.callLog.push('beforeRead'); + return options; + } + + // AfterRead has a union return type since it handles all read operations + // (find, findOne, count, findAndCount). In practice, use specific hooks instead. + @AfterRead() + async afterRead( + result: + | TestEntityFixture + | TestEntityFixture[] + | null + | number + | [TestEntityFixture[], number], + ) { + this.callLog.push('afterRead'); + return result; + } + + @BeforeWrite() + async beforeWrite( + data: DeepPartial | DeepPartial[], + ) { + this.callLog.push('beforeWrite'); + return data; + } + + @AfterWrite() + async afterWrite(result: TestEntityFixture | TestEntityFixture[]) { + this.callLog.push('afterWrite'); + return result; + } + + @BeforeTransition() + async beforeTransition(entity: TestEntityFixture) { + this.callLog.push('beforeTransition'); + return entity; + } + + @AfterTransition() + async afterTransition(result: TestEntityFixture) { + this.callLog.push('afterTransition'); + return result; + } + + @BeforeDestroy() + async beforeDestroy(entity: TestEntityFixture) { + this.callLog.push('beforeDestroy'); + return entity; + } + + @AfterDestroy() + async afterDestroy(result: TestEntityFixture) { + this.callLog.push('afterDestroy'); + return result; + } + + // Fine-grained read hooks + @BeforeFind() + async beforeFind(options: RepositoryFindOptions) { + this.callLog.push('beforeFind'); + return options; + } + + @AfterFind() + async afterFind(result: TestEntityFixture[]) { + this.callLog.push('afterFind'); + return result; + } + + @BeforeFindOne() + async beforeFindOne(options: RepositoryFindOneOptions) { + this.callLog.push('beforeFindOne'); + return options; + } + + @AfterFindOne() + async afterFindOne(result: TestEntityFixture | null) { + this.callLog.push('afterFindOne'); + return result; + } + + @BeforeCount() + async beforeCount(options: RepositoryFindOptions) { + this.callLog.push('beforeCount'); + return options; + } + + @AfterCount() + async afterCount(result: number) { + this.callLog.push('afterCount'); + return result; + } + + @BeforeFindAndCount() + async beforeFindAndCount(options: RepositoryFindOptions) { + this.callLog.push('beforeFindAndCount'); + return options; + } + + @AfterFindAndCount() + async afterFindAndCount(result: [TestEntityFixture[], number]) { + this.callLog.push('afterFindAndCount'); + return result; + } + + // Fine-grained create hooks + @BeforeCreate() + async beforeCreate(data: DeepPartial) { + this.callLog.push('beforeCreate'); + return data; + } + + @AfterCreate() + async afterCreate(result: TestEntityFixture) { + this.callLog.push('afterCreate'); + return result; + } + + @BeforeCreateMany() + async beforeCreateMany(data: DeepPartial[]) { + this.callLog.push('beforeCreateMany'); + return data; + } + + @AfterCreateMany() + async afterCreateMany(result: TestEntityFixture[]) { + this.callLog.push('afterCreateMany'); + return result; + } + + // Fine-grained update hooks + @BeforeUpdate() + async beforeUpdate(data: DeepPartial) { + this.callLog.push('beforeUpdate'); + return data; + } + + @AfterUpdate() + async afterUpdate(result: TestEntityFixture) { + this.callLog.push('afterUpdate'); + return result; + } + + @BeforeUpsert() + async beforeUpsert(data: DeepPartial) { + this.callLog.push('beforeUpsert'); + return data; + } + + @AfterUpsert() + async afterUpsert(result: TestEntityFixture) { + this.callLog.push('afterUpsert'); + return result; + } + + @BeforeReplace() + async beforeReplace(data: DeepPartial) { + this.callLog.push('beforeReplace'); + return data; + } + + @AfterReplace() + async afterReplace(result: TestEntityFixture) { + this.callLog.push('afterReplace'); + return result; + } + + // Fine-grained delete hooks + @BeforeDelete() + async beforeDelete(entity: TestEntityFixture) { + this.callLog.push('beforeDelete'); + return entity; + } + + @AfterDelete() + async afterDelete(result: TestEntityFixture) { + this.callLog.push('afterDelete'); + return result; + } + + // Fine-grained lifecycle hooks + @BeforeSoftDelete() + async beforeSoftDelete(entity: TestEntityFixture) { + this.callLog.push('beforeSoftDelete'); + return entity; + } + + @AfterSoftDelete() + async afterSoftDelete(result: TestEntityFixture) { + this.callLog.push('afterSoftDelete'); + return result; + } + + @BeforeRestore() + async beforeRestore(entity: TestEntityFixture) { + this.callLog.push('beforeRestore'); + return entity; + } + + @AfterRestore() + async afterRestore(result: TestEntityFixture) { + this.callLog.push('afterRestore'); + return result; + } +} + +// ============================================================================= +// Test Helpers +// ============================================================================= + +/** + * Create a hook context with the given hooks. + * Adds the RepoHook.KEY type to each hook config (normally done by HookInterceptor). + */ +function createHookContext(...hookClasses: Type[]) { + const ctx = new AppContextHost(); + ctx.defineOverlay(HooksCtx, { + hooks: hookClasses.map((hook) => ({ hook, type: RepoHook.KEY })), + }); + return ctx; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('TypeOrmRepository Hooks', () => { + let moduleFixture: TestingModule; + let testRepository: TypeOrmRepository; + let allHooks: AllHooks; + let seedingSource: SeedingSource; + let testFactory: TestFactoryFixture; + + beforeEach(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + CoreModule.forRoot(), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: TEST_ENTITY_TOKEN, + entity: TestEntityFixture, + }, + ], + }), + ], + providers: [AllHooks], + }).compile(); + + testRepository = moduleFixture.get>( + getDynamicRepositoryToken(TEST_ENTITY_TOKEN), + ); + + // Get the hook instance that was created by the module + // This is the same instance that will be resolved by HookResolverService + allHooks = moduleFixture.get(AllHooks); + + seedingSource = new SeedingSource({ + dataSource: moduleFixture.get(getDataSourceToken()), + }); + + await seedingSource.initialize(); + + testFactory = new TestFactoryFixture({ + entity: TestEntityFixture, + seedingSource, + }); + }); + + afterEach(async () => { + allHooks.callLog = []; + vi.clearAllMocks(); + await moduleFixture.close(); + }); + + // =========================================================================== + // Read Operations + // =========================================================================== + + describe('find()', () => { + it('should call BeforeRead, BeforeFind, AfterFind, AfterRead in order', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + + const result = await testRepository.find({ + ctx: createHookContext(AllHooks), + }); + + expect(allHooks.callLog).toEqual([ + 'beforeRead', + 'beforeFind', + 'afterFind', + 'afterRead', + ]); + expect(result.length).toBe(2); + }); + + it('should not call hooks when no hooks in context', async () => { + await testFactory.create({ firstName: 'Alice' }); + + const result = await testRepository.find(); + + expect(allHooks.callLog).toEqual([]); + expect(result.length).toBe(1); + }); + }); + + describe('findOne()', () => { + it('should call BeforeRead, BeforeFindOne, AfterFindOne, AfterRead in order', async () => { + const entity = await testFactory.create({ firstName: 'Alice' }); + + const result = await testRepository.findOne({ + where: Where.eq('id', entity.id), + ctx: createHookContext(AllHooks), + }); + + expect(allHooks.callLog).toEqual([ + 'beforeRead', + 'beforeFindOne', + 'afterFindOne', + 'afterRead', + ]); + expect(result?.firstName).toBe('Alice'); + }); + + it('should call hooks even when entity not found', async () => { + const result = await testRepository.findOne({ + where: Where.eq('id', 'non-existent-id'), + ctx: createHookContext(AllHooks), + }); + + expect(allHooks.callLog).toEqual([ + 'beforeRead', + 'beforeFindOne', + 'afterFindOne', + 'afterRead', + ]); + expect(result).toBeNull(); + }); + }); + + describe('count()', () => { + it('should call BeforeRead, BeforeCount, AfterCount in order', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + + const result = await testRepository.count({ + ctx: createHookContext(AllHooks), + }); + + expect(allHooks.callLog).toEqual([ + 'beforeRead', + 'beforeCount', + 'afterCount', + ]); + expect(result).toBe(2); + }); + }); + + describe('findAndCount()', () => { + it('should call BeforeRead, BeforeFindAndCount, AfterFindAndCount in order', async () => { + await testFactory.create({ firstName: 'Alice' }); + await testFactory.create({ firstName: 'Bob' }); + + const [entities, count] = await testRepository.findAndCount({ + ctx: createHookContext(AllHooks), + }); + + expect(allHooks.callLog).toEqual([ + 'beforeRead', + 'beforeFindAndCount', + 'afterFindAndCount', + ]); + expect(entities.length).toBe(2); + expect(count).toBe(2); + }); + }); + + // =========================================================================== + // Create Operations + // =========================================================================== + + describe('create()', () => { + it('should call BeforeWrite, BeforeCreate, AfterCreate, AfterWrite in order', async () => { + const result = await testRepository.create( + { firstName: 'Alice', lastName: 'Smith' }, + { + ctx: createHookContext(AllHooks), + }, + ); + + expect(allHooks.callLog).toEqual([ + 'beforeWrite', + 'beforeCreate', + 'afterCreate', + 'afterWrite', + ]); + expect(result.firstName).toBe('Alice'); + expect(result.id).toBeDefined(); + }); + }); + + describe('createMany()', () => { + it('should call BeforeWrite, BeforeCreateMany, AfterCreateMany, AfterWrite in order', async () => { + const result = await testRepository.createMany( + [ + { firstName: 'Alice', lastName: 'Smith' }, + { firstName: 'Bob', lastName: 'Jones' }, + ], + { + ctx: createHookContext(AllHooks), + }, + ); + + expect(allHooks.callLog).toEqual([ + 'beforeWrite', + 'beforeCreateMany', + 'afterCreateMany', + 'afterWrite', + ]); + expect(result.length).toBe(2); + expect(result[0].firstName).toBe('Alice'); + expect(result[1].firstName).toBe('Bob'); + }); + }); + + // =========================================================================== + // Update Operations + // =========================================================================== + + describe('update()', () => { + it('should call BeforeWrite, BeforeUpdate, AfterUpdate, AfterWrite in order', async () => { + const entity = await testFactory.create({ firstName: 'Alice' }); + + const result = await testRepository.update( + entity, + { lastName: 'Updated' }, + { + ctx: createHookContext(AllHooks), + }, + ); + + expect(allHooks.callLog).toEqual([ + 'beforeWrite', + 'beforeUpdate', + 'afterUpdate', + 'afterWrite', + ]); + expect(result.lastName).toBe('Updated'); + }); + }); + + describe('upsert()', () => { + it('should call BeforeWrite, BeforeUpsert, AfterUpsert, AfterWrite in order for insert', async () => { + const id = '00000000-0000-0000-0000-000000000001'; + + const result = await testRepository.upsert( + { id, firstName: 'Alice', lastName: 'Smith' }, + { + ctx: createHookContext(AllHooks), + }, + ); + + expect(allHooks.callLog).toEqual([ + 'beforeWrite', + 'beforeUpsert', + 'afterUpsert', + 'afterWrite', + ]); + expect(result.firstName).toBe('Alice'); + expect(result.id).toBe(id); + }); + + it('should call hooks for upsert update', async () => { + const entity = await testFactory.create({ firstName: 'Alice' }); + + const result = await testRepository.upsert( + { id: entity.id, firstName: 'Alice Updated', lastName: 'Smith' }, + { + ctx: createHookContext(AllHooks), + }, + ); + + expect(allHooks.callLog).toEqual([ + 'beforeWrite', + 'beforeUpsert', + 'afterUpsert', + 'afterWrite', + ]); + expect(result.firstName).toBe('Alice Updated'); + }); + }); + + describe('replace()', () => { + it('should call BeforeWrite, BeforeReplace, AfterReplace, AfterWrite in order', async () => { + const entity = await testFactory.create({ + firstName: 'Alice', + lastName: 'Smith', + }); + + const result = await testRepository.replace( + entity, + { firstName: 'Replaced', lastName: 'Name' }, + { + ctx: createHookContext(AllHooks), + }, + ); + + expect(allHooks.callLog).toEqual([ + 'beforeWrite', + 'beforeReplace', + 'afterReplace', + 'afterWrite', + ]); + expect(result.firstName).toBe('Replaced'); + expect(result.lastName).toBe('Name'); + }); + }); + + // =========================================================================== + // Delete Operations + // =========================================================================== + + describe('delete()', () => { + it('should call BeforeDestroy, BeforeDelete, AfterDelete, AfterDestroy in order', async () => { + const entity = await testFactory.create({ firstName: 'Alice' }); + + const result = await testRepository.delete(entity, { + ctx: createHookContext(AllHooks), + }); + + expect(allHooks.callLog).toEqual([ + 'beforeDestroy', + 'beforeDelete', + 'afterDelete', + 'afterDestroy', + ]); + expect(result.firstName).toBe('Alice'); + + // Verify entity was actually deleted + const found = await testRepository.findOne({ + where: Where.eq('id', entity.id), + }); + expect(found).toBeNull(); + }); + }); + + // =========================================================================== + // Lifecycle Operations (soft delete/restore) + // =========================================================================== + + describe('softDelete()', () => { + it('should call BeforeTransition, BeforeSoftDelete, AfterSoftDelete, AfterTransition in order', async () => { + const entity = await testFactory.create({ firstName: 'Alice' }); + + const result = await testRepository.softDelete(entity, { + ctx: createHookContext(AllHooks), + }); + + expect(allHooks.callLog).toEqual([ + 'beforeTransition', + 'beforeSoftDelete', + 'afterSoftDelete', + 'afterTransition', + ]); + expect(result.dateDeleted).toBeDefined(); + + // Verify entity is soft deleted (not returned by default query) + const found = await testRepository.findOne({ + where: Where.eq('id', entity.id), + }); + expect(found).toBeNull(); + }); + }); + + describe('restore()', () => { + it('should call BeforeTransition, BeforeRestore, AfterRestore, AfterTransition in order', async () => { + const entity = await testFactory.create({ firstName: 'Alice' }); + + // First soft delete + await testRepository.softDelete(entity); + allHooks.callLog = []; // Reset call log + + // Then restore + const result = await testRepository.restore(entity, { + ctx: createHookContext(AllHooks), + }); + + expect(allHooks.callLog).toEqual([ + 'beforeTransition', + 'beforeRestore', + 'afterRestore', + 'afterTransition', + ]); + expect(result.dateDeleted).toBeNull(); + + // Verify entity is restored (returned by default query) + const found = await testRepository.findOne({ + where: Where.eq('id', entity.id), + }); + expect(found).not.toBeNull(); + }); + }); + + // =========================================================================== + // Hook Modification Tests + // =========================================================================== + + describe('hook data modification', () => { + it('should preserve caller original data over hook modifications on write', async () => { + await moduleFixture.close(); + + // Hook tries to override lastName, but preserve strategy keeps the original + @RepoHook() + class ModifyingHook { + @BeforeCreate() + async beforeCreate(data: DeepPartial) { + return { ...data, lastName: 'Hook tried to override' }; + } + } + + moduleFixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + CoreModule.forRoot(), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: TEST_ENTITY_TOKEN, + entity: TestEntityFixture, + }, + ], + }), + ], + providers: [ModifyingHook], + }).compile(); + + const repo = moduleFixture.get>( + getDynamicRepositoryToken(TEST_ENTITY_TOKEN), + ); + + const result = await repo.create( + { firstName: 'Alice', lastName: 'Original' }, + { + ctx: createHookContext(ModifyingHook), + }, + ); + + // preserve strategy: caller's original data wins + expect(result.lastName).toBe('Original'); + }); + + it('should allow hooks to add new fields on write', async () => { + await moduleFixture.close(); + + // Hook adds a field that was NOT in the caller's original data + @RepoHook() + class AddFieldHook { + @BeforeCreate() + async beforeCreate(data: DeepPartial) { + return { ...data, lastName: 'Added by hook' }; + } + } + + moduleFixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + CoreModule.forRoot(), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: TEST_ENTITY_TOKEN, + entity: TestEntityFixture, + }, + ], + }), + ], + providers: [AddFieldHook], + }).compile(); + + const repo = moduleFixture.get>( + getDynamicRepositoryToken(TEST_ENTITY_TOKEN), + ); + + // Caller does NOT provide lastName — hook can add it + const result = await repo.create( + { firstName: 'Alice' }, + { + ctx: createHookContext(AddFieldHook), + }, + ); + + expect(result.firstName).toBe('Alice'); + expect(result.lastName).toBe('Added by hook'); + }); + + it('should allow BeforeFind to add where conditions', async () => { + await moduleFixture.close(); + + @RepoHook() + class FilteringHook { + @BeforeFind() + async beforeFind(options: RepositoryFindOptions) { + return { + ...options, + where: options.where + ? Where.and(options.where, Where.eq('firstName', 'Bob')) + : Where.eq('firstName', 'Bob'), + }; + } + } + + moduleFixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + CoreModule.forRoot(), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: TEST_ENTITY_TOKEN, + entity: TestEntityFixture, + }, + ], + }), + ], + providers: [FilteringHook], + }).compile(); + + const repo = moduleFixture.get>( + getDynamicRepositoryToken(TEST_ENTITY_TOKEN), + ); + + const localSeedingSource = new SeedingSource({ + dataSource: moduleFixture.get(getDataSourceToken()), + }); + await localSeedingSource.initialize(); + + const localFactory = new TestFactoryFixture({ + entity: TestEntityFixture, + seedingSource: localSeedingSource, + }); + + await localFactory.create({ firstName: 'Alice' }); + await localFactory.create({ firstName: 'Bob' }); + await localFactory.create({ firstName: 'Charlie' }); + + const result = await repo.find({ + ctx: createHookContext(FilteringHook), + }); + + // Hook filters to only return 'Bob' + expect(result.length).toBe(1); + expect(result[0].firstName).toBe('Bob'); + }); + }); + + // =========================================================================== + // Multiple Hooks Tests + // =========================================================================== + + describe('multiple hooks on same method', () => { + it('should call hooks in registration order', async () => { + // Close the outer beforeEach module so its unnamed DataSource is freed + // before we register a second one below. + await moduleFixture.close(); + + const callOrder: string[] = []; + + @RepoHook() + class FirstHook { + @BeforeFind() + async beforeFind(options: RepositoryFindOptions) { + callOrder.push('first'); + return options; + } + } + + @RepoHook() + class SecondHook { + @BeforeFind() + async beforeFind(options: RepositoryFindOptions) { + callOrder.push('second'); + return options; + } + } + + moduleFixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + CoreModule.forRoot(), + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: TEST_ENTITY_TOKEN, + entity: TestEntityFixture, + }, + ], + }), + ], + providers: [FirstHook, SecondHook], + }).compile(); + + const repo = moduleFixture.get>( + getDynamicRepositoryToken(TEST_ENTITY_TOKEN), + ); + + await repo.find({ + ctx: createHookContext(FirstHook, SecondHook), + }); + + expect(callOrder).toEqual(['first', 'second']); + }); + }); +}); diff --git a/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository.spec.ts b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository.spec.ts new file mode 100644 index 000000000..4557e417d --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-repository.spec.ts @@ -0,0 +1,162 @@ +import { DataSource, Repository } from 'typeorm'; + +import { Module } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { + getDynamicRepositoryToken, + RepositoryInterface, + RepositoryModule, +} from '@concepta/nestjs-repository'; + +import { ormConfig } from '../../__fixtures__/repository/config/ormconfig.fixture.js'; +import { TestEntityFixture } from '../../__fixtures__/repository/entity/test.entity.fixture.js'; +import { TypeOrmRepositoryModule } from '../../typeorm-repository.module.js'; +import { TypeOrmRepository } from '../typeorm-repository.js'; + +const FACTORY_TOKEN = 'test-factory'; +const STANDARD_TOKEN = 'test-standard'; + +interface CustomRepositoryMethods { + customMethod(): string; +} + +type CustomRepository = Repository & CustomRepositoryMethods; + +const createCustomRepository = (dataSource: DataSource): CustomRepository => { + return dataSource + .getRepository(TestEntityFixture) + .extend({ + customMethod(): string { + return 'custom'; + }, + }); +}; + +@Module({ + imports: [ + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { + key: FACTORY_TOKEN, + entity: TestEntityFixture, + factory: createCustomRepository, + }, + { + key: STANDARD_TOKEN, + entity: TestEntityFixture, + }, + ], + }), + ], +}) +class RegistrationTestModuleFixture {} + +describe(TypeOrmRepository, () => { + describe('provider registration', () => { + let moduleFixture: TestingModule; + + beforeEach(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + RepositoryModule.forRoot({}), + RegistrationTestModuleFixture, + ], + }).compile(); + }); + + afterEach(async () => { + await moduleFixture.close(); + }); + + it('should register standard repository with correct public token', () => { + const expectedToken = getDynamicRepositoryToken(STANDARD_TOKEN); + const repository = + moduleFixture.get>( + expectedToken, + ); + + expect(expectedToken).toBe('DYNAMIC_REPOSITORY_TOKEN_test-standard'); + expect(repository.metadata.name).toBe('TestEntityFixture'); + }); + + it('should register factory repository with correct public token', () => { + const expectedToken = getDynamicRepositoryToken(FACTORY_TOKEN); + const repository = + moduleFixture.get>( + expectedToken, + ); + + expect(expectedToken).toBe('DYNAMIC_REPOSITORY_TOKEN_test-factory'); + expect(repository.metadata.name).toBe('TestEntityFixture'); + }); + + it('should have correct entity name on standard repository', () => { + const repository = moduleFixture.get< + RepositoryInterface + >(getDynamicRepositoryToken(STANDARD_TOKEN)); + + expect(repository.metadata.name).toBe('TestEntityFixture'); + }); + + it('should have correct entity name on factory repository', () => { + const repository = moduleFixture.get< + RepositoryInterface + >(getDynamicRepositoryToken(FACTORY_TOKEN)); + + expect(repository.metadata.name).toBe('TestEntityFixture'); + }); + + it('should throw when retrieving unregistered token', () => { + expect(() => { + moduleFixture.get(getDynamicRepositoryToken('not-registered')); + }).toThrow(); + }); + + it('should provide access to TypeORM repository via public token', () => { + const repository = moduleFixture.get< + TypeOrmRepository + >(getDynamicRepositoryToken(STANDARD_TOKEN)); + + expect(repository).toBeInstanceOf(TypeOrmRepository); + expect(repository.metadata.type).toBe(TestEntityFixture); + }); + }); + + describe('factory pattern', () => { + let moduleFixture: TestingModule; + let customRepository: TypeOrmRepository; + + beforeEach(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + RepositoryModule.forRoot({}), + RegistrationTestModuleFixture, + ], + }).compile(); + + // Get the TypeOrmRepository via public token + customRepository = moduleFixture.get< + TypeOrmRepository + >(getDynamicRepositoryToken(FACTORY_TOKEN)); + }); + + afterEach(async () => { + await moduleFixture.close(); + }); + + it('should create repository with custom factory', () => { + expect(customRepository).toBeInstanceOf(TypeOrmRepository); + }); + + it('should have custom method available on underlying repo', () => { + const repo = customRepository['repo'] as CustomRepository; + expect(repo.customMethod).toBeInstanceOf(Function); + expect(repo.customMethod()).toBe('custom'); + }); + }); +}); diff --git a/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-where-translation.spec.ts b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-where-translation.spec.ts new file mode 100644 index 000000000..56dec12f1 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/repository/__tests__/typeorm-where-translation.spec.ts @@ -0,0 +1,453 @@ +import { + And, + Between, + Equal, + In, + IsNull, + LessThan, + LessThanOrEqual, + Like, + MoreThan, + MoreThanOrEqual, + Not, + type Repository, +} from 'typeorm'; + +import { + type JoinClause, + Where, + type WhereClause, + type WhereCondition, + type WhereOperator, +} from '@concepta/nestjs-repository'; + +import { TypeOrmRepository } from '../typeorm-repository.js'; + +interface TestEntity { + id: string; + firstName: string; + lastName: string; + age: number; +} + +class TestEntityClass { + id!: string; + firstName!: string; + lastName!: string; + age!: number; +} + +/** + * Subclass that exposes protected translation methods for unit testing. + */ +class TestableTypeOrmRepository extends TypeOrmRepository { + public testToFindOperator(cond: WhereCondition) { + return this.toFindOperator(cond); + } + + public testBranchToFindOptionsWhere(leaves: WhereClause[]) { + return this.branchToFindOptionsWhere(leaves); + } + + public testTranslateWhere(clause?: WhereClause) { + return this.translateWhere(clause); + } + + public testTranslateJoin(join?: JoinClause[]) { + return this.translateJoin(join); + } +} + +function createTestableRepo(): TestableTypeOrmRepository { + const mockRepo = { + metadata: { + name: 'TestEntity', + targetName: 'TestEntity', + columns: [ + { + propertyName: 'id', + isPrimary: true, + isDeleteDate: false, + isVersion: false, + }, + { + propertyName: 'firstName', + isPrimary: false, + isDeleteDate: false, + isVersion: false, + }, + { + propertyName: 'lastName', + isPrimary: false, + isDeleteDate: false, + isVersion: false, + }, + { + propertyName: 'age', + isPrimary: false, + isDeleteDate: false, + isVersion: false, + }, + ], + relations: [], + }, + target: TestEntityClass, + } as unknown as Repository; + + return new TestableTypeOrmRepository(mockRepo, { entityKey: 'test-entity' }); +} + +describe('TypeOrmRepository WHERE clause translation', () => { + let repo: TestableTypeOrmRepository; + + beforeEach(() => { + repo = createTestableRepo(); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // toFindOperator — all 17 operator cases + // ═════════════════════════════════════════════════════════════════════════════ + + describe('toFindOperator', () => { + it('should translate EQ to Equal', () => { + const result = repo.testToFindOperator( + Where.eq('firstName', 'John'), + ); + expect(result).toEqual(Equal('John')); + }); + + it('should translate NE to Not(Equal)', () => { + const result = repo.testToFindOperator( + Where.ne('firstName', 'John'), + ); + expect(result).toEqual(Not(Equal('John'))); + }); + + it('should translate GT to MoreThan', () => { + const result = repo.testToFindOperator(Where.gt('age', 18)); + expect(result).toEqual(MoreThan(18)); + }); + + it('should translate GTE to MoreThanOrEqual', () => { + const result = repo.testToFindOperator(Where.gte('age', 18)); + expect(result).toEqual(MoreThanOrEqual(18)); + }); + + it('should translate LT to LessThan', () => { + const result = repo.testToFindOperator(Where.lt('age', 65)); + expect(result).toEqual(LessThan(65)); + }); + + it('should translate LTE to LessThanOrEqual', () => { + const result = repo.testToFindOperator(Where.lte('age', 65)); + expect(result).toEqual(LessThanOrEqual(65)); + }); + + it('should translate CONTAINS to Like(%value%)', () => { + const result = repo.testToFindOperator( + Where.contains('firstName', 'oh'), + ); + expect(result).toEqual(Like('%oh%')); + }); + + it('should translate NCONTAINS to Not(Like(%value%))', () => { + const result = repo.testToFindOperator( + Where.notContains('firstName', 'oh'), + ); + expect(result).toEqual(Not(Like('%oh%'))); + }); + + it('should translate STARTS to Like(value%)', () => { + const result = repo.testToFindOperator( + Where.starts('firstName', 'Jo'), + ); + expect(result).toEqual(Like('Jo%')); + }); + + it('should translate NSTARTS to Not(Like(value%))', () => { + const result = repo.testToFindOperator( + Where.notStarts('firstName', 'Jo'), + ); + expect(result).toEqual(Not(Like('Jo%'))); + }); + + it('should translate ENDS to Like(%value)', () => { + const result = repo.testToFindOperator( + Where.ends('firstName', 'hn'), + ); + expect(result).toEqual(Like('%hn')); + }); + + it('should translate NENDS to Not(Like(%value))', () => { + const result = repo.testToFindOperator( + Where.notEnds('firstName', 'hn'), + ); + expect(result).toEqual(Not(Like('%hn'))); + }); + + it('should translate IN to In', () => { + const result = repo.testToFindOperator( + Where.in('firstName', ['John', 'Jane']), + ); + expect(result).toEqual(In(['John', 'Jane'])); + }); + + it('should translate NIN to Not(In)', () => { + const result = repo.testToFindOperator( + Where.notIn('firstName', ['John', 'Jane']), + ); + expect(result).toEqual(Not(In(['John', 'Jane']))); + }); + + it('should translate IS_NULL to IsNull', () => { + const result = repo.testToFindOperator( + Where.isNull('lastName'), + ); + expect(result).toEqual(IsNull()); + }); + + it('should translate NOT_NULL to Not(IsNull)', () => { + const result = repo.testToFindOperator( + Where.notNull('lastName'), + ); + expect(result).toEqual(Not(IsNull())); + }); + + it('should translate BETWEEN to Between', () => { + const result = repo.testToFindOperator( + Where.between('age', 18, 65), + ); + expect(result).toEqual(Between(18, 65)); + }); + + it('should throw on unknown operator', () => { + const cond = { + field: 'firstName', + operator: 'unknown_op' as WhereOperator, + value: 'test', + } as WhereCondition; + + expect(() => repo.testToFindOperator(cond)).toThrow(); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // branchToFindOptionsWhere + // ═════════════════════════════════════════════════════════════════════════════ + + describe('branchToFindOptionsWhere', () => { + it('should convert a single field condition', () => { + const leaves: WhereClause[] = [Where.eq('firstName', 'John')]; + const result = repo.testBranchToFindOptionsWhere(leaves); + expect(result).toEqual({ firstName: Equal('John') }); + }); + + it('should convert multiple different field conditions', () => { + const leaves: WhereClause[] = [ + Where.eq('firstName', 'John'), + Where.gt('age', 18), + ]; + const result = repo.testBranchToFindOptionsWhere(leaves); + expect(result).toEqual({ + firstName: Equal('John'), + age: MoreThan(18), + }); + }); + + it('should merge same-field conditions with And', () => { + const leaves: WhereClause[] = [ + Where.gte('age', 18), + Where.lte('age', 65), + ]; + const result = repo.testBranchToFindOptionsWhere(leaves); + expect(result).toEqual({ + age: And(MoreThanOrEqual(18), LessThanOrEqual(65)), + }); + }); + + it('should nest relation-tagged conditions under relation key', () => { + const leaves: WhereClause[] = [ + Where.rel('posts', Where.eq('id', '123')), + ]; + const result = repo.testBranchToFindOptionsWhere(leaves); + expect(result).toEqual({ + posts: { id: Equal('123') }, + }); + }); + + it('should merge same-field conditions within a relation', () => { + const leaves: WhereClause[] = [ + Where.rel('posts', Where.gte('age', 1)), + Where.rel('posts', Where.lte('age', 100)), + ]; + const result = repo.testBranchToFindOptionsWhere(leaves); + expect(result).toEqual({ + posts: { age: And(MoreThanOrEqual(1), LessThanOrEqual(100)) }, + }); + }); + + it('should handle mixed field and relation conditions', () => { + const leaves: WhereClause[] = [ + Where.eq('firstName', 'John'), + Where.rel('posts', Where.eq('id', 'abc')), + ]; + const result = repo.testBranchToFindOptionsWhere(leaves); + expect(result).toEqual({ + firstName: Equal('John'), + posts: { id: Equal('abc') }, + }); + }); + + it('should handle multiple distinct relations', () => { + const leaves: WhereClause[] = [ + Where.rel('posts', Where.eq('firstName', 'Draft')), + Where.rel('comments', Where.gt('age', 5)), + ]; + const result = repo.testBranchToFindOptionsWhere(leaves); + expect(result).toEqual({ + posts: { firstName: Equal('Draft') }, + comments: { age: MoreThan(5) }, + }); + }); + + it('should return empty object for empty leaves', () => { + const result = repo.testBranchToFindOptionsWhere([]); + expect(result).toEqual({}); + }); + + it('should skip compound nodes (non-conditions)', () => { + const leaves: WhereClause[] = [ + Where.and(Where.eq('firstName', 'John')), + ]; + const result = repo.testBranchToFindOptionsWhere(leaves); + expect(result).toEqual({}); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // translateWhere + // ═════════════════════════════════════════════════════════════════════════════ + + describe('translateWhere', () => { + it('should return undefined for undefined input', () => { + expect(repo.testTranslateWhere(undefined)).toEqual(undefined); + }); + + it('should translate a single condition to single-element array', () => { + const clause = Where.eq('firstName', 'John'); + expect(repo.testTranslateWhere(clause)).toEqual([ + { firstName: Equal('John') }, + ]); + }); + + it('should translate AND compound to single element with merged conditions', () => { + const clause = Where.and( + Where.eq('firstName', 'John'), + Where.gt('age', 18), + ); + expect(repo.testTranslateWhere(clause)).toEqual([ + { firstName: Equal('John'), age: MoreThan(18) }, + ]); + }); + + it('should translate OR compound to multiple elements', () => { + const clause = Where.or( + Where.eq('firstName', 'John'), + Where.eq('firstName', 'Jane'), + ); + expect(repo.testTranslateWhere(clause)).toEqual([ + { firstName: Equal('John') }, + { firstName: Equal('Jane') }, + ]); + }); + + it('should distribute AND over OR into DNF', () => { + const clause = Where.and( + Where.or( + Where.eq('firstName', 'John'), + Where.eq('firstName', 'Jane'), + ), + Where.gt('age', 18), + ); + expect(repo.testTranslateWhere(clause)).toEqual([ + { firstName: Equal('John'), age: MoreThan(18) }, + { firstName: Equal('Jane'), age: MoreThan(18) }, + ]); + }); + + it('should handle nested AND within OR', () => { + const clause = Where.or( + Where.and( + Where.eq('firstName', 'John'), + Where.gt('age', 18), + ), + Where.and( + Where.eq('firstName', 'Jane'), + Where.lt('age', 30), + ), + ); + expect(repo.testTranslateWhere(clause)).toEqual([ + { firstName: Equal('John'), age: MoreThan(18) }, + { firstName: Equal('Jane'), age: LessThan(30) }, + ]); + }); + + it('should merge same field in AND branch with And()', () => { + const clause = Where.and( + Where.gte('age', 18), + Where.lte('age', 65), + ); + expect(repo.testTranslateWhere(clause)).toEqual([ + { age: And(MoreThanOrEqual(18), LessThanOrEqual(65)) }, + ]); + }); + + it('should handle relation-tagged conditions', () => { + const clause = Where.and( + Where.eq('firstName', 'John'), + Where.rel('posts', Where.eq('id', 'abc')), + ); + expect(repo.testTranslateWhere(clause)).toEqual([ + { firstName: Equal('John'), posts: { id: Equal('abc') } }, + ]); + }); + }); + + // ═════════════════════════════════════════════════════════════════════════════ + // translateJoin + // ═════════════════════════════════════════════════════════════════════════════ + + describe('translateJoin', () => { + it('should return undefined for undefined input', () => { + expect(repo.testTranslateJoin(undefined)).toEqual(undefined); + }); + + it('should return undefined for empty array', () => { + expect(repo.testTranslateJoin([])).toEqual(undefined); + }); + + it('should translate a single join', () => { + const joins: JoinClause[] = [{ relation: 'posts' }]; + expect(repo.testTranslateJoin(joins)).toEqual({ posts: true }); + }); + + it('should translate multiple joins', () => { + const joins: JoinClause[] = [ + { relation: 'posts' }, + { relation: 'comments' }, + ]; + expect(repo.testTranslateJoin(joins)).toEqual({ + posts: true, + comments: true, + }); + }); + + it('should deduplicate same relation', () => { + const joins: JoinClause[] = [ + { relation: 'posts' }, + { relation: 'posts' }, + ]; + expect(repo.testTranslateJoin(joins)).toEqual({ posts: true }); + }); + }); +}); diff --git a/packages/nestjs-repository-typeorm/src/repository/typeorm-metadata.types.ts b/packages/nestjs-repository-typeorm/src/repository/typeorm-metadata.types.ts new file mode 100644 index 000000000..1e44f1958 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/repository/typeorm-metadata.types.ts @@ -0,0 +1,53 @@ +/** + * Minimal structural interfaces for TypeORM internal metadata types. + * + * TypeORM's ColumnMetadata and RelationMetadata are not part of the public API + * and are only accessible via deep subpath imports that are not compatible with + * all moduleResolution strategies. These structural interfaces describe only the + * properties accessed by this package — TypeORM's runtime objects satisfy them. + */ + +/** + * Join column shape as used for FK/PK column metadata. + */ +export interface TypeOrmJoinColumnMetadata { + readonly propertyName: string; + readonly referencedColumn?: { readonly propertyName: string }; +} + +/** + * Minimal shape of TypeORM's ColumnMetadata used by this package. + */ +export interface TypeOrmColumnMetadata { + readonly propertyName: string; + readonly isPrimary: boolean; + readonly isDeleteDate: boolean; + readonly isVersion: boolean; +} + +/** + * Minimal shape of the inverse-relation properties accessed by this package. + * Only the fields that mapNonOwning / mapManyToManyNonOwner actually read are + * required here — this lets test mocks supply partial objects. + */ +export interface TypeOrmInverseRelation { + readonly joinColumns: readonly TypeOrmJoinColumnMetadata[]; + readonly inverseJoinColumns?: readonly TypeOrmJoinColumnMetadata[]; + readonly junctionEntityMetadata?: { readonly name: string }; +} + +/** + * Minimal shape of TypeORM's RelationMetadata used by this package. + */ +export interface TypeOrmRelationMetadata { + readonly propertyName: string; + readonly inverseEntityMetadata: { readonly name: string }; + readonly isOneToMany: boolean; + readonly isManyToMany: boolean; + readonly isManyToManyOwner: boolean; + readonly isOwning: boolean; + readonly joinColumns: readonly TypeOrmJoinColumnMetadata[]; + readonly inverseJoinColumns: readonly TypeOrmJoinColumnMetadata[]; + readonly junctionEntityMetadata?: { readonly name: string }; + readonly inverseRelation?: TypeOrmInverseRelation; +} diff --git a/packages/nestjs-repository-typeorm/src/repository/typeorm-options.schema.ts b/packages/nestjs-repository-typeorm/src/repository/typeorm-options.schema.ts new file mode 100644 index 000000000..548a54514 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/repository/typeorm-options.schema.ts @@ -0,0 +1,314 @@ +import { type EntityTarget, type FindOptionsOrder } from 'typeorm'; + +import { type Type, type PlainLiteralObject } from '@nestjs/common'; + +import { + type OrderClause, + type RepositoryColumnMetadataInterface, + type RepositoryRelationMetadataInterface, + type RelationActionConfig, +} from '@concepta/nestjs-repository'; + +import { + type TypeOrmColumnMetadata, + type TypeOrmRelationMetadata, +} from './typeorm-metadata.types.js'; + +/** + * Type guard that validates EntityTarget satisfies Type. + */ +export function isEntity( + target: EntityTarget, +): target is Type { + return typeof target === 'function' && target.prototype !== undefined; +} + +/** + * Build Entity from EntityTarget, throwing if invalid. + */ +export function buildEntity( + target: EntityTarget, + entityName: string, +): Type { + if (!isEntity(target)) { + throw new Error(`Invalid entity for "${entityName}"`); + } + return target; +} + +/** + * Map TypeORM column metadata to typed repository column metadata. + */ +export function buildColumns( + columns: TypeOrmColumnMetadata[], +): RepositoryColumnMetadataInterface[] { + return columns.map((col) => { + return { + name: col.propertyName, + isPrimary: col.isPrimary, + isRemoveDate: col.isDeleteDate, + isVersion: col.isVersion, + }; + }); +} + +/** + * Map TypeORM relation metadata to ORM-agnostic relation metadata. + * + * Handles owning/non-owning sides and M:N junction tables. + * Skips relations where required FK metadata is unavailable. + * + * @param relations - TypeORM relation metadata array + * @param relationsConfig - optional per-relation action config from forFeature() + */ +export function buildRelations( + relations: TypeOrmRelationMetadata[], + relationsConfig?: Record, +): RepositoryRelationMetadataInterface[] { + const result: RepositoryRelationMetadataInterface[] = []; + + for (const rel of relations) { + const mapped = mapRelation(rel); + if (mapped) { + const cfg = relationsConfig?.[mapped.name]; + result.push( + cfg + ? { + ...mapped, + onDelete: cfg.onDelete, + onUpdate: cfg.onUpdate, + federated: cfg.federated, + distinctFilter: cfg.distinctFilter, + } + : mapped, + ); + } + } + + return result; +} + +/** + * Determine cardinality from the perspective of the entity owning the property. + */ +function getCardinality(rel: TypeOrmRelationMetadata): 'one' | 'many' { + if (rel.isOneToMany || rel.isManyToMany) return 'many'; + return 'one'; +} + +/** + * Map a single TypeORM RelationMetadata to our agnostic format. + * Returns undefined if required metadata is unavailable. + */ +function mapRelation( + rel: TypeOrmRelationMetadata, +): RepositoryRelationMetadataInterface | undefined { + const name = rel.propertyName; + const targetEntity = rel.inverseEntityMetadata.name; + + if (rel.isManyToMany) { + return mapManyToMany(rel, name, targetEntity); + } + + const cardinality = getCardinality(rel); + + if (rel.isOwning) { + return mapOwning(rel, name, targetEntity, cardinality); + } + + return mapNonOwning(rel, name, targetEntity, cardinality); +} + +/** + * Map an owning-side relation (many-to-one or one-to-one owner). + * joinColumns live on the current entity. + */ +function mapOwning( + rel: TypeOrmRelationMetadata, + name: string, + targetEntity: string, + cardinality: 'one' | 'many', +): RepositoryRelationMetadataInterface | undefined { + const joinCol = rel.joinColumns[0]; + if (!joinCol) return undefined; + + const referencedCol = joinCol.referencedColumn; + if (!referencedCol) return undefined; + + return { + name, + targetEntity, + cardinality, + on: { + from: joinCol.propertyName, + to: referencedCol.propertyName, + }, + }; +} + +/** + * Map a non-owning-side relation (one-to-many or one-to-one NOT owner). + * Must look at the inverse relation's joinColumns and swap from/to. + */ +function mapNonOwning( + rel: TypeOrmRelationMetadata, + name: string, + targetEntity: string, + cardinality: 'one' | 'many', +): RepositoryRelationMetadataInterface | undefined { + const inverse = rel.inverseRelation; + if (!inverse) return undefined; + + const inverseJoinCol = inverse.joinColumns[0]; + if (!inverseJoinCol) return undefined; + + const referencedCol = inverseJoinCol.referencedColumn; + if (!referencedCol) return undefined; + + // Swapped: our PK is what they reference, their FK is the join column + return { + name, + targetEntity, + cardinality, + on: { + from: referencedCol.propertyName, + to: inverseJoinCol.propertyName, + }, + }; +} + +/** + * Map a many-to-many relation (owning or non-owning side). + * Junction table metadata provides through info. + */ +function mapManyToMany( + rel: TypeOrmRelationMetadata, + name: string, + targetEntity: string, +): RepositoryRelationMetadataInterface | undefined { + if (rel.isManyToManyOwner) { + return mapManyToManyOwner(rel, name, targetEntity); + } + return mapManyToManyNonOwner(rel, name, targetEntity); +} + +/** + * Map M:N owning side. joinColumns/inverseJoinColumns are junction columns. + */ +function mapManyToManyOwner( + rel: TypeOrmRelationMetadata, + name: string, + targetEntity: string, +): RepositoryRelationMetadataInterface | undefined { + const junctionMeta = rel.junctionEntityMetadata; + if (!junctionMeta) return undefined; + + const sourceJoinCol = rel.joinColumns[0]; + const targetJoinCol = rel.inverseJoinColumns[0]; + if (!sourceJoinCol || !targetJoinCol) return undefined; + + const sourceRef = sourceJoinCol.referencedColumn; + const targetRef = targetJoinCol.referencedColumn; + if (!sourceRef || !targetRef) return undefined; + + return { + name, + targetEntity, + cardinality: 'many', + on: { + from: sourceRef.propertyName, + to: targetRef.propertyName, + }, + through: { + relation: junctionMeta.name, + fromKey: sourceJoinCol.propertyName, + toKey: targetJoinCol.propertyName, + }, + }; +} + +/** + * Map M:N non-owning side. Must read from the inverse (owning) relation's + * junction metadata, swapping source/target perspective. + */ +function mapManyToManyNonOwner( + rel: TypeOrmRelationMetadata, + name: string, + targetEntity: string, +): RepositoryRelationMetadataInterface | undefined { + const inverse = rel.inverseRelation; + if (!inverse) return undefined; + + const junctionMeta = inverse.junctionEntityMetadata; + if (!junctionMeta) return undefined; + + // From the owner's perspective: joinColumns → owner, inverseJoinColumns → us + const theirJoinCol = inverse.joinColumns[0]; + const ourJoinCol = inverse.inverseJoinColumns?.[0]; + if (!theirJoinCol || !ourJoinCol) return undefined; + + const ourRef = ourJoinCol.referencedColumn; + const theirRef = theirJoinCol.referencedColumn; + if (!ourRef || !theirRef) return undefined; + + return { + name, + targetEntity, + cardinality: 'many', + on: { + from: ourRef.propertyName, + to: theirRef.propertyName, + }, + through: { + relation: junctionMeta.name, + fromKey: ourJoinCol.propertyName, + toKey: theirJoinCol.propertyName, + }, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// OrderClause → TypeORM FindOptionsOrder +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Type guard: validates a string is a valid TypeORM sort direction. + */ +export function isOrderValue(value: string): value is 'ASC' | 'DESC' { + return value === 'ASC' || value === 'DESC'; +} + +/** + * Build TypeORM FindOptionsOrder from an OrderClause. + * + * Preserves array order — JavaScript objects maintain insertion order + * for string keys, which TypeORM uses to determine sort priority. + * + * Each entry is validated by {@link isOrderValue} during construction. + */ +export function buildOrder( + keys: OrderClause, +): FindOptionsOrder | undefined { + if (keys.length === 0) return undefined; + + const result: Record> = {}; + let hasEntries = false; + + for (const key of keys) { + if (!isOrderValue(key.order)) continue; + hasEntries = true; + + if (key.relation) { + const existing = result[key.relation]; + const nested = typeof existing === 'object' ? existing : {}; + nested[key.field] = key.order; + result[key.relation] = nested; + } else { + result[key.field] = key.order; + } + } + + if (!hasEntries) return undefined; + + return Object.assign, typeof result>({}, result); +} diff --git a/packages/nestjs-repository-typeorm/src/repository/typeorm-provider-options.interface.ts b/packages/nestjs-repository-typeorm/src/repository/typeorm-provider-options.interface.ts new file mode 100644 index 000000000..b0f23d5ae --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/repository/typeorm-provider-options.interface.ts @@ -0,0 +1,25 @@ +import { type DataSource, type Repository } from 'typeorm'; + +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type RepositoryProviderOptions } from '@concepta/nestjs-repository'; + +import { type TypeOrmDataSourceToken } from '../typeorm-repository.types.js'; + +/** + * TypeORM-specific provider options. + */ +export interface TypeOrmProviderOptionsInterface< + Entity extends PlainLiteralObject = PlainLiteralObject, +> extends RepositoryProviderOptions { + /** + * Data source name or instance for multi-connection setups. + */ + dataSource?: TypeOrmDataSourceToken; + + /** + * Custom repository factory. + * Receives DataSource, returns Repository instance. + */ + factory?: (dataSource: DataSource) => Repository; +} diff --git a/packages/nestjs-repository-typeorm/src/repository/typeorm-repository.ts b/packages/nestjs-repository-typeorm/src/repository/typeorm-repository.ts new file mode 100644 index 000000000..46c977fd4 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/repository/typeorm-repository.ts @@ -0,0 +1,580 @@ +import { + And, + Between, + Equal, + type FindOperator, + type FindOptionsWhere, + In, + IsNull, + LessThan, + LessThanOrEqual, + Like, + MoreThan, + MoreThanOrEqual, + Not, + type Repository, + type EntityManager, + type FindOptionsRelations, + type FindManyOptions, + type FindOneOptions, +} from 'typeorm'; + +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + AppContextHost, + type AppContextLike, + type DeepPartial, + RuntimeException, + type HookResolverService, +} from '@concepta/nestjs-core'; +import { + isWhereCondition, + type JoinClause, + OptimisticLockException, + type RelationActionConfig, + TrxCtx, + RepositoryAdapter, + type RepositoryCreateOptions, + type RepositoryDeleteOptions, + type RepositoryFindOneOptions, + type RepositoryFindOptions, + type RepositoryMetadataInterface, + type RepositoryRestoreOptions, + type RepositoryUpdateOptions, + type RepositoryUpsertOptions, + type TransactionScope, + type WhereClause, + type WhereCondition, + WhereOperator, +} from '@concepta/nestjs-repository'; + +import { TypeOrmEntityNameException } from '../exceptions/typeorm-entity-name.exception.js'; + +import { + buildEntity, + buildColumns, + buildOrder, + buildRelations, +} from './typeorm-options.schema.js'; + +/** + * Options for constructing a TypeOrmRepository. + */ +export interface TypeOrmRepositoryOptions { + entityKey: string; + transactionKey?: string; + hookResolver?: HookResolverService; + relationsConfig?: Record; + transactionScope?: TransactionScope; +} + +/** + * TypeORM implementation of RepositoryInterface. + * Wraps a TypeORM Repository with transaction-aware operations. + */ +export class TypeOrmRepository< + Entity extends PlainLiteralObject, +> extends RepositoryAdapter { + readonly metadata: RepositoryMetadataInterface; + + constructor( + private readonly repo: Repository, + private readonly options: TypeOrmRepositoryOptions, + ) { + super(options.entityKey, options.hookResolver); + + const entityName = repo.metadata?.name || repo.metadata?.targetName; + + if (!entityName) { + throw new TypeOrmEntityNameException(); + } + + const entityType = buildEntity(repo.target, entityName); + const columns = buildColumns(repo.metadata.columns); + const relations = repo.metadata.relations + ? buildRelations(repo.metadata.relations, options.relationsConfig) + : []; + + this.metadata = { + name: entityName, + type: entityType, + columns, + relations, + }; + } + + /** + * Get the repository, using transactional EntityManager if available. + * Creates the driver transaction lazily on first access via `getOrStart()`. + */ + protected async getRepo(ctx?: AppContextLike): Promise> { + if (this.options.transactionKey) { + const context = AppContextHost.from(ctx); + if (context.supports(TrxCtx)) { + const { trx } = context.with(TrxCtx); + if (trx?.isSupported) { + const tx = await trx.getOrStart(this.options.transactionKey); + return tx + .getClient() + .getRepository(this.metadata.type); + } + } + } + return this.repo; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // WhereClause → TypeORM translation + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Translate a WhereClause into TypeORM FindOptionsWhere[]. + * + * Two-phase approach: + * 1. Flatten WhereClause AST into DNF (OR of AND-branches) — agnostic + * 2. Translate each AND-branch to a TypeORM FindOptionsWhere — ORM-specific + */ + protected translateWhere( + clause?: WhereClause, + ): FindOptionsWhere[] | undefined { + if (!clause) return undefined; + const dnf = this.toDnf(clause); + if (dnf.length === 0) return undefined; + return dnf.map((branch) => this.branchToFindOptionsWhere(branch)); + } + + /** + * Convert an AND-branch of WhereClause leaves into a single + * TypeORM FindOptionsWhere. Same-field conditions are merged + * with TypeORM And(). Relation-tagged conditions are nested + * under their relation key. + */ + protected branchToFindOptionsWhere( + leaves: WhereClause[], + ): FindOptionsWhere { + const fields: Record> = {}; + const relations: Record>> = {}; + + for (const leaf of leaves) { + if (!isWhereCondition(leaf)) continue; + + const op = this.toFindOperator(leaf); + + if (leaf.relation) { + const nested = (relations[leaf.relation] ??= {}); + nested[leaf.field] = nested[leaf.field] + ? And(nested[leaf.field], op) + : op; + } else { + const existing = fields[leaf.field]; + fields[leaf.field] = existing ? And(existing, op) : op; + } + } + + return Object.assign< + FindOptionsWhere, + Record>, + Record>> + >({}, fields, relations); + } + + /** + * Map a WhereCondition to a TypeORM FindOperator. + */ + protected toFindOperator(cond: WhereCondition): FindOperator { + const { operator } = cond; + switch (operator) { + case WhereOperator.EQ: + return Equal(cond.value); + case WhereOperator.NE: + return Not(Equal(cond.value)); + case WhereOperator.GT: + return MoreThan(cond.value); + case WhereOperator.GTE: + return MoreThanOrEqual(cond.value); + case WhereOperator.LT: + return LessThan(cond.value); + case WhereOperator.LTE: + return LessThanOrEqual(cond.value); + case WhereOperator.CONTAINS: + return Like(`%${cond.value}%`); + case WhereOperator.NCONTAINS: + return Not(Like(`%${cond.value}%`)); + case WhereOperator.STARTS: + return Like(`${cond.value}%`); + case WhereOperator.NSTARTS: + return Not(Like(`${cond.value}%`)); + case WhereOperator.ENDS: + return Like(`%${cond.value}`); + case WhereOperator.NENDS: + return Not(Like(`%${cond.value}`)); + case WhereOperator.IN: + return In(cond.value); + case WhereOperator.NIN: + return Not(In(cond.value)); + case WhereOperator.IS_NULL: + return IsNull(); + case WhereOperator.NOT_NULL: + return Not(IsNull()); + case WhereOperator.BETWEEN: + return Between(cond.value[0], cond.value[1]); + default: { + const _exhaustive: never = operator; + void _exhaustive; + throw new RuntimeException({ + message: 'Unknown where operator "%s"', + messageParams: [operator], + fault: 'internal', + }); + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // JoinClause → TypeORM relations + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Translate JoinClause[] into TypeORM FindOptionsRelations. + */ + protected translateJoin( + join?: JoinClause[], + ): FindOptionsRelations | undefined { + if (!join?.length) return undefined; + const relations: Record = {}; + for (const j of join) { + relations[j.relation] = true; + } + return Object.assign, Record>( + {}, + relations, + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Internal: build native TypeORM FindOptions from our options + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Convert RepositoryFindOptions to TypeORM FindManyOptions. + */ + protected buildNativeFindManyOptions( + options: RepositoryFindOptions, + ): FindManyOptions { + return { + ...this.buildNativeFindBaseOptions(options), + skip: options.skip, + take: options.take, + }; + } + + /** + * Convert RepositoryFindOneOptions to TypeORM FindOneOptions. + */ + protected buildNativeFindOneOptions( + options: RepositoryFindOneOptions, + ): FindOneOptions { + return this.buildNativeFindBaseOptions(options); + } + + private buildNativeFindBaseOptions( + options: RepositoryFindOneOptions, + ): FindOneOptions { + const resolvedJoin = this.resolveJoinClauses(options.join); + const where = this.translateWhere(options.where); + const relations = this.translateJoin(resolvedJoin); + const order = buildOrder(options.order ?? []); + return { + select: options.select, + where, + relations, + order, + withDeleted: options.withDeleted, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Query operations + // ═══════════════════════════════════════════════════════════════════════════ + + protected async doFind( + options: RepositoryFindOptions = {}, + ): Promise { + const repo = await this.getRepo(options.ctx); + return repo.find(this.buildNativeFindManyOptions(options)); + } + + protected async doFindOne( + options: RepositoryFindOneOptions, + ): Promise { + const repo = await this.getRepo(options.ctx); + return repo.findOne(this.buildNativeFindOneOptions(options)); + } + + protected async doCount( + options: RepositoryFindOptions = {}, + ): Promise { + const repo = await this.getRepo(options.ctx); + return repo.count(this.buildNativeFindManyOptions(options)); + } + + protected async doFindAndCount( + options: RepositoryFindOptions = {}, + ): Promise<[Entity[], number]> { + const repo = await this.getRepo(options.ctx); + return repo.findAndCount(this.buildNativeFindManyOptions(options)); + } + + // Create operations + + protected async doCreate( + entity: DeepPartial, + options?: RepositoryCreateOptions, + ): Promise { + const repo = await this.getRepo(options?.ctx); + return repo.save(entity); + } + + protected async doCreateMany( + entities: DeepPartial[], + options?: RepositoryCreateOptions, + ): Promise { + const repo = await this.getRepo(options?.ctx); + return repo.save(entities); + } + + // Update operations + + protected async doUpdate( + entity: Entity, + data: DeepPartial, + options?: RepositoryUpdateOptions, + ): Promise { + const versionColumn = this.getVersionColumn(); + + if (versionColumn) { + return this.saveWithVersionCheck( + entity, + data, + versionColumn, + options?.ctx, + ); + } + + const repo = await this.getRepo(options?.ctx); + const merged = repo.merge(entity, data); + return repo.save(merged); + } + + protected async doUpsert( + entity: DeepPartial, + options?: RepositoryUpsertOptions, + ): Promise { + const repo = await this.getRepo(options?.ctx); + const conflictPaths = this.getPrimaryColumns(); + const entityInstance = repo.create(entity); + const insertResult = await repo.upsert(entityInstance, conflictPaths); + + const identifiers = insertResult.identifiers[0] ?? {}; + const primaryKeys: Partial> = {}; + + for (const col of conflictPaths) { + const value = identifiers[col] ?? entityInstance[col]; + + if (value === undefined) { + throw new Error(`Upsert requires primary key "${col}" to be set`); + } + + primaryKeys[col] = value; + } + + const result = await repo.findOne({ where: primaryKeys }); + + if (!result) { + throw new Error('Upsert failed: entity not found after upsert'); + } + + return result; + } + + protected async doReplace( + entity: Entity, + data: DeepPartial, + options?: RepositoryUpdateOptions, + ): Promise { + const versionColumn = this.getVersionColumn(); + + if (versionColumn) { + return this.saveWithVersionCheck( + entity, + data, + versionColumn, + options?.ctx, + ); + } + + const repo = await this.getRepo(options?.ctx); + const replaced = repo.merge(entity, data); + return repo.save(replaced); + } + + /** + * Persist `data` onto `entity` guarded by an atomic optimistic-lock check + * on the version column read at fetch time. + * + * `repo.increment(conditions, propertyPath, value)` is used for the guard + * itself — unlike `repo.createQueryBuilder().update().set(...)`, its + * `propertyPath` is a plain `string` rather than `QueryDeepPartialEntity`, + * so it doesn't hit the generics wall that makes TypeORM's `.set()` + * impossible to satisfy for a library-level generic `Entity` type + * parameter. It performs a single atomic + * `UPDATE ... SET version = version + 0 WHERE id = :id AND version = :expected` + * statement and reports 0 affected rows on a mismatch — exactly the + * compare-and-swap this needs. The `+ 0` is deliberate, not a typo: it's a + * pure atomic check with no real effect of its own (verified against both + * this repo's supported drivers — Postgres's `rowCount` and TypeORM's own + * sqlite driver both report `affected` based on rows *matched* by WHERE, + * not rows whose value actually changed, unlike MySQL's default + * behavior). The real, sole version bump happens in the `repo.save()` + * below — `@VersionColumn` entities auto-increment on every `save()` + * regardless of whether the value you hand it changed, so doing a real + * `+1` here as well would double-bump every successful update. + * + * That guard statement and the follow-up field write are two separate SQL + * statements, so — unless both run inside one DB transaction — a third + * writer could still interleave between them and reintroduce a lost + * update. `TransactionScope.run()` closes that window: it joins the + * caller's transaction if one is already active (e.g. via + * `@Transactional()`), or opens a short-lived one scoped to just this + * call if not, so every caller gets the same guarantee without having to + * opt in. + * + * `merged[versionColumn]` is forced back to the freshly-read value + * immediately after merging so a client-supplied `version` in `data` can + * never override it — `repo.save()`'s own auto-increment is what actually + * advances it from there. + */ + private async saveWithVersionCheck( + entity: Entity, + data: DeepPartial, + versionColumn: keyof Entity & string, + ctx?: PlainLiteralObject, + ): Promise { + const run = async (txCtx?: AppContextLike): Promise => { + const repo = await this.getRepo(txCtx); + + const primaryWhere: FindOptionsWhere = {}; + for (const col of this.getPrimaryColumns()) { + primaryWhere[col] = entity[col]; + } + + const lockWhere: FindOptionsWhere = { ...primaryWhere }; + lockWhere[versionColumn] = entity[versionColumn]; + + const lockResult = await repo.increment(lockWhere, versionColumn, 0); + + if (lockResult.affected === 0) { + throw new OptimisticLockException(this.metadata.name); + } + + const fresh = await repo.findOne({ where: primaryWhere }); + + if (!fresh) { + throw new RuntimeException({ + message: 'Entity "%s" not found after update', + messageParams: [this.metadata.name], + fault: 'internal', + }); + } + + // `repo.merge()` mutates `fresh` in place and returns the same + // reference, so the true version must be captured *before* merging — + // reading `fresh[versionColumn]` afterward would just be reading back + // whatever `data` already overwrote it with. + const trueVersion = fresh[versionColumn]; + const merged = repo.merge(fresh, data); + merged[versionColumn] = trueVersion; + + return repo.save(merged); + }; + + if (this.options.transactionScope) { + return this.options.transactionScope.run(ctx ?? {}, (txCtx) => + run(txCtx), + ); + } + + // No TransactionScope wired (e.g. TypeOrmRepositoryModule used directly, + // without RepositoryModule.forRoot() — TransactionScope is only + // provided by the latter). If the caller is already inside their own + // active transaction, the guard and the write still resolve to the + // same connection via getRepo()/TrxCtx, so it's still safe — only + // refuse when neither guarantee is present, since running the guard + // and the write as two separate autocommit statements would silently + // reopen the exact race this whole mechanism exists to close. + const alreadyInTransaction = ctx + ? AppContextHost.from(ctx).supports(TrxCtx) + : false; + + if (!alreadyInTransaction) { + throw new RuntimeException({ + message: + 'Optimistic locking for "%s" requires an active transaction — ' + + 'import RepositoryModule.forRoot() so TransactionScope is ' + + 'available, or wrap this call in an existing transaction', + messageParams: [this.metadata.name], + fault: 'usage', + }); + } + + return run(ctx); + } + + // Delete operations + + protected async doDelete( + entity: Entity, + options?: RepositoryDeleteOptions, + ): Promise { + const repo = await this.getRepo(options?.ctx); + return repo.remove(entity); + } + + protected async doDeleteMany( + entities: Entity[], + options?: RepositoryDeleteOptions, + ): Promise { + const repo = await this.getRepo(options?.ctx); + return repo.remove(entities); + } + + protected async doSoftDelete( + entity: Entity, + options?: RepositoryDeleteOptions, + ): Promise { + const repo = await this.getRepo(options?.ctx); + return repo.softRemove(entity); + } + + protected async doRestore( + entity: Entity, + options?: RepositoryRestoreOptions, + ): Promise { + const repo = await this.getRepo(options?.ctx); + return repo.recover(entity); + } + + // Utility methods + + transform(entityLike: DeepPartial): Entity { + return this.repo.create(entityLike); + } + + merge( + mergeIntoEntity: Entity, + ...entityLikes: DeepPartial[] + ): Entity { + return this.repo.merge(mergeIntoEntity, ...entityLikes); + } +} diff --git a/packages/nestjs-repository-typeorm/src/transaction/__tests__/transaction-scope.e2e-spec.ts b/packages/nestjs-repository-typeorm/src/transaction/__tests__/transaction-scope.e2e-spec.ts new file mode 100644 index 000000000..8b3347b07 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/transaction/__tests__/transaction-scope.e2e-spec.ts @@ -0,0 +1,176 @@ +import { type DataSource, type EntityManager } from 'typeorm'; + +import { Test, type TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; + +import { AppContextHost } from '@concepta/nestjs-core'; +import { + getDynamicRepositoryToken, + type TransactionContextInterface, + TransactionScope, +} from '@concepta/nestjs-repository'; + +import { TEST_ENTITY_TOKEN } from '../../__fixtures__/repository/config/test.constants.fixture.js'; +import { TestEntityFixture } from '../../__fixtures__/repository/entity/test.entity.fixture.js'; +import { AppModuleFixture } from '../../__fixtures__/repository/module/app.module.fixture.js'; +import { type TypeOrmRepository } from '../../repository/typeorm-repository.js'; +import { resolveTransactionKey } from '../../typeorm-repository.util.js'; + +/** + * Regression coverage for #468 against a real TypeORM stack — a second + * `run()` on the same context must not reuse the first run's (committed + * or rolled back) transaction, and a plain, non-transactional read after a + * completed run must not touch it either. + */ +describe('TransactionScope — sequential run() on the same context (#468)', () => { + let moduleFixture: TestingModule; + let txScope: TransactionScope; + let testRepository: TypeOrmRepository; + + beforeEach(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + txScope = moduleFixture.get(TransactionScope); + testRepository = moduleFixture.get>( + getDynamicRepositoryToken(TEST_ENTITY_TOKEN), + ); + }); + + it('should persist both rows when run() is called twice on the same context', async () => { + const ctx = new AppContextHost(); + + await txScope.run(ctx, async (txCtx: TransactionContextInterface) => { + return testRepository.create({ firstName: 'Alice' }, { ctx: txCtx }); + }); + + await txScope.run(ctx, async (txCtx: TransactionContextInterface) => { + return testRepository.create({ firstName: 'Bob' }, { ctx: txCtx }); + }); + + const result = await testRepository.find(); + expect(result.map((e) => e.firstName).sort()).toEqual(['Alice', 'Bob']); + }); + + it('should serve a plain read on the same context after a completed run without touching the dead transaction', async () => { + const ctx = new AppContextHost(); + + await txScope.run(ctx, async (txCtx: TransactionContextInterface) => { + return testRepository.create({ firstName: 'Alice' }, { ctx: txCtx }); + }); + + const result = await testRepository.find({ ctx }); + expect(result.map((e) => e.firstName)).toEqual(['Alice']); + }); + + it('should roll back a failed run and still commit a later successful run on the same context', async () => { + const ctx = new AppContextHost(); + + await expect( + txScope.run(ctx, async (txCtx: TransactionContextInterface) => { + await testRepository.create({ firstName: 'Doomed' }, { ctx: txCtx }); + throw new Error('rollback me'); + }), + ).rejects.toThrow('rollback me'); + + await txScope.run(ctx, async (txCtx: TransactionContextInterface) => { + return testRepository.create({ firstName: 'Alice' }, { ctx: txCtx }); + }); + + const result = await testRepository.find(); + expect(result.map((e) => e.firstName)).toEqual(['Alice']); + }); +}); + +/** + * Regression coverage for out-of-scope defect #1 — commitAll() rolled back + * "clean" (never explicitly marked dirty) transactions, so a write made + * directly through `tx.getClient()` (the documented escape hatch) was + * silently discarded while `run()` still resolved successfully. + */ +describe('TransactionScope — commits every active transaction (defect #1)', () => { + let moduleFixture: TestingModule; + let txScope: TransactionScope; + let testRepository: TypeOrmRepository; + + beforeEach(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + txScope = moduleFixture.get(TransactionScope); + testRepository = moduleFixture.get>( + getDynamicRepositoryToken(TEST_ENTITY_TOKEN), + ); + }); + + it('should persist a write made directly through tx.getClient(), with no markDirty() call', async () => { + const ctx = new AppContextHost(); + + await txScope.run(ctx, async (txCtx: TransactionContextInterface) => { + const tx = await txCtx.trx.getOrStart(resolveTransactionKey()); + const manager = tx.getClient(); + await manager.save(TestEntityFixture, { firstName: 'Ghost' }); + }); + + const result = await testRepository.find(); + expect(result.map((e) => e.firstName)).toEqual(['Ghost']); + }); + + it('should leave the database untouched for a readOnly run through the repository', async () => { + const ctx = new AppContextHost(); + + await txScope.runReadOnly( + ctx, + async (txCtx: TransactionContextInterface) => { + return testRepository.create({ firstName: 'Alice' }, { ctx: txCtx }); + }, + ); + + const result = await testRepository.find(); + expect(result).toEqual([]); + }); +}); + +/** + * Regression coverage against a real driver for a race in + * `TransactionManager.getOrStart()` — two concurrent repository calls as the + * first DB work in a `run()` used to each open their own `QueryRunner` + * before either finished starting, so only the last one written was ever + * committed or released, leaking the other. + */ +describe('TransactionScope — concurrent repository calls share one connection', () => { + let moduleFixture: TestingModule; + let txScope: TransactionScope; + let testRepository: TypeOrmRepository; + let dataSource: DataSource; + + beforeEach(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + txScope = moduleFixture.get(TransactionScope); + testRepository = moduleFixture.get>( + getDynamicRepositoryToken(TEST_ENTITY_TOKEN), + ); + dataSource = moduleFixture.get(getDataSourceToken()); + }); + + it('should open only one QueryRunner for concurrent reads racing to start the same transaction', async () => { + const createQueryRunnerSpy = vi.spyOn(dataSource, 'createQueryRunner'); + + const ctx = new AppContextHost(); + + await txScope.run(ctx, async (txCtx: TransactionContextInterface) => { + await Promise.all([ + testRepository.find({ ctx: txCtx }), + testRepository.find({ ctx: txCtx }), + testRepository.find({ ctx: txCtx }), + ]); + }); + + expect(createQueryRunnerSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nestjs-repository-typeorm/src/transaction/typeorm-transaction.factory.ts b/packages/nestjs-repository-typeorm/src/transaction/typeorm-transaction.factory.ts new file mode 100644 index 000000000..cc4de56b3 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/transaction/typeorm-transaction.factory.ts @@ -0,0 +1,28 @@ +import { DataSource } from 'typeorm'; + +import { Injectable } from '@nestjs/common'; + +import { + TransactionInterface, + TransactionFactoryInterface, +} from '@concepta/nestjs-repository'; + +import { TypeOrmTransaction } from './typeorm-transaction.js'; + +/** + * Factory for creating TypeORM transactions. + * + * Registered with the TransactionFactoryRegistry to enable automatic + * transaction management via the `@Transactional()` decorator. + */ +@Injectable() +export class TypeOrmTransactionFactory implements TransactionFactoryInterface { + constructor(private readonly dataSource: DataSource) {} + + /** + * Create a new transaction instance bound to this factory's DataSource. + */ + create(): TransactionInterface { + return new TypeOrmTransaction(this.dataSource); + } +} diff --git a/packages/nestjs-repository-typeorm/src/transaction/typeorm-transaction.spec.ts b/packages/nestjs-repository-typeorm/src/transaction/typeorm-transaction.spec.ts new file mode 100644 index 000000000..2b015fb55 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/transaction/typeorm-transaction.spec.ts @@ -0,0 +1,221 @@ +import { type DataSource, type EntityManager, type QueryRunner } from 'typeorm'; +import { type Mock } from 'vitest'; +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { TypeOrmTransactionFactory } from './typeorm-transaction.factory.js'; +import { TypeOrmTransaction } from './typeorm-transaction.js'; + +interface MockQueryRunner { + connect: Mock; + startTransaction: Mock; + commitTransaction: Mock; + rollbackTransaction: Mock; + release: Mock; + isTransactionActive: boolean; + manager: EntityManager | undefined; +} + +describe(TypeOrmTransaction.name, () => { + let transaction: TypeOrmTransaction; + let mockDataSource: DeepMockProxy; + let mockQueryRunner: MockQueryRunner; + let mockEntityManager: DeepMockProxy; + + beforeEach(() => { + mockEntityManager = mockDeep(); + + mockQueryRunner = { + connect: vi.fn().mockResolvedValue(undefined), + startTransaction: vi.fn().mockResolvedValue(undefined), + commitTransaction: vi.fn().mockResolvedValue(undefined), + rollbackTransaction: vi.fn().mockResolvedValue(undefined), + release: vi.fn().mockResolvedValue(undefined), + isTransactionActive: false, + manager: mockEntityManager, + }; + + mockDataSource = mockDeep(); + mockDataSource.createQueryRunner.mockReturnValue( + mockQueryRunner as unknown as QueryRunner, + ); + + transaction = new TypeOrmTransaction(mockDataSource); + }); + + describe('isActive', () => { + it('should return false when no query runner', () => { + expect(transaction.isActive).toBe(false); + }); + + it('should return query runner transaction active state', async () => { + await transaction.start(); + mockQueryRunner.isTransactionActive = true; + expect(transaction.isActive).toBe(true); + }); + }); + + describe('start', () => { + it('should create query runner, connect, and start transaction', async () => { + await transaction.start(); + + expect(mockDataSource.createQueryRunner).toHaveBeenCalled(); + expect(mockQueryRunner.connect).toHaveBeenCalled(); + expect(mockQueryRunner.startTransaction).toHaveBeenCalled(); + }); + + it('should release the query runner and rethrow when startTransaction() rejects', async () => { + const startError = new Error('deadlock detected'); + mockQueryRunner.startTransaction.mockRejectedValueOnce(startError); + + await expect(transaction.start()).rejects.toBe(startError); + + expect(mockQueryRunner.release).toHaveBeenCalledTimes(1); + expect(transaction.isActive).toBe(false); + }); + + it('should release the query runner and rethrow when connect() rejects', async () => { + const connectError = new Error('connection pool exhausted'); + mockQueryRunner.connect.mockRejectedValueOnce(connectError); + + await expect(transaction.start()).rejects.toBe(connectError); + + expect(mockQueryRunner.release).toHaveBeenCalledTimes(1); + expect(mockQueryRunner.startTransaction).not.toHaveBeenCalled(); + expect(transaction.isActive).toBe(false); + }); + + it('should still rethrow the original error when the release-on-failure itself also fails', async () => { + const startError = new Error('deadlock detected'); + mockQueryRunner.startTransaction.mockRejectedValueOnce(startError); + mockQueryRunner.release.mockRejectedValueOnce( + new Error('release failed'), + ); + + await expect(transaction.start()).rejects.toBe(startError); + }); + + it('should not leave a stale query runner behind after a failed start(), so getClient() still throws', async () => { + mockQueryRunner.startTransaction.mockRejectedValueOnce( + new Error('deadlock detected'), + ); + + await expect(transaction.start()).rejects.toThrow(); + + expect(() => transaction.getClient()).toThrow( + 'No active transaction - cannot get client', + ); + }); + }); + + describe('commit', () => { + it('should throw if no active transaction', async () => { + await expect(transaction.commit()).rejects.toThrow( + 'No active transaction to commit', + ); + }); + + it('should commit transaction and release query runner', async () => { + await transaction.start(); + await transaction.commit(); + + expect(mockQueryRunner.commitTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.release).toHaveBeenCalled(); + }); + + it('should release query runner even if commit fails', async () => { + await transaction.start(); + mockQueryRunner.commitTransaction.mockRejectedValueOnce( + new Error('Commit failed'), + ); + + await expect(transaction.commit()).rejects.toThrow('Commit failed'); + expect(mockQueryRunner.release).toHaveBeenCalled(); + }); + }); + + describe('rollback', () => { + it('should do nothing if no query runner', async () => { + await transaction.rollback(); + expect(mockQueryRunner.rollbackTransaction).not.toHaveBeenCalled(); + }); + + it('should rollback and release when transaction is active', async () => { + await transaction.start(); + mockQueryRunner.isTransactionActive = true; + + await transaction.rollback(); + + expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.release).toHaveBeenCalled(); + }); + + it('should only release when transaction is not active', async () => { + await transaction.start(); + mockQueryRunner.isTransactionActive = false; + + await transaction.rollback(); + + expect(mockQueryRunner.rollbackTransaction).not.toHaveBeenCalled(); + expect(mockQueryRunner.release).toHaveBeenCalled(); + }); + + it('should release query runner even if rollback fails', async () => { + await transaction.start(); + mockQueryRunner.isTransactionActive = true; + mockQueryRunner.rollbackTransaction.mockRejectedValueOnce( + new Error('Rollback failed'), + ); + + await expect(transaction.rollback()).rejects.toThrow('Rollback failed'); + expect(mockQueryRunner.release).toHaveBeenCalled(); + }); + }); + + describe('getClient', () => { + it('should throw if no active transaction', () => { + expect(() => transaction.getClient()).toThrow( + 'No active transaction - cannot get client', + ); + }); + + it('should return entity manager when transaction is active', async () => { + await transaction.start(); + + const client = transaction.getClient(); + expect(client).toBe(mockEntityManager); + }); + + it('should throw if query runner has no manager', async () => { + await transaction.start(); + (mockQueryRunner as { manager: EntityManager | undefined }).manager = + undefined; + + expect(() => transaction.getClient()).toThrow( + 'No active transaction - cannot get client', + ); + }); + }); +}); + +describe(TypeOrmTransactionFactory.name, () => { + let factory: TypeOrmTransactionFactory; + let mockDataSource: DeepMockProxy; + + beforeEach(() => { + mockDataSource = mockDeep(); + factory = new TypeOrmTransactionFactory(mockDataSource); + }); + + describe('create', () => { + it('should create a TypeOrmTransaction instance', () => { + const transaction = factory.create(); + expect(transaction).toBeInstanceOf(TypeOrmTransaction); + }); + + it('should create new transaction on each call', () => { + const tx1 = factory.create(); + const tx2 = factory.create(); + expect(tx1).not.toBe(tx2); + }); + }); +}); diff --git a/packages/nestjs-repository-typeorm/src/transaction/typeorm-transaction.ts b/packages/nestjs-repository-typeorm/src/transaction/typeorm-transaction.ts new file mode 100644 index 000000000..1b96a73b5 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/transaction/typeorm-transaction.ts @@ -0,0 +1,115 @@ +import { type DataSource, type QueryRunner, type EntityManager } from 'typeorm'; + +import { Logger } from '@nestjs/common'; + +import { type TransactionInterface } from '@concepta/nestjs-repository'; + +/** + * TypeORM implementation of a transaction. + * + * Wraps a TypeORM QueryRunner to manage transaction lifecycle. Each instance + * represents a single transaction that can be started, committed, or rolled back. + * + * @example + * ```typescript + * const tx = new TypeOrmTransaction(dataSource); + * await tx.start(); + * + * const manager = tx.getClient(); + * await manager.save(entity); + * + * await tx.commit(); + * ``` + */ +export class TypeOrmTransaction implements TransactionInterface { + private queryRunner: QueryRunner | null = null; + + constructor(private readonly dataSource: DataSource) {} + + /** + * Whether the transaction is currently active. + */ + get isActive(): boolean { + return this.queryRunner?.isTransactionActive ?? false; + } + + /** + * Start the transaction by creating a QueryRunner and beginning a + * transaction. + * + * `this.queryRunner` is only assigned once both steps succeed. A + * `connect()`/`startTransaction()` failure still leaves a real, + * connected QueryRunner behind — it's released here rather than left to + * leak a pool connection, since nothing else holds a reference to it. + */ + async start(): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + + try { + await queryRunner.connect(); + await queryRunner.startTransaction(); + } catch (error) { + try { + await queryRunner.release(); + } catch (releaseError) { + Logger.error( + `Failed to release QueryRunner after start() failed: ${releaseError}`, + releaseError instanceof Error ? releaseError.stack : undefined, + ); + } + throw error; + } + + this.queryRunner = queryRunner; + } + + /** + * Commit the transaction and release the QueryRunner. + */ + async commit(): Promise { + if (!this.queryRunner) { + throw new Error('No active transaction to commit'); + } + + try { + await this.queryRunner.commitTransaction(); + } finally { + await this.cleanup(); + } + } + + /** + * Rollback the transaction and release the QueryRunner. + * Safe to call even if no transaction is active. + */ + async rollback(): Promise { + if (!this.queryRunner) { + return; + } + + try { + if (this.queryRunner.isTransactionActive) { + await this.queryRunner.rollbackTransaction(); + } + } finally { + await this.cleanup(); + } + } + + /** + * Get the EntityManager for this transaction. + */ + getClient(): T { + if (!this.queryRunner?.manager) { + throw new Error('No active transaction - cannot get client'); + } + return this.queryRunner.manager as T; + } + + private async cleanup(): Promise { + if (this.queryRunner) { + await this.queryRunner.release(); + this.queryRunner = null; + } + } +} diff --git a/packages/nestjs-repository-typeorm/src/typeorm-repository.constants.ts b/packages/nestjs-repository-typeorm/src/typeorm-repository.constants.ts new file mode 100644 index 000000000..81e334beb --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/typeorm-repository.constants.ts @@ -0,0 +1,4 @@ +/** + * The TypeOrm default data source name + */ +export const TYPEORM_DEFAULT_DATA_SOURCE_NAME = 'default'; diff --git a/packages/nestjs-repository-typeorm/src/typeorm-repository.module.ts b/packages/nestjs-repository-typeorm/src/typeorm-repository.module.ts new file mode 100644 index 000000000..d0e8daaa3 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/typeorm-repository.module.ts @@ -0,0 +1,99 @@ +import { Global, Module, Provider } from '@nestjs/common'; + +import { + getDynamicRepositoryToken, + DynamicRepositoryModule, +} from '@concepta/nestjs-repository'; + +import { TypeOrmProviderOptionsInterface } from './repository/typeorm-provider-options.interface.js'; +import { + createTypeOrmProvider, + getTypeOrmImports, + createTransactionFactoryDescriptor, + resolveDataSourceName, +} from './typeorm-repository.util.js'; + +/** + * TypeORM Repository module providing data access with transaction support. + * + * Can be used directly or wrapped by RepositoryModule. Direct usage does + * NOT provide `TransactionScope` (only `RepositoryModule.forRoot()` does), + * so optimistic locking on a versioned entity (see + * `TypeOrmRepository`'s "Optimistic Locking" docs) throws immediately on + * `update`/`replace` unless the caller is already inside their own active + * transaction — it never silently runs unprotected. + * + * @example Direct usage + * ```typescript + * @Module({ + * imports: [ + * TypeOrmModule.forRoot({ ... }), + * TypeOrmRepositoryModule.forFeature([ + * { key: 'orders', entity: Order }, + * { key: 'customers', entity: Customer, dataSource: 'secondary' }, + * { key: 'audit', entity: AuditLog, factory: createAuditRepository }, + * ]), + * ], + * }) + * export class AppModule {} + * ``` + * + * @example Via RepositoryModule wrapper + * ```typescript + * @Module({ + * imports: [ + * TypeOrmModule.forRoot({ ... }), + * RepositoryModule.forFeature({ + * module: TypeOrmRepositoryModule, + * entities: [{ key: 'orders', entity: Order }], + * }), + * ], + * }) + * export class AppModule {} + * ``` + */ +@Global() +@Module({}) +export class TypeOrmRepositoryModule { + /** + * Register repositories for TypeORM entities. + * + * @param entities - Entity options + * @returns Dynamic module with repository providers and transaction factory descriptors + */ + static forFeature( + entities: TypeOrmProviderOptionsInterface[], + ): DynamicRepositoryModule { + // Get imports + const imports = getTypeOrmImports(entities); + + // Collect unique data source names for transaction factory descriptors + const dataSourceNames = new Set(); + for (const entity of entities) { + dataSourceNames.add(resolveDataSourceName(entity.dataSource)); + } + + // Create providers for entities + const providers: Provider[] = entities.map((entityOption) => + createTypeOrmProvider(entityOption), + ); + + // Export tokens for injection + const exports = entities.map((entityOption) => + getDynamicRepositoryToken(entityOption.key), + ); + + // Create transaction factory descriptors for RepositoryModule to register + const transactionFactories = Array.from(dataSourceNames).map((dsName) => + createTransactionFactoryDescriptor(dsName), + ); + + return { + module: TypeOrmRepositoryModule, + imports, + providers, + exports, + transactionFactories, + }; + } +} diff --git a/packages/nestjs-repository-typeorm/src/typeorm-repository.types.ts b/packages/nestjs-repository-typeorm/src/typeorm-repository.types.ts new file mode 100644 index 000000000..76e1e302b --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/typeorm-repository.types.ts @@ -0,0 +1,3 @@ +import { type DataSource, type DataSourceOptions } from 'typeorm'; + +export type TypeOrmDataSourceToken = DataSource | DataSourceOptions | string; diff --git a/packages/nestjs-repository-typeorm/src/typeorm-repository.util.spec.ts b/packages/nestjs-repository-typeorm/src/typeorm-repository.util.spec.ts new file mode 100644 index 000000000..5990900a3 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/typeorm-repository.util.spec.ts @@ -0,0 +1,440 @@ +import { type DataSource, type Repository } from 'typeorm'; +import { mockDeep } from 'vitest-mock-extended'; + +import { getDataSourceToken } from '@nestjs/typeorm'; + +import { HookResolverService } from '@concepta/nestjs-core'; +import { getDynamicRepositoryToken } from '@concepta/nestjs-repository'; + +import { type TypeOrmProviderOptionsInterface } from './repository/typeorm-provider-options.interface.js'; +import { TypeOrmRepository } from './repository/typeorm-repository.js'; +import { TypeOrmTransactionFactory } from './transaction/typeorm-transaction.factory.js'; +import { TYPEORM_DEFAULT_DATA_SOURCE_NAME } from './typeorm-repository.constants.js'; +import { + resolveDataSourceName, + resolveTransactionKey, + resolveTokenName, + createTypeOrmRepository, + createTypeOrmProvider, + getTypeOrmImports, + createTransactionFactoryDescriptor, + OPTIONAL_HOOK_RESOLVER_INJECT, +} from './typeorm-repository.util.js'; + +// Mock entity class for testing +class TestEntity { + id!: string; + name!: string; +} + +describe('typeorm-repository.util', () => { + describe('resolveDataSourceName', () => { + it('should return default name when no dataSource provided', () => { + const result = resolveDataSourceName(); + expect(result).toBe(TYPEORM_DEFAULT_DATA_SOURCE_NAME); + }); + + it('should return default name when dataSource is undefined', () => { + const result = resolveDataSourceName(undefined); + expect(result).toBe(TYPEORM_DEFAULT_DATA_SOURCE_NAME); + }); + + it('should return string dataSource as-is', () => { + const result = resolveDataSourceName('secondary'); + expect(result).toBe('secondary'); + }); + + it('should return DataSource name when DataSource object provided', () => { + const mockDataSource = { name: 'custom-ds' } as DataSource; + const result = resolveDataSourceName(mockDataSource); + expect(result).toBe('custom-ds'); + }); + + it('should return default name when DataSource has no name', () => { + const mockDataSource = {} as DataSource; + const result = resolveDataSourceName(mockDataSource); + expect(result).toBe(TYPEORM_DEFAULT_DATA_SOURCE_NAME); + }); + }); + + describe('resolveTransactionKey', () => { + it('should return typeorm:default for no dataSource', () => { + const result = resolveTransactionKey(); + expect(result).toBe(`typeorm:${TYPEORM_DEFAULT_DATA_SOURCE_NAME}`); + }); + + it('should return typeorm: for named dataSource', () => { + const result = resolveTransactionKey('secondary'); + expect(result).toBe('typeorm:secondary'); + }); + + it('should handle DataSource object', () => { + const mockDataSource = { name: 'custom-ds' } as DataSource; + const result = resolveTransactionKey(mockDataSource); + expect(result).toBe('typeorm:custom-ds'); + }); + }); + + describe('resolveTokenName', () => { + it('should return undefined for default data source name', () => { + const result = resolveTokenName(TYPEORM_DEFAULT_DATA_SOURCE_NAME); + expect(result).toBeUndefined(); + }); + + it('should return the name for non-default data source', () => { + const result = resolveTokenName('secondary'); + expect(result).toBe('secondary'); + }); + + it('should return undefined for undefined input', () => { + const result = resolveTokenName(undefined); + expect(result).toBeUndefined(); + }); + }); + + describe('createTypeOrmRepository', () => { + it('should create TypeOrmRepository with default transaction key', () => { + const mockRepo = { + target: TestEntity, + metadata: { name: 'TestEntity', targetName: 'TestEntity', columns: [] }, + } as unknown as Repository; + + const result = createTypeOrmRepository(mockRepo, 'test-entity'); + + expect(result).toBeInstanceOf(TypeOrmRepository); + expect(result.metadata.type).toBe(TestEntity); + }); + + it('should create TypeOrmRepository with custom data source', () => { + const mockRepo = { + target: TestEntity, + metadata: { name: 'TestEntity', targetName: 'TestEntity', columns: [] }, + } as unknown as Repository; + + const result = createTypeOrmRepository( + mockRepo, + 'test-entity', + 'secondary', + ); + + expect(result).toBeInstanceOf(TypeOrmRepository); + }); + + it('should create TypeOrmRepository with hookResolver', () => { + const mockRepo = { + target: TestEntity, + metadata: { name: 'TestEntity', targetName: 'TestEntity', columns: [] }, + } as unknown as Repository; + const mockHookResolver = {} as HookResolverService; + + const result = createTypeOrmRepository( + mockRepo, + 'test-entity', + undefined, + mockHookResolver, + ); + + expect(result).toBeInstanceOf(TypeOrmRepository); + }); + }); + + describe('OPTIONAL_HOOK_RESOLVER_INJECT', () => { + it('should have correct token and optional flag', () => { + expect(OPTIONAL_HOOK_RESOLVER_INJECT.token).toBe(HookResolverService); + expect(OPTIONAL_HOOK_RESOLVER_INJECT.optional).toBe(true); + }); + }); + + describe('createTypeOrmProvider', () => { + it('should create provider for standard entity', () => { + const options: TypeOrmProviderOptionsInterface = { + key: 'test-entity', + entity: TestEntity, + }; + + const provider = createTypeOrmProvider(options); + + expect(provider).toHaveProperty('provide'); + expect(provider).toHaveProperty('inject'); + expect(provider).toHaveProperty('useFactory'); + // Provider uses public token directly + expect((provider as { provide: string }).provide).toBe( + getDynamicRepositoryToken('test-entity'), + ); + }); + + it('should create provider with custom data source', () => { + const options: TypeOrmProviderOptionsInterface = { + key: 'test-entity', + entity: TestEntity, + dataSource: 'secondary', + }; + + const provider = createTypeOrmProvider(options); + + expect(provider).toHaveProperty('provide'); + expect((provider as { inject: unknown[] }).inject).toBeDefined(); + }); + + it('should create provider with factory function', () => { + const mockFactory = vi.fn(); + const options: TypeOrmProviderOptionsInterface = { + key: 'test-entity', + entity: TestEntity, + factory: mockFactory, + }; + + const provider = createTypeOrmProvider(options); + + expect(provider).toHaveProperty('provide'); + expect(provider).toHaveProperty('useFactory'); + }); + + it('factory provider should use DataSource token for injection', () => { + const mockFactory = vi.fn(); + const options: TypeOrmProviderOptionsInterface = { + key: 'test-entity', + entity: TestEntity, + factory: mockFactory, + }; + + const provider = createTypeOrmProvider(options); + const inject = (provider as { inject: unknown[] }).inject; + + // When using factory, should inject DataSource + expect(inject).toContain(getDataSourceToken()); + }); + + it('standard provider should inject optional HookResolverService', () => { + const options: TypeOrmProviderOptionsInterface = { + key: 'test-entity', + entity: TestEntity, + }; + + const provider = createTypeOrmProvider(options); + const inject = (provider as { inject: unknown[] }).inject; + + expect(inject).toContainEqual(OPTIONAL_HOOK_RESOLVER_INJECT); + }); + + it('factory provider should inject optional HookResolverService', () => { + const mockFactory = vi.fn(); + const options: TypeOrmProviderOptionsInterface = { + key: 'test-entity', + entity: TestEntity, + factory: mockFactory, + }; + + const provider = createTypeOrmProvider(options); + const inject = (provider as { inject: unknown[] }).inject; + + expect(inject).toContainEqual(OPTIONAL_HOOK_RESOLVER_INJECT); + }); + + it('factory useFactory should call custom factory and wrap result', () => { + const mockRepo = { + target: TestEntity, + metadata: { name: 'TestEntity', targetName: 'TestEntity', columns: [] }, + } as unknown as Repository; + const mockFactory = vi.fn().mockReturnValue(mockRepo); + + const options: TypeOrmProviderOptionsInterface = { + key: 'test-entity', + entity: TestEntity, + factory: mockFactory, + }; + + const provider = createTypeOrmProvider(options); + const useFactory = ( + provider as { useFactory: (ds: DataSource) => unknown } + ).useFactory; + const mockDataSource = {} as DataSource; + + const result = useFactory(mockDataSource); + + expect(mockFactory).toHaveBeenCalledWith(mockDataSource); + expect(result).toBeInstanceOf(TypeOrmRepository); + }); + + it('standard useFactory should create repository with hookResolver', () => { + const options: TypeOrmProviderOptionsInterface = { + key: 'test-entity', + entity: TestEntity, + }; + + const provider = createTypeOrmProvider(options); + const useFactory = ( + provider as { + useFactory: ( + repo: Repository, + hookResolver?: HookResolverService, + ) => unknown; + } + ).useFactory; + + const mockRepo = { + target: TestEntity, + metadata: { name: 'TestEntity', targetName: 'TestEntity', columns: [] }, + } as unknown as Repository; + const mockHookResolver = {} as HookResolverService; + + const result = useFactory(mockRepo, mockHookResolver); + + expect(result).toBeInstanceOf(TypeOrmRepository); + }); + + it('standard useFactory should work without hookResolver', () => { + const options: TypeOrmProviderOptionsInterface = { + key: 'test-entity', + entity: TestEntity, + }; + + const provider = createTypeOrmProvider(options); + const useFactory = ( + provider as { + useFactory: ( + repo: Repository, + hookResolver?: HookResolverService, + ) => unknown; + } + ).useFactory; + + const mockRepo = { + target: TestEntity, + metadata: { name: 'TestEntity', targetName: 'TestEntity', columns: [] }, + } as unknown as Repository; + + const result = useFactory(mockRepo, undefined); + + expect(result).toBeInstanceOf(TypeOrmRepository); + }); + + it('factory useFactory should pass hookResolver to repository', () => { + const mockRepo = { + target: TestEntity, + metadata: { name: 'TestEntity', targetName: 'TestEntity', columns: [] }, + } as unknown as Repository; + const mockFactory = vi.fn().mockReturnValue(mockRepo); + + const options: TypeOrmProviderOptionsInterface = { + key: 'test-entity', + entity: TestEntity, + factory: mockFactory, + }; + + const provider = createTypeOrmProvider(options); + const useFactory = ( + provider as { + useFactory: ( + ds: DataSource, + hookResolver?: HookResolverService, + ) => unknown; + } + ).useFactory; + const mockDataSource = {} as DataSource; + const mockHookResolver = {} as HookResolverService; + + const result = useFactory(mockDataSource, mockHookResolver); + + expect(result).toBeInstanceOf(TypeOrmRepository); + }); + + it('factory useFactory should work without hookResolver', () => { + const mockRepo = { + target: TestEntity, + metadata: { name: 'TestEntity', targetName: 'TestEntity', columns: [] }, + } as unknown as Repository; + const mockFactory = vi.fn().mockReturnValue(mockRepo); + + const options: TypeOrmProviderOptionsInterface = { + key: 'test-entity', + entity: TestEntity, + factory: mockFactory, + }; + + const provider = createTypeOrmProvider(options); + const useFactory = ( + provider as { + useFactory: ( + ds: DataSource, + hookResolver?: HookResolverService, + ) => unknown; + } + ).useFactory; + const mockDataSource = {} as DataSource; + + const result = useFactory(mockDataSource, undefined); + + expect(result).toBeInstanceOf(TypeOrmRepository); + }); + }); + + describe('getTypeOrmImports', () => { + it('should return TypeOrmModule.forFeature for default data source', () => { + const entities: TypeOrmProviderOptionsInterface[] = [ + { key: 'test', entity: TestEntity }, + ]; + + const imports = getTypeOrmImports(entities); + + expect(imports).toHaveLength(1); + expect(imports[0]).toHaveProperty('module'); + }); + + it('should group entities by data source', () => { + class Entity1 { + id!: string; + } + class Entity2 { + id!: string; + } + class Entity3 { + id!: string; + } + + const entities: TypeOrmProviderOptionsInterface[] = [ + { key: 'e1', entity: Entity1 }, + { key: 'e2', entity: Entity2, dataSource: 'secondary' }, + { key: 'e3', entity: Entity3 }, + ]; + + const imports = getTypeOrmImports(entities); + + // Should have 2 imports: one for default, one for secondary + expect(imports).toHaveLength(2); + }); + + it('should return empty array for empty entities', () => { + const imports = getTypeOrmImports([]); + expect(imports).toHaveLength(0); + }); + }); + + describe('createTransactionFactoryDescriptor', () => { + it('should create descriptor for default data source', () => { + const descriptor = createTransactionFactoryDescriptor(); + + expect(descriptor.key).toBe( + `typeorm:${TYPEORM_DEFAULT_DATA_SOURCE_NAME}`, + ); + expect(descriptor.inject).toContain(getDataSourceToken()); + expect(descriptor.useFactory).toBeInstanceOf(Function); + }); + + it('should create descriptor for named data source', () => { + const descriptor = createTransactionFactoryDescriptor('secondary'); + + expect(descriptor.key).toBe('typeorm:secondary'); + expect(descriptor.inject).toContain(getDataSourceToken('secondary')); + }); + + it('useFactory should return TypeOrmTransactionFactory', () => { + const descriptor = createTransactionFactoryDescriptor(); + const mockDataSource = mockDeep(); + + const factory = descriptor.useFactory(mockDataSource); + + expect(factory).toBeInstanceOf(TypeOrmTransactionFactory); + }); + }); +}); diff --git a/packages/nestjs-repository-typeorm/src/typeorm-repository.util.ts b/packages/nestjs-repository-typeorm/src/typeorm-repository.util.ts new file mode 100644 index 000000000..555a4a056 --- /dev/null +++ b/packages/nestjs-repository-typeorm/src/typeorm-repository.util.ts @@ -0,0 +1,210 @@ +import { type DataSource, type Repository } from 'typeorm'; + +import { + type DynamicModule, + type PlainLiteralObject, + type Provider, +} from '@nestjs/common'; +import { + getDataSourceToken, + getRepositoryToken, + TypeOrmModule, +} from '@nestjs/typeorm'; +import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type.js'; + +import { HookResolverService } from '@concepta/nestjs-core'; +import { + getDynamicRepositoryToken, + type RelationActionConfig, + TransactionScope, +} from '@concepta/nestjs-repository'; + +import { type TypeOrmProviderOptionsInterface } from './repository/typeorm-provider-options.interface.js'; +import { TypeOrmRepository } from './repository/typeorm-repository.js'; +import { TypeOrmTransactionFactory } from './transaction/typeorm-transaction.factory.js'; +import { TYPEORM_DEFAULT_DATA_SOURCE_NAME } from './typeorm-repository.constants.js'; +import { type TypeOrmDataSourceToken } from './typeorm-repository.types.js'; + +/** + * Resolve data source name from token. + */ +export function resolveDataSourceName( + dataSource?: TypeOrmDataSourceToken, +): string { + if (!dataSource) { + return TYPEORM_DEFAULT_DATA_SOURCE_NAME; + } + return typeof dataSource === 'string' + ? dataSource + : (dataSource.name ?? TYPEORM_DEFAULT_DATA_SOURCE_NAME); +} + +/** + * Resolve transaction key for a data source. + */ +export function resolveTransactionKey( + dataSource?: TypeOrmDataSourceToken, +): string { + return `typeorm:${resolveDataSourceName(dataSource)}`; +} + +/** + * Resolve TypeORM token name from data source name. + */ +export function resolveTokenName(dsName?: string): string | undefined { + return dsName === TYPEORM_DEFAULT_DATA_SOURCE_NAME ? undefined : dsName; +} + +/** + * Create a TypeOrmRepository instance. + */ +export function createTypeOrmRepository( + repo: Repository, + entityKey: string, + dataSource?: string, + hookResolver?: HookResolverService, + relationsConfig?: Record, + transactionScope?: TransactionScope, +): TypeOrmRepository { + return new TypeOrmRepository(repo, { + entityKey, + transactionKey: resolveTransactionKey(dataSource), + hookResolver, + relationsConfig, + transactionScope, + }); +} + +/** + * Injection token for optional HookResolverService. + * Using this constant allows NestJS to inject undefined when HookResolverService is not available. + */ +export const OPTIONAL_HOOK_RESOLVER_INJECT = { + token: HookResolverService, + optional: true, +}; + +/** + * Injection token for optional TransactionScope. + * `RepositoryModule.forRoot()` provides it globally, but repository + * providers must still resolve cleanly for hand-wired/test usage that + * doesn't import it. + */ +export const OPTIONAL_TRANSACTION_SCOPE_INJECT = { + token: TransactionScope, + optional: true, +}; + +/** + * Create a NestJS provider for an entity. + */ +export function createTypeOrmProvider( + options: TypeOrmProviderOptionsInterface, +): Provider { + const { key, entity, dataSource, factory, relations } = options; + const dsName = resolveDataSourceName(dataSource); + const dsToken = resolveTokenName(dsName); + + if (factory) { + return { + provide: getDynamicRepositoryToken(key), + inject: [ + getDataSourceToken(dsToken), + OPTIONAL_HOOK_RESOLVER_INJECT, + OPTIONAL_TRANSACTION_SCOPE_INJECT, + ], + useFactory: ( + ds: DataSource, + hookResolver?: HookResolverService, + transactionScope?: TransactionScope, + ) => { + return createTypeOrmRepository( + factory(ds), + key, + dsName, + hookResolver, + relations, + transactionScope, + ); + }, + }; + } else { + return { + provide: getDynamicRepositoryToken(key), + inject: [ + getRepositoryToken(entity, dsToken), + OPTIONAL_HOOK_RESOLVER_INJECT, + OPTIONAL_TRANSACTION_SCOPE_INJECT, + ], + useFactory: ( + repo: Repository, + hookResolver?: HookResolverService, + transactionScope?: TransactionScope, + ) => { + return createTypeOrmRepository( + repo, + key, + dsName, + hookResolver, + relations, + transactionScope, + ); + }, + }; + } +} + +/** + * Get TypeORM module imports for entities. + */ +export function getTypeOrmImports( + entities: readonly TypeOrmProviderOptionsInterface[], +): DynamicModule[] { + // Group entities by data source for TypeORM imports + const entitiesByDataSource: Record = {}; + + for (const entityOption of entities) { + const dsName = resolveDataSourceName(entityOption.dataSource); + + if (!(dsName in entitiesByDataSource)) { + entitiesByDataSource[dsName] = []; + } + + entitiesByDataSource[dsName].push(entityOption.entity); + } + + const imports: DynamicModule[] = []; + + for (const dsName in entitiesByDataSource) { + imports.push( + TypeOrmModule.forFeature( + entitiesByDataSource[dsName], + resolveTokenName(dsName), + ), + ); + } + + return imports; +} + +/** + * Create a transaction factory descriptor for a data source. + * RepositoryModule handles the actual registration. + * + * @param dataSource - Optional data source name + * @returns Transaction factory descriptor + */ +export function createTransactionFactoryDescriptor(dataSource?: string): { + key: string; + inject: ReturnType[]; + useFactory: (ds: DataSource) => TypeOrmTransactionFactory; +} { + const dsName = resolveDataSourceName(dataSource); + const dsToken = resolveTokenName(dsName); + + return { + key: resolveTransactionKey(dataSource), + inject: [getDataSourceToken(dsToken)], + useFactory: (ds: DataSource) => new TypeOrmTransactionFactory(ds), + }; +} diff --git a/packages/nestjs-repository-typeorm/tsconfig.json b/packages/nestjs-repository-typeorm/tsconfig.json new file mode 100644 index 000000000..f62d1f578 --- /dev/null +++ b/packages/nestjs-repository-typeorm/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", + "typeRoots": [ + "./node_modules/@types", + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/nestjs-auth-jwt/typedoc.json b/packages/nestjs-repository-typeorm/typedoc.json similarity index 100% rename from packages/nestjs-auth-jwt/typedoc.json rename to packages/nestjs-repository-typeorm/typedoc.json diff --git a/packages/nestjs-repository/README.md b/packages/nestjs-repository/README.md new file mode 100644 index 000000000..922f9347b --- /dev/null +++ b/packages/nestjs-repository/README.md @@ -0,0 +1,1183 @@ +# @concepta/nestjs-repository + +Repository abstraction module for NestJS. Provides a driver-agnostic +`RepositoryAdapter` base class, transaction management with automatic +nesting, and a two-level repository hook system. + +## Project + +[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-repository)](https://www.npmjs.com/package/@concepta/nestjs-repository) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-repository)](https://www.npmjs.com/package/@concepta/nestjs-repository) +[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) +[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-repository%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +## Table of Contents + +- [Installation](#installation) +- [Module Registration](#module-registration) +- [Architecture Overview](#architecture-overview) +- [Repository Adapter](#repository-adapter) +- [Relations and Joins](#relations-and-joins) +- [Where Clause Builder](#where-clause-builder) +- [Order Clause Builder](#order-clause-builder) +- [Transaction Management](#transaction-management) +- [Transactional Decorator](#transactional-decorator) +- [Repository Hooks](#repository-hooks) +- [Repository Registry](#repository-registry) +- [Federation](#federation) +- [Injecting Repositories](#injecting-repositories) +- [Exceptions](#exceptions) +- [Entry Points](#entry-points) + +## Installation + +```sh +yarn add @concepta/nestjs-repository @nestjs/common @nestjs/core rxjs +``` + +### Requirements + +ESM-only — no CJS build is published. Requires Node `>= 22.12` and +NestJS 12. + +### Dependencies + +| Package | Notes | +| --- | --- | +| `@concepta/nestjs-core` | Core interfaces, hook system, and utilities | +| `@tsyche/membrane` | Hook pipeline (`Permeator`/`Membrane`) — ^0.7.0 | + +### Peer Dependencies + +| Package | Required | Notes | +| --- | --- | --- | +| `@nestjs/common` | Yes | NestJS core — install explicitly, no longer bundled | +| `@nestjs/core` | Yes | Reflector for metadata — install explicitly | +| `rxjs` | Yes | Used by `TransactionalRunner` and interceptor | + +## Module Registration + +### forRoot + +`forRoot()` registers the module **globally** and sets up the transaction +infrastructure (factory registry, scope, runner, interceptor). + +```ts +import { RepositoryModule } from '@concepta/nestjs-repository'; + +@Module({ + imports: [ + RepositoryModule.forRoot({ + defaultTimeout: 30000, // transaction timeout in ms (default) + }), + ], +}) +export class AppModule {} +``` + +### forRootAsync + +```ts +@Module({ + imports: [ + RepositoryModule.forRootAsync({ + useFactory: async (configService: ConfigService) => ({ + defaultTimeout: configService.get('TX_TIMEOUT', 30000), + }), + inject: [ConfigService], + }), + ], +}) +export class AppModule {} +``` + +### forFeature + +`forFeature()` registers repository providers for specific entities. It +delegates to the driver module's own `forFeature()` method and automatically +registers entities in the repository registry and transaction factories. + +```ts +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +@Module({ + imports: [ + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: 'orders', entity: Order }, + { key: 'customers', entity: Customer }, + ], + }), + ], +}) +export class OrderModule {} +``` + +Each entity registration creates a dynamic repository provider that can be +injected by key using `@InjectDynamicRepository()`. + +### Settings + +```ts +interface RepositoryModuleOptionsInterface { + defaultTimeout?: number; // Transaction timeout in milliseconds (default: 30000) +} +``` + +## Architecture Overview + +```text +Application Code + | +RepositoryModule (forRoot / forFeature) + | + +-- RepositoryAdapter (abstract, driver-agnostic) + | Concrete implementations: TypeOrmRepository, etc. + | + +-- Transaction Layer + | TransactionScope -> TransactionManager -> TransactionFactory + | + +-- Hook System + | @RepoHook + @BeforeCreate / @AfterFind / etc. + | + +-- Registry + RepositoryRegistryService (duplicate key detection at bootstrap) +``` + +- **RepositoryAdapter** -- abstract base class implementing + `RepositoryInterface` with query, create, update, delete, and lifecycle + operations +- **Transaction Layer** -- `TransactionScope` orchestrates transaction + lifecycle with automatic nesting; `TransactionManager` manages active + transactions for one scope, shared by every participant via a refcount; + factories are registered per driver/datasource +- **Hook System** -- two-level decorators (high-level semantic + fine-grained) + for cross-cutting concerns like auditing, tenant filtering, and validation +- **Registry** -- validates at application bootstrap that no duplicate + repository keys exist across features + +## Repository Adapter + +`RepositoryAdapter` is the abstract base class that all driver-specific +repository implementations extend. It implements `RepositoryInterface` with +a template-method design: the public operations (`find`, `create`, `update`, +etc.) are concrete wrappers that run the hook pipeline, each delegating to a +protected abstract `do*` method that the driver implements. + +### Abstract Members + +Concrete implementations must provide these protected `do*` methods, plus +the abstract `transform`/`merge` utilities and the `metadata` property: + +| Category | Method | Signature | +| --- | --- | --- | +| Query | `doFind` | `(options?) => Promise` | +| Query | `doFindOne` | `(options) => Promise` | +| Query | `doCount` | `(options?) => Promise` | +| Query | `doFindAndCount` | `(options?) => Promise<[Entity[], number]>` | +| Create | `doCreate` | `(entity, options?) => Promise` | +| Create | `doCreateMany` | `(entities, options?) => Promise` | +| Update | `doUpdate` | `(entity, data, options?) => Promise` | +| Update | `doUpsert` | `(entity, options?) => Promise` | +| Update | `doReplace` | `(entity, data, options?) => Promise` | +| Delete | `doDelete` | `(entity, options?) => Promise` | +| Delete | `doDeleteMany` | `(entities, options?) => Promise` | +| Delete | `doSoftDelete` | `(entity, options?) => Promise` | +| Lifecycle | `doRestore` | `(entity, options?) => Promise` | +| Utility | `transform` | `(entityLike) => Entity` | +| Utility | `merge` | `(mergeIntoEntity, ...entityLikes) => Entity` | +| Utility | `metadata` | `RepositoryMetadataInterface` (abstract property) | + +### Concrete Members + +The public `find`, `findOne`, `count`, `findAndCount`, `create`, +`createMany`, `update`, `upsert`, `replace`, `delete`, `deleteMany`, +`softDelete`, and `restore` methods are concrete — each runs the +[hook pipeline](#hook-pipeline) around the matching `do*` method. + +| Member | Visibility | Description | +| --- | --- | --- | +| `prepare(dto)` | public | Returns `dto` unchanged if it is already an entity instance, otherwise `Object.assign(new entityType(), dto)` | +| `getPrimaryColumns()` | protected | Get primary key column names from metadata (subclass-author API) | +| `getVersionColumn()` | protected | Get the optimistic-locking version column name from metadata, if any (subclass-author API) | +| `toDnf(clause)` | protected | Convert `WhereClause` AST to Disjunctive Normal Form (subclass-author API) | +| `runHooks(methodKey, payload, ctx)` | protected | Execute repository hooks for a lifecycle event (subclass-author API) | +| `resolveJoinClauses(join?)` | protected | Resolve structural join properties from relation metadata (subclass-author API) | + +### Implementing a Repository + +```ts +import { RepositoryAdapter } from '@concepta/nestjs-repository'; + +class MyDriverRepository extends RepositoryAdapter { + readonly metadata = { /* ... */ }; + + protected async doFind(options?) { + return this.repo.find(options); + } + + protected async doCreate(entity, options?) { + return this.repo.save(entity); + } + + // ... implement the remaining do* methods, transform, and merge +} +``` + +Each entry in `metadata.columns` must supply `name`, `isPrimary`, +`isRemoveDate`, and `isVersion`. Set `isVersion: true` on the +optimistic-locking version column, if the driver has one — `getVersionColumn()` +reads it. Adapters that leave it `false` everywhere simply get no +optimistic-locking support. + +## Relations and Joins + +Repository find options accept a `join` array of `JoinClause` entries to load +related entities alongside the root query. + +### JoinClause + +Each `JoinClause` describes how to join a related entity: + +```ts +interface JoinClause { + relation: string; // relation name (must match entity metadata) + joinType?: 'LEFT' | 'INNER'; // default: 'LEFT' +} +``` + +Structural properties (`on`, `through`, `cardinality`) are resolved +automatically from entity relation metadata by the adapter (via the +protected `resolveJoinClauses()`). + +### Join Helper + +The `Join` helper builds `JoinClause` arrays: + +```ts +import { Join } from '@concepta/nestjs-repository'; + +// Load a single relation (LEFT join by default) +const [users, total] = await userRepo.findAndCount({ + ...Join.join(Join.left('company')), +}); +// users[0].company → Company | null + +// Multiple relations with different join types +const [users, total] = await userRepo.findAndCount({ + ...Join.join( + Join.left('posts'), + Join.inner('company'), + ), +}); + +// Many-to-many (junction configured in relation metadata) +const [users, total] = await userRepo.findAndCount({ + ...Join.join(Join.left('roles')), +}); +``` + +### Join Methods + +| Method | Description | +| --- | --- | +| `left(relation)` | LEFT JOIN (default — includes rows with no match) | +| `inner(relation)` | INNER JOIN (excludes rows with no match) | +| `join(...clauses)` | Wrap join clauses into `{ join: clauses }` for passing to `find()` | + +### Filtering by Relations + +Use `Where.rel()` to filter by fields on a related entity. The relation +must be included in the join: + +```ts +const w = Where.for(); + +const [users, total] = await userRepo.findAndCount({ + ...Join.join(Join.left('posts')), + ...w.where( + w.and( + w.eq('status', 'active'), + w.rel('posts', Where.eq('published', true)), + ), + ), +}); +``` + +### Sorting by Relations + +Use `OrderBy.rel()` to sort by fields on a related entity: + +```ts +const o = OrderBy.for(); + +const [users, total] = await userRepo.findAndCount({ + ...Join.join(Join.left('posts')), + ...o.order( + o.rel('posts', OrderBy.desc('createdAt')), + o.asc('name'), + ), +}); +``` + +### Relation Metadata + +Relation metadata is populated automatically by the ORM driver (e.g., +`TypeOrmRepository` reads TypeORM's `RelationMetadata`). You can also +configure per-relation behavior in `forFeature()`: + +```ts +RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [{ + key: 'users', + entity: UserEntity, + relations: { + posts: { federated: true }, // use separate queries + company: { onDelete: 'delegate' }, // defer to DB cascade settings + }, + }], +}); +``` + +Relations marked `federated: true` use separate queries instead of SQL +JOINs. See [Federation](#federation) for details. + +## Where Clause Builder + +The `Where` helper builds ORM-agnostic +`WhereClause` AST objects that `RepositoryAdapter` implementations translate +into driver-specific queries. + +### How Translation Works + +1. The `Where` helper builds a `WhereClause` AST (tree of conditions and + compound operators) +2. `RepositoryAdapter.toDnf()` flattens the AST into Disjunctive Normal Form + (an OR of ANDs) +3. The concrete driver (e.g., `TypeOrmRepository`) translates each AND-branch + into a driver-specific query object +4. Same-field conditions within a branch are merged (e.g., `gt` + `lt` on the + same field become a combined range) + +### Static API + +Pass the entity type as a generic parameter on each call: + +```ts +import { Where } from '@concepta/nestjs-repository'; + +// Simple equality +const activeOrders = await orderRepo.find( + Where.where(Where.eq('status', 'active')), +); + +// Compound conditions +const result = await orderRepo.find( + Where.where( + Where.and( + Where.eq('status', 'active'), + Where.gt('total', 100), + Where.contains('notes', 'urgent'), + ), + ), +); + +// OR conditions +const result = await orderRepo.find( + Where.where( + Where.or( + Where.eq('status', 'shipped'), + Where.eq('status', 'delivered'), + ), + ), +); +``` + +### Typed Builder API + +Bind the entity type once with `Where.for()`. All subsequent calls +type-check field names against the entity: + +```ts +import { Where } from '@concepta/nestjs-repository'; + +const w = Where.for(); + +// Simple query +const orders = await orderRepo.find( + w.where(w.eq('status', 'active')), +); + +// Nested AND/OR +const orders = await orderRepo.find( + w.where( + w.and( + w.eq('status', 'active'), + w.or( + w.gte('total', 1000), + w.contains('notes', 'priority'), + ), + ), + ), +); + +// Null checks and range +const orders = await orderRepo.find( + w.where( + w.and( + w.notNull('assigneeId'), + w.between('total', 100, 500), + ), + ), +); + +// Set membership +const orders = await orderRepo.find( + w.where( + w.in('status', ['pending', 'processing', 'shipped']), + ), +); + +// Pattern matching +const orders = await orderRepo.find( + w.where( + w.and( + w.starts('sku', 'ELEC-'), + w.notContains('notes', 'cancelled'), + ), + ), +); +``` + +### Relation Conditions + +Use `rel()` to tag a condition with a relation name. The condition is applied +as a filter on the related entity (see [Filtering by Relations](#filtering-by-relations)): + +```ts +const w = Where.for(); + +// Filter orders by customer tier +const orders = await orderRepo.findAndCount({ + ...Join.join(Join.left('customer')), + ...w.where( + w.and( + w.eq('status', 'active'), + w.rel('customer', Where.eq('tier', 'gold')), + ), + ), +}); +``` + +### Condition Operators + +| Method | Description | +| --- | --- | +| `eq(field, value)` | Equal | +| `ne(field, value)` | Not equal | +| `gt(field, value)` | Greater than | +| `gte(field, value)` | Greater than or equal | +| `lt(field, value)` | Less than | +| `lte(field, value)` | Less than or equal | +| `contains(field, value)` | Contains substring | +| `notContains(field, value)` | Does not contain substring | +| `starts(field, value)` | Starts with | +| `notStarts(field, value)` | Does not start with | +| `ends(field, value)` | Ends with | +| `notEnds(field, value)` | Does not end with | +| `in(field, values)` | In array | +| `notIn(field, values)` | Not in array | +| `isNull(field)` | Is null | +| `notNull(field)` | Is not null | +| `between(field, from, to)` | Between range (inclusive) | + +### Compound Operators + +| Method | Description | +| --- | --- | +| `and(...conditions)` | All conditions must match | +| `or(...conditions)` | Any condition must match | + +### Utility Methods + +| Method | Description | +| --- | --- | +| `where(clause)` | Wrap a `WhereClause` into `{ where: clause }` for passing to `find()` | +| `rel(relation, condition)` | Tag a condition with a relation name | +| `for()` | Create a typed builder with field name checking | + +## Order Clause Builder + +The `OrderBy` helper builds ORM-agnostic +`OrderClause` arrays that `RepositoryAdapter` implementations translate +into driver-specific sort options. + +### Static OrderBy API + +Pass the entity type as a generic parameter on each call: + +```ts +import { OrderBy } from '@concepta/nestjs-repository'; + +// Single sort +const users = await userRepo.find( + OrderBy.order(OrderBy.asc('name')), +); + +// Multiple sorts (priority follows array order) +const users = await userRepo.find( + OrderBy.order( + OrderBy.desc('createdAt'), + OrderBy.asc('name'), + ), +); +``` + +### Typed OrderBy Builder API + +Bind the entity type once with `OrderBy.for()`. All subsequent calls +type-check field names against the entity: + +```ts +import { OrderBy } from '@concepta/nestjs-repository'; + +const o = OrderBy.for(); + +const users = await userRepo.find( + o.order(o.desc('createdAt'), o.asc('name')), +); +``` + +### Relation Sorting + +Use `rel()` to sort by a field on a related entity (see +[Sorting by Relations](#sorting-by-relations)): + +```ts +// Sort users by post title, then by creation date +const users = await userRepo.findAndCount({ + ...Join.join(Join.left('posts')), + ...OrderBy.order( + OrderBy.rel('posts', OrderBy.asc('title')), + OrderBy.desc('createdAt'), + ), +}); +``` + +### Sort Methods + +| Method | Description | +| --- | --- | +| `asc(field)` | Ascending sort | +| `desc(field)` | Descending sort | + +### OrderBy Utility Methods + +| Method | Description | +| --- | --- | +| `order(...keys)` | Wrap sort keys into `{ order: keys }` for passing to `find()` | +| `rel(relation, key)` | Tag a sort key with a relation name | +| `relDot(dotField, key)` | Extract relation from `"relation.field"` dot notation | +| `for()` | Create a typed builder with field name checking | + +### Combining Where + OrderBy + +Spread both helpers into find options: + +```ts +const w = Where.for(); +const o = OrderBy.for(); + +const orders = await orderRepo.find({ + ...w.where(w.eq('status', 'active')), + ...o.order(o.desc('createdAt')), +}); +``` + +### Passing Context + +All repository methods accept an optional `ctx` property in their options. +The `ctx` is a `PlainLiteralObject` that carries the entity key, +transaction state, and hook configuration. When `ctx` has an active `trx` +(TransactionManager), the repository automatically uses the transactional +connection — no manual wiring required. + +Spread `Where.where()` into options alongside `ctx`: + +```ts +const w = Where.for(); + +// Query within a transaction +const orders = await orderRepo.find({ + ...w.where(w.eq('status', 'active')), + ctx, +}); + +// Create within a transaction +const order = await orderRepo.create(dto, { ctx }); + +// Nested service calls share the same transaction via ctx +await this.txScope.run(ctx, async (txCtx) => { + const orders = await orderRepo.find({ + ...w.where(w.gt('total', 100)), + ctx: txCtx, + }); + await auditRepo.create( + { action: 'query', count: orders.length }, + { ctx: txCtx }, + ); +}); +``` + +The `ctx` is propagated through nested `TransactionScope.run()` calls. Inner +calls join the outer transaction automatically. Pass the `txCtx` handed to +the operation — not the outer `ctx` — to repository calls inside it. See +[Transaction Management](#transaction-management) for details. + +## Transaction Management + +The transaction layer provides automatic transaction lifecycle management +with automatic nesting support. + +### TransactionScope + +`TransactionScope` is the primary API for running operations within +transactions. It is provided globally by `RepositoryModule.forRoot()`. + +```ts +import { TransactionScope } from '@concepta/nestjs-repository'; + +@Injectable() +export class OrderService { + constructor(private readonly txScope: TransactionScope) {} + + async createOrder(ctx: PlainLiteralObject, dto: DeepPartial) { + return this.txScope.run(ctx, async (txCtx) => { + // Pass txCtx, not ctx, to repository calls inside the operation + const order = await orderRepo.create(dto, { ctx: txCtx }); + const item = await inventoryRepo.findOne({ + where: { id: order.itemId }, + ctx: txCtx, + }); + await inventoryRepo.update(item, { reserved: true }, { ctx: txCtx }); + + // Register post-commit callback + txCtx.trx.onCommit(() => { + // Send confirmation email after successful commit + }); + + return order; + }); + } +} +``` + +### Domain Events with mergeObjectContext + +When using DDD aggregates that extend `AggregateRoot` from `@nestjs/cqrs`, +use `EventPublisher.mergeObjectContext()` to wire up event publishing, then +register `commit()` and `uncommit()` as post-commit/rollback callbacks. +This ensures domain events are only published after the transaction succeeds. + +```ts +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +@CommandHandler(CreateOrderCommand) +export class CreateOrderHandler implements ICommandHandler { + constructor( + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + private readonly repositoryResolver: OrderRepositoryResolver, + ) {} + + async execute(command: CreateOrderCommand): Promise { + const { ctx, namespace, dto } = command; + + const orderRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const order = this.eventPublisher.mergeObjectContext( + Order.create(eventContext, dto), + ); + + await orderRepo.save(txCtx, order); + + txCtx.trx.onCommit(() => order.commit()); // publish domain events + txCtx.trx.onRollback(() => order.uncommit()); // discard domain events + + return order; + }); + } +} +``` + +```ts +// Read-only transaction (always rolls back). onRollback callbacks run +// (the scope did roll back); onCommit callbacks never run. +await this.txScope.runReadOnly(ctx, async (txCtx) => { + return orderRepo.find({ ctx: txCtx }); +}); + +// Custom timeout +await this.txScope.run(ctx, operation, { + timeout: 5000, +}); +``` + +### Nesting + +Nested and concurrent `run()` calls on the same context join a single +transaction, which commits or rolls back when the last participant exits — +not necessarily the first one to have entered (or immediately if any +participant times out). After that, the context carries no transaction +state — a later `run()` on it starts a fresh, independent transaction. + +```ts +// Outermost — creates transaction +await this.txScope.run(ctx, async (txCtx) => { + await serviceA.doWork(ctx); // joins existing transaction + await serviceB.doWork(ctx); // joins existing transaction +}); +// Transaction commits here (or rolls back on error) — ctx is now released + +// A later, unrelated run() on the same ctx starts an independent scope +await this.txScope.run(ctx, async (txCtx) => { + await serviceC.doWork(ctx); +}); +``` + +Inside the operation, prefer the `txCtx` handed to you over the outer `ctx` +for repository calls: a stale `txCtx` fails loudly with +`TransactionClosedException` once its scope has settled, while a stale +`ctx` silently falls back to running non-transactionally. Don't hold either +one past the point `run()` resolves, and don't spawn unawaited work inside +the operation — anything still running once the transaction settles writes +outside it either way. + +Because every participant shares one scope, a sibling's failure can doom +work that otherwise succeeded: + +- If a participant's own operation succeeds, but a nested or concurrent + sibling sharing the same scope fails, that participant's `run()` call + rejects with `TransactionScopeFailedException` (carrying the sibling's + error as `context.originalError`) rather than resolving as if nothing + happened — even if the caller caught the sibling's error itself. +- `readOnly` is decided once, by whichever `run()` call creates the scope — + every later participant just joins it. A nested or concurrent `run()` + (or `runReadOnly()`) call whose explicit `readOnly` option conflicts with + the scope it's joining throws `TransactionReadOnlyConflictException` + instead of silently discarding one side's intent. `timeout`, by contrast, + is honored per participant, not scope-wide. + +### TransactionManager + +`txCtx.trx` is a `TransactionManager` — the handle for registering +post-commit/rollback work and, for non-repository work, reading the +cancellation signal. + +| Method | Description | +| --- | --- | +| `onCommit(fn)` | Register post-commit callback; throws `TransactionClosedException` once the scope has closed | +| `onRollback(fn)` | Register post-rollback callback; a `readOnly` scope always rolls back, so these run whether or not its operation succeeded; throws `TransactionClosedException` once the scope has closed | +| `signal` | `AbortSignal` that aborts once the scope is doomed (an operation threw, or the final commit failed), carrying that failure as `signal.reason`. Stays unaborted for a scope that settles cleanly — it signals "doomed", not "settled" | + +`getOrStart(key)` also exists but is used by repository/driver internals — +application code doesn't call it directly. + +`onCommit`/`onRollback` callbacks flush one at a time, in registration +order, once the scope settles, so a later callback can rely on an earlier +one having fully finished first. A callback that throws is logged, not +rethrown — it doesn't fail `run()` and doesn't stop the callbacks after it +from running, so a callback that must not fail silently should handle its +own errors. + +#### Cancellation and timeouts + +`trx.signal` aborts as soon as the scope is doomed, so operations doing +non-repository work (an HTTP call, a queue publish) can opt in to stopping +early instead of running to completion against a transaction that's already +rolling back: + +```ts +await txScope.run(ctx, async (txCtx) => { + const res = await fetch(url, { signal: txCtx.trx.signal }); + ... +}); +``` + +This is cooperative — nothing here forcibly stops an operation that ignores +the signal. A timed-out `run()` rejects with `TransactionTimeoutException`; +an operation that outlives the timeout keeps running as an abandoned orphan, +and its eventual failure is logged rather than surfaced to the caller, who +has long since moved on. A timeout settles the scope immediately rather +than waiting for every participant to exit, so a sibling still running — +nested or concurrent, even one well within its own timeout — gets +`TransactionClosedException` from its `txCtx`, and its `run()` rejects with +`TransactionScopeFailedException` even if its own operation goes on to +succeed. + +The timeout covers the operation, not settlement — a `commit()` or +`rollback()` call that hangs (a dead connection, a lock wait) is not +bounded by it and can leave `run()` unresolved indefinitely. There is no +safe way to abandon an in-flight commit without knowing whether it landed. + +#### Multiple datasources + +A single `run()` scope can span transactions on more than one datasource. +Commits are sequential, not two-phase: if one fails after another has +already committed, `run()` rejects with `TransactionHeuristicCommitException` +instead of an ordinary commit error, since that earlier commit can't be +undone. `onRollback` callbacks run in that case — never `onCommit` — even +for the datasource(s) that durably committed, so domain events registered +via `onCommit(() => agg.commit())` are treated as not-yet-safe-to-publish. + +### TransactionFactory + +Each driver/datasource provides a `TransactionFactoryInterface`: + +```ts +interface TransactionFactoryInterface { + create(): TransactionInterface; +} +``` + +Factories are registered automatically when using `RepositoryModule.forFeature()` +with a driver module that returns `transactionFactories` in its +`DynamicRepositoryModule`. + +## Transactional Decorator + +The `@Transactional()` decorator wraps controller routes in transactions +declaratively. It can be applied at the class level (all routes) or method +level (individual routes). + +```ts +import { Transactional } from '@concepta/nestjs-repository'; + +@Controller('orders') +@Transactional() +export class OrderController { + @Post() + async create(@Body() dto: DeepPartial) { + // Runs in a transaction + } + + // Disable transaction for this route + @Get() + @Transactional(false) + async list() { + // No transaction + } + + // Read-only transaction + @Get(':id') + @Transactional({ readOnly: true }) + async read(@Param('id') id: string) { + // Read-only transaction (always rolls back) + } +} +``` + +### Options + +```ts +interface TransactionalOptions { + readOnly?: boolean; + timeout?: number; // milliseconds (default: 30000) +} +``` + +- **`readOnly`** -- always roll back, for read-only operations (default: `false`) +- **`timeout`** -- transaction timeout in milliseconds + +Method-level `@Transactional()` overrides class-level settings. +Pass `false` to disable transactions for a specific method. + +### TransactionalRunner + +`TransactionalRunner` is used internally by `TransactionInterceptor` to +check for `@Transactional()` metadata and wrap operations. It can also be +used directly in custom interceptors: + +```ts +import { TransactionalRunner } from '@concepta/nestjs-repository'; + +@Injectable() +export class CustomInterceptor implements NestInterceptor { + constructor(private readonly txRunner: TransactionalRunner) {} + + intercept(context: ExecutionContext, next: CallHandler) { + return this.txRunner.run(context, () => next.handle()); + } +} +``` + +### Detecting `@Transactional()` + +`isTransactional()` and `getTransactionalOptions()` read the metadata +`@Transactional()` sets, without depending on the underlying metadata key — +useful for building tooling (route audits, OpenAPI generation, custom +interceptors) that needs to know whether a route is transactional: + +```ts +import { getTransactionalOptions, isTransactional } from '@concepta/nestjs-repository'; + +// Pass targets in override order (e.g. handler before class), same as +// Nest's own Reflector.getAllAndOverride — the first target that carries +// the metadata wins. +isTransactional(context.getHandler(), context.getClass()); // boolean + +// Resolve the options themselves (undefined if none, false if explicitly +// disabled with `@Transactional(false)`) +getTransactionalOptions(context.getHandler(), context.getClass()); +``` + +## Repository Hooks + +The hook system provides cross-cutting concerns for repository operations. +Hooks are resolved at runtime via `@concepta/nestjs-core` and can be scoped +to specific entities using specifications. + +### Defining a Hook + +```ts +import { + RepoHook, + BeforeFind, + AfterCreate, +} from '@concepta/nestjs-repository'; + +@RepoHook() +export class AuditHook { + @BeforeFind() + addTenantFilter(options, ctx) { + // Modify query options before find + return { ...options, where: { ...options.where, tenantId: ctx.tenantId } }; + } + + @AfterCreate() + logCreation(entity, ctx) { + // React to entity creation + return entity; + } +} +``` + +### Scoped Hooks + +Use specifications to restrict a hook to specific entities: + +```ts +import { RepoHook, RepoSpec, AfterCreate } from '@concepta/nestjs-repository'; + +@RepoHook(RepoSpec.isEntity('User')) +export class UserOnlyHook { + @AfterCreate() + notifyUserCreated(result, ctx) { + // Only runs for User entity operations + return result; + } +} +``` + +`RepoSpec.isEntity(name)` builds an `EntitySpecification` (also exported +for direct use) that matches when the repository's entity key equals `name`. + +### Hook Decorators + +Hooks are organized into two levels: high-level semantic decorators that +match broad categories, and fine-grained decorators for specific operations. + +#### High-Level Semantic + +| Decorator | Matches | +| --- | --- | +| `@BeforeRead` / `@AfterRead` | find, findOne, count, findAndCount | +| `@BeforeWrite` / `@AfterWrite` | create, createMany, update, upsert, replace | +| `@BeforeTransition` / `@AfterTransition` | softDelete, restore | +| `@BeforeDestroy` / `@AfterDestroy` | delete, deleteMany (hard delete) | + +#### Fine-Grained + +| Category | Decorators | +| --- | --- | +| Query | `@BeforeFind` `@AfterFind` `@BeforeFindOne` `@AfterFindOne` `@BeforeCount` `@AfterCount` `@BeforeFindAndCount` `@AfterFindAndCount` | +| Create | `@BeforeCreate` `@AfterCreate` `@BeforeCreateMany` `@AfterCreateMany` | +| Update | `@BeforeUpdate` `@AfterUpdate` `@BeforeUpsert` `@AfterUpsert` `@BeforeReplace` `@AfterReplace` | +| Delete | `@BeforeDelete` `@AfterDelete` `@BeforeDeleteMany` `@AfterDeleteMany` | +| Lifecycle | `@BeforeSoftDelete` `@AfterSoftDelete` `@BeforeRestore` `@AfterRestore` | + +Hook methods receive the operation payload and an optional context, and must +return the (possibly modified) payload. + +### Hook Pipeline + +Hook execution is orchestrated by `RepoPermeatorFactory`, built on +`@tsyche/membrane` (`Permeator`/`Membrane`). Each public repository +operation runs before-hooks on its input, calls the driver's `do*` method, +then runs after-hooks on the result, with one of two merge semantics: + +- **`overwrite`** -- read operations (`find`, `findOne`, `count`, + `findAndCount`) and `createMany`: hooks may freely transform options and + results. +- **`preserve`** -- single-entity write operations (`create`, `update`, + `upsert`, `replace`) and delete/lifecycle operations (`delete`, + `deleteMany`, `softDelete`, `restore`): the original/DB result wins over + hook mutations. + +Any error thrown inside the pipeline (a hook or the driver call) is wrapped +in `RepositoryQueryException`. `RuntimeException` subclasses — `OptimisticLockException`, +`FederationException`, the transaction exceptions, and any already-wrapped +`RepositoryQueryException` — pass through unchanged, so callers can catch +them by type and their `httpStatus` survives to the transport layer. + +Two `OverlayRef` tokens are exported for reading repository state from an +`AppContextHost` (via `ctx.with(ref)` or `@Ctx(ref)`): + +| Export | Description | +| --- | --- | +| `RepoCtx` | Overlay carrying the entity key in scope: `{ entity: string }` | +| `TrxCtx` | Overlay carrying the active `TransactionManager`: `{ trx }` | + +## Repository Registry + +`RepositoryRegistryService` validates at application bootstrap that no +duplicate repository keys exist across `forFeature()` calls. If duplicates +are found, it throws `RepositoryDuplicateKeyException` with details about +which keys conflict. + +```ts +// These two registrations would conflict at bootstrap: +RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [{ key: 'users', entity: UserEntity }], +}); + +RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [{ key: 'users', entity: AdminEntity }], // duplicate key! +}); +// Throws: Duplicate repository keys: "users" (registered for UserEntity, attempted for AdminEntity) +``` + +## Federation + +When a relation is marked `federated: true` (see +[Relation Metadata](#relation-metadata)), the `FederationOrchestrator` +intercepts `findAndCount` calls and executes **separate queries** for the +root entity and each relation instead of using SQL JOINs. Results are +hydrated together transparently. + +This is useful when: + +- JOINs produce expensive Cartesian products +- Relations live in different datasources +- Precise pagination control is needed (JOINs inflate row counts) + +### How It Works + +The caller uses the same `join`, `Where.rel()`, and `OrderBy.rel()` APIs +described in [Relations and Joins](#relations-and-joins). The orchestrator +analyzes the query and picks a strategy: + +| Strategy | When | Flow | +| --- | --- | --- | +| **ROOT_FIRST** | No relation filters or sorts | Query root → fetch relations in parallel → hydrate | +| **RELATION_FIRST** | Has relation filters or sorts | Query relations → discover root IDs → fetch constrained roots → hydrate | + +ROOT_FIRST is the common case: one root query plus one query per relation, +all relations fetched in parallel. + +RELATION_FIRST handles queries that filter or sort by relation fields. It +iteratively queries the driving relation to discover matching root entity +IDs, then fetches only those roots. + +### distinctFilter + +For many-cardinality federated relations that use sorts or filters, provide +a `distinctFilter` to ensure one relation entity per root. Without it, +sorting is non-deterministic and a filtered total counts matching relation +rows rather than distinct roots (e.g. two matching posts for one user would +report a total of 2 instead of 1). Missing it throws `FederationException`: + +```ts +relations: { + posts: { + federated: true, + distinctFilter: Where.eq('isPrimary', true), + }, +}, +``` + +### Constants + +| Constant | Default | Description | +| --- | --- | --- | +| `FEDERATION_DEFAULT_LIMIT` | 10 | Default page size when none specified | +| `FEDERATION_MAX_ITERATIONS` | 10 | Max iterations for relation-first constraint discovery | +| `FEDERATION_MAX_BUFFER_SIZE` | 1000 | Max offset before aborting iterative discovery | + +### Limitations + +- OR conditions across federated relations are not supported (throws + `FederationException`) +- Filtering or sorting by the owning side of a federated relation (the + side holding the foreign key, e.g. the many side of a `@ManyToOne`) is + not supported — only the non-owning side can drive relation-first + discovery (throws `FederationException`) +- Only `findAndCount` is federated; `find`, `findOne`, and `count` use + standard ORM queries + +## Injecting Repositories + +Use `@InjectDynamicRepository()` to inject +repositories registered via `forFeature()`: + +```ts +import { InjectDynamicRepository } from '@concepta/nestjs-repository'; + +@Injectable() +export class OrderService { + constructor( + @InjectDynamicRepository('orders') + private readonly orderRepo: RepositoryInterface, + ) {} + + async findAll() { + return this.orderRepo.find(); + } +} +``` + +The injection token is derived from the `key` provided in +`RepositoryProviderOptions` via `getDynamicRepositoryToken(key)`, which is +also exported for manual provider wiring. + +## Exceptions + +| Exception | Description | +| --- | --- | +| `RepositoryQueryException` | Wraps any opaque error thrown by a repository operation or its hook pipeline. `RuntimeException` subclasses (e.g. `OptimisticLockException`) pass through unwrapped | +| `OptimisticLockException` | An `update`/`replace` targeted a stale version — the row was modified by another request since it was read | +| `RepositoryDuplicateKeyException` | Duplicate repository keys detected at bootstrap | +| `TransactionTimeoutException` | Transaction exceeded timeout duration | +| `TransactionClosedException` | A settled scope was used again — `getOrStart`, `enter`, `onCommit`, or `onRollback` after close | +| `TransactionHeuristicCommitException` | A multi-datasource commit failed after at least one datasource had already committed | +| `TransactionReadOnlyConflictException` | A joining `run()`/`runReadOnly()` call's `readOnly` option conflicts with the scope it's joining | +| `TransactionScopeFailedException` | A participant's own operation succeeded, but its shared scope had already failed via a sibling | +| `FederationException` | Unsupported federated query (e.g., OR across federated relations) | + +## Entry Points + +| Import Path | Contents | +| --- | --- | +| `@concepta/nestjs-repository` | Module, adapter, repository interfaces, Where/OrderBy/Join builders, transaction management, hooks, federation, decorators, exceptions | +| `@concepta/nestjs-repository/testing` | `createMockTransaction`, `createMockRepository`, `MockTransactionHandle` | diff --git a/packages/nestjs-repository/package.json b/packages/nestjs-repository/package.json new file mode 100644 index 000000000..1a34cdbd5 --- /dev/null +++ b/packages/nestjs-repository/package.json @@ -0,0 +1,43 @@ +{ + "name": "@concepta/nestjs-repository", + "version": "8.0.0-alpha.10", + "description": "Rockets NestJS Repository", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "default": "./dist/testing.js" + } + }, + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" + ], + "dependencies": { + "@concepta/nestjs-core": "^8.0.0-alpha.10", + "@tsyche/membrane": "^0.7.0" + }, + "devDependencies": { + "@nestjs/common": "^12.0.1", + "@nestjs/core": "^12.0.1", + "@nestjs/testing": "^12.0.1", + "vitest": "^4.1.9", + "vitest-mock-extended": "^4.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.1", + "@nestjs/core": "^12.0.1", + "rxjs": "^7.8.1" + } +} diff --git a/packages/nestjs-repository/src/__tests__/exception-fault.spec.ts b/packages/nestjs-repository/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..eafaadd6e --- /dev/null +++ b/packages/nestjs-repository/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,94 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { OptimisticLockException } from '../exceptions/optimistic-lock.exception.js'; +import { RepositoryDuplicateKeyException } from '../exceptions/repository-duplicate-key.exception.js'; +import { RepositoryQueryException } from '../exceptions/repository-query.exception.js'; +import { TransactionClosedException } from '../exceptions/transaction-closed.exception.js'; +import { TransactionHeuristicCommitException } from '../exceptions/transaction-heuristic-commit.exception.js'; +import { TransactionReadOnlyConflictException } from '../exceptions/transaction-read-only-conflict.exception.js'; +import { TransactionScopeFailedException } from '../exceptions/transaction-scope-failed.exception.js'; +import { TransactionTimeoutException } from '../exceptions/transaction-timeout.exception.js'; +import { FederationException } from '../federation/exceptions/federation.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'OptimisticLockException', + build: () => new OptimisticLockException('SomeEntity'), + fault: 'client', + }, + { + name: 'RepositoryDuplicateKeyException', + build: () => + new RepositoryDuplicateKeyException([ + { key: 'k', existing: 'a', attempted: 'b' }, + ]), + fault: 'usage', + }, + { + name: 'RepositoryQueryException', + build: () => new RepositoryQueryException('SomeEntity'), + fault: 'internal', + }, + { + name: 'TransactionClosedException', + build: () => new TransactionClosedException(), + fault: 'usage', + }, + { + name: 'TransactionHeuristicCommitException', + build: () => new TransactionHeuristicCommitException(1, 1), + fault: 'internal', + }, + { + name: 'TransactionReadOnlyConflictException', + build: () => new TransactionReadOnlyConflictException(), + fault: 'usage', + }, + { + name: 'TransactionScopeFailedException', + build: () => new TransactionScopeFailedException(), + fault: 'internal', + }, + { + name: 'TransactionTimeoutException', + build: () => new TransactionTimeoutException(1000), + fault: 'internal', + }, + { + name: 'FederationException', + build: () => new FederationException(), + fault: 'internal', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-repository/src/context/interfaces/repository-context.interface.ts b/packages/nestjs-repository/src/context/interfaces/repository-context.interface.ts new file mode 100644 index 000000000..a19b9c79f --- /dev/null +++ b/packages/nestjs-repository/src/context/interfaces/repository-context.interface.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { OverlayRef } from '@concepta/nestjs-core'; + +/** + * Context interface for the entity routing overlay. + * + * Returned by the `withRepo()` overlay method. Identifies which + * entity key is in scope for hooks and repository operations. + */ +export interface RepositoryContextInterface extends PlainLiteralObject { + entity: string; +} + +export const RepoCtx = new OverlayRef<'withRepo', RepositoryContextInterface>( + 'withRepo', +); diff --git a/packages/nestjs-common/src/repository/decorators/inject-dynamic-repository.decorator.ts b/packages/nestjs-repository/src/decorators/inject-dynamic-repository.decorator.ts similarity index 90% rename from packages/nestjs-common/src/repository/decorators/inject-dynamic-repository.decorator.ts rename to packages/nestjs-repository/src/decorators/inject-dynamic-repository.decorator.ts index 044af4144..39874bb16 100644 --- a/packages/nestjs-common/src/repository/decorators/inject-dynamic-repository.decorator.ts +++ b/packages/nestjs-repository/src/decorators/inject-dynamic-repository.decorator.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; -import { getDynamicRepositoryToken } from '../utils/get-dynamic-repository-token'; +import { getDynamicRepositoryToken } from '../utils/get-dynamic-repository-token.js'; export const InjectDynamicRepository = (key: string) => { return Inject(getDynamicRepositoryToken(key)); diff --git a/packages/nestjs-repository/src/exceptions/optimistic-lock.exception.ts b/packages/nestjs-repository/src/exceptions/optimistic-lock.exception.ts new file mode 100644 index 000000000..a4c0a8df6 --- /dev/null +++ b/packages/nestjs-repository/src/exceptions/optimistic-lock.exception.ts @@ -0,0 +1,28 @@ +import { HttpStatus } from '@nestjs/common'; + +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +/** + * Exception thrown when an update/replace targets a stale version of an + * entity — the row was modified by another request since it was read. + */ +export class OptimisticLockException extends RuntimeException { + declare context: RuntimeException['context'] & { entityName: string }; + + constructor(entityName: string, options?: RuntimeExceptionOptions) { + super({ + message: + 'Update conflict on %s: the record was modified by another request', + messageParams: [entityName], + httpStatus: HttpStatus.CONFLICT, + fault: 'client', + ...options, + }); + + this.context = { ...this.context, entityName }; + this.errorCode = 'OPTIMISTIC_LOCK_CONFLICT'; + } +} diff --git a/packages/nestjs-repository/src/exceptions/repository-duplicate-key.exception.ts b/packages/nestjs-repository/src/exceptions/repository-duplicate-key.exception.ts new file mode 100644 index 000000000..fe5276906 --- /dev/null +++ b/packages/nestjs-repository/src/exceptions/repository-duplicate-key.exception.ts @@ -0,0 +1,28 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +/** + * Exception thrown when duplicate repository keys are registered. + */ +export class RepositoryDuplicateKeyException extends RuntimeException { + constructor( + duplicates: { key: string; existing: string; attempted: string }[], + options?: RuntimeExceptionOptions, + ) { + const details = duplicates.map( + (d) => + `"${d.key}" (registered for ${d.existing}, attempted for ${d.attempted})`, + ); + + super({ + message: 'Duplicate repository keys: %s', + messageParams: [details.join(', ')], + fault: 'usage', + ...options, + }); + + this.errorCode = 'DUPLICATE_REPOSITORY_KEY'; + } +} diff --git a/packages/nestjs-repository/src/exceptions/repository-query.exception.ts b/packages/nestjs-repository/src/exceptions/repository-query.exception.ts new file mode 100644 index 000000000..badb4fa06 --- /dev/null +++ b/packages/nestjs-repository/src/exceptions/repository-query.exception.ts @@ -0,0 +1,20 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +export class RepositoryQueryException extends RuntimeException { + declare context: RuntimeException['context'] & { entityName: string }; + + constructor(entityName: string, options?: RuntimeExceptionOptions) { + super({ + message: 'Error while trying to query the %s repository', + messageParams: [entityName], + fault: 'internal', + ...options, + }); + + this.context = { ...this.context, entityName }; + this.errorCode = 'REPOSITORY_QUERY_ERROR'; + } +} diff --git a/packages/nestjs-repository/src/exceptions/transaction-closed.exception.ts b/packages/nestjs-repository/src/exceptions/transaction-closed.exception.ts new file mode 100644 index 000000000..9c8c11989 --- /dev/null +++ b/packages/nestjs-repository/src/exceptions/transaction-closed.exception.ts @@ -0,0 +1,20 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +/** + * Exception thrown when a transaction scope is accessed after it has + * already settled (committed or rolled back). + */ +export class TransactionClosedException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Transaction scope is closed and can no longer be used', + fault: 'usage', + ...options, + }); + + this.errorCode = 'TRANSACTION_CLOSED'; + } +} diff --git a/packages/nestjs-repository/src/exceptions/transaction-heuristic-commit.exception.ts b/packages/nestjs-repository/src/exceptions/transaction-heuristic-commit.exception.ts new file mode 100644 index 000000000..c4c5b1763 --- /dev/null +++ b/packages/nestjs-repository/src/exceptions/transaction-heuristic-commit.exception.ts @@ -0,0 +1,36 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +/** + * Exception thrown when a multi-datasource commit fails after at least one + * datasource has already committed. Without real two-phase commit, that + * earlier commit cannot be undone — the outcome is "heuristic" + * (mixed/undetermined) rather than atomic across datasources. + * + * Not thrown when nothing had committed yet — rolling everything back is + * then a clean, atomic outcome, and the raw underlying error is thrown + * instead, however many datasources were involved. + */ +export class TransactionHeuristicCommitException extends RuntimeException { + constructor( + committedCount: number, + rolledBackCount: number, + options?: RuntimeExceptionOptions, + ) { + super({ + message: + 'Heuristic commit failure: %d of %d datasource transactions committed before a failure; the remaining %d were rolled back and cannot be committed', + messageParams: [ + committedCount, + committedCount + rolledBackCount, + rolledBackCount, + ], + fault: 'internal', + ...options, + }); + + this.errorCode = 'TRANSACTION_HEURISTIC_COMMIT'; + } +} diff --git a/packages/nestjs-repository/src/exceptions/transaction-read-only-conflict.exception.ts b/packages/nestjs-repository/src/exceptions/transaction-read-only-conflict.exception.ts new file mode 100644 index 000000000..b1bcf59f1 --- /dev/null +++ b/packages/nestjs-repository/src/exceptions/transaction-read-only-conflict.exception.ts @@ -0,0 +1,27 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +/** + * Exception thrown when a `run()` call joins an existing transaction scope + * with a `readOnly` option that conflicts with the scope it's joining. + * + * `readOnly` is decided once, by whichever `run()` created the scope — + * every later participant just joins it. Silently ignoring a conflicting + * `readOnly` would either roll back writes the caller expected to persist, + * or let a `runReadOnly()` call's "must not persist" guarantee be silently + * dropped, in each case without any error to explain why. + */ +export class TransactionReadOnlyConflictException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: + 'run() was called with a readOnly option that conflicts with the transaction scope it joined', + fault: 'usage', + ...options, + }); + + this.errorCode = 'TRANSACTION_READ_ONLY_CONFLICT'; + } +} diff --git a/packages/nestjs-repository/src/exceptions/transaction-scope-failed.exception.ts b/packages/nestjs-repository/src/exceptions/transaction-scope-failed.exception.ts new file mode 100644 index 000000000..35e2228dc --- /dev/null +++ b/packages/nestjs-repository/src/exceptions/transaction-scope-failed.exception.ts @@ -0,0 +1,31 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +/** + * Exception thrown from a `run()` call whose own operation succeeded, but + * whose shared transaction scope had already failed by the time it exited. + * + * Participants share one scope, refcounted via `enter()`/`exit()` — only + * the last one to exit actually commits or rolls back. Without this, a + * caller that catches a sibling `run()`'s error (nested or concurrent) + * would see its own `run()` resolve successfully for a unit of work the + * scope discarded underneath it, with `onCommit` never firing and nothing + * to explain why. + * + * Carries the failure that doomed the scope — usually the sibling's thrown + * error, or a commit failure — as `context.originalError` / `cause`. + */ +export class TransactionScopeFailedException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: + 'run() succeeded, but its shared transaction scope had already failed and rolled back', + fault: 'internal', + ...options, + }); + + this.errorCode = 'TRANSACTION_SCOPE_FAILED'; + } +} diff --git a/packages/nestjs-repository/src/exceptions/transaction-timeout.exception.ts b/packages/nestjs-repository/src/exceptions/transaction-timeout.exception.ts new file mode 100644 index 000000000..96ba890c0 --- /dev/null +++ b/packages/nestjs-repository/src/exceptions/transaction-timeout.exception.ts @@ -0,0 +1,20 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +/** + * Exception thrown when a transaction times out. + */ +export class TransactionTimeoutException extends RuntimeException { + constructor(timeoutMs: number, options?: RuntimeExceptionOptions) { + super({ + message: 'Transaction timeout after %dms', + messageParams: [timeoutMs], + fault: 'internal', + ...options, + }); + + this.errorCode = 'TRANSACTION_TIMEOUT'; + } +} diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/combined-filters.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/combined-filters.spec.ts new file mode 100644 index 000000000..e26d941a6 --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/combined-filters.spec.ts @@ -0,0 +1,342 @@ +/** + * Behavior tests for combined root + relation filters (RELATION_FIRST). + * + * When both root AND relation filters exist: + * 1. rootRepo.count() for root filter total + * 2. Discovery via peer repo (relation conditions only) + * 3. fetchConstrainedRoots with Where.and(rootWhere, idConstraint) + * 4. Hydration via peer repo + * 5. Total = min(rootFilterTotal, relationTotal) + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/combined-filters.spec.ts + */ +import { + WhereCompoundOperator, + WhereOperator, +} from '../../../repository/repository.types.js'; +import { Where } from '../../../repository/where.helpers.js'; +import { type TestRoot, type TestRelation } from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Combined Root+Relation Filters', () => { + describe('Combined Filters with Pagination', () => { + it('should handle root filter + relation filter with page 1', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const activeRelations = [ + { id: 1, rootId: 1, title: 'Feature A', status: 'active' }, + { id: 2, rootId: 2, title: 'Feature B', status: 'active' }, + { id: 3, rootId: 4, title: 'Feature C', status: 'active' }, + ] as TestRelation[]; + + const projectRoots = [ + { id: 1, name: 'Project Alpha' }, + { id: 2, name: 'Project Beta' }, + { id: 4, name: 'Project Delta' }, + ] as TestRoot[]; + + rootRepo.count.mockResolvedValue(5); // 5 total "Project" roots + rootRepo.findAndCount.mockResolvedValue([projectRoots, 3]); + + peerRepo.findAndCount + .mockResolvedValueOnce([activeRelations, 3]) + .mockResolvedValueOnce([activeRelations, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: Where.and( + { + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'Project', + }, + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + ), + join: [{ relation: 'relations' }], + take: 3, + skip: 0, + }); + + // ASSERT - Call counts + expect(rootRepo.count).toHaveBeenCalledTimes(1); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + + // rootRepo.count: root-only where + const countCall = rootRepo.count.mock.calls[0][0]; + expect(countCall?.where).toEqual({ + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'Project', + }); + + // Discovery: relation conditions (user + distinctFilter) + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + expect(discoveryCall?.take).toBe(3); + expect(discoveryCall?.skip).toBe(0); + + // Constrained root fetch: Where.and(rootFilter, idConstraint) + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual( + Where.and( + { + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'Project', + }, + { field: 'id', operator: WhereOperator.IN, value: [1, 2, 4] }, + ), + ); + expect(rootCall?.take).toBe(3); + expect(rootCall?.skip).toBeUndefined(); + + // Results: total = min(rootTotal=5, relTotal=3) = 3 + expect(total).toBe(3); + expect(result).toHaveLength(3); + expect(result.map((r) => r.id)).toEqual([1, 2, 4]); + + // Enrichment + expect(result[0].relations).toEqual([activeRelations[0]]); + expect(result[1].relations).toEqual([activeRelations[1]]); + expect(result[2].relations).toEqual([activeRelations[2]]); + }); + + it('should handle multiple root + relation filters with pagination', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const activeHighPriorityRelations = [ + { id: 1, rootId: 1, title: 'Critical', status: 'active', priority: 10 }, + { id: 2, rootId: 3, title: 'High', status: 'active', priority: 8 }, + { id: 3, rootId: 4, title: 'Important', status: 'active', priority: 7 }, + ] as TestRelation[]; + + const filteredRoots = [ + { id: 1, name: 'Project Alpha', companyId: 1 }, + { id: 3, name: 'Project Gamma', companyId: 1 }, + { id: 4, name: 'Project Delta', companyId: 1 }, + ] as TestRoot[]; + + rootRepo.count.mockResolvedValue(3); + rootRepo.findAndCount.mockResolvedValue([filteredRoots, 3]); + + peerRepo.findAndCount + .mockResolvedValueOnce([activeHighPriorityRelations, 3]) + .mockResolvedValueOnce([activeHighPriorityRelations, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: Where.and( + { + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'Project', + }, + { field: 'companyId', operator: WhereOperator.EQ, value: 1 }, + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + { + field: 'priority', + operator: WhereOperator.GTE, + value: 7, + relation: 'relations', + }, + ), + join: [{ relation: 'relations' }], + take: 5, + skip: 0, + }); + + // ASSERT + expect(rootRepo.count).toHaveBeenCalledTimes(1); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + + // rootRepo.count: compound root-only where (two root conditions) + const countCall = rootRepo.count.mock.calls[0][0]; + expect(countCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'Project', + }, + { field: 'companyId', operator: WhereOperator.EQ, value: 1 }, + ], + }); + + // Discovery: both relation conditions + distinctFilter + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + { + field: 'priority', + operator: WhereOperator.GTE, + value: 7, + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + + // Constrained root fetch: Where.and(compound_root_where, idConstraint) + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual( + Where.and( + { + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'Project', + }, + { field: 'companyId', operator: WhereOperator.EQ, value: 1 }, + ], + }, + { field: 'id', operator: WhereOperator.IN, value: [1, 3, 4] }, + ), + ); + + expect(total).toBe(3); + expect(result).toHaveLength(3); + expect(result.map((r) => r.id)).toEqual([1, 3, 4]); + }); + + it('should handle combined filters when results are reduced below page size', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const criticalRelations = [ + { id: 1, rootId: 2, title: 'System Outage', status: 'critical' }, + { id: 2, rootId: 5, title: 'Security Breach', status: 'critical' }, + ] as TestRelation[]; + + const enterpriseRoots = [ + { id: 2, name: 'Enterprise Suite' }, + { id: 5, name: 'Enterprise Security' }, + ] as TestRoot[]; + + rootRepo.count.mockResolvedValue(4); // 4 Enterprise roots total + rootRepo.findAndCount.mockResolvedValue([enterpriseRoots, 2]); + + peerRepo.findAndCount + .mockResolvedValueOnce([criticalRelations, 2]) + .mockResolvedValueOnce([criticalRelations, 2]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: Where.and( + { + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'Enterprise', + }, + { + field: 'status', + operator: WhereOperator.EQ, + value: 'critical', + relation: 'relations', + }, + ), + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(rootRepo.count).toHaveBeenCalledTimes(1); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + + // Fewer results than page size: total = min(4, 2) = 2 + expect(total).toBe(2); + expect(result).toHaveLength(2); + expect(result.map((r) => r.id)).toEqual([2, 5]); + + // Enrichment + expect(result[0].relations).toEqual([criticalRelations[0]]); + expect(result[1].relations).toEqual([criticalRelations[1]]); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/complex-scenario.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/complex-scenario.spec.ts new file mode 100644 index 000000000..a4a58438e --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/complex-scenario.spec.ts @@ -0,0 +1,271 @@ +/** + * Behavior tests for complex federation scenarios. + * + * Tests the buffer strategy with sparse data requiring multiple iterations + * in the RELATION_FIRST discovery phase. + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/complex-scenario.spec.ts + */ +import { + WhereCompoundOperator, + WhereOperator, +} from '../../../repository/repository.types.js'; +import { type TestRoot, type TestRelation } from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Complex Scenarios', () => { + describe('Sparse data iteration', () => { + it('should handle sparse data requiring multiple iterations', async () => { + // ARRANGE - Each batch of 10 relations yields few unique root IDs + // This exercises the buffer strategy's iterative discovery + const relation = mockOneToManyRelation('comments', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // Batch 1: 10 relations, 3 unique roots (479, 67, 89) + const batch1 = [ + { id: 822, rootId: 479, title: 'Check 1', priority: 11 }, + { id: 823, rootId: 479, title: 'Check 2', priority: 11 }, + { id: 824, rootId: 479, title: 'Check 3', priority: 11 }, + { id: 825, rootId: 479, title: 'Check 4', priority: 11 }, + { id: 112, rootId: 67, title: 'Feature 1', priority: 10 }, + { id: 113, rootId: 67, title: 'Feature 2', priority: 10 }, + { id: 114, rootId: 67, title: 'Feature 3', priority: 10 }, + { id: 203, rootId: 89, title: 'Issue 1', priority: 10 }, + { id: 204, rootId: 89, title: 'Issue 2', priority: 10 }, + { id: 205, rootId: 89, title: 'Issue 3', priority: 10 }, + ] as TestRelation[]; + + // Batch 2: 10 relations, 2 new roots (23, 156) — accumulated 5 + const batch2 = [ + { id: 47, rootId: 23, title: 'Fix 1', priority: 10 }, + { id: 48, rootId: 23, title: 'Fix 2', priority: 10 }, + { id: 49, rootId: 23, title: 'Fix 3', priority: 10 }, + { id: 50, rootId: 23, title: 'Fix 4', priority: 10 }, + { id: 51, rootId: 23, title: 'Fix 5', priority: 10 }, + { id: 341, rootId: 156, title: 'Perf 1', priority: 9 }, + { id: 342, rootId: 156, title: 'Perf 2', priority: 9 }, + { id: 343, rootId: 156, title: 'Perf 3', priority: 9 }, + { id: 344, rootId: 156, title: 'Perf 4', priority: 9 }, + { id: 345, rootId: 156, title: 'Perf 5', priority: 9 }, + ] as TestRelation[]; + + // Batch 3: 10 relations, 2 new roots (201, 234) — accumulated 7 + const batch3 = [ + { id: 389, rootId: 201, title: 'Patch 1', priority: 9 }, + { id: 390, rootId: 201, title: 'Patch 2', priority: 9 }, + { id: 391, rootId: 201, title: 'Patch 3', priority: 9 }, + { id: 392, rootId: 201, title: 'Patch 4', priority: 9 }, + { id: 393, rootId: 201, title: 'Patch 5', priority: 9 }, + { id: 421, rootId: 234, title: 'UI 1', priority: 9 }, + { id: 422, rootId: 234, title: 'UI 2', priority: 9 }, + { id: 423, rootId: 234, title: 'UI 3', priority: 9 }, + { id: 424, rootId: 234, title: 'UI 4', priority: 9 }, + { id: 425, rootId: 234, title: 'UI 5', priority: 9 }, + ] as TestRelation[]; + + // Batch 4: 10 relations, 1 new root (298) — accumulated 8 + const batch4 = [ + { id: 534, rootId: 298, title: 'Doc 1', priority: 8 }, + { id: 535, rootId: 298, title: 'Doc 2', priority: 8 }, + { id: 536, rootId: 298, title: 'Doc 3', priority: 8 }, + { id: 537, rootId: 298, title: 'Doc 4', priority: 8 }, + { id: 538, rootId: 298, title: 'Doc 5', priority: 8 }, + { id: 539, rootId: 298, title: 'Doc 6', priority: 8 }, + { id: 540, rootId: 298, title: 'Doc 7', priority: 8 }, + { id: 541, rootId: 298, title: 'Doc 8', priority: 8 }, + { id: 542, rootId: 298, title: 'Doc 9', priority: 8 }, + { id: 543, rootId: 298, title: 'Doc 10', priority: 8 }, + ] as TestRelation[]; + + // Batch 5: 7 relations, 3 new roots (345, 389, 412) — accumulated 11 >= take(10) + const batch5 = [ + { id: 612, rootId: 345, title: 'API 1', priority: 8 }, + { id: 614, rootId: 345, title: 'API 3', priority: 8 }, + { id: 687, rootId: 389, title: 'DB 1', priority: 8 }, + { id: 688, rootId: 389, title: 'DB 2', priority: 8 }, + { id: 689, rootId: 389, title: 'DB 3', priority: 8 }, + { id: 734, rootId: 412, title: 'Test 1', priority: 8 }, + { id: 735, rootId: 412, title: 'Test 2', priority: 8 }, + ] as TestRelation[]; + + const allRelations = [ + ...batch1, + ...batch2, + ...batch3, + ...batch4, + ...batch5, + ]; + + const totalComments = 500; + + // Discovery batches (5 iterations) + peerRepo.findAndCount + .mockResolvedValueOnce([batch1, totalComments]) + .mockResolvedValueOnce([batch2, totalComments]) + .mockResolvedValueOnce([batch3, totalComments]) + .mockResolvedValueOnce([batch4, totalComments]) + .mockResolvedValueOnce([batch5, totalComments]) + // Hydration: all relations for discovered roots + .mockResolvedValueOnce([allRelations, allRelations.length]); + + // Root fetch: 11 discovered roots, but take=10 slices to 10 + const correspondingRoots = [ + { id: 23, name: 'Root 23' }, + { id: 67, name: 'Root 67' }, + { id: 89, name: 'Root 89' }, + { id: 156, name: 'Root 156' }, + { id: 201, name: 'Root 201' }, + { id: 234, name: 'Root 234' }, + { id: 298, name: 'Root 298' }, + { id: 345, name: 'Root 345' }, + { id: 389, name: 'Root 389' }, + { id: 412, name: 'Root 412' }, + { id: 479, name: 'Root 479' }, + ] as TestRoot[]; + + rootRepo.findAndCount.mockResolvedValue([ + correspondingRoots, + correspondingRoots.length, + ]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'priority', + operator: WhereOperator.GTE, + value: 8, + relation: 'comments', + }, + join: [{ relation: 'comments' }], + take: 10, + skip: 0, + }); + + // ASSERT - Handler call counts + // 5 discovery + 1 hydration = 6 peer calls + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(6); + // 1 constrained root fetch + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Discovery offsets advance: 0, 10, 20, 30, 40 + expect(peerRepo.findAndCount.mock.calls[0][0]?.skip).toBe(0); + expect(peerRepo.findAndCount.mock.calls[1][0]?.skip).toBe(10); + expect(peerRepo.findAndCount.mock.calls[2][0]?.skip).toBe(20); + expect(peerRepo.findAndCount.mock.calls[3][0]?.skip).toBe(30); + expect(peerRepo.findAndCount.mock.calls[4][0]?.skip).toBe(40); + + // Each discovery call has the same filter conditions + for (let i = 0; i < 5; i++) { + const call = peerRepo.findAndCount.mock.calls[i][0]; + expect(call?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'priority', + operator: WhereOperator.GTE, + value: 8, + relation: 'comments', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'comments', + }, + ], + }); + } + + // Result: 10 roots (sliced from 11 discovered) + expect(result).toHaveLength(10); + expect(total).toBe(500); + + // Every root has comments array + for (const root of result) { + expect(root).toHaveProperty('comments'); + expect(Array.isArray(root.comments)).toBe(true); + } + }); + + it('should stop iterating when a batch is exhausted before reaching take', async () => { + // ARRANGE - Data runs out before accumulating enough roots + const relation = mockOneToManyRelation('comments', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // Only 5 matching relations across 3 roots (fewer than take=10) + const allRelations = [ + { id: 1, rootId: 1, title: 'Task A', priority: 9 }, + { id: 2, rootId: 1, title: 'Task B', priority: 8 }, + { id: 3, rootId: 2, title: 'Task C', priority: 8 }, + { id: 4, rootId: 3, title: 'Task D', priority: 8 }, + { id: 5, rootId: 3, title: 'Task E', priority: 8 }, + ] as TestRelation[]; + + // Single discovery batch: 5 < take(10) → exhausted + peerRepo.findAndCount + .mockResolvedValueOnce([allRelations, 5]) + // Hydration + .mockResolvedValueOnce([allRelations, 5]); + + const roots = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[]; + rootRepo.findAndCount.mockResolvedValue([roots, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'priority', + operator: WhereOperator.GTE, + value: 8, + relation: 'comments', + }, + join: [{ relation: 'comments' }], + take: 10, + skip: 0, + }); + + // ASSERT + // Only 1 discovery (exhausted) + 1 hydration = 2 peer calls + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + expect(result).toHaveLength(3); + expect(total).toBe(5); + + // All roots have comments + for (const root of result) { + expect(root).toHaveProperty('comments'); + expect(Array.isArray(root.comments)).toBe(true); + } + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/context-propagation.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/context-propagation.spec.ts new file mode 100644 index 000000000..b897d291b --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/context-propagation.spec.ts @@ -0,0 +1,148 @@ +/** + * Behavior tests for context propagation across federation strategies. + * + * Verifies that RepositoryContextInterface (trx, hooks, etc.) is forwarded + * to every repository call — both root and peer — in ROOT_FIRST and + * RELATION_FIRST execution paths. + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/transaction-propagation.spec.ts + */ +import { WhereOperator } from '../../../repository/repository.types.js'; +import { type TestRoot, type TestRelation } from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, + mockContext, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Context Propagation', () => { + it('should propagate ctx to relation queries in ROOT_FIRST strategy', async () => { + // ARRANGE + const relation = mockOneToManyRelation('comments', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const ctx = mockContext({ entity: 'TestRoot' }); + + rootRepo.findAndCount.mockResolvedValue([ + [ + { id: 1, name: 'Root 1' } as TestRoot, + { id: 2, name: 'Root 2' } as TestRoot, + ], + 2, + ]); + peerRepo.findAndCount.mockResolvedValue([ + [ + { id: 1, rootId: 1, title: 'Comment A' } as TestRelation, + { id: 2, rootId: 2, title: 'Comment B' } as TestRelation, + ], + 2, + ]); + + // ACT + await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'comments' }], + take: 10, + ctx, + }); + + // ASSERT - both root and peer received the same ctx reference + expect(rootRepo.findAndCount.mock.calls[0][0]?.ctx).toBe(ctx); + expect(peerRepo.findAndCount.mock.calls[0][0]?.ctx).toBe(ctx); + }); + + it('should propagate ctx to relation queries in RELATION_FIRST strategy', async () => { + // ARRANGE - relation filter triggers RELATION_FIRST + const relation = mockOneToManyRelation('comments', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const ctx = mockContext({ entity: 'TestRoot' }); + + // Discovery phase: peer query finds matching relations + peerRepo.findAndCount.mockResolvedValueOnce([ + [ + { + id: 1, + rootId: 1, + title: 'Published A', + status: 'published', + } as TestRelation, + { + id: 2, + rootId: 2, + title: 'Published B', + status: 'published', + } as TestRelation, + ], + 2, + ]); + + // Constrained root fetch + rootRepo.findAndCount.mockResolvedValue([ + [ + { id: 1, name: 'Root 1' } as TestRoot, + { id: 2, name: 'Root 2' } as TestRoot, + ], + 2, + ]); + + // Hydration fetch + peerRepo.findAndCount.mockResolvedValueOnce([ + [ + { + id: 1, + rootId: 1, + title: 'Published A', + status: 'published', + } as TestRelation, + { + id: 2, + rootId: 2, + title: 'Published B', + status: 'published', + } as TestRelation, + ], + 2, + ]); + + // ACT + await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'published', + relation: 'comments', + }, + join: [{ relation: 'comments' }], + take: 10, + ctx, + }); + + // ASSERT - every peer repo call (discovery + hydration) received ctx + for (const call of peerRepo.findAndCount.mock.calls) { + expect(call[0]?.ctx).toBe(ctx); + } + + // Root repo calls also received ctx + for (const call of rootRepo.findAndCount.mock.calls) { + expect(call[0]?.ctx).toBe(ctx); + } + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/distinct-filter-validation.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/distinct-filter-validation.spec.ts new file mode 100644 index 000000000..d78f23c74 --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/distinct-filter-validation.spec.ts @@ -0,0 +1,208 @@ +/** + * Validation tests for distinctFilter requirements on many-cardinality relations. + * + * Tests that relation sorting requires distinctFilter for many relationships + * to ensure deterministic root deduplication. + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/distinct-filter-validation.spec.ts + */ +import { + WhereCompoundOperator, + WhereOperator, +} from '../../../repository/repository.types.js'; +import { FederationException } from '../../exceptions/federation.exception.js'; +import { type TestRoot, type TestRelation } from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, + mockOneToOneRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - distinctFilter Validation', () => { + describe('distinctFilter requirement validation', () => { + it('should throw error when many-cardinality relation lacks distinctFilter', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + // No distinctFilter — should fail for many-cardinality sort + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // ACT & ASSERT + const error = await orchestrator + .findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(FederationException); + expect(error.message).toContain('distinctFilter'); + expect(error.message).toContain('many-cardinality'); + }); + + it('should succeed when many-cardinality relation has distinctFilter', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const relationData = [ + { id: 1, rootId: 1, title: 'Alpha Task', isLatest: true }, + { id: 2, rootId: 2, title: 'Beta Task', isLatest: true }, + { id: 3, rootId: 3, title: 'Charlie Task', isLatest: true }, + ] as TestRelation[]; + + const rootData = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[]; + + // Discovery + hydration + peerRepo.findAndCount + .mockResolvedValueOnce([relationData, 3]) + .mockResolvedValueOnce([relationData, 3]); + + rootRepo.findAndCount.mockResolvedValue([rootData, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 3, + skip: 0, + }); + + // ASSERT + expect(result).toHaveLength(3); + expect(total).toBe(3); + + // Verify distinctFilter was applied in discovery call + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'rootId', + operator: WhereOperator.NOT_NULL, + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + }); + + it('should automatically inject NOT_NULL filter for relation sorting', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // Discovery returns empty → short-circuit + peerRepo.findAndCount.mockResolvedValue([[], 0]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - NOT_NULL was automatically injected + expect(result).toEqual([]); + expect(total).toBe(0); + + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'rootId', + operator: WhereOperator.NOT_NULL, + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + }); + + it('should work fine with one-cardinality relations (no distinctFilter needed)', async () => { + // ARRANGE - one-to-one relation: no distinctFilter required + const relation = mockOneToOneRelation('profile', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // Discovery + hydration + peerRepo.findAndCount + .mockResolvedValueOnce([ + [{ id: 1, rootId: 1, title: 'Developer Profile' }] as TestRelation[], + 1, + ]) + .mockResolvedValueOnce([ + [{ id: 1, rootId: 1, title: 'Developer Profile' }] as TestRelation[], + 1, + ]); + + rootRepo.findAndCount.mockResolvedValue([ + [{ id: 1, name: 'Root 1' }] as TestRoot[], + 1, + ]); + + // ACT - Should not throw (one-to-one doesn't need distinctFilter) + const [result, total] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'profile' }], + join: [{ relation: 'profile' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(result).toHaveLength(1); + expect(total).toBe(1); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/inner-join-behavior.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/inner-join-behavior.spec.ts new file mode 100644 index 000000000..79ece24eb --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/inner-join-behavior.spec.ts @@ -0,0 +1,687 @@ +/** + * Behavior tests for INNER JOIN pattern achieved through relation filters. + * + * When a relation-tagged filter is present, the orchestrator uses + * RELATION_FIRST strategy: discovery -> constrained roots -> hydration. + * This produces INNER JOIN semantics (only roots with matching relations). + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/inner-join-behavior.spec.ts + */ +import { + WhereCompoundOperator, + WhereOperator, +} from '../../../repository/repository.types.js'; +import { Where } from '../../../repository/where.helpers.js'; +import { + type TestRoot, + type TestRelation, + createMinimalRootRelationSet, + createFilteredDataSet, +} from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Inner Join Behavior', () => { + describe('Relation existence filter (NOT_NULL)', () => { + it('should constrain root results when relation existence filter present', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const data = createMinimalRootRelationSet(); + const latestRelations = data.relations.filter((r) => r.isLatest); + + // Discovery: find relations matching NOT_NULL + distinctFilter + peerRepo.findAndCount + .mockResolvedValueOnce([latestRelations, latestRelations.length]) + // Hydration + .mockResolvedValueOnce([latestRelations, latestRelations.length]); + + // Constrained root fetch: only roots 1 and 2 + const constrainedRoots = data.roots.filter((r) => r.id <= 2); + rootRepo.findAndCount.mockResolvedValue([ + constrainedRoots, + constrainedRoots.length, + ]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'rootId', + operator: WhereOperator.NOT_NULL, + relation: 'relations', + }, + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Root constrained to discovered IDs + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual({ + field: 'id', + operator: WhereOperator.IN, + value: [1, 2], + }); + expect(rootCall?.take).toBe(10); + + // Result verification + expect(total).toBe(2); + expect(result).toHaveLength(2); + expect(result.map((r) => r.id)).toEqual([1, 2]); + + // Enrichment + expect(result[0].relations).toEqual([ + { id: 1, rootId: 1, title: 'Relation 1', isLatest: true }, + ]); + expect(result[1].relations).toEqual([ + { id: 2, rootId: 2, title: 'Relation 2', isLatest: true }, + ]); + }); + }); + + describe('Relation value filter', () => { + it('should apply INNER JOIN with relation value filters (status=active)', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const data = createFilteredDataSet(); + + // Discovery: active relations -> roots [1, 2] + peerRepo.findAndCount + .mockResolvedValueOnce([ + data.activeRelations, + data.activeRelations.length, + ]) + // Hydration + .mockResolvedValueOnce([ + data.activeRelations, + data.activeRelations.length, + ]); + + const constrainedRoots = data.roots.filter( + (r) => r.id === 1 || r.id === 2, + ); + rootRepo.findAndCount.mockResolvedValue([ + constrainedRoots, + constrainedRoots.length, + ]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - RELATION_FIRST strategy + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Discovery call: status=active + distinctFilter + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + + expect(total).toBe(2); + expect(result).toHaveLength(2); + expect(result.map((r) => r.id)).toEqual([1, 2]); + }); + }); + + describe('Empty relation match', () => { + it('should return empty result when no relations match filters', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + peerRepo.findAndCount.mockResolvedValueOnce([[], 0]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'archived', + relation: 'relations', + }, + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(rootRepo.findAndCount).not.toHaveBeenCalled(); + + expect(total).toBe(0); + expect(result).toEqual([]); + }); + }); + + describe('Combined root and relation filters', () => { + it('should apply INNER JOIN with combined root and relation filters', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const activeRelations = [ + { id: 1, rootId: 1, title: 'Feature A', status: 'active' }, + { id: 2, rootId: 2, title: 'Feature B', status: 'active' }, + ] as TestRelation[]; + + peerRepo.findAndCount + .mockResolvedValueOnce([activeRelations, activeRelations.length]) + .mockResolvedValueOnce([activeRelations, activeRelations.length]); + + const constrainedRoots = [ + { id: 1, name: 'Project Alpha' }, + { id: 2, name: 'Project Beta' }, + ] as TestRoot[]; + rootRepo.findAndCount.mockResolvedValue([ + constrainedRoots, + constrainedRoots.length, + ]); + + // rootRepo.count called because root filter exists + rootRepo.count.mockResolvedValue(3); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: Where.and( + { + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'Project', + }, + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + ), + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(rootRepo.count).toHaveBeenCalledTimes(1); + + // Count call: root filter only + const countCall = rootRepo.count.mock.calls[0][0]; + expect(countCall?.where).toEqual({ + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'Project', + }); + + // Discovery call: relation conditions only + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + + // Constrained root call: root filter AND id constraint + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual( + Where.and( + { + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'Project', + }, + { field: 'id', operator: WhereOperator.IN, value: [1, 2] }, + ), + ); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.id)).toEqual([1, 2]); + // total = min(rootTotal=3, relTotal=2) = 2 + expect(total).toBe(2); + }); + }); + + describe('Pagination with INNER JOIN', () => { + it('should handle INNER JOIN with pagination on page 1', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const page1Relations = [ + { id: 1, rootId: 1, title: 'Task 1', status: 'active' }, + { id: 2, rootId: 2, title: 'Task 2', status: 'active' }, + { id: 3, rootId: 3, title: 'Task 3', status: 'active' }, + ] as TestRelation[]; + + peerRepo.findAndCount + .mockResolvedValueOnce([page1Relations, 5]) + .mockResolvedValueOnce([page1Relations, 3]); + + const page1Roots = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[]; + rootRepo.findAndCount.mockResolvedValue([page1Roots, page1Roots.length]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + join: [{ relation: 'relations' }], + take: 3, + skip: 0, + }); + + // ASSERT + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.take).toBe(3); + expect(discoveryCall?.skip).toBe(0); + + expect(result).toHaveLength(3); + expect(result.map((r) => r.id)).toEqual([1, 2, 3]); + expect(total).toBe(5); + }); + + it('should handle INNER JOIN with pagination on page 2', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const page2Relations = [ + { id: 4, rootId: 4, title: 'Task 4', status: 'active' }, + { id: 5, rootId: 5, title: 'Task 5', status: 'active' }, + ] as TestRelation[]; + + peerRepo.findAndCount + .mockResolvedValueOnce([page2Relations, 5]) + .mockResolvedValueOnce([page2Relations, 2]); + + const page2Roots = [ + { id: 4, name: 'Root 4' }, + { id: 5, name: 'Root 5' }, + ] as TestRoot[]; + rootRepo.findAndCount.mockResolvedValue([page2Roots, page2Roots.length]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + join: [{ relation: 'relations' }], + take: 3, + skip: 3, + }); + + // ASSERT + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.take).toBe(3); + expect(discoveryCall?.skip).toBe(3); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.id)).toEqual([4, 5]); + expect(total).toBe(5); + }); + + it('should handle pagination when filter reduces results below page size', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const criticalRelations = [ + { id: 1, rootId: 1, title: 'Critical Bug', status: 'critical' }, + { id: 2, rootId: 3, title: 'Critical Feature', status: 'critical' }, + ] as TestRelation[]; + + peerRepo.findAndCount + .mockResolvedValueOnce([criticalRelations, 2]) + .mockResolvedValueOnce([criticalRelations, 2]); + + const matchingRoots = [ + { id: 1, name: 'Root 1' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[]; + rootRepo.findAndCount.mockResolvedValue([ + matchingRoots, + matchingRoots.length, + ]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'critical', + relation: 'relations', + }, + join: [{ relation: 'relations' }], + take: 5, + skip: 0, + }); + + // ASSERT + expect(result).toHaveLength(2); + expect(result.map((r) => r.id)).toEqual([1, 3]); + expect(total).toBe(2); + }); + }); + + describe('Relation sort with INNER JOIN', () => { + it('should preserve relation sort order in INNER JOIN scenario', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + // Relations sorted by title: Alpha(root2), Beta(root1), Charlie(root3) + const sortedRelations = [ + { id: 2, rootId: 2, title: 'Alpha Task', status: 'active' }, + { id: 1, rootId: 1, title: 'Beta Task', status: 'active' }, + { id: 3, rootId: 3, title: 'Charlie Task', status: 'active' }, + ] as TestRelation[]; + + peerRepo.findAndCount + .mockResolvedValueOnce([sortedRelations, 3]) + .mockResolvedValueOnce([sortedRelations, 3]); + + const roots = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[]; + rootRepo.findAndCount.mockResolvedValue([roots, roots.length]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Discovery call has sort + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.order).toEqual([ + { field: 'title', order: 'ASC', relation: 'relations' }, + ]); + + // Root ordering follows relation sort: rootId 2, 1, 3 + expect(result).toHaveLength(3); + expect(result.map((r) => r.id)).toEqual([2, 1, 3]); + expect(total).toBe(3); + }); + }); + + describe('Multiple relation filters (AND condition)', () => { + it('should handle INNER JOIN with multiple relation filters', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const matchingRelations = [ + { + id: 1, + rootId: 1, + title: 'Active High', + status: 'active', + priority: 8, + }, + { + id: 2, + rootId: 3, + title: 'Active Medium', + status: 'active', + priority: 5, + }, + ] as TestRelation[]; + + peerRepo.findAndCount + .mockResolvedValueOnce([matchingRelations, matchingRelations.length]) + .mockResolvedValueOnce([matchingRelations, matchingRelations.length]); + + const matchingRoots = [ + { id: 1, name: 'Root 1' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[]; + rootRepo.findAndCount.mockResolvedValue([ + matchingRoots, + matchingRoots.length, + ]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: Where.and( + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + { + field: 'priority', + operator: WhereOperator.GTE, + value: 5, + relation: 'relations', + }, + ), + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Discovery call: both relation filters + distinctFilter + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + { + field: 'priority', + operator: WhereOperator.GTE, + value: 5, + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + + expect(total).toBe(2); + expect(result).toHaveLength(2); + expect(result.map((r) => r.id)).toEqual([1, 3]); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/join-type.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/join-type.spec.ts new file mode 100644 index 000000000..ed0331942 --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/join-type.spec.ts @@ -0,0 +1,318 @@ +/** + * Tests for join type behavior (LEFT vs INNER) for forward relations. + * + * LEFT JOIN (default): ROOT_FIRST strategy, all roots returned including + * those without matching relations. + * + * INNER JOIN: NOT_NULL filter injected automatically, triggers RELATION_FIRST + * strategy, only roots with matching relations returned. + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/join-type.spec.ts + */ +import { + WhereCompoundOperator, + WhereOperator, +} from '../../../repository/repository.types.js'; +import { + type TestRoot, + type TestRelation, + createMinimalRootRelationSet, +} from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Join Type (Forward Relations)', () => { + describe('Forward relationships (one-to-many)', () => { + it('should use LEFT JOIN by default (no joinType specified)', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const data = createMinimalRootRelationSet(); + rootRepo.findAndCount.mockResolvedValue([data.roots, 3]); + peerRepo.findAndCount.mockResolvedValue([data.relations, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - ROOT_FIRST strategy (root called first, then peer) + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(1); + + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + peerRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // All 3 roots returned (LEFT JOIN behavior) + expect(result).toHaveLength(3); + expect(total).toBe(3); + + // Root 1: has relation 1 + expect(result[0].relations).toEqual([ + { id: 1, rootId: 1, title: 'Relation 1', isLatest: true }, + ]); + // Root 2: has relations 2 and 3 + expect(result[1].relations).toEqual([ + { id: 2, rootId: 2, title: 'Relation 2', isLatest: true }, + { id: 3, rootId: 2, title: 'Relation 3', isLatest: false }, + ]); + // Root 3: no relations (LEFT JOIN keeps it) + expect(result[2].relations).toEqual([]); + }); + + it('should use LEFT JOIN when joinType: "LEFT" is explicitly specified', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const data = createMinimalRootRelationSet(); + rootRepo.findAndCount.mockResolvedValue([data.roots, 3]); + peerRepo.findAndCount.mockResolvedValue([data.relations, 3]); + + // ACT - explicit LEFT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations', joinType: 'LEFT' }], + take: 10, + skip: 0, + }); + + // ASSERT - ROOT_FIRST strategy (LEFT JOIN behavior) + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(1); + + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + peerRepo.findAndCount.mock.invocationCallOrder[0], + ); + + expect(result).toHaveLength(3); + expect(total).toBe(3); + }); + + it('should automatically inject NOT_NULL filter for joinType: "INNER"', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const data = createMinimalRootRelationSet(); + // INNER JOIN: only relations with non-null rootId + const innerJoinRelations = data.relations.filter((r) => r.rootId); + + // Discovery + hydration + peerRepo.findAndCount + .mockResolvedValueOnce([innerJoinRelations, 3]) + .mockResolvedValueOnce([innerJoinRelations, 3]); + + // Only roots 1 and 2 have relations (root 3 excluded by INNER JOIN) + rootRepo.findAndCount.mockResolvedValue([ + data.roots.filter((r) => r.id !== 3), + 2, + ]); + + // ACT + const [result] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations', joinType: 'INNER' }], + take: 10, + skip: 0, + }); + + // ASSERT - RELATION_FIRST strategy (peer called first) + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + expect(peerRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + rootRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Discovery call: NOT_NULL + distinctFilter injected + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'rootId', + operator: WhereOperator.NOT_NULL, + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + + // Constrained root fetch: only roots with matching relations + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual({ + field: 'id', + operator: WhereOperator.IN, + value: [1, 2], + }); + + // Only 2 roots returned (INNER JOIN excludes root 3) + expect(result).toHaveLength(2); + }); + + it('should preserve existing filters when injecting NOT_NULL for INNER join', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const data = createMinimalRootRelationSet(); + const activeRelations = data.relations.slice(0, 2); + + // Discovery + hydration + peerRepo.findAndCount + .mockResolvedValueOnce([activeRelations, 2]) + .mockResolvedValueOnce([activeRelations, 2]); + + rootRepo.findAndCount.mockResolvedValue([data.roots.slice(0, 2), 2]); + + // ACT + await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + join: [{ relation: 'relations', joinType: 'INNER' }], + take: 10, + skip: 0, + }); + + // ASSERT - Discovery has user filter + injected NOT_NULL + distinctFilter + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + { + field: 'rootId', + operator: WhereOperator.NOT_NULL, + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + }); + + it('should include both user NOT_NULL and injected NOT_NULL for INNER join', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const data = createMinimalRootRelationSet(); + + // Discovery + hydration + peerRepo.findAndCount + .mockResolvedValueOnce([data.relations, 3]) + .mockResolvedValueOnce([data.relations, 3]); + + rootRepo.findAndCount.mockResolvedValue([ + data.roots.filter((r) => r.id !== 3), + 2, + ]); + + // ACT - User provides NOT_NULL + INNER join + await orchestrator.findAndCount(rootRepo, { + where: { + field: 'rootId', + operator: WhereOperator.NOT_NULL, + relation: 'relations', + }, + join: [{ relation: 'relations', joinType: 'INNER' }], + take: 10, + skip: 0, + }); + + // ASSERT - Both user NOT_NULL and injected NOT_NULL appear + // (deduplication is not performed at the repository level) + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'rootId', + operator: WhereOperator.NOT_NULL, + relation: 'relations', + }, + { + field: 'rootId', + operator: WhereOperator.NOT_NULL, + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/multi-relation-constraint.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/multi-relation-constraint.spec.ts new file mode 100644 index 000000000..fe8c76cdd --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/multi-relation-constraint.spec.ts @@ -0,0 +1,173 @@ +/** + * Behavior test for multi-relation constraint field usage. + * + * When processRelationChain processes multiple relations sequentially, + * it must use the target relation's foreignKey (on.to) — not the root's + * primary key — when constraining the second relation with IDs discovered + * from the first. + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/multi-relation-constraint.spec.ts + */ +import { + WhereCompoundOperator, + WhereOperator, +} from '../../../repository/repository.types.js'; +import { Where } from '../../../repository/where.helpers.js'; +import { + type TestRoot, + type TestRelation, + type TestProfile, +} from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, + mockOneToOneRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Multi-Relation Constraint Field', () => { + it('should use foreignKey (not rootKey) when constraining second relation', async () => { + // ARRANGE - Two forward relations: profiles (driving) + comments (constrained) + const profileRelation = mockOneToOneRelation('profiles', 'TestProfile', { + on: { from: 'id', to: 'rootId' }, + }); + + const commentRelation = mockOneToManyRelation('comments', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + + const rootRepo = mockTestRepo('TestRoot', { + relations: [profileRelation, commentRelation], + }); + const profileRepo = mockTestRepo('TestProfile'); + const commentRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestProfile: profileRepo, + TestRelation: commentRepo, + }); + + // Profile discovery: returns root IDs [10, 20, 30] + profileRepo.findAndCount + .mockResolvedValueOnce([ + [ + { id: 1, rootId: 10, bio: 'A' }, + { id: 2, rootId: 20, bio: 'B' }, + { id: 3, rootId: 30, bio: 'C' }, + ] as TestProfile[], + 3, + ]) + // Hydration + .mockResolvedValueOnce([ + [ + { id: 1, rootId: 10, bio: 'A' }, + { id: 2, rootId: 20, bio: 'B' }, + ] as TestProfile[], + 2, + ]); + + // Comment constrained discovery + hydration + commentRepo.findAndCount + .mockResolvedValueOnce([ + [ + { + id: 101, + rootId: 10, + title: 'Comment A', + status: 'published', + isLatest: true, + }, + { + id: 102, + rootId: 20, + title: 'Comment B', + status: 'published', + isLatest: true, + }, + ] as TestRelation[], + 2, + ]) + .mockResolvedValueOnce([ + [ + { + id: 101, + rootId: 10, + title: 'Comment A', + status: 'published', + isLatest: true, + }, + { + id: 102, + rootId: 20, + title: 'Comment B', + status: 'published', + isLatest: true, + }, + ] as TestRelation[], + 2, + ]); + + rootRepo.findAndCount.mockResolvedValue([ + [ + { id: 10, name: 'Root 10' }, + { id: 20, name: 'Root 20' }, + ] as TestRoot[], + 2, + ]); + + // ACT + const [result] = await orchestrator.findAndCount(rootRepo, { + where: Where.and( + { + field: 'isActive', + operator: WhereOperator.EQ, + value: true, + relation: 'profiles', + }, + { + field: 'status', + operator: WhereOperator.EQ, + value: 'published', + relation: 'comments', + }, + ), + join: [{ relation: 'profiles' }, { relation: 'comments' }], + take: 5, + skip: 0, + }); + + // ASSERT - The comment relation's constraint must use foreignKey ('rootId'), + // not rootKey ('id'). + const commentDiscoveryCall = commentRepo.findAndCount.mock.calls[0][0]; + const whereClause = commentDiscoveryCall?.where; + + // Should be an AND compound with conditions + expect(whereClause).toHaveProperty('operator', WhereCompoundOperator.AND); + expect(whereClause).toHaveProperty('conditions'); + + // Find the FK constraint: the condition without a `relation` tag + // (injected by processRelationChain using relation.on.to) + const conditions = ( + whereClause as { + conditions: { + field: string; + operator: string; + value: unknown; + relation?: string; + }[]; + } + ).conditions; + const constraintFilter = conditions.find((c) => !c.relation); + expect(constraintFilter).toBeDefined(); + expect(constraintFilter!.field).toBe('rootId'); + expect(constraintFilter!.value).toEqual([10, 20, 30]); + + // Verify final results + expect(result).toHaveLength(2); + expect(result.map((r) => r.id)).toEqual([10, 20]); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/no-relations.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/no-relations.spec.ts new file mode 100644 index 000000000..2f43d10d6 --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/no-relations.spec.ts @@ -0,0 +1,147 @@ +/** + * Behavior tests for queries without any federated relations. + * + * Verifies that when no federated joins are requested, the orchestrator + * passes through to rootRepo.findAndCount unchanged. + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/no-relations.spec.ts + */ +import { WhereOperator } from '../../../repository/repository.types.js'; +import { type TestRoot, type TestRelation } from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - No Relations Query', () => { + it('should pass through root request unchanged when no joins are requested', async () => { + // ARRANGE + const rootRepo = mockTestRepo('TestRoot'); + const { orchestrator } = mockOrchestrator({}); + + const roots = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[]; + + rootRepo.findAndCount.mockResolvedValue([roots, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + take: 10, + skip: 0, + }); + + // ASSERT + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(3); + expect(total).toBe(3); + }); + + it('should preserve root filters when no joins are requested', async () => { + // ARRANGE + const rootRepo = mockTestRepo('TestRoot'); + const { orchestrator } = mockOrchestrator({}); + + const filteredRoots = [{ id: 1, name: 'test' }] as TestRoot[]; + rootRepo.findAndCount.mockResolvedValue([filteredRoots, 1]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'name', + operator: WhereOperator.EQ, + value: 'test', + }, + take: 10, + skip: 0, + }); + + // ASSERT + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + const callOptions = rootRepo.findAndCount.mock.calls[0][0]; + expect(callOptions?.where).toEqual({ + field: 'name', + operator: WhereOperator.EQ, + value: 'test', + }); + expect(result).toHaveLength(1); + expect(total).toBe(1); + expect(result[0]).toEqual({ id: 1, name: 'test' }); + }); + + it('should preserve root sorting when no joins are requested', async () => { + // ARRANGE + const rootRepo = mockTestRepo('TestRoot'); + const { orchestrator } = mockOrchestrator({}); + + const sortedRoots = [ + { id: 3, name: 'Root A' }, + { id: 1, name: 'Root B' }, + { id: 2, name: 'Root C' }, + ] as TestRoot[]; + + rootRepo.findAndCount.mockResolvedValue([sortedRoots, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'name', order: 'ASC' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + const callOptions = rootRepo.findAndCount.mock.calls[0][0]; + expect(callOptions?.order).toEqual([{ field: 'name', order: 'ASC' }]); + expect(result).toHaveLength(3); + expect(total).toBe(3); + expect(result.map((r) => r.id)).toEqual([3, 1, 2]); + }); + + it('should handle empty root results with no joins', async () => { + // ARRANGE + const rootRepo = mockTestRepo('TestRoot'); + const { orchestrator } = mockOrchestrator({}); + + rootRepo.findAndCount.mockResolvedValue([[], 0]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + take: 10, + skip: 0, + }); + + // ASSERT + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(result).toEqual([]); + expect(total).toBe(0); + }); + + it('should not call any peer repo when no federated joins exist', async () => { + // ARRANGE - Repo has relations in metadata but no joins requested + const rootRepo = mockTestRepo('TestRoot'); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const roots = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + ] as TestRoot[]; + + rootRepo.findAndCount.mockResolvedValue([roots, 2]); + + // ACT — no join in options + const [result, total] = await orchestrator.findAndCount(rootRepo, { + take: 10, + skip: 0, + }); + + // ASSERT — peer repo never called + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(0); + expect(result).toHaveLength(2); + expect(total).toBe(2); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/owning-relation-validation.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/owning-relation-validation.spec.ts new file mode 100644 index 000000000..20af72ddb --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/owning-relation-validation.spec.ts @@ -0,0 +1,100 @@ +/** + * Validation tests for filtering/sorting on owning federated relations. + * + * RELATION_FIRST discovery can only extract root ids from non-owning + * relations (target FK -> root PK). An owning relation (root FK -> target + * PK) can't drive discovery, so filtering or sorting by one must be + * rejected loudly rather than silently returning an empty, wrong result. + */ +import { WhereOperator } from '../../../repository/repository.types.js'; +import { FederationException } from '../../exceptions/federation.exception.js'; +import { type TestRoot } from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOwningOneToOneRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - owning relation filter/sort validation', () => { + it('should throw when filtering on an owning relation', async () => { + const blogRelation = mockOwningOneToOneRelation( + 'blog', + 'BlogEntity', + 'blogId', + ); + const rootRepo = mockTestRepo('UserEntity', { + relations: [blogRelation], + }); + const blogRepo = mockTestRepo('BlogEntity'); + const { orchestrator } = mockOrchestrator({ BlogEntity: blogRepo }); + + const error = await orchestrator + .findAndCount(rootRepo, { + where: { + field: 'title', + operator: WhereOperator.EQ, + value: 'My Blog', + relation: 'blog', + }, + join: [{ relation: 'blog' }], + take: 10, + skip: 0, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(FederationException); + expect(error.message).toContain('blog'); + expect(rootRepo.findAndCount).not.toHaveBeenCalled(); + expect(blogRepo.findAndCount).not.toHaveBeenCalled(); + }); + + it('should throw when sorting on an owning relation', async () => { + const blogRelation = mockOwningOneToOneRelation( + 'blog', + 'BlogEntity', + 'blogId', + ); + const rootRepo = mockTestRepo('UserEntity', { + relations: [blogRelation], + }); + const blogRepo = mockTestRepo('BlogEntity'); + const { orchestrator } = mockOrchestrator({ BlogEntity: blogRepo }); + + await expect( + orchestrator.findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'blog' }], + join: [{ relation: 'blog' }], + take: 10, + skip: 0, + }), + ).rejects.toThrow(FederationException); + }); + + it('should not throw for a plain join (no filter/sort) on an owning relation', async () => { + const blogRelation = mockOwningOneToOneRelation( + 'blog', + 'BlogEntity', + 'blogId', + ); + const rootRepo = mockTestRepo('UserEntity', { + relations: [blogRelation], + }); + const blogRepo = mockTestRepo('BlogEntity'); + const { orchestrator } = mockOrchestrator({ BlogEntity: blogRepo }); + + rootRepo.findAndCount.mockResolvedValue([ + [{ id: 1, name: 'Alice', blogId: 100 }], + 1, + ]); + blogRepo.findAndCount.mockResolvedValue([ + [{ id: 100, title: 'My Blog' }], + 1, + ]); + + const [data] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'blog' }], + }); + + expect(data[0].blog).toEqual({ id: 100, title: 'My Blog' }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/relation-filter-total-validation.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/relation-filter-total-validation.spec.ts new file mode 100644 index 000000000..d35dcf2e7 --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/relation-filter-total-validation.spec.ts @@ -0,0 +1,171 @@ +/** + * Validation test for accurate totals when filtering many-cardinality + * relations. + * + * The RELATION_FIRST total is read from the driving relation's own + * `findAndCount` total, which counts matching child rows — not distinct + * root ids. For a many-cardinality relation, two matching child rows + * belonging to one root would report total=2 for a single matching root. + * `distinctFilter` is the existing mechanism (already required for + * relation *sorts*, see relation-sort-validation.spec.ts) that narrows a + * many relation to at most one row per root, keeping that total accurate. + * This extends the same requirement to relation *filters*. + */ +import { WhereOperator } from '../../../repository/repository.types.js'; +import { FederationException } from '../../exceptions/federation.exception.js'; +import { type TestRoot, type TestRelation } from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, + mockOneToOneRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - relation filter total accuracy', () => { + it('should throw when filtering on a many-cardinality relation without distinctFilter', async () => { + const relation = mockOneToManyRelation('comments', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + // No distinctFilter — the filtered total would count child rows. + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const error = await orchestrator + .findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'published', + relation: 'comments', + }, + join: [{ relation: 'comments' }], + take: 10, + skip: 0, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(FederationException); + expect(error.message).toContain('distinctFilter'); + expect(error.message).toContain('comments'); + expect(rootRepo.findAndCount).not.toHaveBeenCalled(); + expect(peerRepo.findAndCount).not.toHaveBeenCalled(); + }); + + it('should still succeed when filtering a many-cardinality relation with distinctFilter', async () => { + const relation = mockOneToManyRelation('comments', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + peerRepo.findAndCount + .mockResolvedValueOnce([ + [ + { + id: 1, + rootId: 10, + title: 'Comment', + status: 'published', + isLatest: true, + }, + ], + 1, + ]) + .mockResolvedValueOnce([ + [ + { + id: 1, + rootId: 10, + title: 'Comment', + status: 'published', + isLatest: true, + }, + ], + 1, + ]); + rootRepo.findAndCount.mockResolvedValue([[{ id: 10, name: 'Root 10' }], 1]); + + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'published', + relation: 'comments', + }, + join: [{ relation: 'comments' }], + take: 10, + skip: 0, + }); + + expect(result).toHaveLength(1); + expect(total).toBe(1); + }); + + it('should not throw for a plain INNER JOIN (no user filter) on a many-cardinality relation', async () => { + // The auto-injected NOT_NULL for INNER JOIN is structural, not a + // user-specified filter — it alone shouldn't demand distinctFilter. + const relation = mockOneToManyRelation('posts', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + peerRepo.findAndCount.mockResolvedValue([ + [{ id: 1, rootId: 10 }] as TestRelation[], + 1, + ]); + rootRepo.findAndCount.mockResolvedValue([[{ id: 10, name: 'Root 10' }], 1]); + + await expect( + orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts', joinType: 'INNER' }], + take: 10, + skip: 0, + }), + ).resolves.toBeDefined(); + }); + + it('should not throw when filtering a one-cardinality relation without distinctFilter', async () => { + const relation = mockOneToOneRelation('profile', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + peerRepo.findAndCount + .mockResolvedValueOnce([[{ id: 1, rootId: 10 }] as TestRelation[], 1]) + .mockResolvedValueOnce([[{ id: 1, rootId: 10 }] as TestRelation[], 1]); + rootRepo.findAndCount.mockResolvedValue([[{ id: 10, name: 'Root 10' }], 1]); + + await expect( + orchestrator.findAndCount(rootRepo, { + where: { + field: 'bio', + operator: WhereOperator.CONTAINS, + value: 'x', + relation: 'profile', + }, + join: [{ relation: 'profile' }], + take: 10, + skip: 0, + }), + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/relation-first-pagination.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/relation-first-pagination.spec.ts new file mode 100644 index 000000000..fb4c1fa86 --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/relation-first-pagination.spec.ts @@ -0,0 +1,86 @@ +/** + * Behavior test for iterative discovery pagination in RELATION_FIRST. + * + * When the first discovery batch doesn't surface enough unique root ids + * (sparse relation data), BufferStrategy advances to a second batch. That + * second batch's skip must stay relative to the caller's own `skip` — + * `userSkip + offset` — not just the buffer's internal `offset`, or a + * paginated request can read data from before the requested window. + */ +import { WhereOperator } from '../../../repository/repository.types.js'; +import { type TestRoot, type TestRelation } from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - RELATION_FIRST pagination', () => { + it('keeps the second discovery batch relative to the caller-supplied skip', async () => { + // ARRANGE + const relation = mockOneToManyRelation('comments', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const commentRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: commentRepo }); + + // First batch: 2 rows, but both belong to the same root — sparse data, + // only 1 unique id discovered against a take of 2. + commentRepo.findAndCount + .mockResolvedValueOnce([ + [ + { id: 201, rootId: 10, title: 'C1', isLatest: true }, + { id: 202, rootId: 10, title: 'C2', isLatest: true }, + ] as TestRelation[], + 5, + ]) + // Second batch: one more unique root, satisfies take=2. + .mockResolvedValueOnce([ + [ + { id: 203, rootId: 20, title: 'C3', isLatest: true }, + ] as TestRelation[], + 5, + ]) + // Hydration pass. + .mockResolvedValue([[], 0]); + + rootRepo.findAndCount.mockResolvedValue([ + [ + { id: 10, name: 'Root 10' }, + { id: 20, name: 'Root 20' }, + ] as TestRoot[], + 2, + ]); + + // ACT + await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'comments', + }, + join: [{ relation: 'comments' }], + take: 2, + skip: 5, + }); + + // ASSERT + const firstBatch = commentRepo.findAndCount.mock.calls[0][0]; + const secondBatch = commentRepo.findAndCount.mock.calls[1][0]; + + expect(firstBatch?.skip).toBe(5); + // Buffer offset for the second batch is 2 (batchSize == take); the + // effective skip must stay relative to the caller's skip of 5, i.e. 7 — + // not the bare buffer offset of 2. + expect(secondBatch?.skip).toBe(7); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/relation-sort-behavior.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/relation-sort-behavior.spec.ts new file mode 100644 index 000000000..d074857aa --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/relation-sort-behavior.spec.ts @@ -0,0 +1,480 @@ +/** + * Behavior tests for relation sort strategy (RELATION_FIRST). + * + * Relation sort causes RELATION_FIRST sequencing: + * 1. Discovery: peer query with sort + injected filters + pagination + * 2. Root fetch: constrained to discovered root IDs + * 3. Hydration: peer query with FK constraint for enrichment + * 4. Reorder: roots reordered to match discovery order + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/relation-sort-behavior.spec.ts + */ +import { + WhereCompoundOperator, + WhereOperator, +} from '../../../repository/repository.types.js'; +import { + type TestRoot, + type TestRelation, + createRelationSortByTitleSet, + createRelationSortByPrioritySet, + createRelationSortPaginationSet, +} from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Relation Sort Strategy', () => { + describe('Forward relationship relation sort', () => { + it('should sort roots by relation field with distinctFilter and NOT_NULL filter', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const data = createRelationSortByTitleSet(); + + // Discovery: first 3 relations sorted by title (one per unique root) + peerRepo.findAndCount + .mockResolvedValueOnce([data.relationsByTitle.slice(0, 3), 3]) + // Hydration: all 4 relations for discovered roots + .mockResolvedValueOnce([data.relationsByTitle, 4]); + + // Root fetch: constrained to discovered root IDs + rootRepo.findAndCount.mockResolvedValue([data.rootsInNaturalOrder, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - Handler call verification + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Relation called first (RELATION_FIRST strategy) + expect(peerRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + rootRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Discovery call (call 0): injected NOT_NULL + distinctFilter + sort + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'rootId', + operator: WhereOperator.NOT_NULL, + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + expect(discoveryCall?.order).toEqual([ + { field: 'title', order: 'ASC', relation: 'relations' }, + ]); + expect(discoveryCall?.take).toBe(10); + expect(discoveryCall?.skip).toBe(0); + + // Hydration call (call 1): same conditions + FK constraint + const hydrationCall = peerRepo.findAndCount.mock.calls[1][0]; + expect(hydrationCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'rootId', + operator: WhereOperator.NOT_NULL, + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + { + field: 'rootId', + operator: WhereOperator.IN, + value: [2, 1, 3], + }, + ], + }); + + // Root fetch: constrained to discovered IDs + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual({ + field: 'id', + operator: WhereOperator.IN, + value: [2, 1, 3], + }); + + // ASSERT - Result verification + expect(total).toBe(3); + expect(result).toHaveLength(3); + // Roots reordered by relation sort: Alpha(rootId:2), Beta(rootId:1), Charlie(rootId:3) + expect(result.map((r) => r.id)).toEqual([2, 1, 3]); + + // Verify enrichment + expect(result[0].relations).toEqual([ + { id: 1, rootId: 2, title: 'Alpha Task' }, + ]); + expect(result[1].relations).toEqual([ + { id: 2, rootId: 1, title: 'Beta Task' }, + { id: 4, rootId: 1, title: 'Delta Task' }, + ]); + expect(result[2].relations).toEqual([ + { id: 3, rootId: 3, title: 'Charlie Task' }, + ]); + }); + + it('should handle relation sort with additional AND filters', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const data = createRelationSortByPrioritySet(); + + // Only relations with priority >= 5, deduplicated by rootId + const highPriorityRelations = data.relationsByPriority + .filter((r) => r.priority! >= 5) + .filter( + (r, index, array) => + array.findIndex((x) => x.rootId === r.rootId) === index, + ); + + // All high-priority relations (no dedup) for hydration + const allHighPriority = data.relationsByPriority.filter( + (r) => r.priority! >= 5, + ); + + // Discovery: unique high-priority relations sorted by priority DESC + peerRepo.findAndCount + .mockResolvedValueOnce([highPriorityRelations, 3]) + // Hydration: all high-priority relations + .mockResolvedValueOnce([allHighPriority, 4]); + + // Root fetch: constrained to discovered root IDs + rootRepo.findAndCount.mockResolvedValue([data.uniqueRootsInOrder, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'priority', + operator: WhereOperator.GTE, + value: 5, + relation: 'relations', + }, + order: [{ field: 'priority', order: 'DESC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - Handler call verification + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Relation called first (RELATION_FIRST strategy) + expect(peerRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + rootRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Discovery call (call 0): user filter + injected NOT_NULL + distinctFilter + sort + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'priority', + operator: WhereOperator.GTE, + value: 5, + relation: 'relations', + }, + { + field: 'rootId', + operator: WhereOperator.NOT_NULL, + relation: 'relations', + }, + { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + relation: 'relations', + }, + ], + }); + expect(discoveryCall?.order).toEqual([ + { field: 'priority', order: 'DESC', relation: 'relations' }, + ]); + expect(discoveryCall?.take).toBe(10); + expect(discoveryCall?.skip).toBe(0); + + // ASSERT - Result verification + expect(total).toBe(3); + expect(result).toHaveLength(3); + expect(result.map((r) => r.id)).toEqual([1, 2, 3]); + }); + + it('should deduplicate roots when multiple relations match', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const data = createRelationSortByPrioritySet(); + + // Deduplicated by rootId (distinctFilter effect) + const uniqueRelations = data.relationsByPriority.filter( + (r, index, array) => + array.findIndex((x) => x.rootId === r.rootId) === index, + ); + + // Discovery: unique relations sorted by priority DESC + peerRepo.findAndCount + .mockResolvedValueOnce([uniqueRelations, 3]) + // Hydration: all relations + .mockResolvedValueOnce([ + data.relationsByPriority, + data.relationsByPriority.length, + ]); + + // Root fetch: constrained to discovered root IDs + rootRepo.findAndCount.mockResolvedValue([data.uniqueRootsInOrder, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'priority', order: 'DESC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - Handler call verification + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Roots appear only once despite multiple relations + expect(total).toBe(3); + expect(result).toHaveLength(3); + expect(result.map((r) => r.id)).toEqual([1, 2, 3]); + }); + + it('should return empty result when no relations match with sort', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + // Discovery returns empty + peerRepo.findAndCount.mockResolvedValue([[], 0]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'archived', + relation: 'relations', + }, + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - No relations found, so root not called + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(0); + + expect(result).toEqual([]); + expect(total).toBe(0); + }); + + it('should apply relation sort with pagination correctly', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const data = createRelationSortPaginationSet(); + const firstPageRelations = data.allRelationsSorted.slice(0, 5); + + // Discovery: first 5 sorted relations (page 1) + peerRepo.findAndCount + .mockResolvedValueOnce([firstPageRelations, 10]) + // Hydration: same 5 relations for enrichment + .mockResolvedValueOnce([firstPageRelations, 5]); + + // Root fetch: constrained to first-page root IDs + rootRepo.findAndCount.mockResolvedValue([data.firstPageRoots, 5]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 5, + skip: 0, + }); + + // ASSERT - Handler call verification + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Discovery call (call 0): pagination take=5, skip=0 + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.order).toEqual([ + { field: 'title', order: 'ASC', relation: 'relations' }, + ]); + expect(discoveryCall?.take).toBe(5); + expect(discoveryCall?.skip).toBe(0); + + // Root fetch: constrained to page 1 IDs + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual({ + field: 'id', + operator: WhereOperator.IN, + value: [5, 2, 8, 1, 9], + }); + + // ASSERT - Result verification + expect(total).toBe(10); + expect(result).toHaveLength(5); + expect(result.map((r) => r.id)).toEqual([5, 2, 8, 1, 9]); + }); + + it('should apply relation sort with pagination correctly for page 2', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const data = createRelationSortPaginationSet(); + const secondPageRelations = data.allRelationsSorted.slice(5, 10); + + // Discovery: second 5 sorted relations (page 2, skip=5) + peerRepo.findAndCount + .mockResolvedValueOnce([secondPageRelations, 10]) + // Hydration: same 5 relations for enrichment + .mockResolvedValueOnce([secondPageRelations, 5]); + + // Root fetch: constrained to second-page root IDs + rootRepo.findAndCount.mockResolvedValue([data.secondPageRoots, 5]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 5, + skip: 5, + }); + + // ASSERT - Handler call verification + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(2); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Discovery call (call 0): pagination take=5, skip=5 + const discoveryCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.order).toEqual([ + { field: 'title', order: 'ASC', relation: 'relations' }, + ]); + expect(discoveryCall?.take).toBe(5); + expect(discoveryCall?.skip).toBe(5); + + // Root fetch: constrained to page 2 IDs + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual({ + field: 'id', + operator: WhereOperator.IN, + value: [4, 7, 3, 6, 10], + }); + + // ASSERT - Result verification + expect(total).toBe(10); + expect(result).toHaveLength(5); + expect(result.map((r) => r.id)).toEqual([4, 7, 3, 6, 10]); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/relation-sort-validation.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/relation-sort-validation.spec.ts new file mode 100644 index 000000000..6ec1bdcfb --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/relation-sort-validation.spec.ts @@ -0,0 +1,300 @@ +/** + * Validation tests for relation sort requirements. + * + * Relation sort on many-cardinality relations requires distinctFilter + * configuration to ensure deterministic deduplication. + * Tests various invalid configurations and validates helpful error messages. + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/relation-sort-validation.spec.ts + */ +import { WhereOperator } from '../../../repository/repository.types.js'; +import { FederationException } from '../../exceptions/federation.exception.js'; +import { type TestRoot, type TestRelation } from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Relation Sort Validation', () => { + describe('Forward relationship validation', () => { + it('should throw error when relation sort lacks distinctFilter', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + // No distinctFilter + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // ACT & ASSERT + const error = await orchestrator + .findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(FederationException); + expect(error.message).toContain('distinctFilter'); + + // No handlers should be called when validation fails + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(0); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(0); + }); + + it('should throw error when relation sort has relation filters but no distinctFilter', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + // No distinctFilter — filters alone don't satisfy the requirement + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // ACT & ASSERT + const error = await orchestrator + .findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + order: [{ field: 'priority', order: 'DESC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(FederationException); + expect(error.message).toContain('distinctFilter'); + + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(0); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(0); + }); + + it('should throw error when relation sort has multiple filters but no distinctFilter', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + // No distinctFilter + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // ACT & ASSERT + const error = await orchestrator + .findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(FederationException); + expect(error.message).toContain('distinctFilter'); + + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(0); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(0); + }); + + it('should throw error when relation sort has AND filters on non-join fields only', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + // No distinctFilter + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // ACT & ASSERT — Multiple relation filters, none with distinctFilter + const error = await orchestrator + .findAndCount(rootRepo, { + where: { + operator: 'and' as never, + conditions: [ + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + { + field: 'priority', + operator: WhereOperator.GTE, + value: 5, + relation: 'relations', + }, + ], + }, + order: [{ field: 'createdAt', order: 'DESC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(FederationException); + expect(error.message).toContain('distinctFilter'); + + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(0); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(0); + }); + + it('should provide helpful error message with relation name', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + // No distinctFilter + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // ACT & ASSERT + const error = await orchestrator + .findAndCount(rootRepo, { + order: [{ field: 'priority', order: 'DESC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(FederationException); + expect(error.message).toContain('distinctFilter'); + expect(error.message).toContain('relations'); + }); + }); + + describe('Mixed filter scenarios', () => { + it('should throw error when root filters exist but no distinctFilter on sorted relation', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + // No distinctFilter + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // ACT & ASSERT — Root filter + relation sort without distinctFilter + const error = await orchestrator + .findAndCount(rootRepo, { + where: { + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'Project', + }, + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(FederationException); + expect(error.message).toContain('distinctFilter'); + }); + }); + + describe('Valid configurations (should not throw)', () => { + it('should not throw error when distinctFilter is configured', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // Mock empty responses + peerRepo.findAndCount.mockResolvedValue([[], 0]); + + // ACT - Should not throw + const [result] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - Validation passed, relation handler called + expect(result).toEqual([]); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(0); + }); + + it('should not throw error when distinctFilter is configured with additional filters', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // Mock empty responses + peerRepo.findAndCount.mockResolvedValue([[], 0]); + + // ACT - Should not throw + const [result] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + order: [{ field: 'priority', order: 'DESC', relation: 'relations' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - Validation passed + expect(result).toEqual([]); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/root-sort-behavior.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/root-sort-behavior.spec.ts new file mode 100644 index 000000000..6d1b522bb --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/root-sort-behavior.spec.ts @@ -0,0 +1,247 @@ +/** + * Behavior tests for root sort strategy (LEFT JOIN compatible). + * + * Root sort allows LEFT JOIN behavior — all roots returned, sorted by root field. + * No constraint validation needed for root sorts. + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/root-sort-behavior.spec.ts + */ +import { WhereOperator } from '../../../repository/repository.types.js'; +import { + type TestRoot, + type TestRelation, + createNameSortDataSet, + createIdDescSortDataSet, + createMultiSortDataSet, +} from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Root Sort Strategy', () => { + describe('Single root field sort', () => { + it('should sort roots by name with LEFT JOIN behavior', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const data = createNameSortDataSet(); + + rootRepo.findAndCount.mockResolvedValue([data.roots, 3]); + peerRepo.findAndCount.mockResolvedValue([data.relations, 2]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'name', order: 'ASC' }], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - Handler call verification + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Root called first (ROOT_FIRST strategy) + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + peerRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Root query: LEFT JOIN = no filter constraints, just sort + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toBeUndefined(); + expect(rootCall?.order).toEqual([{ field: 'name', order: 'ASC' }]); + expect(rootCall?.take).toBe(10); + expect(rootCall?.skip).toBe(0); + + // ASSERT - Result verification + expect(total).toBe(3); + expect(result).toHaveLength(3); + expect(result.map((r) => r.id)).toEqual([1, 3, 2]); // Root A, Root B, Root C + + // Verify enrichment + expect(result[0].relations).toEqual([ + { id: 1, rootId: 1, title: 'Relation 1' }, + ]); + expect(result[1].relations).toEqual([ + { id: 2, rootId: 3, title: 'Relation 2' }, + ]); + expect(result[2].relations).toEqual([]); // Root 2 has no relations + }); + + it('should sort roots by id descending with LEFT JOIN behavior', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const data = createIdDescSortDataSet(); + + rootRepo.findAndCount.mockResolvedValue([data.roots, 5]); + peerRepo.findAndCount.mockResolvedValue([data.relations, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'id', order: 'DESC' }], + join: [{ relation: 'relations' }], + take: 5, + skip: 0, + }); + + // ASSERT + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Root called first + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + peerRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Root query: sort by id DESC + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toBeUndefined(); + expect(rootCall?.order).toEqual([{ field: 'id', order: 'DESC' }]); + + // Result verification + expect(total).toBe(5); + expect(result).toHaveLength(5); + expect(result.map((r) => r.id)).toEqual([3, 4, 5, 2, 1]); + + // Enrichment verification + expect(result[0].relations).toEqual([]); // id:3 no relations + expect(result[1].relations).toEqual([ + { id: 2, rootId: 4, title: 'Relation 2' }, + { id: 3, rootId: 4, title: 'Relation 3' }, + ]); + expect(result[2].relations).toEqual([]); // id:5 no relations + expect(result[3].relations).toEqual([ + { id: 1, rootId: 2, title: 'Relation 1' }, + ]); + expect(result[4].relations).toEqual([]); // id:1 no relations + }); + }); + + describe('Multiple root field sorts', () => { + it('should sort roots by multiple fields with LEFT JOIN behavior', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const data = createMultiSortDataSet(); + + rootRepo.findAndCount.mockResolvedValue([data.roots, 3]); + peerRepo.findAndCount.mockResolvedValue([data.relations, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + order: [ + { field: 'name', order: 'ASC' }, + { field: 'id', order: 'DESC' }, + ], + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(1); + + // Root query: multi-field sort + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toBeUndefined(); + expect(rootCall?.order).toEqual([ + { field: 'name', order: 'ASC' }, + { field: 'id', order: 'DESC' }, + ]); + + // Result verification + expect(total).toBe(3); + expect(result).toHaveLength(3); + expect(result.map((r) => r.id)).toEqual([3, 1, 2]); // Root A(3), Root A(1), Root B(2) + + // Enrichment + expect(result[0].relations).toEqual([ + { id: 3, rootId: 3, title: 'Relation 3' }, + ]); + expect(result[1].relations).toEqual([ + { id: 1, rootId: 1, title: 'Relation 1' }, + { id: 2, rootId: 1, title: 'Relation 2' }, + ]); + expect(result[2].relations).toEqual([]); // Root 2 has no relations + }); + }); + + describe('Root sort with filters', () => { + it('should apply root filter and sort together', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: peerRepo, + }); + + const filteredRoots = [{ id: 1, name: 'Root A' } as TestRoot]; + + rootRepo.findAndCount.mockResolvedValue([filteredRoots, 1]); + peerRepo.findAndCount.mockResolvedValue([ + [{ id: 1, rootId: 1, title: 'Relation 1' } as TestRelation], + 1, + ]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { field: 'name', operator: WhereOperator.CONTAINS, value: 'A' }, + order: [{ field: 'name', order: 'ASC' }], + join: [{ relation: 'relations' }], + take: 10, + }); + + // ASSERT + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual({ + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'A', + }); + expect(rootCall?.order).toEqual([{ field: 'name', order: 'ASC' }]); + + expect(total).toBe(1); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + expect(result[0].relations).toEqual([ + { id: 1, rootId: 1, title: 'Relation 1' }, + ]); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/behavior/unsupported-features.spec.ts b/packages/nestjs-repository/src/federation/__tests__/behavior/unsupported-features.spec.ts new file mode 100644 index 000000000..57e8511cf --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/behavior/unsupported-features.spec.ts @@ -0,0 +1,140 @@ +/** + * Validation tests for unsupported federation features. + * + * Tests that OR compounds containing relation-tagged conditions + * are rejected with a clear error message. + * + * Ported from nestjs-crud __tests__/crud-federation/behavior/unsupported-features.spec.ts + */ +import { + WhereCompoundOperator, + WhereOperator, +} from '../../../repository/repository.types.js'; +import { FederationException } from '../../exceptions/federation.exception.js'; +import { type TestRoot, type TestRelation } from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Unsupported Features Validation', () => { + describe('OR filter validation', () => { + it('should throw error when OR compound contains relation-tagged condition', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // ACT & ASSERT + await expect( + orchestrator.findAndCount(rootRepo, { + where: { + operator: WhereCompoundOperator.OR, + conditions: [ + { + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'test', + }, + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + ], + }, + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }), + ).rejects.toThrow(FederationException); + + expect(rootRepo.findAndCount).not.toHaveBeenCalled(); + expect(peerRepo.findAndCount).not.toHaveBeenCalled(); + }); + + it('should throw with descriptive message mentioning the relation name', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // ACT + const error = await orchestrator + .findAndCount(rootRepo, { + where: { + operator: WhereCompoundOperator.OR, + conditions: [ + { + field: 'title', + operator: WhereOperator.CONTAINS, + value: 'test', + relation: 'relations', + }, + ], + }, + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(FederationException); + expect(error.message).toContain('OR'); + expect(error.message).toContain('relations'); + }); + + it('should not throw error when AND compound is used normally', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + peerRepo.findAndCount.mockResolvedValue([[], 0]); + + // ACT - AND compound with relation filter: should work fine + const [result, total] = await orchestrator.findAndCount(rootRepo, { + where: { + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'status', + operator: WhereOperator.EQ, + value: 'active', + relation: 'relations', + }, + ], + }, + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - No error + expect(result).toEqual([]); + expect(total).toBe(0); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/buffer-strategy.spec.ts b/packages/nestjs-repository/src/federation/__tests__/buffer-strategy.spec.ts new file mode 100644 index 000000000..48daf4593 --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/buffer-strategy.spec.ts @@ -0,0 +1,48 @@ +import { BufferStrategy } from '../buffer-strategy.js'; +import { FEDERATION_MAX_BUFFER_SIZE } from '../federation.constants.js'; + +describe('BufferStrategy', () => { + it('should advance with default batch size equal to user limit', () => { + const buffer = new BufferStrategy(10); + const first = buffer.advance(); + expect(first).toEqual({ limit: 10, offset: 0 }); + + const second = buffer.advance(); + expect(second).toEqual({ limit: 10, offset: 10 }); + }); + + it('should use custom batch size', () => { + const buffer = new BufferStrategy(10, { batchSize: 25 }); + const first = buffer.advance(); + expect(first).toEqual({ limit: 25, offset: 0 }); + + const second = buffer.advance(); + expect(second).toEqual({ limit: 25, offset: 25 }); + }); + + it('should report limit reached when offset exceeds maxOffset', () => { + const buffer = new BufferStrategy(10, { maxOffset: 20 }); + expect(buffer.hasReachedLimit()).toBe(false); + + buffer.advance(); // offset → 10 + expect(buffer.hasReachedLimit()).toBe(false); + + buffer.advance(); // offset → 20 + expect(buffer.hasReachedLimit()).toBe(true); + }); + + it('should cap maxOffset at FEDERATION_MAX_BUFFER_SIZE', () => { + const buffer = new BufferStrategy(10, { + maxOffset: FEDERATION_MAX_BUFFER_SIZE + 500, + }); + + // Advance to FEDERATION_MAX_BUFFER_SIZE + let advances = 0; + while (!buffer.hasReachedLimit()) { + buffer.advance(); + advances++; + } + + expect(advances).toBe(FEDERATION_MAX_BUFFER_SIZE / 10); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/execution-strategy.spec.ts b/packages/nestjs-repository/src/federation/__tests__/execution-strategy.spec.ts new file mode 100644 index 000000000..55040842f --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/execution-strategy.spec.ts @@ -0,0 +1,203 @@ +import { WhereOperator } from '../../repository/repository.types.js'; +import { FederationException } from '../exceptions/federation.exception.js'; +import { analyzeExecution } from '../execution-strategy.js'; +import { + type FederatedRelation, + FederationStrategy, +} from '../federation.types.js'; +import { FilterAnalyzer } from '../filter-analyzer.js'; + +const makeRelation = ( + overrides: Partial & { name: string }, +): FederatedRelation => ({ + targetEntity: `${overrides.name}Entity`, + cardinality: 'many', + on: { from: 'id', to: `${overrides.name}Id` }, + isOwning: false, + joinType: 'LEFT', + ...overrides, +}); + +describe('analyzeExecution', () => { + const posts = makeRelation({ name: 'posts' }); + const postsWithDistinct = makeRelation({ + name: 'posts', + distinctFilter: { + field: 'published', + operator: WhereOperator.EQ, + value: true, + }, + }); + const comments = makeRelation({ name: 'comments' }); + + it('should select ROOT_FIRST when no relation sorts or filters', () => { + const filterAnalyzer = new FilterAnalyzer(undefined, [posts], new Set()); + const result = analyzeExecution( + filterAnalyzer, + [{ field: 'createdAt', order: 'DESC' }], + [posts], + ); + + expect(result.strategy).toBe(FederationStrategy.ROOT_FIRST); + expect(result.rootOrder).toEqual([{ field: 'createdAt', order: 'DESC' }]); + expect(result.relationOrders.size).toBe(0); + expect(result.sortedRelationNames.size).toBe(0); + }); + + it('should select RELATION_FIRST when relation sort exists', () => { + const filterAnalyzer = new FilterAnalyzer( + undefined, + [postsWithDistinct], + new Set(['posts']), + ); + const result = analyzeExecution( + filterAnalyzer, + [{ field: 'title', order: 'ASC', relation: 'posts' }], + [postsWithDistinct], + ); + + expect(result.strategy).toBe(FederationStrategy.RELATION_FIRST); + expect(result.rootOrder).toBeUndefined(); + expect(result.relationOrders.get('posts')).toEqual([ + { field: 'title', order: 'ASC', relation: 'posts' }, + ]); + expect(result.drivingRelation).toBe(postsWithDistinct); + }); + + it('should select RELATION_FIRST when relation filter exists', () => { + const where = { + field: 'title', + operator: WhereOperator.EQ, + value: 'hello', + relation: 'posts', + }; + const filterAnalyzer = new FilterAnalyzer( + where, + [postsWithDistinct], + new Set(), + ); + const result = analyzeExecution(filterAnalyzer, undefined, [ + postsWithDistinct, + ]); + + expect(result.strategy).toBe(FederationStrategy.RELATION_FIRST); + expect(result.drivingRelation).toBe(postsWithDistinct); + }); + + it('should separate mixed root and relation orders', () => { + const filterAnalyzer = new FilterAnalyzer( + undefined, + [postsWithDistinct], + new Set(['posts']), + ); + const result = analyzeExecution( + filterAnalyzer, + [ + { field: 'createdAt', order: 'DESC' }, + { field: 'title', order: 'ASC', relation: 'posts' }, + ], + [postsWithDistinct], + ); + + expect(result.rootOrder).toEqual([{ field: 'createdAt', order: 'DESC' }]); + expect(result.relationOrders.get('posts')).toEqual([ + { field: 'title', order: 'ASC', relation: 'posts' }, + ]); + }); + + it('should throw when sorting on many-cardinality relation without distinctFilter', () => { + const manyRelation = makeRelation({ + name: 'posts', + cardinality: 'many', + }); + const filterAnalyzer = new FilterAnalyzer( + undefined, + [manyRelation], + new Set(['posts']), + ); + + expect(() => + analyzeExecution( + filterAnalyzer, + [{ field: 'title', order: 'ASC', relation: 'posts' }], + [manyRelation], + ), + ).toThrow(FederationException); + }); + + it('should allow sorting on many-cardinality relation with distinctFilter', () => { + const manyWithFilter = makeRelation({ + name: 'posts', + cardinality: 'many', + distinctFilter: { + field: 'published', + operator: WhereOperator.EQ, + value: true, + }, + }); + const filterAnalyzer = new FilterAnalyzer( + undefined, + [manyWithFilter], + new Set(['posts']), + ); + + expect(() => + analyzeExecution( + filterAnalyzer, + [{ field: 'title', order: 'ASC', relation: 'posts' }], + [manyWithFilter], + ), + ).not.toThrow(); + }); + + it('should allow sorting on one-cardinality relation without distinctFilter', () => { + const oneRelation = makeRelation({ + name: 'profile', + cardinality: 'one', + }); + const filterAnalyzer = new FilterAnalyzer( + undefined, + [oneRelation], + new Set(['profile']), + ); + + expect(() => + analyzeExecution( + filterAnalyzer, + [{ field: 'bio', order: 'ASC', relation: 'profile' }], + [oneRelation], + ), + ).not.toThrow(); + }); + + it('should prefer sorted relation as driving over filtered relation', () => { + const where = { + field: 'body', + operator: WhereOperator.EQ, + value: 'test', + relation: 'comments', + }; + const filterAnalyzer = new FilterAnalyzer( + where, + [postsWithDistinct, comments], + new Set(['posts']), + ); + + const result = analyzeExecution( + filterAnalyzer, + [{ field: 'title', order: 'ASC', relation: 'posts' }], + [postsWithDistinct, comments], + ); + + expect(result.drivingRelation).toBe(postsWithDistinct); + }); + + it('should handle no order at all', () => { + const filterAnalyzer = new FilterAnalyzer(undefined, [posts], new Set()); + const result = analyzeExecution(filterAnalyzer, undefined, [posts]); + + expect(result.strategy).toBe(FederationStrategy.ROOT_FIRST); + expect(result.rootOrder).toBeUndefined(); + expect(result.relationOrders.size).toBe(0); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/federation-orchestrator.spec.ts b/packages/nestjs-repository/src/federation/__tests__/federation-orchestrator.spec.ts new file mode 100644 index 000000000..2f0025371 --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/federation-orchestrator.spec.ts @@ -0,0 +1,972 @@ +import { WhereOperator } from '../../repository/repository.types.js'; +import { Where } from '../../repository/where.helpers.js'; +import { FederationException } from '../exceptions/federation.exception.js'; + +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, + mockOneToOneRelation, + mockOwningOneToOneRelation, + mockContext, + type TestRoot, + type TestRelation, + type TestProfile, +} from './fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator', () => { + // ═════════════════════════════════════════════════════════════════════ + // No relations (passthrough) + // ═════════════════════════════════════════════════════════════════════ + + describe('no federated relations', () => { + it('should delegate to root repo when no joins are provided', async () => { + const rootRepo = mockTestRepo('UserEntity'); + const { orchestrator } = mockOrchestrator({}); + + const expected: [TestRoot[], number] = [[{ id: 1, name: 'Alice' }], 1]; + rootRepo.findAndCount.mockResolvedValue(expected); + + const result = await orchestrator.findAndCount(rootRepo, { + where: { field: 'name', operator: WhereOperator.EQ, value: 'Alice' }, + }); + + expect(result).toEqual(expected); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + }); + + it('should delegate when joins exist but none are federated', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity', { + federated: false, + }); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const { orchestrator } = mockOrchestrator({}); + + const expected: [TestRoot[], number] = [[{ id: 1, name: 'Alice' }], 1]; + rootRepo.findAndCount.mockResolvedValue(expected); + + const result = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }], + }); + + expect(result).toEqual(expected); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + }); + + it('should delegate when no join matches a federated relation', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const { orchestrator } = mockOrchestrator({}); + + const expected: [TestRoot[], number] = [[{ id: 1, name: 'Alice' }], 1]; + rootRepo.findAndCount.mockResolvedValue(expected); + + // Join references a different relation than the federated one + const result = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'comments' }], + }); + + expect(result).toEqual(expected); + }); + }); + + // ═════════════════════════════════════════════════════════════════════ + // ROOT_FIRST strategy + // ═════════════════════════════════════════════════════════════════════ + + describe('ROOT_FIRST strategy', () => { + it('should fetch roots first then hydrate one-to-many relation', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + rootRepo.findAndCount.mockResolvedValue([ + [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + 2, + ]); + postRepo.findAndCount.mockResolvedValue([ + [ + { id: 10, userId: 1, title: 'Post A' }, + { id: 11, userId: 1, title: 'Post B' }, + { id: 12, userId: 2, title: 'Post C' }, + ], + 3, + ]); + + const [data, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }], + }); + + expect(total).toBe(2); + expect(data).toHaveLength(2); + expect(data[0].posts).toEqual([ + { id: 10, userId: 1, title: 'Post A' }, + { id: 11, userId: 1, title: 'Post B' }, + ]); + expect(data[1].posts).toEqual([{ id: 12, userId: 2, title: 'Post C' }]); + }); + + it('should hydrate one-to-one non-owning relation', async () => { + const profileRelation = mockOneToOneRelation('profile', 'ProfileEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [profileRelation], + }); + const profileRepo = mockTestRepo('ProfileEntity'); + const { orchestrator } = mockOrchestrator({ + ProfileEntity: profileRepo, + }); + + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + profileRepo.findAndCount.mockResolvedValue([ + [{ id: 5, userId: 1, bio: 'Hello' }], + 1, + ]); + + const [data] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'profile' }], + }); + + expect(data[0].profile).toEqual({ id: 5, userId: 1, bio: 'Hello' }); + }); + + it('should hydrate owning one-to-one relation via root FK', async () => { + const blogRelation = mockOwningOneToOneRelation( + 'blog', + 'BlogEntity', + 'blogId', + ); + const rootRepo = mockTestRepo('UserEntity', { + relations: [blogRelation], + }); + const blogRepo = mockTestRepo('BlogEntity'); + const { orchestrator } = mockOrchestrator({ BlogEntity: blogRepo }); + + rootRepo.findAndCount.mockResolvedValue([ + [{ id: 1, name: 'Alice', blogId: 100 }], + 1, + ]); + blogRepo.findAndCount.mockResolvedValue([ + [{ id: 100, title: 'My Blog' }], + 1, + ]); + + const [data] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'blog' }], + }); + + expect(data[0].blog).toEqual({ id: 100, title: 'My Blog' }); + }); + + it('should set null for one-cardinality with no match', async () => { + const profileRelation = mockOneToOneRelation('profile', 'ProfileEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [profileRelation], + }); + const profileRepo = mockTestRepo('ProfileEntity'); + const { orchestrator } = mockOrchestrator({ + ProfileEntity: profileRepo, + }); + + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + profileRepo.findAndCount.mockResolvedValue([[], 0]); + + const [data] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'profile' }], + }); + + expect(data[0].profile).toBeNull(); + }); + + it('should set empty array for many-cardinality with no matches', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + postRepo.findAndCount.mockResolvedValue([[], 0]); + + const [data] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }], + }); + + expect(data[0].posts).toEqual([]); + }); + + it('should return empty when root query returns no results', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + rootRepo.findAndCount.mockResolvedValue([[], 0]); + + const [data, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }], + }); + + expect(data).toEqual([]); + expect(total).toBe(0); + expect(postRepo.findAndCount).not.toHaveBeenCalled(); + }); + + it('should hydrate multiple relations in parallel', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const profileRelation = mockOneToOneRelation('profile', 'ProfileEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation, profileRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const profileRepo = mockTestRepo('ProfileEntity'); + const { orchestrator } = mockOrchestrator({ + PostEntity: postRepo, + ProfileEntity: profileRepo, + }); + + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + postRepo.findAndCount.mockResolvedValue([ + [{ id: 10, userId: 1, title: 'Post' }], + 1, + ]); + profileRepo.findAndCount.mockResolvedValue([ + [{ id: 5, userId: 1, bio: 'Hello' }], + 1, + ]); + + const [data] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }, { relation: 'profile' }], + }); + + expect(data[0].posts).toEqual([{ id: 10, userId: 1, title: 'Post' }]); + expect(data[0].profile).toEqual({ id: 5, userId: 1, bio: 'Hello' }); + }); + + it('should pass root-only where and order, stripping federated joins', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + postRepo.findAndCount.mockResolvedValue([ + [{ id: 10, userId: 1, title: 'Post' }], + 1, + ]); + + await orchestrator.findAndCount(rootRepo, { + where: { field: 'name', operator: WhereOperator.EQ, value: 'Alice' }, + order: [{ field: 'createdAt', order: 'DESC' }], + join: [{ relation: 'posts' }], + take: 5, + skip: 0, + }); + + // Root query should have root-only conditions and no federated joins + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual({ + field: 'name', + operator: WhereOperator.EQ, + value: 'Alice', + }); + expect(rootCall?.order).toEqual([{ field: 'createdAt', order: 'DESC' }]); + expect(rootCall?.join).toBeUndefined(); + }); + + it('should preserve non-federated joins in root query', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const commentsRelation = mockOneToManyRelation( + 'comments', + 'CommentEntity', + { federated: false }, + ); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation, commentsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + postRepo.findAndCount.mockResolvedValue([[], 0]); + + await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }, { relation: 'comments' }], + }); + + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.join).toEqual([{ relation: 'comments' }]); + }); + }); + + // ═════════════════════════════════════════════════════════════════════ + // RELATION_FIRST strategy (relation filter) + // ═════════════════════════════════════════════════════════════════════ + + describe('RELATION_FIRST strategy (relation filter)', () => { + it('should discover root IDs via relation query when filter targets relation', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity', { + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + // Discovery phase: peer query returns posts with userId references + postRepo.findAndCount.mockResolvedValueOnce([ + [ + { id: 10, userId: 1, title: 'Match' }, + { id: 11, userId: 2, title: 'Also Match' }, + ], + 2, + ]); + + // Constrained root fetch + rootRepo.findAndCount.mockResolvedValue([ + [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + 2, + ]); + rootRepo.count.mockResolvedValue(100); + + // Hydration fetch + postRepo.findAndCount.mockResolvedValueOnce([ + [ + { id: 10, userId: 1, title: 'Match' }, + { id: 11, userId: 2, title: 'Also Match' }, + ], + 2, + ]); + + const [data, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'title', + operator: WhereOperator.EQ, + value: 'Match', + relation: 'posts', + }, + join: [{ relation: 'posts' }], + }); + + expect(data).toHaveLength(2); + expect(total).toBe(2); + }); + + it('should return empty when relation discovery finds no matches', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity', { + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + rootRepo.count.mockResolvedValue(100); + postRepo.findAndCount.mockResolvedValue([[], 0]); + + const [data, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'title', + operator: WhereOperator.EQ, + value: 'Nonexistent', + relation: 'posts', + }, + join: [{ relation: 'posts' }], + }); + + expect(data).toEqual([]); + expect(total).toBe(0); + }); + }); + + // ═════════════════════════════════════════════════════════════════════ + // RELATION_FIRST strategy (relation sort) + // ═════════════════════════════════════════════════════════════════════ + + describe('RELATION_FIRST strategy (relation sort)', () => { + it('should use relation sort to drive root ordering', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity', { + distinctFilter: { + field: 'published', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + // Discovery: sorted relation data determines root order + postRepo.findAndCount.mockResolvedValueOnce([ + [ + { id: 12, userId: 2, title: 'AAA', published: true }, + { id: 10, userId: 1, title: 'BBB', published: true }, + ], + 2, + ]); + + // Constrained root fetch (comes back in DB order, not relation order) + rootRepo.findAndCount.mockResolvedValue([ + [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + 2, + ]); + rootRepo.count.mockResolvedValue(100); + + // Hydration fetch + postRepo.findAndCount.mockResolvedValueOnce([ + [ + { id: 12, userId: 2, title: 'AAA', published: true }, + { id: 10, userId: 1, title: 'BBB', published: true }, + ], + 2, + ]); + + const [data] = await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'posts' }], + join: [{ relation: 'posts' }], + }); + + // Bob (userId=2) should be first because his post 'AAA' sorts before 'BBB' + expect(data[0].id).toBe(2); + expect(data[1].id).toBe(1); + }); + + it('should pass relation order to peer query during discovery', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity', { + distinctFilter: { + field: 'published', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + postRepo.findAndCount.mockResolvedValueOnce([ + [{ id: 10, userId: 1, title: 'Post', published: true }], + 1, + ]); + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + rootRepo.count.mockResolvedValue(1); + postRepo.findAndCount.mockResolvedValueOnce([ + [{ id: 10, userId: 1, title: 'Post', published: true }], + 1, + ]); + + await orchestrator.findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'posts' }], + join: [{ relation: 'posts' }], + }); + + // First peer call (discovery) should include order + const discoveryCall = postRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.order).toEqual([ + { field: 'title', order: 'ASC', relation: 'posts' }, + ]); + }); + }); + + // ═════════════════════════════════════════════════════════════════════ + // Pagination + // ═════════════════════════════════════════════════════════════════════ + + describe('pagination', () => { + it('should apply take and skip to root query in ROOT_FIRST', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + rootRepo.findAndCount.mockResolvedValue([ + [{ id: 3, name: 'Charlie' }], + 10, + ]); + postRepo.findAndCount.mockResolvedValue([[], 0]); + + const [data, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }], + take: 1, + skip: 2, + }); + + expect(data).toHaveLength(1); + expect(total).toBe(10); + + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.take).toBe(1); + expect(rootCall?.skip).toBe(2); + }); + + it('should use default limit when take is not specified', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + postRepo.findAndCount.mockResolvedValue([[], 0]); + + await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }], + }); + + // Default limit is only used in RELATION_FIRST buffer strategy; + // ROOT_FIRST passes through the caller's take (undefined here) + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.take).toBeUndefined(); + }); + + it('should compute accurate total in RELATION_FIRST as min of root and relation totals', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity', { + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + rootRepo.count.mockResolvedValue(50); + + // Discovery returns 3 matching relation rows (total=30) + postRepo.findAndCount.mockResolvedValueOnce([ + [ + { id: 10, userId: 1, title: 'A' }, + { id: 11, userId: 2, title: 'B' }, + ], + 30, + ]); + + // Constrained root fetch + rootRepo.findAndCount.mockResolvedValue([ + [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + 2, + ]); + + // Hydration + postRepo.findAndCount.mockResolvedValueOnce([ + [ + { id: 10, userId: 1, title: 'A' }, + { id: 11, userId: 2, title: 'B' }, + ], + 2, + ]); + + const [, total] = await orchestrator.findAndCount(rootRepo, { + where: { + field: 'title', + operator: WhereOperator.CONTAINS, + value: 'test', + relation: 'posts', + }, + join: [{ relation: 'posts' }], + }); + + // min(rootFilterTotal=50, relationTotal=30) + expect(total).toBe(30); + }); + }); + + // ═════════════════════════════════════════════════════════════════════ + // INNER JOIN behavior + // ═════════════════════════════════════════════════════════════════════ + + describe('INNER JOIN behavior', () => { + it('should inject NOT_NULL filter on root FK for owning INNER JOIN', async () => { + const blogRelation = mockOwningOneToOneRelation( + 'blog', + 'BlogEntity', + 'blogId', + ); + const rootRepo = mockTestRepo('UserEntity', { + relations: [blogRelation], + }); + const blogRepo = mockTestRepo('BlogEntity'); + const { orchestrator } = mockOrchestrator({ BlogEntity: blogRepo }); + + rootRepo.findAndCount.mockResolvedValue([ + [{ id: 1, name: 'Alice', blogId: 100 }], + 1, + ]); + blogRepo.findAndCount.mockResolvedValue([ + [{ id: 100, title: 'Blog' }], + 1, + ]); + + await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'blog', joinType: 'INNER' }], + }); + + // Root query should include NOT_NULL condition on blogId + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual({ + field: 'blogId', + operator: WhereOperator.NOT_NULL, + }); + }); + + it('should inject NOT_NULL on target FK for non-owning INNER JOIN', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + // The NOT_NULL condition on userId triggers RELATION_FIRST + postRepo.findAndCount.mockResolvedValueOnce([[{ id: 10, userId: 1 }], 1]); + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + rootRepo.count.mockResolvedValue(1); + postRepo.findAndCount.mockResolvedValueOnce([[{ id: 10, userId: 1 }], 1]); + + await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts', joinType: 'INNER' }], + }); + + // Discovery peer query should include NOT_NULL on userId + const discoveryCall = postRepo.findAndCount.mock.calls[0][0]; + expect(discoveryCall?.where).toEqual( + expect.objectContaining({ + field: 'userId', + operator: WhereOperator.NOT_NULL, + }), + ); + }); + }); + + // ═════════════════════════════════════════════════════════════════════ + // Context propagation + // ═════════════════════════════════════════════════════════════════════ + + describe('context propagation', () => { + it('should pass ctx to all repository calls', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + const ctx = mockContext({ entity: 'UserEntity' }); + + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + postRepo.findAndCount.mockResolvedValue([ + [{ id: 10, userId: 1, title: 'Post' }], + 1, + ]); + + await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }], + ctx, + }); + + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.ctx).toBe(ctx); + + const peerCall = postRepo.findAndCount.mock.calls[0][0]; + expect(peerCall?.ctx).toBe(ctx); + }); + }); + + // ═════════════════════════════════════════════════════════════════════ + // Error handling + // ═════════════════════════════════════════════════════════════════════ + + describe('error handling', () => { + it('should throw when entity has no primary key', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + // Override columns to have no primary key + (rootRepo.metadata as { columns: unknown[] }).columns = []; + + const { orchestrator } = mockOrchestrator({}); + + await expect( + orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }], + }), + ).rejects.toThrow(FederationException); + }); + + it('should throw when peer entity is not registered', async () => { + const postsRelation = mockOneToManyRelation( + 'posts', + 'UnregisteredEntity', + ); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + // No peer repo registered for UnregisteredEntity + const { orchestrator } = mockOrchestrator({}); + + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + + await expect( + orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }], + }), + ).rejects.toThrow(FederationException); + }); + + it('should throw when sorting many-cardinality without distinctFilter', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + await expect( + orchestrator.findAndCount(rootRepo, { + order: [{ field: 'title', order: 'ASC', relation: 'posts' }], + join: [{ relation: 'posts' }], + }), + ).rejects.toThrow(FederationException); + }); + + it('should throw when relation filter is inside OR compound', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + await expect( + orchestrator.findAndCount(rootRepo, { + where: { + operator: 'or' as never, + conditions: [ + { field: 'name', operator: WhereOperator.EQ, value: 'test' }, + { + field: 'title', + operator: WhereOperator.EQ, + value: 'hello', + relation: 'posts', + }, + ], + } as never, + join: [{ relation: 'posts' }], + }), + ).rejects.toThrow(FederationException); + }); + }); + + // ═════════════════════════════════════════════════════════════════════ + // Combined filters (root + relation) + // ═════════════════════════════════════════════════════════════════════ + + describe('combined root and relation filters', () => { + it('should separate root where from relation-tagged conditions', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity', { + distinctFilter: { + field: 'isLatest', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + rootRepo.count.mockResolvedValue(5); + + // Discovery peer query + postRepo.findAndCount.mockResolvedValueOnce([ + [{ id: 10, userId: 1, title: 'hello' }], + 1, + ]); + + // Constrained root fetch + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + + // Hydration + postRepo.findAndCount.mockResolvedValueOnce([ + [{ id: 10, userId: 1, title: 'hello' }], + 1, + ]); + + await orchestrator.findAndCount(rootRepo, { + where: { + operator: 'and' as never, + conditions: [ + { field: 'name', operator: WhereOperator.EQ, value: 'Alice' }, + { + field: 'title', + operator: WhereOperator.EQ, + value: 'hello', + relation: 'posts', + }, + ], + } as never, + join: [{ relation: 'posts' }], + }); + + // RELATION_FIRST: root findAndCount is fetchConstrainedRoots + // which ANDs the root condition with the discovered ID constraint + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.where).toEqual( + Where.and(Where.eq('name', 'Alice'), Where.eq('id', 1)), + ); + }); + }); + + // ═════════════════════════════════════════════════════════════════════ + // Complex multi-relation scenario + // ═════════════════════════════════════════════════════════════════════ + + describe('complex scenario', () => { + it('should handle multiple federated relations with mixed cardinality', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity'); + const profileRelation = mockOneToOneRelation('profile', 'ProfileEntity'); + const blogRelation = mockOwningOneToOneRelation( + 'blog', + 'BlogEntity', + 'blogId', + ); + + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation, profileRelation, blogRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const profileRepo = mockTestRepo('ProfileEntity'); + const blogRepo = mockTestRepo('BlogEntity'); + const { orchestrator } = mockOrchestrator({ + PostEntity: postRepo, + ProfileEntity: profileRepo, + BlogEntity: blogRepo, + }); + + rootRepo.findAndCount.mockResolvedValue([ + [ + { id: 1, name: 'Alice', blogId: 100 }, + { id: 2, name: 'Bob', blogId: null }, + ], + 2, + ]); + postRepo.findAndCount.mockResolvedValue([ + [ + { id: 10, userId: 1, title: 'Post A' }, + { id: 11, userId: 2, title: 'Post B' }, + ], + 2, + ]); + profileRepo.findAndCount.mockResolvedValue([ + [{ id: 5, userId: 1, bio: 'Hello' }], + 1, + ]); + blogRepo.findAndCount.mockResolvedValue([ + [{ id: 100, title: 'Blog A' }], + 1, + ]); + + const [data, total] = await orchestrator.findAndCount(rootRepo, { + join: [ + { relation: 'posts' }, + { relation: 'profile' }, + { relation: 'blog' }, + ], + }); + + expect(total).toBe(2); + expect(data).toHaveLength(2); + + // Alice: has posts, profile, and blog + expect(data[0].posts).toEqual([{ id: 10, userId: 1, title: 'Post A' }]); + expect(data[0].profile).toEqual({ id: 5, userId: 1, bio: 'Hello' }); + expect(data[0].blog).toEqual({ id: 100, title: 'Blog A' }); + + // Bob: has posts, no profile, no blog (FK is null) + expect(data[1].posts).toEqual([{ id: 11, userId: 2, title: 'Post B' }]); + expect(data[1].profile).toBeNull(); + expect(data[1].blog).toBeNull(); + }); + }); + + // ═════════════════════════════════════════════════════════════════════ + // distinctFilter handling + // ═════════════════════════════════════════════════════════════════════ + + describe('distinctFilter', () => { + it('should include distinctFilter in peer query conditions', async () => { + const postsRelation = mockOneToManyRelation('posts', 'PostEntity', { + distinctFilter: { + field: 'published', + operator: WhereOperator.EQ, + value: true, + }, + }); + const rootRepo = mockTestRepo('UserEntity', { + relations: [postsRelation], + }); + const postRepo = mockTestRepo('PostEntity'); + const { orchestrator } = mockOrchestrator({ PostEntity: postRepo }); + + rootRepo.findAndCount.mockResolvedValue([[{ id: 1, name: 'Alice' }], 1]); + postRepo.findAndCount.mockResolvedValue([ + [{ id: 10, userId: 1, title: 'Post', published: true }], + 1, + ]); + + await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'posts' }], + }); + + // Hydration peer query should include the distinctFilter condition + const peerCall = postRepo.findAndCount.mock.calls[0][0]; + const whereStr = JSON.stringify(peerCall?.where); + expect(whereStr).toContain('"field":"published"'); + expect(whereStr).toContain(`"operator":"${WhereOperator.EQ}"`); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/federation-test-data.ts b/packages/nestjs-repository/src/federation/__tests__/federation-test-data.ts new file mode 100644 index 000000000..350630cfd --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/federation-test-data.ts @@ -0,0 +1,250 @@ +/** + * Preset data builders for federation orchestrator tests. + * + * Ported from nestjs-crud __tests__/crud-federation/fixtures/crud-federation-test-data.ts + * Data shapes preserved exactly — only the import types changed. + */ +import { type PlainLiteralObject } from '@nestjs/common'; + +export interface TestRoot extends PlainLiteralObject { + id: number; + name: string; + companyId?: number; + [key: string]: unknown; +} + +export interface TestRelation extends PlainLiteralObject { + id: number; + rootId: number; + title: string; + priority?: number; + status?: string; + isLatest?: boolean; + [key: string]: unknown; +} + +export interface TestProfile extends PlainLiteralObject { + id: number; + rootId: number; + bio: string; + avatar?: string; + [key: string]: unknown; +} + +export interface TestSettings extends PlainLiteralObject { + id: number; + rootId: number; + theme: string; + notifications: boolean; + [key: string]: unknown; +} + +// Minimal root-relation dataset (2-3 entities for basic tests) +export const createMinimalRootRelationSet = () => ({ + roots: [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[], + + relations: [ + { id: 1, rootId: 1, title: 'Relation 1', isLatest: true }, + { id: 2, rootId: 2, title: 'Relation 2', isLatest: true }, + { id: 3, rootId: 2, title: 'Relation 3', isLatest: false }, + // Root 3 has no relations - useful for LEFT JOIN tests + ] as TestRelation[], +}); + +// Filtered dataset with mixed active/inactive states +export const createFilteredDataSet = () => ({ + roots: [ + { id: 1, name: 'Active Root' }, + { id: 2, name: 'Mixed Root' }, + { id: 3, name: 'Inactive Root' }, + ] as TestRoot[], + + activeRelations: [ + { id: 1, rootId: 1, title: 'Active Task', status: 'active' }, + { id: 2, rootId: 2, title: 'Active Item', status: 'active' }, + ] as TestRelation[], + + allRelations: [ + { id: 1, rootId: 1, title: 'Active Task', status: 'active' }, + { id: 2, rootId: 2, title: 'Active Item', status: 'active' }, + { id: 3, rootId: 2, title: 'Pending Item', status: 'pending' }, + { id: 4, rootId: 3, title: 'Inactive Task', status: 'inactive' }, + ] as TestRelation[], +}); + +// Sort order dataset with predictable names +export const createNameSortDataSet = () => ({ + roots: [ + { id: 1, name: 'Root A' }, + { id: 3, name: 'Root B' }, + { id: 2, name: 'Root C' }, + ] as TestRoot[], + + relations: [ + { id: 1, rootId: 1, title: 'Relation 1' }, + { id: 2, rootId: 3, title: 'Relation 2' }, + // Root 2 has no relations + ] as TestRelation[], +}); + +export const createIdDescSortDataSet = () => ({ + roots: [ + { id: 3, name: 'Root 5' }, + { id: 4, name: 'Root 4' }, + { id: 5, name: 'Root 3' }, + { id: 2, name: 'Root 2' }, + { id: 1, name: 'Root 1' }, + ] as TestRoot[], + + relations: [ + { id: 1, rootId: 2, title: 'Relation 1' }, + { id: 2, rootId: 4, title: 'Relation 2' }, + { id: 3, rootId: 4, title: 'Relation 3' }, + ] as TestRelation[], +}); + +export const createMultiSortDataSet = () => ({ + roots: [ + { id: 3, name: 'Root A' }, + { id: 1, name: 'Root A' }, + { id: 2, name: 'Root B' }, + ] as TestRoot[], + + relations: [ + { id: 1, rootId: 1, title: 'Relation 1' }, + { id: 2, rootId: 1, title: 'Relation 2' }, + { id: 3, rootId: 3, title: 'Relation 3' }, + // Root 2 has no relations + ] as TestRelation[], +}); + +// Multi-relation dataset (root with multiple relation types) +export const createMultiRelationSet = () => ({ + roots: [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + ] as TestRoot[], + + relations: [ + { id: 1, rootId: 1, title: 'Relation 1' }, + { id: 2, rootId: 2, title: 'Relation 2' }, + ] as TestRelation[], + + profiles: [ + { id: 1, rootId: 1, bio: 'Profile 1', avatar: 'avatar1.jpg' }, + // Root 2 has no profile + ] as TestProfile[], + + settings: [ + { id: 1, rootId: 1, theme: 'dark', notifications: true }, + { id: 2, rootId: 2, theme: 'light', notifications: false }, + ] as TestSettings[], +}); + +// Single entity datasets for minimal tests +export const createSingleEntitySet = () => ({ + roots: [{ id: 1, name: 'Only Root' }] as TestRoot[], + relations: [{ id: 1, rootId: 1, title: 'Only Relation' }] as TestRelation[], +}); + +// Relation sort by title dataset for relation-driven sorting +export const createRelationSortByTitleSet = () => ({ + relationsByTitle: [ + { id: 1, rootId: 2, title: 'Alpha Task' }, + { id: 2, rootId: 1, title: 'Beta Task' }, + { id: 3, rootId: 3, title: 'Charlie Task' }, + { id: 4, rootId: 1, title: 'Delta Task' }, + ] as TestRelation[], + + rootsInRelationOrder: [ + { id: 2, name: 'Root 2' }, + { id: 1, name: 'Root 1' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[], + + rootsInNaturalOrder: [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[], +}); + +// Relation sort by priority with multiple relations per root +export const createRelationSortByPrioritySet = () => ({ + relationsByPriority: [ + { id: 1, rootId: 1, title: 'Critical', priority: 10 }, + { id: 2, rootId: 1, title: 'High A', priority: 8 }, + { id: 3, rootId: 2, title: 'High B', priority: 7 }, + { id: 4, rootId: 3, title: 'Medium', priority: 5 }, + { id: 5, rootId: 2, title: 'Low', priority: 3 }, + ] as TestRelation[], + + uniqueRootsInOrder: [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[], +}); + +// Large relation sort dataset for pagination testing +export const createRelationSortPaginationSet = () => ({ + allRelationsSorted: [ + { id: 1, rootId: 5, title: 'Alpha' }, + { id: 2, rootId: 2, title: 'Bravo' }, + { id: 3, rootId: 8, title: 'Charlie' }, + { id: 4, rootId: 1, title: 'Delta' }, + { id: 5, rootId: 9, title: 'Echo' }, + { id: 6, rootId: 4, title: 'Foxtrot' }, + { id: 7, rootId: 7, title: 'Golf' }, + { id: 8, rootId: 3, title: 'Hotel' }, + { id: 9, rootId: 6, title: 'India' }, + { id: 10, rootId: 10, title: 'Juliet' }, + ] as TestRelation[], + + firstPageRoots: [ + { id: 5, name: 'Root 5' }, + { id: 2, name: 'Root 2' }, + { id: 8, name: 'Root 8' }, + { id: 1, name: 'Root 1' }, + { id: 9, name: 'Root 9' }, + ] as TestRoot[], + + secondPageRoots: [ + { id: 4, name: 'Root 4' }, + { id: 7, name: 'Root 7' }, + { id: 3, name: 'Root 3' }, + { id: 6, name: 'Root 6' }, + { id: 10, name: 'Root 10' }, + ] as TestRoot[], +}); + +// Combined root and relation filters dataset +export const createCombinedFiltersSet = () => ({ + projectRoots: [ + { id: 1, name: 'Project Alpha' }, + { id: 2, name: 'Project Beta' }, + ] as TestRoot[], + + allRoots: [ + { id: 1, name: 'Project Alpha' }, + { id: 2, name: 'Project Beta' }, + { id: 3, name: 'Internal Tool' }, + { id: 4, name: 'Project Gamma' }, + ] as TestRoot[], + + activeRelations: [ + { id: 1, rootId: 1, title: 'Feature A', status: 'active' }, + { id: 2, rootId: 2, title: 'Feature B', status: 'active' }, + ] as TestRelation[], + + allRelations: [ + { id: 1, rootId: 1, title: 'Feature A', status: 'active' }, + { id: 2, rootId: 2, title: 'Feature B', status: 'active' }, + { id: 3, rootId: 3, title: 'Internal Task', status: 'active' }, + { id: 4, rootId: 4, title: 'Old Feature', status: 'completed' }, + ] as TestRelation[], +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/filter-analyzer.spec.ts b/packages/nestjs-repository/src/federation/__tests__/filter-analyzer.spec.ts new file mode 100644 index 000000000..5b90b727d --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/filter-analyzer.spec.ts @@ -0,0 +1,283 @@ +import { + WhereCompoundOperator, + WhereOperator, +} from '../../repository/repository.types.js'; +import { FederationException } from '../exceptions/federation.exception.js'; +import { type FederatedRelation } from '../federation.types.js'; +import { FilterAnalyzer } from '../filter-analyzer.js'; + +const makeRelation = ( + overrides: Partial & { name: string }, +): FederatedRelation => ({ + targetEntity: `${overrides.name}Entity`, + cardinality: 'many', + on: { from: 'id', to: `${overrides.name}Id` }, + isOwning: false, + joinType: 'LEFT', + ...overrides, +}); + +describe('FilterAnalyzer', () => { + const posts = makeRelation({ name: 'posts' }); + const comments = makeRelation({ name: 'comments' }); + + describe('extractRelationConditions', () => { + it('should pass through root-only where clause unchanged', () => { + const where = { + field: 'name', + operator: WhereOperator.EQ, + value: 'test', + }; + const analyzer = new FilterAnalyzer(where, [posts], new Set()); + + expect(analyzer.getRootWhere()).toEqual(where); + expect(analyzer.getRelationConditions(posts)).toEqual([]); + }); + + it('should extract relation-tagged condition from root', () => { + const where = { + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'name', operator: WhereOperator.EQ, value: 'test' }, + { + field: 'title', + operator: WhereOperator.EQ, + value: 'hello', + relation: 'posts', + }, + ], + }; + const analyzer = new FilterAnalyzer(where, [posts], new Set()); + + expect(analyzer.getRootWhere()).toEqual({ + field: 'name', + operator: WhereOperator.EQ, + value: 'test', + }); + expect(analyzer.getRelationConditions(posts)).toEqual([ + { + field: 'title', + operator: WhereOperator.EQ, + value: 'hello', + relation: 'posts', + }, + ]); + }); + + it('should return undefined root where when all conditions are relation-tagged', () => { + const where = { + field: 'title', + operator: WhereOperator.EQ, + value: 'hello', + relation: 'posts', + }; + const analyzer = new FilterAnalyzer(where, [posts], new Set()); + + expect(analyzer.getRootWhere()).toBeUndefined(); + expect(analyzer.getRelationConditions(posts)).toHaveLength(1); + }); + + it('should throw on relation condition inside OR compound', () => { + const where = { + operator: WhereCompoundOperator.OR, + conditions: [ + { field: 'name', operator: WhereOperator.EQ, value: 'a' }, + { + field: 'title', + operator: WhereOperator.EQ, + value: 'b', + relation: 'posts', + }, + ], + }; + + expect(() => new FilterAnalyzer(where, [posts], new Set())).toThrow( + FederationException, + ); + }); + + it('should handle nested AND compounds', () => { + const where = { + operator: WhereCompoundOperator.AND, + conditions: [ + { + operator: WhereCompoundOperator.AND, + conditions: [ + { + field: 'title', + operator: WhereOperator.EQ, + value: 'x', + relation: 'posts', + }, + { + field: 'body', + operator: WhereOperator.CONTAINS, + value: 'y', + relation: 'comments', + }, + ], + }, + { field: 'active', operator: WhereOperator.EQ, value: true }, + ], + }; + const analyzer = new FilterAnalyzer(where, [posts, comments], new Set()); + + expect(analyzer.getRootWhere()).toEqual({ + field: 'active', + operator: WhereOperator.EQ, + value: true, + }); + expect(analyzer.getRelationConditions(posts)).toHaveLength(1); + expect(analyzer.getRelationConditions(comments)).toHaveLength(1); + }); + + it('should handle undefined where clause', () => { + const analyzer = new FilterAnalyzer(undefined, [posts], new Set()); + + expect(analyzer.getRootWhere()).toBeUndefined(); + expect(analyzer.getRelationConditions(posts)).toEqual([]); + }); + }); + + describe('INNER JOIN injection', () => { + it('should inject NOT_NULL on root FK for owning INNER JOIN', () => { + const owningRelation = makeRelation({ + name: 'profile', + cardinality: 'one', + isOwning: true, + joinType: 'INNER', + on: { from: 'profileId', to: 'id' }, + }); + + const analyzer = new FilterAnalyzer( + undefined, + [owningRelation], + new Set(), + ); + + expect(analyzer.getRootWhere()).toEqual({ + field: 'profileId', + operator: WhereOperator.NOT_NULL, + }); + }); + + it('should inject NOT_NULL on target FK for non-owning INNER JOIN', () => { + const nonOwning = makeRelation({ + name: 'posts', + joinType: 'INNER', + }); + + const analyzer = new FilterAnalyzer(undefined, [nonOwning], new Set()); + + expect(analyzer.getRelationConditions(nonOwning)).toEqual([ + { + field: 'postsId', + operator: WhereOperator.NOT_NULL, + relation: 'posts', + }, + ]); + }); + + it('should inject NOT_NULL for sorted relations (treated as INNER)', () => { + const analyzer = new FilterAnalyzer( + undefined, + [posts], + new Set(['posts']), + ); + + expect(analyzer.getRelationConditions(posts)).toEqual([ + { + field: 'postsId', + operator: WhereOperator.NOT_NULL, + relation: 'posts', + }, + ]); + }); + }); + + describe('distinctFilter injection', () => { + it('should add distinctFilter as relation condition', () => { + const withDistinct = makeRelation({ + name: 'posts', + distinctFilter: { + field: 'published', + operator: WhereOperator.EQ, + value: true, + }, + }); + + const analyzer = new FilterAnalyzer(undefined, [withDistinct], new Set()); + + expect(analyzer.getRelationConditions(withDistinct)).toEqual([ + { + field: 'published', + operator: WhereOperator.EQ, + value: true, + relation: 'posts', + }, + ]); + }); + }); + + describe('hasFiltersForRelation / hasRelationFilters', () => { + it('should detect relation filters', () => { + const where = { + field: 'title', + operator: WhereOperator.EQ, + value: 'hello', + relation: 'posts', + }; + const analyzer = new FilterAnalyzer(where, [posts, comments], new Set()); + + expect(analyzer.hasFiltersForRelation(posts)).toBe(true); + expect(analyzer.hasFiltersForRelation(comments)).toBe(false); + expect(analyzer.hasRelationFilters([posts, comments])).toBe(true); + }); + }); + + describe('buildConstraint', () => { + it('should return undefined for empty values', () => { + expect(FilterAnalyzer.buildConstraint('id', [])).toBeUndefined(); + }); + + it('should return EQ for single value', () => { + expect(FilterAnalyzer.buildConstraint('id', [1])).toEqual({ + field: 'id', + operator: WhereOperator.EQ, + value: 1, + }); + }); + + it('should return IN for multiple values', () => { + expect(FilterAnalyzer.buildConstraint('id', [1, 2, 3])).toEqual({ + field: 'id', + operator: WhereOperator.IN, + value: [1, 2, 3], + }); + }); + }); + + describe('buildWhereClause', () => { + it('should return undefined for empty conditions', () => { + expect(FilterAnalyzer.buildWhereClause([])).toBeUndefined(); + }); + + it('should return single condition unwrapped', () => { + const condition = { + field: 'id', + operator: WhereOperator.EQ, + value: 1, + }; + expect(FilterAnalyzer.buildWhereClause([condition])).toEqual(condition); + }); + + it('should wrap multiple conditions in AND compound', () => { + const a = { field: 'a', operator: WhereOperator.EQ, value: 1 }; + const b = { field: 'b', operator: WhereOperator.EQ, value: 2 }; + expect(FilterAnalyzer.buildWhereClause([a, b])).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [a, b], + }); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/fixtures/federation-orchestrator.mock.ts b/packages/nestjs-repository/src/federation/__tests__/fixtures/federation-orchestrator.mock.ts new file mode 100644 index 000000000..fecbb7ebc --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/fixtures/federation-orchestrator.mock.ts @@ -0,0 +1,221 @@ +import { vi, type Mocked, type Mock } from 'vitest'; + +import { type PlainLiteralObject } from '@nestjs/common'; +import { type ModuleRef } from '@nestjs/core'; + +import { type RepositoryRelationMetadataInterface } from '../../../repository/interfaces/repository-relation-metadata.interface.js'; +import { type RepositoryInterface } from '../../../repository/interfaces/repository.interface.js'; +import { RepositoryRegistryService } from '../../../services/repository-registry.service.js'; +import { createMockRepository } from '../../../testing/create-mock-repository.js'; +import { getDynamicRepositoryToken } from '../../../utils/get-dynamic-repository-token.js'; +import { FederationOrchestrator } from '../../federation-orchestrator.service.js'; +import { type FederatedRelation } from '../../federation.types.js'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════════════════ + +export type MockRepo = + Mocked>; + +export interface TestRoot { + id: number; + name: string; + profileId?: number | null; + blogId?: number | null; + [key: string]: unknown; +} + +export interface TestRelation { + id: number; + userId: number; + title?: string; + published?: boolean; + [key: string]: unknown; +} + +export interface TestProfile { + id: number; + userId: number; + bio?: string; + [key: string]: unknown; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test repository factory +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Create a mock repository with standard metadata for testing. + * + * @param entityName - Name of the entity for metadata + * @param options - Optional overrides for columns, relations, etc. + */ +export function mockTestRepo( + entityName: string, + options: { + primaryKey?: string; + relations?: RepositoryRelationMetadataInterface[]; + } = {}, +): MockRepo { + const { primaryKey = 'id', relations } = options; + + return createMockRepository({ + name: entityName, + columns: [ + { + name: primaryKey, + isPrimary: true, + isRemoveDate: false, + isVersion: false, + }, + ], + relations, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Orchestrator factory +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Create a FederationOrchestrator wired to mock peer repositories. + * + * @param peerRepos - Map of entity name to mock repository + * @returns Object with orchestrator and mock references + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function mockOrchestrator(peerRepos: Record>): { + orchestrator: FederationOrchestrator; + registry: RepositoryRegistryService; + moduleRef: { get: Mock }; +} { + const registry = new RepositoryRegistryService(); + + // Register and bootstrap each peer repository + for (const entityName of Object.keys(peerRepos)) { + registry.register({ + key: entityName, + entityName, + moduleName: 'TestModule', + }); + } + registry.onApplicationBootstrap(); + + // Mock ModuleRef.get to resolve peer repos by dynamic token + const moduleRef = { + get: vi.fn((token: string) => { + for (const [entityName, repo] of Object.entries(peerRepos)) { + if (token === getDynamicRepositoryToken(entityName)) { + return repo; + } + } + throw new Error(`No mock repo for token: ${token}`); + }), + }; + + const orchestrator = new FederationOrchestrator( + registry, + moduleRef as unknown as ModuleRef, + ); + + return { orchestrator, registry, moduleRef }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Context factory +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Create a minimal context for testing. + */ +export function mockContext( + overrides: PlainLiteralObject = {}, +): PlainLiteralObject { + return { + hooks: [], + entity: 'TestEntity', + ...overrides, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Relation builders +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Build a non-owning one-to-many federated relation metadata entry. + * + * Non-owning: root PK (on.from = 'id') \> target FK (on.to = e.g. 'userId') + */ +export function mockOneToManyRelation( + name: string, + targetEntity: string, + overrides: Partial = {}, +): RepositoryRelationMetadataInterface { + return { + name, + targetEntity, + cardinality: 'many', + on: { from: 'id', to: 'userId' }, + federated: true, + ...overrides, + }; +} + +/** + * Build a non-owning one-to-one federated relation metadata entry. + * + * Non-owning: root PK (on.from = 'id') \> target FK (on.to = e.g. 'userId') + */ +export function mockOneToOneRelation( + name: string, + targetEntity: string, + overrides: Partial = {}, +): RepositoryRelationMetadataInterface { + return { + name, + targetEntity, + cardinality: 'one', + on: { from: 'id', to: 'userId' }, + federated: true, + ...overrides, + }; +} + +/** + * Build an owning one-to-one federated relation metadata entry. + * + * Owning: root FK (on.from = e.g. 'blogId') \> target PK (on.to = 'id') + */ +export function mockOwningOneToOneRelation( + name: string, + targetEntity: string, + rootFK: string, + targetPK = 'id', +): RepositoryRelationMetadataInterface { + return { + name, + targetEntity, + cardinality: 'one', + on: { from: rootFK, to: targetPK }, + federated: true, + }; +} + +/** + * Convert a RepositoryRelationMetadataInterface to a FederatedRelation + * with computed isOwning and joinType fields. + */ +export function mockFederatedRelation( + rel: RepositoryRelationMetadataInterface, + rootPrimaryKeys: string[] = ['id'], + joinType: 'LEFT' | 'INNER' = 'LEFT', +): FederatedRelation { + return { + ...rel, + isOwning: !rootPrimaryKeys.includes(rel.on.from), + joinType, + }; +} diff --git a/packages/nestjs-repository/src/federation/__tests__/hydration.spec.ts b/packages/nestjs-repository/src/federation/__tests__/hydration.spec.ts new file mode 100644 index 000000000..be87fccc1 --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/hydration.spec.ts @@ -0,0 +1,252 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type FederatedRelation, + type RelationResult, +} from '../federation.types.js'; +import { hydrateRelations, initializeEmptyRelations } from '../hydration.js'; + +const makeRelation = ( + overrides: Partial & { name: string }, +): FederatedRelation => ({ + targetEntity: `${overrides.name}Entity`, + cardinality: 'many', + on: { from: 'id', to: `${overrides.name}Id` }, + isOwning: false, + joinType: 'LEFT', + ...overrides, +}); + +describe('hydrateRelations', () => { + describe('non-owning many-cardinality', () => { + const posts = makeRelation({ + name: 'posts', + cardinality: 'many', + on: { from: 'id', to: 'userId' }, + }); + + it('should assign matching targets to roots', () => { + const roots: PlainLiteralObject[] = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + const results: RelationResult[] = [ + { + relation: posts, + data: [ + { id: 10, userId: 1, title: 'Post A' }, + { id: 11, userId: 1, title: 'Post B' }, + { id: 12, userId: 2, title: 'Post C' }, + ], + total: 3, + }, + ]; + + hydrateRelations(roots, 'id', results); + + expect(roots[0].posts).toEqual([ + { id: 10, userId: 1, title: 'Post A' }, + { id: 11, userId: 1, title: 'Post B' }, + ]); + expect(roots[1].posts).toEqual([{ id: 12, userId: 2, title: 'Post C' }]); + }); + + it('should initialize empty array for roots with no matches', () => { + const roots: PlainLiteralObject[] = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + const results: RelationResult[] = [ + { + relation: posts, + data: [{ id: 10, userId: 1, title: 'Post A' }], + total: 1, + }, + ]; + + hydrateRelations(roots, 'id', results); + + expect(roots[0].posts).toEqual([{ id: 10, userId: 1, title: 'Post A' }]); + expect(roots[1].posts).toEqual([]); + }); + }); + + describe('non-owning one-cardinality', () => { + const profile = makeRelation({ + name: 'profile', + cardinality: 'one', + on: { from: 'id', to: 'userId' }, + }); + + it('should assign single target to root', () => { + const roots: PlainLiteralObject[] = [{ id: 1, name: 'Alice' }]; + const results: RelationResult[] = [ + { + relation: profile, + data: [{ id: 5, userId: 1, bio: 'Hello' }], + total: 1, + }, + ]; + + hydrateRelations(roots, 'id', results); + + expect(roots[0].profile).toEqual({ + id: 5, + userId: 1, + bio: 'Hello', + }); + }); + + it('should initialize null for roots with no matches', () => { + const roots: PlainLiteralObject[] = [{ id: 1, name: 'Alice' }]; + const results: RelationResult[] = [ + { relation: profile, data: [], total: 0 }, + ]; + + hydrateRelations(roots, 'id', results); + + expect(roots[0].profile).toBeNull(); + }); + }); + + describe('owning one-cardinality', () => { + const blog = makeRelation({ + name: 'blog', + cardinality: 'one', + isOwning: true, + on: { from: 'blogId', to: 'id' }, + }); + + it('should match via root FK to target PK', () => { + const roots: PlainLiteralObject[] = [ + { id: 1, blogId: 100 }, + { id: 2, blogId: 200 }, + ]; + const results: RelationResult[] = [ + { + relation: blog, + data: [ + { id: 100, title: 'Blog A' }, + { id: 200, title: 'Blog B' }, + ], + total: 2, + }, + ]; + + hydrateRelations(roots, 'id', results); + + expect(roots[0].blog).toEqual({ id: 100, title: 'Blog A' }); + expect(roots[1].blog).toEqual({ id: 200, title: 'Blog B' }); + }); + + it('should leave null when FK is null', () => { + const roots: PlainLiteralObject[] = [{ id: 1, blogId: null }]; + const results: RelationResult[] = [ + { relation: blog, data: [], total: 0 }, + ]; + + hydrateRelations(roots, 'id', results); + + expect(roots[0].blog).toBeNull(); + }); + }); + + describe('owning many-cardinality', () => { + const tags = makeRelation({ + name: 'tags', + cardinality: 'many', + isOwning: true, + on: { from: 'tagGroupId', to: 'groupId' }, + }); + + it('should group targets by root FK value', () => { + const roots: PlainLiteralObject[] = [ + { id: 1, tagGroupId: 'g1' }, + { id: 2, tagGroupId: 'g1' }, + ]; + const results: RelationResult[] = [ + { + relation: tags, + data: [ + { id: 10, groupId: 'g1', label: 'Tag A' }, + { id: 11, groupId: 'g1', label: 'Tag B' }, + ], + total: 2, + }, + ]; + + hydrateRelations(roots, 'id', results); + + expect(roots[0].tags).toEqual([ + { id: 10, groupId: 'g1', label: 'Tag A' }, + { id: 11, groupId: 'g1', label: 'Tag B' }, + ]); + expect(roots[1].tags).toEqual([ + { id: 10, groupId: 'g1', label: 'Tag A' }, + { id: 11, groupId: 'g1', label: 'Tag B' }, + ]); + }); + }); + + it('should handle empty roots', () => { + const posts = makeRelation({ name: 'posts' }); + const roots: PlainLiteralObject[] = []; + hydrateRelations(roots, 'id', [{ relation: posts, data: [], total: 0 }]); + expect(roots).toEqual([]); + }); + + it('should handle multiple relations', () => { + const posts = makeRelation({ + name: 'posts', + cardinality: 'many', + on: { from: 'id', to: 'userId' }, + }); + const profile = makeRelation({ + name: 'profile', + cardinality: 'one', + on: { from: 'id', to: 'userId' }, + }); + + const roots: PlainLiteralObject[] = [{ id: 1, name: 'Alice' }]; + const results: RelationResult[] = [ + { + relation: posts, + data: [{ id: 10, userId: 1, title: 'Post' }], + total: 1, + }, + { + relation: profile, + data: [{ id: 5, userId: 1, bio: 'Hello' }], + total: 1, + }, + ]; + + hydrateRelations(roots, 'id', results); + + expect(roots[0].posts).toEqual([{ id: 10, userId: 1, title: 'Post' }]); + expect(roots[0].profile).toEqual({ id: 5, userId: 1, bio: 'Hello' }); + }); +}); + +describe('initializeEmptyRelations', () => { + const posts = makeRelation({ name: 'posts', cardinality: 'many' }); + const profile = makeRelation({ name: 'profile', cardinality: 'one' }); + + it('should set defaults based on cardinality', () => { + const roots: PlainLiteralObject[] = [{ id: 1 }, { id: 2 }]; + initializeEmptyRelations(roots, [posts, profile]); + + expect(roots[0].posts).toEqual([]); + expect(roots[0].profile).toBeNull(); + expect(roots[1].posts).toEqual([]); + expect(roots[1].profile).toBeNull(); + }); + + it('should skip existing properties when onlyIfMissing is true', () => { + const roots: PlainLiteralObject[] = [{ id: 1, posts: [{ id: 10 }] }]; + initializeEmptyRelations(roots, [posts, profile], true); + + expect(roots[0].posts).toEqual([{ id: 10 }]); + expect(roots[0].profile).toBeNull(); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/integration/one-to-many-forward.spec.ts b/packages/nestjs-repository/src/federation/__tests__/integration/one-to-many-forward.spec.ts new file mode 100644 index 000000000..f4c31a1f6 --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/integration/one-to-many-forward.spec.ts @@ -0,0 +1,465 @@ +/** + * Integration tests for one-to-many forward relationship enrichment. + * + * One-to-many forward: Relation.rootId -> Root.id (non-owning, cardinality 'many') + * + * Key behaviors: + * - Existing relations → array on root + * - Missing relations → empty array on root (LEFT JOIN) + * - Multiple roots with varying relation counts + * - Multiple one-to-many relation types on same root + * + * Ported from nestjs-crud __tests__/crud-federation/integration/one-to-many-forward.spec.ts + */ +import { WhereOperator } from '../../../repository/repository.types.js'; +import { + type TestRoot, + type TestRelation, + type TestSettings, +} from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToManyRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Integration: One-to-Many Forward', () => { + describe('Handler call sequencing', () => { + it('should call root first then relation (ROOT_FIRST / LEFT JOIN)', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const roots = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + { id: 4, name: 'Root 4' }, + { id: 5, name: 'Root 5' }, + ] as TestRoot[]; + + const relations = [ + { id: 1, rootId: 1, title: 'Relation 1' }, + { id: 2, rootId: 2, title: 'Relation 2' }, + { id: 3, rootId: 3, title: 'Relation 3' }, + { id: 4, rootId: 99, title: 'Orphan Relation' }, + ] as TestRelation[]; + + rootRepo.findAndCount.mockResolvedValue([roots, 5]); + peerRepo.findAndCount.mockResolvedValue([relations, 4]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - ROOT_FIRST + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + peerRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Relation handler called with root IDs + const peerCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(peerCall?.where).toEqual({ + field: 'rootId', + operator: WhereOperator.IN, + value: [1, 2, 3, 4, 5], + }); + + // All roots returned + expect(total).toBe(5); + expect(result).toHaveLength(5); + + // Enrichment: roots with matching relations get arrays, others get [] + expect(result[0].relations).toEqual([ + { id: 1, rootId: 1, title: 'Relation 1' }, + ]); + expect(result[1].relations).toEqual([ + { id: 2, rootId: 2, title: 'Relation 2' }, + ]); + expect(result[2].relations).toEqual([ + { id: 3, rootId: 3, title: 'Relation 3' }, + ]); + expect(result[3].relations).toEqual([]); // Root 4: no relations + expect(result[4].relations).toEqual([]); // Root 5: no relations + // Orphan relation (rootId: 99) correctly not attached + }); + + it('should set empty arrays when no relations exist (LEFT JOIN)', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const roots = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + ] as TestRoot[]; + + rootRepo.findAndCount.mockResolvedValue([roots, 2]); + peerRepo.findAndCount.mockResolvedValue([[], 0]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - All roots returned with empty relation arrays + expect(total).toBe(2); + expect(result).toHaveLength(2); + expect(result[0].relations).toEqual([]); + expect(result[1].relations).toEqual([]); + }); + }); + + describe('Data patterns', () => { + it('should handle roots with varying relation counts', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const roots = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + { id: 4, name: 'Root 4' }, + ] as TestRoot[]; + + const relations = [ + { id: 1, rootId: 1, title: 'Relation 1A' }, + { id: 2, rootId: 1, title: 'Relation 1B' }, + { id: 3, rootId: 1, title: 'Relation 1C' }, + { id: 4, rootId: 2, title: 'Relation 2A' }, + // Root 3: no relations + { id: 5, rootId: 4, title: 'Relation 4A' }, + { id: 6, rootId: 4, title: 'Relation 4B' }, + ] as TestRelation[]; + + rootRepo.findAndCount.mockResolvedValue([roots, 4]); + peerRepo.findAndCount.mockResolvedValue([relations, 6]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(total).toBe(4); + expect(result).toHaveLength(4); + + // Root 1: 3 relations + expect(result[0].relations).toEqual([ + { id: 1, rootId: 1, title: 'Relation 1A' }, + { id: 2, rootId: 1, title: 'Relation 1B' }, + { id: 3, rootId: 1, title: 'Relation 1C' }, + ]); + // Root 2: 1 relation + expect(result[1].relations).toEqual([ + { id: 4, rootId: 2, title: 'Relation 2A' }, + ]); + // Root 3: 0 relations + expect(result[2].relations).toEqual([]); + // Root 4: 2 relations + expect(result[3].relations).toEqual([ + { id: 5, rootId: 4, title: 'Relation 4A' }, + { id: 6, rootId: 4, title: 'Relation 4B' }, + ]); + }); + + it('should handle single root with multiple relations', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const roots = [{ id: 1, name: 'Only Root' }] as TestRoot[]; + const relations = [ + { id: 1, rootId: 1, title: 'Relation 1' }, + { id: 2, rootId: 1, title: 'Relation 2' }, + ] as TestRelation[]; + + rootRepo.findAndCount.mockResolvedValue([roots, 1]); + peerRepo.findAndCount.mockResolvedValue([relations, 2]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 10, + skip: 0, + }); + + // ASSERT - Single root ID → EQ constraint (not IN) + const peerCall = peerRepo.findAndCount.mock.calls[0][0]; + expect(peerCall?.where).toEqual({ + field: 'rootId', + operator: WhereOperator.EQ, + value: 1, + }); + + expect(total).toBe(1); + expect(result).toHaveLength(1); + expect(result[0].relations).toEqual(relations); + }); + }); + + describe('Multiple relation types', () => { + it('should handle multiple one-to-many relationships on same root', async () => { + // ARRANGE + const relationsRelation = mockOneToManyRelation( + 'relations', + 'TestRelation', + { on: { from: 'id', to: 'rootId' } }, + ); + const settingsRelation = mockOneToManyRelation( + 'settings', + 'TestSettings', + { on: { from: 'id', to: 'rootId' } }, + ); + + const rootRepo = mockTestRepo('TestRoot', { + relations: [relationsRelation, settingsRelation], + }); + const relationRepo = mockTestRepo('TestRelation'); + const settingsRepo = mockTestRepo('TestSettings'); + const { orchestrator } = mockOrchestrator({ + TestRelation: relationRepo, + TestSettings: settingsRepo, + }); + + const roots = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + ] as TestRoot[]; + + const relations = [ + { id: 1, rootId: 1, title: 'Relation 1A' }, + { id: 2, rootId: 1, title: 'Relation 1B' }, + { id: 3, rootId: 2, title: 'Relation 2A' }, + // Root 3: no relations + ] as TestRelation[]; + + const settings = [ + { id: 1, rootId: 1, theme: 'dark', notifications: true }, + // Root 2: no settings + { id: 2, rootId: 3, theme: 'auto', notifications: true }, + ] as TestSettings[]; + + rootRepo.findAndCount.mockResolvedValue([roots, 3]); + relationRepo.findAndCount.mockResolvedValue([relations, 3]); + settingsRepo.findAndCount.mockResolvedValue([settings, 2]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }, { relation: 'settings' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(relationRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(settingsRepo.findAndCount).toHaveBeenCalledTimes(1); + + expect(total).toBe(3); + expect(result).toHaveLength(3); + + // Every root has both relation properties + for (const root of result) { + expect(root).toHaveProperty('relations'); + expect(Array.isArray(root.relations)).toBe(true); + expect(root).toHaveProperty('settings'); + expect(Array.isArray(root.settings)).toBe(true); + } + + // Relation enrichment + expect(result[0].relations).toEqual([ + { id: 1, rootId: 1, title: 'Relation 1A' }, + { id: 2, rootId: 1, title: 'Relation 1B' }, + ]); + expect(result[1].relations).toEqual([ + { id: 3, rootId: 2, title: 'Relation 2A' }, + ]); + expect(result[2].relations).toEqual([]); + + // Settings enrichment + expect(result[0].settings).toEqual([ + { id: 1, rootId: 1, theme: 'dark', notifications: true }, + ]); + expect(result[1].settings).toEqual([]); + expect(result[2].settings).toEqual([ + { id: 2, rootId: 3, theme: 'auto', notifications: true }, + ]); + }); + }); + + describe('Pagination', () => { + it('should correctly enrich relations for paginated results', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // Page 2: roots 6-10 + const page2Roots = [ + { id: 6, name: 'Root 6' }, + { id: 7, name: 'Root 7' }, + { id: 8, name: 'Root 8' }, + { id: 9, name: 'Root 9' }, + { id: 10, name: 'Root 10' }, + ] as TestRoot[]; + + const page2Relations = [ + { id: 11, rootId: 6, title: 'Relation 6A' }, + { id: 12, rootId: 6, title: 'Relation 6B' }, + { id: 13, rootId: 7, title: 'Relation 7A' }, + { id: 14, rootId: 8, title: 'Relation 8A' }, + { id: 15, rootId: 8, title: 'Relation 8B' }, + { id: 16, rootId: 8, title: 'Relation 8C' }, + // Roots 9 and 10: no relations + ] as TestRelation[]; + + rootRepo.findAndCount.mockResolvedValue([page2Roots, 10]); + peerRepo.findAndCount.mockResolvedValue([page2Relations, 6]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 5, + skip: 5, + }); + + // ASSERT + expect(total).toBe(10); + expect(result).toHaveLength(5); + + // Root pagination passed through + const rootCall = rootRepo.findAndCount.mock.calls[0][0]; + expect(rootCall?.take).toBe(5); + expect(rootCall?.skip).toBe(5); + + // Enrichment for page 2 + expect(result[0].relations).toEqual([ + { id: 11, rootId: 6, title: 'Relation 6A' }, + { id: 12, rootId: 6, title: 'Relation 6B' }, + ]); + expect(result[1].relations).toEqual([ + { id: 13, rootId: 7, title: 'Relation 7A' }, + ]); + expect(result[2].relations).toEqual([ + { id: 14, rootId: 8, title: 'Relation 8A' }, + { id: 15, rootId: 8, title: 'Relation 8B' }, + { id: 16, rootId: 8, title: 'Relation 8C' }, + ]); + expect(result[3].relations).toEqual([]); + expect(result[4].relations).toEqual([]); + }); + + it('should handle empty page gracefully', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + // Beyond available data + rootRepo.findAndCount.mockResolvedValue([[], 10]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 5, + skip: 10, + }); + + // ASSERT - Empty result, no peer call + expect(total).toBe(10); + expect(result).toEqual([]); + expect(peerRepo.findAndCount).toHaveBeenCalledTimes(0); + }); + + it('should handle partial last page', async () => { + // ARRANGE + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const peerRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ TestRelation: peerRepo }); + + const lastPageRoots = [ + { id: 11, name: 'Root 11' }, + { id: 12, name: 'Root 12' }, + ] as TestRoot[]; + + const lastPageRelations = [ + { id: 11, rootId: 11, title: 'Relation 11A' }, + { id: 12, rootId: 12, title: 'Relation 12A' }, + { id: 13, rootId: 12, title: 'Relation 12B' }, + ] as TestRelation[]; + + rootRepo.findAndCount.mockResolvedValue([lastPageRoots, 12]); + peerRepo.findAndCount.mockResolvedValue([lastPageRelations, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 5, + skip: 10, + }); + + // ASSERT + expect(total).toBe(12); + expect(result).toHaveLength(2); + + expect(result[0].relations).toEqual([ + { id: 11, rootId: 11, title: 'Relation 11A' }, + ]); + expect(result[1].relations).toEqual([ + { id: 12, rootId: 12, title: 'Relation 12A' }, + { id: 13, rootId: 12, title: 'Relation 12B' }, + ]); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/integration/one-to-one-forward.spec.ts b/packages/nestjs-repository/src/federation/__tests__/integration/one-to-one-forward.spec.ts new file mode 100644 index 000000000..8cdc1ce17 --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/integration/one-to-one-forward.spec.ts @@ -0,0 +1,331 @@ +/** + * Integration tests for one-to-one forward relationship enrichment. + * + * One-to-one forward: Profile.rootId -> Root.id (non-owning, cardinality 'one') + * + * Key behaviors: + * - Existing relation → single object on root + * - Missing relation → null on root (not empty array) + * - Multiple one-to-one relations → each independently enriched + * + * Ported from nestjs-crud __tests__/crud-federation/integration/one-to-one-forward.spec.ts + */ +import { WhereOperator } from '../../../repository/repository.types.js'; +import { + type TestRoot, + type TestProfile, + type TestSettings, + createMultiRelationSet, +} from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToOneRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Integration: One-to-One Forward', () => { + describe('Root with existing related entity', () => { + it('should populate profile entity object on root (LEFT JOIN)', async () => { + // ARRANGE + const relation = mockOneToOneRelation('profile', 'TestProfile', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const profileRepo = mockTestRepo('TestProfile'); + const { orchestrator } = mockOrchestrator({ TestProfile: profileRepo }); + + const data = createMultiRelationSet(); + + rootRepo.findAndCount.mockResolvedValue([data.roots, 2]); + profileRepo.findAndCount.mockResolvedValue([data.profiles, 1]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'profile' }], + take: 10, + skip: 0, + }); + + // ASSERT - ROOT_FIRST strategy + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(profileRepo.findAndCount).toHaveBeenCalledTimes(1); + + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + profileRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Result verification + expect(total).toBe(2); + expect(result).toHaveLength(2); + + // Root 1: has profile → single object + expect(result[0].profile).toEqual({ + id: 1, + rootId: 1, + bio: 'Profile 1', + avatar: 'avatar1.jpg', + }); + + // Root 2: no profile → null (not empty array) + expect(result[1].profile).toBeNull(); + }); + }); + + describe('Root with missing related entity', () => { + it('should set null profile on root when no profile exists (LEFT JOIN)', async () => { + // ARRANGE + const relation = mockOneToOneRelation('profile', 'TestProfile', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const profileRepo = mockTestRepo('TestProfile'); + const { orchestrator } = mockOrchestrator({ TestProfile: profileRepo }); + + const data = createMultiRelationSet(); + + rootRepo.findAndCount.mockResolvedValue([data.roots, 2]); + // No profiles at all + profileRepo.findAndCount.mockResolvedValue([[], 0]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'profile' }], + take: 10, + skip: 0, + }); + + // ASSERT + expect(total).toBe(2); + expect(result).toHaveLength(2); + + // All roots get null profile (LEFT JOIN keeps roots) + expect(result[0].profile).toBeNull(); + expect(result[1].profile).toBeNull(); + }); + }); + + describe('Root with multiple one-to-one relationships', () => { + it('should handle multiple one-to-one forward relationships correctly', async () => { + // ARRANGE + const profileRelation = mockOneToOneRelation('profile', 'TestProfile', { + on: { from: 'id', to: 'rootId' }, + }); + const settingsRelation = mockOneToOneRelation( + 'settings', + 'TestSettings', + { + on: { from: 'id', to: 'rootId' }, + }, + ); + const rootRepo = mockTestRepo('TestRoot', { + relations: [profileRelation, settingsRelation], + }); + const profileRepo = mockTestRepo('TestProfile'); + const settingsRepo = mockTestRepo('TestSettings'); + const { orchestrator } = mockOrchestrator({ + TestProfile: profileRepo, + TestSettings: settingsRepo, + }); + + const roots = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + { id: 4, name: 'Root 4' }, + { id: 5, name: 'Root 5' }, + ] as TestRoot[]; + + const profiles = [ + { id: 1, rootId: 1, bio: 'Profile 1', avatar: 'avatar1.jpg' }, + { id: 2, rootId: 3, bio: 'Profile for Root 3' }, + { id: 3, rootId: 4, bio: 'Profile for Root 4', avatar: 'avatar4.jpg' }, + // Roots 2 and 5 have no profiles + ] as TestProfile[]; + + const settings = [ + { id: 1, rootId: 1, theme: 'dark', notifications: true }, + { id: 2, rootId: 2, theme: 'light', notifications: false }, + { id: 3, rootId: 5, theme: 'auto', notifications: true }, + // Roots 3 and 4 have no settings + ] as TestSettings[]; + + rootRepo.findAndCount.mockResolvedValue([roots, 5]); + profileRepo.findAndCount.mockResolvedValue([profiles, 3]); + settingsRepo.findAndCount.mockResolvedValue([settings, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'profile' }, { relation: 'settings' }], + take: 10, + skip: 0, + }); + + // ASSERT - ROOT_FIRST, all 3 repos called + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(profileRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(settingsRepo.findAndCount).toHaveBeenCalledTimes(1); + + expect(total).toBe(5); + expect(result).toHaveLength(5); + + // Profile enrichment (one-to-one: object or null) + expect(result[0].profile).toEqual({ + id: 1, + rootId: 1, + bio: 'Profile 1', + avatar: 'avatar1.jpg', + }); + expect(result[1].profile).toBeNull(); + expect(result[2].profile).toEqual({ + id: 2, + rootId: 3, + bio: 'Profile for Root 3', + }); + expect(result[3].profile).toEqual({ + id: 3, + rootId: 4, + bio: 'Profile for Root 4', + avatar: 'avatar4.jpg', + }); + expect(result[4].profile).toBeNull(); + + // Settings enrichment (one-to-one: object or null) + expect(result[0].settings).toEqual({ + id: 1, + rootId: 1, + theme: 'dark', + notifications: true, + }); + expect(result[1].settings).toEqual({ + id: 2, + rootId: 2, + theme: 'light', + notifications: false, + }); + expect(result[2].settings).toBeNull(); + expect(result[3].settings).toBeNull(); + expect(result[4].settings).toEqual({ + id: 3, + rootId: 5, + theme: 'auto', + notifications: true, + }); + }); + }); + + describe('Pagination handling', () => { + it('should correctly enrich one-to-one relations with pagination', async () => { + // ARRANGE + const relation = mockOneToOneRelation('profile', 'TestProfile', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const profileRepo = mockTestRepo('TestProfile'); + const { orchestrator } = mockOrchestrator({ TestProfile: profileRepo }); + + // Page 1: roots 1-5 + const page1Roots = [ + { id: 1, name: 'Root 1' }, + { id: 2, name: 'Root 2' }, + { id: 3, name: 'Root 3' }, + { id: 4, name: 'Root 4' }, + { id: 5, name: 'Root 5' }, + ] as TestRoot[]; + + const page1Profiles = [ + { id: 1, rootId: 1, bio: 'Profile for Root 1', avatar: 'avatar1.jpg' }, + { id: 2, rootId: 3, bio: 'Profile for Root 3' }, + { id: 3, rootId: 5, bio: 'Profile for Root 5', avatar: 'avatar5.jpg' }, + // Roots 2 and 4 have no profiles + ] as TestProfile[]; + + rootRepo.findAndCount.mockResolvedValue([page1Roots, 10]); + profileRepo.findAndCount.mockResolvedValue([page1Profiles, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'profile' }], + take: 5, + skip: 0, + }); + + // ASSERT + expect(total).toBe(10); + expect(result).toHaveLength(5); + + // Verify profile hydration constraint + const profileCall = profileRepo.findAndCount.mock.calls[0][0]; + expect(profileCall?.where).toEqual({ + field: 'rootId', + operator: WhereOperator.IN, + value: [1, 2, 3, 4, 5], + }); + + // Enrichment + expect(result[0].profile).toEqual(page1Profiles[0]); + expect(result[1].profile).toBeNull(); + expect(result[2].profile).toEqual(page1Profiles[1]); + expect(result[3].profile).toBeNull(); + expect(result[4].profile).toEqual(page1Profiles[2]); + }); + + it('should correctly enrich one-to-one relations for page 2', async () => { + // ARRANGE + const relation = mockOneToOneRelation('profile', 'TestProfile', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const profileRepo = mockTestRepo('TestProfile'); + const { orchestrator } = mockOrchestrator({ TestProfile: profileRepo }); + + // Page 2: roots 6-10 + const page2Roots = [ + { id: 6, name: 'Root 6' }, + { id: 7, name: 'Root 7' }, + { id: 8, name: 'Root 8' }, + { id: 9, name: 'Root 9' }, + { id: 10, name: 'Root 10' }, + ] as TestRoot[]; + + const page2Profiles = [ + { id: 4, rootId: 6, bio: 'Profile for Root 6' }, + { id: 5, rootId: 8, bio: 'Profile for Root 8', avatar: 'avatar8.jpg' }, + { + id: 6, + rootId: 10, + bio: 'Profile for Root 10', + avatar: 'avatar10.jpg', + }, + ] as TestProfile[]; + + rootRepo.findAndCount.mockResolvedValue([page2Roots, 10]); + profileRepo.findAndCount.mockResolvedValue([page2Profiles, 3]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'profile' }], + take: 5, + skip: 5, + }); + + // ASSERT + expect(total).toBe(10); + expect(result).toHaveLength(5); + + // Enrichment for page 2 + expect(result[0].profile).toEqual(page2Profiles[0]); + expect(result[1].profile).toBeNull(); + expect(result[2].profile).toEqual(page2Profiles[1]); + expect(result[3].profile).toBeNull(); + expect(result[4].profile).toEqual(page2Profiles[2]); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/__tests__/integration/read-hydration.spec.ts b/packages/nestjs-repository/src/federation/__tests__/integration/read-hydration.spec.ts new file mode 100644 index 000000000..c67b9d50b --- /dev/null +++ b/packages/nestjs-repository/src/federation/__tests__/integration/read-hydration.spec.ts @@ -0,0 +1,390 @@ +/** + * Integration tests for single-entity read with relation hydration. + * + * The CRUD layer's `service.read()` fetches one root and hydrates relations. + * At the repository level this is equivalent to `findAndCount` returning + * a single root with join-requested relations hydrated via the orchestrator. + * + * Key behaviors: + * - One-to-one: existing → object, missing → null + * - One-to-many: existing → array, missing → empty array + * - Mixed relation types independently enriched + * - Null foreign key on root → null relation (LEFT JOIN) + * + * Ported from nestjs-crud __tests__/crud-federation/integration/read-hydration.spec.ts + */ +import { WhereOperator } from '../../../repository/repository.types.js'; +import { + type TestRoot, + type TestRelation, + type TestProfile, + createSingleEntitySet, + createMinimalRootRelationSet, + createMultiRelationSet, +} from '../federation-test-data.js'; +import { + mockTestRepo, + mockOrchestrator, + mockOneToOneRelation, + mockOneToManyRelation, +} from '../fixtures/federation-orchestrator.mock.js'; + +describe('FederationOrchestrator - Integration: Read Hydration', () => { + describe('no relations', () => { + it('should fetch single root without relations', async () => { + // ARRANGE + const data = createSingleEntitySet(); + const rootRepo = mockTestRepo('TestRoot'); + const { orchestrator } = mockOrchestrator({}); + + rootRepo.findAndCount.mockResolvedValue([data.roots, 1]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + take: 1, + skip: 0, + }); + + // ASSERT + expect(result).toEqual(data.roots); + expect(total).toBe(1); + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + }); + }); + + describe('one-to-one forward relation', () => { + it('should hydrate existing one-to-one relation', async () => { + // ARRANGE + const data = createMinimalRootRelationSet(); + const relation = mockOneToOneRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const relationRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: relationRepo, + }); + + // Single root + rootRepo.findAndCount.mockResolvedValue([[data.roots[0]], 1]); + // Relation exists for root 1 + relationRepo.findAndCount.mockResolvedValue([[data.relations[0]], 1]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 1, + skip: 0, + }); + + // ASSERT + expect(total).toBe(1); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + + // Handler call verification + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(relationRepo.findAndCount).toHaveBeenCalledTimes(1); + + // ROOT_FIRST: root called before relation + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + relationRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Verify relation service was called with correct filter + // buildConstraint uses EQ (not IN) for single-value constraints + const relationCall = relationRepo.findAndCount.mock.calls[0][0]; + expect(relationCall?.where).toEqual({ + field: 'rootId', + operator: WhereOperator.EQ, + value: 1, + }); + + // Verify enrichment - one-to-one → single object + expect(result[0].relations).toEqual(data.relations[0]); + }); + + it('should handle missing one-to-one relation', async () => { + // ARRANGE + const data = createSingleEntitySet(); + const relation = mockOneToOneRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const relationRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: relationRepo, + }); + + rootRepo.findAndCount.mockResolvedValue([data.roots, 1]); + // No relations found + relationRepo.findAndCount.mockResolvedValue([[], 0]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 1, + skip: 0, + }); + + // ASSERT + expect(total).toBe(1); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + + // Handler call verification + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(relationRepo.findAndCount).toHaveBeenCalledTimes(1); + + // ROOT_FIRST: root called before relation + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + relationRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Verify relation service was called with correct filter + // buildConstraint uses EQ (not IN) for single-value constraints + const relationCall = relationRepo.findAndCount.mock.calls[0][0]; + expect(relationCall?.where).toEqual({ + field: 'rootId', + operator: WhereOperator.EQ, + value: 1, + }); + + // Verify enrichment - one-to-one missing → null + expect(result[0].relations).toBeNull(); + }); + }); + + describe('one-to-many forward relation', () => { + it('should hydrate multiple one-to-many relations', async () => { + // ARRANGE + const data = createMultiRelationSet(); + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const relationRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: relationRepo, + }); + + // Single root + rootRepo.findAndCount.mockResolvedValue([[data.roots[0]], 1]); + // Multiple relations for root 1 + const multipleRelations = [ + { id: 1, rootId: 1, title: 'Relation 1' }, + { id: 2, rootId: 1, title: 'Relation 2' }, + ] as TestRelation[]; + relationRepo.findAndCount.mockResolvedValue([multipleRelations, 2]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 1, + skip: 0, + }); + + // ASSERT + expect(total).toBe(1); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + + // Handler call verification + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(relationRepo.findAndCount).toHaveBeenCalledTimes(1); + + // ROOT_FIRST: root called before relation + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + relationRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Verify relation service was called with correct filter + // buildConstraint uses EQ (not IN) for single-value constraints + const relationCall = relationRepo.findAndCount.mock.calls[0][0]; + expect(relationCall?.where).toEqual({ + field: 'rootId', + operator: WhereOperator.EQ, + value: 1, + }); + + // Verify enrichment - one-to-many → array + expect(result[0].relations).toEqual(multipleRelations); + }); + + it('should handle empty one-to-many relation', async () => { + // ARRANGE + const data = createSingleEntitySet(); + const relation = mockOneToManyRelation('relations', 'TestRelation', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const relationRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestRelation: relationRepo, + }); + + rootRepo.findAndCount.mockResolvedValue([data.roots, 1]); + // No relations found + relationRepo.findAndCount.mockResolvedValue([[], 0]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'relations' }], + take: 1, + skip: 0, + }); + + // ASSERT + expect(total).toBe(1); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + + // Handler call verification + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(relationRepo.findAndCount).toHaveBeenCalledTimes(1); + + // ROOT_FIRST: root called before relation + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + relationRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Verify relation service was called with correct filter + // buildConstraint uses EQ (not IN) for single-value constraints + const relationCall = relationRepo.findAndCount.mock.calls[0][0]; + expect(relationCall?.where).toEqual({ + field: 'rootId', + operator: WhereOperator.EQ, + value: 1, + }); + + // Verify enrichment - one-to-many empty → empty array + expect(result[0].relations).toEqual([]); + }); + }); + + describe('mixed relation types', () => { + it('should hydrate both one-to-one and one-to-many relations', async () => { + // ARRANGE + const data = createMultiRelationSet(); + const profileRelation = mockOneToOneRelation('profile', 'TestProfile', { + on: { from: 'id', to: 'rootId' }, + }); + const relationRelation = mockOneToManyRelation( + 'relations', + 'TestRelation', + { + on: { from: 'id', to: 'rootId' }, + }, + ); + const rootRepo = mockTestRepo('TestRoot', { + relations: [profileRelation, relationRelation], + }); + const profileRepo = mockTestRepo('TestProfile'); + const relationRepo = mockTestRepo('TestRelation'); + const { orchestrator } = mockOrchestrator({ + TestProfile: profileRepo, + TestRelation: relationRepo, + }); + + // Single root + rootRepo.findAndCount.mockResolvedValue([[data.roots[0]], 1]); + + // Profile exists for root 1 (one-to-one) + profileRepo.findAndCount.mockResolvedValue([[data.profiles[0]], 1]); + + // Multiple relations for root 1 (one-to-many) + const multipleRelations = [ + { id: 1, rootId: 1, title: 'Relation 1' }, + { id: 2, rootId: 1, title: 'Relation 2' }, + ] as TestRelation[]; + relationRepo.findAndCount.mockResolvedValue([multipleRelations, 2]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'profile' }, { relation: 'relations' }], + take: 1, + skip: 0, + }); + + // ASSERT + expect(total).toBe(1); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + + // Handler call verification + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(profileRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(relationRepo.findAndCount).toHaveBeenCalledTimes(1); + + // ROOT_FIRST: root called before both relations + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + profileRepo.findAndCount.mock.invocationCallOrder[0], + ); + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + relationRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Verify enrichment - both relations properly attached + expect(result[0].profile).toEqual(data.profiles[0]); + expect(result[0].relations).toEqual(multipleRelations); + }); + }); + + describe('null foreign key handling', () => { + it('should handle null foreign key in forward relationship', async () => { + // ARRANGE + const rootWithNullForeignKey = { + id: 1, + name: 'Only Root', + profileId: null, + } as TestRoot; + + const relation = mockOneToOneRelation('profile', 'TestProfile', { + on: { from: 'id', to: 'rootId' }, + }); + const rootRepo = mockTestRepo('TestRoot', { + relations: [relation], + }); + const profileRepo = mockTestRepo('TestProfile'); + const { orchestrator } = mockOrchestrator({ + TestProfile: profileRepo, + }); + + rootRepo.findAndCount.mockResolvedValue([[rootWithNullForeignKey], 1]); + // No profiles found + profileRepo.findAndCount.mockResolvedValue([[], 0]); + + // ACT + const [result, total] = await orchestrator.findAndCount(rootRepo, { + join: [{ relation: 'profile' }], + take: 1, + skip: 0, + }); + + // ASSERT + expect(total).toBe(1); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + + // Handler call verification + expect(rootRepo.findAndCount).toHaveBeenCalledTimes(1); + expect(profileRepo.findAndCount).toHaveBeenCalledTimes(1); + + // ROOT_FIRST: root called before profile + expect(rootRepo.findAndCount.mock.invocationCallOrder[0]).toBeLessThan( + profileRepo.findAndCount.mock.invocationCallOrder[0], + ); + + // Verify enrichment - profile should be null for null foreign key + expect(result[0].profile).toBeNull(); + }); + }); +}); diff --git a/packages/nestjs-repository/src/federation/buffer-strategy.ts b/packages/nestjs-repository/src/federation/buffer-strategy.ts new file mode 100644 index 000000000..1ae429f48 --- /dev/null +++ b/packages/nestjs-repository/src/federation/buffer-strategy.ts @@ -0,0 +1,41 @@ +import { FEDERATION_MAX_BUFFER_SIZE } from './federation.constants.js'; + +/** + * Manages offset-based pagination for iterative constraint discovery. + * + * Addresses the "sparse data problem" in relation-first federation: + * when sorting by a relation field, the first page of sorted relations + * might only correspond to a few unique root entities. + * + * Progressively fetches more relation data until enough unique root IDs + * are discovered to satisfy the requested limit. + */ +export class BufferStrategy { + private currentOffset = 0; + private readonly batchSize: number; + private readonly maxOffset: number; + + constructor( + userLimit: number, + options: { batchSize?: number; maxOffset?: number } = {}, + ) { + const { batchSize = userLimit, maxOffset = FEDERATION_MAX_BUFFER_SIZE } = + options; + + this.batchSize = batchSize; + this.maxOffset = Math.min(maxOffset, FEDERATION_MAX_BUFFER_SIZE); + } + + /** Advance to next batch and return parameters. */ + advance(): { limit: number; offset: number } { + const limit = this.batchSize; + const offset = this.currentOffset; + this.currentOffset += limit; + return { limit, offset }; + } + + /** Check if maximum offset has been reached. */ + hasReachedLimit(): boolean { + return this.currentOffset >= this.maxOffset; + } +} diff --git a/packages/nestjs-repository/src/federation/exceptions/federation.exception.ts b/packages/nestjs-repository/src/federation/exceptions/federation.exception.ts new file mode 100644 index 000000000..7133b5e78 --- /dev/null +++ b/packages/nestjs-repository/src/federation/exceptions/federation.exception.ts @@ -0,0 +1,14 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +/** + * Exception thrown during federation query orchestration. + */ +export class FederationException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ fault: 'internal', ...options }); + this.errorCode = 'FEDERATION_ERROR'; + } +} diff --git a/packages/nestjs-repository/src/federation/execution-strategy.ts b/packages/nestjs-repository/src/federation/execution-strategy.ts new file mode 100644 index 000000000..1bc33df1c --- /dev/null +++ b/packages/nestjs-repository/src/federation/execution-strategy.ts @@ -0,0 +1,196 @@ +import { HttpStatus } from '@nestjs/common'; + +import { + type OrderClause, + type OrderSortKey, +} from '../repository/repository.types.js'; + +import { FederationException } from './exceptions/federation.exception.js'; +import { + type FederatedRelation, + FederationStrategy, +} from './federation.types.js'; +import { type FilterAnalyzer } from './filter-analyzer.js'; +import { type ExecutionAnalysis } from './interfaces/execution-analysis.interface.js'; + +export { ExecutionAnalysis } from './interfaces/execution-analysis.interface.js'; + +/** + * Analyze the query to determine execution strategy and separate + * root vs relation order sort keys. + * + * Strategy selection: + * - ROOT_FIRST: No relation sorts or filters. Fetch roots, then enrich. + * - RELATION_FIRST: Has relation sorts or filters. Discover root IDs + * via relation queries first, then fetch matching roots. + */ +export function analyzeExecution( + filterAnalyzer: FilterAnalyzer, + order: OrderClause | undefined, + relations: FederatedRelation[], +): ExecutionAnalysis { + const { rootOrder, relationOrders, sortedRelationNames, drivingRelation } = + separateOrder(order, relations); + + validateRelationSorts(sortedRelationNames, relations); + validateNoOwningRelationConstraints( + relations, + sortedRelationNames, + filterAnalyzer, + ); + + // Driving relation: first with sort, then first with filter + const effectiveDrivingRelation = + drivingRelation ?? + relations.find((r) => filterAnalyzer.hasFiltersForRelation(r)); + + // A many-cardinality relation chosen to drive discovery purely by a + // caller-specified filter (not a sort — that's covered by + // validateRelationSorts above) reports its total from matching child + // rows, not distinct root ids, unless distinctFilter narrows it to one + // row per root. Structural NOT_NULL injection alone doesn't trigger + // this — see FilterAnalyzer.hasUserFiltersForRelation. + if ( + !drivingRelation && + effectiveDrivingRelation && + effectiveDrivingRelation.cardinality === 'many' && + !effectiveDrivingRelation.distinctFilter && + filterAnalyzer.hasUserFiltersForRelation(effectiveDrivingRelation) + ) { + throw new FederationException({ + message: + 'Filtering on many-cardinality relation "%s" requires distinctFilter configuration', + messageParams: [effectiveDrivingRelation.name], + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } + + const hasRelationSorts = sortedRelationNames.size > 0; + const hasRelationFilters = filterAnalyzer.hasRelationFilters(relations); + + const strategy = + hasRelationSorts || hasRelationFilters + ? FederationStrategy.RELATION_FIRST + : FederationStrategy.ROOT_FIRST; + + return { + strategy, + rootOrder, + relationOrders, + drivingRelation: effectiveDrivingRelation, + sortedRelationNames, + filterAnalyzer, + }; +} + +/** + * Separate OrderClause into root vs relation parts. + * + * Sort keys whose `relation` matches a federated relation name + * are extracted as relation orders. Everything else is a root order. + */ +function separateOrder( + order: OrderClause | undefined, + relations: FederatedRelation[], +): { + rootOrder: OrderClause | undefined; + relationOrders: Map; + sortedRelationNames: Set; + drivingRelation: FederatedRelation | undefined; +} { + if (!order || order.length === 0) { + return { + rootOrder: undefined, + relationOrders: new Map(), + sortedRelationNames: new Set(), + drivingRelation: undefined, + }; + } + + const relationsByName = new Map(relations.map((r) => [r.name, r])); + const rootKeys: OrderSortKey[] = []; + const relationOrders = new Map(); + const sortedRelationNames = new Set(); + let drivingRelation: FederatedRelation | undefined; + + for (const key of order) { + const relation = key.relation + ? relationsByName.get(key.relation) + : undefined; + + if (relation && key.relation) { + const arr = relationOrders.get(key.relation) ?? []; + arr.push(key); + relationOrders.set(key.relation, arr); + sortedRelationNames.add(key.relation); + if (!drivingRelation) drivingRelation = relation; + } else { + rootKeys.push(key); + } + } + + return { + rootOrder: rootKeys.length > 0 ? rootKeys : undefined, + relationOrders, + sortedRelationNames, + drivingRelation, + }; +} + +/** + * Reject filtering or sorting on an owning relation (root FK \> target PK). + * + * RELATION_FIRST discovery only chains non-owning relations — an owning + * relation's target rows carry no root id to extract, so it can't drive + * discovery. Without this check, a filter/sort on an owning-only relation + * set silently produces an empty discovery batch and returns `[[], 0]` as + * if nothing matched, rather than failing loudly. + */ +function validateNoOwningRelationConstraints( + relations: FederatedRelation[], + sortedRelationNames: Set, + filterAnalyzer: FilterAnalyzer, +): void { + for (const relation of relations) { + if (!relation.isOwning) continue; + + if ( + sortedRelationNames.has(relation.name) || + filterAnalyzer.hasFiltersForRelation(relation) + ) { + throw new FederationException({ + message: + 'Filtering or sorting on owning federated relation "%s" is not supported', + messageParams: [relation.name], + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } + } +} + +/** + * Validate that many-cardinality relations being sorted have distinctFilter. + */ +function validateRelationSorts( + sortedRelationNames: Set, + relations: FederatedRelation[], +): void { + for (const name of sortedRelationNames) { + const relation = relations.find((r) => r.name === name); + if ( + relation && + relation.cardinality === 'many' && + !relation.distinctFilter + ) { + throw new FederationException({ + message: + 'Sorting on many-cardinality relation "%s" requires distinctFilter configuration', + messageParams: [name], + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } + } +} diff --git a/packages/nestjs-repository/src/federation/federation-orchestrator.service.ts b/packages/nestjs-repository/src/federation/federation-orchestrator.service.ts new file mode 100644 index 000000000..eaa0539e3 --- /dev/null +++ b/packages/nestjs-repository/src/federation/federation-orchestrator.service.ts @@ -0,0 +1,579 @@ +import { Inject, Injectable, PlainLiteralObject } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; + +import { JoinClause } from '../repository/interfaces/join-clause.interface.js'; +import { RepositoryFindOptions } from '../repository/interfaces/repository-options.interface.js'; +import { RepositoryRelationMetadataInterface } from '../repository/interfaces/repository-relation-metadata.interface.js'; +import { RepositoryInterface } from '../repository/interfaces/repository.interface.js'; +import { OrderClause } from '../repository/repository.types.js'; +import { Where } from '../repository/where.helpers.js'; +import { + REPOSITORY_REGISTRY, + RepositoryRegistryService, +} from '../services/repository-registry.service.js'; +import { getDynamicRepositoryToken } from '../utils/get-dynamic-repository-token.js'; + +import { BufferStrategy } from './buffer-strategy.js'; +import { FederationException } from './exceptions/federation.exception.js'; +import { analyzeExecution, ExecutionAnalysis } from './execution-strategy.js'; +import { + FEDERATION_DEFAULT_LIMIT, + FEDERATION_MAX_ITERATIONS, +} from './federation.constants.js'; +import { + FederatedRelation, + FederationStrategy, + RelationResult, +} from './federation.types.js'; +import { FilterAnalyzer } from './filter-analyzer.js'; +import { hydrateRelations } from './hydration.js'; + +export const FEDERATION_ORCHESTRATOR = Symbol('FederationOrchestrator'); + +/** + * Stateless singleton that orchestrates federated (separate-query) + * relation loading at the repository level. + * + * When a `findAndCount` call includes joins targeting relations + * marked `federated: true` in metadata, the orchestrator: + * + * 1. Strips federated joins from the root query. + * 2. Analyzes filters/sorts to choose ROOT_FIRST or RELATION_FIRST. + * 3. Executes root + peer queries via RepositoryRegistry. + * 4. Hydrates results and returns `[Entity[], accurateTotal]`. + */ +@Injectable() +export class FederationOrchestrator { + constructor( + @Inject(REPOSITORY_REGISTRY) + private readonly registry: RepositoryRegistryService, + private readonly moduleRef: ModuleRef, + ) {} + + /** + * Execute a federated findAndCount. + * + * If no joined relations are federated, delegates directly to the + * root repository's `findAndCount`. + */ + async findAndCount( + rootRepo: RepositoryInterface, + options?: RepositoryFindOptions, + ): Promise<[Entity[], number]> { + const meta = rootRepo.metadata; + const primaryKeys = meta.columns + .filter((c) => c.isPrimary) + .map((c) => c.name); + const rootPK = primaryKeys[0]; + + if (!rootPK) { + throw new FederationException({ + message: 'Entity "%s" has no primary key column', + messageParams: [meta.name], + fault: 'usage', + }); + } + + // Identify federated relations from metadata + requested joins + const federatedRelations = this.buildFederatedRelations( + meta.relations ?? [], + primaryKeys, + options?.join, + ); + + if (federatedRelations.length === 0) { + return rootRepo.findAndCount(options); + } + + // Build filter analyzer (separates root vs relation conditions) + const sortedRelationNames = this.getSortedRelationNames( + options?.order, + federatedRelations, + ); + const filterAnalyzer = new FilterAnalyzer( + options?.where, + federatedRelations, + sortedRelationNames, + ); + + // Analyze execution strategy + const analysis = analyzeExecution( + filterAnalyzer, + options?.order, + federatedRelations, + ); + + // Strip federated joins from root options + const rootOptions = this.buildRootOptions( + options, + analysis, + federatedRelations, + ); + + const take = options?.take ?? FEDERATION_DEFAULT_LIMIT; + const skip = options?.skip ?? 0; + + if (analysis.strategy === FederationStrategy.ROOT_FIRST) { + return this.executeRootFirst( + rootRepo, + rootOptions, + federatedRelations, + rootPK, + analysis, + options, + ); + } + + return this.executeRelationFirst( + rootRepo, + rootOptions, + federatedRelations, + rootPK, + analysis, + take, + skip, + options, + ); + } + + // ═══════════════════════════════════════════════════════════════════ + // ROOT_FIRST strategy + // ═══════════════════════════════════════════════════════════════════ + + private async executeRootFirst( + rootRepo: RepositoryInterface, + rootOptions: RepositoryFindOptions, + relations: FederatedRelation[], + rootPK: string, + analysis: ExecutionAnalysis, + originalOptions?: RepositoryFindOptions, + ): Promise<[Entity[], number]> { + const [roots, total] = await rootRepo.findAndCount(rootOptions); + + if (roots.length === 0) { + return [[], total]; + } + + const relationResults = await this.fetchRelationsForRoots( + roots, + relations, + analysis, + originalOptions, + ); + + hydrateRelations(roots, rootPK, relationResults); + + return [roots, total]; + } + + // ═══════════════════════════════════════════════════════════════════ + // RELATION_FIRST strategy + // ═══════════════════════════════════════════════════════════════════ + + private async executeRelationFirst( + rootRepo: RepositoryInterface, + rootOptions: RepositoryFindOptions, + relations: FederatedRelation[], + rootPK: string, + analysis: ExecutionAnalysis, + take: number, + skip: number, + originalOptions?: RepositoryFindOptions, + ): Promise<[Entity[], number]> { + // Get root filter total for accurate pagination + const rootFilterTotal = await this.getRootTotal(rootRepo, rootOptions); + + // Discover root IDs through iterative relation queries + const discovery = await this.discoverRootIds( + relations, + analysis, + take, + skip, + originalOptions, + ); + + if (discovery.rootIds.length === 0) { + return [[], 0]; + } + + // Fetch constrained roots + let roots = await this.fetchConstrainedRoots( + rootRepo, + rootOptions, + rootPK, + discovery.rootIds, + take, + ); + + // Reorder to match relation-driven sort order + roots = this.reorderByIds(roots, discovery.rootIds, rootPK); + roots = roots.slice(0, take); + + // Fetch complete relation data for final roots + const relationResults = await this.fetchRelationsForRoots( + roots, + relations, + analysis, + originalOptions, + ); + + hydrateRelations(roots, rootPK, relationResults); + + const accurateTotal = Math.min(rootFilterTotal, discovery.relationTotal); + return [roots, accurateTotal]; + } + + // ═══════════════════════════════════════════════════════════════════ + // Root ID discovery (iterative constraint building) + // ═══════════════════════════════════════════════════════════════════ + + private async discoverRootIds( + relations: FederatedRelation[], + analysis: ExecutionAnalysis, + take: number, + skip: number, + originalOptions?: RepositoryFindOptions, + ): Promise<{ + rootIds: unknown[]; + relationTotal: number; + }> { + const accumulated = new Set(); + const buffer = new BufferStrategy(take); + let relationTotal = 0; + + for (let i = 0; i < FEDERATION_MAX_ITERATIONS; i++) { + const batch = await this.processRelationChain( + relations, + analysis, + buffer, + skip, + originalOptions, + ); + + relationTotal = Math.max(relationTotal, batch.relationTotal); + + for (const id of batch.constraintIds) { + accumulated.add(id); + } + + if ( + accumulated.size >= take || + batch.constraintIds.length === 0 || + batch.exhausted + ) { + break; + } + + if (buffer.hasReachedLimit()) break; + } + + return { rootIds: [...accumulated], relationTotal }; + } + + /** + * Process relations sequentially, each passing root ID constraints + * to the next. Non-owning relations only (owning relations cannot + * produce root ID constraints). + */ + private async processRelationChain( + relations: FederatedRelation[], + analysis: ExecutionAnalysis, + buffer: BufferStrategy, + userSkip: number, + originalOptions?: RepositoryFindOptions, + ): Promise<{ + constraintIds: unknown[]; + relationTotal: number; + exhausted: boolean; + }> { + const { limit, offset } = buffer.advance(); + const nonOwnerRelations = relations.filter((r) => !r.isOwning); + + let constraintIds: unknown[] = []; + let relationTotal = 0; + let exhausted = false; + + for (let i = 0; i < nonOwnerRelations.length; i++) { + const relation = nonOwnerRelations[i]; + const isDriving = relation === analysis.drivingRelation; + const isFirst = i === 0; + const shouldPaginate = + isDriving || (!analysis.drivingRelation && isFirst); + + // Build peer query options + const peerConditions = [ + ...analysis.filterAnalyzer.getRelationConditions(relation), + ]; + + // Add constraint from previous relation's root IDs + if (constraintIds.length > 0) { + const constraint = FilterAnalyzer.buildConstraint( + relation.on.to, + constraintIds, + ); + if (constraint) peerConditions.push(constraint); + } + + // Determine pagination for this relation. `offset` tracks the + // buffer's own progress through successive discovery batches, so it + // must stay relative to the caller's skip — not replace it — or a + // later batch reads data from before the caller's requested window. + let effectiveSkip: number | undefined; + if (shouldPaginate) { + effectiveSkip = userSkip + offset; + } + + const peerOptions: RepositoryFindOptions = { + where: FilterAnalyzer.buildWhereClause(peerConditions), + order: isDriving + ? analysis.relationOrders.get(relation.name) + : undefined, + take: shouldPaginate ? limit : undefined, + skip: effectiveSkip, + ctx: originalOptions?.ctx, + }; + + const peerRepo = this.getPeerRepo(relation.targetEntity); + const [data, total] = await peerRepo.findAndCount(peerOptions); + + if (!data || data.length === 0) { + constraintIds = []; + break; + } + + if (isDriving || (isFirst && relationTotal === 0)) { + relationTotal = total; + } + + if (shouldPaginate && data.length < limit) { + exhausted = true; + } + + // Extract root IDs from relation data (target FK → root PK) + const ids = data.map((d) => d[relation.on.to]).filter((v) => v != null); + constraintIds = [...new Set(ids)]; + + if (constraintIds.length === 0) break; + } + + return { constraintIds, relationTotal, exhausted }; + } + + // ═══════════════════════════════════════════════════════════════════ + // Relation fetching + // ═══════════════════════════════════════════════════════════════════ + + /** + * Fetch relation data for a set of root entities. + * Builds constraint filters from root field values and queries + * each peer repository in parallel. + */ + private async fetchRelationsForRoots( + roots: Entity[], + relations: FederatedRelation[], + analysis: ExecutionAnalysis, + originalOptions?: RepositoryFindOptions, + ): Promise { + if (roots.length === 0 || relations.length === 0) return []; + + const promises = relations.map(async (relation) => { + try { + const peerRepo = this.getPeerRepo(relation.targetEntity); + + // Extract constraint values from roots + const rawValues = roots + .map((r) => r[relation.on.from]) + .filter((v) => v != null); + const constraintValues = [...new Set(rawValues)]; + + // Build peer query conditions + const conditions = [ + ...analysis.filterAnalyzer.getRelationConditions(relation), + ]; + const constraint = FilterAnalyzer.buildConstraint( + relation.on.to, + constraintValues, + ); + if (constraint) conditions.push(constraint); + + const peerOptions: RepositoryFindOptions = { + where: FilterAnalyzer.buildWhereClause(conditions), + ctx: originalOptions?.ctx, + }; + + const [data, total] = await peerRepo.findAndCount(peerOptions); + return { relation, data, total }; + } catch (error) { + throw new FederationException({ + message: 'Failed to fetch relation "%s" from entity "%s"', + messageParams: [relation.name, relation.targetEntity], + originalError: error, + fault: 'internal', + }); + } + }); + + return Promise.all(promises); + } + + // ═══════════════════════════════════════════════════════════════════ + // Root query helpers + // ═══════════════════════════════════════════════════════════════════ + + /** + * Get the total count of roots matching root-only filters. + * Used for accurate pagination in RELATION_FIRST strategy. + */ + private async getRootTotal( + rootRepo: RepositoryInterface, + rootOptions: RepositoryFindOptions, + ): Promise { + if (!rootOptions.where) return Number.MAX_SAFE_INTEGER; + + const countOptions: RepositoryFindOptions = { + where: rootOptions.where, + ctx: rootOptions.ctx, + }; + + return rootRepo.count(countOptions); + } + + /** + * Fetch roots constrained to specific IDs. + */ + private async fetchConstrainedRoots( + rootRepo: RepositoryInterface, + rootOptions: RepositoryFindOptions, + rootPK: string, + rootIds: unknown[], + take: number, + ): Promise { + const constraint = FilterAnalyzer.buildConstraint(rootPK, rootIds); + if (!constraint) return []; + + const where = rootOptions.where + ? Where.and(rootOptions.where, constraint) + : constraint; + + const constrainedOptions: RepositoryFindOptions = { + ...rootOptions, + where, + take, + skip: undefined, + }; + + const [roots] = await rootRepo.findAndCount(constrainedOptions); + return roots; + } + + /** + * Reorder roots to match the order of provided IDs. + */ + private reorderByIds( + roots: Entity[], + orderedIds: unknown[], + rootPK: string, + ): Entity[] { + const map = new Map(); + for (const root of roots) { + map.set(root[rootPK], root); + } + + return orderedIds + .map((id) => map.get(id)) + .filter((r): r is Entity => r !== undefined); + } + + // ═══════════════════════════════════════════════════════════════════ + // Setup helpers + // ═══════════════════════════════════════════════════════════════════ + + /** + * Build FederatedRelation array from metadata + join clauses. + * Only includes relations that are both `federated: true` in metadata + * AND present in the requested join list. + */ + private buildFederatedRelations( + relations: RepositoryRelationMetadataInterface[], + rootPrimaryKeys: string[], + joins?: JoinClause[], + ): FederatedRelation[] { + if (!joins?.length) return []; + + const joinMap = new Map(joins.map((j) => [j.relation, j])); + + return relations + .filter((rel) => rel.federated && joinMap.has(rel.name)) + .map((rel) => ({ + ...rel, + joinType: joinMap.get(rel.name)?.joinType ?? ('LEFT' as const), + isOwning: !rootPrimaryKeys.includes(rel.on.from), + })); + } + + /** + * Build root query options with federated joins stripped + * and relation-tagged conditions removed. + */ + private buildRootOptions( + options: RepositoryFindOptions | undefined, + analysis: ExecutionAnalysis, + federatedRelations: FederatedRelation[], + ): RepositoryFindOptions { + const federatedNames = new Set(federatedRelations.map((r) => r.name)); + const nonFederatedJoins = options?.join?.filter( + (j) => !federatedNames.has(j.relation), + ); + + return { + ...options, + where: analysis.filterAnalyzer.getRootWhere(), + order: analysis.rootOrder, + join: + nonFederatedJoins && nonFederatedJoins.length > 0 + ? nonFederatedJoins + : undefined, + }; + } + + /** + * Get the set of relation names present as sort keys in the order options. + */ + private getSortedRelationNames( + order: OrderClause | undefined, + relations: FederatedRelation[], + ): Set { + if (!order) return new Set(); + + const relationNames = new Set(relations.map((r) => r.name)); + const sorted = new Set(); + + for (const key of order) { + if (key.relation && relationNames.has(key.relation)) { + sorted.add(key.relation); + } + } + + return sorted; + } + + /** + * Resolve a peer repository by entity name via the registry. + */ + private getPeerRepo( + entityName: string, + ): RepositoryInterface { + const item = this.registry.getByEntityName(entityName); + if (!item) { + throw new FederationException({ + message: 'No repository registered for entity "%s"', + messageParams: [entityName], + fault: 'usage', + }); + } + + return this.moduleRef.get(getDynamicRepositoryToken(item.key), { + strict: false, + }); + } +} diff --git a/packages/nestjs-repository/src/federation/federation.constants.ts b/packages/nestjs-repository/src/federation/federation.constants.ts new file mode 100644 index 000000000..b2f196122 --- /dev/null +++ b/packages/nestjs-repository/src/federation/federation.constants.ts @@ -0,0 +1,8 @@ +/** Default take limit when none specified. */ +export const FEDERATION_DEFAULT_LIMIT = 10; + +/** Maximum iterations for iterative constraint discovery. */ +export const FEDERATION_MAX_ITERATIONS = 10; + +/** Maximum offset before aborting iterative discovery. */ +export const FEDERATION_MAX_BUFFER_SIZE = 1000; diff --git a/packages/nestjs-repository/src/federation/federation.types.ts b/packages/nestjs-repository/src/federation/federation.types.ts new file mode 100644 index 000000000..efb4e3dce --- /dev/null +++ b/packages/nestjs-repository/src/federation/federation.types.ts @@ -0,0 +1,39 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type RepositoryRelationMetadataInterface } from '../repository/interfaces/repository-relation-metadata.interface.js'; + +/** + * Relation metadata enriched with a single computed field. + * + * `isOwning` is derived from comparing `on.from` against the + * root entity's primary keys and is used throughout federation + * to determine constraint direction and hydration logic. + */ +export type FederatedRelation = + Readonly & { + /** + * True when root entity holds the FK (owning side). + * + * Owning: `on.from` is root FK, `on.to` is target PK. + * Non-owning: `on.from` is root PK, `on.to` is target FK. + * + * Owning relations cannot drive RELATION_FIRST strategy + * because root IDs cannot be extracted from target data. + */ + isOwning: boolean; + /** Copied from the JoinClause at build time. */ + joinType: 'LEFT' | 'INNER'; + }; + +/** Execution strategy type. */ +export enum FederationStrategy { + ROOT_FIRST = 'ROOT_FIRST', + RELATION_FIRST = 'RELATION_FIRST', +} + +/** Relation query result. */ +export interface RelationResult { + relation: FederatedRelation; + data: PlainLiteralObject[]; + total: number; +} diff --git a/packages/nestjs-repository/src/federation/filter-analyzer.ts b/packages/nestjs-repository/src/federation/filter-analyzer.ts new file mode 100644 index 000000000..00804aeb0 --- /dev/null +++ b/packages/nestjs-repository/src/federation/filter-analyzer.ts @@ -0,0 +1,232 @@ +import { HttpStatus, type PlainLiteralObject } from '@nestjs/common'; + +import { + type WhereClause, + type WhereCondition, + isWhereCondition, + isWhereCompound, +} from '../repository/interfaces/where-clause.interface.js'; +import { + WhereCompoundOperator, + WhereOperator, +} from '../repository/repository.types.js'; + +import { FederationException } from './exceptions/federation.exception.js'; +import { type FederatedRelation } from './federation.types.js'; + +/** + * Separates a WhereClause tree into root conditions and + * relation-tagged conditions grouped by relation name. + * + * Also injects NOT_NULL filters for INNER JOIN semantics + * and applies distinctFilter from relation metadata. + */ +export class FilterAnalyzer { + private rootWhere: WhereClause | undefined; + private readonly relationConditions = new Map< + string, + WhereCondition[] + >(); + private readonly userFilteredRelationNames: Set; + + constructor( + where: WhereClause | undefined, + relations: FederatedRelation[], + sortedRelationNames: Set, + ) { + const federatedNames = new Set(relations.map((r) => r.name)); + this.rootWhere = this.extractRelationConditions(where, federatedNames); + // Snapshot before injection: NOT_NULL (INNER JOIN) and distinctFilter + // conditions are structural, not caller-specified, so they shouldn't + // count as a "filter" for validation that only cares about what the + // caller actually asked to filter by. + this.userFilteredRelationNames = new Set(this.relationConditions.keys()); + + if (relations.length > 0) { + this.injectInnerJoinFilters(relations, sortedRelationNames); + this.injectDistinctFilters(relations); + } + } + + /** Root WhereClause with relation-tagged conditions removed. */ + getRootWhere(): WhereClause | undefined { + return this.rootWhere; + } + + /** Get extracted conditions for a specific relation. */ + getRelationConditions( + relation: FederatedRelation, + ): readonly WhereCondition[] { + return this.relationConditions.get(relation.name) ?? []; + } + + /** Whether the given relation has any extracted filter conditions. */ + hasFiltersForRelation(relation: FederatedRelation): boolean { + const conditions = this.relationConditions.get(relation.name); + return conditions !== undefined && conditions.length > 0; + } + + /** + * Whether the caller specified a filter condition on this relation, + * excluding structural conditions this class injects itself (INNER JOIN + * NOT_NULL, distinctFilter). + */ + hasUserFiltersForRelation(relation: FederatedRelation): boolean { + return this.userFilteredRelationNames.has(relation.name); + } + + hasRelationFilters(relations: FederatedRelation[]): boolean { + return relations.some((r) => this.hasFiltersForRelation(r)); + } + + /** Build a constraint WhereCondition for IN/EQ on a field. */ + static buildConstraint( + field: string, + values: unknown[], + ): WhereCondition | undefined { + if (values.length === 0) return undefined; + if (values.length === 1) { + return { field, operator: WhereOperator.EQ, value: values[0] }; + } + return { field, operator: WhereOperator.IN, value: values }; + } + + /** Build a WhereClause from a flat array of AND conditions. */ + static buildWhereClause( + conditions: WhereCondition[], + ): WhereClause | undefined { + if (conditions.length === 0) return undefined; + if (conditions.length === 1) return conditions[0]; + return { operator: WhereCompoundOperator.AND, conditions }; + } + + // ═══════════════════════════════════════════════════════════════════ + // Tree extraction + // ═══════════════════════════════════════════════════════════════════ + + /** + * Walk the WhereClause tree and extract relation-tagged conditions. + * Returns the pruned tree with those conditions removed. + * + * Throws if a relation-tagged condition appears inside an OR compound. + */ + private extractRelationConditions( + clause: WhereClause | undefined, + federatedNames: Set, + ): WhereClause | undefined { + if (!clause) return undefined; + return this.filterClause(clause, federatedNames, false); + } + + private filterClause( + clause: WhereClause, + federatedNames: Set, + insideOr: boolean, + ): WhereClause | undefined { + if (isWhereCondition(clause)) { + if (clause.relation && federatedNames.has(clause.relation)) { + if (insideOr) { + throw new FederationException({ + message: + 'OR conditions on federated relation "%s" are not supported', + messageParams: [clause.relation], + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } + this.addRelationCondition(clause.relation, clause); + return undefined; + } + return clause; + } + + if (!isWhereCompound(clause)) return clause; + + const isOr = clause.operator === WhereCompoundOperator.OR; + const kept: WhereClause[] = []; + + for (const child of clause.conditions) { + const filtered = this.filterClause( + child, + federatedNames, + insideOr || isOr, + ); + if (filtered) kept.push(filtered); + } + + if (kept.length === 0) return undefined; + if (kept.length === 1) return kept[0]; + return { operator: clause.operator, conditions: kept }; + } + + // ═══════════════════════════════════════════════════════════════════ + // Condition injection + // ═══════════════════════════════════════════════════════════════════ + + private addRootCondition( + condition: WhereCondition, + ): void { + if (!this.rootWhere) { + this.rootWhere = condition; + } else { + this.rootWhere = { + operator: WhereCompoundOperator.AND, + conditions: [this.rootWhere, condition], + }; + } + } + + private addRelationCondition( + relationName: string, + condition: WhereCondition, + ): void { + let conditions = this.relationConditions.get(relationName); + if (!conditions) { + conditions = []; + this.relationConditions.set(relationName, conditions); + } + conditions.push(condition); + } + + /** + * Inject NOT_NULL filters for INNER JOIN relations and sorted relations. + * + * Owning: NOT_NULL on root's FK column (added to root where). + * Non-owning: NOT_NULL on target's FK column (added to relation conditions). + */ + private injectInnerJoinFilters( + relations: FederatedRelation[], + sortedRelationNames: Set, + ): void { + const needsInnerJoin = relations.filter( + (r) => r.joinType === 'INNER' || sortedRelationNames.has(r.name), + ); + + for (const relation of needsInnerJoin) { + if (relation.isOwning) { + this.addRootCondition({ + field: relation.on.from, + operator: WhereOperator.NOT_NULL, + }); + } else { + this.addRelationCondition(relation.name, { + field: relation.on.to, + operator: WhereOperator.NOT_NULL, + relation: relation.name, + }); + } + } + } + + /** Add distinctFilter conditions from relation metadata. */ + private injectDistinctFilters(relations: FederatedRelation[]): void { + for (const relation of relations) { + if (relation.distinctFilter) { + this.addRelationCondition(relation.name, { + ...relation.distinctFilter, + relation: relation.name, + }); + } + } + } +} diff --git a/packages/nestjs-repository/src/federation/hydration.ts b/packages/nestjs-repository/src/federation/hydration.ts new file mode 100644 index 000000000..c8ede61ff --- /dev/null +++ b/packages/nestjs-repository/src/federation/hydration.ts @@ -0,0 +1,143 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type FederatedRelation, + type RelationResult, +} from './federation.types.js'; + +/** + * Hydrate relations on root entities by matching FK columns. + * + * For each relation result, assigns target entities to root entities + * based on the `on.from` (root column) and `on.to` (target column) mapping. + */ +export function hydrateRelations( + roots: Entity[], + rootPrimaryKey: string, + relationResults: RelationResult[], +): void { + if (roots.length === 0 || relationResults.length === 0) return; + + // Build root lookup by primary key + const rootMap = new Map(); + for (const root of roots) { + rootMap.set(root[rootPrimaryKey], root); + } + + for (const result of relationResults) { + const relation = result.relation; + + if (relation.isOwning) { + hydrateOwning(roots, relation, result.data); + } else { + hydrateNonOwning(rootMap, relation, result.data); + } + } + + // Fill in missing relation properties with defaults + const allRelations = relationResults.map((r) => r.relation); + initializeEmptyRelations(roots, allRelations, true); +} + +/** + * Hydrate owning relation: root[on.from] → target[on.to]. + * Root holds the FK, target holds the PK. + */ +function hydrateOwning( + roots: Entity[], + relation: FederatedRelation, + targets: PlainLiteralObject[], +): void { + if (relation.cardinality === 'many') { + // Group targets by their key column (target PK) + const grouped = groupBy(targets, relation.on.to); + + for (const root of roots) { + const fk = root[relation.on.from]; + if (fk != null) { + setProperty(root, relation.name, grouped.get(fk) ?? []); + } + } + } else { + // Single target lookup + const byKey = new Map(); + for (const target of targets) { + byKey.set(target[relation.on.to], target); + } + + for (const root of roots) { + const fk = root[relation.on.from]; + if (fk != null) { + const target = byKey.get(fk); + if (target) setProperty(root, relation.name, target); + } + } + } +} + +/** + * Hydrate non-owning relation: target[on.to] → root[on.from]. + * Target holds the FK pointing to root's PK. + */ +function hydrateNonOwning( + rootMap: Map, + relation: FederatedRelation, + targets: PlainLiteralObject[], +): void { + // Group targets by the root key they reference + const grouped = groupBy(targets, relation.on.to); + + for (const [rootKeyValue, groupedTargets] of grouped) { + const root = rootMap.get(rootKeyValue); + if (!root) continue; + + if (relation.cardinality === 'one') { + setProperty(root, relation.name, groupedTargets[0] ?? null); + } else { + setProperty(root, relation.name, groupedTargets); + } + } +} + +/** + * Initialize relation properties with empty defaults (null or []). + */ +export function initializeEmptyRelations( + roots: Entity[], + relations: FederatedRelation[], + onlyIfMissing = false, +): void { + for (const root of roots) { + for (const relation of relations) { + if (!onlyIfMissing || !(relation.name in root)) { + const defaultValue = relation.cardinality === 'one' ? null : []; + setProperty(root, relation.name, defaultValue); + } + } + } +} + +function groupBy( + items: PlainLiteralObject[], + key: string, +): Map { + const map = new Map(); + for (const item of items) { + const value = item[key]; + let group = map.get(value); + if (!group) { + group = []; + map.set(value, group); + } + group.push(item); + } + return map; +} + +function setProperty( + entity: PlainLiteralObject, + name: string, + value: unknown, +): void { + entity[name] = value; +} diff --git a/packages/nestjs-repository/src/federation/interfaces/execution-analysis.interface.ts b/packages/nestjs-repository/src/federation/interfaces/execution-analysis.interface.ts new file mode 100644 index 000000000..596c6f093 --- /dev/null +++ b/packages/nestjs-repository/src/federation/interfaces/execution-analysis.interface.ts @@ -0,0 +1,24 @@ +import { type OrderClause } from '../../repository/repository.types.js'; +import { + type FederatedRelation, + type FederationStrategy, +} from '../federation.types.js'; +import { type FilterAnalyzer } from '../filter-analyzer.js'; + +/** + * Result of analyzing execution requirements for a federation query. + */ +export interface ExecutionAnalysis { + /** Whether to query roots first or relations first. */ + strategy: FederationStrategy; + /** Order sort keys for the root query (relation keys removed). */ + rootOrder: OrderClause | undefined; + /** Order sort keys keyed by relation name for peer queries. */ + relationOrders: Map; + /** First relation with sorts or filters (drives RELATION_FIRST iteration). */ + drivingRelation: FederatedRelation | undefined; + /** Relation names that appear as sort keys. */ + sortedRelationNames: Set; + /** Filter analysis results. */ + filterAnalyzer: FilterAnalyzer; +} diff --git a/packages/nestjs-repository/src/hooks/hook-method.types.ts b/packages/nestjs-repository/src/hooks/hook-method.types.ts new file mode 100644 index 000000000..e82f58858 --- /dev/null +++ b/packages/nestjs-repository/src/hooks/hook-method.types.ts @@ -0,0 +1,333 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { + type RepositoryFindOptions, + type RepositoryFindOneOptions, +} from '../repository/interfaces/repository-options.interface.js'; + +// ============================================================================= +// Read Operations +// ============================================================================= + +/** + * Before find - receives FindManyOptions, returns modified options. + */ +export type BeforeFindMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = ( + options: RepositoryFindOptions, + ctx?: Ctx, +) => Promise>; + +/** + * After find - receives array of entities, returns modified array. + */ +export type AfterFindMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity[], ctx?: Ctx) => Promise; + +/** + * Before findOne - receives FindOneOptions, returns modified options. + */ +export type BeforeFindOneMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = ( + options: RepositoryFindOneOptions, + ctx?: Ctx, +) => Promise>; + +/** + * After findOne - receives entity or null, returns entity or null. + */ +export type AfterFindOneMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity | null, ctx?: Ctx) => Promise; + +/** + * Before count - receives FindManyOptions, returns modified options. + */ +export type BeforeCountMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = ( + options: RepositoryFindOptions, + ctx?: Ctx, +) => Promise>; + +/** + * After count - receives count, returns count. + */ +export type AfterCountMethod< + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: number, ctx?: Ctx) => Promise; + +/** + * Before findAndCount - receives FindManyOptions, returns modified options. + */ +export type BeforeFindAndCountMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = ( + options: RepositoryFindOptions, + ctx?: Ctx, +) => Promise>; + +/** + * After findAndCount - receives [entities, count], returns [entities, count]. + */ +export type AfterFindAndCountMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: [Entity[], number], ctx?: Ctx) => Promise<[Entity[], number]>; + +// ============================================================================= +// Create Operations +// ============================================================================= + +/** + * Before create - receives entity data, returns modified data. + */ +export type BeforeCreateMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (data: DeepPartial, ctx?: Ctx) => Promise>; + +/** + * After create - receives created entity, returns entity. + */ +export type AfterCreateMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity, ctx?: Ctx) => Promise; + +/** + * Before createMany - receives array of entity data, returns modified array. + */ +export type BeforeCreateManyMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (data: DeepPartial[], ctx?: Ctx) => Promise[]>; + +/** + * After createMany - receives created entities, returns entities. + */ +export type AfterCreateManyMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity[], ctx?: Ctx) => Promise; + +// ============================================================================= +// Update Operations +// ============================================================================= + +/** + * Before update - receives entity and update data, returns modified data. + */ +export type BeforeUpdateMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = ( + entity: Entity, + data: DeepPartial, + ctx?: Ctx, +) => Promise>; + +/** + * After update - receives updated entity, returns entity. + */ +export type AfterUpdateMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity, ctx?: Ctx) => Promise; + +/** + * Before upsert - receives entity data, returns modified data. + */ +export type BeforeUpsertMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (data: DeepPartial, ctx?: Ctx) => Promise>; + +/** + * After upsert - receives upserted entity, returns entity. + */ +export type AfterUpsertMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity, ctx?: Ctx) => Promise; + +/** + * Before replace - receives entity and replacement data, returns modified data. + */ +export type BeforeReplaceMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = ( + entity: Entity, + data: DeepPartial, + ctx?: Ctx, +) => Promise>; + +/** + * After replace - receives replaced entity, returns entity. + */ +export type AfterReplaceMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity, ctx?: Ctx) => Promise; + +// ============================================================================= +// Delete Operations +// ============================================================================= + +/** + * Before delete - receives entity, returns entity (or throws to prevent). + */ +export type BeforeDeleteMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (entity: Entity, ctx?: Ctx) => Promise; + +/** + * After delete - receives deleted entity, returns entity. + */ +export type AfterDeleteMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity, ctx?: Ctx) => Promise; + +/** + * Before deleteMany - receives entities, returns entities (or throws to prevent). + */ +export type BeforeDeleteManyMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (entities: Entity[], ctx?: Ctx) => Promise; + +/** + * After deleteMany - receives deleted entities, returns entities. + */ +export type AfterDeleteManyMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity[], ctx?: Ctx) => Promise; + +// ============================================================================= +// Lifecycle Operations (soft delete/restore) +// ============================================================================= + +/** + * Before soft delete - receives entity, returns entity. + */ +export type BeforeSoftDeleteMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (entity: Entity, ctx?: Ctx) => Promise; + +/** + * After soft delete - receives soft-deleted entity, returns entity. + */ +export type AfterSoftDeleteMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity, ctx?: Ctx) => Promise; + +/** + * Before restore - receives entity, returns entity. + */ +export type BeforeRestoreMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (entity: Entity, ctx?: Ctx) => Promise; + +/** + * After restore - receives restored entity, returns entity. + */ +export type AfterRestoreMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity, ctx?: Ctx) => Promise; + +// ============================================================================= +// High-Level Semantic Operations (catch-all) +// ============================================================================= + +/** + * Before any read operation (find, findOne, count, findAndCount). + * Uses FindManyOptions as it's the superset. + */ +export type BeforeReadMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = ( + options: RepositoryFindOptions, + ctx?: Ctx, +) => Promise>; + +/** + * After any read operation. + */ +export type AfterReadMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = ( + result: Entity | Entity[] | null | number | [Entity[], number], + ctx?: Ctx, +) => Promise; + +/** + * Before any write operation (create, createMany, update, upsert, replace). + */ +export type BeforeWriteMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = ( + data: DeepPartial | DeepPartial[], + ctx?: Ctx, +) => Promise | DeepPartial[]>; + +/** + * After any write operation. + */ +export type AfterWriteMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity | Entity[], ctx?: Ctx) => Promise; + +/** + * Before any transition operation (softRemove, restore). + */ +export type BeforeTransitionMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (entity: Entity, ctx?: Ctx) => Promise; + +/** + * After any transition operation. + */ +export type AfterTransitionMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity, ctx?: Ctx) => Promise; + +/** + * Before any destroy operation (remove - hard delete). + */ +export type BeforeDestroyMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (entity: Entity, ctx?: Ctx) => Promise; + +/** + * After any destroy operation. + */ +export type AfterDestroyMethod< + Entity extends PlainLiteralObject = PlainLiteralObject, + Ctx extends PlainLiteralObject = PlainLiteralObject, +> = (result: Entity, ctx?: Ctx) => Promise; diff --git a/packages/nestjs-repository/src/hooks/repo-permeator-factory.ts b/packages/nestjs-repository/src/hooks/repo-permeator-factory.ts new file mode 100644 index 000000000..9e559e6c2 --- /dev/null +++ b/packages/nestjs-repository/src/hooks/repo-permeator-factory.ts @@ -0,0 +1,320 @@ +import { + Membrane, + Permeator, + type IPermeator, + type PermeateCallback, + type PermeatorOptions, +} from '@tsyche/membrane'; + +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type DeepPartial, + type HookMethodKeyType, + RuntimeException, +} from '@concepta/nestjs-core'; + +import { RepositoryQueryException } from '../exceptions/repository-query.exception.js'; +import { + type RepositoryFindOptions, + type RepositoryFindOneOptions, +} from '../repository/interfaces/repository-options.interface.js'; + +import { RepoHookMethodKey as K } from './repository-hook.decorators.js'; + +type RunHooksFn = ( + methodKey: HookMethodKeyType, + payload: T, + ctx: PlainLiteralObject | undefined, +) => Promise; + +type Ctx = PlainLiteralObject; +type HookCb = PermeateCallback; + +type RepoPermeator = IPermeator< + TIn, + TOut, + unknown, + unknown, + Ctx, + TResult +>; + +export class RepoPermeatorFactory< + Entity extends PlainLiteralObject = PlainLiteralObject, +> { + // Read operations (overwrite: hooks can freely transform) + readonly find: RepoPermeator, Entity[]>; + readonly findOne: RepoPermeator< + RepositoryFindOneOptions, + Entity, + Entity | null + >; + readonly count: RepoPermeator, number>; + readonly findAndCount: RepoPermeator< + RepositoryFindOptions, + [Entity[], number] + >; + + // Write operations (preserve: original/DB result wins) + readonly create: RepoPermeator, Entity>; + readonly createMany: RepoPermeator[], Entity[]>; + readonly update: RepoPermeator, Entity>; + readonly upsert: RepoPermeator, Entity>; + readonly replace: RepoPermeator, Entity>; + + // Delete/lifecycle operations + readonly delete: RepoPermeator; + readonly deleteMany: RepoPermeator; + readonly softDelete: RepoPermeator; + readonly restore: RepoPermeator; + + constructor(runHooks: RunHooksFn, entityName: string) { + const cb = + (key: HookMethodKeyType): HookCb => + (payload: T, ambient?: Ctx) => + runHooks(key, payload, ambient); + + const options: PermeatorOptions = { + onError: (error: unknown): never => { + if (error instanceof RuntimeException) throw error; + throw new RepositoryQueryException(entityName, { + originalError: error, + }); + }, + }; + + // Read + this.find = Permeator.mutable( + Membrane.sequence( + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_READ), + 'overwrite', + ), + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_FIND), + 'overwrite', + ), + ), + Membrane.sequence( + Membrane.collection(cb(K.AFTER_FIND), 'overwrite'), + Membrane.collection(cb(K.AFTER_READ), 'overwrite'), + ), + options, + ); + + this.findOne = Permeator.mutable( + Membrane.sequence( + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_READ), + 'overwrite', + ), + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_FIND_ONE), + 'overwrite', + ), + ), + Membrane.nullable( + Membrane.sequence( + Membrane.object( + cb(K.AFTER_FIND_ONE), + 'overwrite', + ), + Membrane.object(cb(K.AFTER_READ), 'overwrite'), + ), + ), + options, + ); + + this.count = Permeator.mutable( + Membrane.sequence( + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_READ), + 'overwrite', + ), + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_COUNT), + 'overwrite', + ), + ), + Membrane.scalar(cb(K.AFTER_COUNT)), + options, + ); + + this.findAndCount = Permeator.mutable( + Membrane.sequence( + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_READ), + 'overwrite', + ), + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_FIND_AND_COUNT), + 'overwrite', + ), + ), + Membrane.object<[Entity[], number], unknown, Ctx>( + cb(K.AFTER_FIND_AND_COUNT), + 'overwrite', + ), + options, + ); + + // Write (preserve: original data wins) + this.create = Permeator.mutable( + Membrane.sequence( + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_WRITE), + 'preserve', + ), + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_CREATE), + 'preserve', + ), + ), + Membrane.sequence( + Membrane.object(cb(K.AFTER_CREATE), 'preserve'), + Membrane.object(cb(K.AFTER_WRITE), 'preserve'), + ), + options, + ); + + this.createMany = Permeator.mutable( + Membrane.sequence( + Membrane.collection, Ctx>( + cb(K.BEFORE_WRITE), + 'overwrite', + ), + Membrane.collection, Ctx>( + cb(K.BEFORE_CREATE_MANY), + 'overwrite', + ), + ), + Membrane.sequence( + Membrane.collection(cb(K.AFTER_CREATE_MANY), 'overwrite'), + Membrane.collection(cb(K.AFTER_WRITE), 'overwrite'), + ), + options, + ); + + this.update = Permeator.mutable( + Membrane.sequence( + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_WRITE), + 'preserve', + ), + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_UPDATE), + 'preserve', + ), + ), + Membrane.sequence( + Membrane.object(cb(K.AFTER_UPDATE), 'preserve'), + Membrane.object(cb(K.AFTER_WRITE), 'preserve'), + ), + options, + ); + + this.upsert = Permeator.mutable( + Membrane.sequence( + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_WRITE), + 'preserve', + ), + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_UPSERT), + 'preserve', + ), + ), + Membrane.sequence( + Membrane.object(cb(K.AFTER_UPSERT), 'preserve'), + Membrane.object(cb(K.AFTER_WRITE), 'preserve'), + ), + options, + ); + + this.replace = Permeator.mutable( + Membrane.sequence( + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_WRITE), + 'preserve', + ), + Membrane.object, unknown, Ctx>( + cb(K.BEFORE_REPLACE), + 'preserve', + ), + ), + Membrane.sequence( + Membrane.object(cb(K.AFTER_REPLACE), 'preserve'), + Membrane.object(cb(K.AFTER_WRITE), 'preserve'), + ), + options, + ); + + // Delete/lifecycle + this.delete = Permeator.mutable( + Membrane.sequence( + Membrane.object(cb(K.BEFORE_DESTROY), 'preserve'), + Membrane.object(cb(K.BEFORE_DELETE), 'preserve'), + ), + Membrane.sequence( + Membrane.object(cb(K.AFTER_DELETE), 'preserve'), + Membrane.object(cb(K.AFTER_DESTROY), 'preserve'), + ), + options, + ); + + this.deleteMany = Permeator.mutable( + Membrane.sequence( + Membrane.collection(cb(K.BEFORE_DESTROY), 'preserve'), + Membrane.collection(cb(K.BEFORE_DELETE_MANY), 'preserve'), + ), + Membrane.sequence( + Membrane.collection(cb(K.AFTER_DELETE_MANY), 'preserve'), + Membrane.collection(cb(K.AFTER_DESTROY), 'preserve'), + ), + options, + ); + + this.softDelete = Permeator.mutable( + Membrane.sequence( + Membrane.object( + cb(K.BEFORE_TRANSITION), + 'preserve', + ), + Membrane.object( + cb(K.BEFORE_SOFT_DELETE), + 'preserve', + ), + ), + Membrane.sequence( + Membrane.object( + cb(K.AFTER_SOFT_DELETE), + 'preserve', + ), + Membrane.object( + cb(K.AFTER_TRANSITION), + 'preserve', + ), + ), + options, + ); + + this.restore = Permeator.mutable( + Membrane.sequence( + Membrane.object( + cb(K.BEFORE_TRANSITION), + 'preserve', + ), + Membrane.object(cb(K.BEFORE_RESTORE), 'preserve'), + ), + Membrane.sequence( + Membrane.object(cb(K.AFTER_RESTORE), 'preserve'), + Membrane.object( + cb(K.AFTER_TRANSITION), + 'preserve', + ), + ), + options, + ); + } +} diff --git a/packages/nestjs-repository/src/hooks/repository-hook.decorators.ts b/packages/nestjs-repository/src/hooks/repository-hook.decorators.ts new file mode 100644 index 000000000..d16b39f5f --- /dev/null +++ b/packages/nestjs-repository/src/hooks/repository-hook.decorators.ts @@ -0,0 +1,346 @@ +import { + type SpecificationInterface, + createHookMethodDecorator, + Hook, + type HookTypeInterface, +} from '@concepta/nestjs-core'; + +/** + * Repository hook method keys. + * Used with createHookMethodDecorator to create type-safe hook decorators. + */ +export const RepoHookMethodKey = { + // High-level semantic keys + BEFORE_READ: 'beforeRead', + AFTER_READ: 'afterRead', + BEFORE_WRITE: 'beforeWrite', + AFTER_WRITE: 'afterWrite', + BEFORE_TRANSITION: 'beforeTransition', + AFTER_TRANSITION: 'afterTransition', + BEFORE_DESTROY: 'beforeDestroy', + AFTER_DESTROY: 'afterDestroy', + + // Fine-grained method keys + BEFORE_FIND: 'beforeFind', + AFTER_FIND: 'afterFind', + BEFORE_FIND_ONE: 'beforeFindOne', + AFTER_FIND_ONE: 'afterFindOne', + BEFORE_COUNT: 'beforeCount', + AFTER_COUNT: 'afterCount', + BEFORE_FIND_AND_COUNT: 'beforeFindAndCount', + AFTER_FIND_AND_COUNT: 'afterFindAndCount', + BEFORE_CREATE: 'beforeCreate', + AFTER_CREATE: 'afterCreate', + BEFORE_CREATE_MANY: 'beforeCreateMany', + AFTER_CREATE_MANY: 'afterCreateMany', + BEFORE_UPDATE: 'beforeUpdate', + AFTER_UPDATE: 'afterUpdate', + BEFORE_UPSERT: 'beforeUpsert', + AFTER_UPSERT: 'afterUpsert', + BEFORE_REPLACE: 'beforeReplace', + AFTER_REPLACE: 'afterReplace', + BEFORE_DELETE: 'beforeDelete', + AFTER_DELETE: 'afterDelete', + BEFORE_DELETE_MANY: 'beforeDeleteMany', + AFTER_DELETE_MANY: 'afterDeleteMany', + BEFORE_SOFT_DELETE: 'beforeSoftDelete', + AFTER_SOFT_DELETE: 'afterSoftDelete', + BEFORE_RESTORE: 'beforeRestore', + AFTER_RESTORE: 'afterRestore', +} as const; + +// ============================================================================= +// Repository Hook Type Decorator +// ============================================================================= + +/** + * Marks a class as a repository hook. + * + * @param spec - Optional specification for when this hook applies + * + * @example + * ```typescript + * @RepoHook() + * export class TenantHook { + * @BeforeFind() + * addTenantFilter(options, ctx) { ... } + * } + * + * @RepoHook(Spec.entity('User')) + * export class UserOnlyHook { + * @AfterCreate() + * notifyUserCreated(result, ctx) { ... } + * } + * ``` + */ +export function RepoHook(spec?: SpecificationInterface): ClassDecorator { + return Hook({ type: RepoHook, spec }); +} + +RepoHook.KEY = 'RepositoryHook'; +Object.freeze(RepoHook); + +// Type assertion for HookTypeInterface +export const RepoHookType: HookTypeInterface = RepoHook; + +// ============================================================================= +// High-Level Semantic Decorators (catch-all) +// ============================================================================= + +/** + * Runs before any read operation (find, findOne, count, findAndCount). + */ +export const BeforeRead = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_READ, +); + +/** + * Runs after any read operation (find, findOne, count, findAndCount). + */ +export const AfterRead = createHookMethodDecorator( + RepoHookMethodKey.AFTER_READ, +); + +/** + * Runs before any write operation (create, createMany, update, upsert, replace). + */ +export const BeforeWrite = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_WRITE, +); + +/** + * Runs after any write operation (create, createMany, update, upsert, replace). + */ +export const AfterWrite = createHookMethodDecorator( + RepoHookMethodKey.AFTER_WRITE, +); + +/** + * Runs before any lifecycle transition (softRemove, restore). + */ +export const BeforeTransition = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_TRANSITION, +); + +/** + * Runs after any lifecycle transition (softRemove, restore). + */ +export const AfterTransition = createHookMethodDecorator( + RepoHookMethodKey.AFTER_TRANSITION, +); + +/** + * Runs before any destroy operation (remove - hard delete). + */ +export const BeforeDestroy = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_DESTROY, +); + +/** + * Runs after any destroy operation (remove - hard delete). + */ +export const AfterDestroy = createHookMethodDecorator( + RepoHookMethodKey.AFTER_DESTROY, +); + +// ============================================================================= +// Fine-Grained Method Decorators - Query +// ============================================================================= + +/** + * Runs before find() - query for multiple entities. + */ +export const BeforeFind = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_FIND, +); + +/** + * Runs after find() - query for multiple entities. + */ +export const AfterFind = createHookMethodDecorator( + RepoHookMethodKey.AFTER_FIND, +); + +/** + * Runs before findOne() - query for a single entity. + */ +export const BeforeFindOne = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_FIND_ONE, +); + +/** + * Runs after findOne() - query for a single entity. + */ +export const AfterFindOne = createHookMethodDecorator( + RepoHookMethodKey.AFTER_FIND_ONE, +); + +/** + * Runs before count() - count entities. + */ +export const BeforeCount = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_COUNT, +); + +/** + * Runs after count() - count entities. + */ +export const AfterCount = createHookMethodDecorator( + RepoHookMethodKey.AFTER_COUNT, +); + +/** + * Runs before findAndCount() - query and count entities. + */ +export const BeforeFindAndCount = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_FIND_AND_COUNT, +); + +/** + * Runs after findAndCount() - query and count entities. + */ +export const AfterFindAndCount = createHookMethodDecorator( + RepoHookMethodKey.AFTER_FIND_AND_COUNT, +); + +// ============================================================================= +// Fine-Grained Method Decorators - Create +// ============================================================================= + +/** + * Runs before create() - create a single entity. + */ +export const BeforeCreate = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_CREATE, +); + +/** + * Runs after create() - create a single entity. + */ +export const AfterCreate = createHookMethodDecorator( + RepoHookMethodKey.AFTER_CREATE, +); + +/** + * Runs before createMany() - create multiple entities. + */ +export const BeforeCreateMany = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_CREATE_MANY, +); + +/** + * Runs after createMany() - create multiple entities. + */ +export const AfterCreateMany = createHookMethodDecorator( + RepoHookMethodKey.AFTER_CREATE_MANY, +); + +// ============================================================================= +// Fine-Grained Method Decorators - Update +// ============================================================================= + +/** + * Runs before update() - update an existing entity. + */ +export const BeforeUpdate = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_UPDATE, +); + +/** + * Runs after update() - update an existing entity. + */ +export const AfterUpdate = createHookMethodDecorator( + RepoHookMethodKey.AFTER_UPDATE, +); + +/** + * Runs before upsert() - create or update an entity. + */ +export const BeforeUpsert = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_UPSERT, +); + +/** + * Runs after upsert() - create or update an entity. + */ +export const AfterUpsert = createHookMethodDecorator( + RepoHookMethodKey.AFTER_UPSERT, +); + +/** + * Runs before replace() - fully replace an existing entity. + */ +export const BeforeReplace = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_REPLACE, +); + +/** + * Runs after replace() - fully replace an existing entity. + */ +export const AfterReplace = createHookMethodDecorator( + RepoHookMethodKey.AFTER_REPLACE, +); + +// ============================================================================= +// Fine-Grained Method Decorators - Delete (hard delete) +// ============================================================================= + +/** + * Runs before delete() - permanently delete an entity. + */ +export const BeforeDelete = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_DELETE, +); + +/** + * Runs after delete() - permanently delete an entity. + */ +export const AfterDelete = createHookMethodDecorator( + RepoHookMethodKey.AFTER_DELETE, +); + +/** + * Runs before deleteMany() - permanently delete multiple entities. + */ +export const BeforeDeleteMany = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_DELETE_MANY, +); + +/** + * Runs after deleteMany() - permanently delete multiple entities. + */ +export const AfterDeleteMany = createHookMethodDecorator( + RepoHookMethodKey.AFTER_DELETE_MANY, +); + +// ============================================================================= +// Fine-Grained Method Decorators - Lifecycle (soft delete/restore) +// ============================================================================= + +/** + * Runs before softDelete() - soft delete an entity. + */ +export const BeforeSoftDelete = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_SOFT_DELETE, +); + +/** + * Runs after softDelete() - soft delete an entity. + */ +export const AfterSoftDelete = createHookMethodDecorator( + RepoHookMethodKey.AFTER_SOFT_DELETE, +); + +/** + * Runs before restore() - restore a soft-deleted entity. + */ +export const BeforeRestore = createHookMethodDecorator( + RepoHookMethodKey.BEFORE_RESTORE, +); + +/** + * Runs after restore() - restore a soft-deleted entity. + */ +export const AfterRestore = createHookMethodDecorator( + RepoHookMethodKey.AFTER_RESTORE, +); diff --git a/packages/nestjs-repository/src/hooks/specifications/entity.specification.ts b/packages/nestjs-repository/src/hooks/specifications/entity.specification.ts new file mode 100644 index 000000000..403f5cfd2 --- /dev/null +++ b/packages/nestjs-repository/src/hooks/specifications/entity.specification.ts @@ -0,0 +1,22 @@ +import { type SpecificationInterface } from '@concepta/nestjs-core'; + +/** + * Specification that matches when the context's entity key + * equals the target entity key. + * + * Used with repository hook method decorators to scope hooks + * to specific entities. + * + * @example + * ```typescript + * @BeforeFindOne(RepoSpec.isEntity('user-credentials')) + * scopeToUser(options, ctx) { ... } + * ``` + */ +export class EntitySpecification implements SpecificationInterface { + constructor(private readonly entity: string) {} + + isSatisfiedBy(context: { entity?: string }): boolean { + return context.entity === this.entity; + } +} diff --git a/packages/nestjs-repository/src/hooks/specifications/repo-spec.factory.ts b/packages/nestjs-repository/src/hooks/specifications/repo-spec.factory.ts new file mode 100644 index 000000000..ee4d3ae30 --- /dev/null +++ b/packages/nestjs-repository/src/hooks/specifications/repo-spec.factory.ts @@ -0,0 +1,23 @@ +import { type SpecificationInterface } from '@concepta/nestjs-core'; + +import { EntitySpecification } from './entity.specification.js'; + +/** + * Factory for creating repository-specific specifications. + * + * @example + * ```typescript + * @RepoHook() + * export class UserScopeHook { + * @BeforeFindOne(RepoSpec.isEntity('user-credentials')) + * scopeToUser(options, ctx) { ... } + * } + * ``` + */ +export const RepoSpec = { + /** + * Matches when the repository entity matches the given name. + */ + isEntity: (entityName: string): SpecificationInterface => + new EntitySpecification(entityName), +}; diff --git a/packages/nestjs-repository/src/index.ts b/packages/nestjs-repository/src/index.ts new file mode 100644 index 000000000..1c434cf0e --- /dev/null +++ b/packages/nestjs-repository/src/index.ts @@ -0,0 +1,237 @@ +// ═══════════════════════════════════════════════════════════════════ +// Module +// ═══════════════════════════════════════════════════════════════════ +export { RepositoryModule } from './repository.module.js'; + +// ═══════════════════════════════════════════════════════════════════ +// Repository Adapter +// ═══════════════════════════════════════════════════════════════════ +export { RepositoryAdapter } from './repository/repository-adapter.js'; + +// ═══════════════════════════════════════════════════════════════════ +// Repository Implementation Interfaces +// ═══════════════════════════════════════════════════════════════════ +export { + RelationActionConfig, + RepositoryProviderOptions, +} from './interfaces/repository-provider-options.interface.js'; +export { + RepositoryModuleInterface, + DynamicRepositoryModule, +} from './interfaces/repository-module.interface.js'; + +// ═══════════════════════════════════════════════════════════════════ +// Exceptions +// ═══════════════════════════════════════════════════════════════════ +export { RepositoryDuplicateKeyException } from './exceptions/repository-duplicate-key.exception.js'; +export { RepositoryQueryException } from './exceptions/repository-query.exception.js'; +export { OptimisticLockException } from './exceptions/optimistic-lock.exception.js'; +export { FederationException } from './federation/exceptions/federation.exception.js'; +export { TransactionTimeoutException } from './exceptions/transaction-timeout.exception.js'; +export { TransactionClosedException } from './exceptions/transaction-closed.exception.js'; +export { TransactionHeuristicCommitException } from './exceptions/transaction-heuristic-commit.exception.js'; +export { TransactionReadOnlyConflictException } from './exceptions/transaction-read-only-conflict.exception.js'; +export { TransactionScopeFailedException } from './exceptions/transaction-scope-failed.exception.js'; + +// ═══════════════════════════════════════════════════════════════════ +// Transaction +// ═══════════════════════════════════════════════════════════════════ +export { TransactionFactoryInterface } from './interfaces/transaction-factory.interface.js'; +export { TransactionManager } from './transaction/transaction-manager.js'; +export { TransactionScope } from './transaction/transaction-scope.js'; +export { RepoCtx } from './context/interfaces/repository-context.interface.js'; +export { TrxCtx } from './transaction/interfaces/transaction-context.interface.js'; +export { TransactionalRunner } from './transaction/transactional-runner.js'; +export { + getTransactionalOptions, + isTransactional, + Transactional, + TransactionalOptions, +} from './transaction/transactional.decorator.js'; +export { TransactionInterceptor } from './interceptors/transaction.interceptor.js'; + +// ═══════════════════════════════════════════════════════════════════ +// Permeators +// ═══════════════════════════════════════════════════════════════════ +export { RepoPermeatorFactory } from './hooks/repo-permeator-factory.js'; + +// ═══════════════════════════════════════════════════════════════════ +// Hooks +// ═══════════════════════════════════════════════════════════════════ + +// Hook method types +export { + // Read operations + BeforeFindMethod, + AfterFindMethod, + BeforeFindOneMethod, + AfterFindOneMethod, + BeforeCountMethod, + AfterCountMethod, + BeforeFindAndCountMethod, + AfterFindAndCountMethod, + // Create operations + BeforeCreateMethod, + AfterCreateMethod, + BeforeCreateManyMethod, + AfterCreateManyMethod, + // Update operations + BeforeUpdateMethod, + AfterUpdateMethod, + BeforeUpsertMethod, + AfterUpsertMethod, + BeforeReplaceMethod, + AfterReplaceMethod, + // Delete operations + BeforeDeleteMethod, + AfterDeleteMethod, + BeforeDeleteManyMethod, + AfterDeleteManyMethod, + // Lifecycle operations + BeforeSoftDeleteMethod, + AfterSoftDeleteMethod, + BeforeRestoreMethod, + AfterRestoreMethod, + // High-level semantic operations + BeforeReadMethod, + AfterReadMethod, + BeforeWriteMethod, + AfterWriteMethod, + BeforeTransitionMethod, + AfterTransitionMethod, + BeforeDestroyMethod, + AfterDestroyMethod, +} from './hooks/hook-method.types.js'; + +// Hook decorators +export { + // Repository hook method keys + RepoHookMethodKey, + // Repository hook type decorator + RepoHook, + // High-level semantic decorators + BeforeRead, + AfterRead, + BeforeWrite, + AfterWrite, + BeforeTransition, + AfterTransition, + BeforeDestroy, + AfterDestroy, + // Fine-grained query decorators + BeforeFind, + AfterFind, + BeforeFindOne, + AfterFindOne, + BeforeCount, + AfterCount, + BeforeFindAndCount, + AfterFindAndCount, + // Fine-grained create decorators + BeforeCreate, + AfterCreate, + BeforeCreateMany, + AfterCreateMany, + // Fine-grained update decorators + BeforeUpdate, + AfterUpdate, + BeforeUpsert, + AfterUpsert, + BeforeReplace, + AfterReplace, + // Fine-grained delete decorators + BeforeDelete, + AfterDelete, + BeforeDeleteMany, + AfterDeleteMany, + // Fine-grained lifecycle decorators + BeforeSoftDelete, + AfterSoftDelete, + BeforeRestore, + AfterRestore, +} from './hooks/repository-hook.decorators.js'; + +// Hook specifications +export { RepoSpec } from './hooks/specifications/repo-spec.factory.js'; +export { EntitySpecification } from './hooks/specifications/entity.specification.js'; + +// Repository interfaces +export { RepositoryInterface } from './repository/interfaces/repository.interface.js'; +export { RepositoryEntityOptionInterface } from './repository/interfaces/repository-entity-option.interface.js'; +export { RepositoryColumnMetadataInterface } from './repository/interfaces/repository-column-metadata.interface.js'; +export { RepositoryMetadataInterface } from './repository/interfaces/repository-metadata.interface.js'; +export { RepositoryRelationMetadataInterface } from './repository/interfaces/repository-relation-metadata.interface.js'; + +// Repository option types +export { + RepositoryFindOneOptions, + RepositoryFindOptions, + RepositoryCreateOptions, + RepositoryUpdateOptions, + RepositoryUpsertOptions, + RepositoryDeleteOptions, + RepositoryRestoreOptions, +} from './repository/interfaces/repository-options.interface.js'; + +// Repository query types +export { + EntityColumn, + WhereOperator, + WhereNullaryOperator, + WhereScalarOperator, + WhereArrayOperator, + WherePairOperator, + WhereCompoundOperator, + WhereConditionArr, + RelationAction, + SortOrder, + OrderSortKey, + OrderSortKeyArr, + OrderClause, +} from './repository/repository.types.js'; + +// Order sort key interfaces +export { + OrderSortKeyAsc, + OrderSortKeyDesc, +} from './repository/interfaces/order-sort-key.interface.js'; + +// Join clause interface +export { JoinClause } from './repository/interfaces/join-clause.interface.js'; + +// Where clause interfaces +export { + WhereConditionNullary, + WhereConditionScalar, + WhereConditionArray, + WhereConditionPair, + WhereCondition, + WhereCompound, + WhereClause, + isWhereCondition, + isWhereCompound, + isNullaryCondition, + isArrayCondition, + isPairCondition, +} from './repository/interfaces/where-clause.interface.js'; + +// Where clause helpers +export { Where } from './repository/where.helpers.js'; + +// Order clause helpers +export { OrderBy } from './repository/order-by.helpers.js'; + +// Join clause helpers +export { Join } from './repository/join.helpers.js'; + +// Repository utils +export { getDynamicRepositoryToken } from './utils/get-dynamic-repository-token.js'; + +// Repository decorators +export { InjectDynamicRepository } from './decorators/inject-dynamic-repository.decorator.js'; + +// Transaction interfaces +export { TransactionInterface } from './transaction/interfaces/transaction.interface.js'; + +// Context interfaces +export { TransactionContextInterface } from './transaction/interfaces/transaction-context.interface.js'; diff --git a/packages/nestjs-repository/src/interceptors/transaction.interceptor.ts b/packages/nestjs-repository/src/interceptors/transaction.interceptor.ts new file mode 100644 index 000000000..afb8c2c1b --- /dev/null +++ b/packages/nestjs-repository/src/interceptors/transaction.interceptor.ts @@ -0,0 +1,28 @@ +import { Observable } from 'rxjs'; + +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; + +import { TransactionalRunner } from '../transaction/transactional-runner.js'; + +/** + * Interceptor that wraps requests in transactions. + * + * Delegates to {@link TransactionalRunner} which checks for + * `@Transactional()` metadata and wraps the operation in a + * {@link TransactionScope} if present. + * + * Applied automatically by the `@Transactional()` decorator. + */ +@Injectable() +export class TransactionInterceptor implements NestInterceptor { + constructor(private readonly txRunner: TransactionalRunner) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + return this.txRunner.run(context, () => next.handle()); + } +} diff --git a/packages/nestjs-repository/src/interfaces/repository-feature-options.interface.ts b/packages/nestjs-repository/src/interfaces/repository-feature-options.interface.ts new file mode 100644 index 000000000..e37307b13 --- /dev/null +++ b/packages/nestjs-repository/src/interfaces/repository-feature-options.interface.ts @@ -0,0 +1,18 @@ +import { type RepositoryModuleInterface } from './repository-module.interface.js'; +import { type RepositoryProviderOptions } from './repository-provider-options.interface.js'; + +/** + * Feature module options for RepositoryModule.forFeature() + */ +export interface RepositoryFeatureOptions { + /** + * Repository module class with static forFeature method. + * e.g., TypeOrmRepositoryModule + */ + module: RepositoryModuleInterface; + + /** + * Entity registrations. + */ + entities: RepositoryProviderOptions[]; +} diff --git a/packages/nestjs-repository/src/interfaces/repository-module-options.interface.ts b/packages/nestjs-repository/src/interfaces/repository-module-options.interface.ts new file mode 100644 index 000000000..1cecda636 --- /dev/null +++ b/packages/nestjs-repository/src/interfaces/repository-module-options.interface.ts @@ -0,0 +1,9 @@ +/** + * Core module options for RepositoryModule + */ +export interface RepositoryModuleOptionsInterface { + /** + * Default transaction timeout in milliseconds. + */ + defaultTimeout?: number; +} diff --git a/packages/nestjs-repository/src/interfaces/repository-module.interface.ts b/packages/nestjs-repository/src/interfaces/repository-module.interface.ts new file mode 100644 index 000000000..7caaa294e --- /dev/null +++ b/packages/nestjs-repository/src/interfaces/repository-module.interface.ts @@ -0,0 +1,56 @@ +import { type DynamicModule, type InjectionToken } from '@nestjs/common'; + +import { type RepositoryProviderOptions } from './repository-provider-options.interface.js'; +import { type TransactionFactoryInterface } from './transaction-factory.interface.js'; + +/** + * Descriptor for a transaction factory that a respository module wants to register. + * RepositoryModule handles the actual registration with the registry. + */ +export interface TransactionFactoryDescriptor { + /** + * Transaction key (e.g., 'typeorm:default', 'mongoose:default'). + */ + key: string; + + /** + * Injection tokens needed to create the factory. + */ + inject: InjectionToken[]; + + /** + * Factory function that creates the TransactionFactoryInterface. + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + useFactory: (...args: any[]) => TransactionFactoryInterface; +} + +/** + * Result returned by repository module's forFeature method. + * Extends DynamicModule with optional transaction factory descriptors. + */ +export interface DynamicRepositoryModule extends DynamicModule { + /** + * Transaction factory descriptors for RepositoryModule to register. + */ + transactionFactories?: TransactionFactoryDescriptor[]; +} + +/** + * Interface for repository module classes that provide a static forFeature(). + * Repository packages implement this to provide their own module. + * + * This describes the static side of the class (the constructor object itself). + */ +export interface RepositoryModuleInterface { + /** + * The class name of the module (inherited from Function.prototype.name). + */ + readonly name: string; + + /** + * Register repositories for entities. + * This is a static method on the module class. + */ + forFeature(entities: RepositoryProviderOptions[]): DynamicRepositoryModule; +} diff --git a/packages/nestjs-repository/src/interfaces/repository-provider-options.interface.ts b/packages/nestjs-repository/src/interfaces/repository-provider-options.interface.ts new file mode 100644 index 000000000..0f2975cf3 --- /dev/null +++ b/packages/nestjs-repository/src/interfaces/repository-provider-options.interface.ts @@ -0,0 +1,51 @@ +import { type Type, type PlainLiteralObject } from '@nestjs/common'; + +import { type WhereCondition } from '../repository/interfaces/where-clause.interface.js'; +import { type RelationAction } from '../repository/repository.types.js'; + +/** + * Per-relation configuration for forFeature() registration. + * + * Supports onDelete/onUpdate behavior and federation settings. + */ +export interface RelationActionConfig { + onDelete?: Extract; + onUpdate?: Extract; + /** Use separate queries instead of DB joins for this relation. */ + federated?: boolean; + /** + * Required for many-cardinality federated relations with sorts/filters. + * Ensures exactly one relation entity per root for deterministic ordering. + */ + distinctFilter?: WhereCondition; +} + +/** + * Options for registering a repository provider. + * Repository modules may extend this with driver-specific options. + */ +export interface RepositoryProviderOptions< + Entity extends PlainLiteralObject = PlainLiteralObject, +> { + /** + * String key used as injection token. + * Used with `@InjectDynamicRepository('key')`. + */ + key: string; + + /** + * Entity class. + */ + entity: Type; + + /** + * Per-relation action config (onDelete / onUpdate). + * Keyed by relation property name on the entity. + */ + relations?: Record; + + /** + * Additional driver-specific options. + */ + [key: string]: unknown; +} diff --git a/packages/nestjs-repository/src/interfaces/repository-registry-item.interface.ts b/packages/nestjs-repository/src/interfaces/repository-registry-item.interface.ts new file mode 100644 index 000000000..af4267dd2 --- /dev/null +++ b/packages/nestjs-repository/src/interfaces/repository-registry-item.interface.ts @@ -0,0 +1,19 @@ +/** + * Item stored in the repository registry. + */ +export interface RepositoryRegistryItem { + /** + * String key used as injection token. + */ + readonly key: string; + + /** + * Entity class name. + */ + readonly entityName: string; + + /** + * Module class name that registered this item (e.g., 'TypeOrmRepositoryModule'). + */ + readonly moduleName: string; +} diff --git a/packages/nestjs-repository/src/interfaces/transaction-factory.interface.ts b/packages/nestjs-repository/src/interfaces/transaction-factory.interface.ts new file mode 100644 index 000000000..60fdfde0e --- /dev/null +++ b/packages/nestjs-repository/src/interfaces/transaction-factory.interface.ts @@ -0,0 +1,9 @@ +import { type TransactionInterface } from '../transaction/interfaces/transaction.interface.js'; + +/** + * Factory for creating transactions. + * Each driver/datasource provides its own factory implementation. + */ +export interface TransactionFactoryInterface { + create(): TransactionInterface; +} diff --git a/packages/nestjs-repository/src/interfaces/transactional-options.interface.ts b/packages/nestjs-repository/src/interfaces/transactional-options.interface.ts new file mode 100644 index 000000000..12e69369e --- /dev/null +++ b/packages/nestjs-repository/src/interfaces/transactional-options.interface.ts @@ -0,0 +1,16 @@ +/** + * Options for the `@Transactional` decorator + */ +export interface TransactionalOptions { + /** + * If true, transaction always rolls back (for read-only operations). + * Defaults to false. + */ + readOnly?: boolean; + + /** + * Transaction timeout in milliseconds. + * Defaults to 30000. + */ + timeout?: number; +} diff --git a/packages/nestjs-repository/src/repository.constants.ts b/packages/nestjs-repository/src/repository.constants.ts new file mode 100644 index 000000000..84e58d96e --- /dev/null +++ b/packages/nestjs-repository/src/repository.constants.ts @@ -0,0 +1 @@ +export const REPOSITORY_MODULE_OPTIONS = Symbol('REPOSITORY_MODULE_OPTIONS'); diff --git a/packages/nestjs-repository/src/repository.module-definition.ts b/packages/nestjs-repository/src/repository.module-definition.ts new file mode 100644 index 000000000..ba7aaea07 --- /dev/null +++ b/packages/nestjs-repository/src/repository.module-definition.ts @@ -0,0 +1,94 @@ +import { + ConfigurableModuleBuilder, + type DynamicModule, + type Provider, +} from '@nestjs/common'; + +import { + FEDERATION_ORCHESTRATOR, + FederationOrchestrator, +} from './federation/federation-orchestrator.service.js'; +import { TransactionInterceptor } from './interceptors/transaction.interceptor.js'; +import { type RepositoryModuleOptionsInterface } from './interfaces/repository-module-options.interface.js'; +import { REPOSITORY_MODULE_OPTIONS } from './repository.constants.js'; +import { + RepositoryRegistryService, + REPOSITORY_REGISTRY, +} from './services/repository-registry.service.js'; +import { + TransactionFactoryRegistry, + TRANSACTION_FACTORY_REGISTRY, +} from './transaction/transaction-factory-registry.js'; +import { TransactionScope } from './transaction/transaction-scope.js'; +import { TransactionalRunner } from './transaction/transactional-runner.js'; + +const RAW_OPTIONS_TOKEN = Symbol('__REPOSITORY_MODULE_RAW_OPTIONS_TOKEN__'); + +export const { + ConfigurableModuleClass: RepositoryModuleClass, + OPTIONS_TYPE: REPOSITORY_OPTIONS_TYPE, + ASYNC_OPTIONS_TYPE: REPOSITORY_ASYNC_OPTIONS_TYPE, +} = new ConfigurableModuleBuilder({ + moduleName: 'Repository', + optionsInjectionToken: RAW_OPTIONS_TOKEN, +}) + .setExtras({}, (definition: DynamicModule) => { + const { providers = [] } = definition; + + return { + ...definition, + global: true, + providers: createRepositoryProviders({ providers }), + exports: createRepositoryExports(), + }; + }) + .setClassMethodName('forRoot') + .build(); + +export type RepositoryOptions = typeof REPOSITORY_OPTIONS_TYPE; +export type RepositoryAsyncOptions = typeof REPOSITORY_ASYNC_OPTIONS_TYPE; + +export function createRepositoryProviders(options: { + providers?: Provider[]; +}): Provider[] { + return [ + ...(options.providers ?? []), + { + provide: REPOSITORY_MODULE_OPTIONS, + useExisting: RAW_OPTIONS_TOKEN, + }, + { + provide: TRANSACTION_FACTORY_REGISTRY, + useClass: TransactionFactoryRegistry, + }, + { + provide: REPOSITORY_REGISTRY, + useClass: RepositoryRegistryService, + }, + { + provide: FEDERATION_ORCHESTRATOR, + useClass: FederationOrchestrator, + }, + TransactionScope, + TransactionalRunner, + TransactionInterceptor, + ]; +} + +export function createRepositoryExports(): ( + | symbol + | typeof TransactionScope + | typeof TransactionFactoryRegistry + | typeof TransactionalRunner + | typeof TransactionInterceptor +)[] { + return [ + REPOSITORY_MODULE_OPTIONS, + TRANSACTION_FACTORY_REGISTRY, + REPOSITORY_REGISTRY, + FEDERATION_ORCHESTRATOR, + TransactionScope, + TransactionalRunner, + TransactionInterceptor, + ]; +} diff --git a/packages/nestjs-repository/src/repository.module.ts b/packages/nestjs-repository/src/repository.module.ts new file mode 100644 index 000000000..db88c3b53 --- /dev/null +++ b/packages/nestjs-repository/src/repository.module.ts @@ -0,0 +1,134 @@ +import { Module, DynamicModule, Provider } from '@nestjs/common'; + +import { + FEDERATION_ORCHESTRATOR, + FederationOrchestrator, +} from './federation/federation-orchestrator.service.js'; +import { RepositoryFeatureOptions } from './interfaces/repository-feature-options.interface.js'; +import { DynamicRepositoryModule } from './interfaces/repository-module.interface.js'; +import { RepositoryAdapter } from './repository/repository-adapter.js'; +import { RepositoryModuleClass } from './repository.module-definition.js'; +import { + RepositoryRegistryService, + REPOSITORY_REGISTRY, +} from './services/repository-registry.service.js'; +import { + TransactionFactoryRegistry, + TRANSACTION_FACTORY_REGISTRY, +} from './transaction/transaction-factory-registry.js'; +import { getDynamicRepositoryToken } from './utils/get-dynamic-repository-token.js'; + +/** + * Repository module providing data access abstraction with transaction support. + * + * @example + * ```typescript + * // app.module.ts + * @Module({ + * imports: [ + * TypeOrmModule.forRoot({ ... }), + * RepositoryModule.forRoot({}), + * RepositoryModule.forFeature({ + * module: TypeOrmRepositoryModule, + * entities: [ + * { key: 'orders', entity: Order }, + * { key: 'customers', entity: Customer }, + * ], + * }), + * ], + * }) + * export class AppModule {} + * ``` + */ +@Module({}) +export class RepositoryModule extends RepositoryModuleClass { + /** + * Register repositories for entities. + * + * Delegates to the repository module's forFeature method. + * + * @example + * ```typescript + * RepositoryModule.forFeature({ + * module: TypeOrmRepositoryModule, + * entities: [ + * { key: 'orders', entity: Order }, + * { key: 'customers', entity: Customer }, + * ], + * }) + * ``` + */ + static forFeature(options: RepositoryFeatureOptions): DynamicModule { + const { module, entities } = options; + const dynamicModule: DynamicRepositoryModule = module.forFeature(entities); + const moduleName = module.name; + + const providers: Provider[] = [...(dynamicModule.providers ?? [])]; + + // Repository registry registration + const registrationToken = Symbol( + `REPOSITORY_REGISTRATION_${moduleName}_${Date.now()}`, + ); + + const repoTokens = entities.map((e) => getDynamicRepositoryToken(e.key)); + + providers.push({ + provide: registrationToken, + inject: [REPOSITORY_REGISTRY, FEDERATION_ORCHESTRATOR, ...repoTokens], + useFactory: ( + registry: RepositoryRegistryService, + orchestrator: FederationOrchestrator, + ...repos: unknown[] + ) => { + for (const entity of entities) { + registry.register({ + key: entity.key, + entityName: entity.entity.name, + moduleName, + }); + } + for (const repo of repos) { + if (repo instanceof RepositoryAdapter) { + repo.setFederationOrchestrator(orchestrator); + } + } + return true; + }, + }); + + // Transaction factory registration + if (dynamicModule.transactionFactories) { + for (const descriptor of dynamicModule.transactionFactories) { + const txToken = Symbol(`TX_FACTORY_${descriptor.key}_${Date.now()}`); + providers.push({ + provide: txToken, + inject: [ + { token: TRANSACTION_FACTORY_REGISTRY, optional: true }, + ...descriptor.inject, + ], + useFactory: ( + registry: TransactionFactoryRegistry | undefined, + ...args: unknown[] + ) => { + if (registry) { + const factory = descriptor.useFactory(...args); + registry.register(descriptor.key, factory); + } + return null; + }, + }); + } + } + + // Export public tokens (providers now use getDynamicRepositoryToken directly) + const exports = entities.map((entity) => + getDynamicRepositoryToken(entity.key), + ); + + return { + ...dynamicModule, + providers, + exports, + }; + } +} diff --git a/packages/nestjs-repository/src/repository/__tests__/join.helpers.spec.ts b/packages/nestjs-repository/src/repository/__tests__/join.helpers.spec.ts new file mode 100644 index 000000000..40d3eb055 --- /dev/null +++ b/packages/nestjs-repository/src/repository/__tests__/join.helpers.spec.ts @@ -0,0 +1,42 @@ +import { Join } from '../join.helpers.js'; + +describe('Join', () => { + describe('static left()', () => { + it('should create a LEFT join clause', () => { + expect(Join.left('posts')).toEqual({ + relation: 'posts', + joinType: 'LEFT', + }); + }); + }); + + describe('static inner()', () => { + it('should create an INNER join clause', () => { + expect(Join.inner('company')).toEqual({ + relation: 'company', + joinType: 'INNER', + }); + }); + }); + + describe('static join()', () => { + it('should wrap clauses in a { join } object', () => { + expect(Join.join(Join.left('posts'), Join.inner('company'))).toEqual({ + join: [ + { relation: 'posts', joinType: 'LEFT' }, + { relation: 'company', joinType: 'INNER' }, + ], + }); + }); + + it('should return empty join for no clauses', () => { + expect(Join.join()).toEqual({ join: [] }); + }); + + it('should handle a single clause', () => { + expect(Join.join(Join.left('posts'))).toEqual({ + join: [{ relation: 'posts', joinType: 'LEFT' }], + }); + }); + }); +}); diff --git a/packages/nestjs-repository/src/repository/__tests__/order-by.helpers.spec.ts b/packages/nestjs-repository/src/repository/__tests__/order-by.helpers.spec.ts new file mode 100644 index 000000000..ec0947af3 --- /dev/null +++ b/packages/nestjs-repository/src/repository/__tests__/order-by.helpers.spec.ts @@ -0,0 +1,171 @@ +import { OrderBy } from '../order-by.helpers.js'; +import { SortOrder } from '../repository.types.js'; + +interface TestEntity { + id: string; + name: string; + createdAt: string; +} + +describe('OrderBy', () => { + // ═══════════════════════════════════════════════════════════════════════════ + // Static asc / desc + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static asc()', () => { + it('should create an ASC sort key', () => { + const result = OrderBy.asc('name'); + expect(result).toEqual({ field: 'name', order: SortOrder.ASC }); + }); + }); + + describe('static desc()', () => { + it('should create a DESC sort key', () => { + const result = OrderBy.desc('createdAt'); + expect(result).toEqual({ field: 'createdAt', order: SortOrder.DESC }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Static order() + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static order()', () => { + it('should wrap keys in an { order } object', () => { + const result = OrderBy.order( + OrderBy.desc('createdAt'), + OrderBy.asc('name'), + ); + expect(result).toEqual({ + order: [ + { field: 'createdAt', order: SortOrder.DESC }, + { field: 'name', order: SortOrder.ASC }, + ], + }); + }); + + it('should return empty order for no keys', () => { + expect(OrderBy.order()).toEqual({ order: [] }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Static for() + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static for()', () => { + it('should return an OrderBy instance', () => { + const o = OrderBy.for(); + expect(o).toBeInstanceOf(OrderBy); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Static rel() and relDot() + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static rel()', () => { + it('should tag a sort key with a relation name', () => { + const key = OrderBy.asc('name'); + const result = OrderBy.rel('posts', key); + expect(result).toEqual({ + field: 'name', + order: SortOrder.ASC, + relation: 'posts', + }); + }); + + it('should not mutate the original key', () => { + const key = OrderBy.asc('name'); + OrderBy.rel('posts', key); + expect('relation' in key).toBe(false); + }); + }); + + describe('static relDot()', () => { + it('should extract relation from dot-notation field', () => { + const key = OrderBy.asc('title'); + const result = OrderBy.relDot('blog.title', key); + expect(result).toEqual({ + field: 'title', + order: SortOrder.ASC, + relation: 'blog', + }); + }); + + it('should return key unchanged when no dot is present', () => { + const key = OrderBy.asc('name'); + const result = OrderBy.relDot('name', key); + expect(result).toEqual({ field: 'name', order: SortOrder.ASC }); + }); + + it('should throw when leading dot produces empty relation', () => { + const key = OrderBy.asc('field'); + expect(() => OrderBy.relDot('.field', key)).toThrow( + 'relDot expects "relation.field" dot notation', + ); + }); + + it('should throw for multi-dot fields', () => { + const key = OrderBy.asc('c'); + expect(() => OrderBy.relDot('a.b.c', key)).toThrow( + 'relDot expects "relation.field" dot notation', + ); + }); + + it('should not mutate the original key', () => { + const key = OrderBy.asc('title'); + OrderBy.relDot('blog.title', key); + expect('relation' in key).toBe(false); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Instance API + // ═══════════════════════════════════════════════════════════════════════════ + + describe('instance API', () => { + const o = OrderBy.for(); + + it('asc() should delegate to OrderBy.asc', () => { + expect(o.asc('name')).toEqual({ + field: 'name', + order: SortOrder.ASC, + }); + }); + + it('desc() should delegate to OrderBy.desc', () => { + expect(o.desc('createdAt')).toEqual({ + field: 'createdAt', + order: SortOrder.DESC, + }); + }); + + it('rel() should delegate to OrderBy.rel', () => { + const key = o.asc('name'); + expect(o.rel('posts', key)).toEqual({ + field: 'name', + order: SortOrder.ASC, + relation: 'posts', + }); + }); + + it('relDot() should delegate to OrderBy.relDot', () => { + const key = o.asc('name'); + expect(o.relDot('blog.name', key)).toEqual({ + field: 'name', + order: SortOrder.ASC, + relation: 'blog', + }); + }); + + it('order() should wrap keys in an { order } object', () => { + expect(o.order(o.desc('createdAt'), o.asc('name'))).toEqual({ + order: [ + { field: 'createdAt', order: SortOrder.DESC }, + { field: 'name', order: SortOrder.ASC }, + ], + }); + }); + }); +}); diff --git a/packages/nestjs-repository/src/repository/__tests__/where.helpers.spec.ts b/packages/nestjs-repository/src/repository/__tests__/where.helpers.spec.ts new file mode 100644 index 000000000..2cf9acd59 --- /dev/null +++ b/packages/nestjs-repository/src/repository/__tests__/where.helpers.spec.ts @@ -0,0 +1,610 @@ +import { + isWhereCondition, + isWhereCompound, + isNullaryCondition, + isArrayCondition, + isPairCondition, +} from '../interfaces/where-clause.interface.js'; +import { WhereOperator, WhereCompoundOperator } from '../repository.types.js'; +import { Where } from '../where.helpers.js'; + +interface TestEntity { + id: string; + name: string; + status: string; +} + +describe('Where', () => { + // ═══════════════════════════════════════════════════════════════════════════ + // Static scalar operators + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static eq()', () => { + it('should create an EQ condition', () => { + const result = Where.eq('name', 'Alice'); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.EQ, + value: 'Alice', + }); + }); + }); + + describe('static ne()', () => { + it('should create a NE condition', () => { + const result = Where.ne('status', 'inactive'); + expect(result).toEqual({ + field: 'status', + operator: WhereOperator.NE, + value: 'inactive', + }); + }); + }); + + describe('static gt()', () => { + it('should create a GT condition', () => { + const result = Where.gt('age', 18); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.GT, + value: 18, + }); + }); + }); + + describe('static gte()', () => { + it('should create a GTE condition', () => { + const result = Where.gte('age', 21); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.GTE, + value: 21, + }); + }); + }); + + describe('static lt()', () => { + it('should create a LT condition', () => { + const result = Where.lt('age', 65); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.LT, + value: 65, + }); + }); + }); + + describe('static lte()', () => { + it('should create a LTE condition', () => { + const result = Where.lte('age', 100); + expect(result).toEqual({ + field: 'age', + operator: WhereOperator.LTE, + value: 100, + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Static pattern operators + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static contains()', () => { + it('should create a CONTAINS condition', () => { + const result = Where.contains('name', 'lic'); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.CONTAINS, + value: 'lic', + }); + }); + }); + + describe('static notContains()', () => { + it('should create a NCONTAINS condition', () => { + const result = Where.notContains('name', 'bob'); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.NCONTAINS, + value: 'bob', + }); + }); + }); + + describe('static starts()', () => { + it('should create a STARTS condition', () => { + const result = Where.starts('name', 'Al'); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.STARTS, + value: 'Al', + }); + }); + }); + + describe('static notStarts()', () => { + it('should create a NSTARTS condition', () => { + const result = Where.notStarts('name', 'Bo'); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.NSTARTS, + value: 'Bo', + }); + }); + }); + + describe('static ends()', () => { + it('should create an ENDS condition', () => { + const result = Where.ends('name', 'ice'); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.ENDS, + value: 'ice', + }); + }); + }); + + describe('static notEnds()', () => { + it('should create a NENDS condition', () => { + const result = Where.notEnds('name', 'xyz'); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.NENDS, + value: 'xyz', + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Static array operators + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static in()', () => { + it('should create an IN condition with an array value', () => { + const result = Where.in('status', ['active', 'pending']); + expect(result).toEqual({ + field: 'status', + operator: WhereOperator.IN, + value: ['active', 'pending'], + }); + }); + }); + + describe('static notIn()', () => { + it('should create a NIN condition with an array value', () => { + const result = Where.notIn('status', ['banned', 'suspended']); + expect(result).toEqual({ + field: 'status', + operator: WhereOperator.NIN, + value: ['banned', 'suspended'], + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Static nullary operators + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static isNull()', () => { + it('should create an IS_NULL condition with no value property', () => { + const result = Where.isNull('name'); + expect(result).toEqual({ + field: 'name', + operator: WhereOperator.IS_NULL, + }); + }); + + it('should not have a value property', () => { + const result = Where.isNull('name'); + expect('value' in result).toBe(false); + }); + }); + + describe('static notNull()', () => { + it('should create a NOT_NULL condition with no value property', () => { + const result = Where.notNull('status'); + expect(result).toEqual({ + field: 'status', + operator: WhereOperator.NOT_NULL, + }); + }); + + it('should not have a value property', () => { + const result = Where.notNull('status'); + expect('value' in result).toBe(false); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Static pair operator + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static between()', () => { + it('should create a BETWEEN condition with a two-element tuple', () => { + const result = Where.between('id', 10, 20); + expect(result).toEqual({ + field: 'id', + operator: WhereOperator.BETWEEN, + value: [10, 20], + }); + }); + + it('should work with date strings', () => { + const result = Where.between('created', '2024-01-01', '2024-12-31'); + expect(result).toEqual({ + field: 'created', + operator: WhereOperator.BETWEEN, + value: ['2024-01-01', '2024-12-31'], + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Static compound builders + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static and()', () => { + it('should create an AND compound from two conditions', () => { + const c1 = Where.eq('status', 'active'); + const c2 = Where.gt('age', 18); + + const result = Where.and(c1, c2); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'status', operator: 'eq', value: 'active' }, + { field: 'age', operator: 'gt', value: 18 }, + ], + }); + }); + + it('should accept more than two conditions', () => { + const result = Where.and( + Where.eq('a', 1), + Where.eq('b', 2), + Where.eq('c', 3), + ); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'a', operator: 'eq', value: 1 }, + { field: 'b', operator: 'eq', value: 2 }, + { field: 'c', operator: 'eq', value: 3 }, + ], + }); + }); + + it('should accept nested compounds', () => { + const inner = Where.or(Where.eq('x', 1), Where.eq('y', 2)); + const result = Where.and(Where.eq('z', 3), inner); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'z', operator: 'eq', value: 3 }, + { + operator: WhereCompoundOperator.OR, + conditions: [ + { field: 'x', operator: 'eq', value: 1 }, + { field: 'y', operator: 'eq', value: 2 }, + ], + }, + ], + }); + }); + }); + + describe('static or()', () => { + it('should create an OR compound from two conditions', () => { + const c1 = Where.eq('status', 'active'); + const c2 = Where.eq('status', 'pending'); + + const result = Where.or(c1, c2); + expect(result).toEqual({ + operator: WhereCompoundOperator.OR, + conditions: [ + { field: 'status', operator: 'eq', value: 'active' }, + { field: 'status', operator: 'eq', value: 'pending' }, + ], + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Static where() and for() + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static where()', () => { + it('should wrap a condition in a { where } object', () => { + const condition = Where.eq('name', 'Alice'); + expect(Where.where(condition)).toEqual({ + where: { field: 'name', operator: 'eq', value: 'Alice' }, + }); + }); + }); + + describe('static for()', () => { + it('should return a Where instance', () => { + const w = Where.for(); + expect(w).toBeInstanceOf(Where); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Static rel() and relDot() + // ═══════════════════════════════════════════════════════════════════════════ + + describe('static rel()', () => { + it('should tag a condition with a relation name', () => { + const condition = Where.eq('status', 'active'); + const result = Where.rel('tasks', condition); + expect(result).toEqual({ + field: 'status', + operator: 'eq', + value: 'active', + relation: 'tasks', + }); + }); + + it('should not mutate the original condition', () => { + const condition = Where.eq('status', 'active'); + Where.rel('tasks', condition); + expect('relation' in condition).toBe(false); + }); + }); + + describe('static relDot()', () => { + it('should extract relation from dot-notation field', () => { + const condition = Where.eq('status', 'active'); + const result = Where.relDot('blog.status', condition); + expect(result).toEqual({ + field: 'status', + operator: 'eq', + value: 'active', + relation: 'blog', + }); + }); + + it('should return condition unchanged when no dot is present', () => { + const condition = Where.eq('status', 'active'); + const result = Where.relDot('status', condition); + expect(result).toEqual({ + field: 'status', + operator: 'eq', + value: 'active', + }); + }); + + it('should throw when leading dot produces empty relation', () => { + const condition = Where.eq('field', 'value'); + expect(() => Where.relDot('.field', condition)).toThrow( + 'relDot expects "relation.field" dot notation', + ); + }); + + it('should throw for multi-dot fields', () => { + const condition = Where.eq('c', 'value'); + expect(() => Where.relDot('a.b.c', condition)).toThrow( + 'relDot expects "relation.field" dot notation', + ); + }); + + it('should not mutate the original condition', () => { + const condition = Where.eq('status', 'active'); + Where.relDot('blog.status', condition); + expect('relation' in condition).toBe(false); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Instance API + // ═══════════════════════════════════════════════════════════════════════════ + + describe('instance API', () => { + const w = Where.for(); + + it('eq() should delegate to Where.eq', () => { + expect(w.eq('name', 'Alice')).toEqual({ + field: 'name', + operator: WhereOperator.EQ, + value: 'Alice', + }); + }); + + it('ne() should delegate to Where.ne', () => { + expect(w.ne('status', 'inactive')).toEqual({ + field: 'status', + operator: WhereOperator.NE, + value: 'inactive', + }); + }); + + it('and() should delegate to Where.and', () => { + const result = w.and(w.eq('status', 'active'), w.eq('name', 'Alice')); + expect(result).toEqual({ + operator: WhereCompoundOperator.AND, + conditions: [ + { field: 'status', operator: 'eq', value: 'active' }, + { field: 'name', operator: 'eq', value: 'Alice' }, + ], + }); + }); + + it('or() should delegate to Where.or', () => { + const result = w.or(w.eq('status', 'active'), w.eq('status', 'pending')); + expect(result).toEqual({ + operator: WhereCompoundOperator.OR, + conditions: [ + { field: 'status', operator: 'eq', value: 'active' }, + { field: 'status', operator: 'eq', value: 'pending' }, + ], + }); + }); + + it('rel() should delegate to Where.rel', () => { + const condition = w.eq('status', 'active'); + expect(w.rel('tasks', condition)).toEqual({ + field: 'status', + operator: 'eq', + value: 'active', + relation: 'tasks', + }); + }); + + it('relDot() should delegate to Where.relDot', () => { + const condition = w.eq('status', 'active'); + expect(w.relDot('blog.status', condition)).toEqual({ + field: 'status', + operator: 'eq', + value: 'active', + relation: 'blog', + }); + }); + + it('where() should delegate to Where.where', () => { + const condition = w.eq('name', 'Alice'); + expect(w.where(condition)).toEqual({ + where: { field: 'name', operator: 'eq', value: 'Alice' }, + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Type guards + // ═══════════════════════════════════════════════════════════════════════════ + + describe('type guards', () => { + describe('isWhereCondition()', () => { + it('should return true for a scalar condition', () => { + expect(isWhereCondition(Where.eq('name', 'Alice'))).toBe( + true, + ); + }); + + it('should return true for a nullary condition', () => { + expect(isWhereCondition(Where.isNull('name'))).toBe(true); + }); + + it('should return true for an array condition', () => { + expect( + isWhereCondition(Where.in('status', ['active'])), + ).toBe(true); + }); + + it('should return true for a pair condition', () => { + expect(isWhereCondition(Where.between('id', 1, 10))).toBe( + true, + ); + }); + + it('should return false for a compound clause', () => { + const compound = Where.and( + Where.eq('name', 'Alice'), + Where.eq('status', 'active'), + ); + expect(isWhereCondition(compound)).toBe(false); + }); + }); + + describe('isWhereCompound()', () => { + it('should return true for an AND compound', () => { + const compound = Where.and( + Where.eq('name', 'Alice'), + Where.eq('status', 'active'), + ); + expect(isWhereCompound(compound)).toBe(true); + }); + + it('should return true for an OR compound', () => { + const compound = Where.or( + Where.eq('name', 'Alice'), + Where.eq('status', 'active'), + ); + expect(isWhereCompound(compound)).toBe(true); + }); + + it('should return false for a field condition', () => { + expect(isWhereCompound(Where.eq('name', 'Alice'))).toBe( + false, + ); + }); + }); + + describe('isNullaryCondition()', () => { + it('should return true for IS_NULL', () => { + expect(isNullaryCondition(Where.isNull('name'))).toBe(true); + }); + + it('should return true for NOT_NULL', () => { + expect(isNullaryCondition(Where.notNull('status'))).toBe( + true, + ); + }); + + it('should return false for EQ', () => { + expect(isNullaryCondition(Where.eq('name', 'Alice'))).toBe( + false, + ); + }); + + it('should return false for IN', () => { + expect( + isNullaryCondition(Where.in('status', ['active'])), + ).toBe(false); + }); + + it('should return false for BETWEEN', () => { + expect(isNullaryCondition(Where.between('id', 1, 10))).toBe( + false, + ); + }); + }); + + describe('isArrayCondition()', () => { + it('should return true for IN', () => { + expect( + isArrayCondition(Where.in('status', ['active'])), + ).toBe(true); + }); + + it('should return true for NIN', () => { + expect( + isArrayCondition(Where.notIn('status', ['banned'])), + ).toBe(true); + }); + + it('should return false for EQ', () => { + expect(isArrayCondition(Where.eq('name', 'Alice'))).toBe( + false, + ); + }); + + it('should return false for IS_NULL', () => { + expect(isArrayCondition(Where.isNull('name'))).toBe(false); + }); + }); + + describe('isPairCondition()', () => { + it('should return true for BETWEEN', () => { + expect(isPairCondition(Where.between('id', 1, 10))).toBe( + true, + ); + }); + + it('should return false for EQ', () => { + expect(isPairCondition(Where.eq('name', 'Alice'))).toBe( + false, + ); + }); + + it('should return false for IN', () => { + expect( + isPairCondition(Where.in('status', ['active'])), + ).toBe(false); + }); + + it('should return false for IS_NULL', () => { + expect(isPairCondition(Where.isNull('name'))).toBe(false); + }); + }); + }); +}); diff --git a/packages/nestjs-repository/src/repository/interfaces/join-clause.interface.ts b/packages/nestjs-repository/src/repository/interfaces/join-clause.interface.ts new file mode 100644 index 000000000..335b0f5fe --- /dev/null +++ b/packages/nestjs-repository/src/repository/interfaces/join-clause.interface.ts @@ -0,0 +1,20 @@ +/** + * Describes how to join a related entity. + * + * Structural properties (`on`, `through`, `cardinality`) are resolved + * from repository relation metadata — only `relation` and `joinType` + * are specified at query time. + * + * @example + * ```typescript + * { relation: 'blog' } + * { relation: 'posts', joinType: 'INNER' } + * ``` + */ +export interface JoinClause { + /** Relation name to join (must match entity metadata). */ + relation: string; + + /** Join semantics: 'LEFT' (default) or 'INNER'. */ + joinType?: 'LEFT' | 'INNER'; +} diff --git a/packages/nestjs-repository/src/repository/interfaces/order-sort-key.interface.ts b/packages/nestjs-repository/src/repository/interfaces/order-sort-key.interface.ts new file mode 100644 index 000000000..e656872e4 --- /dev/null +++ b/packages/nestjs-repository/src/repository/interfaces/order-sort-key.interface.ts @@ -0,0 +1,29 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type EntityColumn, type SortOrder } from '../repository.types.js'; + +// ═══════════════════════════════════════════════════════════════════════════════ +// OrderSortKey variants — discriminated union on `order` +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * A sort key with ascending order. + */ +export interface OrderSortKeyAsc< + T extends PlainLiteralObject = PlainLiteralObject, +> { + field: EntityColumn; + order: typeof SortOrder.ASC; + relation?: string; +} + +/** + * A sort key with descending order. + */ +export interface OrderSortKeyDesc< + T extends PlainLiteralObject = PlainLiteralObject, +> { + field: EntityColumn; + order: typeof SortOrder.DESC; + relation?: string; +} diff --git a/packages/nestjs-repository/src/repository/interfaces/repository-column-metadata.interface.ts b/packages/nestjs-repository/src/repository/interfaces/repository-column-metadata.interface.ts new file mode 100644 index 000000000..1eb0899ae --- /dev/null +++ b/packages/nestjs-repository/src/repository/interfaces/repository-column-metadata.interface.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +/** + * Column metadata for repository introspection. + */ +export interface RepositoryColumnMetadataInterface< + Entity extends PlainLiteralObject = PlainLiteralObject, +> { + /** Property name on the entity class */ + name: keyof Entity & string; + /** Whether this is a primary key column */ + isPrimary: boolean; + /** Whether this column is the soft-remove date column */ + isRemoveDate: boolean; + /** Whether this column is the optimistic-locking version column */ + isVersion: boolean; +} diff --git a/packages/nestjs-repository/src/repository/interfaces/repository-entity-option.interface.ts b/packages/nestjs-repository/src/repository/interfaces/repository-entity-option.interface.ts new file mode 100644 index 000000000..856c112d3 --- /dev/null +++ b/packages/nestjs-repository/src/repository/interfaces/repository-entity-option.interface.ts @@ -0,0 +1,7 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +export interface RepositoryEntityOptionInterface< + T extends PlainLiteralObject = PlainLiteralObject, +> { + entity: Type; +} diff --git a/packages/nestjs-repository/src/repository/interfaces/repository-metadata.interface.ts b/packages/nestjs-repository/src/repository/interfaces/repository-metadata.interface.ts new file mode 100644 index 000000000..69f7459e9 --- /dev/null +++ b/packages/nestjs-repository/src/repository/interfaces/repository-metadata.interface.ts @@ -0,0 +1,21 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { type RepositoryColumnMetadataInterface } from './repository-column-metadata.interface.js'; +import { type RepositoryRelationMetadataInterface } from './repository-relation-metadata.interface.js'; + +/** + * Repository metadata interface for entity introspection. + * Provides schema information without exposing ORM internals. + */ +export interface RepositoryMetadataInterface< + Entity extends PlainLiteralObject, +> { + /** Entity name (class name) */ + name: string; + /** Entity class/constructor */ + type: Type; + /** All columns in the entity */ + columns: RepositoryColumnMetadataInterface[]; + /** Relation metadata for join resolution */ + relations?: RepositoryRelationMetadataInterface[]; +} diff --git a/packages/nestjs-repository/src/repository/interfaces/repository-options.interface.ts b/packages/nestjs-repository/src/repository/interfaces/repository-options.interface.ts new file mode 100644 index 000000000..becda7e61 --- /dev/null +++ b/packages/nestjs-repository/src/repository/interfaces/repository-options.interface.ts @@ -0,0 +1,61 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type OrderClause } from '../repository.types.js'; + +import { type JoinClause } from './join-clause.interface.js'; +import { type WhereClause } from './where-clause.interface.js'; + +/** + * Base options with optional context. + */ +export interface RepositoryBaseOptions { + ctx?: PlainLiteralObject; +} + +/** + * Options for finding a single entity. + */ +export interface RepositoryFindOneOptions< + Entity extends PlainLiteralObject = PlainLiteralObject, +> extends RepositoryBaseOptions { + select?: (keyof Entity)[]; + where?: WhereClause; + join?: JoinClause[]; + order?: OrderClause; + withDeleted?: boolean; +} + +/** + * Options for finding multiple entities. + */ +export interface RepositoryFindOptions< + Entity extends PlainLiteralObject = PlainLiteralObject, +> extends RepositoryFindOneOptions { + skip?: number; + take?: number; +} + +/** + * Options for create operations. + */ +export interface RepositoryCreateOptions extends RepositoryBaseOptions {} + +/** + * Options for update operations. + */ +export interface RepositoryUpdateOptions extends RepositoryBaseOptions {} + +/** + * Options for upsert operations. + */ +export interface RepositoryUpsertOptions extends RepositoryBaseOptions {} + +/** + * Options for delete operations. + */ +export interface RepositoryDeleteOptions extends RepositoryBaseOptions {} + +/** + * Options for restore operations. + */ +export interface RepositoryRestoreOptions extends RepositoryBaseOptions {} diff --git a/packages/nestjs-repository/src/repository/interfaces/repository-relation-metadata.interface.ts b/packages/nestjs-repository/src/repository/interfaces/repository-relation-metadata.interface.ts new file mode 100644 index 000000000..f2ae3b7a3 --- /dev/null +++ b/packages/nestjs-repository/src/repository/interfaces/repository-relation-metadata.interface.ts @@ -0,0 +1,48 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type RelationAction } from '../repository.types.js'; + +import { type WhereCondition } from './where-clause.interface.js'; + +/** + * ORM-agnostic relation metadata for repository introspection. + * + * Based on the Tier 3 ORM common denominator — available in + * TypeORM, MikroORM, Sequelize, Prisma, Objection, Drizzle Relational. + * + * Mirrors `JoinClause.on` and `JoinClause.through` for trivial mapping. + * `on.from` is always "my column", `on.to` is always "their column". + */ +export interface RepositoryRelationMetadataInterface { + /** Relation property name on the entity (e.g., 'blog', 'posts') */ + name: string; + /** Target entity name (e.g., 'BlogEntity') */ + targetEntity: string; + /** 'one' for 1:1/N:1, 'many' for 1:N/M:N */ + cardinality: 'one' | 'many'; + /** Column mapping: from = source entity column, to = target entity column */ + on: { from: string; to: string }; + /** M:N junction info (mirrors JoinClause.through) */ + through?: { + /** Junction entity or table name. */ + relation: string; + /** Junction FK column pointing to the source entity. */ + fromKey: string; + /** Junction FK column pointing to the target entity. */ + toKey: string; + }; + /** Action to take on related records when the source entity is deleted. */ + onDelete?: RelationAction; + /** Action to take on related records when the source entity's PK is updated. */ + onUpdate?: RelationAction; + /** Use separate queries instead of DB joins for this relation (federation). */ + federated?: boolean; + /** + * Required for many-cardinality federated relations with sorts/filters. + * Ensures exactly one relation entity per root for deterministic ordering. + * + * TODO: Evaluate applying distinctFilter to standard (non-federated) relation + * queries as well, where many-cardinality joins can produce duplicate rows. + */ + distinctFilter?: WhereCondition; +} diff --git a/packages/nestjs-repository/src/repository/interfaces/repository.interface.ts b/packages/nestjs-repository/src/repository/interfaces/repository.interface.ts new file mode 100644 index 000000000..8ffd0eead --- /dev/null +++ b/packages/nestjs-repository/src/repository/interfaces/repository.interface.ts @@ -0,0 +1,201 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type DeepPartial } from '@concepta/nestjs-core'; + +import { type RepositoryMetadataInterface } from './repository-metadata.interface.js'; +import { + type RepositoryCreateOptions, + type RepositoryDeleteOptions, + type RepositoryFindOptions, + type RepositoryFindOneOptions, + type RepositoryRestoreOptions, + type RepositoryUpdateOptions, + type RepositoryUpsertOptions, +} from './repository-options.interface.js'; + +export interface RepositoryInterface { + /** + * Repository metadata for entity introspection. + * Provides schema information without exposing ORM internals. + */ + readonly metadata: RepositoryMetadataInterface; + + // Query operations + + /** + * Find multiple entities matching the given options. + * + * @param options - Find options (where, order, skip, take, etc.) + * @returns Array of matching entities + */ + find(options?: RepositoryFindOptions): Promise; + + /** + * Find a single entity matching the given options. + * + * @param options - Find options (where, order, etc.) + * @returns The matching entity or null if not found + */ + findOne(options: RepositoryFindOneOptions): Promise; + + /** + * Count entities matching the given options. + * + * @param options - Find options (where conditions) + * @returns Number of matching entities + */ + count(options?: RepositoryFindOptions): Promise; + + /** + * Find multiple entities and count total matching records. + * + * @param options - Find options (where, order, skip, take, etc.) + * @returns Tuple of [entities, totalCount] + */ + findAndCount( + options?: RepositoryFindOptions, + ): Promise<[Entity[], number]>; + + // Create operations + + /** + * Create a single entity. + * + * @param entity - Partial entity data to create + * @param options - Create options + * @returns The created entity + */ + create( + entity: DeepPartial, + options?: RepositoryCreateOptions, + ): Promise; + + /** + * Create multiple entities. + * + * @param entities - Array of partial entity data to create + * @param options - Create options + * @returns Array of created entities + */ + createMany( + entities: DeepPartial[], + options?: RepositoryCreateOptions, + ): Promise; + + // Update operations + + /** + * Update an existing entity by merging partial data. + * Preserves fields not specified in the data. + * + * @param entity - Existing entity to update (primary keys used for identification) + * @param data - Partial data to merge into entity + * @param options - Update options + * @returns The updated entity + */ + update( + entity: Entity, + data: DeepPartial, + options?: RepositoryUpdateOptions, + ): Promise; + + /** + * Create or update an entity based on primary key. + * If entity with matching primary key exists, updates it; otherwise creates new. + * + * @param entity - Entity data with primary key + * @param options - Upsert options + * @returns The created or updated entity + */ + upsert( + entity: DeepPartial, + options?: RepositoryUpsertOptions, + ): Promise; + + /** + * Replace an entity's fields with new data. + * Overwrites all fields (clears fields not specified in data). + * + * @param entity - Existing entity to replace (primary keys used for identification) + * @param data - New data to replace entity with + * @param options - Update options + * @returns The replaced entity + */ + replace( + entity: Entity, + data: DeepPartial, + options?: RepositoryUpdateOptions, + ): Promise; + + // Delete operations + + /** + * Permanently delete an entity (hard delete). + * + * @param entity - Entity to delete (primary keys used for identification) + * @param options - Delete options + * @returns The deleted entity + */ + delete(entity: Entity, options?: RepositoryDeleteOptions): Promise; + + /** + * Permanently delete multiple entities (hard delete). + * + * @param entities - Array of entities to delete + * @param options - Delete options + * @returns Array of deleted entities + */ + deleteMany( + entities: Entity[], + options?: RepositoryDeleteOptions, + ): Promise; + + /** + * Soft delete an entity by setting its delete date. + * + * @param entity - Entity to soft delete (primary keys used for identification) + * @param options - Delete options + * @returns The soft-deleted entity + */ + softDelete( + entity: Entity, + options?: RepositoryDeleteOptions, + ): Promise; + + /** + * Restore a soft-deleted entity. + * + * @param entity - Soft-deleted entity to restore (primary keys used for identification) + * @param options - Restore options + * @returns The restored entity + */ + restore(entity: Entity, options?: RepositoryRestoreOptions): Promise; + + // Utility methods + + /** + * Transform a partial entity-like object into an entity instance. + * + * @param entityLike - Partial entity data + * @returns Entity instance + */ + transform(entityLike: DeepPartial): Entity; + + /** + * Merge multiple partial entities into an existing entity. + * + * @param mergeIntoEntity - Target entity to merge into + * @param entityLikes - Partial entities to merge + * @returns The merged entity + */ + merge(mergeIntoEntity: Entity, ...entityLikes: DeepPartial[]): Entity; + + /** + * Prepare a DTO for write operations. + * Transforms DTO to entity instance if needed. + * + * @param dto - DTO or partial entity to prepare + * @returns Prepared entity instance, or undefined if invalid + */ + prepare(dto: DeepPartial): Entity | undefined; +} diff --git a/packages/nestjs-repository/src/repository/interfaces/where-clause.interface.ts b/packages/nestjs-repository/src/repository/interfaces/where-clause.interface.ts new file mode 100644 index 000000000..d74fdc57e --- /dev/null +++ b/packages/nestjs-repository/src/repository/interfaces/where-clause.interface.ts @@ -0,0 +1,136 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type EntityColumn, + type WhereArrayOperator, + type WhereCompoundOperator, + type WhereNullaryOperator, + WhereOperator, + type WherePairOperator, + type WhereScalarOperator, +} from '../repository.types.js'; + +// ═══════════════════════════════════════════════════════════════════════════════ +// WhereCondition variants — discriminated union on `operator` +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Condition without a value (IS_NULL, NOT_NULL). + */ +export interface WhereConditionNullary< + T extends PlainLiteralObject = PlainLiteralObject, +> { + field: EntityColumn; + operator: WhereNullaryOperator; + relation?: string; +} + +/** + * Condition with a scalar value (EQ, NE, GT, GTE, LT, LTE, pattern ops). + */ +export interface WhereConditionScalar< + T extends PlainLiteralObject = PlainLiteralObject, +> { + field: EntityColumn; + operator: WhereScalarOperator; + value: unknown; + relation?: string; +} + +/** + * Condition with an array value (IN, NIN). + */ +export interface WhereConditionArray< + T extends PlainLiteralObject = PlainLiteralObject, +> { + field: EntityColumn; + operator: WhereArrayOperator; + value: unknown[]; + relation?: string; +} + +/** + * Condition with a pair value (BETWEEN). + */ +export interface WhereConditionPair< + T extends PlainLiteralObject = PlainLiteralObject, +> { + field: EntityColumn; + operator: WherePairOperator; + value: [unknown, unknown]; + relation?: string; +} + +/** + * A condition on a single entity field — discriminated union on `operator`. + */ +export type WhereCondition = + | WhereConditionNullary + | WhereConditionScalar + | WhereConditionArray + | WhereConditionPair; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Compound clauses +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * A compound condition combining other clauses. + */ +export interface WhereCompound { + operator: WhereCompoundOperator; + conditions: WhereClause[]; +} + +/** + * A single node in the where clause AST. + */ +export type WhereClause = WhereCondition | WhereCompound; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Type guards +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Type guard to distinguish field conditions from compounds. + */ +export function isWhereCondition(c: WhereClause): c is WhereCondition { + return 'field' in c && 'operator' in c; +} + +/** + * Type guard to distinguish compound clauses from field conditions. + */ +export function isWhereCompound(c: WhereClause): c is WhereCompound { + return 'conditions' in c && 'operator' in c; +} + +/** + * Type guard for nullary conditions (IS_NULL, NOT_NULL). + */ +export function isNullaryCondition( + c: WhereCondition, +): c is WhereConditionNullary { + return ( + c.operator === WhereOperator.IS_NULL || + c.operator === WhereOperator.NOT_NULL + ); +} + +/** + * Type guard for array conditions (IN, NIN). + */ +export function isArrayCondition( + c: WhereCondition, +): c is WhereConditionArray { + return c.operator === WhereOperator.IN || c.operator === WhereOperator.NIN; +} + +/** + * Type guard for pair conditions (BETWEEN). + */ +export function isPairCondition( + c: WhereCondition, +): c is WhereConditionPair { + return c.operator === WhereOperator.BETWEEN; +} diff --git a/packages/nestjs-repository/src/repository/join.helpers.ts b/packages/nestjs-repository/src/repository/join.helpers.ts new file mode 100644 index 000000000..819d0ee3f --- /dev/null +++ b/packages/nestjs-repository/src/repository/join.helpers.ts @@ -0,0 +1,34 @@ +import { type JoinClause } from './interfaces/join-clause.interface.js'; + +/** + * Join clause builder — static API for constructing `JoinClause` arrays. + * + * @example + * ```typescript + * repository.findAndCount({ + * ...Join.join(Join.left('posts'), Join.inner('company')), + * }); + * ``` + */ +export class Join { + /** + * Create a LEFT JOIN clause (default join type). + */ + static left(relation: string): JoinClause { + return { relation, joinType: 'LEFT' }; + } + + /** + * Create an INNER JOIN clause. + */ + static inner(relation: string): JoinClause { + return { relation, joinType: 'INNER' }; + } + + /** + * Wrap join clauses into `{ join: clauses }` for passing to find options. + */ + static join(...clauses: JoinClause[]): { join: JoinClause[] } { + return { join: clauses }; + } +} diff --git a/packages/nestjs-repository/src/repository/order-by.helpers.ts b/packages/nestjs-repository/src/repository/order-by.helpers.ts new file mode 100644 index 000000000..4197fe515 --- /dev/null +++ b/packages/nestjs-repository/src/repository/order-by.helpers.ts @@ -0,0 +1,130 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { RuntimeException } from '@concepta/nestjs-core'; + +import { + type OrderSortKeyAsc, + type OrderSortKeyDesc, +} from './interfaces/order-sort-key.interface.js'; +import { + type EntityColumn, + type OrderClause, + type OrderSortKey, + SortOrder, +} from './repository.types.js'; + +/** + * Order clause builder with both static and instance APIs. + * + * @example Static usage (pass Entity as generic per call): + * ```typescript + * repository.find(OrderBy.order(OrderBy.asc('name'))); + * repository.find(OrderBy.order(OrderBy.desc('createdAt'), OrderBy.asc('name'))); + * ``` + * + * @example Typed builder (Entity bound via factory): + * ```typescript + * const o = OrderBy.for(); + * repository.find(o.order(o.desc('createdAt'), o.asc('name'))); + * ``` + */ +export class OrderBy { + // ═══════════════════════════════════════════════════════════════════════════ + // Static API + // ═══════════════════════════════════════════════════════════════════════════ + + static asc( + field: EntityColumn, + ): OrderSortKeyAsc { + return { field, order: SortOrder.ASC }; + } + + static desc( + field: EntityColumn, + ): OrderSortKeyDesc { + return { field, order: SortOrder.DESC }; + } + + /** + * Tag an OrderSortKey with a relation name. + * + * @example + * ```typescript + * OrderBy.rel('posts', OrderBy.asc('title')) + * // => { field: 'title', order: 'ASC', relation: 'posts' } + * ``` + */ + static rel< + E extends PlainLiteralObject = PlainLiteralObject, + K extends OrderSortKey = OrderSortKey, + >(relation: string, key: K): K { + return { ...key, relation }; + } + + /** + * Parse a dot-notation field and tag the key with the extracted relation. + * + * @example + * ```typescript + * OrderBy.relDot('blog.title', OrderBy.asc('title')) + * // => { field: 'title', order: 'ASC', relation: 'blog' } + * ``` + */ + static relDot< + E extends PlainLiteralObject = PlainLiteralObject, + K extends OrderSortKey = OrderSortKey, + >(dotField: string, key: K): K { + const parts = dotField.split('.'); + if (parts.length === 1) return key; + if (parts.length !== 2 || !parts[0]) { + throw new RuntimeException({ + message: 'relDot expects "relation.field" dot notation, got "%s"', + messageParams: [ + String(dotField) + .replace(/[^\w.]/g, '') + .substring(0, 100), + ], + fault: 'usage', + }); + } + return { ...key, relation: parts[0] }; + } + + static order(...keys: OrderSortKey[]): { order: OrderClause } { + return { order: keys }; + } + + static for(): OrderBy { + return new OrderBy(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Instance API (field names checked against Entity) + // ═══════════════════════════════════════════════════════════════════════════ + + asc(field: EntityColumn): OrderSortKeyAsc { + return OrderBy.asc(field); + } + + desc(field: EntityColumn): OrderSortKeyDesc { + return OrderBy.desc(field); + } + + rel = OrderSortKey>( + relation: string, + key: K, + ): K { + return OrderBy.rel(relation, key); + } + + relDot = OrderSortKey>( + dotField: string, + key: K, + ): K { + return OrderBy.relDot(dotField, key); + } + + order(...keys: OrderSortKey[]): { order: OrderClause } { + return { order: keys }; + } +} diff --git a/packages/nestjs-repository/src/repository/repository-adapter.spec.ts b/packages/nestjs-repository/src/repository/repository-adapter.spec.ts new file mode 100644 index 000000000..07c12c0d1 --- /dev/null +++ b/packages/nestjs-repository/src/repository/repository-adapter.spec.ts @@ -0,0 +1,320 @@ +import { type PlainLiteralObject, type Type } from '@nestjs/common'; + +import { + AppContextHost, + type DeepPartial, + HooksCtx, + RuntimeException, +} from '@concepta/nestjs-core'; + +import { type JoinClause } from './interfaces/join-clause.interface.js'; +import { type RepositoryMetadataInterface } from './interfaces/repository-metadata.interface.js'; +import { + type RepositoryFindOptions, + type RepositoryFindOneOptions, + type RepositoryCreateOptions, + type RepositoryUpdateOptions, + type RepositoryUpsertOptions, + type RepositoryDeleteOptions, + type RepositoryRestoreOptions, +} from './interfaces/repository-options.interface.js'; +import { type WhereClause } from './interfaces/where-clause.interface.js'; +import { RepositoryAdapter } from './repository-adapter.js'; +import { Where } from './where.helpers.js'; + +// ─── Test entity ───────────────────────────────────────────────────────────── + +interface TestEntity extends PlainLiteralObject { + id: string; + name: string; + version: number; +} + +class TestEntityClass { + id!: string; + name!: string; + version!: number; +} + +// ─── Concrete subclass to expose protected methods ─────────────────────────── + +class TestRepositoryAdapter extends RepositoryAdapter { + readonly metadata: RepositoryMetadataInterface = { + name: 'TestEntity', + type: TestEntityClass as Type, + columns: [ + { name: 'id', isPrimary: true, isRemoveDate: false, isVersion: false }, + { name: 'name', isPrimary: false, isRemoveDate: false, isVersion: false }, + { + name: 'version', + isPrimary: false, + isRemoveDate: false, + isVersion: true, + }, + ], + relations: [ + { + name: 'posts', + targetEntity: 'PostEntity', + cardinality: 'many' as const, + on: { from: 'id', to: 'authorId' }, + }, + { + name: 'tags', + targetEntity: 'TagEntity', + cardinality: 'many' as const, + on: { from: 'id', to: 'id' }, + through: { + relation: 'entity_tags', + fromKey: 'entityId', + toKey: 'tagId', + }, + }, + ], + }; + + protected doFind( + _options?: RepositoryFindOptions, + ): Promise { + throw new Error('not implemented'); + } + protected doFindOne( + _options: RepositoryFindOneOptions, + ): Promise { + throw new Error('not implemented'); + } + protected doCount( + _options?: RepositoryFindOptions, + ): Promise { + throw new Error('not implemented'); + } + protected doFindAndCount( + _options?: RepositoryFindOptions, + ): Promise<[TestEntity[], number]> { + throw new Error('not implemented'); + } + protected doCreate( + _entity: DeepPartial, + _options?: RepositoryCreateOptions, + ): Promise { + throw new Error('not implemented'); + } + protected doCreateMany( + _entities: DeepPartial[], + _options?: RepositoryCreateOptions, + ): Promise { + throw new Error('not implemented'); + } + protected doUpdate( + _entity: TestEntity, + _data: DeepPartial, + _options?: RepositoryUpdateOptions, + ): Promise { + throw new Error('not implemented'); + } + protected doUpsert( + _entity: DeepPartial, + _options?: RepositoryUpsertOptions, + ): Promise { + throw new Error('not implemented'); + } + protected doReplace( + _entity: TestEntity, + _data: DeepPartial, + _options?: RepositoryUpdateOptions, + ): Promise { + throw new Error('not implemented'); + } + protected doDelete( + _entity: TestEntity, + _options?: RepositoryDeleteOptions, + ): Promise { + throw new Error('not implemented'); + } + protected doDeleteMany( + _entities: TestEntity[], + _options?: RepositoryDeleteOptions, + ): Promise { + throw new Error('not implemented'); + } + protected doSoftDelete( + _entity: TestEntity, + _options?: RepositoryDeleteOptions, + ): Promise { + throw new Error('not implemented'); + } + protected doRestore( + _entity: TestEntity, + _options?: RepositoryRestoreOptions, + ): Promise { + throw new Error('not implemented'); + } + transform(_entityLike: DeepPartial): TestEntity { + throw new Error('not implemented'); + } + merge( + _mergeIntoEntity: TestEntity, + ..._entityLikes: DeepPartial[] + ): TestEntity { + throw new Error('not implemented'); + } + + exposedResolveJoinClauses(join?: JoinClause[]): JoinClause[] | undefined { + return this.resolveJoinClauses(join); + } + + exposedToDnf(clause: WhereClause): WhereClause[][] { + return this.toDnf(clause); + } + + exposedCartesianProduct(groups: WhereClause[][][]): WhereClause[][] { + return this.cartesianProduct(groups); + } + + exposedEntityCtx(ctx?: PlainLiteralObject): PlainLiteralObject | undefined { + return this.entityCtx(ctx); + } + + exposedGetVersionColumn(): (keyof TestEntity & string) | undefined { + return this.getVersionColumn(); + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe(RepositoryAdapter.name, () => { + let adapter: TestRepositoryAdapter; + + beforeEach(() => { + adapter = new TestRepositoryAdapter('test-entity'); + }); + + describe('resolveJoinClauses', () => { + it('should return undefined for undefined input', () => { + expect(adapter.exposedResolveJoinClauses(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty array', () => { + expect(adapter.exposedResolveJoinClauses([])).toBeUndefined(); + }); + + it('should pass through valid join clauses', () => { + const input: JoinClause[] = [{ relation: 'posts' }]; + const result = adapter.exposedResolveJoinClauses(input); + expect(result).toBe(input); + }); + + it('should validate multiple joins', () => { + const input: JoinClause[] = [{ relation: 'posts' }, { relation: 'tags' }]; + const result = adapter.exposedResolveJoinClauses(input); + expect(result).toBe(input); + }); + + it('should throw RuntimeException for unknown relation', () => { + expect(() => { + adapter.exposedResolveJoinClauses([{ relation: 'nonexistent' }]); + }).toThrow(RuntimeException); + }); + }); + + describe('toDnf', () => { + it('should return single-element branch for a condition', () => { + const cond = Where.eq('id', '1'); + const result = adapter.exposedToDnf(cond); + expect(result).toEqual([[cond]]); + }); + + it('should flatten AND into a single branch', () => { + const a = Where.eq('id', '1'); + const b = Where.gt('version', 2); + const result = adapter.exposedToDnf(Where.and(a, b)); + expect(result).toEqual([[a, b]]); + }); + + it('should flatten OR into separate branches', () => { + const a = Where.eq('id', '1'); + const b = Where.eq('id', '2'); + const result = adapter.exposedToDnf(Where.or(a, b)); + expect(result).toEqual([[a], [b]]); + }); + + it('should distribute AND over OR (DNF conversion)', () => { + const a = Where.eq('id', '1'); + const b = Where.eq('name', 'x'); + const c = Where.eq('name', 'y'); + // AND(a, OR(b, c)) => OR(AND(a,b), AND(a,c)) + const result = adapter.exposedToDnf(Where.and(a, Where.or(b, c))); + expect(result).toEqual([ + [a, b], + [a, c], + ]); + }); + }); + + describe('cartesianProduct', () => { + it('should compute product of two groups', () => { + const a = Where.eq('id', '1'); + const b = Where.eq('id', '2'); + const c = Where.eq('name', 'x'); + + const result = adapter.exposedCartesianProduct([[[a], [b]], [[c]]]); + expect(result).toEqual([ + [a, c], + [b, c], + ]); + }); + }); + + describe('prepare', () => { + it('should return an entity instance for an empty object', () => { + const result = adapter.prepare({}); + expect(result).toBeInstanceOf(TestEntityClass); + }); + + it('should return entity instance as-is', () => { + const entity = new TestEntityClass(); + entity.id = '1'; + expect(adapter.prepare(entity)).toBe(entity); + }); + }); + + describe('getPrimaryColumns', () => { + it('should return primary column names', () => { + // Access via metadata — getPrimaryColumns is protected but we can verify + // through the columns metadata + const primaries = adapter.metadata.columns + .filter((c) => c.isPrimary) + .map((c) => c.name); + expect(primaries).toEqual(['id']); + }); + }); + + describe('getVersionColumn', () => { + it('should return the version column name', () => { + expect(adapter.exposedGetVersionColumn()).toBe('version'); + }); + }); + + describe('entityCtx', () => { + it('should scope each repository to its own entity, even when repositories share one ctx', () => { + const repoA = new TestRepositoryAdapter('entity-a'); + const repoB = new TestRepositoryAdapter('entity-b'); + const ctx = new AppContextHost(); + + const ambientA = repoA.exposedEntityCtx(ctx); + const ambientB = repoB.exposedEntityCtx(ctx); + + expect(ambientA?.entity).toBe('entity-a'); + expect(ambientB?.entity).toBe('entity-b'); + }); + + it('should still inherit an already-defined overlay from the shared ctx', () => { + const ctx = new AppContextHost(); + ctx.defineOverlay(HooksCtx, { hooks: [] }); + + const ambient = adapter.exposedEntityCtx(ctx); + + expect(ambient?.hooks).toEqual([]); + }); + }); +}); diff --git a/packages/nestjs-repository/src/repository/repository-adapter.ts b/packages/nestjs-repository/src/repository/repository-adapter.ts new file mode 100644 index 000000000..c0628a7e5 --- /dev/null +++ b/packages/nestjs-repository/src/repository/repository-adapter.ts @@ -0,0 +1,514 @@ +import { HttpStatus, type PlainLiteralObject } from '@nestjs/common'; + +import { + AppContextHost, + type DeepPartial, + isObject, + RuntimeException, + type HookMethodKeyType, + type HookResolverService, +} from '@concepta/nestjs-core'; + +import { RepoCtx } from '../context/interfaces/repository-context.interface.js'; +import { type FederationOrchestrator } from '../federation/federation-orchestrator.service.js'; +import { RepoPermeatorFactory } from '../hooks/repo-permeator-factory.js'; +import { RepoHook } from '../hooks/repository-hook.decorators.js'; + +import { type JoinClause } from './interfaces/join-clause.interface.js'; +import { type RepositoryMetadataInterface } from './interfaces/repository-metadata.interface.js'; +import { + type RepositoryFindOptions, + type RepositoryFindOneOptions, + type RepositoryCreateOptions, + type RepositoryUpdateOptions, + type RepositoryUpsertOptions, + type RepositoryDeleteOptions, + type RepositoryRestoreOptions, +} from './interfaces/repository-options.interface.js'; +import { type RepositoryInterface } from './interfaces/repository.interface.js'; +import { + type WhereClause, + isWhereCondition, + isWhereCompound, +} from './interfaces/where-clause.interface.js'; +import { WhereCompoundOperator } from './repository.types.js'; + +/** + * Abstract repository adapter that implements entity hydration. + * + * Concrete repository implementations should extend this class. + * + * @example + * ```typescript + * class TypeOrmRepository extends RepositoryAdapter { + * async find(options?) { + * return await this.repo.find(options); + * } + * + * async create(entity, options?) { + * return await this.repo.save(entity); + * } + * } + * ``` + */ +export abstract class RepositoryAdapter< + Entity extends PlainLiteralObject, +> implements RepositoryInterface { + abstract readonly metadata: RepositoryMetadataInterface; + + readonly entityKey: string; + + private _permeator?: RepoPermeatorFactory; + private _federationOrchestrator?: FederationOrchestrator; + + constructor( + entityKey: string, + protected readonly hookResolver?: HookResolverService, + ) { + this.entityKey = entityKey; + } + + /** + * Set the federation orchestrator for this repository. + * When set, `findAndCount` will delegate to the orchestrator + * for queries that include federated joins. + */ + setFederationOrchestrator(orchestrator: FederationOrchestrator): void { + this._federationOrchestrator = orchestrator; + } + + protected get permeator(): RepoPermeatorFactory { + if (!this._permeator) { + this._permeator = new RepoPermeatorFactory( + this.runHooks.bind(this), + this.entityKey, + ); + } + return this._permeator; + } + + /** + * Build the ambient context for hook execution. + * + * Chains overlays via prototype inheritance so that hook methods + * can access locals, hooks, entity, and trx through the chain. + * + * `entity` is installed on a fresh child scoped to this one call, not on + * `ctx` itself — unlike trx/hooks (stable for the whole scope/request), + * it differs per repository per call, so it can't use `defineOverlay`'s + * idempotent "declare once" semantics without pinning to whichever + * repository happened to touch `ctx` first. + */ + protected entityCtx( + ctx?: PlainLiteralObject, + ): PlainLiteralObject | undefined { + if (!ctx) return undefined; + const appCtx = AppContextHost.from(ctx); + + const repoScoped = AppContextHost.from(Object.create(appCtx)); + repoScoped.defineOverlay(RepoCtx, { entity: this.entityKey }); + + return repoScoped + .require(RepoCtx) + .withRepo() + .optional() + .withHooks() + .optional() + .withTrx(); + } + + // Query operations + + async find(options: RepositoryFindOptions = {}): Promise { + return this.permeator.find.permeate( + options, + (scoped) => this.doFind(scoped), + this.entityCtx(options.ctx), + ); + } + + protected abstract doFind( + options?: RepositoryFindOptions, + ): Promise; + + async findOne( + options: RepositoryFindOneOptions, + ): Promise { + return this.permeator.findOne.permeate( + options, + (scoped) => this.doFindOne(scoped), + this.entityCtx(options.ctx), + ); + } + + protected abstract doFindOne( + options: RepositoryFindOneOptions, + ): Promise; + + async count(options: RepositoryFindOptions = {}): Promise { + return this.permeator.count.permeate( + options, + (scoped) => this.doCount(scoped), + this.entityCtx(options.ctx), + ); + } + + protected abstract doCount( + options?: RepositoryFindOptions, + ): Promise; + + /** + * Find entities and return with total count. + * + * When a federation orchestrator is set and the query includes + * joins targeting `federated: true` relations, delegates to the + * orchestrator for cross-entity query orchestration. + */ + async findAndCount( + options: RepositoryFindOptions = {}, + ): Promise<[Entity[], number]> { + if (this._federationOrchestrator && this.hasFederatedJoins(options?.join)) { + return this._federationOrchestrator.findAndCount(this, options); + } + return this.permeator.findAndCount.permeate( + options, + (scoped) => this.doFindAndCount(scoped), + this.entityCtx(options.ctx), + ); + } + + protected abstract doFindAndCount( + options?: RepositoryFindOptions, + ): Promise<[Entity[], number]>; + + // Create operations + + async create( + entity: DeepPartial, + options?: RepositoryCreateOptions, + ): Promise { + return this.permeator.create.permeate( + entity, + (scoped) => this.doCreate(scoped, options), + this.entityCtx(options?.ctx), + ); + } + + protected abstract doCreate( + entity: DeepPartial, + options?: RepositoryCreateOptions, + ): Promise; + + async createMany( + entities: DeepPartial[], + options?: RepositoryCreateOptions, + ): Promise { + return this.permeator.createMany.permeate( + entities, + (scoped) => this.doCreateMany(scoped, options), + this.entityCtx(options?.ctx), + ); + } + + protected abstract doCreateMany( + entities: DeepPartial[], + options?: RepositoryCreateOptions, + ): Promise; + + // Update operations + + async update( + entity: Entity, + data: DeepPartial, + options?: RepositoryUpdateOptions, + ): Promise { + return this.permeator.update.permeate( + data, + (scoped) => this.doUpdate(entity, scoped, options), + this.entityCtx(options?.ctx), + ); + } + + protected abstract doUpdate( + entity: Entity, + data: DeepPartial, + options?: RepositoryUpdateOptions, + ): Promise; + + async upsert( + entity: DeepPartial, + options?: RepositoryUpsertOptions, + ): Promise { + return this.permeator.upsert.permeate( + entity, + (scoped) => this.doUpsert(scoped, options), + this.entityCtx(options?.ctx), + ); + } + + protected abstract doUpsert( + entity: DeepPartial, + options?: RepositoryUpsertOptions, + ): Promise; + + async replace( + entity: Entity, + data: DeepPartial, + options?: RepositoryUpdateOptions, + ): Promise { + return this.permeator.replace.permeate( + data, + (scoped) => this.doReplace(entity, scoped, options), + this.entityCtx(options?.ctx), + ); + } + + protected abstract doReplace( + entity: Entity, + data: DeepPartial, + options?: RepositoryUpdateOptions, + ): Promise; + + // Delete operations + + async delete( + entity: Entity, + options?: RepositoryDeleteOptions, + ): Promise { + return this.permeator.delete.permeate( + entity, + (scoped) => this.doDelete(scoped, options), + this.entityCtx(options?.ctx), + ); + } + + protected abstract doDelete( + entity: Entity, + options?: RepositoryDeleteOptions, + ): Promise; + + async deleteMany( + entities: Entity[], + options?: RepositoryDeleteOptions, + ): Promise { + return this.permeator.deleteMany.permeate( + entities, + (scoped) => this.doDeleteMany(scoped, options), + this.entityCtx(options?.ctx), + ); + } + + protected abstract doDeleteMany( + entities: Entity[], + options?: RepositoryDeleteOptions, + ): Promise; + + async softDelete( + entity: Entity, + options?: RepositoryDeleteOptions, + ): Promise { + return this.permeator.softDelete.permeate( + entity, + (scoped) => this.doSoftDelete(scoped, options), + this.entityCtx(options?.ctx), + ); + } + + protected abstract doSoftDelete( + entity: Entity, + options?: RepositoryDeleteOptions, + ): Promise; + + async restore( + entity: Entity, + options?: RepositoryRestoreOptions, + ): Promise { + return this.permeator.restore.permeate( + entity, + (scoped) => this.doRestore(scoped, options), + this.entityCtx(options?.ctx), + ); + } + + protected abstract doRestore( + entity: Entity, + options?: RepositoryRestoreOptions, + ): Promise; + + // Utility methods + + abstract transform(entityLike: DeepPartial): Entity; + + abstract merge( + mergeIntoEntity: Entity, + ...entityLikes: DeepPartial[] + ): Entity; + + /** + * Prepare a DTO for write operations. + * Transforms DTO to entity instance if needed. An empty object is a + * valid entity (e.g. every column is server-populated) — rejecting it + * is a schema/validation-layer decision, not this adapter's (see #466). + */ + prepare(dto: DeepPartial): Entity | undefined { + if (!isObject(dto)) { + return undefined; + } + + const entityType = this.metadata.type; + + if (dto instanceof entityType) { + return dto; + } + + return Object.assign(new entityType(), dto); + } + + /** + * Get primary key column names from metadata + */ + protected getPrimaryColumns(): (keyof Entity & string)[] { + return this.metadata.columns + .filter((col) => col.isPrimary) + .map((col) => col.name); + } + + /** + * Get the optimistic-locking version column name from metadata, if any. + */ + protected getVersionColumn(): (keyof Entity & string) | undefined { + return this.metadata.columns.find((col) => col.isVersion)?.name; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Federation helpers + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Check if any requested joins target federated relations. + */ + private hasFederatedJoins(join?: JoinClause[]): boolean { + if (!join?.length || !this.metadata.relations?.length) return false; + const joinNames = new Set(join.map((j) => j.relation)); + return this.metadata.relations.some( + (r) => r.federated && joinNames.has(r.name), + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // JoinClause resolution (ORM-agnostic) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Validate JoinClauses against repository relation metadata. + * + * Called by ORM adapters before translating to native find options. + */ + protected resolveJoinClauses(join?: JoinClause[]): JoinClause[] | undefined { + if (!join?.length) return undefined; + + const relMap = new Map(this.metadata.relations?.map((r) => [r.name, r])); + + for (const j of join) { + if (!relMap.has(j.relation)) { + throw new RuntimeException({ + message: 'Unknown relation "%s" on entity "%s"', + messageParams: [j.relation, this.metadata.name], + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } + } + + return join; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // WhereClause AST helpers (ORM-agnostic) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Flatten a WhereClause tree into Disjunctive Normal Form: + * an array of AND-branches, where each branch is a flat list + * of WhereClause leaves. The outer array represents OR. + * + * Leaves are either WhereConditions or not(...) compounds + * preserved for ORM-specific translation. + */ + protected toDnf(clause: WhereClause): WhereClause[][] { + if (isWhereCondition(clause)) { + return [[clause]]; + } + + if (!isWhereCompound(clause)) return []; + + switch (clause.operator) { + case WhereCompoundOperator.OR: + return clause.conditions.flatMap((c) => this.toDnf(c)); + + case WhereCompoundOperator.AND: { + const groups = clause.conditions.map((c) => this.toDnf(c)); + const nonEmpty = groups.filter((g) => g.length > 0); + if (nonEmpty.length === 0) return []; + if (nonEmpty.length === 1) return nonEmpty[0]; + return this.cartesianProduct(nonEmpty); + } + + default: + return []; + } + } + + protected static readonly MAX_DNF_BRANCHES = 50; + + /** + * Compute cartesian product of AND-groups of OR-branches. + * Distributes AND over OR at the AST level. + * + * e.g., `[[[a]], [[b], [c]]] => [[a, b], [a, c]]` + */ + protected cartesianProduct(groups: WhereClause[][][]): WhereClause[][] { + let result = groups[0]; + + for (let i = 1; i < groups.length; i++) { + const nextGroup = groups[i]; + const newResult: WhereClause[][] = []; + for (const existing of result) { + for (const next of nextGroup) { + if (newResult.length >= RepositoryAdapter.MAX_DNF_BRANCHES) { + throw new RuntimeException({ + message: 'Where clause too complex: exceeded %d DNF branches', + messageParams: [RepositoryAdapter.MAX_DNF_BRANCHES], + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } + newResult.push([...existing, ...next]); + } + } + result = newResult; + } + + return result; + } + + /** + * Run repository hooks for a specific method key. + * + * @param methodKey - The hook method key (e.g., 'beforeFind', 'afterCreate') + * @param payload - The payload to pass through hooks + * @param ctx - The hook context + * @returns The payload after processing by applicable hooks + */ + protected async runHooks( + methodKey: HookMethodKeyType, + payload: T, + ctx: PlainLiteralObject | undefined, + ): Promise { + if (!this.hookResolver) { + return payload; + } + + return this.hookResolver.execute(RepoHook, methodKey, payload, ctx); + } +} diff --git a/packages/nestjs-repository/src/repository/repository.types.ts b/packages/nestjs-repository/src/repository/repository.types.ts new file mode 100644 index 000000000..075bc69fe --- /dev/null +++ b/packages/nestjs-repository/src/repository/repository.types.ts @@ -0,0 +1,126 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type OrderSortKeyAsc, + type OrderSortKeyDesc, +} from './interfaces/order-sort-key.interface.js'; + +/** + * Column name type — narrows to `keyof T & string` when an entity is provided. + */ +export type EntityColumn = + keyof T & string; + +/** + * Canonical operator constants for the where clause AST. + * String values match the wire format without the $ prefix. + */ +export const WhereOperator = { + // Point filters (discrete values, high sargability) + EQ: 'eq', + NE: 'ne', + IN: 'in', + NIN: 'nin', + // Pattern filters (string matching) + CONTAINS: 'contains', + NCONTAINS: 'ncontains', + STARTS: 'starts', + NSTARTS: 'nstarts', + ENDS: 'ends', + NENDS: 'nends', + // Null state + IS_NULL: 'null', + NOT_NULL: 'nnull', + // Range filters + GT: 'gt', + LT: 'lt', + GTE: 'gte', + LTE: 'lte', + BETWEEN: 'between', +} as const; + +export type WhereOperator = (typeof WhereOperator)[keyof typeof WhereOperator]; + +/** + * Operator group types — partition WhereOperator by value shape. + */ +export type WhereNullaryOperator = + | typeof WhereOperator.IS_NULL + | typeof WhereOperator.NOT_NULL; + +export type WhereScalarOperator = + | typeof WhereOperator.EQ + | typeof WhereOperator.NE + | typeof WhereOperator.GT + | typeof WhereOperator.GTE + | typeof WhereOperator.LT + | typeof WhereOperator.LTE + | typeof WhereOperator.CONTAINS + | typeof WhereOperator.NCONTAINS + | typeof WhereOperator.STARTS + | typeof WhereOperator.NSTARTS + | typeof WhereOperator.ENDS + | typeof WhereOperator.NENDS; + +export type WhereArrayOperator = + | typeof WhereOperator.IN + | typeof WhereOperator.NIN; + +export type WherePairOperator = typeof WhereOperator.BETWEEN; + +/** + * Canonical compound operator constants. + */ +export const WhereCompoundOperator = { + AND: 'and', + OR: 'or', +} as const; + +export type WhereCompoundOperator = + (typeof WhereCompoundOperator)[keyof typeof WhereCompoundOperator]; + +/** + * Tuple shorthand for a where condition: `[field, operator, value?]`. + */ +export type WhereConditionArr< + T extends PlainLiteralObject = PlainLiteralObject, +> = [EntityColumn, WhereOperator, unknown?]; + +/** + * Relation action types for onDelete / onUpdate behavior. + * + * - `delegate` — defer to native schema settings (default) + * - `cascade` — adapter handles it (guarantees hooks/events run) + * - `restrict` — throw error if related records exist + * - `setNull` — set FK to null, leaving orphans + */ +export type RelationAction = 'delegate' | 'cascade' | 'restrict' | 'setNull'; + +/** + * Sort order constants. + */ +export const SortOrder = { + ASC: 'ASC', + DESC: 'DESC', +} as const; + +export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]; + +/** + * A sort key on a single entity field — discriminated union on `order`. + */ +export type OrderSortKey = + | OrderSortKeyAsc + | OrderSortKeyDesc; + +/** + * Tuple shorthand for an order sort key: `[field, order]`. + */ +export type OrderSortKeyArr = + [EntityColumn, SortOrder]; + +/** + * An ordered list of sort keys — the ORDER BY clause. + */ +export type OrderClause = + OrderSortKey[]; diff --git a/packages/nestjs-repository/src/repository/where.helpers.ts b/packages/nestjs-repository/src/repository/where.helpers.ts new file mode 100644 index 000000000..f199984c3 --- /dev/null +++ b/packages/nestjs-repository/src/repository/where.helpers.ts @@ -0,0 +1,365 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { RuntimeException } from '@concepta/nestjs-core'; + +import { + type WhereClause, + type WhereCompound, + type WhereCondition, + type WhereConditionArray, + type WhereConditionNullary, + type WhereConditionPair, + type WhereConditionScalar, +} from './interfaces/where-clause.interface.js'; +import { + type EntityColumn, + WhereCompoundOperator, + WhereOperator, +} from './repository.types.js'; + +/** + * Where clause builder with both static and instance APIs. + * + * @example Static usage (pass Entity as generic per call): + * ```typescript + * repository.findOne(Where.where(Where.eq('id', userId))); + * repository.find(Where.where(Where.and(Where.eq('status', 'active'), Where.gt('age', 18)))); + * ``` + * + * @example Typed builder (Entity bound via factory): + * ```typescript + * const w = Where.for(); + * repository.find(w.where(w.and(w.eq('status', 'active'), w.gt('age', 18)))); + * ``` + */ +export class Where { + // ═══════════════════════════════════════════════════════════════════════════ + // Static API + // ═══════════════════════════════════════════════════════════════════════════ + + static eq( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return { field, operator: WhereOperator.EQ, value }; + } + + static ne( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return { field, operator: WhereOperator.NE, value }; + } + + static gt( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return { field, operator: WhereOperator.GT, value }; + } + + static gte( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return { field, operator: WhereOperator.GTE, value }; + } + + static lt( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return { field, operator: WhereOperator.LT, value }; + } + + static lte( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return { field, operator: WhereOperator.LTE, value }; + } + + static contains( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return { field, operator: WhereOperator.CONTAINS, value }; + } + + static notContains( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return { field, operator: WhereOperator.NCONTAINS, value }; + } + + static starts( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return { field, operator: WhereOperator.STARTS, value }; + } + + static notStarts( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return { field, operator: WhereOperator.NSTARTS, value }; + } + + static ends( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return { field, operator: WhereOperator.ENDS, value }; + } + + static notEnds( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return { field, operator: WhereOperator.NENDS, value }; + } + + static in( + field: EntityColumn, + value: unknown[], + ): WhereConditionArray { + return { field, operator: WhereOperator.IN, value }; + } + + static notIn( + field: EntityColumn, + value: unknown[], + ): WhereConditionArray { + return { field, operator: WhereOperator.NIN, value }; + } + + static isNull( + field: EntityColumn, + ): WhereConditionNullary { + return { field, operator: WhereOperator.IS_NULL }; + } + + static notNull( + field: EntityColumn, + ): WhereConditionNullary { + return { field, operator: WhereOperator.NOT_NULL }; + } + + static between( + field: EntityColumn, + from: unknown, + to: unknown, + ): WhereConditionPair { + return { field, operator: WhereOperator.BETWEEN, value: [from, to] }; + } + + static and(...conditions: WhereClause[]): WhereCompound { + return { operator: WhereCompoundOperator.AND, conditions }; + } + + static or(...conditions: WhereClause[]): WhereCompound { + return { operator: WhereCompoundOperator.OR, conditions }; + } + + static where(clause: WhereClause): { where: WhereClause } { + return { where: clause }; + } + + static for(): Where { + return new Where(); + } + + /** + * Tag a WhereCondition with a relation name. + * + * @example + * ```typescript + * Where.rel('tasks', Where.eq('status', 'active')) + * // => { field: 'status', operator: 'eq', value: 'active', relation: 'tasks' } + * ``` + */ + static rel< + E extends PlainLiteralObject = PlainLiteralObject, + C extends WhereCondition = WhereCondition, + >(relation: string, condition: C): C { + return { ...condition, relation }; + } + + /** + * Parse a dot-notation field and tag the condition with the extracted relation. + * + * @example + * ```typescript + * Where.relDot('blog.status', Where.eq('status', 'active')) + * // => { field: 'status', operator: 'eq', value: 'active', relation: 'blog' } + * ``` + */ + static relDot< + E extends PlainLiteralObject = PlainLiteralObject, + C extends WhereCondition = WhereCondition, + >(dotField: string, condition: C): C { + const parts = dotField.split('.'); + if (parts.length === 1) return condition; + if (parts.length !== 2 || !parts[0]) { + throw new RuntimeException({ + message: 'relDot expects "relation.field" dot notation, got "%s"', + messageParams: [ + String(dotField) + .replace(/[^\w.]/g, '') + .substring(0, 100), + ], + fault: 'usage', + }); + } + return { ...condition, relation: parts[0] }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Instance API (field names checked against Entity) + // ═══════════════════════════════════════════════════════════════════════════ + + eq( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return Where.eq(field, value); + } + + ne( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return Where.ne(field, value); + } + + gt( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return Where.gt(field, value); + } + + gte( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return Where.gte(field, value); + } + + lt( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return Where.lt(field, value); + } + + lte( + field: EntityColumn, + value: unknown, + ): WhereConditionScalar { + return Where.lte(field, value); + } + + contains( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return Where.contains(field, value); + } + + notContains( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return Where.notContains(field, value); + } + + starts( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return Where.starts(field, value); + } + + notStarts( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return Where.notStarts(field, value); + } + + ends( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return Where.ends(field, value); + } + + notEnds( + field: EntityColumn, + value: string, + ): WhereConditionScalar { + return Where.notEnds(field, value); + } + + in( + field: EntityColumn, + value: unknown[], + ): WhereConditionArray { + return Where.in(field, value); + } + + notIn( + field: EntityColumn, + value: unknown[], + ): WhereConditionArray { + return Where.notIn(field, value); + } + + isNull(field: EntityColumn): WhereConditionNullary { + return Where.isNull(field); + } + + notNull(field: EntityColumn): WhereConditionNullary { + return Where.notNull(field); + } + + between( + field: EntityColumn, + from: unknown, + to: unknown, + ): WhereConditionPair { + return Where.between(field, from, to); + } + + and(...conditions: WhereClause[]): WhereCompound { + return Where.and(...conditions); + } + + or(...conditions: WhereClause[]): WhereCompound { + return Where.or(...conditions); + } + + rel = WhereCondition>( + relation: string, + condition: C, + ): C { + return Where.rel(relation, condition); + } + + relDot = WhereCondition>( + dotField: string, + condition: C, + ): C { + return Where.relDot(dotField, condition); + } + + /** + * Wrap a WhereClause into a `{ where }` options object. + */ + where(clause: WhereClause): { where: WhereClause } { + return { where: clause }; + } +} diff --git a/packages/nestjs-repository/src/services/repository-registry.service.spec.ts b/packages/nestjs-repository/src/services/repository-registry.service.spec.ts new file mode 100644 index 000000000..7270face0 --- /dev/null +++ b/packages/nestjs-repository/src/services/repository-registry.service.spec.ts @@ -0,0 +1,139 @@ +import { Module, DynamicModule } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; + +import { RepositoryDuplicateKeyException } from '../exceptions/repository-duplicate-key.exception.js'; +import { RepositoryModule } from '../repository.module.js'; +import { getDynamicRepositoryToken } from '../utils/get-dynamic-repository-token.js'; + +import { + REPOSITORY_REGISTRY, + RepositoryRegistryService, +} from './repository-registry.service.js'; + +// Mock entity classes +class UserEntity {} +class OrderEntity {} +class DuplicateUserEntity {} + +// Mock repository module that provides adapter tokens +@Module({}) +class MockRepositoryModule { + static forFeature( + entities: { key: string; entity: { name: string } }[], + ): DynamicModule { + return { + module: MockRepositoryModule, + providers: entities.map((e) => ({ + provide: getDynamicRepositoryToken(e.key), + useValue: { entity: e.entity, entityName: () => e.entity.name }, + })), + exports: entities.map((e) => getDynamicRepositoryToken(e.key)), + }; + } +} + +describe('RepositoryRegistryService', () => { + it('should allow unique keys', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: MockRepositoryModule, + entities: [ + { key: 'users', entity: UserEntity }, + { key: 'orders', entity: OrderEntity }, + ], + }), + ], + }).compile(); + + const app = moduleRef.createNestApplication(); + + // Should not throw - unique keys are allowed + await expect(app.init()).resolves.not.toThrow(); + + await app.close(); + }); + + it('should throw on duplicate keys at bootstrap', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: MockRepositoryModule, + entities: [{ key: 'users', entity: UserEntity }], + }), + RepositoryModule.forFeature({ + module: MockRepositoryModule, + entities: [{ key: 'users', entity: DuplicateUserEntity }], + }), + ], + }).compile(); + + const app = moduleRef.createNestApplication(); + + await expect(app.init()).rejects.toThrow(RepositoryDuplicateKeyException); + + await app.close(); + }); + + it('should look up registry item by entity name after bootstrap', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: MockRepositoryModule, + entities: [ + { key: 'users', entity: UserEntity }, + { key: 'orders', entity: OrderEntity }, + ], + }), + ], + }).compile(); + + const app = moduleRef.createNestApplication(); + await app.init(); + + const registry = + moduleRef.get(REPOSITORY_REGISTRY); + + const userItem = registry.getByEntityName('UserEntity'); + expect(userItem).toEqual({ + key: 'users', + entityName: 'UserEntity', + moduleName: 'MockRepositoryModule', + }); + + const orderItem = registry.getByEntityName('OrderEntity'); + expect(orderItem).toEqual({ + key: 'orders', + entityName: 'OrderEntity', + moduleName: 'MockRepositoryModule', + }); + + expect(registry.getByEntityName('NonExistent')).toBeUndefined(); + + await app.close(); + }); + + it('should isolate registrations between test runs', async () => { + // This test verifies that static state doesn't leak + // by registering the same key that was used in the first test + const moduleRef = await Test.createTestingModule({ + imports: [ + RepositoryModule.forRoot({}), + RepositoryModule.forFeature({ + module: MockRepositoryModule, + entities: [{ key: 'users', entity: UserEntity }], + }), + ], + }).compile(); + + const app = moduleRef.createNestApplication(); + + // Should not throw - each test gets fresh registry + await expect(app.init()).resolves.not.toThrow(); + + await app.close(); + }); +}); diff --git a/packages/nestjs-repository/src/services/repository-registry.service.ts b/packages/nestjs-repository/src/services/repository-registry.service.ts new file mode 100644 index 000000000..c6284901d --- /dev/null +++ b/packages/nestjs-repository/src/services/repository-registry.service.ts @@ -0,0 +1,75 @@ +import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; + +import { RepositoryDuplicateKeyException } from '../exceptions/repository-duplicate-key.exception.js'; +import { RepositoryRegistryItem } from '../interfaces/repository-registry-item.interface.js'; + +export { RepositoryRegistryItem }; + +export const REPOSITORY_REGISTRY = Symbol('RepositoryRegistry'); + +/** + * Registry for tracking repository registrations. + * + * Validates for duplicate keys at application bootstrap. + */ +@Injectable() +export class RepositoryRegistryService implements OnApplicationBootstrap { + private readonly registry = new Map< + string, + Readonly + >(); + private readonly entityIndex = new Map< + string, + Readonly + >(); + private readonly pending: Readonly[] = []; + + /** + * Queue an item for validation at bootstrap. + */ + register(item: RepositoryRegistryItem): void { + this.pending.push(Object.freeze({ ...item })); + } + + /** + * Look up a registry item by entity name. + * + * Available after application bootstrap. + */ + getByEntityName( + entityName: string, + ): Readonly | undefined { + return this.entityIndex.get(entityName); + } + + /** + * Validate all pending items at application bootstrap. + * Throws if duplicate keys are found. + */ + onApplicationBootstrap(): void { + const duplicates: { key: string; existing: string; attempted: string }[] = + []; + + for (const item of this.pending) { + const existing = this.registry.get(item.key); + + if (existing) { + duplicates.push({ + key: item.key, + existing: existing.entityName, + attempted: item.entityName, + }); + } else { + this.registry.set(item.key, item); + this.entityIndex.set(item.entityName, item); + } + } + + // Clear pending after processing + this.pending.length = 0; + + if (duplicates.length > 0) { + throw new RepositoryDuplicateKeyException(duplicates); + } + } +} diff --git a/packages/nestjs-repository/src/testing.ts b/packages/nestjs-repository/src/testing.ts new file mode 100644 index 000000000..98e467ccc --- /dev/null +++ b/packages/nestjs-repository/src/testing.ts @@ -0,0 +1,6 @@ +export { + MockTransactionHandle, + createMockTransaction, +} from './testing/create-mock-transaction.js'; + +export { createMockRepository } from './testing/create-mock-repository.js'; diff --git a/packages/nestjs-repository/src/testing/create-mock-repository.ts b/packages/nestjs-repository/src/testing/create-mock-repository.ts new file mode 100644 index 000000000..40bc5ef49 --- /dev/null +++ b/packages/nestjs-repository/src/testing/create-mock-repository.ts @@ -0,0 +1,44 @@ +import { vi, type Mocked } from 'vitest'; + +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type RepositoryInterface } from '../repository/interfaces/repository.interface.js'; + +/** + * Create a Vitest-mocked RepositoryInterface for unit testing. + * + * All methods are `vi.fn()` stubs. Override individual mocks + * as needed in your test setup. + * + * @param metadataOverrides - Optional overrides for repository metadata + */ +export function createMockRepository< + Entity extends PlainLiteralObject = PlainLiteralObject, +>( + metadataOverrides: Partial['metadata']> = {}, +): Mocked> { + return { + metadata: { + name: 'MockEntity', + type: class {} as never, + columns: [], + ...metadataOverrides, + }, + find: vi.fn(), + findOne: vi.fn(), + count: vi.fn(), + findAndCount: vi.fn(), + create: vi.fn(), + createMany: vi.fn(), + update: vi.fn(), + upsert: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + deleteMany: vi.fn(), + softDelete: vi.fn(), + restore: vi.fn(), + transform: vi.fn(), + merge: vi.fn(), + prepare: vi.fn(), + }; +} diff --git a/packages/nestjs-repository/src/testing/create-mock-transaction.ts b/packages/nestjs-repository/src/testing/create-mock-transaction.ts new file mode 100644 index 000000000..eeb0d8048 --- /dev/null +++ b/packages/nestjs-repository/src/testing/create-mock-transaction.ts @@ -0,0 +1,43 @@ +import { vi, type Mock } from 'vitest'; +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { AppContextHost } from '@concepta/nestjs-core'; + +import { + type TransactionContextInterface, + TrxCtx, +} from '../transaction/interfaces/transaction-context.interface.js'; +import { type TransactionScope } from '../transaction/transaction-scope.js'; + +export interface MockTransactionHandle { + onCommit: Mock; + onRollback: Mock; +} + +/** + * Create a mock TransactionScope for unit testing. + * + * The `run` mock immediately invokes the callback with a mock + * `TransactionContextInterface` backed by a real `AppContextHost` + * so that nested `AppContextHost.from()` calls work correctly. + */ +export function createMockTransaction(): { + transaction: DeepMockProxy; + trxHandle: MockTransactionHandle; +} { + const trxHandle: MockTransactionHandle = { + onCommit: vi.fn(), + onRollback: vi.fn(), + }; + + const mockHost = new AppContextHost(); + mockHost.defineOverlay(TrxCtx, { + trx: trxHandle, + } as unknown as TransactionContextInterface); + const mockTxCtx = mockHost.with(TrxCtx); + + const transaction = mockDeep(); + transaction.run.mockImplementation((_ctx, fn) => fn(mockTxCtx)); + + return { transaction, trxHandle }; +} diff --git a/packages/nestjs-repository/src/transaction/interfaces/transaction-context.interface.ts b/packages/nestjs-repository/src/transaction/interfaces/transaction-context.interface.ts new file mode 100644 index 000000000..132e04ca6 --- /dev/null +++ b/packages/nestjs-repository/src/transaction/interfaces/transaction-context.interface.ts @@ -0,0 +1,19 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { OverlayRef } from '@concepta/nestjs-core'; + +import { type TransactionManager } from '../transaction-manager.js'; + +/** + * Context interface for the transaction overlay. + * + * Returned by the `withTrx()` overlay method. Provides access + * to the {@link TransactionManager} for the current scope. + */ +export interface TransactionContextInterface extends PlainLiteralObject { + trx: TransactionManager; +} + +export const TrxCtx = new OverlayRef<'withTrx', TransactionContextInterface>( + 'withTrx', +); diff --git a/packages/nestjs-repository/src/transaction/interfaces/transaction-manager.interface.ts b/packages/nestjs-repository/src/transaction/interfaces/transaction-manager.interface.ts new file mode 100644 index 000000000..9c0fa8edf --- /dev/null +++ b/packages/nestjs-repository/src/transaction/interfaces/transaction-manager.interface.ts @@ -0,0 +1,123 @@ +import { type TransactionInterface } from './transaction.interface.js'; + +/** + * Manages the transactions (one per driver:datasource key) that belong to + * a single {@link TransactionScope.run} scope, plus that scope's own + * lifecycle (entry/exit refcount, settled state). + */ +export interface TransactionManagerInterface { + /** + * Whether real transaction support is available (factories registered). + */ + readonly isSupported: boolean; + + /** + * Whether this scope was opened with `readOnly: true`. + */ + readonly isReadOnly: boolean; + + /** + * Whether the scope has settled (committed or rolled back) and can no + * longer be used. + */ + readonly isClosed: boolean; + + /** + * Whether the scope's operation has thrown. + */ + readonly hasFailed: boolean; + + /** + * Aborts once the scope is doomed — a participant's operation threw, or + * the final commit failed — carrying that failure as `signal.reason`. + * Stays unaborted for a scope that settles successfully. Cooperative: + * nothing in this library forcibly stops an operation that ignores it. + */ + readonly signal: AbortSignal; + + /** + * Mark that a `run()` call has entered this scope. Returns the resulting + * depth. Throws `TransactionClosedException` once the scope is closed — + * a stale handle re-entering a settled scope must fail loudly rather + * than refcount and eventually re-settle it. + */ + enter(): number; + + /** + * Mark that a `run()` call has exited this scope. Returns the resulting + * depth — the scope should settle when this reaches 0. + */ + exit(): number; + + /** + * Mark the scope's operation as having thrown, and abort `signal` with + * `reason`. If `reason` is not given, `signal.reason` is not `undefined` + * — `AbortController.abort()` installs a synthetic `AbortError` instead, + * per the platform `AbortSignal` contract. Idempotent — the first reason + * wins. + */ + markFailed(reason?: unknown): void; + + /** + * Close the scope. Once closed, `getOrStart`, `enter`, `onCommit` and + * `onRollback` all throw `TransactionClosedException` — the scope is + * inert from this point on, including for a still-running orphaned + * operation that outlived a timeout. + */ + close(): void; + + /** + * Commit all active transactions, sequentially, stopping at the first + * failure. Transactions that haven't committed yet at that point are + * rolled back rather than abandoned. Throws the raw underlying error when + * nothing had committed yet — a clean, atomic rollback, regardless of how + * many datasources were involved — or `TransactionHeuristicCommitException` + * once at least one datasource has already committed, since that commit + * can't be undone without real 2PC, leaving an inherently mixed outcome. + */ + commitAll(): Promise; + + /** + * Rollback all active transactions. Every one is attempted even if an + * earlier one fails — a failure is logged, not thrown, so it never + * abandons the rest or replaces an error the caller is already handling. + */ + rollbackAll(): Promise; + + /** + * Get the current transaction for the given key, or create one lazily + * via the factory registry if none exists. Throws once the scope is + * closed. + */ + getOrStart(key: string): Promise; + + /** + * Register a callback to run after all transactions commit successfully. + * Throws `TransactionClosedException` once the scope is closed, rather + * than silently dropping a registration nothing will ever flush. + */ + onCommit(fn: () => void | Promise): void; + + /** + * Register a callback to run after transactions are rolled back. A + * `readOnly` scope always rolls back, so these run whether or not its + * operation succeeded. Throws `TransactionClosedException` once the + * scope is closed, rather than silently dropping a registration nothing + * will ever flush. + */ + onRollback(fn: () => void | Promise): void; + + /** + * Execute and clear all onCommit callbacks, one at a time in + * registration order — not concurrently. A rejection is logged, not + * thrown, and doesn't stop the callbacks after it from running. + */ + flushOnCommitCallbacks(): Promise; + + /** + * Execute and clear all onRollback callbacks, one at a time in + * registration order — not concurrently. A rejection is logged, not + * thrown, and doesn't stop the callbacks after it from running. + */ + flushOnRollbackCallbacks(): Promise; +} diff --git a/packages/nestjs-repository/src/transaction/interfaces/transaction.interface.ts b/packages/nestjs-repository/src/transaction/interfaces/transaction.interface.ts new file mode 100644 index 000000000..acece2966 --- /dev/null +++ b/packages/nestjs-repository/src/transaction/interfaces/transaction.interface.ts @@ -0,0 +1,10 @@ +/** + * A single transaction - manages lifecycle for one driver/datasource/run scope + */ +export interface TransactionInterface { + readonly isActive: boolean; + start(): Promise; + commit(): Promise; + rollback(): Promise; + getClient(): T; +} diff --git a/packages/nestjs-repository/src/transaction/transaction-factory-registry.spec.ts b/packages/nestjs-repository/src/transaction/transaction-factory-registry.spec.ts new file mode 100644 index 000000000..c9cc6b2d8 --- /dev/null +++ b/packages/nestjs-repository/src/transaction/transaction-factory-registry.spec.ts @@ -0,0 +1,59 @@ +import { type TransactionFactoryInterface } from '../interfaces/transaction-factory.interface.js'; + +import { type TransactionInterface } from './interfaces/transaction.interface.js'; +import { TransactionFactoryRegistry } from './transaction-factory-registry.js'; + +describe(TransactionFactoryRegistry.name, () => { + let registry: TransactionFactoryRegistry; + let mockFactory: TransactionFactoryInterface; + let mockTransaction: TransactionInterface; + + beforeEach(() => { + registry = new TransactionFactoryRegistry(); + + mockTransaction = { + isActive: false, + start: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), + getClient: vi.fn(), + }; + + mockFactory = { + create: vi.fn().mockReturnValue(mockTransaction), + }; + }); + + describe('register', () => { + it('should register a factory', () => { + registry.register('typeorm:default', mockFactory); + expect(registry.get('typeorm:default')).toBe(mockFactory); + }); + + it('should skip if key already exists', () => { + const secondFactory: TransactionFactoryInterface = { + create: vi.fn(), + }; + + registry.register('typeorm:default', mockFactory); + registry.register('typeorm:default', secondFactory); + + // Should still have the first factory + const retrieved = registry.get('typeorm:default'); + expect(retrieved).toBe(mockFactory); + }); + }); + + describe('get', () => { + it('should return factory for key', () => { + registry.register('typeorm:default', mockFactory); + const retrieved = registry.get('typeorm:default'); + expect(retrieved).toBe(mockFactory); + }); + + it('should return undefined for unknown key', () => { + const retrieved = registry.get('unknown:key'); + expect(retrieved).toBeUndefined(); + }); + }); +}); diff --git a/packages/nestjs-repository/src/transaction/transaction-factory-registry.ts b/packages/nestjs-repository/src/transaction/transaction-factory-registry.ts new file mode 100644 index 000000000..a5df2b065 --- /dev/null +++ b/packages/nestjs-repository/src/transaction/transaction-factory-registry.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; + +import { TransactionFactoryInterface } from '../interfaces/transaction-factory.interface.js'; + +export const TRANSACTION_FACTORY_REGISTRY = Symbol( + 'TransactionFactoryRegistry', +); + +/** + * Registry for transaction factories. + * Each repository module registers its factory keyed by "driver:datasource". + */ +@Injectable() +export class TransactionFactoryRegistry { + private readonly factories = new Map(); + + register(key: string, factory: TransactionFactoryInterface): void { + if (!this.factories.has(key)) { + this.factories.set(key, factory); + } + } + + get(key: string): TransactionFactoryInterface | undefined { + return this.factories.get(key); + } + + get count(): number { + return this.factories.size; + } +} diff --git a/packages/nestjs-repository/src/transaction/transaction-manager.spec.ts b/packages/nestjs-repository/src/transaction/transaction-manager.spec.ts new file mode 100644 index 000000000..88a91d63f --- /dev/null +++ b/packages/nestjs-repository/src/transaction/transaction-manager.spec.ts @@ -0,0 +1,591 @@ +import { type Mock } from 'vitest'; + +import { TransactionClosedException } from '../exceptions/transaction-closed.exception.js'; +import { TransactionHeuristicCommitException } from '../exceptions/transaction-heuristic-commit.exception.js'; + +import { type TransactionInterface } from './interfaces/transaction.interface.js'; +import { TransactionFactoryRegistry } from './transaction-factory-registry.js'; +import { TransactionManager } from './transaction-manager.js'; + +describe(TransactionManager.name, () => { + let manager: TransactionManager; + let registry: TransactionFactoryRegistry; + + const createMockTransaction = ( + overrides: Partial<{ + isActive: boolean; + start: Mock; + commit: Mock; + rollback: Mock; + getClient: Mock; + }> = {}, + ): TransactionInterface => ({ + isActive: false, + start: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), + getClient: vi.fn(), + ...overrides, + }); + + const seed = async ( + manager: TransactionManager, + registry: TransactionFactoryRegistry, + key: string, + tx: TransactionInterface, + ): Promise => { + registry.register(key, { create: () => tx }); + await manager.getOrStart(key); + }; + + beforeEach(() => { + registry = new TransactionFactoryRegistry(); + manager = new TransactionManager(registry); + }); + + describe('getOrStart', () => { + it('should create and start transaction lazily via factory', async () => { + const newTx = createMockTransaction(); + registry.register('typeorm:default', { create: () => newTx }); + + const result = await manager.getOrStart('typeorm:default'); + + expect(result).toBe(newTx); + expect(newTx.start).toHaveBeenCalledTimes(1); + }); + + it('should store lazily created transaction for subsequent gets', async () => { + const newTx = createMockTransaction(); + registry.register('typeorm:default', { create: () => newTx }); + + await manager.getOrStart('typeorm:default'); + const second = await manager.getOrStart('typeorm:default'); + + expect(second).toBe(newTx); + expect(newTx.start).toHaveBeenCalledTimes(1); + }); + + it('should throw when no factory registered for key', async () => { + await expect(manager.getOrStart('unknown:key')).rejects.toThrow( + 'No transaction factory registered for key "unknown:key"', + ); + }); + + it('should throw TransactionClosedException once the scope is closed', async () => { + manager.close(); + + await expect(manager.getOrStart('typeorm:default')).rejects.toThrow( + TransactionClosedException, + ); + }); + }); + + describe('concurrent calls to getOrStart for the same key', () => { + /** + * Registers a factory whose `start()` is gated on a manually-released + * promise, then fires two `getOrStart` calls back-to-back without + * awaiting either — this pins the interleaving exactly, rather than + * relying on timers, so the race is deterministic. + */ + const raceGetOrStart = (key: string) => { + const created: TransactionInterface[] = []; + let releaseStart: () => void = () => {}; + const gate = new Promise((resolve) => { + releaseStart = resolve; + }); + + registry.register(key, { + create: () => { + const tx: TransactionInterface = { + isActive: true, + start: vi.fn().mockImplementation(async () => { + await gate; + }), + commit: vi.fn(), + rollback: vi.fn(), + getClient: vi.fn(), + }; + created.push(tx); + return tx; + }, + }); + + const first = manager.getOrStart(key); + const second = manager.getOrStart(key); + + return { created, first, second, releaseStart }; + }; + + it('should create only one transaction when two calls race before start() resolves', async () => { + const { created, first, second, releaseStart } = + raceGetOrStart('typeorm:default'); + + releaseStart(); + const [a, b] = await Promise.all([first, second]); + + expect(created).toHaveLength(1); + expect(a).toBe(b); + }); + + it('should settle the shared transaction once', async () => { + const { created, first, second, releaseStart } = + raceGetOrStart('typeorm:default'); + + releaseStart(); + await Promise.all([first, second]); + + await manager.commitAll(); + + expect(created[0].commit).toHaveBeenCalledTimes(1); + }); + + it('should skip a key whose start() rejected, in both commitAll and rollbackAll', async () => { + registry.register('typeorm:broken', { + create: () => ({ + isActive: false, + start: vi.fn().mockRejectedValue(new Error('connect failed')), + commit: vi.fn(), + rollback: vi.fn(), + getClient: vi.fn(), + }), + }); + + await expect(manager.getOrStart('typeorm:broken')).rejects.toThrow( + 'connect failed', + ); + + await expect(manager.commitAll()).resolves.toBeUndefined(); + await expect(manager.rollbackAll()).resolves.toBeUndefined(); + }); + }); + + describe('lifecycle state', () => { + it('should default to not read-only, not closed, not failed', () => { + expect(manager.isReadOnly).toBe(false); + expect(manager.isClosed).toBe(false); + expect(manager.hasFailed).toBe(false); + }); + + it('should carry the readOnly flag passed at construction', () => { + const readOnlyManager = new TransactionManager(registry, true); + expect(readOnlyManager.isReadOnly).toBe(true); + }); + + it('should track enter/exit depth', () => { + expect(manager.enter()).toBe(1); + expect(manager.enter()).toBe(2); + expect(manager.exit()).toBe(1); + expect(manager.exit()).toBe(0); + }); + + it('should record markFailed', () => { + expect(manager.hasFailed).toBe(false); + manager.markFailed(); + expect(manager.hasFailed).toBe(true); + }); + + it('should not abort the signal until markFailed is called', () => { + expect(manager.signal.aborted).toBe(false); + }); + + it('should abort the signal with the given reason on markFailed', () => { + const reason = new Error('doomed'); + manager.markFailed(reason); + expect(manager.signal.aborted).toBe(true); + expect(manager.signal.reason).toBe(reason); + }); + + it('should keep the first reason when markFailed is called more than once', () => { + const first = new Error('first'); + const second = new Error('second'); + manager.markFailed(first); + manager.markFailed(second); + expect(manager.signal.reason).toBe(first); + }); + + it('should record close', () => { + expect(manager.isClosed).toBe(false); + manager.close(); + expect(manager.isClosed).toBe(true); + }); + + it('should throw TransactionClosedException from enter() once closed', () => { + manager.close(); + expect(() => manager.enter()).toThrow(TransactionClosedException); + }); + }); + + describe('commitAll', () => { + it('should commit active transactions, dirtied or not', async () => { + const tx = createMockTransaction({ isActive: true }); + await seed(manager, registry, 'typeorm:default', tx); + + await manager.commitAll(); + + expect(tx.commit).toHaveBeenCalledTimes(1); + expect(tx.rollback).not.toHaveBeenCalled(); + }); + + it('should skip inactive transactions', async () => { + const inactiveTx = createMockTransaction({ isActive: false }); + await seed(manager, registry, 'typeorm:default', inactiveTx); + + await manager.commitAll(); + + expect(inactiveTx.commit).not.toHaveBeenCalled(); + expect(inactiveTx.rollback).not.toHaveBeenCalled(); + }); + + it('should handle multiple transactions', async () => { + const activeTx = createMockTransaction({ isActive: true }); + const otherActiveTx = createMockTransaction({ isActive: true }); + const inactiveTx = createMockTransaction({ isActive: false }); + + await seed(manager, registry, 'typeorm:default', activeTx); + await seed(manager, registry, 'mongoose:default', otherActiveTx); + await seed(manager, registry, 'prisma:default', inactiveTx); + + await manager.commitAll(); + + expect(activeTx.commit).toHaveBeenCalledTimes(1); + expect(otherActiveTx.commit).toHaveBeenCalledTimes(1); + expect(inactiveTx.commit).not.toHaveBeenCalled(); + expect(inactiveTx.rollback).not.toHaveBeenCalled(); + }); + + it('should roll back — rather than abandon — a transaction that comes after one whose commit fails', async () => { + const firstTx = createMockTransaction({ isActive: true }); + const failingTx = createMockTransaction({ + isActive: true, + commit: vi.fn().mockRejectedValue(new Error('commit failed')), + }); + const abandonedTx = createMockTransaction({ isActive: true }); + + await seed(manager, registry, 'typeorm:first', firstTx); + await seed(manager, registry, 'typeorm:failing', failingTx); + await seed(manager, registry, 'typeorm:abandoned', abandonedTx); + + await expect(manager.commitAll()).rejects.toThrow(); + + expect(firstTx.commit).toHaveBeenCalledTimes(1); + expect(failingTx.commit).toHaveBeenCalledTimes(1); + expect(abandonedTx.commit).not.toHaveBeenCalled(); + expect(abandonedTx.rollback).toHaveBeenCalledTimes(1); + }); + + it('should reject with the original error when only one transaction is active', async () => { + const originalError = new Error('commit failed'); + const failingTx = createMockTransaction({ + isActive: true, + commit: vi.fn().mockRejectedValue(originalError), + }); + + await seed(manager, registry, 'typeorm:default', failingTx); + + await expect(manager.commitAll()).rejects.toBe(originalError); + }); + + it('should reject with the original error, not a heuristic exception, when multiple datasources are involved but none committed', async () => { + const originalError = new Error('commit failed'); + const failingTx = createMockTransaction({ + isActive: true, + commit: vi.fn().mockRejectedValue(originalError), + }); + const neverAttemptedTx = createMockTransaction({ isActive: true }); + + await seed(manager, registry, 'typeorm:failing', failingTx); + await seed(manager, registry, 'typeorm:neverAttempted', neverAttemptedTx); + + // Nothing committed and both were rolled back — a clean, atomic + // outcome, not a mixed one. The real error should surface directly + // rather than be buried in a heuristic exception's originalError. + await expect(manager.commitAll()).rejects.toBe(originalError); + }); + + it('should reject with TransactionHeuristicCommitException when a later datasource fails after an earlier one already committed', async () => { + const originalError = new Error('commit failed'); + const committedTx = createMockTransaction({ isActive: true }); + const failingTx = createMockTransaction({ + isActive: true, + commit: vi.fn().mockRejectedValue(originalError), + }); + + await seed(manager, registry, 'typeorm:committed', committedTx); + await seed(manager, registry, 'typeorm:failing', failingTx); + + let caught: unknown; + try { + await manager.commitAll(); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(TransactionHeuristicCommitException); + const exception = caught as TransactionHeuristicCommitException; + expect(exception.context.originalError?.message).toBe( + originalError.message, + ); + expect(exception.message).toContain('1'); + expect(committedTx.commit).toHaveBeenCalledTimes(1); + expect(committedTx.rollback).not.toHaveBeenCalled(); + }); + }); + + describe('rollbackAll', () => { + it('should rollback active transactions', async () => { + const activeTx = createMockTransaction({ isActive: true }); + await seed(manager, registry, 'typeorm:default', activeTx); + + await manager.rollbackAll(); + + expect(activeTx.rollback).toHaveBeenCalledTimes(1); + }); + + it('should skip inactive transactions', async () => { + const inactiveTx = createMockTransaction({ isActive: false }); + await seed(manager, registry, 'typeorm:default', inactiveTx); + + await manager.rollbackAll(); + + expect(inactiveTx.rollback).not.toHaveBeenCalled(); + }); + + it('should handle multiple transactions', async () => { + const activeTx1 = createMockTransaction({ isActive: true }); + const activeTx2 = createMockTransaction({ isActive: true }); + const inactiveTx = createMockTransaction({ isActive: false }); + + await seed(manager, registry, 'typeorm:default', activeTx1); + await seed(manager, registry, 'mongoose:default', activeTx2); + await seed(manager, registry, 'prisma:default', inactiveTx); + + await manager.rollbackAll(); + + expect(activeTx1.rollback).toHaveBeenCalledTimes(1); + expect(activeTx2.rollback).toHaveBeenCalledTimes(1); + expect(inactiveTx.rollback).not.toHaveBeenCalled(); + }); + + it('should still roll back the other transactions when one rollback fails', async () => { + const failingTx = createMockTransaction({ + isActive: true, + rollback: vi.fn().mockRejectedValue(new Error('rollback failed')), + }); + const otherTx = createMockTransaction({ isActive: true }); + + await seed(manager, registry, 'typeorm:failing', failingTx); + await seed(manager, registry, 'typeorm:other', otherTx); + + await manager.rollbackAll(); + + expect(otherTx.rollback).toHaveBeenCalledTimes(1); + }); + + it('should never reject, even when a rollback fails', async () => { + const failingTx = createMockTransaction({ + isActive: true, + rollback: vi.fn().mockRejectedValue(new Error('rollback failed')), + }); + await seed(manager, registry, 'typeorm:default', failingTx); + + await expect(manager.rollbackAll()).resolves.toBeUndefined(); + }); + + it('should never reject, even when a rollback fails with a non-Error, non-string-coercible reason', async () => { + const failingTx = createMockTransaction({ + isActive: true, + rollback: vi.fn().mockRejectedValue(Object.create(null)), + }); + await seed(manager, registry, 'typeorm:default', failingTx); + + await expect(manager.rollbackAll()).resolves.toBeUndefined(); + }); + + it('should never reject, even when a rollback fails with a Symbol reason', async () => { + const failingTx = createMockTransaction({ + isActive: true, + rollback: vi.fn().mockRejectedValue(Symbol('boom')), + }); + await seed(manager, registry, 'typeorm:default', failingTx); + + await expect(manager.rollbackAll()).resolves.toBeUndefined(); + }); + }); + + describe('onCommit / flushOnCommitCallbacks', () => { + it('should execute callbacks in order on flush', async () => { + const order: number[] = []; + manager.onCommit(() => { + order.push(1); + }); + manager.onCommit(() => { + order.push(2); + }); + manager.onCommit(() => { + order.push(3); + }); + + await manager.flushOnCommitCallbacks(); + + expect(order).toEqual([1, 2, 3]); + }); + + it('should run async callbacks sequentially, in registration order, not concurrently', async () => { + const order: number[] = []; + manager.onCommit(async () => { + // Slower than the others — a concurrent flush would let callbacks + // 2 and 3 finish first, since they never yield. + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push(1); + }); + manager.onCommit(async () => { + order.push(2); + }); + manager.onCommit(async () => { + order.push(3); + }); + + await manager.flushOnCommitCallbacks(); + + expect(order).toEqual([1, 2, 3]); + }); + + it('should clear callbacks after flush', async () => { + const fn = vi.fn(); + manager.onCommit(fn); + + await manager.flushOnCommitCallbacks(); + await manager.flushOnCommitCallbacks(); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should not execute callbacks until flushed', () => { + const fn = vi.fn(); + manager.onCommit(fn); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('should not reject and should still run other callbacks when one rejects with undefined', async () => { + const fn = vi.fn(); + manager.onCommit(async () => { + throw undefined; + }); + manager.onCommit(fn); + + await expect(manager.flushOnCommitCallbacks()).resolves.toBeUndefined(); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should not reject when a callback rejects with null', async () => { + manager.onCommit(async () => { + throw null; + }); + + await expect(manager.flushOnCommitCallbacks()).resolves.toBeUndefined(); + }); + + it('should not reject when a callback rejects with a non-Error, non-string-coercible reason', async () => { + const fn = vi.fn(); + manager.onCommit(async () => { + throw Object.create(null); + }); + manager.onCommit(fn); + + await expect(manager.flushOnCommitCallbacks()).resolves.toBeUndefined(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should not reject when a callback rejects with a Symbol', async () => { + manager.onCommit(async () => { + throw Symbol('boom'); + }); + + await expect(manager.flushOnCommitCallbacks()).resolves.toBeUndefined(); + }); + + it('should throw TransactionClosedException rather than silently drop a registration after close()', () => { + manager.close(); + expect(() => manager.onCommit(() => {})).toThrow( + TransactionClosedException, + ); + }); + }); + + describe('onRollback / flushOnRollbackCallbacks', () => { + it('should execute callbacks in order on flush', async () => { + const order: number[] = []; + manager.onRollback(() => { + order.push(1); + }); + manager.onRollback(() => { + order.push(2); + }); + manager.onRollback(() => { + order.push(3); + }); + + await manager.flushOnRollbackCallbacks(); + + expect(order).toEqual([1, 2, 3]); + }); + + it('should run async callbacks sequentially, in registration order, not concurrently', async () => { + const order: number[] = []; + manager.onRollback(async () => { + // Slower than the others — a concurrent flush would let callbacks + // 2 and 3 finish first, since they never yield. + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push(1); + }); + manager.onRollback(async () => { + order.push(2); + }); + manager.onRollback(async () => { + order.push(3); + }); + + await manager.flushOnRollbackCallbacks(); + + expect(order).toEqual([1, 2, 3]); + }); + + it('should clear callbacks after flush', async () => { + const fn = vi.fn(); + manager.onRollback(fn); + + await manager.flushOnRollbackCallbacks(); + await manager.flushOnRollbackCallbacks(); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should not execute callbacks until flushed', () => { + const fn = vi.fn(); + manager.onRollback(fn); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('should not reject and should still run other callbacks when one rejects with undefined', async () => { + const fn = vi.fn(); + manager.onRollback(async () => { + throw undefined; + }); + manager.onRollback(fn); + + await expect(manager.flushOnRollbackCallbacks()).resolves.toBeUndefined(); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should throw TransactionClosedException rather than silently drop a registration after close()', () => { + manager.close(); + expect(() => manager.onRollback(() => {})).toThrow( + TransactionClosedException, + ); + }); + }); +}); diff --git a/packages/nestjs-repository/src/transaction/transaction-manager.ts b/packages/nestjs-repository/src/transaction/transaction-manager.ts new file mode 100644 index 000000000..6d1c75994 --- /dev/null +++ b/packages/nestjs-repository/src/transaction/transaction-manager.ts @@ -0,0 +1,307 @@ +import { Logger } from '@nestjs/common'; + +import { AppContextHost } from '@concepta/nestjs-core'; + +import { TransactionClosedException } from '../exceptions/transaction-closed.exception.js'; +import { TransactionHeuristicCommitException } from '../exceptions/transaction-heuristic-commit.exception.js'; +import { type TransactionFactoryInterface } from '../interfaces/transaction-factory.interface.js'; + +import { type TransactionManagerInterface } from './interfaces/transaction-manager.interface.js'; +import { type TransactionInterface } from './interfaces/transaction.interface.js'; +import { type TransactionFactoryRegistry } from './transaction-factory-registry.js'; + +/** + * Runtime manager holding the transactions (one per driver:datasource key) + * for a single {@link TransactionScope.run} scope, lazy transaction + * creation via factory registry, and post-commit/rollback callbacks. + * + * Also owns that scope's lifecycle: `enter()`/`exit()` refcount concurrent + * `run()` calls sharing the same scope, and `close()` permanently closes it + * once settled so a stale handle fails loudly via `getOrStart` instead of + * silently falling through to non-transactional access. + */ +export class TransactionManager implements TransactionManagerInterface { + private readonly transactions = new Map< + string, + Promise + >(); + private readonly commitCallbacks: (() => void | Promise)[] = []; + private readonly rollbackCallbacks: (() => void | Promise)[] = []; + private readonly abortController = new AbortController(); + private depth = 0; + private closed = false; + private failed = false; + + constructor( + private readonly registry: TransactionFactoryRegistry, + private readonly readOnly: boolean = false, + private readonly scopeHost: AppContextHost = new AppContextHost(), + ) {} + + get isSupported(): boolean { + return this.registry.count > 0; + } + + get isReadOnly(): boolean { + return this.readOnly; + } + + get isClosed(): boolean { + return this.closed; + } + + get hasFailed(): boolean { + return this.failed; + } + + /** + * Aborts once the scope is doomed — a participant's operation threw, or + * `settle()`'s commit failed — carrying that failure as `signal.reason`. + * Stays unaborted for a scope that settles successfully. Cooperative: + * nothing in this library forcibly stops an operation that ignores it. + */ + get signal(): AbortSignal { + return this.abortController.signal; + } + + /** + * The `AppContextHost` that created this scope — the host `run()` first + * saw `!supports(TrxCtx)` on, as opposed to a joining participant's own + * run-scoped child. `settle()` releases `TrxCtx` from this host, not from + * whichever participant happened to exit last, since exit order need not + * match creation order (e.g. an outer participant that times out exits + * before a still-running nested one). + */ + get host(): AppContextHost { + return this.scopeHost; + } + + enter(): number { + if (this.closed) { + throw new TransactionClosedException(); + } + + return ++this.depth; + } + + exit(): number { + return --this.depth; + } + + markFailed(reason?: unknown): void { + this.failed = true; + this.abortController.abort(reason); + } + + close(): void { + this.closed = true; + } + + /** + * Get the current transaction for the given key, or create one lazily + * via the factory registry if none exists. + * + * The in-flight promise — not the resolved transaction — is cached, and + * the cache write happens before anything is awaited. That keeps two + * concurrent calls for the same key from interleaving: whichever runs + * first creates and starts the transaction, and the second sees the + * cached promise and joins it instead of starting a rival one. + */ + async getOrStart(key: string): Promise { + if (this.closed) { + throw new TransactionClosedException(); + } + + const existing = this.transactions.get(key); + if (existing) { + return existing; + } + + const factory = this.registry.get(key); + if (!factory) { + throw new Error(`No transaction factory registered for key "${key}"`); + } + + const pending = this.startTransaction(factory); + this.transactions.set(key, pending); + + return pending; + } + + private async startTransaction( + factory: TransactionFactoryInterface, + ): Promise { + const tx = factory.create(); + await tx.start(); + return tx; + } + + /** + * Commit all active transactions, sequentially, stopping at the first + * failure. Whichever transactions haven't committed yet when that + * happens — the failed one and everything after it — are rolled back + * instead of left dangling. Throws the raw underlying error when nothing + * had committed yet — rolling everything back is then a clean, atomic + * outcome, whether one datasource was involved or several — or + * {@link TransactionHeuristicCommitException} once at least one + * datasource has already committed, since that earlier commit can't be + * undone without real two-phase commit, leaving an inherently mixed + * ("heuristic") outcome across datasources. + */ + async commitAll(): Promise { + const active = (await this.startedTransactions()).filter( + (tx) => tx.isActive, + ); + + let committedCount = 0; + let originalError: unknown; + + for (const tx of active) { + try { + await tx.commit(); + committedCount++; + } catch (error) { + originalError = error; + break; + } + } + + if (originalError === undefined) { + return; + } + + await this.settleAll(active.slice(committedCount), (tx) => tx.rollback()); + + if (committedCount === 0) { + throw originalError; + } + + throw new TransactionHeuristicCommitException( + committedCount, + active.length - committedCount, + { originalError }, + ); + } + + /** + * Rollback all active transactions. Every one is attempted even if an + * earlier one fails — rollback is best-effort cleanup, so a failure is + * logged rather than thrown, and never abandons the rest. + */ + async rollbackAll(): Promise { + const active = (await this.startedTransactions()).filter( + (tx) => tx.isActive, + ); + + await this.settleAll(active, (tx) => tx.rollback()); + } + + /** + * Attempt `settle` on every given transaction, even if some fail. Never + * throws — failures are logged, matching the swallow-and-log style of + * {@link flushOnCommitCallbacks}/{@link flushOnRollbackCallbacks}. + */ + private async settleAll( + transactions: TransactionInterface[], + settle: (tx: TransactionInterface) => Promise, + ): Promise { + const results = await Promise.allSettled(transactions.map(settle)); + this.logRejections(results, 'Transaction rollback failed'); + } + + /** + * Log a single rejection reason. A rejection reason can be anything a + * caller threw — including `null`/`undefined`, a plain object, or a + * `Symbol` — so both the `.stack` read and the string interpolation are + * guarded rather than assumed safe. + */ + private logRejection(reason: unknown, message: string): void { + let description: string; + try { + description = `${reason}`; + } catch { + description = ''; + } + + Logger.error( + `${message}: ${description}`, + reason instanceof Error ? reason.stack : undefined, + ); + } + + /** + * Log every rejected result from an `allSettled` batch. + */ + private logRejections( + results: PromiseSettledResult[], + message: string, + ): void { + results.forEach((result) => { + if (result.status === 'rejected') { + this.logRejection(result.reason, message); + } + }); + } + + /** + * Transactions whose `start()` actually succeeded. A key whose `start()` + * rejected never began, so there is nothing to commit or roll back for + * it — surfacing that rejection here would replace the caller's real + * error instead of the one that led to settlement. + */ + private async startedTransactions(): Promise { + const results = await Promise.allSettled(this.transactions.values()); + return results.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [], + ); + } + + onCommit(fn: () => void | Promise): void { + if (this.closed) { + throw new TransactionClosedException(); + } + + this.commitCallbacks.push(fn); + } + + onRollback(fn: () => void | Promise): void { + if (this.closed) { + throw new TransactionClosedException(); + } + + this.rollbackCallbacks.push(fn); + } + + async flushOnCommitCallbacks(): Promise { + await this.flushCallbacks( + this.commitCallbacks.splice(0), + 'Transaction onCommit Callback Error', + ); + } + + async flushOnRollbackCallbacks(): Promise { + await this.flushCallbacks( + this.rollbackCallbacks.splice(0), + 'Transaction onRollback Callback Error', + ); + } + + /** + * Run callbacks one at a time, in registration order, rather than + * concurrently — each callback fully resolves (or rejects) before the + * next one starts. Every callback still runs even if an earlier one + * rejects; a rejection is logged, not thrown. + */ + private async flushCallbacks( + callbacks: (() => void | Promise)[], + message: string, + ): Promise { + for (const callback of callbacks) { + try { + await callback(); + } catch (error) { + this.logRejection(error, message); + } + } + } +} diff --git a/packages/nestjs-repository/src/transaction/transaction-scope.spec.ts b/packages/nestjs-repository/src/transaction/transaction-scope.spec.ts new file mode 100644 index 000000000..73343c3ba --- /dev/null +++ b/packages/nestjs-repository/src/transaction/transaction-scope.spec.ts @@ -0,0 +1,1183 @@ +import { Logger } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { AppContextHost } from '@concepta/nestjs-core'; + +import { TransactionClosedException } from '../exceptions/transaction-closed.exception.js'; +import { TransactionReadOnlyConflictException } from '../exceptions/transaction-read-only-conflict.exception.js'; +import { TransactionScopeFailedException } from '../exceptions/transaction-scope-failed.exception.js'; +import { TransactionTimeoutException } from '../exceptions/transaction-timeout.exception.js'; +import { REPOSITORY_MODULE_OPTIONS } from '../repository.constants.js'; + +import { + type TransactionContextInterface, + TrxCtx, +} from './interfaces/transaction-context.interface.js'; +import { type TransactionInterface } from './interfaces/transaction.interface.js'; +import { + TransactionFactoryRegistry, + TRANSACTION_FACTORY_REGISTRY, +} from './transaction-factory-registry.js'; +import { TransactionScope } from './transaction-scope.js'; + +describe(TransactionScope.name, () => { + let transaction: TransactionScope; + let mockRegistry: TransactionFactoryRegistry; + + const createMockTransaction = (): TransactionInterface => { + let isActive = false; + + return { + get isActive() { + return isActive; + }, + start: vi.fn().mockImplementation(async () => { + isActive = true; + }), + commit: vi.fn().mockImplementation(async () => { + isActive = false; + }), + rollback: vi.fn().mockImplementation(async () => { + isActive = false; + }), + getClient: vi.fn(), + }; + }; + + beforeEach(async () => { + mockRegistry = new TransactionFactoryRegistry(); + mockRegistry.register('default', { create: createMockTransaction }); + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionScope, + { + provide: TRANSACTION_FACTORY_REGISTRY, + useValue: mockRegistry, + }, + { + provide: REPOSITORY_MODULE_OPTIONS, + useValue: { defaultTimeout: 30000 }, + }, + ], + }).compile(); + + transaction = moduleRef.get(TransactionScope); + }); + + describe('run', () => { + it('should auto-define TrxCtx and run lifecycle', async () => { + const ctx = new AppContextHost(); + const operation = vi.fn().mockResolvedValue('result'); + + const result = await transaction.run(ctx, operation); + + expect(result).toBe('result'); + expect(operation).toHaveBeenCalledWith( + expect.objectContaining({ + trx: expect.objectContaining({ + onCommit: expect.any(Function), + onRollback: expect.any(Function), + }), + }), + ); + }); + + it('should accept a plain object and coerce via AppContextHost.from()', async () => { + const ctx = {}; + const operation = vi.fn().mockResolvedValue('result'); + + const result = await transaction.run(ctx, operation); + + expect(result).toBe('result'); + expect(operation).toHaveBeenCalled(); + }); + + it('should detect nested call via supports(TrxCtx)', async () => { + const ctx = new AppContextHost(); + + await transaction.run(ctx, async () => { + // TrxCtx is now defined — nested run should join + const innerResult = await transaction.run(ctx, async () => 'inner'); + expect(innerResult).toBe('inner'); + return 'outer'; + }); + }); + + it('should run lifecycle even without factories registered', async () => { + const emptyRegistry = new TransactionFactoryRegistry(); + const moduleRef = await Test.createTestingModule({ + providers: [ + TransactionScope, + { + provide: TRANSACTION_FACTORY_REGISTRY, + useValue: emptyRegistry, + }, + { + provide: REPOSITORY_MODULE_OPTIONS, + useValue: { defaultTimeout: 30000 }, + }, + ], + }).compile(); + + const txScope = moduleRef.get(TransactionScope); + const ctx = new AppContextHost(); + + const operation = vi.fn().mockResolvedValue('result'); + const result = await txScope.run(ctx, operation); + + expect(result).toBe('result'); + expect(operation).toHaveBeenCalled(); + }); + }); + + describe('onApplicationBootstrap', () => { + it('should warn when no transaction factory is registered', async () => { + const emptyRegistry = new TransactionFactoryRegistry(); + const moduleRef = await Test.createTestingModule({ + providers: [ + TransactionScope, + { + provide: TRANSACTION_FACTORY_REGISTRY, + useValue: emptyRegistry, + }, + { + provide: REPOSITORY_MODULE_OPTIONS, + useValue: { defaultTimeout: 30000 }, + }, + ], + }).compile(); + + const txScope = moduleRef.get(TransactionScope); + const warnSpy = vi.spyOn(Logger, 'warn').mockImplementation(() => {}); + + txScope.onApplicationBootstrap(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain( + 'No transaction factory is registered', + ); + + warnSpy.mockRestore(); + }); + + it('should not warn when a transaction factory is registered', () => { + const warnSpy = vi.spyOn(Logger, 'warn').mockImplementation(() => {}); + + transaction.onApplicationBootstrap(); + + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('should also warn from the first run() call, as a fallback for a boot-time warning that never reached its logger transport', async () => { + const emptyRegistry = new TransactionFactoryRegistry(); + const moduleRef = await Test.createTestingModule({ + providers: [ + TransactionScope, + { + provide: TRANSACTION_FACTORY_REGISTRY, + useValue: emptyRegistry, + }, + { + provide: REPOSITORY_MODULE_OPTIONS, + useValue: { defaultTimeout: 30000 }, + }, + ], + }).compile(); + + const txScope = moduleRef.get(TransactionScope); + const warnSpy = vi.spyOn(Logger, 'warn').mockImplementation(() => {}); + + // onApplicationBootstrap() deliberately not called — simulates a + // logger transport not yet attached at boot. + await txScope.run(new AppContextHost(), async () => 'result'); + await txScope.run(new AppContextHost(), async () => 'result'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + + warnSpy.mockRestore(); + }); + }); + + describe('commit and rollback lifecycle', () => { + it('should commit active transactions on success', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + + const ctx = new AppContextHost(); + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + return 'result'; + }); + + expect(mockTx.start).toHaveBeenCalledTimes(1); + expect(mockTx.commit).toHaveBeenCalledTimes(1); + expect(mockTx.rollback).not.toHaveBeenCalled(); + }); + + it('should rollback all on error', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + + const ctx = new AppContextHost(); + const error = new Error('Operation failed'); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + throw error; + }), + ).rejects.toThrow(error); + + expect(mockTx.rollback).toHaveBeenCalledTimes(1); + }); + + it('should surface the operation error, not a rollback failure that happens while handling it', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + mockTx.rollback = vi + .fn() + .mockRejectedValue(new Error('connection dropped during rollback')); + + const ctx = new AppContextHost(); + const operationError = new Error('Operation failed'); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + throw operationError; + }), + ).rejects.toBe(operationError); + }); + + it('should surface the commit failure, not a rollback failure that happens while falling back from it', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + const commitError = new Error('commit failed'); + mockTx.commit = vi.fn().mockRejectedValue(commitError); + mockTx.rollback = vi + .fn() + .mockRejectedValue(new Error('connection dropped during rollback')); + + const ctx = new AppContextHost(); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + return 'result'; + }), + ).rejects.toBe(commitError); + }); + + it('should not roll back a transaction twice when the commit-failure fallback rollback already ran', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + const commitError = new Error('commit failed'); + mockTx.commit = vi.fn().mockRejectedValue(commitError); + // Rejects without clearing isActive — unlike the default mock, whose + // rollback always clears it — so a genuine second rollback attempt + // stays visible here instead of being filtered out by isActive. + mockTx.rollback = vi + .fn() + .mockRejectedValue(new Error('rollback also failed')); + + const ctx = new AppContextHost(); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + return 'result'; + }), + ).rejects.toBe(commitError); + + expect(mockTx.rollback).toHaveBeenCalledTimes(1); + }); + + it('should release the scope even when the rollback reason is not an Error', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + mockTx.rollback = vi.fn().mockRejectedValue(Object.create(null)); + + const ctx = new AppContextHost(); + const operationError = new Error('Operation failed'); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + throw operationError; + }), + ).rejects.toBe(operationError); + + expect(ctx.supports(TrxCtx)).toBe(false); + }); + }); + + describe('readOnly transactions', () => { + it('should rollback on success when readOnly=true', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + + const ctx = new AppContextHost(); + + await transaction.run( + ctx, + async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + return 'result'; + }, + { readOnly: true }, + ); + + expect(mockTx.rollback).toHaveBeenCalledTimes(1); + expect(mockTx.commit).not.toHaveBeenCalled(); + }); + + it('runReadOnly should set readOnly=true', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + + const ctx = new AppContextHost(); + + await transaction.runReadOnly( + ctx, + async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + return 'result'; + }, + ); + + expect(mockTx.rollback).toHaveBeenCalledTimes(1); + expect(mockTx.commit).not.toHaveBeenCalled(); + }); + + it('should flush onRollback callbacks after a successful readOnly run', async () => { + const ctx = new AppContextHost(); + const rollbackCb = vi.fn(); + + await transaction.runReadOnly( + ctx, + async (txCtx: TransactionContextInterface) => { + txCtx.trx.onRollback(rollbackCb); + return 'result'; + }, + ); + + expect(rollbackCb).toHaveBeenCalledTimes(1); + }); + + it('should not flush onCommit callbacks after a successful readOnly run', async () => { + const ctx = new AppContextHost(); + const commitCb = vi.fn(); + + await transaction.runReadOnly( + ctx, + async (txCtx: TransactionContextInterface) => { + txCtx.trx.onCommit(commitCb); + return 'result'; + }, + ); + + expect(commitCb).not.toHaveBeenCalled(); + }); + + it('should flush onRollback callbacks exactly once when a readOnly run fails', async () => { + const ctx = new AppContextHost(); + const rollbackCb = vi.fn(); + + await expect( + transaction.runReadOnly( + ctx, + async (txCtx: TransactionContextInterface) => { + txCtx.trx.onRollback(rollbackCb); + throw new Error('fail'); + }, + ), + ).rejects.toThrow('fail'); + + expect(rollbackCb).toHaveBeenCalledTimes(1); + }); + + it('should throw TransactionReadOnlyConflictException when a read-write run joins a readOnly scope', async () => { + const ctx = new AppContextHost(); + + await expect( + transaction.runReadOnly( + ctx, + async (txCtx: TransactionContextInterface) => { + return transaction.run(txCtx, async () => 'inner', { + readOnly: false, + }); + }, + ), + ).rejects.toThrow(TransactionReadOnlyConflictException); + }); + + it('should throw TransactionReadOnlyConflictException when a runReadOnly joins a read-write scope', async () => { + const ctx = new AppContextHost(); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + return transaction.runReadOnly(txCtx, async () => 'inner'); + }), + ).rejects.toThrow(TransactionReadOnlyConflictException); + }); + + it('should join a readOnly scope silently when the joining run does not specify readOnly', async () => { + const ctx = new AppContextHost(); + + const result = await transaction.runReadOnly( + ctx, + async (txCtx: TransactionContextInterface) => { + return transaction.run(txCtx, async () => 'inner'); + }, + ); + + expect(result).toBe('inner'); + }); + + it('should join a read-write scope silently when the joining run does not specify readOnly', async () => { + const ctx = new AppContextHost(); + + const result = await transaction.run( + ctx, + async (txCtx: TransactionContextInterface) => { + return transaction.run(txCtx, async () => 'inner'); + }, + ); + + expect(result).toBe('inner'); + }); + + it('should not corrupt the refcount when a conflicting join is rejected — the outer scope still settles once', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + const ctx = new AppContextHost(); + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + + await expect( + transaction.run(txCtx, async () => 'inner', { readOnly: true }), + ).rejects.toThrow(TransactionReadOnlyConflictException); + + return 'outer'; + }); + + expect(mockTx.commit).toHaveBeenCalledTimes(1); + expect(mockTx.rollback).not.toHaveBeenCalled(); + }); + }); + + describe('timeout handling', () => { + it('should throw TransactionTimeoutException on timeout', async () => { + const ctx = new AppContextHost(); + const operation = vi + .fn() + .mockImplementation( + async () => new Promise((resolve) => setTimeout(resolve, 200)), + ); + + await expect( + transaction.run(ctx, operation, { timeout: 50 }), + ).rejects.toThrow(TransactionTimeoutException); + }); + + it('should abort the signal with a TransactionTimeoutException on timeout', async () => { + const ctx = new AppContextHost(); + let signal: AbortSignal | undefined; + + const operation = vi + .fn() + .mockImplementation(async (txCtx: TransactionContextInterface) => { + signal = txCtx.trx.signal; + return new Promise((resolve) => setTimeout(resolve, 200)); + }); + + await expect( + transaction.run(ctx, operation, { timeout: 50 }), + ).rejects.toThrow(TransactionTimeoutException); + + expect(signal?.aborted).toBe(true); + expect(signal?.reason).toBeInstanceOf(TransactionTimeoutException); + }); + + it('should log rather than swallow an operation that rejects after its transaction timed out', async () => { + const errorSpy = vi.spyOn(Logger, 'error').mockImplementation(() => {}); + const ctx = new AppContextHost(); + let releaseOrphan: (() => void) | undefined; + const orphanError = new Error('orphan failure'); + + const operation = vi.fn().mockImplementation(async () => { + await new Promise((resolve) => { + releaseOrphan = resolve; + }); + throw orphanError; + }); + + await expect( + transaction.run(ctx, operation, { timeout: 50 }), + ).rejects.toThrow(TransactionTimeoutException); + + expect(releaseOrphan).toBeDefined(); + releaseOrphan?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('orphan failure'), + expect.any(String), + ); + + errorSpy.mockRestore(); + }); + }); + + describe('settling immediately on timeout, without waiting for other participants', () => { + it('should release TrxCtx as soon as the timeout fires, before a still-running nested participant exits', async () => { + const ctx = new AppContextHost(); + let releaseNested: (() => void) | undefined; + + const outerRun = transaction.run( + ctx, + async (txCtx: TransactionContextInterface) => { + return transaction.run(txCtx, async () => { + await new Promise((resolve) => { + releaseNested = resolve; + }); + return 'nested'; + }); + }, + { timeout: 50 }, + ); + + await expect(outerRun).rejects.toThrow(TransactionTimeoutException); + + expect(ctx.supports(TrxCtx)).toBe(false); + + releaseNested?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + it('should have rolled back the started transaction by the time the timeout rejects, without waiting for the still-running nested participant', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + + const ctx = new AppContextHost(); + let releaseNested: (() => void) | undefined; + + const outerRun = transaction.run( + ctx, + async (txCtx: TransactionContextInterface) => { + return transaction.run( + txCtx, + async (nestedTxCtx: TransactionContextInterface) => { + await nestedTxCtx.trx.getOrStart('typeorm:default'); + await new Promise((resolve) => { + releaseNested = resolve; + }); + return 'nested'; + }, + ); + }, + { timeout: 50 }, + ); + + await expect(outerRun).rejects.toThrow(TransactionTimeoutException); + + expect(mockTx.rollback).toHaveBeenCalledTimes(1); + + // The nested participant later exiting must not roll back a second time. + releaseNested?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockTx.rollback).toHaveBeenCalledTimes(1); + }); + + it('should let a run() retried on the same ctx immediately after a timeout start its own fresh scope, not join the doomed one', async () => { + const created: TransactionInterface[] = []; + mockRegistry.register('typeorm:default', { + create: () => { + const tx = createMockTransaction(); + created.push(tx); + return tx; + }, + }); + + const ctx = new AppContextHost(); + let releaseNested: (() => void) | undefined; + + const outerRun = transaction.run( + ctx, + async (txCtx: TransactionContextInterface) => { + return transaction.run( + txCtx, + async (nestedTxCtx: TransactionContextInterface) => { + await nestedTxCtx.trx.getOrStart('typeorm:default'); + await new Promise((resolve) => { + releaseNested = resolve; + }); + return 'nested'; + }, + ); + }, + { timeout: 50 }, + ); + + await expect(outerRun).rejects.toThrow(TransactionTimeoutException); + + const retryResult = await transaction.run( + ctx, + async (retryTxCtx: TransactionContextInterface) => { + await retryTxCtx.trx.getOrStart('typeorm:default'); + return 'retried'; + }, + ); + + expect(retryResult).toBe('retried'); + expect(created).toHaveLength(2); + expect(created[1].commit).toHaveBeenCalledTimes(1); + expect(created[1].rollback).not.toHaveBeenCalled(); + + releaseNested?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + it('should not be pinned forever by an orphan that never resolves', async () => { + const ctx = new AppContextHost(); + + const outerRun = transaction.run( + ctx, + async (txCtx: TransactionContextInterface) => { + return transaction.run(txCtx, async () => { + // Never resolves — simulates a hung operation (dead connection, + // stuck lock wait) that outlives the timeout and keeps running. + return new Promise(() => {}); + }); + }, + { timeout: 50 }, + ); + + await expect(outerRun).rejects.toThrow(TransactionTimeoutException); + + const result = await transaction.run(ctx, async () => 'fresh'); + expect(result).toBe('fresh'); + }); + }); + + describe('trx.signal', () => { + it('should not abort the signal when the operation succeeds', async () => { + const ctx = new AppContextHost(); + let signal: AbortSignal | undefined; + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + signal = txCtx.trx.signal; + return 'result'; + }); + + expect(signal?.aborted).toBe(false); + }); + + it('should abort the signal with the thrown error when the operation fails', async () => { + const ctx = new AppContextHost(); + let signal: AbortSignal | undefined; + const operationError = new Error('operation failed'); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + signal = txCtx.trx.signal; + throw operationError; + }), + ).rejects.toBe(operationError); + + expect(signal?.aborted).toBe(true); + expect(signal?.reason).toBe(operationError); + }); + + it('should abort the signal shared with the outer scope when a nested run fails', async () => { + const ctx = new AppContextHost(); + let outerSignal: AbortSignal | undefined; + const innerError = new Error('inner failed'); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + outerSignal = txCtx.trx.signal; + + await expect( + transaction.run(ctx, async () => { + throw innerError; + }), + ).rejects.toBe(innerError); + + expect(outerSignal?.aborted).toBe(true); + expect(outerSignal?.reason).toBe(innerError); + + // The outer's own operation "succeeds" from here — it caught + // the nested failure — but the scope they share is doomed. + return 'outer'; + }), + ).rejects.toThrow(TransactionScopeFailedException); + }); + }); + + describe('rejecting a participant whose shared scope already failed', () => { + it('should carry the failure that doomed the scope as originalError', async () => { + const ctx = new AppContextHost(); + const innerError = new Error('inner failed'); + let caught: unknown; + + try { + await transaction.run( + ctx, + async (txCtx: TransactionContextInterface) => { + await expect( + transaction.run(txCtx, async () => { + throw innerError; + }), + ).rejects.toBe(innerError); + + return 'outer'; + }, + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(TransactionScopeFailedException); + const exception = caught as TransactionScopeFailedException; + expect(exception.context.originalError).toBe(innerError); + }); + + it('should reject the other side of a concurrent pair when its sibling fails', async () => { + const ctx = new AppContextHost(); + const siblingError = new Error('sibling failed'); + + const failing = transaction.run(ctx, async () => { + throw siblingError; + }); + const succeeding = transaction.run(ctx, async () => 'ok'); + + const [failingResult, succeedingResult] = await Promise.allSettled([ + failing, + succeeding, + ]); + + expect(failingResult).toEqual({ + status: 'rejected', + reason: siblingError, + }); + expect(succeedingResult.status).toBe('rejected'); + expect( + succeedingResult.status === 'rejected' + ? succeedingResult.reason + : undefined, + ).toBeInstanceOf(TransactionScopeFailedException); + }); + + it('should not flush onCommit callbacks registered by a participant whose scope already failed', async () => { + const ctx = new AppContextHost(); + const commitCb = vi.fn(); + const innerError = new Error('inner failed'); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + txCtx.trx.onCommit(commitCb); + + await expect( + transaction.run(txCtx, async () => { + throw innerError; + }), + ).rejects.toBe(innerError); + + return 'outer'; + }), + ).rejects.toThrow(TransactionScopeFailedException); + + expect(commitCb).not.toHaveBeenCalled(); + }); + + it("should still surface a participant's own thrown error, unwrapped, when that participant is the one that failed", async () => { + const ctx = new AppContextHost(); + const error = new Error('own failure'); + + await expect( + transaction.run(ctx, async () => { + throw error; + }), + ).rejects.toBe(error); + }); + }); + + describe('onCommit / onRollback callbacks', () => { + it('should flush onCommit callbacks after successful commit', async () => { + const ctx = new AppContextHost(); + const callback = vi.fn(); + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + txCtx.trx.onCommit(callback); + return 'result'; + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should flush onRollback callbacks after error rollback', async () => { + const ctx = new AppContextHost(); + const callback = vi.fn(); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + txCtx.trx.onRollback(callback); + throw new Error('fail'); + }), + ).rejects.toThrow('fail'); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not flush onCommit callbacks on rollback', async () => { + const ctx = new AppContextHost(); + const commitCb = vi.fn(); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + txCtx.trx.onCommit(commitCb); + throw new Error('fail'); + }), + ).rejects.toThrow('fail'); + + expect(commitCb).not.toHaveBeenCalled(); + }); + + it('should not flush onRollback callbacks on commit', async () => { + const ctx = new AppContextHost(); + const rollbackCb = vi.fn(); + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + txCtx.trx.onRollback(rollbackCb); + return 'result'; + }); + + expect(rollbackCb).not.toHaveBeenCalled(); + }); + + it('should accumulate callbacks from nested runs and flush at outermost', async () => { + const ctx = new AppContextHost(); + const order: number[] = []; + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + txCtx.trx.onCommit(() => { + order.push(1); + }); + + await transaction.run( + ctx, + async (innerTxCtx: TransactionContextInterface) => { + innerTxCtx.trx.onCommit(() => { + order.push(2); + }); + return 'inner'; + }, + ); + + txCtx.trx.onCommit(() => { + order.push(3); + }); + return 'outer'; + }); + + expect(order).toEqual([1, 2, 3]); + }); + }); + + describe('settling against the scope creator, not the last exiter (#468)', () => { + it('should release TrxCtx from the original ctx when a timed-out outer exits before its still-running nested participant', async () => { + const ctx = new AppContextHost(); + let releaseNested: (() => void) | undefined; + + const outerRun = transaction.run( + ctx, + async (txCtx: TransactionContextInterface) => { + // Nested run — created inside the outer operation, so it shares + // the outer's scope and outlives the outer's timeout. + return transaction.run(txCtx, async () => { + await new Promise((resolve) => { + releaseNested = resolve; + }); + return 'nested'; + }); + }, + { timeout: 50 }, + ); + + await expect(outerRun).rejects.toThrow(TransactionTimeoutException); + + // The outer exited (to depth 1, not 0) without settling — the + // nested participant is still running and still holds the scope. + expect(releaseNested).toBeDefined(); + releaseNested?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Now that the nested participant has exited too (to depth 0), the + // scope must have settled against ctx — the host that created it — + // not against the nested participant's own run-scoped child. + expect(ctx.supports(TrxCtx)).toBe(false); + + // And a fresh run() on the same ctx must succeed rather than seeing + // a stale, already-closed TransactionManager. + const result = await transaction.run(ctx, async () => 'fresh'); + expect(result).toBe('fresh'); + }); + }); + + describe('closing before settling, not after', () => { + it('should already be closed by the time commitAll() starts committing', async () => { + const mockTx = createMockTransaction(); + let capturedTxCtx: TransactionContextInterface | undefined; + let closedDuringCommit: boolean | undefined; + mockTx.commit = vi.fn().mockImplementation(async () => { + closedDuringCommit = capturedTxCtx?.trx.isClosed; + }); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + + const ctx = new AppContextHost(); + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + capturedTxCtx = txCtx; + await txCtx.trx.getOrStart('typeorm:default'); + return 'result'; + }); + + expect(closedDuringCommit).toBe(true); + }); + + it('should reject a getOrStart() call for a new key made from within commit(), rather than let it start an orphaned transaction', async () => { + const mockTx = createMockTransaction(); + const otherTx = createMockTransaction(); + let capturedTxCtx: TransactionContextInterface | undefined; + let getOrStartDuringCommit: Promise | undefined; + mockTx.commit = vi.fn().mockImplementation(async () => { + getOrStartDuringCommit = capturedTxCtx?.trx.getOrStart('typeorm:other'); + }); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + mockRegistry.register('typeorm:other', { create: () => otherTx }); + + const ctx = new AppContextHost(); + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + capturedTxCtx = txCtx; + await txCtx.trx.getOrStart('typeorm:default'); + return 'result'; + }); + + await expect(getOrStartDuringCommit).rejects.toThrow( + TransactionClosedException, + ); + expect(otherTx.start).not.toHaveBeenCalled(); + }); + + it('should throw TransactionClosedException rather than re-settle when run() is called again with a stale, already-closed txCtx', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + + const ctx = new AppContextHost(); + let capturedTxCtx: TransactionContextInterface | undefined; + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + capturedTxCtx = txCtx; + await txCtx.trx.getOrStart('typeorm:default'); + }); + + expect(mockTx.commit).toHaveBeenCalledTimes(1); + + const staleOperation = vi.fn().mockResolvedValue('noop'); + + await expect( + transaction.run( + capturedTxCtx as TransactionContextInterface, + staleOperation, + ), + ).rejects.toThrow(TransactionClosedException); + + // enter() must reject before the stale participant's operation ever + // runs — not merely before the scope re-settles. + expect(staleOperation).not.toHaveBeenCalled(); + }); + }); + + describe('nested run() calls', () => { + it('should not double commit on nested run', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + + const ctx = new AppContextHost(); + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + + // Nested run — should just execute, no lifecycle ownership + await transaction.run(ctx, async () => 'inner'); + + return 'outer'; + }); + + // Only committed once by outermost + expect(mockTx.commit).toHaveBeenCalledTimes(1); + }); + + it('should propagate error from nested run to outermost', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + + const ctx = new AppContextHost(); + const error = new Error('inner failure'); + + await expect( + transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + + await transaction.run(ctx, async () => { + throw error; + }); + + return 'outer'; + }), + ).rejects.toThrow(error); + + expect(mockTx.rollback).toHaveBeenCalledTimes(1); + }); + }); + + describe('sequential and concurrent run() on the same context (#468)', () => { + it('should give each sequential run its own transaction, started and committed once', async () => { + const created: TransactionInterface[] = []; + mockRegistry.register('typeorm:default', { + create: () => { + const tx = createMockTransaction(); + created.push(tx); + return tx; + }, + }); + + const ctx = new AppContextHost(); + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + }); + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + }); + + expect(created).toHaveLength(2); + expect(created[0]).not.toBe(created[1]); + expect(created[0].commit).toHaveBeenCalledTimes(1); + expect(created[1].commit).toHaveBeenCalledTimes(1); + }); + + it('should clear supports(TrxCtx) after a successful run and after a throwing run', async () => { + const ctx = new AppContextHost(); + + await transaction.run(ctx, async () => 'ok'); + expect(ctx.supports(TrxCtx)).toBe(false); + + await expect( + transaction.run(ctx, async () => { + throw new Error('fail'); + }), + ).rejects.toThrow('fail'); + expect(ctx.supports(TrxCtx)).toBe(false); + }); + + it('should not clear supports(TrxCtx) for a nested run — only the outermost settles it', async () => { + const ctx = new AppContextHost(); + + await transaction.run(ctx, async () => { + await transaction.run(ctx, async () => 'inner'); + + // Still inside the outer operation — the scope must still be live. + expect(ctx.supports(TrxCtx)).toBe(true); + + return 'outer'; + }); + + expect(ctx.supports(TrxCtx)).toBe(false); + }); + + it('should share one transaction across concurrent runs and commit once, after both resolve', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + + const ctx = new AppContextHost(); + + const slow = transaction.run( + ctx, + async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + await new Promise((resolve) => setTimeout(resolve, 20)); + return 'slow'; + }, + ); + + const fast = transaction.run( + ctx, + async (txCtx: TransactionContextInterface) => { + await txCtx.trx.getOrStart('typeorm:default'); + // The slow run is still in flight — nobody should have settled yet. + expect(mockTx.commit).not.toHaveBeenCalled(); + return 'fast'; + }, + ); + + const [slowResult, fastResult] = await Promise.all([slow, fast]); + + expect(slowResult).toBe('slow'); + expect(fastResult).toBe('fast'); + expect(mockTx.commit).toHaveBeenCalledTimes(1); + expect(ctx.supports(TrxCtx)).toBe(false); + }); + + it('should have removed TrxCtx from ctx by the time onCommit callbacks run', async () => { + const ctx = new AppContextHost(); + let sawDuringCallback: boolean | undefined; + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + txCtx.trx.onCommit(() => { + sawDuringCallback = ctx.supports(TrxCtx); + }); + }); + + expect(sawDuringCallback).toBe(false); + }); + + it('should keep the run-scoped child resolving TrxCtx after the scope closes, and fail loudly on reuse', async () => { + const mockTx = createMockTransaction(); + mockRegistry.register('typeorm:default', { create: () => mockTx }); + + const ctx = new AppContextHost(); + let capturedTxCtx: TransactionContextInterface | undefined; + + await transaction.run(ctx, async (txCtx: TransactionContextInterface) => { + capturedTxCtx = txCtx; + }); + + expect(capturedTxCtx).toBeDefined(); + const childHost = AppContextHost.from( + capturedTxCtx as TransactionContextInterface, + ); + + // The parent ctx released the scope... + expect(ctx.supports(TrxCtx)).toBe(false); + // ...but a handle still held by orphaned code keeps resolving it, + // rather than silently falling through to non-transactional access. + expect(childHost.supports(TrxCtx)).toBe(true); + + await expect( + (capturedTxCtx as TransactionContextInterface).trx.getOrStart( + 'typeorm:default', + ), + ).rejects.toThrow(TransactionClosedException); + }); + }); +}); diff --git a/packages/nestjs-repository/src/transaction/transaction-scope.ts b/packages/nestjs-repository/src/transaction/transaction-scope.ts new file mode 100644 index 000000000..63be5d4b0 --- /dev/null +++ b/packages/nestjs-repository/src/transaction/transaction-scope.ts @@ -0,0 +1,312 @@ +import { + Injectable, + Inject, + Logger, + type OnApplicationBootstrap, + Optional, + PlainLiteralObject, +} from '@nestjs/common'; + +import { AppContextHost } from '@concepta/nestjs-core'; + +import { TransactionReadOnlyConflictException } from '../exceptions/transaction-read-only-conflict.exception.js'; +import { TransactionScopeFailedException } from '../exceptions/transaction-scope-failed.exception.js'; +import { TransactionTimeoutException } from '../exceptions/transaction-timeout.exception.js'; +import { RepositoryModuleOptionsInterface } from '../interfaces/repository-module-options.interface.js'; +import { REPOSITORY_MODULE_OPTIONS } from '../repository.constants.js'; + +import { + TransactionContextInterface, + TrxCtx, +} from './interfaces/transaction-context.interface.js'; +import { + TransactionFactoryRegistry, + TRANSACTION_FACTORY_REGISTRY, +} from './transaction-factory-registry.js'; +import { TransactionManager } from './transaction-manager.js'; + +const DEFAULT_TIMEOUT = 30000; + +export interface TransactionRunOptions { + readOnly?: boolean; + timeout?: number; +} + +/** + * Orchestrates transaction lifecycle. + * + * Every unit of work calls `run()`. The first `run()` on a given context + * defines `TrxCtx` and owns the scope; concurrent/nested `run()` calls on + * the same context detect `TrxCtx` is already defined and join it — all + * participants share one `TransactionManager`, refcounted via + * `enter()`/`exit()`. The scope settles when the last participant exits, or + * immediately if any participant times out — closes, commits/rolls back, + * removes `TrxCtx` from the context, then flushes the matching callbacks — + * in that order, so a callback doing repository work on the same ctx gets + * non-transactional access rather than the just-settled transaction. The + * context is left exactly as `run()` found it, so a later, unrelated + * `run()` on the same context starts a fresh scope. + * + * The `TransactionManager` is also re-declared directly on the run-scoped + * `txCtx` child, so a handle held past its scope's settlement (e.g. an + * operation that outlived a timeout) keeps resolving `TrxCtx` — its next + * `getOrStart()` call throws `TransactionClosedException` rather than + * silently falling through to non-transactional access. + * + * @example + * ```typescript + * async execute(command: CreateCacheCommand): Promise { + * return this.txScope.run(command.ctx, async (txCtx) => { + * const cache = Cache.create(eventContext, dto, expirationDate); + * await cacheRepo.save(txCtx, cache); + * txCtx.trx.onCommit(() => cache.commit()); + * return cache; + * }); + * } + * ``` + */ +@Injectable() +export class TransactionScope implements OnApplicationBootstrap { + private readonly defaultTimeout: number; + private warnedEmptyRegistry = false; + + constructor( + @Inject(TRANSACTION_FACTORY_REGISTRY) + private readonly registry: TransactionFactoryRegistry, + @Optional() + @Inject(REPOSITORY_MODULE_OPTIONS) + options?: RepositoryModuleOptionsInterface, + ) { + this.defaultTimeout = options?.defaultTimeout ?? DEFAULT_TIMEOUT; + } + + /** + * By bootstrap, every `forFeature` transaction factory has registered + * (registration happens in provider factories, instantiated during DI — + * see `RepositoryModule.forFeature`), so the count is final here. An + * empty registry means every `run()` in this app executes without a + * transaction: writes are not atomic and `onRollback` callbacks never + * fire, silently, since nothing else in this class distinguishes that + * case from a real commit. + */ + onApplicationBootstrap(): void { + this.warnEmptyRegistryOnce(); + } + + /** + * Repeated at the first `run()`, not just at bootstrap: a custom + * `app.useLogger()` transport wired up after lifecycle hooks have already + * run would otherwise let the boot-time warning go nowhere, with no + * second chance to see it. + */ + private warnEmptyRegistryOnce(): void { + if (this.warnedEmptyRegistry || this.registry.count > 0) { + return; + } + + this.warnedEmptyRegistry = true; + Logger.warn( + 'No transaction factory is registered. TransactionScope.run() will ' + + 'execute without a transaction: writes are not atomic and ' + + 'onRollback callbacks never fire. Register a repository module ' + + 'that provides one, e.g. RepositoryModule.forFeature({ module: ' + + 'TypeOrmRepositoryModule, ... }).', + TransactionScope.name, + ); + } + + /** + * Execute an operation within a transaction scope. + * + * Defines `TrxCtx` on the context if not already present, then runs the + * full lifecycle ceremony. Nesting/concurrency is detected via + * `ctx.supports(TrxCtx)`; participants share one scope, refcounted via + * `enter()`/`exit()`, and the scope settles when the last one exits. + */ + async run( + ctx: PlainLiteralObject, + operation: (txCtx: TransactionContextInterface) => Promise, + options?: TransactionRunOptions, + ): Promise { + this.warnEmptyRegistryOnce(); + + const appCtx = AppContextHost.from(ctx); + const timeout = options?.timeout ?? this.defaultTimeout; + + if (!appCtx.supports(TrxCtx)) { + appCtx.defineOverlay(TrxCtx, { + trx: new TransactionManager( + this.registry, + options?.readOnly ?? false, + appCtx, + ), + }); + } else if ( + options?.readOnly !== undefined && + options.readOnly !== appCtx.with(TrxCtx).trx.isReadOnly + ) { + // readOnly is decided once, by whichever run() created the scope — + // joining it with a conflicting readOnly would either silently roll + // back writes the caller expected to persist, or silently drop + // runReadOnly()'s "must not persist" guarantee. + throw new TransactionReadOnlyConflictException(); + } + + const txCtx = appCtx.with(TrxCtx); + const { trx } = txCtx; + + // Re-declared on the run-scoped child — see class-level doc comment. + AppContextHost.from(txCtx).defineOverlay(TrxCtx, { trx }); + + trx.enter(); + + let result: T; + let timedOut = false; + + try { + result = await this.withTimeout(operation(txCtx), timeout); + } catch (error) { + timedOut = error instanceof TransactionTimeoutException; + trx.markFailed(error); + throw error; + } finally { + // A timeout abandons this participant's operation while it may still + // be running — possibly forever, if it's hung on a dead connection or + // a stuck lock wait. Waiting for the refcount to reach 0 would leave + // the scope, and ctx, doomed but live for as long as that takes. + // Settling right away — regardless of depth — releases ctx + // immediately: a retry starts a genuinely fresh scope instead of + // joining the doomed one, and the still-running orphan's next + // getOrStart/onCommit/onRollback throws TransactionClosedException + // rather than silently racing an in-flight settlement. Safe because + // `settle()` is idempotent and, on this path, trx.hasFailed is + // already set — it can only take the rollbackAll() branch, which + // never throws, so this can't replace the timeout error below. + if (trx.exit() === 0 || timedOut) { + await this.settle(trx); + } + } + + // This participant's own operation succeeded, but a sibling — nested + // or concurrent, sharing the same scope — may have failed and doomed + // it anyway. Checked after the finally, not inside the try, so it + // can't mask a real error from the operation or from settle(). + if (trx.hasFailed) { + throw new TransactionScopeFailedException({ + originalError: trx.signal.reason, + }); + } + + return result; + } + + /** + * Settle a scope: close it, commit or roll back, remove `TrxCtx` — so a + * callback doing repository work on the same ctx gets non-transactional + * access rather than the just-settled transaction — then flush the + * matching callbacks. Called once the last participant exits, or sooner + * if a participant's `run()` times out. + * + * Idempotent: a timeout forces settlement ahead of the refcount reaching + * 0 (see `run()`), so the participant that eventually does bring it to 0 + * may find the scope already closed and must do nothing further — this + * is a no-op in that case. + * + * Releases `TrxCtx` from `trx.host`, the host that created the scope — + * not necessarily this call's own `appCtx`, since the last participant to + * exit need not be the first one to have entered (e.g. an outer + * participant that times out exits before a still-running nested one). + * Releasing the wrong host would leave the creator's `TrxCtx` stranded. + * + * Closes the scope *before* committing/rolling back, not after: a still + * -running orphaned operation (one that outlived a timeout) can call + * `getOrStart`/`onCommit`/`onRollback` at any point while settlement is + * in flight, and without this, it could start a transaction — or + * register a callback — that this settlement's already-taken snapshot + * will never commit, roll back, or flush. + * + * A commit failure's own fallback rollback already happens inside + * `commitAll()` (only the transactions it didn't get to are rolled + * back), so there is no second `rollbackAll()` here — one that would + * otherwise re-attempt a rollback that already ran. `rollbackAll()` + * itself never throws, so nothing between `markFailed` and the end of + * this method can skip removing the overlay. + */ + private async settle(trx: TransactionManager): Promise { + // Idempotent: a timeout can force settlement (see run()) before the + // refcount reaches 0, so the participant that eventually does bring it + // to 0 must find the scope already settled and do nothing further. + if (trx.isClosed) { + return; + } + + trx.close(); + + let settleError: unknown; + + try { + if (trx.hasFailed || trx.isReadOnly) { + await trx.rollbackAll(); + } else { + await trx.commitAll(); + } + } catch (error) { + trx.markFailed(error); + settleError = error; + } + + trx.host.removeOverlay(TrxCtx); + + if (trx.hasFailed || trx.isReadOnly) { + await trx.flushOnRollbackCallbacks(); + } else { + await trx.flushOnCommitCallbacks(); + } + + if (settleError !== undefined) { + throw settleError; + } + } + + /** + * Execute an operation in a read-only transaction scope. + * Shorthand for `run(ctx, operation, { readOnly: true })`. + */ + async runReadOnly( + ctx: PlainLiteralObject, + operation: (txCtx: TransactionContextInterface) => Promise, + ): Promise { + return this.run(ctx, operation, { readOnly: true }); + } + + private withTimeout(promise: Promise, timeout: number): Promise { + return new Promise((resolve, reject) => { + let timedOut = false; + + const handle = setTimeout(() => { + timedOut = true; + reject(new TransactionTimeoutException(timeout)); + }, timeout); + + promise.then( + (result) => { + clearTimeout(handle); + resolve(result); + }, + (error) => { + clearTimeout(handle); + + if (timedOut) { + Logger.error( + `Operation failed after its transaction timed out: ${error}`, + error instanceof Error ? error.stack : undefined, + ); + return; + } + + reject(error); + }, + ); + }); + } +} diff --git a/packages/nestjs-repository/src/transaction/transactional-runner.spec.ts b/packages/nestjs-repository/src/transaction/transactional-runner.spec.ts new file mode 100644 index 000000000..1868c309f --- /dev/null +++ b/packages/nestjs-repository/src/transaction/transactional-runner.spec.ts @@ -0,0 +1,213 @@ +import { lastValueFrom, of, throwError } from 'rxjs'; +import { type Mocked } from 'vitest'; + +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { AppContextHost } from '@concepta/nestjs-core'; + +import { TransactionFactoryInterface } from '../interfaces/transaction-factory.interface.js'; +import { REPOSITORY_MODULE_OPTIONS } from '../repository.constants.js'; + +import { TransactionInterface } from './interfaces/transaction.interface.js'; +import { + TransactionFactoryRegistry, + TRANSACTION_FACTORY_REGISTRY, +} from './transaction-factory-registry.js'; +import { TransactionManager } from './transaction-manager.js'; +import { TransactionScope } from './transaction-scope.js'; +import { TransactionalRunner } from './transactional-runner.js'; +import { Transactional } from './transactional.decorator.js'; + +describe(TransactionalRunner.name, () => { + let runner: TransactionalRunner; + let mockRegistry: TransactionFactoryRegistry; + let mockFactory: Mocked; + let mockTransaction: TransactionInterface; + + const createMockTransaction = (): TransactionInterface => { + let isActive = false; + + return { + get isActive() { + return isActive; + }, + start: vi.fn().mockImplementation(async () => { + isActive = true; + }), + commit: vi.fn().mockImplementation(async () => { + isActive = false; + }), + rollback: vi.fn().mockImplementation(async () => { + isActive = false; + }), + getClient: vi.fn(), + }; + }; + + function createMockExecutionContext( + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + handler: Function, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + controller: Function, + ): ExecutionContext { + const ctx = new AppContextHost(); + return { + getHandler: () => handler, + getClass: () => controller, + switchToHttp: () => ({ + getRequest: () => ({ [Symbol.for('APP_CONTEXT_KEY')]: ctx }), + }), + getArgs: vi.fn(), + getArgByIndex: vi.fn(), + switchToRpc: vi.fn(), + switchToWs: vi.fn(), + getType: vi.fn(), + } as unknown as ExecutionContext; + } + + beforeEach(async () => { + mockTransaction = createMockTransaction(); + + mockFactory = { + create: vi.fn().mockReturnValue(mockTransaction), + }; + + mockRegistry = new TransactionFactoryRegistry(); + mockRegistry.register('typeorm:default', mockFactory); + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionalRunner, + TransactionScope, + Reflector, + { + provide: TRANSACTION_FACTORY_REGISTRY, + useValue: mockRegistry, + }, + { + provide: REPOSITORY_MODULE_OPTIONS, + useValue: { defaultTimeout: 30000 }, + }, + ], + }).compile(); + + runner = moduleRef.get(TransactionalRunner); + }); + + describe('run', () => { + it('should call operation without transaction when no @Transactional', async () => { + class PlainController {} + + function handlerWithoutDecorator() { + return 'result'; + } + + const context = createMockExecutionContext( + handlerWithoutDecorator, + PlainController, + ); + const operation = vi.fn().mockReturnValue(of('result')); + + const result = await lastValueFrom(runner.run(context, operation)); + expect(result).toBe('result'); + }); + + it('should wrap operation in transaction when @Transactional present', async () => { + class TestHandler { + @Transactional() + handle() { + return 'result'; + } + } + + const handler = new TestHandler(); + const context = createMockExecutionContext(handler.handle, TestHandler); + const operation = vi.fn().mockReturnValue(of('result')); + + const result = await lastValueFrom(runner.run(context, operation)); + expect(result).toBe('result'); + }); + + it('should handle errors from operation', async () => { + class TestHandler { + @Transactional() + handle() { + return 'result'; + } + } + + const handler = new TestHandler(); + const context = createMockExecutionContext(handler.handle, TestHandler); + const error = new Error('Operation failed'); + const operation = vi.fn().mockReturnValue(throwError(() => error)); + + await expect(lastValueFrom(runner.run(context, operation))).rejects.toBe( + error, + ); + }); + + it('should use class-level @Transactional for methods without decorator', async () => { + @Transactional() + class TransactionalController { + handle() { + return 'result'; + } + } + + const ctrl = new TransactionalController(); + const context = createMockExecutionContext( + ctrl.handle, + TransactionalController, + ); + const operation = vi.fn().mockReturnValue(of('result')); + + const result = await lastValueFrom(runner.run(context, operation)); + expect(result).toBe('result'); + }); + + it('should respect @Transactional(false) override on method when class has @Transactional', async () => { + @Transactional() + class TransactionalController { + @Transactional(false) + handle() { + return 'result'; + } + } + + const ctrl = new TransactionalController(); + const context = createMockExecutionContext( + ctrl.handle, + TransactionalController, + ); + const operation = vi.fn().mockReturnValue(of('result')); + + const result = await lastValueFrom(runner.run(context, operation)); + expect(result).toBe('result'); + }); + + it('should use readOnly option from decorator', async () => { + class TestHandler { + @Transactional({ readOnly: true }) + handle() { + return 'result'; + } + } + + const handler = new TestHandler(); + const context = createMockExecutionContext(handler.handle, TestHandler); + const operation = vi.fn().mockReturnValue(of('result')); + + // Spy at the TransactionManager level — rollbackAll() is called even + // when no transactions were started (it iterates an empty map). + const rollbackAllSpy = vi.spyOn( + TransactionManager.prototype, + 'rollbackAll', + ); + await lastValueFrom(runner.run(context, operation)); + expect(rollbackAllSpy).toHaveBeenCalled(); + rollbackAllSpy.mockRestore(); + }); + }); +}); diff --git a/packages/nestjs-repository/src/transaction/transactional-runner.ts b/packages/nestjs-repository/src/transaction/transactional-runner.ts new file mode 100644 index 000000000..408ff0a74 --- /dev/null +++ b/packages/nestjs-repository/src/transaction/transactional-runner.ts @@ -0,0 +1,85 @@ +import { Observable, from, throwError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { getAppContext } from '@concepta/nestjs-core'; + +import { TransactionScope } from './transaction-scope.js'; +import { + TRANSACTIONAL_KEY, + TransactionalOptions, +} from './transactional.decorator.js'; + +/** + * Helper for running operations within transactions. + * + * Checks for `@Transactional()` metadata on the handler and wraps the + * operation in a transaction if present. Designed to be used by + * interceptors in consuming modules. + * + * @example + * ```typescript + * // In an interceptor + * intercept(context: ExecutionContext, next: CallHandler) { + * return this.txRunner.run(context, () => next.handle()); + * } + * ``` + */ +@Injectable() +export class TransactionalRunner { + constructor( + private readonly reflector: Reflector, + private readonly txScope: TransactionScope, + ) {} + + /** + * Run an operation, wrapping in a transaction if `@Transactional()` is present. + * + * Checks method-level metadata first, then class-level. + * `@Transactional(false)` on a method disables the class-level transaction. + * + * @param context - The NestJS execution context + * @param operation - The operation to run + * @returns An Observable of the result + */ + run( + context: ExecutionContext, + operation: () => Observable, + ): Observable { + const options = this.reflector.getAllAndOverride< + TransactionalOptions | false + >(TRANSACTIONAL_KEY, [context.getHandler(), context.getClass()]); + + if (!options) { + return operation(); + } + + const request = context.switchToHttp().getRequest(); + const ctx = getAppContext(request); + + return from( + this.txScope.run(ctx, () => this.toPromise(operation()), { + readOnly: options.readOnly, + timeout: options.timeout, + }), + ).pipe(catchError((error) => throwError(() => error))); + } + + /** + * Convert Observable to Promise. + */ + private toPromise(observable: Observable): Promise { + return new Promise((resolve, reject) => { + let result: T; + observable.subscribe({ + next: (value) => { + result = value; + }, + error: (err) => reject(err), + complete: () => resolve(result), + }); + }); + } +} diff --git a/packages/nestjs-repository/src/transaction/transactional.decorator.spec.ts b/packages/nestjs-repository/src/transaction/transactional.decorator.spec.ts new file mode 100644 index 000000000..cea775e5b --- /dev/null +++ b/packages/nestjs-repository/src/transaction/transactional.decorator.spec.ts @@ -0,0 +1,138 @@ +import { + isTransactional, + Transactional, + TRANSACTIONAL_KEY, +} from './transactional.decorator.js'; + +describe('Transactional decorator', () => { + it('should apply metadata with default options', () => { + class TestClass { + @Transactional() + testMethod() { + return 'test'; + } + } + + const metadata = Reflect.getMetadata( + TRANSACTIONAL_KEY, + TestClass.prototype.testMethod, + ); + + expect(metadata).toBeDefined(); + expect(metadata.readOnly).toBe(false); + expect(metadata.timeout).toBeUndefined(); + }); + + it('should apply metadata with readOnly=true', () => { + class TestClass { + @Transactional({ readOnly: true }) + testMethod() { + return 'test'; + } + } + + const metadata = Reflect.getMetadata( + TRANSACTIONAL_KEY, + TestClass.prototype.testMethod, + ); + + expect(metadata.readOnly).toBe(true); + }); + + it('should apply metadata with timeout', () => { + class TestClass { + @Transactional({ timeout: 5000 }) + testMethod() { + return 'test'; + } + } + + const metadata = Reflect.getMetadata( + TRANSACTIONAL_KEY, + TestClass.prototype.testMethod, + ); + + expect(metadata.timeout).toBe(5000); + }); + + it('should apply metadata with multiple options', () => { + class TestClass { + @Transactional({ + readOnly: true, + timeout: 10000, + }) + testMethod() { + return 'test'; + } + } + + const metadata = Reflect.getMetadata( + TRANSACTIONAL_KEY, + TestClass.prototype.testMethod, + ); + + expect(metadata.readOnly).toBe(true); + expect(metadata.timeout).toBe(10000); + }); +}); + +describe('isTransactional', () => { + it('should return false when no target carries the metadata', () => { + class TestClass { + testMethod() { + return 'test'; + } + } + + expect(isTransactional(TestClass.prototype.testMethod)).toBe(false); + }); + + it('should return true when the given target is decorated with @Transactional()', () => { + class TestClass { + @Transactional() + testMethod() { + return 'test'; + } + } + + expect(isTransactional(TestClass.prototype.testMethod)).toBe(true); + }); + + it('should return false when the given target is decorated with @Transactional(false)', () => { + class TestClass { + @Transactional(false) + testMethod() { + return 'test'; + } + } + + expect(isTransactional(TestClass.prototype.testMethod)).toBe(false); + }); + + it('should prefer the first target that carries the metadata (handler before class)', () => { + @Transactional() + class TestClass { + @Transactional(false) + testMethod() { + return 'test'; + } + } + + expect(isTransactional(TestClass.prototype.testMethod, TestClass)).toBe( + false, + ); + }); + + it('should fall through to a later target when an earlier one has no metadata', () => { + @Transactional() + class TestClass { + testMethod() { + return 'test'; + } + } + + expect(isTransactional(TestClass.prototype.testMethod, TestClass)).toBe( + true, + ); + }); +}); diff --git a/packages/nestjs-repository/src/transaction/transactional.decorator.ts b/packages/nestjs-repository/src/transaction/transactional.decorator.ts new file mode 100644 index 000000000..936b7d5f3 --- /dev/null +++ b/packages/nestjs-repository/src/transaction/transactional.decorator.ts @@ -0,0 +1,86 @@ +import { SetMetadata, UseInterceptors, applyDecorators } from '@nestjs/common'; + +import { TransactionInterceptor } from '../interceptors/transaction.interceptor.js'; +import { TransactionalOptions } from '../interfaces/transactional-options.interface.js'; + +export { TransactionalOptions }; + +export const TRANSACTIONAL_KEY = Symbol('Transactional'); + +/** + * Decorator to wrap operations in a transaction. + * + * Can be applied at the class level (all methods) or method level. + * Method-level settings override class-level settings. + * Pass `false` to disable transactions for a specific method. + * + * @example + * ```typescript + * // Class-level: all routes are transactional + * @Controller('orders') + * @Transactional() + * class OrderController { + * @Post() + * async create(@Ctx() ctx, @Body() dto) { ... } + * + * // Override: disable transaction for this route + * @Get() + * @Transactional(false) + * async list(@Ctx() ctx) { ... } + * + * // Override: read-only transaction for this route + * @Get(':id') + * @Transactional({ readOnly: true }) + * async read(@Ctx() ctx) { ... } + * } + * ``` + */ +export function Transactional(options?: TransactionalOptions | false) { + // Explicit opt-out: set metadata to false so the runner skips this method + if (options === false) { + return SetMetadata(TRANSACTIONAL_KEY, false); + } + + const resolvedOptions: TransactionalOptions = { + readOnly: options?.readOnly ?? false, + timeout: options?.timeout, // Let Transaction apply module default + }; + + return applyDecorators( + SetMetadata(TRANSACTIONAL_KEY, resolvedOptions), + UseInterceptors(TransactionInterceptor), + ); +} + +/** + * Resolve the `@Transactional()` metadata for the given targets, in order — + * the first target that carries the metadata wins (e.g. a method overriding + * its class). Returns `undefined` when none of the targets are decorated. + * + * `TRANSACTIONAL_KEY` itself stays unexported so consumers don't couple to + * how this metadata is stored — read it through this function instead. + */ +export function getTransactionalOptions( + ...targets: object[] +): TransactionalOptions | false | undefined { + for (const target of targets) { + const value: TransactionalOptions | false | undefined = Reflect.getMetadata( + TRANSACTIONAL_KEY, + target, + ); + if (value !== undefined) { + return value; + } + } + return undefined; +} + +/** + * Whether any of the given targets is effectively wrapped by + * `@Transactional()` — `false` both when no target carries the metadata and + * when the metadata explicitly opts out (`@Transactional(false)`). + */ +export function isTransactional(...targets: object[]): boolean { + const options = getTransactionalOptions(...targets); + return options !== undefined && options !== false; +} diff --git a/packages/nestjs-common/src/repository/utils/get-dynamic-repository-token.ts b/packages/nestjs-repository/src/utils/get-dynamic-repository-token.ts similarity index 100% rename from packages/nestjs-common/src/repository/utils/get-dynamic-repository-token.ts rename to packages/nestjs-repository/src/utils/get-dynamic-repository-token.ts diff --git a/packages/nestjs-repository/tsconfig.json b/packages/nestjs-repository/tsconfig.json new file mode 100644 index 000000000..f62d1f578 --- /dev/null +++ b/packages/nestjs-repository/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", + "typeRoots": [ + "./node_modules/@types", + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/nestjs-repository/typedoc.json b/packages/nestjs-repository/typedoc.json new file mode 100644 index 000000000..35fed2c95 --- /dev/null +++ b/packages/nestjs-repository/typedoc.json @@ -0,0 +1,3 @@ +{ + "entryPoints": ["src/index.ts"] +} diff --git a/packages/nestjs-role/README.md b/packages/nestjs-role/README.md index 5056e8cbd..1a6e44132 100644 --- a/packages/nestjs-role/README.md +++ b/packages/nestjs-role/README.md @@ -1,54 +1,668 @@ -# Rockets NestJS Role +# @concepta/nestjs-role -A module for managing a basic Role entity, including controller with -full CRUD, DTOs, sample data factory and seeder. +Role-based access management module for NestJS. Manages roles and role +assignments through a DDD/CQRS architecture with domain aggregates, commands, +queries, and domain events. ## Project -[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-user)](https://www.npmjs.com/package/@concepta/nestjs-user) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-user)](https://www.npmjs.com/package/@concepta/nestjs-user) +[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-role)](https://www.npmjs.com/package/@concepta/nestjs-role) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-role)](https://www.npmjs.com/package/@concepta/nestjs-role) [![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) [![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-role%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +## Table of Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Module Registration](#module-registration) +- [Architecture Overview](#architecture-overview) +- [Domain Aggregates](#domain-aggregates) +- [Commands](#commands) +- [Queries](#queries) +- [Domain Events](#domain-events) +- [CRUD Gateway (Optional)](#crud-gateway-optional) +- [Schemas](#schemas) +- [Exceptions](#exceptions) +- [Seeding (Optional)](#seeding-optional) +- [Entry Points](#entry-points) ## Installation -`yarn add @concepta/nestjs-role` +```sh +yarn add @concepta/nestjs-role @nestjs/common @nestjs/core +``` + +This package is ESM-only and requires Node.js >= 22.12 and NestJS 12. + +### Dependencies + +| Package | Notes | +| --- | --- | +| `@concepta/nestjs-core` | Core interfaces, event context, and utilities | +| `@concepta/nestjs-repository` | Repository abstraction and transaction scope | +| `zod` | Schema validation and serialization (Standard Schema) | + +### Peer Dependencies + +| Package | Required | Notes | +| --- | --- | --- | +| `@nestjs/common` | Yes | NestJS core — install explicitly, no longer bundled | +| `@nestjs/core` | Yes | Module reference and reflection — install explicitly | +| `@nestjs/cqrs` | No | Optional peer — required in practice for the CQRS buses | +| `typeorm` | No | Only when using TypeORM repository driver | +| `@concepta/nestjs-crud` | Yes | The main entry imports `paginatedSchema` from it | +| `@concepta/typeorm-seeding` | No | Only when using database seeding | +| `@faker-js/faker` | No | Only when using the seed factory | + +## Quick Start -## Usage +Register the module, define your entities, and wire up repositories. + +### Entities + +Define TypeORM entities that implement the domain interfaces: ```ts -// ... -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { RoleModule } from '@concepta/nestjs-user'; -import { CrudModule } from '@concepta/nestjs-crud'; +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { RoleEntityInterface, RoleAssignmentEntityInterface } from '@concepta/nestjs-role'; + +@Entity() +export class RoleEntity implements RoleEntityInterface { + @PrimaryGeneratedColumn('uuid') id!: string; + @Column() name!: string; + @Column() description!: string; + @Column() dateCreated!: Date; + @Column() dateUpdated!: Date; + @Column({ nullable: true }) dateDeleted!: Date | null; + @Column({ default: 1 }) version!: number; +} + +@Entity() +export class UserRoleEntity implements RoleAssignmentEntityInterface { + @PrimaryGeneratedColumn('uuid') id!: string; + @Column() roleId!: string; + @Column() assigneeId!: string; + @Column() dateCreated!: Date; + @Column() dateUpdated!: Date; + @Column({ nullable: true }) dateDeleted!: Date | null; + @Column({ default: 1 }) version!: number; +} +``` + +### App Module + +```ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; +import { RoleModule } from '@concepta/nestjs-role'; @Module({ imports: [ - TypeOrmExtModule.register({ - type: 'postgres', - url: 'postgres://user:pass@localhost:5432/postgres', + TypeOrmModule.forRoot({ /* ... */ }), + RepositoryModule.forRoot({}), + + // Register the repository entities + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: 'role', entity: RoleEntity }, + { key: 'user-role', entity: UserRoleEntity }, + ], + }), + + // Register the role module globally + RoleModule.forRoot({}), + + // Register repository providers for your entity keys + RoleModule.forFeature({ + roleEntityKey: 'role', + assignmentEntityKeys: ['user-role'], }), - CrudModule.register(), - RoleModule.register(), ], }) export class AppModule {} ``` -## Configuration +### Using Commands and Queries Directly + +```ts +import { Injectable } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { CreateRoleCommand, GetRoleQuery, AssignRoleCommand } from '@concepta/nestjs-role'; + +@Injectable() +export class MyService { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + async createRole(ctx, namespace: string, name: string, description: string) { + return this.commandBus.execute( + new CreateRoleCommand(ctx, namespace, { name, description }), + ); + } + + async getRole(ctx, namespace: string, id: string) { + return this.queryBus.execute(new GetRoleQuery(ctx, namespace, id)); + } + + async assignRole(ctx, namespace: string, roleId: string, assigneeId: string) { + return this.commandBus.execute( + new AssignRoleCommand(ctx, namespace, roleId, assigneeId), + ); + } +} +``` + +## Module Registration + +### forRoot / forRootAsync + +Global registration. Required once per application. + +```ts +RoleModule.forRoot({}) + +// Async with factory +RoleModule.forRootAsync({ + useFactory: async () => ({}), +}) +``` + +Entity namespacing is NOT configured here — it is handled per-feature via +`RoleModule.forFeature({ roleEntityKey, assignmentEntityKeys })` plus the +`RoleNamespace` decorator (see [Context Overlay](#context-overlay)). + +### register / registerAsync + +Non-global variants of `forRoot`. Identical options, scoped to the importing +module. + +### forFeature + +Per-entity registration. Creates repository providers for your entity keys. +Call once for each set of role/assignment entities you need. + +```ts +RoleModule.forFeature({ + roleEntityKey: 'role', + assignmentEntityKeys: ['user-role', 'org-member-role'], +}) +``` + +### Options + +`forRoot()` and `registerAsync()` accept `RoleOptionsInterface` merged with +`RoleExtrasInterface` (extras are passed to `setExtras` on the +`ConfigurableModuleBuilder`): + +```ts +interface RoleExtrasInterface { + global?: boolean; + providers?: Provider[]; + repositories?: { + role?: Type; + roleAssignment?: Type; + }; +} + +interface RoleOptionsInterface {} +``` + +`RoleOptionsInterface` is currently empty — entity keys are supplied +through `RoleModule.forFeature()` and resolved per-request via the +`RoleNamespace` decorator and context overlay (see +[Context Overlay](#context-overlay)). + +`forFeature()` accepts entity key configuration for repository provider +creation: + +```ts +RoleModule.forFeature(config: { + roleEntityKey: string; + assignmentEntityKeys: string[]; +}) +``` + +Pass `repositories.role` or `repositories.roleAssignment` to override the +default repository implementations. + +## Architecture Overview + +```text + ┌───────────────────────────────┐ + │ CRUD HTTP Gateway │ + │ (optional: nestjs-crud) │ + │ │ + │ Request → RequestHandler │ + └──────────────┬────────────────┘ + │ + CommandBus / QueryBus + │ + ┌─────────────────────────┼─────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ +│ Command Handlers │ │ Query Handlers │ │ Domain Events │ +│ │ │ │ │ │ +│ Create, Update, │ │ Get, List, │ │ RoleCreatedEvent │ +│ Replace, Remove, │ │ IsAssigned │ │ RoleUpdatedEvent │ +│ Assign, Revoke │ │ │ │ RoleReplacedEvent │ +└────────┬─────────┘ └────────┬────────┘ │ RoleAssignedEvent │ + │ │ │ RoleRevokedEvent │ + ▼ ▼ └─────────────────────┘ +┌──────────────────────────────────────────┐ +│ Domain Aggregates │ +│ │ +│ Role (DomainAggregate) │ +│ RoleAssignment (DomainAggregate<...>) │ +└────────────────────┬─────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────┐ +│ Repositories + Mappers │ +│ │ +│ RoleRepository ← RoleMapper (DI) │ +│ RoleAssignmentRepository ← Mapper (DI) │ +│ │ +│ Resolved via RepositoryResolver │ +│ (multi-entity / multi-tenant support) │ +└────────────────────┬─────────────────────┘ + │ + ▼ + Database Driver +``` + +**Layers:** + +- **Gateway** — Adapts HTTP requests to CQRS commands/queries. Optional; + requires `@concepta/nestjs-crud`. +- **Application** — Command and query handlers. Orchestrates transactions, + event publishing, and repository calls. +- **Domain** — Aggregate roots (`Role`, `RoleAssignment`) encapsulate business + rules and emit domain events. +- **Infrastructure** — Repositories with DI-injected mappers (`RoleMapper`, + `RoleAssignmentMapper`), Zod schemas, configuration, and provider + factories. + +## Context Overlay + +The role module uses a context overlay to resolve the entity namespace for +each HTTP request. This is required when using the CRUD gateway. + +### RoleNamespace Decorator + +Apply `@RoleNamespace({ name })` to a controller (or via `extraDecorators` +on a generated CRUD controller) to associate it with a role or assignment +entity key: + +```ts +import { RoleNamespace } from '@concepta/nestjs-role'; + +// For generated CRUD controllers, pass via extraDecorators: +CrudModule.forFeature({ + crud: { + controller: { + entity: ROLE_ENTITY_KEY, + path: 'role', + extraDecorators: [RoleNamespace({ name: ROLE_ENTITY_KEY })], + // ... + }, + }, +}) +``` -- [Seeding](#seeding) - - [ENV](#env) +### How It Works -### Seeding +1. `RoleContextOverlay` reads `@RoleNamespace` metadata via `Reflector` +2. `RoleContextOverlay` extends `ContextOverlayInterceptor` and is registered + as a global `APP_INTERCEPTOR`. Its `attach()` method resolves + the namespace and calls `ctx.defineOverlay(RoleCtx, resolved)` +3. Gateway request handlers use `@Ctx(RoleCtx)` (or `ctx.with(RoleCtx)`) + to get `{ namespace }`, used as the entity key for repository resolution -Configurations specific to (optional) database seeding. +## Domain Aggregates -#### ENV +### Role + +Extends `DomainAggregate`. + +| Property | Type | +| --- | --- | +| `id` | `string` | +| `name` | `string` | +| `description` | `string` | +| `version` | `number` | +| `meta` | `AggregateMetaInterface` (dateCreated, dateUpdated, dateDeleted) | + +| Method | Description | Event | +| --- | --- | --- | +| `Role.create(ctx, props)` | Create with generated UUID | `RoleCreatedEvent` | +| `Role.createWithId(ctx, id, props)` | Create with explicit ID | `RoleCreatedEvent` | +| `update(ctx, dto)` | Partial update, bumps version | `RoleUpdatedEvent` | +| `replace(ctx, dto)` | Full replacement, bumps version | `RoleReplacedEvent` | +| `toPlain()` | Returns `{ id, version, ...props, ...meta }` | — | + +Reconstitution from a database entity is handled by `RoleMapper`. + +### RoleAssignment + +Extends `DomainAggregate`. + +| Property | Type | +| --- | --- | +| `id` | `string` | +| `roleId` | `string` | +| `assigneeId` | `string` | +| `version` | `number` | +| `meta` | `AggregateMetaInterface` (dateCreated, dateUpdated, dateDeleted) | + +| Method | Description | Event | +| --- | --- | --- | +| `RoleAssignment.create(ctx, props)` | Create assignment | `RoleAssignedEvent` | +| `revoke(ctx)` | Mark for revocation | `RoleRevokedEvent` | +| `toPlain()` | Returns `{ id, version, ...props, ...meta }` | — | + +Reconstitution from a database entity is handled by `RoleAssignmentMapper`. + +## Commands + +All commands execute within a `TransactionScope`. Domain events are committed +on transaction success and uncommitted on rollback. + +| Command | Input | Returns | Description | +| --- | --- | --- | --- | +| `CreateRoleCommand` | `ctx, namespace, dto` | `Role` | Create a new role | +| `UpdateRoleCommand` | `ctx, namespace, id, dto` | `Role` | Partial update | +| `ReplaceRoleCommand` | `ctx, namespace, id, dto` | `Role` | Full replacement (upsert) | +| `RemoveRoleCommand` | `ctx, namespace, id` | `void` | Hard delete | +| `AssignRoleCommand` | `ctx, namespace, roleId, assigneeId` | `RoleAssignment` | Assign a single role | +| `AssignRolesCommand` | `ctx, namespace, roleIds[], assigneeId` | `RoleAssignment[]` | Assign multiple roles | +| `RevokeRoleCommand` | `ctx, namespace, roleId, assigneeId` | `void` | Revoke a single role | +| `RevokeRolesCommand` | `ctx, namespace, roleIds[], assigneeId` | `void` | Revoke multiple roles | + +**Conflict detection:** `AssignRoleCommand` and `AssignRolesCommand` check for +existing assignments and throw `RoleAssignmentConflictException` or +`RoleAssignmentsConflictException` if duplicates are found. + +## Queries + +| Query | Input | Returns | Description | +| --- | --- | --- | --- | +| `GetRoleQuery` | `ctx, namespace, id` | `Role` | Get role by ID (throws if not found) | +| `GetRoleAssignmentQuery` | `ctx, namespace, id` | `RoleAssignment` | Get assignment by ID | +| `GetAssignedRolesQuery` | `ctx, namespace, assigneeId` | `RoleAssignment[]` | All assignments for an assignee | +| `IsAssignedRoleQuery` | `ctx, namespace, roleId, assigneeId` | `boolean` | Check single assignment | +| `IsAssignedRolesQuery` | `ctx, namespace, roleIds[], assigneeId` | `boolean` | Check all roles assigned | + +## Domain Events + +Subscribe to these events via `@nestjs/cqrs` event handlers (sagas or +`@EventsHandler` classes): + +| Event | Payload | Emitted by | +| --- | --- | --- | +| `RoleCreatedEvent` | `eventContext, role` | `Role.create` / `Role.createWithId` | +| `RoleUpdatedEvent` | `eventContext, role` | `Role.update` | +| `RoleReplacedEvent` | `eventContext, role` | `Role.replace` | +| `RoleAssignedEvent` | `eventContext, assignment` | `RoleAssignment.create` | +| `RoleRevokedEvent` | `eventContext, assignment` | `RoleAssignment.revoke` | + +Events are published after the transaction commits. Each event carries an +`EventContextHost` with the namespace header. + +### Subscribing to Events + +```ts +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { RoleAssignedEvent } from '@concepta/nestjs-role'; + +@EventsHandler(RoleAssignedEvent) +export class RoleAssignedHandler implements IEventHandler { + handle(event: RoleAssignedEvent) { + const { assignment, eventContext } = event; + // React to role assignment... + } +} +``` + +## CRUD Gateway (Optional) + +The module exports request classes and request handlers that bridge HTTP +operations to the CQRS bus. These are building blocks — you wire them into a +controller via `CrudModule.forFeature()` from `@concepta/nestjs-crud`. + +No controller is exported. You generate one through the CRUD module +configuration. + +```ts +import { + CreateRoleRequest, + CreateRoleRequestHandler, + // ... +} from '@concepta/nestjs-role/optional/crud'; +``` + +### Available Request/Handler Pairs + +**Roles:** + +| Operation | Request | Handler | +| --- | --- | --- | +| List | `ListRolesRequest` | `ListRolesRequestHandler` | +| Read | `ReadRoleRequest` | `ReadRoleRequestHandler` | +| Create | `CreateRoleRequest` | `CreateRoleRequestHandler` | +| Update | `UpdateRoleRequest` | `UpdateRoleRequestHandler` | +| Replace | `ReplaceRoleRequest` | `ReplaceRoleRequestHandler` | +| Delete | `DeleteRoleRequest` | `DeleteRoleRequestHandler` | + +**Role Assignments:** + +| Operation | Request | Handler | +| --- | --- | --- | +| List | `ListRoleAssignmentsRequest` | `ListRoleAssignmentsRequestHandler` | +| Read | `ReadRoleAssignmentRequest` | `ReadRoleAssignmentRequestHandler` | +| Create | `CreateRoleAssignmentRequest` | `CreateRoleAssignmentRequestHandler` | +| Delete | `DeleteRoleAssignmentRequest` | `DeleteRoleAssignmentRequestHandler` | + +### Wiring a Role Controller + +Use `CrudModule.forFeature()` to generate a controller that dispatches through +the role request handlers: + +```ts +import { Module } from '@nestjs/common'; +import { Operation } from '@concepta/nestjs-core'; +import { CrudModule, CrudCqrsResolver } from '@concepta/nestjs-crud'; +import { + RoleInterface, + roleCreateSchema, + roleUpdateSchema, + roleSchema, + rolePaginatedSchema, + RoleNamespace, +} from '@concepta/nestjs-role'; +import { + ListRolesRequest, + ListRolesRequestHandler, + ReadRoleRequest, + ReadRoleRequestHandler, + CreateRoleRequest, + CreateRoleRequestHandler, + UpdateRoleRequest, + UpdateRoleRequestHandler, + ReplaceRoleRequest, + ReplaceRoleRequestHandler, + DeleteRoleRequest, + DeleteRoleRequestHandler, +} from '@concepta/nestjs-role/optional/crud'; + +const ROLE_ENTITY_KEY = 'role'; + +@Module({ + imports: [ + CrudModule.forFeature({ + crud: { + controller: { + entity: ROLE_ENTITY_KEY, + path: 'role', + resolver: CrudCqrsResolver, + transactional: true, + extraDecorators: [RoleNamespace({ name: ROLE_ENTITY_KEY })], + request: { body: roleCreateSchema }, + response: { + resource: roleSchema, + paginated: rolePaginatedSchema, + }, + }, + operations: [ + { + operation: Operation.List, + query: ListRolesRequest, + queryHandler: ListRolesRequestHandler, + }, + { + operation: Operation.Read, + query: ReadRoleRequest, + queryHandler: ReadRoleRequestHandler, + }, + { + operation: Operation.Create, + request: { body: roleCreateSchema }, + command: CreateRoleRequest, + commandHandler: CreateRoleRequestHandler, + }, + { + operation: Operation.Update, + request: { body: roleUpdateSchema }, + command: UpdateRoleRequest, + commandHandler: UpdateRoleRequestHandler, + }, + { + operation: Operation.Replace, + request: { body: roleCreateSchema }, + command: ReplaceRoleRequest, + commandHandler: ReplaceRoleRequestHandler, + }, + { + operation: Operation.Delete, + command: DeleteRoleRequest, + commandHandler: DeleteRoleRequestHandler, + }, + ], + }, + }), + ], +}) +export class RoleHttpModule {} +``` + +Note that all role schemas — including `rolePaginatedSchema` and +`roleAssignmentPaginatedSchema` — live in the MAIN entry +(`@concepta/nestjs-role`); only the request/handler classes and batch +schemas come from `optional/crud`. Builder-generated controllers derive +request body validation from `operations[].request.body` automatically; a +handwritten `@CrudController` class would need an explicit +`@CrudBody({ schema })` for runtime validation. + +This generates the following endpoints: + +| Method | Path | Operation | +| --- | --- | --- | +| GET | `/role` | List (paginated) | +| GET | `/role/:id` | Read | +| POST | `/role` | Create | +| PATCH | `/role/:id` | Update | +| PUT | `/role/:id` | Replace | +| DELETE | `/role/:id` | Delete | + +The same pattern applies for role assignments — wire +`CreateRoleAssignmentRequest`/`CreateRoleAssignmentRequestHandler` and the +other assignment pairs into a second `CrudModule.forFeature()` call. + +### Request Flow + +```text +HTTP Request + → CrudContextInterceptor (parses params, query) + → CRUD Request (e.g. CreateRoleRequest) + → Request Handler (bridges to CommandBus/QueryBus) + → Command/Query Handler (domain logic + transaction) + → Domain Aggregate (applies events) + → Repository (persists) + → Transaction commits → Events published +``` + +## Schemas + +All schemas are Zod v4 objects (Standard Schema compatible), replacing the +legacy class-validator DTO classes. + +### Core Schemas (always available) + +Exported from `@concepta/nestjs-role`: + +| Schema | Conforms To | Fields | +| --- | --- | --- | +| `roleSchema` | `RoleInterface` | `id`, `name`, `description`, audit fields (named OpenAPI component `Role`) | +| `roleCreateSchema` | `RoleCreatableInterface` | `name` (required, non-blank), `description` (defaults to `''`) | +| `roleUpdateSchema` | `RoleUpdatableInterface` | `name`, `description` (both optional — a partial update) | +| `rolePaginatedSchema` | — | Paginated role list response | +| `roleAssignmentSchema` | `RoleAssignmentInterface` | `id`, `roleId`, `assigneeId`, audit fields | +| `roleAssignmentCreateSchema` | `RoleAssignmentCreatableInterface` | `roleId`, `assigneeId` | +| `roleAssignmentPaginatedSchema` | — | Paginated assignment list response | + +`roleCreateSchema` requires a non-blank `name` (`.trim().min(1)`) and +defaults an omitted `description` to `''`. `roleUpdateSchema` is a true +partial — both fields are `.optional()`, so an omitted field is left +untouched rather than overwritten; a present-but-blank `name` is still +rejected. An empty `{}` body is accepted as a no-op patch. + +### CRUD Schemas (optional) + +Exported from `@concepta/nestjs-role/optional/crud`: + +| Schema | Purpose | +| --- | --- | +| `roleCreateBatchSchema` | Bulk role creation request | +| `roleAssignmentCreateBatchSchema` | Bulk assignment creation request | + +## Exceptions + +| Exception | HTTP Status | Error Code | Context | +| --- | --- | --- | --- | +| `RoleException` | — | `ROLE_ERROR` | Base exception | +| `RoleNotFoundException` | 404 | `ROLE_NOT_FOUND_ERROR` | `{ id }` | +| `RoleAssignmentNotFoundException` | 404 | `ROLE_ASSIGNMENT_NOT_FOUND_ERROR` | `{ assignmentId }` | +| `RoleAssignmentConflictException` | 409 | `ROLE_ASSIGNMENT_CONFLICT_ERROR` | `{ roleId, assigneeId }` | +| `RoleAssignmentsConflictException` | 409 | `ROLE_ASSIGNMENTS_CONFLICT_ERROR` | `{ assigneeId }` | +| `RoleEntityNotFoundException` | — | `ROLE_ENTITY_NOT_FOUND_ERROR` | `{ entityName }` | + +All exceptions extend `RoleException`, which extends `RuntimeException` from +`@concepta/nestjs-core`. `RuntimeException` extends NestJS's +`HttpException`, so no exception filter registration is needed — errors +serialize over the wire as `{ statusCode, message, errorCode, error? }` +(no `timestamp`). + +## Seeding (Optional) + +When `@concepta/typeorm-seeding` and `@faker-js/faker` are installed, a +`RoleFactory` is available for generating seed data. + +```ts +import { RoleFactory } from '@concepta/nestjs-role/optional/seeding'; +``` -Configurations available via environment. +## Entry Points -| Variable | Type | Default | | -| -------------------------- | ---------- | ------- | ------------------------------------ | -| `ORG_MODULE_SEEDER_AMOUNT` | `` | `50` | number of additional users to create | +| Import Path | Contents | +| --- | --- | +| `@concepta/nestjs-role` | Module, aggregates, commands, queries, events, handlers, schemas (including paginated), repositories, context overlay, exceptions, domain interfaces | +| `@concepta/nestjs-role/optional/crud` | CRUD request/handler classes, batch schemas | +| `@concepta/nestjs-role/optional/typeorm` | `RoleSqliteEntity`, `RolePostgresEntity`, `RoleAssignmentSqliteEntity`, `RoleAssignmentPostgresEntity` | +| `@concepta/nestjs-role/optional/seeding` | `RoleFactory` | diff --git a/packages/nestjs-role/package.json b/packages/nestjs-role/package.json index 6a06a2fab..679cb1ef8 100644 --- a/packages/nestjs-role/package.json +++ b/packages/nestjs-role/package.json @@ -1,35 +1,77 @@ { "name": "@concepta/nestjs-role", - "version": "7.0.0-alpha.10", - "description": "Rockets NestJS User", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "version": "8.0.0-alpha.10", + "description": "Rockets NestJS Role", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./optional/crud": { + "types": "./dist/optional-crud.d.ts", + "default": "./dist/optional-crud.js" + }, + "./optional/seeding": { + "types": "./dist/optional-seeding.d.ts", + "default": "./dist/optional-seeding.js" + }, + "./optional/typeorm": { + "types": "./dist/optional-typeorm.d.ts", + "default": "./dist/optional-typeorm.js" + } + }, "license": "BSD-3-Clause", "publishConfig": { "access": "public" }, "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" ], "dependencies": { - "@concepta/nestjs-access-control": "^7.0.0-alpha.10", - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/swagger": "^11.2.2" + "@concepta/nestjs-core": "^8.0.0-alpha.10", + "@concepta/nestjs-repository": "8.0.0-alpha.10", + "zod": "^4.4.3" }, "devDependencies": { - "@concepta/nestjs-crud": "^7.0.0-alpha.10", - "@concepta/nestjs-typeorm-ext": "^7.0.0-alpha.10", + "@concepta/nestjs-crud": "8.0.0-alpha.10", + "@concepta/nestjs-repository-typeorm": "8.0.0-alpha.10", "@concepta/typeorm-seeding": "^4.0.0", "@faker-js/faker": "^8.4.1", - "@nestjs/testing": "^11.1.9", - "@nestjs/typeorm": "^11.0.0", - "supertest": "^6.3.4" + "@nestjs/common": "^12.0.1", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/swagger": "^12.0.1", + "@nestjs/testing": "^12.0.1", + "@nestjs/typeorm": "^12.0.1", + "supertest": "^6.3.4", + "vitest-mock-extended": "^4.0.0" }, "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", + "@concepta/nestjs-crud": "8.0.0-alpha.10", + "@concepta/typeorm-seeding": "^4.0.0", + "@faker-js/faker": "^8.4.1", + "@nestjs/common": "^12.0.1", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", "typeorm": "^0.3.0" + }, + "peerDependenciesMeta": { + "@concepta/typeorm-seeding": { + "optional": true + }, + "@faker-js/faker": { + "optional": true + }, + "@nestjs/cqrs": { + "optional": true + }, + "typeorm": { + "optional": true + } } } diff --git a/packages/nestjs-role/src/__fixtures__/app.module.crud.fixture.ts b/packages/nestjs-role/src/__fixtures__/app.module.crud.fixture.ts deleted file mode 100644 index cfb8ffee5..000000000 --- a/packages/nestjs-role/src/__fixtures__/app.module.crud.fixture.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { CrudModule } from '@concepta/nestjs-crud'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { - ROLE_MODULE_API_KEY_ROLE_ENTITY_KEY, - ROLE_MODULE_ROLE_ENTITY_KEY, - ROLE_MODULE_USER_ROLE_ENTITY_KEY, -} from '../role.constants'; - -import { RoleControllerFixture } from './controller/role.controller.fixture'; -import { UserRoleAssignmentControllerFixture } from './controller/user-role-assignment.controller.fixture'; -import { ApiKeyEntityFixture } from './entities/api-key-entity.fixture'; -import { ApiKeyRoleEntityFixture } from './entities/api-key-role-entity.fixture'; -import { RoleEntityFixture } from './entities/role-entity.fixture'; -import { UserEntityFixture } from './entities/user-entity.fixture'; -import { UserRoleEntityFixture } from './entities/user-role-entity.fixture'; -import { ApiKeyAssignmentCrudServiceFixture } from './service/api-key-assignment-crud.service.fixture'; -import { ApiKeyAssignmentTypeOrmCrudAdapterFixture } from './service/api-key-assignment-typeorm-crud.adapter.fixture'; -import { RoleCrudServiceFixture } from './service/role-crud.service.fixture'; -import { RoleTypeOrmCrudAdapterFixture } from './service/role-typeorm-crud.adapter.fixture'; -import { UserRoleAssignmentCrudServiceFixture } from './service/user-role-assignment-crud.service.fixture'; -import { UserRoleAssignmentTypeOrmCrudAdapterFixture } from './service/user-role-assignment-typeorm-crud.adapter.fixture'; - -@Module({ - imports: [ - TypeOrmModule.forRoot({ - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [ - RoleEntityFixture, - UserEntityFixture, - UserRoleEntityFixture, - ApiKeyEntityFixture, - ApiKeyRoleEntityFixture, - ], - }), - TypeOrmModule.forFeature([ - RoleEntityFixture, - UserEntityFixture, - UserRoleEntityFixture, - ApiKeyEntityFixture, - ApiKeyRoleEntityFixture, - ]), - TypeOrmExtModule.forFeature({ - [ROLE_MODULE_ROLE_ENTITY_KEY]: { - entity: RoleEntityFixture, - }, - [ROLE_MODULE_USER_ROLE_ENTITY_KEY]: { - entity: UserRoleEntityFixture, - }, - [ROLE_MODULE_API_KEY_ROLE_ENTITY_KEY]: { - entity: ApiKeyRoleEntityFixture, - }, - }), - CrudModule.forRoot({}), - ], - controllers: [RoleControllerFixture, UserRoleAssignmentControllerFixture], - providers: [ - RoleTypeOrmCrudAdapterFixture, - RoleCrudServiceFixture, - UserRoleAssignmentTypeOrmCrudAdapterFixture, - UserRoleAssignmentCrudServiceFixture, - ApiKeyAssignmentTypeOrmCrudAdapterFixture, - ApiKeyAssignmentCrudServiceFixture, - ], -}) -export class AppModuleCrudFixture {} diff --git a/packages/nestjs-role/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-role/src/__fixtures__/app.module.fixture.ts deleted file mode 100644 index 187655fbc..000000000 --- a/packages/nestjs-role/src/__fixtures__/app.module.fixture.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { CrudModule } from '@concepta/nestjs-crud'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { RoleModule } from '../role.module'; - -import { ApiKeyEntityFixture } from './entities/api-key-entity.fixture'; -import { ApiKeyRoleEntityFixture } from './entities/api-key-role-entity.fixture'; -import { RoleEntityFixture } from './entities/role-entity.fixture'; -import { UserEntityFixture } from './entities/user-entity.fixture'; -import { UserRoleEntityFixture } from './entities/user-role-entity.fixture'; - -@Module({ - imports: [ - TypeOrmExtModule.forRoot({ - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [ - RoleEntityFixture, - UserEntityFixture, - UserRoleEntityFixture, - ApiKeyEntityFixture, - ApiKeyRoleEntityFixture, - ], - }), - RoleModule.registerAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - role: { - entity: RoleEntityFixture, - }, - userRole: { - entity: UserRoleEntityFixture, - }, - apiKeyRole: { - entity: ApiKeyRoleEntityFixture, - }, - }), - ], - entities: ['userRole', 'apiKeyRole'], - useFactory: () => ({ - settings: { - assignments: { - user: { entityKey: 'userRole' }, - 'api-key': { entityKey: 'apiKeyRole' }, - }, - }, - }), - }), - CrudModule.forRoot({}), - ], -}) -export class AppModuleFixture {} diff --git a/packages/nestjs-role/src/__fixtures__/controller/role.controller.fixture.ts b/packages/nestjs-role/src/__fixtures__/controller/role.controller.fixture.ts deleted file mode 100644 index b87453369..000000000 --- a/packages/nestjs-role/src/__fixtures__/controller/role.controller.fixture.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { ApiTags } from '@nestjs/swagger'; - -import { - AccessControlCreateMany, - AccessControlCreateOne, - AccessControlDeleteOne, - AccessControlReadMany, - AccessControlReadOne, - AccessControlUpdateOne, -} from '@concepta/nestjs-access-control'; -import { - RoleCreatableInterface, - RoleUpdatableInterface, - RoleEntityInterface, -} from '@concepta/nestjs-common'; -import { - CrudBody, - CrudCreateOne, - CrudDeleteOne, - CrudReadOne, - CrudRequest, - CrudRequestInterface, - CrudUpdateOne, - CrudControllerInterface, - CrudController, - CrudCreateMany, - CrudReadMany, -} from '@concepta/nestjs-crud'; - -import { RoleCreateManyDto } from '../../dto/role-create-many.dto'; -import { RoleCreateDto } from '../../dto/role-create.dto'; -import { RolePaginatedDto } from '../../dto/role-paginated.dto'; -import { RoleUpdateDto } from '../../dto/role-update.dto'; -import { RoleDto } from '../../dto/role.dto'; -import { RoleResource } from '../../role.types'; -import { RoleCrudServiceFixture } from '../service/role-crud.service.fixture'; - -/** - * Role controller. - */ -@ApiTags('role') -@CrudController({ - path: 'role', - model: { - type: RoleDto, - paginatedType: RolePaginatedDto, - }, -}) -export class RoleControllerFixture - implements - CrudControllerInterface< - RoleEntityInterface, - RoleCreatableInterface, - RoleUpdatableInterface - > -{ - /** - * Constructor. - * - * @param roleCrudService - instance of the Role crud service - */ - constructor(private roleCrudService: RoleCrudServiceFixture) {} - - /** - * Get many - * - * @param crudRequest - the CRUD request object - */ - @CrudReadMany() - @AccessControlReadMany(RoleResource.Many) - async getMany(@CrudRequest() crudRequest: CrudRequestInterface) { - return this.roleCrudService.getMany(crudRequest); - } - - /** - * Get one - * - * @param crudRequest - the CRUD request object - */ - @CrudReadOne() - @AccessControlReadOne(RoleResource.One) - async getOne(@CrudRequest() crudRequest: CrudRequestInterface) { - return this.roleCrudService.getOne(crudRequest); - } - - /** - * Create many - * - * @param crudRequest - the CRUD request object - * @param roleCreateManyDto - role create many dto - */ - @CrudCreateMany() - @AccessControlCreateMany(RoleResource.Many) - async createMany( - @CrudRequest() crudRequest: CrudRequestInterface, - @CrudBody() roleCreateManyDto: RoleCreateManyDto, - ) { - // the final data - const roles = []; - - // loop all dtos - for (const roleCreateDto of roleCreateManyDto.bulk) { - // encrypt it - roles.push(roleCreateDto); - } - - // call crud service to create - return this.roleCrudService.createMany(crudRequest, { bulk: roles }); - } - - /** - * Create one - * - * @param crudRequest - the CRUD request object - * @param roleCreateDto - role create dto - */ - @CrudCreateOne() - @AccessControlCreateOne(RoleResource.One) - async createOne( - @CrudRequest() crudRequest: CrudRequestInterface, - @CrudBody() roleCreateDto: RoleCreateDto, - ) { - // call crud service to create - return this.roleCrudService.createOne(crudRequest, roleCreateDto); - } - - /** - * Update one - * - * @param crudRequest - the CRUD request object - * @param roleUpdateDto - role update dto - */ - @CrudUpdateOne() - @AccessControlUpdateOne(RoleResource.One) - async updateOne( - @CrudRequest() crudRequest: CrudRequestInterface, - @CrudBody() roleUpdateDto: RoleUpdateDto, - ) { - return this.roleCrudService.updateOne(crudRequest, roleUpdateDto); - } - - /** - * Delete one - * - * @param crudRequest - the CRUD request object - */ - @CrudDeleteOne() - @AccessControlDeleteOne(RoleResource.One) - async deleteOne(@CrudRequest() crudRequest: CrudRequestInterface) { - return this.roleCrudService.deleteOne(crudRequest); - } -} diff --git a/packages/nestjs-role/src/__fixtures__/controller/user-role-assignment.controller.fixture.ts b/packages/nestjs-role/src/__fixtures__/controller/user-role-assignment.controller.fixture.ts deleted file mode 100644 index 0ab77a64f..000000000 --- a/packages/nestjs-role/src/__fixtures__/controller/user-role-assignment.controller.fixture.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { ApiTags } from '@nestjs/swagger'; - -import { - AccessControlCreateMany, - AccessControlCreateOne, - AccessControlDeleteOne, - AccessControlReadMany, - AccessControlReadOne, -} from '@concepta/nestjs-access-control'; -import { - RoleAssignmentCreatableInterface, - RoleAssignmentInterface, -} from '@concepta/nestjs-common'; -import { - CrudBody, - CrudCreateOne, - CrudDeleteOne, - CrudReadOne, - CrudRequest, - CrudRequestInterface, - CrudControllerInterface, - CrudController, - CrudCreateMany, - CrudReadMany, -} from '@concepta/nestjs-crud'; - -import { RoleAssignmentCreateManyDto } from '../../dto/role-assignment-create-many.dto'; -import { RoleAssignmentCreateDto } from '../../dto/role-assignment-create.dto'; -import { RoleAssignmentPaginatedDto } from '../../dto/role-assignment-paginated.dto'; -import { RoleAssignmentDto } from '../../dto/role-assignment.dto'; -import { RoleAssignmentResource } from '../../role.types'; -import { UserRoleAssignmentCrudServiceFixture } from '../service/user-role-assignment-crud.service.fixture'; - -/** - * Role assignment controller. - */ -@ApiTags('role-assignment') -@CrudController({ - path: 'role-assignment/user', - model: { - type: RoleAssignmentDto, - paginatedType: RoleAssignmentPaginatedDto, - }, - params: { - id: { field: 'id', type: 'string', primary: true }, - assignment: { - field: 'assignment', - disabled: true, - }, - }, -}) -export class UserRoleAssignmentControllerFixture - implements - CrudControllerInterface< - RoleAssignmentInterface, - RoleAssignmentCreatableInterface, - never, - never - > -{ - /** - * Constructor. - * - * @param userRoleAssignmentCrudService User role assignment crud service - */ - constructor( - private userRoleAssignmentCrudService: UserRoleAssignmentCrudServiceFixture, - ) {} - - /** - * Get many user role assignment - * - * @param crudRequest Crud request object - * @returns Found assignments - */ - @CrudReadMany() - @AccessControlReadMany(RoleAssignmentResource.Many) - async getMany(@CrudRequest() crudRequest: CrudRequestInterface) { - return this.userRoleAssignmentCrudService.getMany(crudRequest); - } - - /** - * Get one user role assignment - * - * @param crudRequest Crud request object - * @returns Found assignment - */ - @CrudReadOne() - @AccessControlReadOne(RoleAssignmentResource.One) - async getOne(@CrudRequest() crudRequest: CrudRequestInterface) { - return this.userRoleAssignmentCrudService.getOne(crudRequest); - } - - /** - * Create many users role assignment - * - * @param crudRequest Crud request object - * @param roleAssignmentCreateDto Role assignments create DTOs - * @returns Created assignments - */ - @CrudCreateMany() - @AccessControlCreateMany(RoleAssignmentResource.Many) - async createMany( - @CrudRequest() crudRequest: CrudRequestInterface, - @CrudBody() roleAssignmentCreateDto: RoleAssignmentCreateManyDto, - ) { - // the final data - const roles = []; - - // loop all dtos - for (const roleCreateDto of roleAssignmentCreateDto.bulk) { - // encrypt it - roles.push(roleCreateDto); - } - - // call crud service to create - return this.userRoleAssignmentCrudService.createMany(crudRequest, { - bulk: roles, - }); - } - - /** - * Create one user role assignment - * - * @param crudRequest Crud request object - * @param roleAssignmentCreateDto Role assignment create DTO - * @returns Created assignment - */ - @CrudCreateOne() - @AccessControlCreateOne(RoleAssignmentResource.One) - async createOne( - @CrudRequest() crudRequest: CrudRequestInterface, - @CrudBody() roleAssignmentCreateDto: RoleAssignmentCreateDto, - ) { - // call crud service to create - return this.userRoleAssignmentCrudService.createOne( - crudRequest, - roleAssignmentCreateDto, - ); - } - - /** - * Delete one user role assignment - * - * @param crudRequest Crud request object - */ - @CrudDeleteOne() - @AccessControlDeleteOne(RoleAssignmentResource.One) - async deleteOne(@CrudRequest() crudRequest: CrudRequestInterface) { - return this.userRoleAssignmentCrudService.deleteOne(crudRequest); - } -} diff --git a/packages/nestjs-role/src/__fixtures__/entities/api-key-role-entity.fixture.ts b/packages/nestjs-role/src/__fixtures__/entities/api-key-role-entity.fixture.ts deleted file mode 100644 index c6fc332db..000000000 --- a/packages/nestjs-role/src/__fixtures__/entities/api-key-role-entity.fixture.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Entity } from 'typeorm'; - -import { RoleAssignmentSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * Api Key Role Entity Fixture - */ -@Entity() -export class ApiKeyRoleEntityFixture extends RoleAssignmentSqliteEntity {} diff --git a/packages/nestjs-role/src/__fixtures__/entities/role-entity.fixture.ts b/packages/nestjs-role/src/__fixtures__/entities/role-entity.fixture.ts deleted file mode 100644 index b2799a6b8..000000000 --- a/packages/nestjs-role/src/__fixtures__/entities/role-entity.fixture.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Entity } from 'typeorm'; - -import { RoleEntityInterface } from '@concepta/nestjs-common'; -import { RoleSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * Role Entity Fixture - */ -@Entity() -export class RoleEntityFixture - extends RoleSqliteEntity - implements RoleEntityInterface {} diff --git a/packages/nestjs-role/src/__fixtures__/entities/user-entity.fixture.ts b/packages/nestjs-role/src/__fixtures__/entities/user-entity.fixture.ts deleted file mode 100644 index 139159cba..000000000 --- a/packages/nestjs-role/src/__fixtures__/entities/user-entity.fixture.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Entity, PrimaryGeneratedColumn } from 'typeorm'; - -import { ReferenceIdInterface } from '@concepta/nestjs-common'; - -/** - * User Entity Fixture - */ -@Entity() -export class UserEntityFixture implements ReferenceIdInterface { - @PrimaryGeneratedColumn('uuid') - id!: string; -} diff --git a/packages/nestjs-role/src/__fixtures__/entities/user-role-entity.fixture.ts b/packages/nestjs-role/src/__fixtures__/entities/user-role-entity.fixture.ts deleted file mode 100644 index bb3bfaacd..000000000 --- a/packages/nestjs-role/src/__fixtures__/entities/user-role-entity.fixture.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Entity } from 'typeorm'; - -import { RoleAssignmentSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * Role Entity Fixture - */ -@Entity() -export class UserRoleEntityFixture extends RoleAssignmentSqliteEntity {} diff --git a/packages/nestjs-role/src/__fixtures__/factories/user-role.factory.fixture.ts b/packages/nestjs-role/src/__fixtures__/factories/user-role.factory.fixture.ts deleted file mode 100644 index 7ab8a34bf..000000000 --- a/packages/nestjs-role/src/__fixtures__/factories/user-role.factory.fixture.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Factory } from '@concepta/typeorm-seeding'; - -import { UserRoleEntityFixture } from '../entities/user-role-entity.fixture'; - -export class UserRoleFactoryFixture extends Factory { - options = { entity: UserRoleEntityFixture }; -} diff --git a/packages/nestjs-role/src/__fixtures__/factories/user.factory.fixture.ts b/packages/nestjs-role/src/__fixtures__/factories/user.factory.fixture.ts deleted file mode 100644 index 20a23f535..000000000 --- a/packages/nestjs-role/src/__fixtures__/factories/user.factory.fixture.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Factory } from '@concepta/typeorm-seeding'; - -import { UserEntityFixture } from '../entities/user-entity.fixture'; - -export class UserFactoryFixture extends Factory { - options = { entity: UserEntityFixture }; -} diff --git a/packages/nestjs-role/src/__fixtures__/service/api-key-assignment-crud.service.fixture.ts b/packages/nestjs-role/src/__fixtures__/service/api-key-assignment-crud.service.fixture.ts deleted file mode 100644 index bdf509ea3..000000000 --- a/packages/nestjs-role/src/__fixtures__/service/api-key-assignment-crud.service.fixture.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Inject } from '@nestjs/common'; - -import { RoleAssignmentInterface } from '@concepta/nestjs-common'; -import { CrudService } from '@concepta/nestjs-crud'; -import { CrudAdapter } from '@concepta/nestjs-crud/dist/crud/adapters/crud.adapter'; - -import { ApiKeyAssignmentTypeOrmCrudAdapterFixture } from './api-key-assignment-typeorm-crud.adapter.fixture'; - -/** - * Api key assignment CRUD service - */ -export class ApiKeyAssignmentCrudServiceFixture extends CrudService { - /** - * Constructor - * - * @param crudAdapter Crud adapter for api key assignment entities - */ - constructor( - @Inject(ApiKeyAssignmentTypeOrmCrudAdapterFixture) - protected readonly crudAdapter: CrudAdapter, - ) { - super(crudAdapter); - } -} diff --git a/packages/nestjs-role/src/__fixtures__/service/api-key-assignment-typeorm-crud.adapter.fixture.ts b/packages/nestjs-role/src/__fixtures__/service/api-key-assignment-typeorm-crud.adapter.fixture.ts deleted file mode 100644 index 8be1550be..000000000 --- a/packages/nestjs-role/src/__fixtures__/service/api-key-assignment-typeorm-crud.adapter.fixture.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - InjectDynamicRepository, - RoleAssignmentInterface, -} from '@concepta/nestjs-common'; -import { TypeOrmCrudAdapter } from '@concepta/nestjs-crud'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { ROLE_MODULE_API_KEY_ROLE_ENTITY_KEY } from '../../role.constants'; - -/** - * Role assignment CRUD service - */ -export class ApiKeyAssignmentTypeOrmCrudAdapterFixture extends TypeOrmCrudAdapter { - /** - * Constructor - * - * @param repoAdapter Crud adapter for api key assignment entities - */ - constructor( - @InjectDynamicRepository(ROLE_MODULE_API_KEY_ROLE_ENTITY_KEY) - repoAdapter: TypeOrmRepositoryAdapter, - ) { - super(repoAdapter); - } -} diff --git a/packages/nestjs-role/src/__fixtures__/service/role-crud.service.fixture.ts b/packages/nestjs-role/src/__fixtures__/service/role-crud.service.fixture.ts deleted file mode 100644 index 37308092c..000000000 --- a/packages/nestjs-role/src/__fixtures__/service/role-crud.service.fixture.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { RoleEntityInterface } from '@concepta/nestjs-common'; -import { CrudService } from '@concepta/nestjs-crud'; -import { CrudAdapter } from '@concepta/nestjs-crud/dist/crud/adapters/crud.adapter'; - -import { RoleTypeOrmCrudAdapterFixture } from './role-typeorm-crud.adapter.fixture'; - -/** - * Role CRUD service - */ -@Injectable() -export class RoleCrudServiceFixture extends CrudService { - /** - * Constructor - * - * @param crudAdapter - instance of the role repository. - */ - constructor( - @Inject(RoleTypeOrmCrudAdapterFixture) - crudAdapter: CrudAdapter, - ) { - super(crudAdapter); - } -} diff --git a/packages/nestjs-role/src/__fixtures__/service/role-typeorm-crud.adapter.fixture.ts b/packages/nestjs-role/src/__fixtures__/service/role-typeorm-crud.adapter.fixture.ts deleted file mode 100644 index 2cf263e40..000000000 --- a/packages/nestjs-role/src/__fixtures__/service/role-typeorm-crud.adapter.fixture.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - InjectDynamicRepository, - RoleEntityInterface, -} from '@concepta/nestjs-common'; -import { TypeOrmCrudAdapter } from '@concepta/nestjs-crud'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { ROLE_MODULE_ROLE_ENTITY_KEY } from '../../role.constants'; - -/** - * Role TypeOrm CRUD adapter fixture - */ -@Injectable() -export class RoleTypeOrmCrudAdapterFixture extends TypeOrmCrudAdapter { - /** - * Constructor - * - * @param roleRepoAdapter - instance of the role repository adapter. - */ - constructor( - @InjectDynamicRepository(ROLE_MODULE_ROLE_ENTITY_KEY) - roleRepoAdapter: TypeOrmRepositoryAdapter, - ) { - super(roleRepoAdapter); - } -} diff --git a/packages/nestjs-role/src/__fixtures__/service/user-role-assignment-crud.service.fixture.ts b/packages/nestjs-role/src/__fixtures__/service/user-role-assignment-crud.service.fixture.ts deleted file mode 100644 index 562e64888..000000000 --- a/packages/nestjs-role/src/__fixtures__/service/user-role-assignment-crud.service.fixture.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Inject } from '@nestjs/common'; - -import { RoleAssignmentInterface } from '@concepta/nestjs-common'; -import { CrudService } from '@concepta/nestjs-crud'; -import { CrudAdapter } from '@concepta/nestjs-crud/dist/crud/adapters/crud.adapter'; - -import { UserRoleAssignmentTypeOrmCrudAdapterFixture } from './user-role-assignment-typeorm-crud.adapter.fixture'; - -/** - * Role assignment CRUD service - */ -export class UserRoleAssignmentCrudServiceFixture extends CrudService { - /** - * Constructor - * - * @param crudAdapter Crud service for role assignment entities - */ - constructor( - @Inject(UserRoleAssignmentTypeOrmCrudAdapterFixture) - protected readonly crudAdapter: CrudAdapter, - ) { - super(crudAdapter); - } -} diff --git a/packages/nestjs-role/src/__fixtures__/service/user-role-assignment-typeorm-crud.adapter.fixture.ts b/packages/nestjs-role/src/__fixtures__/service/user-role-assignment-typeorm-crud.adapter.fixture.ts deleted file mode 100644 index 2f5f324d3..000000000 --- a/packages/nestjs-role/src/__fixtures__/service/user-role-assignment-typeorm-crud.adapter.fixture.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - InjectDynamicRepository, - RoleAssignmentInterface, -} from '@concepta/nestjs-common'; -import { TypeOrmCrudAdapter } from '@concepta/nestjs-crud'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { ROLE_MODULE_USER_ROLE_ENTITY_KEY } from '../../role.constants'; - -/** - * Role assignment CRUD service - */ -export class UserRoleAssignmentTypeOrmCrudAdapterFixture extends TypeOrmCrudAdapter { - /** - * Constructor - * - * @param repoAdapter Crud adapter for role assignment entities - */ - constructor( - @InjectDynamicRepository(ROLE_MODULE_USER_ROLE_ENTITY_KEY) - repoAdapter: TypeOrmRepositoryAdapter, - ) { - super(repoAdapter); - } -} diff --git a/packages/nestjs-role/src/__tests__/exception-fault.spec.ts b/packages/nestjs-role/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..7d6ea799d --- /dev/null +++ b/packages/nestjs-role/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,73 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { RoleAssignmentConflictException } from '../application/exceptions/role-assignment-conflict.exception.js'; +import { RoleAssignmentNotFoundException } from '../application/exceptions/role-assignment-not-found.exception.js'; +import { RoleAssignmentsConflictException } from '../application/exceptions/role-assignments-conflict.exception.js'; +import { RoleNotFoundException } from '../application/exceptions/role-not-found.exception.js'; +import { RoleException } from '../application/exceptions/role.exception.js'; +import { RoleEntityNotFoundException } from '../infrastructure/exceptions/role-entity-not-found.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'RoleException (default)', + build: () => new RoleException(), + fault: 'internal', + }, + { + name: 'RoleAssignmentConflictException', + build: () => new RoleAssignmentConflictException('roleId', 'assigneeId'), + fault: 'client', + }, + { + name: 'RoleAssignmentNotFoundException', + build: () => new RoleAssignmentNotFoundException('assignmentId'), + fault: 'client', + }, + { + name: 'RoleAssignmentsConflictException', + build: () => new RoleAssignmentsConflictException('assigneeId'), + fault: 'client', + }, + { + name: 'RoleNotFoundException', + build: () => new RoleNotFoundException({ id: 'id' }), + fault: 'client', + }, + { + name: 'RoleEntityNotFoundException', + build: () => new RoleEntityNotFoundException('SomeEntity'), + fault: 'usage', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-role/src/__fixtures__/entities/api-key-entity.fixture.ts b/packages/nestjs-role/src/__tests__/fixtures/entities/api-key-entity.fixture.ts similarity index 77% rename from packages/nestjs-role/src/__fixtures__/entities/api-key-entity.fixture.ts rename to packages/nestjs-role/src/__tests__/fixtures/entities/api-key-entity.fixture.ts index 8c3f48df9..67bbc688f 100644 --- a/packages/nestjs-role/src/__fixtures__/entities/api-key-entity.fixture.ts +++ b/packages/nestjs-role/src/__tests__/fixtures/entities/api-key-entity.fixture.ts @@ -1,6 +1,6 @@ import { Entity, PrimaryGeneratedColumn } from 'typeorm'; -import { ReferenceIdInterface } from '@concepta/nestjs-common'; +import { ReferenceIdInterface } from '@concepta/nestjs-core'; /** * Api Key Entity Fixture diff --git a/packages/nestjs-role/src/__tests__/fixtures/entities/api-key-role-entity.fixture.ts b/packages/nestjs-role/src/__tests__/fixtures/entities/api-key-role-entity.fixture.ts new file mode 100644 index 000000000..8ea042051 --- /dev/null +++ b/packages/nestjs-role/src/__tests__/fixtures/entities/api-key-role-entity.fixture.ts @@ -0,0 +1,9 @@ +import { Entity } from 'typeorm'; + +import { RoleAssignmentSqliteEntity } from '../../../infrastructure/persistence/typeorm/role-assignment-sqlite.entity.js'; + +/** + * Api Key Role Entity Fixture + */ +@Entity() +export class ApiKeyRoleEntityFixture extends RoleAssignmentSqliteEntity {} diff --git a/packages/nestjs-role/src/__tests__/fixtures/entities/role-entity.fixture.ts b/packages/nestjs-role/src/__tests__/fixtures/entities/role-entity.fixture.ts new file mode 100644 index 000000000..d7dceda7e --- /dev/null +++ b/packages/nestjs-role/src/__tests__/fixtures/entities/role-entity.fixture.ts @@ -0,0 +1,9 @@ +import { Entity } from 'typeorm'; + +import { RoleSqliteEntity } from '../../../infrastructure/persistence/typeorm/role-sqlite.entity.js'; + +/** + * Role Entity Fixture + */ +@Entity() +export class RoleEntityFixture extends RoleSqliteEntity {} diff --git a/packages/nestjs-role/src/__tests__/fixtures/entities/user-entity.fixture.ts b/packages/nestjs-role/src/__tests__/fixtures/entities/user-entity.fixture.ts new file mode 100644 index 000000000..a010efc68 --- /dev/null +++ b/packages/nestjs-role/src/__tests__/fixtures/entities/user-entity.fixture.ts @@ -0,0 +1,15 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +import { ReferenceIdInterface } from '@concepta/nestjs-core'; + +/** + * User Entity Fixture + */ +@Entity() +export class UserEntityFixture implements ReferenceIdInterface { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ default: false }) + isActive!: boolean; +} diff --git a/packages/nestjs-role/src/__tests__/fixtures/entities/user-role-entity.fixture.ts b/packages/nestjs-role/src/__tests__/fixtures/entities/user-role-entity.fixture.ts new file mode 100644 index 000000000..6321a0943 --- /dev/null +++ b/packages/nestjs-role/src/__tests__/fixtures/entities/user-role-entity.fixture.ts @@ -0,0 +1,9 @@ +import { Entity } from 'typeorm'; + +import { RoleAssignmentSqliteEntity } from '../../../infrastructure/persistence/typeorm/role-assignment-sqlite.entity.js'; + +/** + * User Role Entity Fixture + */ +@Entity() +export class UserRoleEntityFixture extends RoleAssignmentSqliteEntity {} diff --git a/packages/nestjs-role/src/__tests__/fixtures/factories/user-role.factory.fixture.ts b/packages/nestjs-role/src/__tests__/fixtures/factories/user-role.factory.fixture.ts new file mode 100644 index 000000000..a5a605ded --- /dev/null +++ b/packages/nestjs-role/src/__tests__/fixtures/factories/user-role.factory.fixture.ts @@ -0,0 +1,9 @@ +import { Factory } from '@concepta/typeorm-seeding'; + +import { UserRoleEntityFixture } from '../entities/user-role-entity.fixture.js'; + +export class UserRoleFactoryFixture extends Factory { + protected options = { + entity: UserRoleEntityFixture, + }; +} diff --git a/packages/nestjs-role/src/__tests__/fixtures/factories/user.factory.fixture.ts b/packages/nestjs-role/src/__tests__/fixtures/factories/user.factory.fixture.ts new file mode 100644 index 000000000..ef7c39e03 --- /dev/null +++ b/packages/nestjs-role/src/__tests__/fixtures/factories/user.factory.fixture.ts @@ -0,0 +1,9 @@ +import { Factory } from '@concepta/typeorm-seeding'; + +import { UserEntityFixture } from '../entities/user-entity.fixture.js'; + +export class UserFactoryFixture extends Factory { + protected options = { + entity: UserEntityFixture, + }; +} diff --git a/packages/nestjs-role/src/__tests__/fixtures/role.seeder.fixture.ts b/packages/nestjs-role/src/__tests__/fixtures/role.seeder.fixture.ts new file mode 100644 index 000000000..558cbdfb1 --- /dev/null +++ b/packages/nestjs-role/src/__tests__/fixtures/role.seeder.fixture.ts @@ -0,0 +1,21 @@ +import { Seeder } from '@concepta/typeorm-seeding'; + +import { RoleFactory } from '../../infrastructure/persistence/role.factory.js'; + +/** + * Role seeder + */ +export class RoleSeederFixture extends Seeder { + /** + * Runner + */ + public async run(): Promise { + const createAmount = process.env?.ROLE_MODULE_SEEDER_AMOUNT + ? Number(process.env.ROLE_MODULE_SEEDER_AMOUNT) + : 50; + + const roleFactory = this.factory(RoleFactory); + + await roleFactory.createMany(createAmount); + } +} diff --git a/packages/nestjs-role/src/__tests__/helpers/mock.helpers.ts b/packages/nestjs-role/src/__tests__/helpers/mock.helpers.ts new file mode 100644 index 000000000..0d21086c2 --- /dev/null +++ b/packages/nestjs-role/src/__tests__/helpers/mock.helpers.ts @@ -0,0 +1,97 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { + createTestEventContext, + createMockCommandBus, + createMockEventPublisher, + createMockQueryBus, +} from '@concepta/nestjs-core/testing'; +import { createMockTransaction } from '@concepta/nestjs-repository/testing'; + +import { type RoleAssignmentEntityInterface } from '../../domain/interfaces/role-assignment-entity.interface.js'; +import { type RoleEntityInterface } from '../../domain/interfaces/role-entity.interface.js'; +import { type RoleAssignmentRepositoryResolver } from '../../infrastructure/persistence/role-assignment-repository.resolver.js'; +import { RoleAssignmentMapper } from '../../infrastructure/persistence/role-assignment.mapper.js'; +import { type RoleAssignmentRepository } from '../../infrastructure/persistence/role-assignment.repository.js'; +import { type RoleRepositoryResolver } from '../../infrastructure/persistence/role-repository.resolver.js'; +import { RoleMapper } from '../../infrastructure/persistence/role.mapper.js'; +import { type RoleRepository } from '../../infrastructure/persistence/role.repository.js'; + +export const DEFAULT_ROLE_NAMESPACE = 'Role'; + +export { + createMockCommandBus, + createMockEventPublisher, + createMockQueryBus, + createMockTransaction, +}; +export type { MockTransactionHandle } from '@concepta/nestjs-repository/testing'; + +export function createMockRoleRepository(): DeepMockProxy { + return mockDeep(); +} + +export function createMockRoleAssignmentRepository(): DeepMockProxy { + return mockDeep(); +} + +export function createMockRoleRepositoryResolver( + repo: RoleRepository, +): DeepMockProxy { + const resolver = mockDeep(); + resolver.resolve.mockReturnValue(repo); + return resolver; +} + +export function createMockAssignmentRepositoryResolver( + repo: RoleAssignmentRepository, +): DeepMockProxy { + const resolver = mockDeep(); + resolver.resolve.mockReturnValue(repo); + return resolver; +} + +export function createMockEventContext(namespace = DEFAULT_ROLE_NAMESPACE) { + return createTestEventContext({ namespace }, {}); +} + +export function createMockRoleEntity( + overrides: Partial = {}, +): RoleEntityInterface { + return { + id: 'test-role-id', + name: 'Test Role', + description: 'A test role', + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + ...overrides, + }; +} + +export function createMockRoleAssignmentEntity( + overrides: Partial = {}, +): RoleAssignmentEntityInterface { + return { + id: 'test-assignment-id', + roleId: 'test-role-id', + assigneeId: 'test-assignee-id', + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + ...overrides, + }; +} + +const roleMapper = new RoleMapper(); +const roleAssignmentMapper = new RoleAssignmentMapper(); + +export function toRoleDomain(entity: RoleEntityInterface) { + return roleMapper.toDomain(entity); +} + +export function toRoleAssignmentDomain(entity: RoleAssignmentEntityInterface) { + return roleAssignmentMapper.toDomain(entity); +} diff --git a/packages/nestjs-role/src/application/commands/handlers/__tests__/assign-role.handler.spec.ts b/packages/nestjs-role/src/application/commands/handlers/__tests__/assign-role.handler.spec.ts new file mode 100644 index 000000000..6a6c68a9e --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/__tests__/assign-role.handler.spec.ts @@ -0,0 +1,69 @@ +import { + createMockRoleAssignmentRepository, + createMockAssignmentRepositoryResolver, + createMockTransaction, + createMockEventPublisher, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { RoleAssignmentConflictException } from '../../../exceptions/role-assignment-conflict.exception.js'; +import { AssignRoleCommand } from '../../impl/assign-role.command.js'; +import { AssignRoleHandler } from '../assign-role.handler.js'; + +describe(AssignRoleHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: AssignRoleHandler; + let trxHandle: ReturnType['trxHandle']; + + beforeEach(() => { + mockRepo = createMockRoleAssignmentRepository(); + const { transaction, trxHandle: trx } = createMockTransaction(); + trxHandle = trx; + + handler = new AssignRoleHandler( + createMockAssignmentRepositoryResolver(mockRepo), + transaction as never, + createMockEventPublisher() as never, + ); + }); + + it('should create and save a role assignment', async () => { + mockRepo.countByRoleIdAndAssignee.mockResolvedValue(0); + + const result = await handler.execute( + new AssignRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'role-1', 'user-1'), + ); + + expect(mockRepo.save).toHaveBeenCalledTimes(1); + expect(result.toPlain()).toEqual({ + id: expect.any(String), + roleId: 'role-1', + assigneeId: 'user-1', + dateCreated: expect.any(Date), + dateUpdated: expect.any(Date), + dateDeleted: null, + version: 1, + }); + }); + + it('should register onCommit and onRollback', async () => { + mockRepo.countByRoleIdAndAssignee.mockResolvedValue(0); + + await handler.execute( + new AssignRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'role-1', 'user-1'), + ); + + expect(trxHandle.onCommit).toHaveBeenCalledTimes(1); + expect(trxHandle.onRollback).toHaveBeenCalledTimes(1); + }); + + it('should throw RoleAssignmentConflictException when already assigned', async () => { + mockRepo.countByRoleIdAndAssignee.mockResolvedValue(1); + + await expect( + handler.execute( + new AssignRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'role-1', 'user-1'), + ), + ).rejects.toThrow(RoleAssignmentConflictException); + }); +}); diff --git a/packages/nestjs-role/src/application/commands/handlers/__tests__/assign-roles.handler.spec.ts b/packages/nestjs-role/src/application/commands/handlers/__tests__/assign-roles.handler.spec.ts new file mode 100644 index 000000000..e73603839 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/__tests__/assign-roles.handler.spec.ts @@ -0,0 +1,123 @@ +import { + createMockRoleAssignmentRepository, + createMockAssignmentRepositoryResolver, + createMockTransaction, + createMockEventPublisher, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { type RoleAssignedEvent } from '../../../../domain/events/role-assigned.event.js'; +import { RoleAssignmentsConflictException } from '../../../exceptions/role-assignments-conflict.exception.js'; +import { AssignRolesCommand } from '../../impl/assign-roles.command.js'; +import { AssignRolesHandler } from '../assign-roles.handler.js'; + +describe(AssignRolesHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: AssignRolesHandler; + let trxHandle: ReturnType['trxHandle']; + + beforeEach(() => { + mockRepo = createMockRoleAssignmentRepository(); + const { transaction, trxHandle: trx } = createMockTransaction(); + trxHandle = trx; + + handler = new AssignRolesHandler( + createMockAssignmentRepositoryResolver(mockRepo), + transaction as never, + createMockEventPublisher() as never, + ); + }); + + it('should create and save multiple role assignments', async () => { + mockRepo.countByRoleIdsAndAssignee.mockResolvedValue(0); + + const result = await handler.execute( + new AssignRolesCommand( + ctx, + DEFAULT_ROLE_NAMESPACE, + ['role-1', 'role-2'], + 'user-1', + ), + ); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.toPlain())).toEqual([ + { + id: expect.any(String), + roleId: 'role-1', + assigneeId: 'user-1', + dateCreated: expect.any(Date), + dateUpdated: expect.any(Date), + dateDeleted: null, + version: 1, + }, + { + id: expect.any(String), + roleId: 'role-2', + assigneeId: 'user-1', + dateCreated: expect.any(Date), + dateUpdated: expect.any(Date), + dateDeleted: null, + version: 1, + }, + ]); + + expect(mockRepo.saveMany).toHaveBeenCalledTimes(1); + }); + + it('should register onCommit and onRollback', async () => { + mockRepo.countByRoleIdsAndAssignee.mockResolvedValue(0); + + await handler.execute( + new AssignRolesCommand(ctx, DEFAULT_ROLE_NAMESPACE, ['role-1'], 'user-1'), + ); + + expect(trxHandle.onCommit).toHaveBeenCalledTimes(1); + expect(trxHandle.onRollback).toHaveBeenCalledTimes(1); + }); + + it('should share one correlationId/causationId across all resulting events', async () => { + mockRepo.countByRoleIdsAndAssignee.mockResolvedValue(0); + + const result = await handler.execute( + new AssignRolesCommand( + ctx, + DEFAULT_ROLE_NAMESPACE, + ['role-1', 'role-2'], + 'user-1', + ), + ); + + const eventContexts = result.map((roleAssignment) => { + const [event] = roleAssignment.getUncommittedEvents() as [ + RoleAssignedEvent, + ]; + return event.eventContext; + }); + + const [first, ...rest] = eventContexts; + for (const eventContext of rest) { + expect(eventContext.getHeader('correlationId')).toBe( + first.getHeader('correlationId'), + ); + expect(eventContext.getHeader('causationId')).toBe( + first.getHeader('causationId'), + ); + } + }); + + it('should throw RoleAssignmentsConflictException when any already assigned', async () => { + mockRepo.countByRoleIdsAndAssignee.mockResolvedValue(1); + + await expect( + handler.execute( + new AssignRolesCommand( + ctx, + DEFAULT_ROLE_NAMESPACE, + ['role-1', 'role-2'], + 'user-1', + ), + ), + ).rejects.toThrow(RoleAssignmentsConflictException); + }); +}); diff --git a/packages/nestjs-role/src/application/commands/handlers/__tests__/create-role.handler.spec.ts b/packages/nestjs-role/src/application/commands/handlers/__tests__/create-role.handler.spec.ts new file mode 100644 index 000000000..e0223830e --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/__tests__/create-role.handler.spec.ts @@ -0,0 +1,71 @@ +import { + createMockRoleRepository, + createMockRoleRepositoryResolver, + createMockTransaction, + createMockEventPublisher, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Role } from '../../../../domain/aggregates/role.js'; +import { CreateRoleCommand } from '../../impl/create-role.command.js'; +import { CreateRoleHandler } from '../create-role.handler.js'; + +describe(CreateRoleHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: CreateRoleHandler; + let trxHandle: ReturnType['trxHandle']; + + beforeEach(() => { + mockRepo = createMockRoleRepository(); + const { transaction, trxHandle: trx } = createMockTransaction(); + trxHandle = trx; + + handler = new CreateRoleHandler( + createMockRoleRepositoryResolver(mockRepo), + transaction as never, + createMockEventPublisher() as never, + ); + }); + + it('should return a Role instance with correct properties', async () => { + const dto = { name: 'Admin', description: 'Administrator role' }; + + const result = await handler.execute( + new CreateRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, dto), + ); + + expect(result).toBeInstanceOf(Role); + expect(result.name).toBe('Admin'); + expect(result.description).toBe('Administrator role'); + }); + + it('should save and return the created role', async () => { + const dto = { name: 'Admin', description: 'Admin' }; + + const result = await handler.execute( + new CreateRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, dto), + ); + + expect(mockRepo.save).toHaveBeenCalledTimes(1); + expect(result.toPlain()).toEqual({ + id: expect.any(String), + name: 'Admin', + description: 'Admin', + dateCreated: expect.any(Date), + dateUpdated: expect.any(Date), + dateDeleted: null, + version: 1, + }); + }); + + it('should register onCommit and onRollback', async () => { + const dto = { name: 'Admin', description: 'Admin' }; + + await handler.execute( + new CreateRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, dto), + ); + + expect(trxHandle.onCommit).toHaveBeenCalledTimes(1); + expect(trxHandle.onRollback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nestjs-role/src/application/commands/handlers/__tests__/remove-role.handler.spec.ts b/packages/nestjs-role/src/application/commands/handlers/__tests__/remove-role.handler.spec.ts new file mode 100644 index 000000000..fe35aa739 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/__tests__/remove-role.handler.spec.ts @@ -0,0 +1,54 @@ +import { AppContextHost } from '@concepta/nestjs-core'; + +import { + createMockRoleRepository, + createMockRoleRepositoryResolver, + createMockTransaction, + createMockRoleEntity, + toRoleDomain, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { RoleNotFoundException } from '../../../exceptions/role-not-found.exception.js'; +import { RemoveRoleCommand } from '../../impl/remove-role.command.js'; +import { RemoveRoleHandler } from '../remove-role.handler.js'; + +describe(RemoveRoleHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: RemoveRoleHandler; + + beforeEach(() => { + mockRepo = createMockRoleRepository(); + const { transaction } = createMockTransaction(); + + handler = new RemoveRoleHandler( + createMockRoleRepositoryResolver(mockRepo), + transaction as never, + ); + }); + + it('should remove the role', async () => { + const existing = toRoleDomain(createMockRoleEntity()); + mockRepo.get.mockResolvedValue(existing); + + await handler.execute( + new RemoveRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'test-role-id'), + ); + + expect(mockRepo.remove).toHaveBeenCalledTimes(1); + expect(mockRepo.remove).toHaveBeenCalledWith( + expect.any(AppContextHost), + existing, + ); + }); + + it('should throw RoleNotFoundException when role does not exist', async () => { + mockRepo.get.mockResolvedValue(null); + + await expect( + handler.execute( + new RemoveRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'missing-id'), + ), + ).rejects.toThrow(RoleNotFoundException); + }); +}); diff --git a/packages/nestjs-role/src/application/commands/handlers/__tests__/replace-role.handler.spec.ts b/packages/nestjs-role/src/application/commands/handlers/__tests__/replace-role.handler.spec.ts new file mode 100644 index 000000000..91c7d200b --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/__tests__/replace-role.handler.spec.ts @@ -0,0 +1,104 @@ +import { + createMockRoleRepository, + createMockRoleRepositoryResolver, + createMockTransaction, + createMockEventPublisher, + createMockRoleEntity, + toRoleDomain, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Role } from '../../../../domain/aggregates/role.js'; +import { ReplaceRoleCommand } from '../../impl/replace-role.command.js'; +import { ReplaceRoleHandler } from '../replace-role.handler.js'; + +describe(ReplaceRoleHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: ReplaceRoleHandler; + let trxHandle: ReturnType['trxHandle']; + + beforeEach(() => { + mockRepo = createMockRoleRepository(); + const { transaction, trxHandle: trx } = createMockTransaction(); + trxHandle = trx; + + handler = new ReplaceRoleHandler( + createMockRoleRepositoryResolver(mockRepo), + transaction as never, + createMockEventPublisher() as never, + ); + }); + + it('should replace an existing role', async () => { + const existing = toRoleDomain( + createMockRoleEntity({ name: 'OldName', description: 'OldDesc' }), + ); + mockRepo.get.mockResolvedValue(existing); + + const dto = { name: 'NewName', description: 'NewDesc' }; + const result = await handler.execute( + new ReplaceRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'test-role-id', dto), + ); + + expect(result).toBeInstanceOf(Role); + expect(result.name).toBe('NewName'); + expect(result.description).toBe('NewDesc'); + }); + + it('should save and return the replaced role', async () => { + const existing = toRoleDomain( + createMockRoleEntity({ name: 'OldName', description: 'OldDesc' }), + ); + mockRepo.get.mockResolvedValue(existing); + + const dto = { name: 'NewName', description: 'NewDesc' }; + const result = await handler.execute( + new ReplaceRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'test-role-id', dto), + ); + + expect(mockRepo.save).toHaveBeenCalledTimes(1); + expect(result.toPlain()).toEqual({ + id: 'test-role-id', + name: 'NewName', + description: 'NewDesc', + dateCreated: new Date('2026-01-01'), + dateUpdated: expect.any(Date), + dateDeleted: null, + version: 2, + }); + }); + + it('should create a new role when none exists', async () => { + mockRepo.get.mockResolvedValue(null); + + const dto = { name: 'Brand New', description: 'Created via replace' }; + const result = await handler.execute( + new ReplaceRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'new-role-id', dto), + ); + + expect(result).toBeInstanceOf(Role); + expect(mockRepo.save).toHaveBeenCalledTimes(1); + expect(result.toPlain()).toEqual({ + id: 'new-role-id', + name: 'Brand New', + description: 'Created via replace', + dateCreated: expect.any(Date), + dateUpdated: expect.any(Date), + dateDeleted: null, + version: 1, + }); + }); + + it('should register onCommit and onRollback', async () => { + const existing = toRoleDomain(createMockRoleEntity()); + mockRepo.get.mockResolvedValue(existing); + + const dto = { name: 'Replaced', description: 'Replaced' }; + await handler.execute( + new ReplaceRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'test-role-id', dto), + ); + + expect(trxHandle.onCommit).toHaveBeenCalledTimes(1); + expect(trxHandle.onRollback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nestjs-role/src/application/commands/handlers/__tests__/revoke-role.handler.spec.ts b/packages/nestjs-role/src/application/commands/handlers/__tests__/revoke-role.handler.spec.ts new file mode 100644 index 000000000..9956aa43e --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/__tests__/revoke-role.handler.spec.ts @@ -0,0 +1,85 @@ +import { AppContextHost } from '@concepta/nestjs-core'; + +import { + createMockRoleAssignmentRepository, + createMockAssignmentRepositoryResolver, + createMockTransaction, + createMockEventPublisher, + createMockRoleAssignmentEntity, + toRoleAssignmentDomain, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { RevokeRoleCommand } from '../../impl/revoke-role.command.js'; +import { RevokeRoleHandler } from '../revoke-role.handler.js'; + +describe(RevokeRoleHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: RevokeRoleHandler; + let trxHandle: ReturnType['trxHandle']; + + beforeEach(() => { + mockRepo = createMockRoleAssignmentRepository(); + const { transaction, trxHandle: trx } = createMockTransaction(); + trxHandle = trx; + + handler = new RevokeRoleHandler( + createMockAssignmentRepositoryResolver(mockRepo), + transaction as never, + createMockEventPublisher() as never, + ); + }); + + it('should revoke and remove the assignment', async () => { + const existing = toRoleAssignmentDomain( + createMockRoleAssignmentEntity({ + roleId: 'role-1', + assigneeId: 'user-1', + }), + ); + mockRepo.findOne.mockResolvedValue(existing); + + await handler.execute( + new RevokeRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'role-1', 'user-1'), + ); + + expect(mockRepo.findOne).toHaveBeenCalledTimes(1); + const [findCtx, findRoleId, findAssigneeId] = + mockRepo.findOne.mock.calls[0]; + expect(findCtx).toBeInstanceOf(AppContextHost); + expect(findRoleId).toBe('role-1'); + expect(findAssigneeId).toBe('user-1'); + + expect(mockRepo.remove).toHaveBeenCalledTimes(1); + const [removeCtx, removeEntity] = mockRepo.remove.mock.calls[0]; + expect(removeCtx).toBeInstanceOf(AppContextHost); + expect(removeEntity).toBe(existing); + }); + + it('should register onCommit and onRollback', async () => { + const existing = toRoleAssignmentDomain(createMockRoleAssignmentEntity()); + mockRepo.findOne.mockResolvedValue(existing); + + await handler.execute( + new RevokeRoleCommand( + ctx, + DEFAULT_ROLE_NAMESPACE, + 'test-role-id', + 'test-assignee-id', + ), + ); + + expect(trxHandle.onCommit).toHaveBeenCalledTimes(1); + expect(trxHandle.onRollback).toHaveBeenCalledTimes(1); + }); + + it('should do nothing when assignment not found', async () => { + mockRepo.findOne.mockResolvedValue(null); + + await handler.execute( + new RevokeRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'role-1', 'user-1'), + ); + + expect(mockRepo.remove).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nestjs-role/src/application/commands/handlers/__tests__/revoke-roles.handler.spec.ts b/packages/nestjs-role/src/application/commands/handlers/__tests__/revoke-roles.handler.spec.ts new file mode 100644 index 000000000..3feac6854 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/__tests__/revoke-roles.handler.spec.ts @@ -0,0 +1,164 @@ +import { AppContextHost } from '@concepta/nestjs-core'; + +import { + createMockRoleAssignmentRepository, + createMockAssignmentRepositoryResolver, + createMockTransaction, + createMockEventPublisher, + createMockRoleAssignmentEntity, + toRoleAssignmentDomain, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { type RoleAssignment } from '../../../../domain/aggregates/role-assignment.js'; +import { type RoleRevokedEvent } from '../../../../domain/events/role-revoked.event.js'; +import { RevokeRolesCommand } from '../../impl/revoke-roles.command.js'; +import { RevokeRolesHandler } from '../revoke-roles.handler.js'; + +describe(RevokeRolesHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: RevokeRolesHandler; + let trxHandle: ReturnType['trxHandle']; + + beforeEach(() => { + mockRepo = createMockRoleAssignmentRepository(); + const { transaction, trxHandle: trx } = createMockTransaction(); + trxHandle = trx; + + handler = new RevokeRolesHandler( + createMockAssignmentRepositoryResolver(mockRepo), + transaction as never, + createMockEventPublisher() as never, + ); + }); + + it('should revoke and remove all matching assignments', async () => { + const assignment1 = toRoleAssignmentDomain( + createMockRoleAssignmentEntity({ + id: 'a1', + roleId: 'role-1', + assigneeId: 'user-1', + }), + ); + const assignment2 = toRoleAssignmentDomain( + createMockRoleAssignmentEntity({ + id: 'a2', + roleId: 'role-2', + assigneeId: 'user-1', + }), + ); + mockRepo.findByRoleIdsAndAssignee.mockResolvedValue([ + assignment1, + assignment2, + ]); + + await handler.execute( + new RevokeRolesCommand( + ctx, + DEFAULT_ROLE_NAMESPACE, + ['role-1', 'role-2'], + 'user-1', + ), + ); + + expect(mockRepo.findByRoleIdsAndAssignee).toHaveBeenCalledTimes(1); + const [findCtx, findRoleIds, findAssigneeId] = + mockRepo.findByRoleIdsAndAssignee.mock.calls[0]; + expect(findCtx).toBeInstanceOf(AppContextHost); + expect(findRoleIds).toEqual(['role-1', 'role-2']); + expect(findAssigneeId).toBe('user-1'); + + expect(mockRepo.removeMany).toHaveBeenCalledTimes(1); + const [removeCtx, removeEntities] = mockRepo.removeMany.mock.calls[0]; + expect(removeCtx).toBeInstanceOf(AppContextHost); + expect(removeEntities).toEqual([assignment1, assignment2]); + }); + + it('should register onCommit and onRollback', async () => { + const assignment = toRoleAssignmentDomain(createMockRoleAssignmentEntity()); + mockRepo.findByRoleIdsAndAssignee.mockResolvedValue([assignment]); + + await handler.execute( + new RevokeRolesCommand( + ctx, + DEFAULT_ROLE_NAMESPACE, + ['test-role-id'], + 'test-assignee-id', + ), + ); + + expect(trxHandle.onCommit).toHaveBeenCalledTimes(1); + expect(trxHandle.onRollback).toHaveBeenCalledTimes(1); + }); + + it('should share one correlationId/causationId across all resulting events', async () => { + const assignment1 = toRoleAssignmentDomain( + createMockRoleAssignmentEntity({ + id: 'a1', + roleId: 'role-1', + assigneeId: 'user-1', + }), + ); + const assignment2 = toRoleAssignmentDomain( + createMockRoleAssignmentEntity({ + id: 'a2', + roleId: 'role-2', + assigneeId: 'user-1', + }), + ); + mockRepo.findByRoleIdsAndAssignee.mockResolvedValue([ + assignment1, + assignment2, + ]); + + await handler.execute( + new RevokeRolesCommand( + ctx, + DEFAULT_ROLE_NAMESPACE, + ['role-1', 'role-2'], + 'user-1', + ), + ); + + const [, removeEntities] = mockRepo.removeMany.mock.calls[0] as [ + unknown, + RoleAssignment[], + ]; + + const eventContexts = removeEntities.map((ra) => { + const [event] = ra.getUncommittedEvents() as [RoleRevokedEvent]; + return event.eventContext; + }); + + const [first, ...rest] = eventContexts; + for (const eventContext of rest) { + expect(eventContext.getHeader('correlationId')).toBe( + first.getHeader('correlationId'), + ); + expect(eventContext.getHeader('causationId')).toBe( + first.getHeader('causationId'), + ); + } + }); + + it('should only remove found assignments', async () => { + const assignment = toRoleAssignmentDomain( + createMockRoleAssignmentEntity({ roleId: 'role-1' }), + ); + mockRepo.findByRoleIdsAndAssignee.mockResolvedValue([assignment]); + + await handler.execute( + new RevokeRolesCommand( + ctx, + DEFAULT_ROLE_NAMESPACE, + ['role-1', 'role-2'], + 'user-1', + ), + ); + + expect(mockRepo.removeMany).toHaveBeenCalledTimes(1); + const [removeCtx2, removeEntities2] = mockRepo.removeMany.mock.calls[0]; + expect(removeCtx2).toBeInstanceOf(AppContextHost); + expect(removeEntities2).toEqual([assignment]); + }); +}); diff --git a/packages/nestjs-role/src/application/commands/handlers/__tests__/update-role.handler.spec.ts b/packages/nestjs-role/src/application/commands/handlers/__tests__/update-role.handler.spec.ts new file mode 100644 index 000000000..51a275f0e --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/__tests__/update-role.handler.spec.ts @@ -0,0 +1,95 @@ +import { + createMockRoleRepository, + createMockRoleRepositoryResolver, + createMockTransaction, + createMockEventPublisher, + createMockRoleEntity, + toRoleDomain, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Role } from '../../../../domain/aggregates/role.js'; +import { RoleNotFoundException } from '../../../exceptions/role-not-found.exception.js'; +import { UpdateRoleCommand } from '../../impl/update-role.command.js'; +import { UpdateRoleHandler } from '../update-role.handler.js'; + +describe(UpdateRoleHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: UpdateRoleHandler; + let trxHandle: ReturnType['trxHandle']; + + beforeEach(() => { + mockRepo = createMockRoleRepository(); + const { transaction, trxHandle: trx } = createMockTransaction(); + trxHandle = trx; + + handler = new UpdateRoleHandler( + createMockRoleRepositoryResolver(mockRepo), + transaction as never, + createMockEventPublisher() as never, + ); + }); + + it('should return an updated Role instance', async () => { + const existing = toRoleDomain( + createMockRoleEntity({ name: 'OldName', description: 'OldDesc' }), + ); + mockRepo.get.mockResolvedValue(existing); + + const dto = { name: 'NewName' }; + const result = await handler.execute( + new UpdateRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'test-role-id', dto), + ); + + expect(result).toBeInstanceOf(Role); + expect(result.name).toBe('NewName'); + expect(result.description).toBe('OldDesc'); + }); + + it('should save and return the updated role', async () => { + const existing = toRoleDomain( + createMockRoleEntity({ name: 'OldName', description: 'OldDesc' }), + ); + mockRepo.get.mockResolvedValue(existing); + + const dto = { name: 'NewName', description: 'NewDesc' }; + const result = await handler.execute( + new UpdateRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'test-role-id', dto), + ); + + expect(mockRepo.save).toHaveBeenCalledTimes(1); + expect(result.toPlain()).toEqual({ + id: 'test-role-id', + name: 'NewName', + description: 'NewDesc', + dateCreated: new Date('2026-01-01'), + dateUpdated: expect.any(Date), + dateDeleted: null, + version: 2, + }); + }); + + it('should register onCommit and onRollback', async () => { + const existing = toRoleDomain(createMockRoleEntity()); + mockRepo.get.mockResolvedValue(existing); + + const dto = { name: 'Updated' }; + await handler.execute( + new UpdateRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'test-role-id', dto), + ); + + expect(trxHandle.onCommit).toHaveBeenCalledTimes(1); + expect(trxHandle.onRollback).toHaveBeenCalledTimes(1); + }); + + it('should throw RoleNotFoundException when role does not exist', async () => { + mockRepo.get.mockResolvedValue(null); + + const dto = { name: 'NewName' }; + await expect( + handler.execute( + new UpdateRoleCommand(ctx, DEFAULT_ROLE_NAMESPACE, 'missing-id', dto), + ), + ).rejects.toThrow(RoleNotFoundException); + }); +}); diff --git a/packages/nestjs-role/src/application/commands/handlers/assign-role.handler.ts b/packages/nestjs-role/src/application/commands/handlers/assign-role.handler.ts new file mode 100644 index 000000000..21c1ba648 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/assign-role.handler.ts @@ -0,0 +1,51 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { RoleAssignment } from '../../../domain/aggregates/role-assignment.js'; +import { RoleAssignmentRepositoryResolverInterface } from '../../../domain/repositories/role-assignment-repository-resolver.interface.js'; +import { ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { RoleAssignmentConflictException } from '../../exceptions/role-assignment-conflict.exception.js'; +import { AssignRoleCommand } from '../impl/assign-role.command.js'; + +@CommandHandler(AssignRoleCommand) +export class AssignRoleHandler implements ICommandHandler { + constructor( + @Inject(ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleAssignmentRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: AssignRoleCommand): Promise { + const { ctx, namespace, roleId, assigneeId } = command; + const assignmentRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const count = await assignmentRepo.countByRoleIdAndAssignee( + txCtx, + roleId, + assigneeId, + ); + + if (count > 0) { + throw new RoleAssignmentConflictException(roleId, assigneeId); + } + + const roleAssignment = this.eventPublisher.mergeObjectContext( + RoleAssignment.create(eventContext, { roleId, assigneeId }), + ); + + await assignmentRepo.save(txCtx, roleAssignment); + + txCtx.trx.onCommit(() => roleAssignment.commit()); + txCtx.trx.onRollback(() => roleAssignment.uncommit()); + + return roleAssignment; + }); + } +} diff --git a/packages/nestjs-role/src/application/commands/handlers/assign-roles.handler.ts b/packages/nestjs-role/src/application/commands/handlers/assign-roles.handler.ts new file mode 100644 index 000000000..451d5044f --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/assign-roles.handler.ts @@ -0,0 +1,55 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { RoleAssignment } from '../../../domain/aggregates/role-assignment.js'; +import { RoleAssignmentRepositoryResolverInterface } from '../../../domain/repositories/role-assignment-repository-resolver.interface.js'; +import { ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { RoleAssignmentsConflictException } from '../../exceptions/role-assignments-conflict.exception.js'; +import { AssignRolesCommand } from '../impl/assign-roles.command.js'; + +@CommandHandler(AssignRolesCommand) +export class AssignRolesHandler implements ICommandHandler { + constructor( + @Inject(ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleAssignmentRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: AssignRolesCommand): Promise { + const { ctx, namespace, roleIds, assigneeId } = command; + const assignmentRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const existingCount = await assignmentRepo.countByRoleIdsAndAssignee( + txCtx, + roleIds, + assigneeId, + ); + + if (existingCount > 0) { + throw new RoleAssignmentsConflictException(assigneeId); + } + + const roleAssignments = roleIds.map((roleId) => + this.eventPublisher.mergeObjectContext( + RoleAssignment.create(eventContext, { roleId, assigneeId }), + ), + ); + + await assignmentRepo.saveMany(txCtx, roleAssignments); + + txCtx.trx.onCommit(() => roleAssignments.forEach((ra) => ra.commit())); + txCtx.trx.onRollback(() => + roleAssignments.forEach((ra) => ra.uncommit()), + ); + + return roleAssignments; + }); + } +} diff --git a/packages/nestjs-role/src/application/commands/handlers/create-role.handler.ts b/packages/nestjs-role/src/application/commands/handlers/create-role.handler.ts new file mode 100644 index 000000000..dfdee3156 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/create-role.handler.ts @@ -0,0 +1,40 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { Role } from '../../../domain/aggregates/role.js'; +import { RoleRepositoryResolverInterface } from '../../../domain/repositories/role-repository-resolver.interface.js'; +import { ROLE_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { CreateRoleCommand } from '../impl/create-role.command.js'; + +@CommandHandler(CreateRoleCommand) +export class CreateRoleHandler implements ICommandHandler { + constructor( + @Inject(ROLE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: CreateRoleCommand): Promise { + const { ctx, namespace, dto } = command; + const roleRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const role = this.eventPublisher.mergeObjectContext( + Role.create(eventContext, dto), + ); + + await roleRepo.save(txCtx, role); + + txCtx.trx.onCommit(() => role.commit()); + txCtx.trx.onRollback(() => role.uncommit()); + + return role; + }); + } +} diff --git a/packages/nestjs-role/src/application/commands/handlers/remove-role.handler.ts b/packages/nestjs-role/src/application/commands/handlers/remove-role.handler.ts new file mode 100644 index 000000000..9c1450238 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/remove-role.handler.ts @@ -0,0 +1,33 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { RoleRepositoryResolverInterface } from '../../../domain/repositories/role-repository-resolver.interface.js'; +import { ROLE_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { RoleNotFoundException } from '../../exceptions/role-not-found.exception.js'; +import { RemoveRoleCommand } from '../impl/remove-role.command.js'; + +@CommandHandler(RemoveRoleCommand) +export class RemoveRoleHandler implements ICommandHandler { + constructor( + @Inject(ROLE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleRepositoryResolverInterface, + private readonly txScope: TransactionScope, + ) {} + + async execute(command: RemoveRoleCommand): Promise { + const { ctx, namespace, id } = command; + const roleRepo = this.repositoryResolver.resolve(namespace); + + return this.txScope.run(ctx, async (txCtx) => { + const role = await roleRepo.get(txCtx, id); + + if (!role) { + throw new RoleNotFoundException({ id: String(id) }); + } + + await roleRepo.remove(txCtx, role); + }); + } +} diff --git a/packages/nestjs-role/src/application/commands/handlers/replace-role.handler.ts b/packages/nestjs-role/src/application/commands/handlers/replace-role.handler.ts new file mode 100644 index 000000000..f75e4a8be --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/replace-role.handler.ts @@ -0,0 +1,48 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { Role } from '../../../domain/aggregates/role.js'; +import { RoleRepositoryResolverInterface } from '../../../domain/repositories/role-repository-resolver.interface.js'; +import { ROLE_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { ReplaceRoleCommand } from '../impl/replace-role.command.js'; + +@CommandHandler(ReplaceRoleCommand) +export class ReplaceRoleHandler implements ICommandHandler { + constructor( + @Inject(ROLE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: ReplaceRoleCommand): Promise { + const { ctx, namespace, id, dto } = command; + const roleRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const existing = await roleRepo.get(txCtx, id); + let role: Role; + + if (existing) { + role = this.eventPublisher.mergeObjectContext(existing); + role.replace(eventContext, dto); + } else { + role = this.eventPublisher.mergeObjectContext( + Role.createWithId(eventContext, String(id), dto), + ); + } + + await roleRepo.save(txCtx, role); + + txCtx.trx.onCommit(() => role.commit()); + txCtx.trx.onRollback(() => role.uncommit()); + + return role; + }); + } +} diff --git a/packages/nestjs-role/src/application/commands/handlers/revoke-role.handler.ts b/packages/nestjs-role/src/application/commands/handlers/revoke-role.handler.ts new file mode 100644 index 000000000..ad268c7a3 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/revoke-role.handler.ts @@ -0,0 +1,42 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { RoleAssignmentRepositoryResolverInterface } from '../../../domain/repositories/role-assignment-repository-resolver.interface.js'; +import { ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { RevokeRoleCommand } from '../impl/revoke-role.command.js'; + +@CommandHandler(RevokeRoleCommand) +export class RevokeRoleHandler implements ICommandHandler { + constructor( + @Inject(ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleAssignmentRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: RevokeRoleCommand): Promise { + const { ctx, namespace, roleId, assigneeId } = command; + const assignmentRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const roleAsmnt = await assignmentRepo.findOne(txCtx, roleId, assigneeId); + + if (!roleAsmnt) { + return; + } + + const roleAsmntMerged = this.eventPublisher.mergeObjectContext(roleAsmnt); + roleAsmntMerged.revoke(eventContext); + + await assignmentRepo.remove(txCtx, roleAsmntMerged); + + txCtx.trx.onCommit(() => roleAsmntMerged.commit()); + txCtx.trx.onRollback(() => roleAsmntMerged.uncommit()); + }); + } +} diff --git a/packages/nestjs-role/src/application/commands/handlers/revoke-roles.handler.ts b/packages/nestjs-role/src/application/commands/handlers/revoke-roles.handler.ts new file mode 100644 index 000000000..e69aa5846 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/revoke-roles.handler.ts @@ -0,0 +1,47 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { RoleAssignmentRepositoryResolverInterface } from '../../../domain/repositories/role-assignment-repository-resolver.interface.js'; +import { ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { RevokeRolesCommand } from '../impl/revoke-roles.command.js'; + +@CommandHandler(RevokeRolesCommand) +export class RevokeRolesHandler implements ICommandHandler { + constructor( + @Inject(ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleAssignmentRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: RevokeRolesCommand): Promise { + const { ctx, namespace, roleIds, assigneeId } = command; + const assignmentRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const roleAssignments = await assignmentRepo.findByRoleIdsAndAssignee( + txCtx, + roleIds, + assigneeId, + ); + + const mergedAssignments = roleAssignments.map((ra) => { + const mergedAssignment = this.eventPublisher.mergeObjectContext(ra); + mergedAssignment.revoke(eventContext); + return mergedAssignment; + }); + + await assignmentRepo.removeMany(txCtx, mergedAssignments); + + txCtx.trx.onCommit(() => mergedAssignments.forEach((ra) => ra.commit())); + txCtx.trx.onRollback(() => + mergedAssignments.forEach((ra) => ra.uncommit()), + ); + }); + } +} diff --git a/packages/nestjs-role/src/application/commands/handlers/update-role.handler.ts b/packages/nestjs-role/src/application/commands/handlers/update-role.handler.ts new file mode 100644 index 000000000..c3c1f77c1 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/handlers/update-role.handler.ts @@ -0,0 +1,47 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { Role } from '../../../domain/aggregates/role.js'; +import { RoleRepositoryResolverInterface } from '../../../domain/repositories/role-repository-resolver.interface.js'; +import { ROLE_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { RoleNotFoundException } from '../../exceptions/role-not-found.exception.js'; +import { UpdateRoleCommand } from '../impl/update-role.command.js'; + +@CommandHandler(UpdateRoleCommand) +export class UpdateRoleHandler implements ICommandHandler { + constructor( + @Inject(ROLE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleRepositoryResolverInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: UpdateRoleCommand): Promise { + const { ctx, namespace, id, dto } = command; + const roleRepo = this.repositoryResolver.resolve(namespace); + + const eventContext = createEventContext(ctx, { namespace }, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const existing = await roleRepo.get(txCtx, id); + + if (!existing) { + throw new RoleNotFoundException({ id: String(id) }); + } + + const role = this.eventPublisher.mergeObjectContext(existing); + + role.update(eventContext, dto); + + await roleRepo.save(txCtx, role); + + txCtx.trx.onCommit(() => role.commit()); + txCtx.trx.onRollback(() => role.uncommit()); + + return role; + }); + } +} diff --git a/packages/nestjs-role/src/application/commands/impl/assign-role.command.ts b/packages/nestjs-role/src/application/commands/impl/assign-role.command.ts new file mode 100644 index 000000000..053a14edf --- /dev/null +++ b/packages/nestjs-role/src/application/commands/impl/assign-role.command.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type RoleAssignment } from '../../../domain/aggregates/role-assignment.js'; + +export class AssignRoleCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly roleId: string, + public readonly assigneeId: string, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/commands/impl/assign-roles.command.ts b/packages/nestjs-role/src/application/commands/impl/assign-roles.command.ts new file mode 100644 index 000000000..701c80af7 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/impl/assign-roles.command.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type RoleAssignment } from '../../../domain/aggregates/role-assignment.js'; + +export class AssignRolesCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly roleIds: string[], + public readonly assigneeId: string, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/commands/impl/create-role.command.ts b/packages/nestjs-role/src/application/commands/impl/create-role.command.ts new file mode 100644 index 000000000..1c1b718fa --- /dev/null +++ b/packages/nestjs-role/src/application/commands/impl/create-role.command.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { + type Role, + type RoleCreateProps, +} from '../../../domain/aggregates/role.js'; + +export class CreateRoleCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly dto: RoleCreateProps, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/commands/impl/remove-role.command.ts b/packages/nestjs-role/src/application/commands/impl/remove-role.command.ts new file mode 100644 index 000000000..b0ea53363 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/impl/remove-role.command.ts @@ -0,0 +1,14 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +export class RemoveRoleCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/commands/impl/replace-role.command.ts b/packages/nestjs-role/src/application/commands/impl/replace-role.command.ts new file mode 100644 index 000000000..189b08802 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/impl/replace-role.command.ts @@ -0,0 +1,20 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { + type Role, + type RoleCreateProps, +} from '../../../domain/aggregates/role.js'; + +export class ReplaceRoleCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly id: ReferenceId, + public readonly dto: RoleCreateProps, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/commands/impl/revoke-role.command.ts b/packages/nestjs-role/src/application/commands/impl/revoke-role.command.ts new file mode 100644 index 000000000..14b46e445 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/impl/revoke-role.command.ts @@ -0,0 +1,13 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +export class RevokeRoleCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly roleId: string, + public readonly assigneeId: string, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/commands/impl/revoke-roles.command.ts b/packages/nestjs-role/src/application/commands/impl/revoke-roles.command.ts new file mode 100644 index 000000000..b43e8c03a --- /dev/null +++ b/packages/nestjs-role/src/application/commands/impl/revoke-roles.command.ts @@ -0,0 +1,13 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +export class RevokeRolesCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly roleIds: string[], + public readonly assigneeId: string, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/commands/impl/update-role.command.ts b/packages/nestjs-role/src/application/commands/impl/update-role.command.ts new file mode 100644 index 000000000..6c414cd21 --- /dev/null +++ b/packages/nestjs-role/src/application/commands/impl/update-role.command.ts @@ -0,0 +1,20 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { + type Role, + type RoleCreateProps, +} from '../../../domain/aggregates/role.js'; + +export class UpdateRoleCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly id: ReferenceId, + public readonly dto: Partial, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/exceptions/__tests__/role-exceptions.spec.ts b/packages/nestjs-role/src/application/exceptions/__tests__/role-exceptions.spec.ts new file mode 100644 index 000000000..235396744 --- /dev/null +++ b/packages/nestjs-role/src/application/exceptions/__tests__/role-exceptions.spec.ts @@ -0,0 +1,89 @@ +import { HttpStatus } from '@nestjs/common'; + +import { RoleEntityNotFoundException } from '../../../infrastructure/exceptions/role-entity-not-found.exception.js'; +import { RoleAssignmentConflictException } from '../role-assignment-conflict.exception.js'; +import { RoleAssignmentNotFoundException } from '../role-assignment-not-found.exception.js'; +import { RoleAssignmentsConflictException } from '../role-assignments-conflict.exception.js'; +import { RoleNotFoundException } from '../role-not-found.exception.js'; +import { RoleException } from '../role.exception.js'; + +describe('Role Exceptions', () => { + describe(RoleException.name, () => { + it('should have errorCode ROLE_ERROR', () => { + const error = new RoleException(); + expect(error.errorCode).toBe('ROLE_ERROR'); + }); + + it('should accept a custom message', () => { + const error = new RoleException({ + message: 'Custom: %s', + messageParams: ['test'], + }); + expect(error.message).toBe('Custom: test'); + }); + }); + + describe(RoleNotFoundException.name, () => { + it('should have 404 status and correct context', () => { + const error = new RoleNotFoundException({ id: 'role-123' }); + + expect(error.errorCode).toBe('ROLE_NOT_FOUND_ERROR'); + expect(error.httpStatus).toBe(HttpStatus.NOT_FOUND); + expect(error.context.id).toBe('role-123'); + expect(error.message).toContain('role-123'); + }); + + it('should accept a custom message', () => { + const error = new RoleNotFoundException({ + id: 'role-123', + message: 'Gone: %s', + }); + expect(error.message).toBe('Gone: role-123'); + }); + }); + + describe(RoleAssignmentNotFoundException.name, () => { + it('should have 404 status and correct context', () => { + const error = new RoleAssignmentNotFoundException('assign-456'); + + expect(error.errorCode).toBe('ROLE_ASSIGNMENT_NOT_FOUND_ERROR'); + expect(error.httpStatus).toBe(HttpStatus.NOT_FOUND); + expect(error.context.assignmentId).toBe('assign-456'); + expect(error.message).toContain('assign-456'); + }); + }); + + describe(RoleAssignmentConflictException.name, () => { + it('should have 409 status and correct context', () => { + const error = new RoleAssignmentConflictException('role-1', 'user-1'); + + expect(error.errorCode).toBe('ROLE_ASSIGNMENT_CONFLICT_ERROR'); + expect(error.httpStatus).toBe(HttpStatus.CONFLICT); + expect(error.context.roleId).toBe('role-1'); + expect(error.context.assigneeId).toBe('user-1'); + expect(error.message).toContain('role-1'); + expect(error.message).toContain('user-1'); + }); + }); + + describe(RoleAssignmentsConflictException.name, () => { + it('should have 409 status and correct context', () => { + const error = new RoleAssignmentsConflictException('user-1'); + + expect(error.errorCode).toBe('ROLE_ASSIGNMENTS_CONFLICT_ERROR'); + expect(error.httpStatus).toBe(HttpStatus.CONFLICT); + expect(error.context.assigneeId).toBe('user-1'); + expect(error.message).toContain('user-1'); + }); + }); + + describe(RoleEntityNotFoundException.name, () => { + it('should have correct errorCode and context', () => { + const error = new RoleEntityNotFoundException('UserRoleEntity'); + + expect(error.errorCode).toBe('ROLE_ENTITY_NOT_FOUND_ERROR'); + expect(error.context.entityName).toBe('UserRoleEntity'); + expect(error.message).toContain('UserRoleEntity'); + }); + }); +}); diff --git a/packages/nestjs-role/src/application/exceptions/role-assignment-conflict.exception.ts b/packages/nestjs-role/src/application/exceptions/role-assignment-conflict.exception.ts new file mode 100644 index 000000000..9f333e2c0 --- /dev/null +++ b/packages/nestjs-role/src/application/exceptions/role-assignment-conflict.exception.ts @@ -0,0 +1,37 @@ +import { HttpStatus } from '@nestjs/common'; + +import { + type RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +import { RoleException } from './role.exception.js'; + +export class RoleAssignmentConflictException extends RoleException { + declare context: RuntimeException['context'] & { + roleId: string; + assigneeId: string; + }; + + constructor( + roleId: string, + assigneeId: string, + options?: RuntimeExceptionOptions, + ) { + super({ + message: 'Role %s is already assigned to assignee %s.', + messageParams: [roleId, assigneeId], + httpStatus: HttpStatus.CONFLICT, + fault: 'client', + ...options, + }); + + this.errorCode = 'ROLE_ASSIGNMENT_CONFLICT_ERROR'; + + this.context = { + ...this.context, + roleId, + assigneeId, + }; + } +} diff --git a/packages/nestjs-role/src/application/exceptions/role-assignment-not-found.exception.ts b/packages/nestjs-role/src/application/exceptions/role-assignment-not-found.exception.ts new file mode 100644 index 000000000..04d7abd60 --- /dev/null +++ b/packages/nestjs-role/src/application/exceptions/role-assignment-not-found.exception.ts @@ -0,0 +1,31 @@ +import { HttpStatus } from '@nestjs/common'; + +import { + type RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +import { RoleException } from './role.exception.js'; + +export class RoleAssignmentNotFoundException extends RoleException { + declare context: RuntimeException['context'] & { + assignmentId: string; + }; + + constructor(assignmentId: string, options?: RuntimeExceptionOptions) { + super({ + message: 'Role assignment not found for id=%s.', + messageParams: [assignmentId], + httpStatus: HttpStatus.NOT_FOUND, + fault: 'client', + ...options, + }); + + this.errorCode = 'ROLE_ASSIGNMENT_NOT_FOUND_ERROR'; + + this.context = { + ...this.context, + assignmentId, + }; + } +} diff --git a/packages/nestjs-role/src/application/exceptions/role-assignments-conflict.exception.ts b/packages/nestjs-role/src/application/exceptions/role-assignments-conflict.exception.ts new file mode 100644 index 000000000..77944681b --- /dev/null +++ b/packages/nestjs-role/src/application/exceptions/role-assignments-conflict.exception.ts @@ -0,0 +1,31 @@ +import { HttpStatus } from '@nestjs/common'; + +import { + type RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +import { RoleException } from './role.exception.js'; + +export class RoleAssignmentsConflictException extends RoleException { + declare context: RuntimeException['context'] & { + assigneeId: string; + }; + + constructor(assigneeId: string, options?: RuntimeExceptionOptions) { + super({ + message: 'One or more roles are already assigned to assignee %s.', + messageParams: [assigneeId], + httpStatus: HttpStatus.CONFLICT, + fault: 'client', + ...options, + }); + + this.errorCode = 'ROLE_ASSIGNMENTS_CONFLICT_ERROR'; + + this.context = { + ...this.context, + assigneeId, + }; + } +} diff --git a/packages/nestjs-role/src/application/exceptions/role-not-found.exception.ts b/packages/nestjs-role/src/application/exceptions/role-not-found.exception.ts new file mode 100644 index 000000000..c0f97ff90 --- /dev/null +++ b/packages/nestjs-role/src/application/exceptions/role-not-found.exception.ts @@ -0,0 +1,29 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeException } from '@concepta/nestjs-core'; + +import { RoleException } from './role.exception.js'; + +export class RoleNotFoundException extends RoleException { + declare context: RuntimeException['context'] & { + id: string; + }; + + constructor(options: { id: string; message?: string }) { + const { id, message = 'Role not found for id=%s' } = options; + + super({ + httpStatus: HttpStatus.NOT_FOUND, + message, + messageParams: [id], + fault: 'client', + }); + + this.errorCode = 'ROLE_NOT_FOUND_ERROR'; + + this.context = { + ...this.context, + id, + }; + } +} diff --git a/packages/nestjs-role/src/exceptions/role.exception.ts b/packages/nestjs-role/src/application/exceptions/role.exception.ts similarity index 75% rename from packages/nestjs-role/src/exceptions/role.exception.ts rename to packages/nestjs-role/src/application/exceptions/role.exception.ts index 34a084cd2..3e4b1ac1c 100644 --- a/packages/nestjs-role/src/exceptions/role.exception.ts +++ b/packages/nestjs-role/src/application/exceptions/role.exception.ts @@ -1,7 +1,7 @@ import { RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; export class RoleException extends RuntimeException { constructor(options?: RuntimeExceptionOptions) { diff --git a/packages/nestjs-role/src/application/queries/handlers/__tests__/get-assigned-roles.handler.spec.ts b/packages/nestjs-role/src/application/queries/handlers/__tests__/get-assigned-roles.handler.spec.ts new file mode 100644 index 000000000..bd6a91fce --- /dev/null +++ b/packages/nestjs-role/src/application/queries/handlers/__tests__/get-assigned-roles.handler.spec.ts @@ -0,0 +1,52 @@ +import { + createMockRoleAssignmentRepository, + createMockAssignmentRepositoryResolver, + createMockRoleAssignmentEntity, + toRoleAssignmentDomain, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { GetAssignedRolesQuery } from '../../impl/get-assigned-roles.query.js'; +import { GetAssignedRolesHandler } from '../get-assigned-roles.handler.js'; + +describe(GetAssignedRolesHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: GetAssignedRolesHandler; + + beforeEach(() => { + mockRepo = createMockRoleAssignmentRepository(); + + handler = new GetAssignedRolesHandler( + createMockAssignmentRepositoryResolver(mockRepo), + ); + }); + + it('should return assignments for the assignee', async () => { + const assignment1 = toRoleAssignmentDomain( + createMockRoleAssignmentEntity({ id: 'a1', roleId: 'role-1' }), + ); + const assignment2 = toRoleAssignmentDomain( + createMockRoleAssignmentEntity({ id: 'a2', roleId: 'role-2' }), + ); + mockRepo.findByAssignee.mockResolvedValue([assignment1, assignment2]); + + const result = await handler.execute( + new GetAssignedRolesQuery(ctx, DEFAULT_ROLE_NAMESPACE, 'user-1'), + ); + + expect(mockRepo.findByAssignee).toHaveBeenCalledWith(ctx, 'user-1'); + expect(result).toHaveLength(2); + expect(result[0]).toBe(assignment1); + expect(result[1]).toBe(assignment2); + }); + + it('should return empty array when no assignments', async () => { + mockRepo.findByAssignee.mockResolvedValue([]); + + const result = await handler.execute( + new GetAssignedRolesQuery(ctx, DEFAULT_ROLE_NAMESPACE, 'user-1'), + ); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/nestjs-role/src/application/queries/handlers/__tests__/get-role-assignment.handler.spec.ts b/packages/nestjs-role/src/application/queries/handlers/__tests__/get-role-assignment.handler.spec.ts new file mode 100644 index 000000000..043462b18 --- /dev/null +++ b/packages/nestjs-role/src/application/queries/handlers/__tests__/get-role-assignment.handler.spec.ts @@ -0,0 +1,59 @@ +import { + createMockRoleAssignmentRepository, + createMockAssignmentRepositoryResolver, + createMockRoleAssignmentEntity, + toRoleAssignmentDomain, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { RoleAssignment } from '../../../../domain/aggregates/role-assignment.js'; +import { RoleAssignmentNotFoundException } from '../../../exceptions/role-assignment-not-found.exception.js'; +import { GetRoleAssignmentQuery } from '../../impl/get-role-assignment.query.js'; +import { GetRoleAssignmentHandler } from '../get-role-assignment.handler.js'; + +describe(GetRoleAssignmentHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: GetRoleAssignmentHandler; + + beforeEach(() => { + mockRepo = createMockRoleAssignmentRepository(); + + handler = new GetRoleAssignmentHandler( + createMockAssignmentRepositoryResolver(mockRepo), + ); + }); + + it('should return a RoleAssignment when found', async () => { + const existing = toRoleAssignmentDomain(createMockRoleAssignmentEntity()); + mockRepo.get.mockResolvedValue(existing); + + const result = await handler.execute( + new GetRoleAssignmentQuery( + ctx, + DEFAULT_ROLE_NAMESPACE, + 'test-assignment-id', + ), + ); + + expect(result).toBeInstanceOf(RoleAssignment); + expect(result.toPlain()).toEqual({ + id: 'test-assignment-id', + roleId: 'test-role-id', + assigneeId: 'test-assignee-id', + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + }); + }); + + it('should throw RoleAssignmentNotFoundException when not found', async () => { + mockRepo.get.mockResolvedValue(null); + + await expect( + handler.execute( + new GetRoleAssignmentQuery(ctx, DEFAULT_ROLE_NAMESPACE, 'missing-id'), + ), + ).rejects.toThrow(RoleAssignmentNotFoundException); + }); +}); diff --git a/packages/nestjs-role/src/application/queries/handlers/__tests__/get-role.handler.spec.ts b/packages/nestjs-role/src/application/queries/handlers/__tests__/get-role.handler.spec.ts new file mode 100644 index 000000000..765f85f5d --- /dev/null +++ b/packages/nestjs-role/src/application/queries/handlers/__tests__/get-role.handler.spec.ts @@ -0,0 +1,53 @@ +import { + createMockRoleRepository, + createMockRoleRepositoryResolver, + createMockRoleEntity, + toRoleDomain, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { Role } from '../../../../domain/aggregates/role.js'; +import { RoleNotFoundException } from '../../../exceptions/role-not-found.exception.js'; +import { GetRoleQuery } from '../../impl/get-role.query.js'; +import { GetRoleHandler } from '../get-role.handler.js'; + +describe(GetRoleHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: GetRoleHandler; + + beforeEach(() => { + mockRepo = createMockRoleRepository(); + + handler = new GetRoleHandler(createMockRoleRepositoryResolver(mockRepo)); + }); + + it('should return a Role when found', async () => { + const existing = toRoleDomain(createMockRoleEntity()); + mockRepo.get.mockResolvedValue(existing); + + const result = await handler.execute( + new GetRoleQuery(ctx, DEFAULT_ROLE_NAMESPACE, 'test-role-id'), + ); + + expect(result).toBeInstanceOf(Role); + expect(result.toPlain()).toEqual({ + id: 'test-role-id', + name: 'Test Role', + description: 'A test role', + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + }); + }); + + it('should throw RoleNotFoundException when not found', async () => { + mockRepo.get.mockResolvedValue(null); + + await expect( + handler.execute( + new GetRoleQuery(ctx, DEFAULT_ROLE_NAMESPACE, 'missing-id'), + ), + ).rejects.toThrow(RoleNotFoundException); + }); +}); diff --git a/packages/nestjs-role/src/application/queries/handlers/__tests__/is-assigned-role.handler.spec.ts b/packages/nestjs-role/src/application/queries/handlers/__tests__/is-assigned-role.handler.spec.ts new file mode 100644 index 000000000..c8bb85f55 --- /dev/null +++ b/packages/nestjs-role/src/application/queries/handlers/__tests__/is-assigned-role.handler.spec.ts @@ -0,0 +1,46 @@ +import { + createMockRoleAssignmentRepository, + createMockAssignmentRepositoryResolver, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { IsAssignedRoleQuery } from '../../impl/is-assigned-role.query.js'; +import { IsAssignedRoleHandler } from '../is-assigned-role.handler.js'; + +describe(IsAssignedRoleHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: IsAssignedRoleHandler; + + beforeEach(() => { + mockRepo = createMockRoleAssignmentRepository(); + + handler = new IsAssignedRoleHandler( + createMockAssignmentRepositoryResolver(mockRepo), + ); + }); + + it('should return true when role is assigned', async () => { + mockRepo.countByRoleIdAndAssignee.mockResolvedValue(1); + + const result = await handler.execute( + new IsAssignedRoleQuery(ctx, DEFAULT_ROLE_NAMESPACE, 'role-1', 'user-1'), + ); + + expect(mockRepo.countByRoleIdAndAssignee).toHaveBeenCalledWith( + ctx, + 'role-1', + 'user-1', + ); + expect(result).toBe(true); + }); + + it('should return false when role is not assigned', async () => { + mockRepo.countByRoleIdAndAssignee.mockResolvedValue(0); + + const result = await handler.execute( + new IsAssignedRoleQuery(ctx, DEFAULT_ROLE_NAMESPACE, 'role-1', 'user-1'), + ); + + expect(result).toBe(false); + }); +}); diff --git a/packages/nestjs-role/src/application/queries/handlers/__tests__/is-assigned-roles.handler.spec.ts b/packages/nestjs-role/src/application/queries/handlers/__tests__/is-assigned-roles.handler.spec.ts new file mode 100644 index 000000000..10d896213 --- /dev/null +++ b/packages/nestjs-role/src/application/queries/handlers/__tests__/is-assigned-roles.handler.spec.ts @@ -0,0 +1,65 @@ +import { + createMockRoleAssignmentRepository, + createMockAssignmentRepositoryResolver, + DEFAULT_ROLE_NAMESPACE, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { IsAssignedRolesQuery } from '../../impl/is-assigned-roles.query.js'; +import { IsAssignedRolesHandler } from '../is-assigned-roles.handler.js'; + +describe(IsAssignedRolesHandler.name, () => { + const ctx = {}; + let mockRepo: ReturnType; + let handler: IsAssignedRolesHandler; + + beforeEach(() => { + mockRepo = createMockRoleAssignmentRepository(); + + handler = new IsAssignedRolesHandler( + createMockAssignmentRepositoryResolver(mockRepo), + ); + }); + + it('should return true when all roles are assigned', async () => { + mockRepo.countByRoleIdsAndAssignee.mockResolvedValue(2); + + const result = await handler.execute( + new IsAssignedRolesQuery( + ctx, + DEFAULT_ROLE_NAMESPACE, + ['role-1', 'role-2'], + 'user-1', + ), + ); + + expect(mockRepo.countByRoleIdsAndAssignee).toHaveBeenCalledWith( + ctx, + ['role-1', 'role-2'], + 'user-1', + ); + expect(result).toBe(true); + }); + + it('should return false when not all roles are assigned', async () => { + mockRepo.countByRoleIdsAndAssignee.mockResolvedValue(1); + + const result = await handler.execute( + new IsAssignedRolesQuery( + ctx, + DEFAULT_ROLE_NAMESPACE, + ['role-1', 'role-2'], + 'user-1', + ), + ); + + expect(result).toBe(false); + }); + + it('should return false when roleIds is empty', async () => { + const result = await handler.execute( + new IsAssignedRolesQuery(ctx, DEFAULT_ROLE_NAMESPACE, [], 'user-1'), + ); + + expect(result).toBe(false); + expect(mockRepo.countByRoleIdsAndAssignee).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nestjs-role/src/application/queries/handlers/get-assigned-roles.handler.ts b/packages/nestjs-role/src/application/queries/handlers/get-assigned-roles.handler.ts new file mode 100644 index 000000000..4be27cffc --- /dev/null +++ b/packages/nestjs-role/src/application/queries/handlers/get-assigned-roles.handler.ts @@ -0,0 +1,23 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { RoleAssignment } from '../../../domain/aggregates/role-assignment.js'; +import { RoleAssignmentRepositoryResolverInterface } from '../../../domain/repositories/role-assignment-repository-resolver.interface.js'; +import { ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { GetAssignedRolesQuery } from '../impl/get-assigned-roles.query.js'; + +@QueryHandler(GetAssignedRolesQuery) +export class GetAssignedRolesHandler implements IQueryHandler { + constructor( + @Inject(ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleAssignmentRepositoryResolverInterface, + ) {} + + async execute(query: GetAssignedRolesQuery): Promise { + const { ctx, namespace, assigneeId } = query; + + const assignmentRepo = this.repositoryResolver.resolve(namespace); + + return assignmentRepo.findByAssignee(ctx, assigneeId); + } +} diff --git a/packages/nestjs-role/src/application/queries/handlers/get-role-assignment.handler.ts b/packages/nestjs-role/src/application/queries/handlers/get-role-assignment.handler.ts new file mode 100644 index 000000000..e6a365978 --- /dev/null +++ b/packages/nestjs-role/src/application/queries/handlers/get-role-assignment.handler.ts @@ -0,0 +1,30 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { RoleAssignment } from '../../../domain/aggregates/role-assignment.js'; +import { RoleAssignmentRepositoryResolverInterface } from '../../../domain/repositories/role-assignment-repository-resolver.interface.js'; +import { ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { RoleAssignmentNotFoundException } from '../../exceptions/role-assignment-not-found.exception.js'; +import { GetRoleAssignmentQuery } from '../impl/get-role-assignment.query.js'; + +@QueryHandler(GetRoleAssignmentQuery) +export class GetRoleAssignmentHandler implements IQueryHandler { + constructor( + @Inject(ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleAssignmentRepositoryResolverInterface, + ) {} + + async execute(query: GetRoleAssignmentQuery): Promise { + const { ctx, namespace, id } = query; + + const assignmentRepo = this.repositoryResolver.resolve(namespace); + + const assignment = await assignmentRepo.get(ctx, id); + + if (!assignment) { + throw new RoleAssignmentNotFoundException(String(id)); + } + + return assignment; + } +} diff --git a/packages/nestjs-role/src/application/queries/handlers/get-role.handler.ts b/packages/nestjs-role/src/application/queries/handlers/get-role.handler.ts new file mode 100644 index 000000000..fec48359d --- /dev/null +++ b/packages/nestjs-role/src/application/queries/handlers/get-role.handler.ts @@ -0,0 +1,30 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { Role } from '../../../domain/aggregates/role.js'; +import { RoleRepositoryResolverInterface } from '../../../domain/repositories/role-repository-resolver.interface.js'; +import { ROLE_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { RoleNotFoundException } from '../../exceptions/role-not-found.exception.js'; +import { GetRoleQuery } from '../impl/get-role.query.js'; + +@QueryHandler(GetRoleQuery) +export class GetRoleHandler implements IQueryHandler { + constructor( + @Inject(ROLE_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleRepositoryResolverInterface, + ) {} + + async execute(query: GetRoleQuery): Promise { + const { ctx, namespace, id } = query; + + const roleRepo = this.repositoryResolver.resolve(namespace); + + const role = await roleRepo.get(ctx, id); + + if (!role) { + throw new RoleNotFoundException({ id: String(id) }); + } + + return role; + } +} diff --git a/packages/nestjs-role/src/application/queries/handlers/is-assigned-role.handler.ts b/packages/nestjs-role/src/application/queries/handlers/is-assigned-role.handler.ts new file mode 100644 index 000000000..3589f7d00 --- /dev/null +++ b/packages/nestjs-role/src/application/queries/handlers/is-assigned-role.handler.ts @@ -0,0 +1,28 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { RoleAssignmentRepositoryResolverInterface } from '../../../domain/repositories/role-assignment-repository-resolver.interface.js'; +import { ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { IsAssignedRoleQuery } from '../impl/is-assigned-role.query.js'; + +@QueryHandler(IsAssignedRoleQuery) +export class IsAssignedRoleHandler implements IQueryHandler { + constructor( + @Inject(ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleAssignmentRepositoryResolverInterface, + ) {} + + async execute(query: IsAssignedRoleQuery): Promise { + const { ctx, namespace, roleId, assigneeId } = query; + + const assignmentRepo = this.repositoryResolver.resolve(namespace); + + const count = await assignmentRepo.countByRoleIdAndAssignee( + ctx, + roleId, + assigneeId, + ); + + return count > 0; + } +} diff --git a/packages/nestjs-role/src/application/queries/handlers/is-assigned-roles.handler.ts b/packages/nestjs-role/src/application/queries/handlers/is-assigned-roles.handler.ts new file mode 100644 index 000000000..922e7c0bb --- /dev/null +++ b/packages/nestjs-role/src/application/queries/handlers/is-assigned-roles.handler.ts @@ -0,0 +1,32 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { RoleAssignmentRepositoryResolverInterface } from '../../../domain/repositories/role-assignment-repository-resolver.interface.js'; +import { ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN } from '../../../role.constants.js'; +import { IsAssignedRolesQuery } from '../impl/is-assigned-roles.query.js'; + +@QueryHandler(IsAssignedRolesQuery) +export class IsAssignedRolesHandler implements IQueryHandler { + constructor( + @Inject(ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN) + private readonly repositoryResolver: RoleAssignmentRepositoryResolverInterface, + ) {} + + async execute(query: IsAssignedRolesQuery): Promise { + const { ctx, namespace, roleIds, assigneeId } = query; + + if (roleIds.length === 0) { + return false; + } + + const assignmentRepo = this.repositoryResolver.resolve(namespace); + + const count = await assignmentRepo.countByRoleIdsAndAssignee( + ctx, + roleIds, + assigneeId, + ); + + return count === roleIds.length; + } +} diff --git a/packages/nestjs-role/src/application/queries/impl/get-assigned-roles.query.ts b/packages/nestjs-role/src/application/queries/impl/get-assigned-roles.query.ts new file mode 100644 index 000000000..01522485e --- /dev/null +++ b/packages/nestjs-role/src/application/queries/impl/get-assigned-roles.query.ts @@ -0,0 +1,14 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type RoleAssignment } from '../../../domain/aggregates/role-assignment.js'; + +export class GetAssignedRolesQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly assigneeId: string, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/queries/impl/get-role-assignment.query.ts b/packages/nestjs-role/src/application/queries/impl/get-role-assignment.query.ts new file mode 100644 index 000000000..e148debd9 --- /dev/null +++ b/packages/nestjs-role/src/application/queries/impl/get-role-assignment.query.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type RoleAssignment } from '../../../domain/aggregates/role-assignment.js'; + +export class GetRoleAssignmentQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/queries/impl/get-role.query.ts b/packages/nestjs-role/src/application/queries/impl/get-role.query.ts new file mode 100644 index 000000000..49df4f157 --- /dev/null +++ b/packages/nestjs-role/src/application/queries/impl/get-role.query.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Role } from '../../../domain/aggregates/role.js'; + +export class GetRoleQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/queries/impl/is-assigned-role.query.ts b/packages/nestjs-role/src/application/queries/impl/is-assigned-role.query.ts new file mode 100644 index 000000000..6b4146ef2 --- /dev/null +++ b/packages/nestjs-role/src/application/queries/impl/is-assigned-role.query.ts @@ -0,0 +1,13 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +export class IsAssignedRoleQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly roleId: string, + public readonly assigneeId: string, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/queries/impl/is-assigned-roles.query.ts b/packages/nestjs-role/src/application/queries/impl/is-assigned-roles.query.ts new file mode 100644 index 000000000..f2d38e655 --- /dev/null +++ b/packages/nestjs-role/src/application/queries/impl/is-assigned-roles.query.ts @@ -0,0 +1,13 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +export class IsAssignedRolesQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly namespace: string, + public readonly roleIds: string[], + public readonly assigneeId: string, + ) { + super(); + } +} diff --git a/packages/nestjs-role/src/application/utils/__tests__/assert-role-id.util.spec.ts b/packages/nestjs-role/src/application/utils/__tests__/assert-role-id.util.spec.ts new file mode 100644 index 000000000..c21ce51bb --- /dev/null +++ b/packages/nestjs-role/src/application/utils/__tests__/assert-role-id.util.spec.ts @@ -0,0 +1,41 @@ +import { HttpStatus } from '@nestjs/common'; + +import { RoleException } from '../../exceptions/role.exception.js'; +import { assertRoleId } from '../assert-role-id.util.js'; + +describe('assertRoleId', () => { + it('should not throw for a valid string id', () => { + expect(() => assertRoleId('abc-123')).not.toThrow(); + }); + + it('should throw RoleException for an empty string', () => { + expect(() => assertRoleId('')).toThrow(RoleException); + }); + + it('should throw RoleException for a whitespace-only string', () => { + expect(() => assertRoleId(' ')).toThrow(RoleException); + }); + + it('should throw RoleException for undefined', () => { + expect(() => assertRoleId(undefined)).toThrow(RoleException); + }); + + it('should throw RoleException for null', () => { + expect(() => assertRoleId(null)).toThrow(RoleException); + }); + + it('should throw RoleException for a number', () => { + expect(() => assertRoleId(42)).toThrow(RoleException); + }); + + it('should throw with httpStatus BAD_REQUEST and a safe message', () => { + try { + assertRoleId(42); + throw new Error('Expected RoleException'); + } catch (e) { + expect(e).toBeInstanceOf(RoleException); + expect((e as RoleException).httpStatus).toBe(HttpStatus.BAD_REQUEST); + expect((e as RoleException).safeMessage).toBe('Invalid id'); + } + }); +}); diff --git a/packages/nestjs-role/src/application/utils/assert-role-id.util.ts b/packages/nestjs-role/src/application/utils/assert-role-id.util.ts new file mode 100644 index 000000000..a7d9e9a89 --- /dev/null +++ b/packages/nestjs-role/src/application/utils/assert-role-id.util.ts @@ -0,0 +1,26 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { RoleException } from '../exceptions/role.exception.js'; + +/** + * Asserts that `value` is a non-empty string id. + * + * Classified `fault: 'client'` for the common case of a caller sending a + * malformed id directly. A controller whose id param is configured with + * `type: 'number'` (see `CrudParams`) will also route through here on every + * request — that's a module wiring mistake, not a client one, but the + * distinction isn't visible from inside this assertion. + */ +export function assertRoleId(value: unknown): asserts value is ReferenceId { + if (typeof value !== 'string' || value.trim() === '') { + throw new RoleException({ + message: 'Expected role id to be a non-empty string, got %s', + messageParams: [typeof value], + safeMessage: 'Invalid id', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } +} diff --git a/packages/nestjs-role/src/config/role-default.config.ts b/packages/nestjs-role/src/config/role-default.config.ts deleted file mode 100644 index 71f44a8cd..000000000 --- a/packages/nestjs-role/src/config/role-default.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { RoleSettingsInterface } from '../interfaces/role-settings.interface'; -import { ROLE_MODULE_DEFAULT_SETTINGS_TOKEN } from '../role.constants'; - -/** - * Default configuration for Role module. - */ -export const roleDefaultConfig = registerAs( - ROLE_MODULE_DEFAULT_SETTINGS_TOKEN, - (): Partial => ({}), -); diff --git a/packages/nestjs-role/src/controllers/role-assignment.controller.e2e-spec.ts b/packages/nestjs-role/src/controllers/role-assignment.controller.e2e-spec.ts deleted file mode 100644 index 21daedb6d..000000000 --- a/packages/nestjs-role/src/controllers/role-assignment.controller.e2e-spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -import assert from 'assert'; - -import supertest from 'supertest'; - -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm'; - -import { - RepositoryInterface, - RoleAssignmentCreatableInterface, -} from '@concepta/nestjs-common'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { RoleFactory } from '../role.factory'; -import { RoleSeeder } from '../role.seeder'; - -import { AppModuleCrudFixture } from '../__fixtures__/app.module.crud.fixture'; -import { RoleEntityFixture } from '../__fixtures__/entities/role-entity.fixture'; -import { UserRoleFactoryFixture } from '../__fixtures__/factories/user-role.factory.fixture'; -import { UserFactoryFixture } from '../__fixtures__/factories/user.factory.fixture'; - -describe('RoleAssignmentController (e2e)', () => { - let app: INestApplication; - let seedingSource: SeedingSource; - let roleRepo: RepositoryInterface; - let userFactory: UserFactoryFixture; - let userRoleFactory: UserRoleFactoryFixture; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleCrudFixture], - }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - - seedingSource = new SeedingSource({ - dataSource: app.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - userFactory = new UserFactoryFixture({ seedingSource }); - userRoleFactory = new UserRoleFactoryFixture({ seedingSource }); - - const roleSeeder = new RoleSeeder({ - factories: [new RoleFactory({ entity: RoleEntityFixture })], - }); - - await seedingSource.run.one(roleSeeder); - - roleRepo = app.get(getRepositoryToken(RoleEntityFixture)); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - it('GET /role-assignment/user', async () => { - const user = await userFactory.create(); - const roles = await roleRepo.find({ take: 10 }); - - for (const role of roles) { - await userRoleFactory - .map((userRole) => { - userRole.roleId = role.id; - userRole.assigneeId = user.id; - }) - .create(); - } - - await supertest(app.getHttpServer()) - .get('/role-assignment/user?limit=10') - .expect(200) - .then((res) => { - assert.strictEqual(res.body.data.length, 10); - }); - }); - - it('GET /role-assignment/user/:id', async () => { - const user = await userFactory.create(); - const roles = await roleRepo.find({ take: 1 }); - - const userRole = await userRoleFactory - .map((userRole) => { - userRole.roleId = roles[0].id; - userRole.assigneeId = user.id; - }) - .create(); - - await supertest(app.getHttpServer()) - .get(`/role-assignment/user/${userRole.id}`) - .expect(200) - .then((res) => { - assert.strictEqual(res.body.roleId, roles[0].id); - assert.strictEqual(res.body.assigneeId, user.id); - }); - }); - - it('POST /role-assignment/user', async () => { - const roles = await roleRepo.find({ take: 1 }); - const user = await userFactory.create(); - - const payload: RoleAssignmentCreatableInterface = { - roleId: roles[0].id, - assigneeId: user.id, - }; - - await supertest(app.getHttpServer()) - .post('/role-assignment/user') - .send(payload) - .expect(201) - .then((res) => { - assert.strictEqual(res.body.roleId, roles[0].id); - assert.strictEqual(res.body.assigneeId, user.id); - }); - }); - - it('DELETE /role-assignment/user/:id', async () => { - const user = await userFactory.create(); - const roles = await roleRepo.find({ take: 1 }); - - const userRole = await userRoleFactory - .map((userRole) => { - userRole.roleId = roles[0].id; - userRole.assigneeId = user.id; - }) - .create(); - - await supertest(app.getHttpServer()) - .delete(`/role-assignment/user/${userRole.id}`) - .expect(200); - }); -}); diff --git a/packages/nestjs-role/src/controllers/role.controller.e2e-spec.ts b/packages/nestjs-role/src/controllers/role.controller.e2e-spec.ts deleted file mode 100644 index eac76e667..000000000 --- a/packages/nestjs-role/src/controllers/role.controller.e2e-spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import supertest from 'supertest'; - -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { RoleFactory } from '../role.factory'; -import { RoleSeeder } from '../role.seeder'; - -import { AppModuleCrudFixture } from '../__fixtures__/app.module.crud.fixture'; -import { RoleEntityFixture } from '../__fixtures__/entities/role-entity.fixture'; - -describe('RoleController (e2e)', () => { - describe('Rest', () => { - let app: INestApplication; - let seedingSource: SeedingSource; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleCrudFixture], - }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - - seedingSource = new SeedingSource({ - dataSource: app.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - const roleFactory = new RoleFactory({ entity: RoleEntityFixture }); - - const roleSeeder = new RoleSeeder({ - factories: [roleFactory], - }); - - await seedingSource.run.one(roleSeeder); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - it('GET /role', async () => { - const response = await supertest(app.getHttpServer()) - .get('/role?limit=10') - .expect(200) - .expect((res) => res.body.data.length === 10); - expect(response); - }); - - it('GET /role/:id', async () => { - // get an role so we have an id - const response = await supertest(app.getHttpServer()) - .get('/role?limit=1') - .expect(200); - - // get one using that id - await supertest(app.getHttpServer()) - .get(`/role/${response.body.data[0].id}`) - .expect(200); - }); - - it('POST /role', async () => { - await supertest(app.getHttpServer()) - .post('/role') - .send({ - name: 'company 1', - }) - .expect(201); - }); - - it('DELETE /role/:id', async () => { - // get an role so we have an id - const response = await supertest(app.getHttpServer()) - .get('/role?limit=1') - .expect(200); - - // delete one using that id - await supertest(app.getHttpServer()) - .delete(`/role/${response.body.data[0].id}`) - .expect(200); - }); - }); -}); diff --git a/packages/nestjs-role/src/domain/aggregates/__tests__/role-assignment.spec.ts b/packages/nestjs-role/src/domain/aggregates/__tests__/role-assignment.spec.ts new file mode 100644 index 000000000..9b29b1c9a --- /dev/null +++ b/packages/nestjs-role/src/domain/aggregates/__tests__/role-assignment.spec.ts @@ -0,0 +1,89 @@ +import { + createMockEventContext, + createMockRoleAssignmentEntity, + toRoleAssignmentDomain, +} from '../../../__tests__/helpers/mock.helpers.js'; +import { RoleAssignedEvent } from '../../events/role-assigned.event.js'; +import { RoleRevokedEvent } from '../../events/role-revoked.event.js'; +import { RoleAssignment } from '../role-assignment.js'; + +describe(RoleAssignment.name, () => { + const eventContext = createMockEventContext(); + + describe('create', () => { + it('should return an instance with correct props', () => { + const assignment = RoleAssignment.create(eventContext, { + roleId: 'role-1', + assigneeId: 'user-1', + }); + + expect(assignment.toPlain()).toEqual({ + id: expect.any(String), + roleId: 'role-1', + assigneeId: 'user-1', + dateCreated: expect.any(Date), + dateUpdated: expect.any(Date), + dateDeleted: null, + version: 1, + }); + }); + + it('should apply a RoleAssignedEvent', () => { + const assignment = RoleAssignment.create(eventContext, { + roleId: 'role-1', + assigneeId: 'user-1', + }); + + const events = assignment.getUncommittedEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(RoleAssignedEvent); + }); + }); + + describe('constructor', () => { + it('should reconstitute from an entity without applying events', () => { + const entity = createMockRoleAssignmentEntity(); + const assignment = toRoleAssignmentDomain(entity); + + expect(assignment.toPlain()).toEqual(entity); + expect(assignment.getUncommittedEvents()).toHaveLength(0); + }); + }); + + describe('toPlain', () => { + it('should return an immutable copy', () => { + const entity = createMockRoleAssignmentEntity(); + const assignment = toRoleAssignmentDomain(entity); + + const plain = assignment.toPlain(); + plain.roleId = 'mutated'; + + expect(assignment.roleId).toBe(entity.roleId); + }); + }); + + describe('revoke', () => { + it('should apply a RoleRevokedEvent', () => { + const assignment = toRoleAssignmentDomain( + createMockRoleAssignmentEntity(), + ); + + assignment.revoke(eventContext); + + const events = assignment.getUncommittedEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(RoleRevokedEvent); + }); + }); + + describe('immutability', () => { + it('should not be affected by mutations to the original entity', () => { + const entity = createMockRoleAssignmentEntity(); + const assignment = toRoleAssignmentDomain(entity); + + entity.roleId = 'mutated'; + + expect(assignment.roleId).toBe('test-role-id'); + }); + }); +}); diff --git a/packages/nestjs-role/src/domain/aggregates/__tests__/role.spec.ts b/packages/nestjs-role/src/domain/aggregates/__tests__/role.spec.ts new file mode 100644 index 000000000..bf68b62a5 --- /dev/null +++ b/packages/nestjs-role/src/domain/aggregates/__tests__/role.spec.ts @@ -0,0 +1,135 @@ +import { + createMockEventContext, + createMockRoleEntity, + toRoleDomain, +} from '../../../__tests__/helpers/mock.helpers.js'; +import { RoleCreatedEvent } from '../../events/role-created.event.js'; +import { RoleReplacedEvent } from '../../events/role-replaced.event.js'; +import { RoleUpdatedEvent } from '../../events/role-updated.event.js'; +import { Role } from '../role.js'; + +describe(Role.name, () => { + const eventContext = createMockEventContext(); + + describe('createWithId', () => { + it('should return a Role with the given id and props', () => { + const role = Role.createWithId(eventContext, 'my-id', { + name: 'Admin', + description: 'Administrator', + }); + + expect(role.toPlain()).toEqual({ + id: 'my-id', + name: 'Admin', + description: 'Administrator', + dateCreated: expect.any(Date), + dateUpdated: expect.any(Date), + dateDeleted: null, + version: 1, + }); + }); + + it('should apply a RoleCreatedEvent', () => { + const role = Role.createWithId(eventContext, 'my-id', { + name: 'Admin', + description: 'Administrator', + }); + + const events = role.getUncommittedEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(RoleCreatedEvent); + }); + }); + + describe('create', () => { + it('should generate a UUID for the id', () => { + const role = Role.create(eventContext, { + name: 'Editor', + description: 'Content editor', + }); + + expect(role.id).toEqual(expect.any(String)); + expect(role.id.length).toBeGreaterThan(0); + }); + }); + + describe('constructor', () => { + it('should reconstitute from entity data without applying events', () => { + const entity = createMockRoleEntity(); + const role = toRoleDomain(entity); + + expect(role.toPlain()).toEqual(entity); + expect(role.getUncommittedEvents()).toHaveLength(0); + }); + }); + + describe('toPlain', () => { + it('should return an immutable copy of the entity', () => { + const entity = createMockRoleEntity(); + const role = toRoleDomain(entity); + + const plain = role.toPlain(); + plain.name = 'mutated'; + + expect(role.name).toBe(entity.name); + }); + }); + + describe('update', () => { + it('should merge partial props and bump version', () => { + const role = toRoleDomain(createMockRoleEntity({ version: 1 })); + + role.update(eventContext, { name: 'Updated Name' }); + + expect(role.name).toBe('Updated Name'); + expect(role.description).toBe('A test role'); + expect(role.version).toBe(2); + }); + + it('should apply a RoleUpdatedEvent', () => { + const role = toRoleDomain(createMockRoleEntity()); + + role.update(eventContext, { name: 'Updated' }); + + const events = role.getUncommittedEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(RoleUpdatedEvent); + }); + }); + + describe('replace', () => { + it('should set name and description, bump version', () => { + const role = toRoleDomain(createMockRoleEntity({ version: 3 })); + + role.replace(eventContext, { + name: 'Replaced', + description: 'Replaced desc', + }); + + expect(role.name).toBe('Replaced'); + expect(role.description).toBe('Replaced desc'); + expect(role.version).toBe(4); + }); + + it('should apply a RoleReplacedEvent', () => { + const role = toRoleDomain(createMockRoleEntity()); + + role.replace(eventContext, { name: 'R', description: 'D' }); + + const events = role.getUncommittedEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(RoleReplacedEvent); + }); + }); + + describe('immutability', () => { + it('should not be affected by mutations to the original entity', () => { + const entity = createMockRoleEntity(); + const role = toRoleDomain(entity); + + entity.name = 'mutated'; + + expect(role.name).toBe('Test Role'); + }); + }); +}); diff --git a/packages/nestjs-role/src/domain/aggregates/role-assignment.ts b/packages/nestjs-role/src/domain/aggregates/role-assignment.ts new file mode 100644 index 000000000..fe4c8d5d7 --- /dev/null +++ b/packages/nestjs-role/src/domain/aggregates/role-assignment.ts @@ -0,0 +1,60 @@ +import { randomUUID } from 'crypto'; + +import { + type DomainFactory, + type EventContextHost, +} from '@concepta/nestjs-core'; +import { DomainAggregate } from '@concepta/nestjs-core/aggregate'; + +import { type RoleEventHeaderInterface } from '../events/interfaces/role-event-header.interface.js'; +import { RoleAssignedEvent } from '../events/role-assigned.event.js'; +import { RoleRevokedEvent } from '../events/role-revoked.event.js'; +import { type RoleAssignmentInterface } from '../interfaces/role-assignment.interface.js'; + +export interface RoleAssignmentCreateProps { + roleId: string; + assigneeId: string; +} + +export class RoleAssignment extends DomainAggregate { + get roleId() { + return this.props.roleId; + } + + get assigneeId() { + return this.props.assigneeId; + } + + static create( + eventContext: EventContextHost, + props: RoleAssignmentCreateProps, + ): RoleAssignment { + return RoleAssignment.createWithId(eventContext, randomUUID(), props); + } + + static createWithId( + eventContext: EventContextHost, + id: string, + props: RoleAssignmentCreateProps, + ): RoleAssignment { + const { roleId, assigneeId } = props; + + const instance = new RoleAssignment(id, { + roleId, + assigneeId, + }); + + instance.apply(new RoleAssignedEvent(eventContext, instance.toPlain())); + + return instance; + } + + revoke(eventContext: EventContextHost): void { + this.apply(new RoleRevokedEvent(eventContext, this.toPlain())); + } +} + +RoleAssignment satisfies DomainFactory< + RoleAssignmentCreateProps, + RoleAssignment +>; diff --git a/packages/nestjs-role/src/domain/aggregates/role.ts b/packages/nestjs-role/src/domain/aggregates/role.ts new file mode 100644 index 000000000..62198d42a --- /dev/null +++ b/packages/nestjs-role/src/domain/aggregates/role.ts @@ -0,0 +1,78 @@ +import { randomUUID } from 'crypto'; + +import { + type DomainFactory, + type EventContextHost, +} from '@concepta/nestjs-core'; +import { DomainAggregate } from '@concepta/nestjs-core/aggregate'; + +import { type RoleEventHeaderInterface } from '../events/interfaces/role-event-header.interface.js'; +import { RoleCreatedEvent } from '../events/role-created.event.js'; +import { RoleReplacedEvent } from '../events/role-replaced.event.js'; +import { RoleUpdatedEvent } from '../events/role-updated.event.js'; +import { type RoleInterface } from '../interfaces/role.interface.js'; + +export interface RoleCreateProps { + name: string; + description: string; +} + +export class Role extends DomainAggregate { + get name() { + return this.props.name; + } + + get description() { + return this.props.description; + } + + static create( + eventContext: EventContextHost, + props: RoleCreateProps, + ): Role { + return Role.createWithId(eventContext, randomUUID(), props); + } + + static createWithId( + eventContext: EventContextHost, + id: string, + props: RoleCreateProps, + ): Role { + const { name, description } = props; + + const role = new Role(id, { + name, + description, + }); + + role.apply(new RoleCreatedEvent(eventContext, role.toPlain())); + + return role; + } + + update( + eventContext: EventContextHost, + dto: Partial, + ): void { + this.props = { + ...this.props, + ...dto, + }; + this.incrementVersion(); + this.apply(new RoleUpdatedEvent(eventContext, this.toPlain())); + } + + replace( + eventContext: EventContextHost, + dto: RoleCreateProps, + ): void { + this.props = { + name: dto.name, + description: dto.description, + }; + this.incrementVersion(); + this.apply(new RoleReplacedEvent(eventContext, this.toPlain())); + } +} + +Role satisfies DomainFactory; diff --git a/packages/nestjs-role/src/domain/events/interfaces/role-event-header.interface.ts b/packages/nestjs-role/src/domain/events/interfaces/role-event-header.interface.ts new file mode 100644 index 000000000..3170e84eb --- /dev/null +++ b/packages/nestjs-role/src/domain/events/interfaces/role-event-header.interface.ts @@ -0,0 +1,5 @@ +import { type EventContextHeadersInterface } from '@concepta/nestjs-core'; + +export interface RoleEventHeaderInterface extends EventContextHeadersInterface { + namespace: string; +} diff --git a/packages/nestjs-role/src/domain/events/role-assigned.event.ts b/packages/nestjs-role/src/domain/events/role-assigned.event.ts new file mode 100644 index 000000000..9c92a2704 --- /dev/null +++ b/packages/nestjs-role/src/domain/events/role-assigned.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type RoleAssignmentInterface } from '../interfaces/role-assignment.interface.js'; + +import { type RoleEventHeaderInterface } from './interfaces/role-event-header.interface.js'; + +export class RoleAssignedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly assignment: RoleAssignmentInterface, + ) {} +} diff --git a/packages/nestjs-role/src/domain/events/role-created.event.ts b/packages/nestjs-role/src/domain/events/role-created.event.ts new file mode 100644 index 000000000..15e826813 --- /dev/null +++ b/packages/nestjs-role/src/domain/events/role-created.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type RoleInterface } from '../interfaces/role.interface.js'; + +import { type RoleEventHeaderInterface } from './interfaces/role-event-header.interface.js'; + +export class RoleCreatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly role: RoleInterface, + ) {} +} diff --git a/packages/nestjs-role/src/domain/events/role-replaced.event.ts b/packages/nestjs-role/src/domain/events/role-replaced.event.ts new file mode 100644 index 000000000..9362e5eee --- /dev/null +++ b/packages/nestjs-role/src/domain/events/role-replaced.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type RoleInterface } from '../interfaces/role.interface.js'; + +import { type RoleEventHeaderInterface } from './interfaces/role-event-header.interface.js'; + +export class RoleReplacedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly role: RoleInterface, + ) {} +} diff --git a/packages/nestjs-role/src/domain/events/role-revoked.event.ts b/packages/nestjs-role/src/domain/events/role-revoked.event.ts new file mode 100644 index 000000000..a2c87a55e --- /dev/null +++ b/packages/nestjs-role/src/domain/events/role-revoked.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type RoleAssignmentInterface } from '../interfaces/role-assignment.interface.js'; + +import { type RoleEventHeaderInterface } from './interfaces/role-event-header.interface.js'; + +export class RoleRevokedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly assignment: RoleAssignmentInterface, + ) {} +} diff --git a/packages/nestjs-role/src/domain/events/role-updated.event.ts b/packages/nestjs-role/src/domain/events/role-updated.event.ts new file mode 100644 index 000000000..e6b723b37 --- /dev/null +++ b/packages/nestjs-role/src/domain/events/role-updated.event.ts @@ -0,0 +1,14 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type RoleInterface } from '../interfaces/role.interface.js'; + +import { type RoleEventHeaderInterface } from './interfaces/role-event-header.interface.js'; + +export class RoleUpdatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly role: RoleInterface, + ) {} +} diff --git a/packages/nestjs-role/src/domain/interfaces/role-assignees.interface.ts b/packages/nestjs-role/src/domain/interfaces/role-assignees.interface.ts new file mode 100644 index 000000000..e31ff5e74 --- /dev/null +++ b/packages/nestjs-role/src/domain/interfaces/role-assignees.interface.ts @@ -0,0 +1,10 @@ +import { type ReferenceIdInterface } from '@concepta/nestjs-core'; + +import { type RoleRelationInterface } from './role-relation.interface.js'; + +export interface RoleAssigneesInterface< + T extends ReferenceIdInterface & RoleRelationInterface = + ReferenceIdInterface & RoleRelationInterface, +> { + assignees: T[]; +} diff --git a/packages/nestjs-role/src/domain/interfaces/role-assignment-creatable.interface.ts b/packages/nestjs-role/src/domain/interfaces/role-assignment-creatable.interface.ts new file mode 100644 index 000000000..8462cdf48 --- /dev/null +++ b/packages/nestjs-role/src/domain/interfaces/role-assignment-creatable.interface.ts @@ -0,0 +1,6 @@ +import { type AssigneeRelationInterface } from '@concepta/nestjs-core'; + +import { type RoleRelationInterface } from './role-relation.interface.js'; + +export interface RoleAssignmentCreatableInterface + extends RoleRelationInterface, AssigneeRelationInterface {} diff --git a/packages/nestjs-role/src/domain/interfaces/role-assignment-entity.interface.ts b/packages/nestjs-role/src/domain/interfaces/role-assignment-entity.interface.ts new file mode 100644 index 000000000..a1bca535f --- /dev/null +++ b/packages/nestjs-role/src/domain/interfaces/role-assignment-entity.interface.ts @@ -0,0 +1,14 @@ +import { + type AuditInterface, + type ReferenceIdInterface, + type ReferenceVersionInterface, +} from '@concepta/nestjs-core'; + +import { type RoleAssignmentInterface } from './role-assignment.interface.js'; + +export interface RoleAssignmentEntityInterface + extends + ReferenceIdInterface, + ReferenceVersionInterface, + RoleAssignmentInterface, + AuditInterface {} diff --git a/packages/nestjs-role/src/domain/interfaces/role-assignment.interface.ts b/packages/nestjs-role/src/domain/interfaces/role-assignment.interface.ts new file mode 100644 index 000000000..98d125a5f --- /dev/null +++ b/packages/nestjs-role/src/domain/interfaces/role-assignment.interface.ts @@ -0,0 +1,6 @@ +import { type AssigneeRelationInterface } from '@concepta/nestjs-core'; + +import { type RoleRelationInterface } from './role-relation.interface.js'; + +export interface RoleAssignmentInterface + extends AssigneeRelationInterface, RoleRelationInterface {} diff --git a/packages/nestjs-role/src/domain/interfaces/role-creatable.interface.ts b/packages/nestjs-role/src/domain/interfaces/role-creatable.interface.ts new file mode 100644 index 000000000..38cd05cb8 --- /dev/null +++ b/packages/nestjs-role/src/domain/interfaces/role-creatable.interface.ts @@ -0,0 +1,6 @@ +import { type RoleInterface } from './role.interface.js'; + +export interface RoleCreatableInterface extends Pick< + RoleInterface, + 'name' | 'description' +> {} diff --git a/packages/nestjs-role/src/domain/interfaces/role-entity.interface.ts b/packages/nestjs-role/src/domain/interfaces/role-entity.interface.ts new file mode 100644 index 000000000..13fb9fba9 --- /dev/null +++ b/packages/nestjs-role/src/domain/interfaces/role-entity.interface.ts @@ -0,0 +1,14 @@ +import { + type AuditInterface, + type ReferenceIdInterface, + type ReferenceVersionInterface, +} from '@concepta/nestjs-core'; + +import { type RoleInterface } from './role.interface.js'; + +export interface RoleEntityInterface + extends + ReferenceIdInterface, + ReferenceVersionInterface, + RoleInterface, + AuditInterface {} diff --git a/packages/nestjs-role/src/domain/interfaces/role-relation.interface.ts b/packages/nestjs-role/src/domain/interfaces/role-relation.interface.ts new file mode 100644 index 000000000..a1835d66e --- /dev/null +++ b/packages/nestjs-role/src/domain/interfaces/role-relation.interface.ts @@ -0,0 +1,8 @@ +import { type ReferenceId } from '@concepta/nestjs-core'; + +/** + * Belongs to role. + */ +export interface RoleRelationInterface { + roleId: T; +} diff --git a/packages/nestjs-role/src/domain/interfaces/role-updatable.interface.ts b/packages/nestjs-role/src/domain/interfaces/role-updatable.interface.ts new file mode 100644 index 000000000..8e024cdf2 --- /dev/null +++ b/packages/nestjs-role/src/domain/interfaces/role-updatable.interface.ts @@ -0,0 +1,5 @@ +import { type RoleInterface } from './role.interface.js'; + +export interface RoleUpdatableInterface extends Partial< + Pick +> {} diff --git a/packages/nestjs-role/src/domain/interfaces/role.interface.ts b/packages/nestjs-role/src/domain/interfaces/role.interface.ts new file mode 100644 index 000000000..606430b6f --- /dev/null +++ b/packages/nestjs-role/src/domain/interfaces/role.interface.ts @@ -0,0 +1,11 @@ +export interface RoleInterface { + /** + * Name + */ + name: string; + + /** + * Name + */ + description: string; +} diff --git a/packages/nestjs-role/src/domain/repositories/role-assignment-repository-resolver.interface.ts b/packages/nestjs-role/src/domain/repositories/role-assignment-repository-resolver.interface.ts new file mode 100644 index 000000000..2877e860c --- /dev/null +++ b/packages/nestjs-role/src/domain/repositories/role-assignment-repository-resolver.interface.ts @@ -0,0 +1,5 @@ +import { type RoleAssignmentRepositoryInterface } from './role-assignment-repository.interface.js'; + +export interface RoleAssignmentRepositoryResolverInterface { + resolve(entityKey: string): RoleAssignmentRepositoryInterface; +} diff --git a/packages/nestjs-role/src/domain/repositories/role-assignment-repository.interface.ts b/packages/nestjs-role/src/domain/repositories/role-assignment-repository.interface.ts new file mode 100644 index 000000000..3338a1586 --- /dev/null +++ b/packages/nestjs-role/src/domain/repositories/role-assignment-repository.interface.ts @@ -0,0 +1,55 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type RoleAssignment } from '../aggregates/role-assignment.js'; + +export interface RoleAssignmentRepositoryInterface { + get(ctx: PlainLiteralObject, id: ReferenceId): Promise; + + findByAssignee( + ctx: PlainLiteralObject, + assigneeId: string, + ): Promise; + + findOne( + ctx: PlainLiteralObject, + roleId: string, + assigneeId: string, + ): Promise; + + findByRoleIdsAndAssignee( + ctx: PlainLiteralObject, + roleIds: string[], + assigneeId: string, + ): Promise; + + countByRoleIdAndAssignee( + ctx: PlainLiteralObject, + roleId: string, + assigneeId: string, + ): Promise; + + countByRoleIdsAndAssignee( + ctx: PlainLiteralObject, + roleIds: string[], + assigneeId: string, + ): Promise; + + save(ctx: PlainLiteralObject, roleAssignment: RoleAssignment): Promise; + + saveMany( + ctx: PlainLiteralObject, + roleAssignments: RoleAssignment[], + ): Promise; + + remove( + ctx: PlainLiteralObject, + roleAssignment: RoleAssignment, + ): Promise; + + removeMany( + ctx: PlainLiteralObject, + roleAssignments: RoleAssignment[], + ): Promise; +} diff --git a/packages/nestjs-role/src/domain/repositories/role-repository-resolver.interface.ts b/packages/nestjs-role/src/domain/repositories/role-repository-resolver.interface.ts new file mode 100644 index 000000000..d02e15812 --- /dev/null +++ b/packages/nestjs-role/src/domain/repositories/role-repository-resolver.interface.ts @@ -0,0 +1,5 @@ +import { type RoleRepositoryInterface } from './role-repository.interface.js'; + +export interface RoleRepositoryResolverInterface { + resolve(entityKey: string): RoleRepositoryInterface; +} diff --git a/packages/nestjs-role/src/domain/repositories/role-repository.interface.ts b/packages/nestjs-role/src/domain/repositories/role-repository.interface.ts new file mode 100644 index 000000000..c2a0af1cd --- /dev/null +++ b/packages/nestjs-role/src/domain/repositories/role-repository.interface.ts @@ -0,0 +1,13 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type Role } from '../aggregates/role.js'; + +export interface RoleRepositoryInterface { + get(ctx: PlainLiteralObject, id: ReferenceId): Promise; + + save(ctx: PlainLiteralObject, role: Role): Promise; + + remove(ctx: PlainLiteralObject, role: Role): Promise; +} diff --git a/packages/nestjs-role/src/dto/role-assignment-create-many.dto.ts b/packages/nestjs-role/src/dto/role-assignment-create-many.dto.ts deleted file mode 100644 index 307ac973c..000000000 --- a/packages/nestjs-role/src/dto/role-assignment-create-many.dto.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { RoleAssignmentCreatableInterface } from '@concepta/nestjs-common'; -import { CrudCreateManyDto } from '@concepta/nestjs-crud'; - -import { RoleAssignmentCreateDto } from './role-assignment-create.dto'; - -/** - * Role assignment create many DTO - */ -@Exclude() -export class RoleAssignmentCreateManyDto extends CrudCreateManyDto { - @Expose() - @ApiProperty({ - type: RoleAssignmentCreateDto, - isArray: true, - description: 'Array of Roles Assignments to create', - }) - @Type(() => RoleAssignmentCreateDto) - @IsArray() - @ArrayNotEmpty() - bulk: RoleAssignmentCreatableInterface[] = []; -} diff --git a/packages/nestjs-role/src/dto/role-assignment-create.dto.ts b/packages/nestjs-role/src/dto/role-assignment-create.dto.ts deleted file mode 100644 index e28a51ed7..000000000 --- a/packages/nestjs-role/src/dto/role-assignment-create.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { PickType } from '@nestjs/swagger'; - -import { RoleAssignmentCreatableInterface } from '@concepta/nestjs-common'; - -import { RoleAssignmentDto } from './role-assignment.dto'; - -/** - * Role Assignment Create DTO - */ -@Exclude() -export class RoleAssignmentCreateDto - extends PickType(RoleAssignmentDto, ['roleId', 'assigneeId'] as const) - implements RoleAssignmentCreatableInterface {} diff --git a/packages/nestjs-role/src/dto/role-assignment-paginated.dto.ts b/packages/nestjs-role/src/dto/role-assignment-paginated.dto.ts deleted file mode 100644 index 7ef56c508..000000000 --- a/packages/nestjs-role/src/dto/role-assignment-paginated.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { RoleAssignmentInterface } from '@concepta/nestjs-common'; -import { CrudResponsePaginatedDto } from '@concepta/nestjs-crud'; - -import { RoleAssignmentDto } from './role-assignment.dto'; - -/** - * Role assignment paginated DTO - */ -@Exclude() -export class RoleAssignmentPaginatedDto extends CrudResponsePaginatedDto { - @Expose() - @ApiProperty({ - type: RoleAssignmentDto, - isArray: true, - description: 'Array of Role Assignments', - }) - @Type(() => RoleAssignmentDto) - data: RoleAssignmentInterface[] = []; -} diff --git a/packages/nestjs-role/src/dto/role-assignment.dto.ts b/packages/nestjs-role/src/dto/role-assignment.dto.ts deleted file mode 100644 index ed2220b9e..000000000 --- a/packages/nestjs-role/src/dto/role-assignment.dto.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { - RoleAssignmentInterface, - CommonEntityDto, - ReferenceId, -} from '@concepta/nestjs-common'; - -/** - * Role assignment DTO - */ -@Exclude() -export class RoleAssignmentDto - extends CommonEntityDto - implements RoleAssignmentInterface -{ - /** - * Role ID - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'Role ID', - }) - roleId!: ReferenceId; - - /** - * Assignee ID - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'Assignee ID', - }) - assigneeId!: ReferenceId; -} diff --git a/packages/nestjs-role/src/dto/role-create-many.dto.ts b/packages/nestjs-role/src/dto/role-create-many.dto.ts deleted file mode 100644 index 805716e8f..000000000 --- a/packages/nestjs-role/src/dto/role-create-many.dto.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { RoleCreatableInterface } from '@concepta/nestjs-common'; -import { CrudCreateManyDto } from '@concepta/nestjs-crud'; - -import { RoleCreateDto } from './role-create.dto'; - -/** - * Role DTO - */ -@Exclude() -export class RoleCreateManyDto extends CrudCreateManyDto { - @Expose() - @ApiProperty({ - type: RoleCreateDto, - isArray: true, - description: 'Array of Roles to create', - }) - @Type(() => RoleCreateDto) - @IsArray() - @ArrayNotEmpty() - bulk: RoleCreateDto[] = []; -} diff --git a/packages/nestjs-role/src/dto/role-create.dto.ts b/packages/nestjs-role/src/dto/role-create.dto.ts deleted file mode 100644 index bf2412fc6..000000000 --- a/packages/nestjs-role/src/dto/role-create.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { PickType } from '@nestjs/swagger'; - -import { RoleCreatableInterface } from '@concepta/nestjs-common'; - -import { RoleDto } from './role.dto'; - -/** - * Role Create DTO - */ -@Exclude() -export class RoleCreateDto - extends PickType(RoleDto, ['name', 'description'] as const) - implements RoleCreatableInterface {} diff --git a/packages/nestjs-role/src/dto/role-paginated.dto.ts b/packages/nestjs-role/src/dto/role-paginated.dto.ts deleted file mode 100644 index 6b4a4e170..000000000 --- a/packages/nestjs-role/src/dto/role-paginated.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { RoleInterface } from '@concepta/nestjs-common'; -import { CrudResponsePaginatedDto } from '@concepta/nestjs-crud'; - -import { RoleDto } from './role.dto'; - -/** - * Role paginated DTO - */ -@Exclude() -export class RolePaginatedDto extends CrudResponsePaginatedDto { - @Expose() - @ApiProperty({ - type: RoleDto, - isArray: true, - description: 'Array of Roles', - }) - @Type(() => RoleDto) - data: RoleInterface[] = []; -} diff --git a/packages/nestjs-role/src/dto/role-update.dto.ts b/packages/nestjs-role/src/dto/role-update.dto.ts deleted file mode 100644 index 55f03047b..000000000 --- a/packages/nestjs-role/src/dto/role-update.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { PickType } from '@nestjs/swagger'; - -import { RoleUpdatableInterface } from '@concepta/nestjs-common'; - -import { RoleDto } from './role.dto'; - -/** - * Role Update DTO - */ -@Exclude() -export class RoleUpdateDto - extends PickType(RoleDto, ['id', 'name', 'description'] as const) - implements RoleUpdatableInterface {} diff --git a/packages/nestjs-role/src/dto/role.dto.ts b/packages/nestjs-role/src/dto/role.dto.ts deleted file mode 100644 index d885aa045..000000000 --- a/packages/nestjs-role/src/dto/role.dto.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsOptional, IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { RoleInterface, CommonEntityDto } from '@concepta/nestjs-common'; - -/** - * Role DTO - */ -@Exclude() -export class RoleDto extends CommonEntityDto implements RoleInterface { - /** - * Name - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'Name of the role', - }) - @IsString() - name = ''; - - /** - * Name - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'Description of the role', - }) - @IsString() - @IsOptional() - description = ''; -} diff --git a/packages/nestjs-role/src/exceptions/role-assignment-conflict.exception.ts b/packages/nestjs-role/src/exceptions/role-assignment-conflict.exception.ts deleted file mode 100644 index c9b9f0f08..000000000 --- a/packages/nestjs-role/src/exceptions/role-assignment-conflict.exception.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { RoleException } from './role.exception'; - -export class RoleAssignmentConflictException extends RoleException { - context: RuntimeException['context'] & { - assignmentName: string; - roleId: string; - assigneeId: string; - }; - - constructor( - assignmentName: string, - roleId: string, - assigneeId: string, - options?: RuntimeExceptionOptions, - ) { - super({ - message: 'Role %s is already assigned to assignee %s for assignment %s.', - messageParams: [roleId, assigneeId, assignmentName], - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'ROLE_ASSIGNMENT_CONFLICT_ERROR'; - - this.context = { - ...super.context, - assignmentName, - roleId, - assigneeId, - }; - } -} diff --git a/packages/nestjs-role/src/exceptions/role-assignment-not-found.exception.ts b/packages/nestjs-role/src/exceptions/role-assignment-not-found.exception.ts deleted file mode 100644 index ceada6b24..000000000 --- a/packages/nestjs-role/src/exceptions/role-assignment-not-found.exception.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { RoleException } from './role.exception'; - -export class RoleAssignmentNotFoundException extends RoleException { - context: RuntimeException['context'] & { assignmentName: string }; - - constructor(assignmentName: string, options?: RuntimeExceptionOptions) { - super({ - message: 'Assignment %s was not registered to be used.', - messageParams: [assignmentName], - ...options, - }); - - this.errorCode = 'ROLE_ASSIGNMENT_NOT_FOUND_ERROR'; - - this.context = { - ...super.context, - assignmentName, - }; - } -} diff --git a/packages/nestjs-role/src/exceptions/role-entity-not-found.exception.ts b/packages/nestjs-role/src/exceptions/role-entity-not-found.exception.ts deleted file mode 100644 index 54de65e66..000000000 --- a/packages/nestjs-role/src/exceptions/role-entity-not-found.exception.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; - -import { RoleException } from './role.exception'; - -export class RoleEntityNotFoundException extends RoleException { - context: RuntimeException['context'] & { entityName: string }; - - constructor(entityName: string, options?: RuntimeExceptionOptions) { - super({ - message: 'Entity %s was not registered to be used.', - messageParams: [entityName], - ...options, - }); - - this.errorCode = 'ROLE_ENTITY_NOT_FOUND_ERROR'; - - this.context = { - ...super.context, - entityName, - }; - } -} diff --git a/packages/nestjs-role/src/exceptions/role-missing-entities-options.exception.ts b/packages/nestjs-role/src/exceptions/role-missing-entities-options.exception.ts deleted file mode 100644 index 205b8585b..000000000 --- a/packages/nestjs-role/src/exceptions/role-missing-entities-options.exception.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RoleException } from './role.exception'; - -export class RoleMissingEntitiesOptionsException extends RoleException { - constructor() { - super({ - message: 'You must provide the entities option', - }); - this.errorCode = 'ROLE_MISSING_ENTITIES_OPTION'; - } -} diff --git a/packages/nestjs-role/src/gateways/decorators/role-namespace.decorator.ts b/packages/nestjs-role/src/gateways/decorators/role-namespace.decorator.ts new file mode 100644 index 000000000..33ab14f2d --- /dev/null +++ b/packages/nestjs-role/src/gateways/decorators/role-namespace.decorator.ts @@ -0,0 +1,10 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLE_NAMESPACE_KEY = 'ROLE_NAMESPACE'; + +export interface RoleNamespaceOptions { + name: string; +} + +export const RoleNamespace = (options: RoleNamespaceOptions) => + SetMetadata(ROLE_NAMESPACE_KEY, options); diff --git a/packages/nestjs-role/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts b/packages/nestjs-role/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts new file mode 100644 index 000000000..74f8f0fb9 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts @@ -0,0 +1,167 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { CoreModule, Operation } from '@concepta/nestjs-core'; +import { CrudCqrsResolver, CrudModule } from '@concepta/nestjs-crud'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { RoleEntityFixture } from '../../../../__tests__/fixtures/entities/role-entity.fixture.js'; +import { UserEntityFixture } from '../../../../__tests__/fixtures/entities/user-entity.fixture.js'; +import { UserRoleEntityFixture } from '../../../../__tests__/fixtures/entities/user-role-entity.fixture.js'; +import { RoleAssignmentEntityInterface } from '../../../../domain/interfaces/role-assignment-entity.interface.js'; +import { RoleInterface } from '../../../../domain/interfaces/role.interface.js'; +import { RoleNamespace } from '../../../../gateways/decorators/role-namespace.decorator.js'; +import { roleAssignmentCreateSchema } from '../../../../infrastructure/schemas/role-assignment-create.schema.js'; +import { roleAssignmentPaginatedSchema } from '../../../../infrastructure/schemas/role-assignment-paginated.schema.js'; +import { roleAssignmentSchema } from '../../../../infrastructure/schemas/role-assignment.schema.js'; +import { roleCreateSchema } from '../../../../infrastructure/schemas/role-create.schema.js'; +import { rolePaginatedSchema } from '../../../../infrastructure/schemas/role-paginated.schema.js'; +import { roleUpdateSchema } from '../../../../infrastructure/schemas/role-update.schema.js'; +import { roleSchema } from '../../../../infrastructure/schemas/role.schema.js'; +import { RoleModule } from '../../../../role.module.js'; +import { CreateRoleAssignmentRequestHandler } from '../../commands/handlers/create-role-assignment-request.handler.js'; +import { CreateRoleRequestHandler } from '../../commands/handlers/create-role-request.handler.js'; +import { DeleteRoleAssignmentRequestHandler } from '../../commands/handlers/delete-role-assignment-request.handler.js'; +import { DeleteRoleRequestHandler } from '../../commands/handlers/delete-role-request.handler.js'; +import { ReplaceRoleRequestHandler } from '../../commands/handlers/replace-role-request.handler.js'; +import { UpdateRoleRequestHandler } from '../../commands/handlers/update-role-request.handler.js'; +import { CreateRoleAssignmentRequest } from '../../commands/impl/create-role-assignment.request.js'; +import { CreateRoleRequest } from '../../commands/impl/create-role.request.js'; +import { DeleteRoleAssignmentRequest } from '../../commands/impl/delete-role-assignment.request.js'; +import { DeleteRoleRequest } from '../../commands/impl/delete-role.request.js'; +import { ReplaceRoleRequest } from '../../commands/impl/replace-role.request.js'; +import { UpdateRoleRequest } from '../../commands/impl/update-role.request.js'; +import { ListRoleAssignmentsRequestHandler } from '../../queries/handlers/list-role-assignments-request.handler.js'; +import { ListRolesRequestHandler } from '../../queries/handlers/list-roles-request.handler.js'; +import { ReadRoleAssignmentRequestHandler } from '../../queries/handlers/read-role-assignment-request.handler.js'; +import { ReadRoleRequestHandler } from '../../queries/handlers/read-role-request.handler.js'; +import { ListRoleAssignmentsRequest } from '../../queries/impl/list-role-assignments.request.js'; +import { ListRolesRequest } from '../../queries/impl/list-roles.request.js'; +import { ReadRoleAssignmentRequest } from '../../queries/impl/read-role-assignment.request.js'; +import { ReadRoleRequest } from '../../queries/impl/read-role.request.js'; + +const ROLE_ENTITY_KEY = 'role'; +const USER_ROLE_ENTITY_KEY = 'userRole'; + +@Module({ + imports: [ + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [RoleEntityFixture, UserRoleEntityFixture, UserEntityFixture], + }), + CqrsModule.forRoot(), + RepositoryModule.forRoot({}), + CrudModule.forRoot({ + defaultResolver: CrudCqrsResolver, + }), + CoreModule.forRoot(), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: ROLE_ENTITY_KEY, entity: RoleEntityFixture }, + { key: USER_ROLE_ENTITY_KEY, entity: UserRoleEntityFixture }, + ], + }), + RoleModule.forRoot({}), + RoleModule.forFeature({ + roleEntityKey: ROLE_ENTITY_KEY, + assignmentEntityKeys: [USER_ROLE_ENTITY_KEY], + }), + CrudModule.forFeature({ + crud: { + controller: { + entity: ROLE_ENTITY_KEY, + path: 'role', + resolver: CrudCqrsResolver, + transactional: true, + extraDecorators: [RoleNamespace({ name: ROLE_ENTITY_KEY })], + request: { body: roleCreateSchema }, + response: { + resource: roleSchema, + paginated: rolePaginatedSchema, + }, + }, + operations: [ + { + operation: Operation.List, + query: ListRolesRequest, + queryHandler: ListRolesRequestHandler, + }, + { + operation: Operation.Read, + query: ReadRoleRequest, + queryHandler: ReadRoleRequestHandler, + }, + { + operation: Operation.Create, + request: { body: roleCreateSchema }, + command: CreateRoleRequest, + commandHandler: CreateRoleRequestHandler, + }, + { + operation: Operation.Update, + request: { body: roleUpdateSchema }, + command: UpdateRoleRequest, + commandHandler: UpdateRoleRequestHandler, + }, + { + operation: Operation.Replace, + request: { body: roleCreateSchema }, + command: ReplaceRoleRequest, + commandHandler: ReplaceRoleRequestHandler, + }, + { + operation: Operation.Delete, + command: DeleteRoleRequest, + commandHandler: DeleteRoleRequestHandler, + }, + ], + }, + }), + CrudModule.forFeature({ + crud: { + controller: { + entity: USER_ROLE_ENTITY_KEY, + path: 'role-assignment/user', + resolver: CrudCqrsResolver, + transactional: true, + extraDecorators: [RoleNamespace({ name: USER_ROLE_ENTITY_KEY })], + request: { body: roleAssignmentCreateSchema }, + response: { + resource: roleAssignmentSchema, + paginated: roleAssignmentPaginatedSchema, + }, + }, + operations: [ + { + operation: Operation.List, + query: ListRoleAssignmentsRequest, + queryHandler: ListRoleAssignmentsRequestHandler, + }, + { + operation: Operation.Read, + query: ReadRoleAssignmentRequest, + queryHandler: ReadRoleAssignmentRequestHandler, + }, + { + operation: Operation.Create, + request: { body: roleAssignmentCreateSchema }, + command: CreateRoleAssignmentRequest, + commandHandler: CreateRoleAssignmentRequestHandler, + }, + { + operation: Operation.Delete, + command: DeleteRoleAssignmentRequest, + commandHandler: DeleteRoleAssignmentRequestHandler, + }, + ], + }, + }), + ], + providers: [], +}) +export class AppCrudModuleFixture {} diff --git a/packages/nestjs-role/src/gateways/http/__tests__/role-crud.controller.e2e-spec.ts b/packages/nestjs-role/src/gateways/http/__tests__/role-crud.controller.e2e-spec.ts new file mode 100644 index 000000000..f250d80c4 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/__tests__/role-crud.controller.e2e-spec.ts @@ -0,0 +1,411 @@ +import { randomUUID } from 'crypto'; + +import supertest from 'supertest'; +import { type MockInstance } from 'vitest'; + +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; + +import { TransactionScope } from '@concepta/nestjs-repository'; +import { SeedingSource } from '@concepta/typeorm-seeding'; + +import { RoleEntityFixture } from '../../../__tests__/fixtures/entities/role-entity.fixture.js'; +import { type UserEntityFixture } from '../../../__tests__/fixtures/entities/user-entity.fixture.js'; +import { UserFactoryFixture } from '../../../__tests__/fixtures/factories/user.factory.fixture.js'; +import { RoleSeederFixture } from '../../../__tests__/fixtures/role.seeder.fixture.js'; +import { RoleFactory } from '../../../infrastructure/persistence/role.factory.js'; + +import { AppCrudModuleFixture } from './fixtures/app-crud.module.fixture.js'; + +describe('RoleCrudController (e2e)', () => { + let app: INestApplication; + let seedingSource: SeedingSource; + let userFactory: UserFactoryFixture; + let user: UserEntityFixture; + let txSpy: MockInstance; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppCrudModuleFixture], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + + const txScope = app.get(TransactionScope); + txSpy = vi.spyOn(txScope, 'run'); + + seedingSource = new SeedingSource({ + dataSource: app.get(getDataSourceToken()), + }); + + await seedingSource.initialize(); + + userFactory = new UserFactoryFixture({ seedingSource }); + + const roleSeeder = new RoleSeederFixture({ + factories: [new RoleFactory({ entity: RoleEntityFixture })], + }); + + await seedingSource.run.one(roleSeeder); + + user = await userFactory.create(); + }); + + afterEach(async () => { + vi.clearAllMocks(); + return app ? await app.close() : undefined; + }); + + describe('Role CRUD', () => { + it('GET /role', async () => { + const res = await supertest(app.getHttpServer()) + .get('/role?limit=2') + .expect(200); + + expect(res.body).toEqual({ + count: 2, + total: expect.any(Number), + page: 1, + pageCount: expect.any(Number), + limit: 2, + data: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + name: expect.any(String), + description: expect.any(String), + }), + ]), + }); + expect(res.body.data.length).toBe(2); + }); + + it('GET /role/:id', async () => { + const listRes = await supertest(app.getHttpServer()) + .get('/role?limit=1') + .expect(200); + + const role = listRes.body.data[0]; + + const res = await supertest(app.getHttpServer()) + .get(`/role/${role.id}`) + .expect(200); + + expect(res.body).toEqual({ + id: role.id, + name: role.name, + description: role.description, + dateCreated: expect.any(String), + dateUpdated: expect.any(String), + dateDeleted: null, + version: expect.any(Number), + }); + }); + + it('POST /role', async () => { + const payload = { + name: 'admin', + description: 'Administrator role', + }; + + const res = await supertest(app.getHttpServer()) + .post('/role') + .send(payload) + .expect(201); + + expect(res.body).toEqual({ + id: expect.any(String), + name: 'admin', + description: 'Administrator role', + dateCreated: expect.any(String), + dateUpdated: expect.any(String), + dateDeleted: null, + version: 1, + }); + }); + + it('PATCH /role/:id', async () => { + const createRes = await supertest(app.getHttpServer()) + .post('/role') + .send({ name: 'editor', description: 'Editor role' }) + .expect(201); + + const roleId = createRes.body.id; + + const res = await supertest(app.getHttpServer()) + .patch(`/role/${roleId}`) + .send({ name: 'editor', description: 'Updated description' }) + .expect(200); + + expect(res.body).toEqual({ + id: roleId, + name: 'editor', + description: 'Updated description', + dateCreated: expect.any(String), + dateUpdated: expect.any(String), + dateDeleted: null, + version: 2, + }); + }); + + it('PATCH /role/:id does not blank name when only description is sent', async () => { + const createRes = await supertest(app.getHttpServer()) + .post('/role') + .send({ name: 'partial-patch', description: 'Original description' }) + .expect(201); + + const roleId = createRes.body.id; + + const res = await supertest(app.getHttpServer()) + .patch(`/role/${roleId}`) + .send({ description: 'Only description sent' }) + .expect(200); + + expect(res.body).toEqual({ + id: roleId, + name: 'partial-patch', + description: 'Only description sent', + dateCreated: expect.any(String), + dateUpdated: expect.any(String), + dateDeleted: null, + version: 2, + }); + }); + + it('POST /role rejects a missing name', async () => { + await supertest(app.getHttpServer()) + .post('/role') + .send({ description: 'No name provided' }) + .expect(400); + }); + + it('PUT /role/:id (new) rejects a missing name', async () => { + await supertest(app.getHttpServer()) + .put(`/role/${randomUUID()}`) + .send({}) + .expect(400); + }); + + it('PUT /role/:id (new)', async () => { + const roleId = randomUUID(); + const payload = { + name: 'viewer', + description: 'Viewer role', + }; + + const res = await supertest(app.getHttpServer()) + .put(`/role/${roleId}`) + .send(payload) + .expect(200); + + expect(res.body).toEqual({ + id: roleId, + name: 'viewer', + description: 'Viewer role', + dateCreated: expect.any(String), + dateUpdated: expect.any(String), + dateDeleted: null, + version: 1, + }); + }); + + it('PUT /role/:id (existing)', async () => { + const createRes = await supertest(app.getHttpServer()) + .post('/role') + .send({ name: 'moderator', description: 'Moderator role' }) + .expect(201); + + const roleId = createRes.body.id; + + const res = await supertest(app.getHttpServer()) + .put(`/role/${roleId}`) + .send({ name: 'moderator', description: 'Replaced description' }) + .expect(200); + + expect(res.body).toEqual({ + id: roleId, + name: 'moderator', + description: 'Replaced description', + dateCreated: expect.any(String), + dateUpdated: expect.any(String), + dateDeleted: null, + version: 2, + }); + }); + + it('DELETE /role/:id', async () => { + const createRes = await supertest(app.getHttpServer()) + .post('/role') + .send({ name: 'temp', description: 'Temporary role' }) + .expect(201); + + await supertest(app.getHttpServer()) + .delete(`/role/${createRes.body.id}`) + .expect(204); + }); + }); + + describe('Role Assignment CRUD', () => { + let roleId: string; + + beforeEach(async () => { + const createRes = await supertest(app.getHttpServer()) + .post('/role') + .send({ name: `role-${randomUUID()}`, description: 'Test role' }) + .expect(201); + + roleId = createRes.body.id; + }); + + it('POST /role-assignment/user', async () => { + const res = await supertest(app.getHttpServer()) + .post('/role-assignment/user') + .send({ roleId, assigneeId: user.id }) + .expect(201); + + expect(res.body).toEqual({ + id: expect.any(String), + roleId, + assigneeId: user.id, + dateCreated: expect.any(String), + dateUpdated: expect.any(String), + dateDeleted: null, + version: 1, + }); + }); + + it('POST /role-assignment/user duplicate should return 409', async () => { + await supertest(app.getHttpServer()) + .post('/role-assignment/user') + .send({ roleId, assigneeId: user.id }) + .expect(201); + + await supertest(app.getHttpServer()) + .post('/role-assignment/user') + .send({ roleId, assigneeId: user.id }) + .expect(409); + }); + + it('GET /role-assignment/user', async () => { + await supertest(app.getHttpServer()) + .post('/role-assignment/user') + .send({ roleId, assigneeId: user.id }) + .expect(201); + + const res = await supertest(app.getHttpServer()) + .get('/role-assignment/user?limit=10') + .expect(200); + + expect(res.body).toEqual({ + count: expect.any(Number), + total: expect.any(Number), + page: 1, + pageCount: expect.any(Number), + limit: 10, + data: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + roleId, + assigneeId: user.id, + }), + ]), + }); + }); + + it('GET /role-assignment/user/:id', async () => { + const assignRes = await supertest(app.getHttpServer()) + .post('/role-assignment/user') + .send({ roleId, assigneeId: user.id }) + .expect(201); + + const res = await supertest(app.getHttpServer()) + .get(`/role-assignment/user/${assignRes.body.id}`) + .expect(200); + + expect(res.body).toEqual({ + id: assignRes.body.id, + roleId, + assigneeId: user.id, + dateCreated: expect.any(String), + dateUpdated: expect.any(String), + dateDeleted: null, + version: 1, + }); + }); + + it('DELETE /role-assignment/user/:id', async () => { + const assignRes = await supertest(app.getHttpServer()) + .post('/role-assignment/user') + .send({ roleId, assigneeId: user.id }) + .expect(201); + + await supertest(app.getHttpServer()) + .delete(`/role-assignment/user/${assignRes.body.id}`) + .expect(204); + }); + }); + + describe('@Transactional', () => { + it('should use transaction for POST /role', async () => { + await supertest(app.getHttpServer()) + .post('/role') + .send({ name: 'tx-test', description: 'tx' }) + .expect(201); + + expect(txSpy).toHaveBeenCalled(); + }); + + it('should use transaction for PATCH /role/:id', async () => { + const createRes = await supertest(app.getHttpServer()) + .post('/role') + .send({ name: 'tx-patch', description: 'tx' }) + .expect(201); + + txSpy.mockClear(); + + await supertest(app.getHttpServer()) + .patch(`/role/${createRes.body.id}`) + .send({ name: 'tx-patch', description: 'patched' }) + .expect(200); + + expect(txSpy).toHaveBeenCalled(); + }); + + it('should use transaction for DELETE /role/:id', async () => { + const createRes = await supertest(app.getHttpServer()) + .post('/role') + .send({ name: 'tx-del', description: 'tx' }) + .expect(201); + + txSpy.mockClear(); + + await supertest(app.getHttpServer()) + .delete(`/role/${createRes.body.id}`) + .expect(204); + + expect(txSpy).toHaveBeenCalled(); + }); + + it('should NOT use transaction for GET /role (list)', async () => { + txSpy.mockClear(); + + await supertest(app.getHttpServer()).get('/role?limit=1').expect(200); + + expect(txSpy).not.toHaveBeenCalled(); + }); + + it('should NOT use transaction for GET /role/:id (read)', async () => { + const listRes = await supertest(app.getHttpServer()) + .get('/role?limit=1') + .expect(200); + + txSpy.mockClear(); + + await supertest(app.getHttpServer()) + .get(`/role/${listRes.body.data[0].id}`) + .expect(200); + + expect(txSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/nestjs-role/src/gateways/http/__tests__/role-crud.swagger.e2e-spec.ts b/packages/nestjs-role/src/gateways/http/__tests__/role-crud.swagger.e2e-spec.ts new file mode 100644 index 000000000..8d87d11f2 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/__tests__/role-crud.swagger.e2e-spec.ts @@ -0,0 +1,111 @@ +import { type INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { standardSchemaConverter } from '@concepta/nestjs-core'; + +import { AppCrudModuleFixture } from './fixtures/app-crud.module.fixture.js'; + +describe('RoleController swagger (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppCrudModuleFixture], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + return app ? await app.close() : undefined; + }); + + it('registers Role/RolePaginated and RoleAssignment/RoleAssignmentPaginated as named, $ref-reused components', () => { + const config = new DocumentBuilder() + .setTitle('role') + .setVersion('1.0') + .build(); + const document = SwaggerModule.createDocument(app, config, { + standardSchemaConverter, + }); + + expect(document.components?.schemas?.Role).toBeDefined(); + expect(document.components?.schemas?.RolePaginated).toBeDefined(); + expect(document.components?.schemas?.RoleAssignment).toBeDefined(); + expect(document.components?.schemas?.RoleAssignmentPaginated).toBeDefined(); + + const readResponse = + document.paths?.['/role/{id}']?.get?.responses?.['200']; + const listResponse = document.paths?.['/role']?.get?.responses?.['200']; + const assignmentReadResponse = + document.paths?.['/role-assignment/user/{id}']?.get?.responses?.['200']; + const assignmentListResponse = + document.paths?.['/role-assignment/user']?.get?.responses?.['200']; + + if (!readResponse || !('content' in readResponse)) { + throw new Error( + 'expected the read response to be a content-bearing response object', + ); + } + if (!listResponse || !('content' in listResponse)) { + throw new Error( + 'expected the list response to be a content-bearing response object', + ); + } + if (!assignmentReadResponse || !('content' in assignmentReadResponse)) { + throw new Error( + 'expected the assignment read response to be a content-bearing response object', + ); + } + if (!assignmentListResponse || !('content' in assignmentListResponse)) { + throw new Error( + 'expected the assignment list response to be a content-bearing response object', + ); + } + + expect(readResponse.content?.['application/json']?.schema).toEqual({ + $ref: '#/components/schemas/Role', + }); + expect(listResponse.content?.['application/json']?.schema).toEqual({ + $ref: '#/components/schemas/RolePaginated', + }); + expect( + assignmentReadResponse.content?.['application/json']?.schema, + ).toEqual({ + $ref: '#/components/schemas/RoleAssignment', + }); + expect( + assignmentListResponse.content?.['application/json']?.schema, + ).toEqual({ + $ref: '#/components/schemas/RoleAssignmentPaginated', + }); + }); + + it('documents the schema-based POST request body inline, since roleCreateSchema is not a named component (no withNamedComponent)', () => { + const config = new DocumentBuilder() + .setTitle('role') + .setVersion('1.0') + .build(); + const document = SwaggerModule.createDocument(app, config, { + standardSchemaConverter, + }); + + const createBody = document.paths?.['/role']?.post?.requestBody; + if (!createBody || !('content' in createBody)) { + throw new Error( + 'expected the create request body to be a content-bearing request body object', + ); + } + + const schema = createBody.content?.['application/json']?.schema; + if (!schema || !('type' in schema)) { + throw new Error('expected an inline object schema, not a $ref'); + } + + expect(schema.type).toBe('object'); + expect(schema.properties).toBeDefined(); + // roleCreateSchema was never passed through withNamedComponent. + expect(document.components?.schemas?.RoleCreate).toBeUndefined(); + }); +}); diff --git a/packages/nestjs-role/src/gateways/http/commands/handlers/create-role-assignment-request.handler.ts b/packages/nestjs-role/src/gateways/http/commands/handlers/create-role-assignment-request.handler.ts new file mode 100644 index 000000000..ceb9995ba --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/handlers/create-role-assignment-request.handler.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { AssignRoleCommand } from '../../../../application/commands/impl/assign-role.command.js'; +import { RoleAssignment } from '../../../../domain/aggregates/role-assignment.js'; +import { CreateRoleAssignmentRequest } from '../impl/create-role-assignment.request.js'; + +@Injectable() +export class CreateRoleAssignmentRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: CreateRoleAssignmentRequest) { + const { context, dto } = command; + const { namespace } = context.withRole(); + const assignment = await this.commandBus.execute< + AssignRoleCommand, + RoleAssignment + >(new AssignRoleCommand(context, namespace, dto.roleId, dto.assigneeId)); + return assignment.toPlain(); + } +} diff --git a/packages/nestjs-role/src/gateways/http/commands/handlers/create-role-request.handler.ts b/packages/nestjs-role/src/gateways/http/commands/handlers/create-role-request.handler.ts new file mode 100644 index 000000000..0f2339eac --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/handlers/create-role-request.handler.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { CreateRoleCommand } from '../../../../application/commands/impl/create-role.command.js'; +import { Role } from '../../../../domain/aggregates/role.js'; +import { CreateRoleRequest } from '../impl/create-role.request.js'; + +@Injectable() +export class CreateRoleRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: CreateRoleRequest) { + const { context, dto } = command; + const { namespace } = context.withRole(); + const role = await this.commandBus.execute( + new CreateRoleCommand(context, namespace, dto), + ); + return role.toPlain(); + } +} diff --git a/packages/nestjs-role/src/gateways/http/commands/handlers/delete-role-assignment-request.handler.ts b/packages/nestjs-role/src/gateways/http/commands/handlers/delete-role-assignment-request.handler.ts new file mode 100644 index 000000000..e37e13184 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/handlers/delete-role-assignment-request.handler.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; + +import { RevokeRoleCommand } from '../../../../application/commands/impl/revoke-role.command.js'; +import { GetRoleAssignmentQuery } from '../../../../application/queries/impl/get-role-assignment.query.js'; +import { assertRoleId } from '../../../../application/utils/assert-role-id.util.js'; +import { RoleAssignment } from '../../../../domain/aggregates/role-assignment.js'; +import { DeleteRoleAssignmentRequest } from '../impl/delete-role-assignment.request.js'; + +@Injectable() +export class DeleteRoleAssignmentRequestHandler { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + async execute(command: DeleteRoleAssignmentRequest) { + const { context } = command; + const { id } = context.params; + + assertRoleId(id); + + const { namespace } = context.withRole(); + const assignment = await this.queryBus.execute< + GetRoleAssignmentQuery, + RoleAssignment + >(new GetRoleAssignmentQuery(context, namespace, id)); + + await this.commandBus.execute( + new RevokeRoleCommand( + context, + namespace, + assignment.roleId, + assignment.assigneeId, + ), + ); + + return null; + } +} diff --git a/packages/nestjs-role/src/gateways/http/commands/handlers/delete-role-request.handler.ts b/packages/nestjs-role/src/gateways/http/commands/handlers/delete-role-request.handler.ts new file mode 100644 index 000000000..1bd0cb7a7 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/handlers/delete-role-request.handler.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { RemoveRoleCommand } from '../../../../application/commands/impl/remove-role.command.js'; +import { assertRoleId } from '../../../../application/utils/assert-role-id.util.js'; +import { DeleteRoleRequest } from '../impl/delete-role.request.js'; + +@Injectable() +export class DeleteRoleRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: DeleteRoleRequest) { + const { context } = command; + const { id } = context.params; + + assertRoleId(id); + + const { namespace } = context.withRole(); + await this.commandBus.execute( + new RemoveRoleCommand(context, namespace, id), + ); + + return null; + } +} diff --git a/packages/nestjs-role/src/gateways/http/commands/handlers/replace-role-request.handler.ts b/packages/nestjs-role/src/gateways/http/commands/handlers/replace-role-request.handler.ts new file mode 100644 index 000000000..0c8ee3b15 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/handlers/replace-role-request.handler.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { ReplaceRoleCommand } from '../../../../application/commands/impl/replace-role.command.js'; +import { assertRoleId } from '../../../../application/utils/assert-role-id.util.js'; +import { Role } from '../../../../domain/aggregates/role.js'; +import { ReplaceRoleRequest } from '../impl/replace-role.request.js'; + +@Injectable() +export class ReplaceRoleRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: ReplaceRoleRequest) { + const { context, dto } = command; + const { id } = context.params; + + assertRoleId(id); + + const { namespace } = context.withRole(); + const role = await this.commandBus.execute( + new ReplaceRoleCommand(context, namespace, id, dto), + ); + return role.toPlain(); + } +} diff --git a/packages/nestjs-role/src/gateways/http/commands/handlers/update-role-request.handler.ts b/packages/nestjs-role/src/gateways/http/commands/handlers/update-role-request.handler.ts new file mode 100644 index 000000000..81da0e486 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/handlers/update-role-request.handler.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { UpdateRoleCommand } from '../../../../application/commands/impl/update-role.command.js'; +import { assertRoleId } from '../../../../application/utils/assert-role-id.util.js'; +import { Role } from '../../../../domain/aggregates/role.js'; +import { UpdateRoleRequest } from '../impl/update-role.request.js'; + +@Injectable() +export class UpdateRoleRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: UpdateRoleRequest) { + const { context, dto } = command; + const { id } = context.params; + + assertRoleId(id); + + const { namespace } = context.withRole(); + const role = await this.commandBus.execute( + new UpdateRoleCommand(context, namespace, id, dto), + ); + return role.toPlain(); + } +} diff --git a/packages/nestjs-role/src/gateways/http/commands/impl/create-role-assignment.request.ts b/packages/nestjs-role/src/gateways/http/commands/impl/create-role-assignment.request.ts new file mode 100644 index 000000000..62ece6f9c --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/impl/create-role-assignment.request.ts @@ -0,0 +1,9 @@ +import { CrudCreateCommand } from '@concepta/nestjs-crud'; + +import { type RoleAssignmentCreatableInterface } from '../../../../domain/interfaces/role-assignment-creatable.interface.js'; +import { type RoleAssignmentEntityInterface } from '../../../../domain/interfaces/role-assignment-entity.interface.js'; + +export class CreateRoleAssignmentRequest extends CrudCreateCommand< + RoleAssignmentEntityInterface, + RoleAssignmentCreatableInterface +> {} diff --git a/packages/nestjs-role/src/gateways/http/commands/impl/create-role.request.ts b/packages/nestjs-role/src/gateways/http/commands/impl/create-role.request.ts new file mode 100644 index 000000000..8931607cc --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/impl/create-role.request.ts @@ -0,0 +1,9 @@ +import { CrudCreateCommand } from '@concepta/nestjs-crud'; + +import { type RoleCreatableInterface } from '../../../../domain/interfaces/role-creatable.interface.js'; +import { type RoleInterface } from '../../../../domain/interfaces/role.interface.js'; + +export class CreateRoleRequest extends CrudCreateCommand< + RoleInterface, + RoleCreatableInterface +> {} diff --git a/packages/nestjs-role/src/gateways/http/commands/impl/delete-role-assignment.request.ts b/packages/nestjs-role/src/gateways/http/commands/impl/delete-role-assignment.request.ts new file mode 100644 index 000000000..45bb0682c --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/impl/delete-role-assignment.request.ts @@ -0,0 +1,5 @@ +import { CrudDeleteCommand } from '@concepta/nestjs-crud'; + +import { type RoleAssignmentEntityInterface } from '../../../../domain/interfaces/role-assignment-entity.interface.js'; + +export class DeleteRoleAssignmentRequest extends CrudDeleteCommand {} diff --git a/packages/nestjs-role/src/gateways/http/commands/impl/delete-role.request.ts b/packages/nestjs-role/src/gateways/http/commands/impl/delete-role.request.ts new file mode 100644 index 000000000..2996eb8b4 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/impl/delete-role.request.ts @@ -0,0 +1,5 @@ +import { CrudDeleteCommand } from '@concepta/nestjs-crud'; + +import { type RoleInterface } from '../../../../domain/interfaces/role.interface.js'; + +export class DeleteRoleRequest extends CrudDeleteCommand {} diff --git a/packages/nestjs-role/src/gateways/http/commands/impl/replace-role.request.ts b/packages/nestjs-role/src/gateways/http/commands/impl/replace-role.request.ts new file mode 100644 index 000000000..cb84909f7 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/impl/replace-role.request.ts @@ -0,0 +1,9 @@ +import { CrudReplaceCommand } from '@concepta/nestjs-crud'; + +import { type RoleCreatableInterface } from '../../../../domain/interfaces/role-creatable.interface.js'; +import { type RoleInterface } from '../../../../domain/interfaces/role.interface.js'; + +export class ReplaceRoleRequest extends CrudReplaceCommand< + RoleInterface, + RoleCreatableInterface +> {} diff --git a/packages/nestjs-role/src/gateways/http/commands/impl/update-role.request.ts b/packages/nestjs-role/src/gateways/http/commands/impl/update-role.request.ts new file mode 100644 index 000000000..ad60bd374 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/commands/impl/update-role.request.ts @@ -0,0 +1,9 @@ +import { CrudUpdateCommand } from '@concepta/nestjs-crud'; + +import { type RoleUpdatableInterface } from '../../../../domain/interfaces/role-updatable.interface.js'; +import { type RoleInterface } from '../../../../domain/interfaces/role.interface.js'; + +export class UpdateRoleRequest extends CrudUpdateCommand< + RoleInterface, + RoleUpdatableInterface +> {} diff --git a/packages/nestjs-role/src/gateways/http/queries/handlers/list-role-assignments-request.handler.ts b/packages/nestjs-role/src/gateways/http/queries/handlers/list-role-assignments-request.handler.ts new file mode 100644 index 000000000..b4e644bca --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/queries/handlers/list-role-assignments-request.handler.ts @@ -0,0 +1,5 @@ +import { CrudListHandler } from '@concepta/nestjs-crud'; + +import { type RoleAssignmentEntityInterface } from '../../../../domain/interfaces/role-assignment-entity.interface.js'; + +export class ListRoleAssignmentsRequestHandler extends CrudListHandler {} diff --git a/packages/nestjs-role/src/gateways/http/queries/handlers/list-roles-request.handler.ts b/packages/nestjs-role/src/gateways/http/queries/handlers/list-roles-request.handler.ts new file mode 100644 index 000000000..9552c3504 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/queries/handlers/list-roles-request.handler.ts @@ -0,0 +1,5 @@ +import { CrudListHandler } from '@concepta/nestjs-crud'; + +import { type RoleInterface } from '../../../../domain/interfaces/role.interface.js'; + +export class ListRolesRequestHandler extends CrudListHandler {} diff --git a/packages/nestjs-role/src/gateways/http/queries/handlers/read-role-assignment-request.handler.ts b/packages/nestjs-role/src/gateways/http/queries/handlers/read-role-assignment-request.handler.ts new file mode 100644 index 000000000..01679a33c --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/queries/handlers/read-role-assignment-request.handler.ts @@ -0,0 +1,5 @@ +import { CrudReadHandler } from '@concepta/nestjs-crud'; + +import { type RoleAssignmentEntityInterface } from '../../../../domain/interfaces/role-assignment-entity.interface.js'; + +export class ReadRoleAssignmentRequestHandler extends CrudReadHandler {} diff --git a/packages/nestjs-role/src/gateways/http/queries/handlers/read-role-request.handler.ts b/packages/nestjs-role/src/gateways/http/queries/handlers/read-role-request.handler.ts new file mode 100644 index 000000000..b5be66d2a --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/queries/handlers/read-role-request.handler.ts @@ -0,0 +1,5 @@ +import { CrudReadHandler } from '@concepta/nestjs-crud'; + +import { type RoleInterface } from '../../../../domain/interfaces/role.interface.js'; + +export class ReadRoleRequestHandler extends CrudReadHandler {} diff --git a/packages/nestjs-role/src/gateways/http/queries/impl/list-role-assignments.request.ts b/packages/nestjs-role/src/gateways/http/queries/impl/list-role-assignments.request.ts new file mode 100644 index 000000000..1d7ab92f4 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/queries/impl/list-role-assignments.request.ts @@ -0,0 +1,5 @@ +import { CrudListQuery } from '@concepta/nestjs-crud'; + +import { type RoleAssignmentEntityInterface } from '../../../../domain/interfaces/role-assignment-entity.interface.js'; + +export class ListRoleAssignmentsRequest extends CrudListQuery {} diff --git a/packages/nestjs-role/src/gateways/http/queries/impl/list-roles.request.ts b/packages/nestjs-role/src/gateways/http/queries/impl/list-roles.request.ts new file mode 100644 index 000000000..3759ee534 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/queries/impl/list-roles.request.ts @@ -0,0 +1,5 @@ +import { CrudListQuery } from '@concepta/nestjs-crud'; + +import { type RoleInterface } from '../../../../domain/interfaces/role.interface.js'; + +export class ListRolesRequest extends CrudListQuery {} diff --git a/packages/nestjs-role/src/gateways/http/queries/impl/read-role-assignment.request.ts b/packages/nestjs-role/src/gateways/http/queries/impl/read-role-assignment.request.ts new file mode 100644 index 000000000..728b042c0 --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/queries/impl/read-role-assignment.request.ts @@ -0,0 +1,5 @@ +import { CrudReadQuery } from '@concepta/nestjs-crud'; + +import { type RoleAssignmentEntityInterface } from '../../../../domain/interfaces/role-assignment-entity.interface.js'; + +export class ReadRoleAssignmentRequest extends CrudReadQuery {} diff --git a/packages/nestjs-role/src/gateways/http/queries/impl/read-role.request.ts b/packages/nestjs-role/src/gateways/http/queries/impl/read-role.request.ts new file mode 100644 index 000000000..fde99d8bc --- /dev/null +++ b/packages/nestjs-role/src/gateways/http/queries/impl/read-role.request.ts @@ -0,0 +1,5 @@ +import { CrudReadQuery } from '@concepta/nestjs-crud'; + +import { type RoleInterface } from '../../../../domain/interfaces/role.interface.js'; + +export class ReadRoleRequest extends CrudReadQuery {} diff --git a/packages/nestjs-role/src/gateways/interfaces/role-context.interface.ts b/packages/nestjs-role/src/gateways/interfaces/role-context.interface.ts new file mode 100644 index 000000000..dcf9581c0 --- /dev/null +++ b/packages/nestjs-role/src/gateways/interfaces/role-context.interface.ts @@ -0,0 +1,3 @@ +export interface RoleContextInterface { + namespace: string; +} diff --git a/packages/nestjs-role/src/gateways/role-context.overlay.ts b/packages/nestjs-role/src/gateways/role-context.overlay.ts new file mode 100644 index 000000000..511f34468 --- /dev/null +++ b/packages/nestjs-role/src/gateways/role-context.overlay.ts @@ -0,0 +1,42 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { + ContextOverlayInterceptor, + getAppContext, + OverlayRef, +} from '@concepta/nestjs-core'; + +import { + ROLE_NAMESPACE_KEY, + RoleNamespaceOptions, +} from './decorators/role-namespace.decorator.js'; +import { RoleContextInterface } from './interfaces/role-context.interface.js'; + +export const RoleCtx = new OverlayRef<'withRole', RoleContextInterface>( + 'withRole', +); + +@Injectable() +export class RoleContextOverlay extends ContextOverlayInterceptor { + readonly ref = RoleCtx; + + constructor(private readonly reflector: Reflector) { + super(); + } + + attach(context: ExecutionContext): void { + const request = context.switchToHttp().getRequest(); + const ctx = getAppContext(request); + const resolved = this.resolve(context); + ctx.defineOverlay(RoleCtx, resolved); + } + + private resolve(context: ExecutionContext): RoleContextInterface { + const options = this.reflector.getAllAndOverride( + ROLE_NAMESPACE_KEY, + [context.getHandler(), context.getClass()], + ); + return { namespace: options?.name ?? '' }; + } +} diff --git a/packages/nestjs-role/src/index.ts b/packages/nestjs-role/src/index.ts index 42b7d5cf4..eb7181b3d 100644 --- a/packages/nestjs-role/src/index.ts +++ b/packages/nestjs-role/src/index.ts @@ -1,23 +1,98 @@ -export { RoleModule } from './role.module'; - -export { RoleService } from './services/role.service'; -export { RoleModelService } from './services/role-model.service'; - -export { RoleModelServiceInterface } from './interfaces/role-model-service.interface'; - -export { RoleAssignmentCreateManyDto } from './dto/role-assignment-create-many.dto'; -export { RoleAssignmentCreateDto } from './dto/role-assignment-create.dto'; -export { RoleAssignmentPaginatedDto } from './dto/role-assignment-paginated.dto'; -export { RoleAssignmentDto } from './dto/role-assignment.dto'; -export { RoleCreateManyDto } from './dto/role-create-many.dto'; -export { RoleCreateDto } from './dto/role-create.dto'; -export { RolePaginatedDto } from './dto/role-paginated.dto'; -export { RoleUpdateDto } from './dto/role-update.dto'; -export { RoleDto } from './dto/role.dto'; - -export { RoleResource, RoleAssignmentResource } from './role.types'; -export { RoleException } from './exceptions/role.exception'; -export { RoleAssignmentNotFoundException as AssignmentNotFoundException } from './exceptions/role-assignment-not-found.exception'; -export { RoleEntityNotFoundException as EntityNotFoundException } from './exceptions/role-entity-not-found.exception'; -export { RoleAssignmentConflictException } from './exceptions/role-assignment-conflict.exception'; -export { RoleMissingEntitiesOptionsException } from './exceptions/role-missing-entities-options.exception'; +// module +export { RoleModule } from './role.module.js'; + +// domain aggregates +export { Role } from './domain/aggregates/role.js'; +export { RoleAssignment } from './domain/aggregates/role-assignment.js'; + +// repositories +export { RoleRepository } from './infrastructure/persistence/role.repository.js'; +export { RoleAssignmentRepository } from './infrastructure/persistence/role-assignment.repository.js'; +export { RoleRepositoryResolver } from './infrastructure/persistence/role-repository.resolver.js'; +export { RoleAssignmentRepositoryResolver } from './infrastructure/persistence/role-assignment-repository.resolver.js'; + +// schemas (Zod / Standard Schema) +export { roleSchema } from './infrastructure/schemas/role.schema.js'; +export { rolePaginatedSchema } from './infrastructure/schemas/role-paginated.schema.js'; +export { roleCreateSchema } from './infrastructure/schemas/role-create.schema.js'; +export { roleUpdateSchema } from './infrastructure/schemas/role-update.schema.js'; +export { roleAssignmentSchema } from './infrastructure/schemas/role-assignment.schema.js'; +export { roleAssignmentPaginatedSchema } from './infrastructure/schemas/role-assignment-paginated.schema.js'; +export { roleAssignmentCreateSchema } from './infrastructure/schemas/role-assignment-create.schema.js'; + +// commands +export { CreateRoleCommand } from './application/commands/impl/create-role.command.js'; +export { UpdateRoleCommand } from './application/commands/impl/update-role.command.js'; +export { ReplaceRoleCommand } from './application/commands/impl/replace-role.command.js'; +export { RemoveRoleCommand } from './application/commands/impl/remove-role.command.js'; +export { AssignRoleCommand } from './application/commands/impl/assign-role.command.js'; +export { AssignRolesCommand } from './application/commands/impl/assign-roles.command.js'; +export { RevokeRoleCommand } from './application/commands/impl/revoke-role.command.js'; +export { RevokeRolesCommand } from './application/commands/impl/revoke-roles.command.js'; + +// events +export { RoleCreatedEvent } from './domain/events/role-created.event.js'; +export { RoleUpdatedEvent } from './domain/events/role-updated.event.js'; +export { RoleReplacedEvent } from './domain/events/role-replaced.event.js'; +export { RoleAssignedEvent } from './domain/events/role-assigned.event.js'; +export { RoleRevokedEvent } from './domain/events/role-revoked.event.js'; + +// queries +export { GetRoleQuery } from './application/queries/impl/get-role.query.js'; +export { GetAssignedRolesQuery } from './application/queries/impl/get-assigned-roles.query.js'; +export { IsAssignedRoleQuery } from './application/queries/impl/is-assigned-role.query.js'; +export { IsAssignedRolesQuery } from './application/queries/impl/is-assigned-roles.query.js'; +export { GetRoleAssignmentQuery } from './application/queries/impl/get-role-assignment.query.js'; + +// command handlers +export { CreateRoleHandler } from './application/commands/handlers/create-role.handler.js'; +export { UpdateRoleHandler } from './application/commands/handlers/update-role.handler.js'; +export { ReplaceRoleHandler } from './application/commands/handlers/replace-role.handler.js'; +export { RemoveRoleHandler } from './application/commands/handlers/remove-role.handler.js'; +export { AssignRoleHandler } from './application/commands/handlers/assign-role.handler.js'; +export { AssignRolesHandler } from './application/commands/handlers/assign-roles.handler.js'; +export { RevokeRoleHandler } from './application/commands/handlers/revoke-role.handler.js'; +export { RevokeRolesHandler } from './application/commands/handlers/revoke-roles.handler.js'; + +// query handlers +export { GetRoleHandler } from './application/queries/handlers/get-role.handler.js'; +export { GetAssignedRolesHandler } from './application/queries/handlers/get-assigned-roles.handler.js'; +export { IsAssignedRoleHandler } from './application/queries/handlers/is-assigned-role.handler.js'; +export { IsAssignedRolesHandler } from './application/queries/handlers/is-assigned-roles.handler.js'; +export { GetRoleAssignmentHandler } from './application/queries/handlers/get-role-assignment.handler.js'; + +// domain repository interfaces +export { RoleRepositoryInterface } from './domain/repositories/role-repository.interface.js'; +export { RoleRepositoryResolverInterface } from './domain/repositories/role-repository-resolver.interface.js'; +export { RoleAssignmentRepositoryInterface } from './domain/repositories/role-assignment-repository.interface.js'; +export { RoleAssignmentRepositoryResolverInterface } from './domain/repositories/role-assignment-repository-resolver.interface.js'; + +// domain interfaces +export { RoleInterface } from './domain/interfaces/role.interface.js'; +export { RoleCreatableInterface } from './domain/interfaces/role-creatable.interface.js'; +export { RoleUpdatableInterface } from './domain/interfaces/role-updatable.interface.js'; +export { RoleEntityInterface } from './domain/interfaces/role-entity.interface.js'; +export { RoleAssignmentInterface } from './domain/interfaces/role-assignment.interface.js'; +export { RoleAssignmentCreatableInterface } from './domain/interfaces/role-assignment-creatable.interface.js'; +export { RoleAssignmentEntityInterface } from './domain/interfaces/role-assignment-entity.interface.js'; +export { RoleAssigneesInterface } from './domain/interfaces/role-assignees.interface.js'; +export { RoleRelationInterface } from './domain/interfaces/role-relation.interface.js'; + +// config interfaces +export { RoleOptionsInterface } from './infrastructure/config/interfaces/role-options.interface.js'; +export { RoleExtrasInterface } from './infrastructure/config/interfaces/role-extras.interface.js'; + +// exceptions +export { RoleException } from './application/exceptions/role.exception.js'; +// context overlay +export { + RoleContextOverlay, + RoleCtx, +} from './gateways/role-context.overlay.js'; +export { RoleNamespace } from './gateways/decorators/role-namespace.decorator.js'; + +export { RoleAssignmentConflictException } from './application/exceptions/role-assignment-conflict.exception.js'; +export { RoleAssignmentsConflictException } from './application/exceptions/role-assignments-conflict.exception.js'; +export { RoleEntityNotFoundException } from './infrastructure/exceptions/role-entity-not-found.exception.js'; +export { RoleNotFoundException } from './application/exceptions/role-not-found.exception.js'; +export { RoleAssignmentNotFoundException } from './application/exceptions/role-assignment-not-found.exception.js'; diff --git a/packages/nestjs-role/src/infrastructure/config/interfaces/role-extras.interface.ts b/packages/nestjs-role/src/infrastructure/config/interfaces/role-extras.interface.ts new file mode 100644 index 000000000..2610a53d2 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/config/interfaces/role-extras.interface.ts @@ -0,0 +1,12 @@ +import { type DynamicModule, type Provider, type Type } from '@nestjs/common'; + +import { type RoleAssignmentRepositoryInterface } from '../../../domain/repositories/role-assignment-repository.interface.js'; +import { type RoleRepositoryInterface } from '../../../domain/repositories/role-repository.interface.js'; + +export interface RoleExtrasInterface extends Pick { + providers?: Provider[]; + repositories?: { + role?: Type; + roleAssignment?: Type; + }; +} diff --git a/packages/nestjs-role/src/infrastructure/config/interfaces/role-options.interface.ts b/packages/nestjs-role/src/infrastructure/config/interfaces/role-options.interface.ts new file mode 100644 index 000000000..605d47701 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/config/interfaces/role-options.interface.ts @@ -0,0 +1,7 @@ +/** + * Role module registration options. Currently empty — the v7-era + * `settings.assignments` map was superseded by `RoleModule.forFeature`'s + * `assignmentEntityKeys`. Kept as an extension point for genuinely + * module-wide options, should one ever be needed. + */ +export interface RoleOptionsInterface {} diff --git a/packages/nestjs-role/src/infrastructure/exceptions/role-entity-not-found.exception.ts b/packages/nestjs-role/src/infrastructure/exceptions/role-entity-not-found.exception.ts new file mode 100644 index 000000000..8ac60a878 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/exceptions/role-entity-not-found.exception.ts @@ -0,0 +1,26 @@ +import { + type RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +import { RoleException } from '../../application/exceptions/role.exception.js'; + +export class RoleEntityNotFoundException extends RoleException { + declare context: RuntimeException['context'] & { entityName: string }; + + constructor(entityName: string, options?: RuntimeExceptionOptions) { + super({ + message: 'Entity %s was not registered to be used.', + messageParams: [entityName], + fault: 'usage', + ...options, + }); + + this.errorCode = 'ROLE_ENTITY_NOT_FOUND_ERROR'; + + this.context = { + ...this.context, + entityName, + }; + } +} diff --git a/packages/nestjs-role/src/infrastructure/persistence/role-assignment-repository.resolver.ts b/packages/nestjs-role/src/infrastructure/persistence/role-assignment-repository.resolver.ts new file mode 100644 index 000000000..a1f49769f --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/persistence/role-assignment-repository.resolver.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; + +import { RoleAssignmentRepositoryResolverInterface } from '../../domain/repositories/role-assignment-repository-resolver.interface.js'; +import { RoleAssignmentRepositoryInterface } from '../../domain/repositories/role-assignment-repository.interface.js'; +import { RoleEntityNotFoundException } from '../exceptions/role-entity-not-found.exception.js'; +import { getDynamicRoleAssignmentRepositoryToken } from '../utils/create-role-assignment-repository-provider.js'; + +@Injectable() +export class RoleAssignmentRepositoryResolver implements RoleAssignmentRepositoryResolverInterface { + constructor(private readonly moduleRef: ModuleRef) {} + + resolve(entityKey: string): RoleAssignmentRepositoryInterface { + const token = getDynamicRoleAssignmentRepositoryToken(entityKey); + + try { + return this.moduleRef.get(token, { + strict: false, + }); + } catch (error) { + throw new RoleEntityNotFoundException(entityKey, { + originalError: error, + }); + } + } +} diff --git a/packages/nestjs-role/src/infrastructure/persistence/role-assignment.mapper.ts b/packages/nestjs-role/src/infrastructure/persistence/role-assignment.mapper.ts new file mode 100644 index 000000000..4ae3f125e --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/persistence/role-assignment.mapper.ts @@ -0,0 +1,21 @@ +import { DomainMapper } from '@concepta/nestjs-core/aggregate'; + +import { RoleAssignment } from '../../domain/aggregates/role-assignment.js'; +import { type RoleAssignmentEntityInterface } from '../../domain/interfaces/role-assignment-entity.interface.js'; +import { type RoleAssignmentInterface } from '../../domain/interfaces/role-assignment.interface.js'; + +export class RoleAssignmentMapper extends DomainMapper< + RoleAssignmentEntityInterface, + RoleAssignmentInterface, + RoleAssignment +> { + createAggregate(entity: RoleAssignmentEntityInterface): RoleAssignment { + const { id, version, dateCreated, dateUpdated, dateDeleted, ...props } = + entity; + return new RoleAssignment(id, props, version, { + dateCreated, + dateUpdated, + dateDeleted, + }); + } +} diff --git a/packages/nestjs-role/src/infrastructure/persistence/role-assignment.repository.ts b/packages/nestjs-role/src/infrastructure/persistence/role-assignment.repository.ts new file mode 100644 index 000000000..4c9c58756 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/persistence/role-assignment.repository.ts @@ -0,0 +1,144 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; +import { type RepositoryInterface, Where } from '@concepta/nestjs-repository'; + +import { type RoleAssignment } from '../../domain/aggregates/role-assignment.js'; +import { type RoleAssignmentEntityInterface } from '../../domain/interfaces/role-assignment-entity.interface.js'; +import { type RoleAssignmentRepositoryInterface } from '../../domain/repositories/role-assignment-repository.interface.js'; + +import { type RoleAssignmentMapper } from './role-assignment.mapper.js'; + +export class RoleAssignmentRepository implements RoleAssignmentRepositoryInterface { + constructor( + protected readonly repository: RepositoryInterface, + private readonly mapper: RoleAssignmentMapper, + ) {} + + async get( + ctx: PlainLiteralObject, + id: ReferenceId, + ): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.eq('id', id), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByAssignee( + ctx: PlainLiteralObject, + assigneeId: string, + ): Promise { + const w = Where.for(); + + const entities = await this.repository.find({ + where: w.eq('assigneeId', assigneeId), + ctx, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findOne( + ctx: PlainLiteralObject, + roleId: string, + assigneeId: string, + ): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.and(w.eq('roleId', roleId), w.eq('assigneeId', assigneeId)), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByRoleIdsAndAssignee( + ctx: PlainLiteralObject, + roleIds: string[], + assigneeId: string, + ): Promise { + const w = Where.for(); + + const entities = await this.repository.find({ + where: w.and(w.in('roleId', roleIds), w.eq('assigneeId', assigneeId)), + ctx, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async countByRoleIdAndAssignee( + ctx: PlainLiteralObject, + roleId: string, + assigneeId: string, + ): Promise { + const w = Where.for(); + + return this.repository.count({ + where: w.and(w.eq('roleId', roleId), w.eq('assigneeId', assigneeId)), + ctx, + }); + } + + async countByRoleIdsAndAssignee( + ctx: PlainLiteralObject, + roleIds: string[], + assigneeId: string, + ): Promise { + const w = Where.for(); + + return this.repository.count({ + where: w.and(w.in('roleId', roleIds), w.eq('assigneeId', assigneeId)), + ctx, + }); + } + + async save( + ctx: PlainLiteralObject, + roleAssignment: RoleAssignment, + ): Promise { + roleAssignment.stampUpdated(); + await this.repository.upsert(this.mapper.toPersistence(roleAssignment), { + ctx, + }); + } + + /** + * Save multiple assignments sequentially within the current transaction. + * Sequential execution ensures consistent ordering and avoids + * potential issues with parallel writes in the same transaction. + */ + async saveMany( + ctx: PlainLiteralObject, + roleAssignments: RoleAssignment[], + ): Promise { + for (const ra of roleAssignments) { + await this.save(ctx, ra); + } + } + + async remove( + ctx: PlainLiteralObject, + roleAssignment: RoleAssignment, + ): Promise { + await this.repository.delete(this.mapper.toPersistence(roleAssignment), { + ctx, + }); + } + + async removeMany( + ctx: PlainLiteralObject, + roleAssignments: RoleAssignment[], + ): Promise { + await this.repository.deleteMany( + roleAssignments.map((ra) => this.mapper.toPersistence(ra)), + { ctx }, + ); + } +} diff --git a/packages/nestjs-role/src/infrastructure/persistence/role-repository.resolver.ts b/packages/nestjs-role/src/infrastructure/persistence/role-repository.resolver.ts new file mode 100644 index 000000000..4ac443d89 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/persistence/role-repository.resolver.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; + +import { RoleRepositoryResolverInterface } from '../../domain/repositories/role-repository-resolver.interface.js'; +import { RoleRepositoryInterface } from '../../domain/repositories/role-repository.interface.js'; +import { RoleEntityNotFoundException } from '../exceptions/role-entity-not-found.exception.js'; +import { getDynamicRoleRepositoryToken } from '../utils/create-role-repository-provider.js'; + +@Injectable() +export class RoleRepositoryResolver implements RoleRepositoryResolverInterface { + constructor(private readonly moduleRef: ModuleRef) {} + + resolve(entityKey: string): RoleRepositoryInterface { + const token = getDynamicRoleRepositoryToken(entityKey); + + try { + return this.moduleRef.get(token, { + strict: false, + }); + } catch (error) { + throw new RoleEntityNotFoundException(entityKey, { + originalError: error, + }); + } + } +} diff --git a/packages/nestjs-role/src/infrastructure/persistence/role.factory.ts b/packages/nestjs-role/src/infrastructure/persistence/role.factory.ts new file mode 100644 index 000000000..9fbf1c56a --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/persistence/role.factory.ts @@ -0,0 +1,48 @@ +import { faker } from '@faker-js/faker'; + +import { Factory } from '@concepta/typeorm-seeding'; + +import { type RoleEntityInterface } from '../../domain/interfaces/role-entity.interface.js'; + +/** + * Role factory + */ +export class RoleFactory extends Factory { + /** + * List of used names. + */ + private usedNames: Record = {}; + + /** + * Factory callback function. + */ + protected async entity( + role: RoleEntityInterface, + ): Promise { + role.name = this.generateName(); + role.description = faker.lorem.sentence(); + return role; + } + + /** + * Generate a unique name. + */ + protected generateName(): string { + const MAX_ATTEMPTS = 1000; + let name: string; + let attempts = 0; + + do { + name = faker.lorem.word(); + attempts++; + if (attempts >= MAX_ATTEMPTS) { + name = `${name}-${attempts}`; + break; + } + } while (this.usedNames[name]); + + this.usedNames[name] = true; + + return name; + } +} diff --git a/packages/nestjs-role/src/infrastructure/persistence/role.mapper.ts b/packages/nestjs-role/src/infrastructure/persistence/role.mapper.ts new file mode 100644 index 000000000..fa4b177b2 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/persistence/role.mapper.ts @@ -0,0 +1,22 @@ +import { DomainMapper } from '@concepta/nestjs-core/aggregate'; + +import { Role } from '../../domain/aggregates/role.js'; +import { type RoleEntityInterface } from '../../domain/interfaces/role-entity.interface.js'; +import { type RoleInterface } from '../../domain/interfaces/role.interface.js'; + +export class RoleMapper extends DomainMapper< + RoleEntityInterface, + RoleInterface, + Role +> { + createAggregate(entity: RoleEntityInterface): Role { + const { id, version, dateCreated, dateUpdated, dateDeleted, ...props } = + entity; + + return new Role(id, props, version, { + dateCreated, + dateUpdated, + dateDeleted, + }); + } +} diff --git a/packages/nestjs-role/src/infrastructure/persistence/role.repository.ts b/packages/nestjs-role/src/infrastructure/persistence/role.repository.ts new file mode 100644 index 000000000..a80f83198 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/persistence/role.repository.ts @@ -0,0 +1,37 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; +import { type RepositoryInterface, Where } from '@concepta/nestjs-repository'; + +import { type Role } from '../../domain/aggregates/role.js'; +import { type RoleEntityInterface } from '../../domain/interfaces/role-entity.interface.js'; +import { type RoleRepositoryInterface } from '../../domain/repositories/role-repository.interface.js'; + +import { type RoleMapper } from './role.mapper.js'; + +export class RoleRepository implements RoleRepositoryInterface { + constructor( + protected readonly repository: RepositoryInterface, + private readonly mapper: RoleMapper, + ) {} + + async get(ctx: PlainLiteralObject, id: ReferenceId): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.eq('id', id), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async save(ctx: PlainLiteralObject, role: Role): Promise { + role.stampUpdated(); + await this.repository.upsert(this.mapper.toPersistence(role), { ctx }); + } + + async remove(ctx: PlainLiteralObject, role: Role): Promise { + await this.repository.delete(this.mapper.toPersistence(role), { ctx }); + } +} diff --git a/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-assignment-postgres.entity.ts b/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-assignment-postgres.entity.ts new file mode 100644 index 000000000..0b0391f6e --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-assignment-postgres.entity.ts @@ -0,0 +1,27 @@ +import { Column, Unique } from 'typeorm'; + +import { ReferenceId } from '@concepta/nestjs-core'; +import { CommonPostgresEntity } from '@concepta/nestjs-repository-typeorm'; + +import { RoleAssignmentEntityInterface } from '../../../domain/interfaces/role-assignment-entity.interface.js'; + +/** + * Role Assignment Postgres Entity + */ +@Unique(['roleId', 'assigneeId']) +export abstract class RoleAssignmentPostgresEntity + extends CommonPostgresEntity + implements RoleAssignmentEntityInterface +{ + /** + * Role ID + */ + @Column({ type: 'uuid' }) + roleId!: ReferenceId; + + /** + * Assignee ID + */ + @Column({ type: 'uuid' }) + assigneeId!: ReferenceId; +} diff --git a/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-assignment-sqlite.entity.ts b/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-assignment-sqlite.entity.ts new file mode 100644 index 000000000..3d266b656 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-assignment-sqlite.entity.ts @@ -0,0 +1,27 @@ +import { Column, Unique } from 'typeorm'; + +import { ReferenceId } from '@concepta/nestjs-core'; +import { CommonSqliteEntity } from '@concepta/nestjs-repository-typeorm'; + +import { RoleAssignmentEntityInterface } from '../../../domain/interfaces/role-assignment-entity.interface.js'; + +/** + * Role Assignment Sqlite Entity + */ +@Unique(['roleId', 'assigneeId']) +export abstract class RoleAssignmentSqliteEntity + extends CommonSqliteEntity + implements RoleAssignmentEntityInterface +{ + /** + * Role ID + */ + @Column({ type: 'uuid' }) + roleId!: ReferenceId; + + /** + * Assignee ID + */ + @Column({ type: 'uuid' }) + assigneeId!: ReferenceId; +} diff --git a/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-postgres.entity.ts b/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-postgres.entity.ts new file mode 100644 index 000000000..91f95eabd --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-postgres.entity.ts @@ -0,0 +1,25 @@ +import { Column } from 'typeorm'; + +import { CommonPostgresEntity } from '@concepta/nestjs-repository-typeorm'; + +import { RoleEntityInterface } from '../../../domain/interfaces/role-entity.interface.js'; + +/** + * Role Postgres Entity + */ +export abstract class RolePostgresEntity + extends CommonPostgresEntity + implements RoleEntityInterface +{ + /** + * Name + */ + @Column() + name!: string; + + /** + * Description + */ + @Column() + description!: string; +} diff --git a/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-sqlite.entity.ts b/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-sqlite.entity.ts new file mode 100644 index 000000000..d46781e18 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/persistence/typeorm/role-sqlite.entity.ts @@ -0,0 +1,19 @@ +import { Column } from 'typeorm'; + +import { CommonSqliteEntity } from '@concepta/nestjs-repository-typeorm'; + +import { RoleEntityInterface } from '../../../domain/interfaces/role-entity.interface.js'; + +/** + * Role Sqlite Entity + */ +export abstract class RoleSqliteEntity + extends CommonSqliteEntity + implements RoleEntityInterface +{ + @Column() + name!: string; + + @Column({ nullable: true }) + description!: string; +} diff --git a/packages/nestjs-role/src/infrastructure/schemas/role-assignment-create-batch.schema.ts b/packages/nestjs-role/src/infrastructure/schemas/role-assignment-create-batch.schema.ts new file mode 100644 index 000000000..b5c5e67bb --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/schemas/role-assignment-create-batch.schema.ts @@ -0,0 +1,8 @@ +import { withOpenApi } from '@concepta/nestjs-core'; +import { createBatchSchema } from '@concepta/nestjs-crud'; + +import { roleAssignmentCreateSchema } from './role-assignment-create.schema.js'; + +export const roleAssignmentCreateBatchSchema = withOpenApi( + createBatchSchema(roleAssignmentCreateSchema), +); diff --git a/packages/nestjs-role/src/infrastructure/schemas/role-assignment-create.schema.ts b/packages/nestjs-role/src/infrastructure/schemas/role-assignment-create.schema.ts new file mode 100644 index 000000000..0f536ed24 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/schemas/role-assignment-create.schema.ts @@ -0,0 +1,15 @@ +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type RoleAssignmentCreatableInterface } from '../../domain/interfaces/role-assignment-creatable.interface.js'; + +import { roleAssignmentSchema } from './role-assignment.schema.js'; + +/** + * Used only as a request body — not a named OpenAPI component (no + * `withNamedComponent`). + */ +export const roleAssignmentCreateSchema = withOpenApi( + conformsTo()( + roleAssignmentSchema.pick({ roleId: true, assigneeId: true }), + ), +); diff --git a/packages/nestjs-role/src/infrastructure/schemas/role-assignment-paginated.schema.ts b/packages/nestjs-role/src/infrastructure/schemas/role-assignment-paginated.schema.ts new file mode 100644 index 000000000..967dd66c7 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/schemas/role-assignment-paginated.schema.ts @@ -0,0 +1,9 @@ +import { withNamedComponent } from '@concepta/nestjs-core'; +import { paginatedSchema } from '@concepta/nestjs-crud'; + +import { roleAssignmentSchema } from './role-assignment.schema.js'; + +export const roleAssignmentPaginatedSchema = withNamedComponent( + paginatedSchema(roleAssignmentSchema), + 'RoleAssignmentPaginated', +); diff --git a/packages/nestjs-role/src/infrastructure/schemas/role-assignment.schema.ts b/packages/nestjs-role/src/infrastructure/schemas/role-assignment.schema.ts new file mode 100644 index 000000000..73deffb70 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/schemas/role-assignment.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { conformsTo, withNamedComponent } from '@concepta/nestjs-core'; +import { domainAggregateSchema } from '@concepta/nestjs-core/aggregate'; + +import { type RoleAssignmentInterface } from '../../domain/interfaces/role-assignment.interface.js'; + +export const roleAssignmentSchema = withNamedComponent( + conformsTo()( + domainAggregateSchema.extend({ + roleId: z.string().min(1).meta({ description: 'Role ID' }), + assigneeId: z.string().min(1).meta({ description: 'Assignee ID' }), + }), + ), + 'RoleAssignment', +); diff --git a/packages/nestjs-role/src/infrastructure/schemas/role-create-batch.schema.ts b/packages/nestjs-role/src/infrastructure/schemas/role-create-batch.schema.ts new file mode 100644 index 000000000..e1b0fbe15 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/schemas/role-create-batch.schema.ts @@ -0,0 +1,8 @@ +import { withOpenApi } from '@concepta/nestjs-core'; +import { createBatchSchema } from '@concepta/nestjs-crud'; + +import { roleCreateSchema } from './role-create.schema.js'; + +export const roleCreateBatchSchema = withOpenApi( + createBatchSchema(roleCreateSchema), +); diff --git a/packages/nestjs-role/src/infrastructure/schemas/role-create.schema.ts b/packages/nestjs-role/src/infrastructure/schemas/role-create.schema.ts new file mode 100644 index 000000000..7bd7f108c --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/schemas/role-create.schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type RoleCreatableInterface } from '../../domain/interfaces/role-creatable.interface.js'; + +/** + * Used only as a request body — not a named OpenAPI component (no + * `withNamedComponent`). `name` is required and rejects a blank or + * whitespace-only value (`.trim().min(1)`) — the legacy `RoleDto` had no + * `@IsNotEmpty()` on `name`, but that gap was never actually reachable: with + * `excludeExtraneousValues: true`, class-transformer left an omitted `name` + * as `undefined` (not the property-initializer default), which + * `@IsString()` (no `@IsOptional()`) rejected with a 400. `description` + * keeps `.default('')`, matching the legacy `@IsOptional()` on that field. + */ +export const roleCreateSchema = withOpenApi( + conformsTo()( + z.object({ + name: z.string().trim().min(1).meta({ description: 'Name of the role' }), + description: z + .string() + .default('') + .meta({ description: 'Description of the role' }), + }), + ), +); diff --git a/packages/nestjs-role/src/infrastructure/schemas/role-paginated.schema.ts b/packages/nestjs-role/src/infrastructure/schemas/role-paginated.schema.ts new file mode 100644 index 000000000..3e0874de8 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/schemas/role-paginated.schema.ts @@ -0,0 +1,9 @@ +import { withNamedComponent } from '@concepta/nestjs-core'; +import { paginatedSchema } from '@concepta/nestjs-crud'; + +import { roleSchema } from './role.schema.js'; + +export const rolePaginatedSchema = withNamedComponent( + paginatedSchema(roleSchema), + 'RolePaginated', +); diff --git a/packages/nestjs-role/src/infrastructure/schemas/role-update.schema.ts b/packages/nestjs-role/src/infrastructure/schemas/role-update.schema.ts new file mode 100644 index 000000000..c4bf3e30c --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/schemas/role-update.schema.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type RoleUpdatableInterface } from '../../domain/interfaces/role-updatable.interface.js'; + +/** + * Used only as a request body — not a named OpenAPI component (no + * `withNamedComponent`). Unlike `roleCreateSchema`, both fields are + * `.optional()` — this is a partial update, so an omitted field must mean + * "don't touch it", not "set it to `''`". A present-but-blank `name` is + * still rejected (`.trim().min(1)`); an empty body (`{}`) is accepted as a + * no-op patch. + */ +export const roleUpdateSchema = withOpenApi( + conformsTo()( + z.object({ + name: z + .string() + .trim() + .min(1) + .optional() + .meta({ description: 'Name of the role' }), + description: z + .string() + .optional() + .meta({ description: 'Description of the role' }), + }), + ), +); diff --git a/packages/nestjs-role/src/infrastructure/schemas/role.schema.spec.ts b/packages/nestjs-role/src/infrastructure/schemas/role.schema.spec.ts new file mode 100644 index 000000000..7ffb226a8 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/schemas/role.schema.spec.ts @@ -0,0 +1,206 @@ +import { roleAssignmentCreateBatchSchema } from './role-assignment-create-batch.schema.js'; +import { roleAssignmentCreateSchema } from './role-assignment-create.schema.js'; +import { roleAssignmentPaginatedSchema } from './role-assignment-paginated.schema.js'; +import { roleAssignmentSchema } from './role-assignment.schema.js'; +import { roleCreateBatchSchema } from './role-create-batch.schema.js'; +import { roleCreateSchema } from './role-create.schema.js'; +import { rolePaginatedSchema } from './role-paginated.schema.js'; +import { roleUpdateSchema } from './role-update.schema.js'; +import { roleSchema } from './role.schema.js'; + +const validRole = { + id: 'abc', + version: 1, + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-02'), + dateDeleted: null, + name: 'admin', + description: 'Administrator role', +}; + +describe('roleSchema', () => { + it('accepts a valid role entity', () => { + expect(roleSchema.parse(validRole)).toEqual(validRole); + }); + + it('strips unknown keys', () => { + const result = roleSchema.parse({ ...validRole, _internal: 'x' }); + expect(result).not.toHaveProperty('_internal'); + }); +}); + +describe('roleCreateSchema', () => { + const validCreate = { name: 'admin', description: 'Administrator role' }; + + it('accepts a valid create payload', () => { + expect(roleCreateSchema.parse(validCreate)).toEqual(validCreate); + }); + + it('defaults description to "" when omitted (matching legacy @IsOptional())', () => { + const { description: _description, ...rest } = validCreate; + expect(roleCreateSchema.parse(rest)).toEqual({ ...rest, description: '' }); + }); + + it('rejects a missing name', () => { + const { name: _name, ...rest } = validCreate; + expect(roleCreateSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects an empty name', () => { + expect( + roleCreateSchema.safeParse({ ...validCreate, name: '' }).success, + ).toBe(false); + }); + + it('rejects a whitespace-only name', () => { + expect( + roleCreateSchema.safeParse({ ...validCreate, name: ' ' }).success, + ).toBe(false); + }); + + it('rejects an empty payload', () => { + expect(roleCreateSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('roleUpdateSchema', () => { + const validUpdate = { name: 'editor', description: 'Editor role' }; + + it('accepts a valid update payload', () => { + expect(roleUpdateSchema.parse(validUpdate)).toEqual(validUpdate); + }); + + it('leaves description untouched (unset) when omitted', () => { + const { description: _description, ...rest } = validUpdate; + expect(roleUpdateSchema.parse(rest)).toEqual(rest); + }); + + it('leaves name untouched (unset) when only description is sent', () => { + const { name: _name, ...rest } = validUpdate; + const result = roleUpdateSchema.parse(rest); + expect(result).toEqual(rest); + expect(result).not.toHaveProperty('name'); + }); + + it('rejects an empty name', () => { + expect( + roleUpdateSchema.safeParse({ ...validUpdate, name: '' }).success, + ).toBe(false); + }); + + it('rejects a whitespace-only name', () => { + expect( + roleUpdateSchema.safeParse({ ...validUpdate, name: ' ' }).success, + ).toBe(false); + }); + + it('accepts an empty payload as a no-op patch', () => { + expect(roleUpdateSchema.parse({})).toEqual({}); + }); +}); + +describe('rolePaginatedSchema', () => { + it('accepts a paginated list of role entities', () => { + const payload = { + data: [validRole], + limit: 10, + count: 1, + total: 1, + page: 1, + pageCount: 1, + }; + expect(rolePaginatedSchema.parse(payload)).toEqual(payload); + }); +}); + +const validRoleAssignment = { + id: 'def', + version: 1, + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-02'), + dateDeleted: null, + roleId: 'role-1', + assigneeId: 'user-1', +}; + +describe('roleAssignmentSchema', () => { + it('accepts a valid role assignment entity', () => { + expect(roleAssignmentSchema.parse(validRoleAssignment)).toEqual( + validRoleAssignment, + ); + }); + + it('rejects a missing roleId', () => { + const { roleId: _roleId, ...rest } = validRoleAssignment; + expect(roleAssignmentSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects an empty roleId (matching legacy @IsNotEmpty())', () => { + expect( + roleAssignmentSchema.safeParse({ ...validRoleAssignment, roleId: '' }) + .success, + ).toBe(false); + }); + + it('rejects an empty assigneeId (matching legacy @IsNotEmpty())', () => { + expect( + roleAssignmentSchema.safeParse({ + ...validRoleAssignment, + assigneeId: '', + }).success, + ).toBe(false); + }); +}); + +describe('roleAssignmentCreateSchema', () => { + const validCreate = { roleId: 'role-1', assigneeId: 'user-1' }; + + it('accepts a valid create payload', () => { + expect(roleAssignmentCreateSchema.parse(validCreate)).toEqual(validCreate); + }); + + it('rejects a missing assigneeId', () => { + const { assigneeId: _assigneeId, ...rest } = validCreate; + expect(roleAssignmentCreateSchema.safeParse(rest).success).toBe(false); + }); +}); + +describe('roleAssignmentPaginatedSchema', () => { + it('accepts a paginated list of role assignment entities', () => { + const payload = { + data: [validRoleAssignment], + limit: 10, + count: 1, + total: 1, + page: 1, + pageCount: 1, + }; + expect(roleAssignmentPaginatedSchema.parse(payload)).toEqual(payload); + }); +}); + +describe('roleCreateBatchSchema', () => { + it('accepts a bulk array of role create payloads', () => { + const payload = { bulk: [{ name: 'admin', description: 'Admin role' }] }; + expect(roleCreateBatchSchema.parse(payload)).toEqual(payload); + }); + + it('rejects an empty bulk array (matching legacy @ArrayNotEmpty())', () => { + expect(roleCreateBatchSchema.safeParse({ bulk: [] }).success).toBe(false); + }); +}); + +describe('roleAssignmentCreateBatchSchema', () => { + it('accepts a bulk array of role assignment create payloads', () => { + const payload = { + bulk: [{ roleId: 'role-1', assigneeId: 'user-1' }], + }; + expect(roleAssignmentCreateBatchSchema.parse(payload)).toEqual(payload); + }); + + it('rejects an empty bulk array (matching legacy @ArrayNotEmpty())', () => { + expect( + roleAssignmentCreateBatchSchema.safeParse({ bulk: [] }).success, + ).toBe(false); + }); +}); diff --git a/packages/nestjs-role/src/infrastructure/schemas/role.schema.ts b/packages/nestjs-role/src/infrastructure/schemas/role.schema.ts new file mode 100644 index 000000000..a19feff73 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/schemas/role.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { conformsTo, withNamedComponent } from '@concepta/nestjs-core'; +import { domainAggregateSchema } from '@concepta/nestjs-core/aggregate'; + +import { type RoleInterface } from '../../domain/interfaces/role.interface.js'; + +export const roleSchema = withNamedComponent( + conformsTo()( + domainAggregateSchema.extend({ + name: z.string().meta({ description: 'Name of the role' }), + description: z.string().meta({ description: 'Description of the role' }), + }), + ), + 'Role', +); diff --git a/packages/nestjs-role/src/infrastructure/utils/create-role-assignment-repository-provider.ts b/packages/nestjs-role/src/infrastructure/utils/create-role-assignment-repository-provider.ts new file mode 100644 index 000000000..5a5ee7d2f --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/utils/create-role-assignment-repository-provider.ts @@ -0,0 +1,44 @@ +import { type Provider, type Type } from '@nestjs/common'; + +import { + getDynamicRepositoryToken, + type RepositoryInterface, +} from '@concepta/nestjs-repository'; + +import { type RoleAssignmentEntityInterface } from '../../domain/interfaces/role-assignment-entity.interface.js'; +import { type RoleAssignmentRepositoryInterface } from '../../domain/repositories/role-assignment-repository.interface.js'; +import { ROLE_ASSIGNMENT_CUSTOM_REPOSITORY_TOKEN } from '../../role.constants.js'; +import { RoleAssignmentMapper } from '../persistence/role-assignment.mapper.js'; +import { RoleAssignmentRepository } from '../persistence/role-assignment.repository.js'; + +/** + * Generates a dynamic repository token for a given Role Assignment entity key. + * + * @param entityKey - Entity key to generate the repository token for + */ +export function getDynamicRoleAssignmentRepositoryToken( + entityKey: string, +): string { + return `ROLE_ASSIGNMENT_REPOSITORY_${entityKey.toUpperCase()}`; +} + +export function createRoleAssignmentRepositoryProvider( + entityKey: string, +): Provider { + return { + provide: getDynamicRoleAssignmentRepositoryToken(entityKey), + inject: [ + getDynamicRepositoryToken(entityKey), + RoleAssignmentMapper, + { token: ROLE_ASSIGNMENT_CUSTOM_REPOSITORY_TOKEN, optional: true }, + ], + useFactory: ( + repository: RepositoryInterface, + mapper: RoleAssignmentMapper, + customRepo?: Type, + ) => { + const RepoClass = customRepo ?? RoleAssignmentRepository; + return new RepoClass(repository, mapper); + }, + }; +} diff --git a/packages/nestjs-role/src/infrastructure/utils/create-role-repository-provider.ts b/packages/nestjs-role/src/infrastructure/utils/create-role-repository-provider.ts new file mode 100644 index 000000000..a22430ea0 --- /dev/null +++ b/packages/nestjs-role/src/infrastructure/utils/create-role-repository-provider.ts @@ -0,0 +1,40 @@ +import { type Provider, type Type } from '@nestjs/common'; + +import { + getDynamicRepositoryToken, + type RepositoryInterface, +} from '@concepta/nestjs-repository'; + +import { type RoleEntityInterface } from '../../domain/interfaces/role-entity.interface.js'; +import { type RoleRepositoryInterface } from '../../domain/repositories/role-repository.interface.js'; +import { ROLE_CUSTOM_REPOSITORY_TOKEN } from '../../role.constants.js'; +import { RoleMapper } from '../persistence/role.mapper.js'; +import { RoleRepository } from '../persistence/role.repository.js'; + +/** + * Generates a dynamic repository token for a given Role entity key. + * + * @param entityKey - Entity key to generate the repository token for + */ +export function getDynamicRoleRepositoryToken(entityKey: string): string { + return `ROLE_REPOSITORY_${entityKey.toUpperCase()}`; +} + +export function createRoleRepositoryProvider(entityKey: string): Provider { + return { + provide: getDynamicRoleRepositoryToken(entityKey), + inject: [ + getDynamicRepositoryToken(entityKey), + RoleMapper, + { token: ROLE_CUSTOM_REPOSITORY_TOKEN, optional: true }, + ], + useFactory: ( + repository: RepositoryInterface, + mapper: RoleMapper, + customRepo?: Type, + ) => { + const RepoClass = customRepo ?? RoleRepository; + return new RepoClass(repository, mapper); + }, + }; +} diff --git a/packages/nestjs-role/src/interfaces/role-assignment-context.ts b/packages/nestjs-role/src/interfaces/role-assignment-context.ts deleted file mode 100644 index 305e44c94..000000000 --- a/packages/nestjs-role/src/interfaces/role-assignment-context.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - ReferenceAssigneeInterface, - ReferenceAssignmentInterface, - ReferenceIdInterface, -} from '@concepta/nestjs-common'; - -export interface RoleAssignmentContext - extends ReferenceAssignmentInterface, - ReferenceAssigneeInterface {} diff --git a/packages/nestjs-role/src/interfaces/role-assignment-options.interface.ts b/packages/nestjs-role/src/interfaces/role-assignment-options.interface.ts deleted file mode 100644 index 8c9b78b07..000000000 --- a/packages/nestjs-role/src/interfaces/role-assignment-options.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - ReferenceIdInterface, - ReferenceRoleInterface, -} from '@concepta/nestjs-common'; - -import { RoleAssignmentContext } from './role-assignment-context'; - -export interface RoleAssignmentOptionsInterface - extends RoleAssignmentContext, - ReferenceRoleInterface {} diff --git a/packages/nestjs-role/src/interfaces/role-entities-options.interface.ts b/packages/nestjs-role/src/interfaces/role-entities-options.interface.ts deleted file mode 100644 index 8acc288f1..000000000 --- a/packages/nestjs-role/src/interfaces/role-entities-options.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ROLE_MODULE_ROLE_ASSIGNMENT_KEY } from '../role.constants'; - -export interface RoleEntitiesOptionsInterface { - [ROLE_MODULE_ROLE_ASSIGNMENT_KEY]?: string[]; -} diff --git a/packages/nestjs-role/src/interfaces/role-model-service.interface.ts b/packages/nestjs-role/src/interfaces/role-model-service.interface.ts deleted file mode 100644 index f5c4eff85..000000000 --- a/packages/nestjs-role/src/interfaces/role-model-service.interface.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - CreateOneInterface, - ReferenceIdInterface, - RemoveOneInterface, - ReplaceOneInterface, - UpdateOneInterface, - RoleCreatableInterface, - RoleUpdatableInterface, - ByIdInterface, - ReferenceId, - RoleEntityInterface, -} from '@concepta/nestjs-common'; - -export interface RoleModelServiceInterface - extends ByIdInterface, - CreateOneInterface, - UpdateOneInterface< - RoleUpdatableInterface & ReferenceIdInterface, - RoleEntityInterface - >, - ReplaceOneInterface< - RoleCreatableInterface & ReferenceIdInterface, - RoleEntityInterface - >, - RemoveOneInterface, RoleEntityInterface> {} diff --git a/packages/nestjs-role/src/interfaces/role-options-extras.interface.ts b/packages/nestjs-role/src/interfaces/role-options-extras.interface.ts deleted file mode 100644 index aab310793..000000000 --- a/packages/nestjs-role/src/interfaces/role-options-extras.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface RoleOptionsExtrasInterface - extends Pick { - entities?: string[]; -} diff --git a/packages/nestjs-role/src/interfaces/role-options.interface.ts b/packages/nestjs-role/src/interfaces/role-options.interface.ts deleted file mode 100644 index 53312246f..000000000 --- a/packages/nestjs-role/src/interfaces/role-options.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { RoleModelServiceInterface } from './role-model-service.interface'; -import { RoleSettingsInterface } from './role-settings.interface'; - -export interface RoleOptionsInterface { - settings: RoleSettingsInterface; - roleModelService?: RoleModelServiceInterface; -} diff --git a/packages/nestjs-role/src/interfaces/role-service.interface.ts b/packages/nestjs-role/src/interfaces/role-service.interface.ts deleted file mode 100644 index 88b9f4847..000000000 --- a/packages/nestjs-role/src/interfaces/role-service.interface.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - ReferenceIdInterface, - RoleAssignmentEntityInterface, -} from '@concepta/nestjs-common'; - -import { RoleAssignmentContext } from './role-assignment-context'; -import { RoleAssignmentOptionsInterface } from './role-assignment-options.interface'; -import { RolesAssignmentOptionsInterface } from './roles-assignment-options.interface'; - -export interface RoleServiceInterface { - /** - * Get all roles for assignee. - * - * @param options - The assignment and assignee of the check (same as entity key) - */ - getAssignedRoles( - options: RoleAssignmentContext, - ): Promise; - - /** - * Check if the assignee is a member of one role. - * - * @param options - The assignment, roles and assignee to check - */ - isAssignedRole( - options: RoleAssignmentOptionsInterface, - ): Promise; - - /** - * Check if the assignee is a member of every role. - * - * @param options - The assignment, roles and assignee to check - */ - isAssignedRoles( - options: RolesAssignmentOptionsInterface, - ): Promise; - - /** - * Assign a role to an assignee. - * - * @param options - The assignment, role and assignee - */ - assignRole( - options: RoleAssignmentOptionsInterface, - ): Promise; - - /** - * Assign multiple roles to an assignee. - * - * @param options - The assignment, roles and assignee - */ - assignRoles( - options: RolesAssignmentOptionsInterface, - ): Promise; - - /** - * Revoke a role from an assignee. - * - * @param options - The assignment, role and assignee - */ - revokeRole( - options: RoleAssignmentOptionsInterface, - ): Promise; - - /** - * Revoke multiple roles from an assignee. - * - * @param options - The assignment, roles and assignee - */ - revokeRoles( - options: RolesAssignmentOptionsInterface, - ): Promise; -} diff --git a/packages/nestjs-role/src/interfaces/role-settings.interface.ts b/packages/nestjs-role/src/interfaces/role-settings.interface.ts deleted file mode 100644 index 76bce458b..000000000 --- a/packages/nestjs-role/src/interfaces/role-settings.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { LiteralObject } from '@concepta/nestjs-common'; - -export interface RoleSettingsInterface { - assignments: LiteralObject<{ entityKey: string }>; -} diff --git a/packages/nestjs-role/src/interfaces/roles-assignment-options.interface.ts b/packages/nestjs-role/src/interfaces/roles-assignment-options.interface.ts deleted file mode 100644 index ac8b59838..000000000 --- a/packages/nestjs-role/src/interfaces/roles-assignment-options.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - ReferenceIdInterface, - ReferenceRolesInterface, -} from '@concepta/nestjs-common'; - -import { RoleAssignmentContext } from './role-assignment-context'; - -export interface RolesAssignmentOptionsInterface - extends RoleAssignmentContext, - ReferenceRolesInterface {} diff --git a/packages/nestjs-role/src/optional-crud.ts b/packages/nestjs-role/src/optional-crud.ts new file mode 100644 index 000000000..6db9cce03 --- /dev/null +++ b/packages/nestjs-role/src/optional-crud.ts @@ -0,0 +1,31 @@ +// schemas (Zod / Standard Schema) +export { roleCreateBatchSchema } from './infrastructure/schemas/role-create-batch.schema.js'; +export { roleAssignmentCreateBatchSchema } from './infrastructure/schemas/role-assignment-create-batch.schema.js'; + +// role requests +export { CreateRoleRequest } from './gateways/http/commands/impl/create-role.request.js'; +export { UpdateRoleRequest } from './gateways/http/commands/impl/update-role.request.js'; +export { ReplaceRoleRequest } from './gateways/http/commands/impl/replace-role.request.js'; +export { DeleteRoleRequest } from './gateways/http/commands/impl/delete-role.request.js'; +export { ListRolesRequest } from './gateways/http/queries/impl/list-roles.request.js'; +export { ReadRoleRequest } from './gateways/http/queries/impl/read-role.request.js'; + +// role request handlers +export { CreateRoleRequestHandler } from './gateways/http/commands/handlers/create-role-request.handler.js'; +export { UpdateRoleRequestHandler } from './gateways/http/commands/handlers/update-role-request.handler.js'; +export { ReplaceRoleRequestHandler } from './gateways/http/commands/handlers/replace-role-request.handler.js'; +export { DeleteRoleRequestHandler } from './gateways/http/commands/handlers/delete-role-request.handler.js'; +export { ListRolesRequestHandler } from './gateways/http/queries/handlers/list-roles-request.handler.js'; +export { ReadRoleRequestHandler } from './gateways/http/queries/handlers/read-role-request.handler.js'; + +// role assignment requests +export { CreateRoleAssignmentRequest } from './gateways/http/commands/impl/create-role-assignment.request.js'; +export { DeleteRoleAssignmentRequest } from './gateways/http/commands/impl/delete-role-assignment.request.js'; +export { ListRoleAssignmentsRequest } from './gateways/http/queries/impl/list-role-assignments.request.js'; +export { ReadRoleAssignmentRequest } from './gateways/http/queries/impl/read-role-assignment.request.js'; + +// role assignment request handlers +export { CreateRoleAssignmentRequestHandler } from './gateways/http/commands/handlers/create-role-assignment-request.handler.js'; +export { DeleteRoleAssignmentRequestHandler } from './gateways/http/commands/handlers/delete-role-assignment-request.handler.js'; +export { ListRoleAssignmentsRequestHandler } from './gateways/http/queries/handlers/list-role-assignments-request.handler.js'; +export { ReadRoleAssignmentRequestHandler } from './gateways/http/queries/handlers/read-role-assignment-request.handler.js'; diff --git a/packages/nestjs-role/src/optional-seeding.ts b/packages/nestjs-role/src/optional-seeding.ts new file mode 100644 index 000000000..5c34c9260 --- /dev/null +++ b/packages/nestjs-role/src/optional-seeding.ts @@ -0,0 +1,6 @@ +/** + * These exports allow you to import seeding related classes + * and tools without loading the entire module which + * runs all of its decorators and meta data. + */ +export { RoleFactory } from './infrastructure/persistence/role.factory.js'; diff --git a/packages/nestjs-role/src/optional-typeorm.ts b/packages/nestjs-role/src/optional-typeorm.ts new file mode 100644 index 000000000..5106f34e0 --- /dev/null +++ b/packages/nestjs-role/src/optional-typeorm.ts @@ -0,0 +1,4 @@ +export { RoleSqliteEntity } from './infrastructure/persistence/typeorm/role-sqlite.entity.js'; +export { RolePostgresEntity } from './infrastructure/persistence/typeorm/role-postgres.entity.js'; +export { RoleAssignmentSqliteEntity } from './infrastructure/persistence/typeorm/role-assignment-sqlite.entity.js'; +export { RoleAssignmentPostgresEntity } from './infrastructure/persistence/typeorm/role-assignment-postgres.entity.js'; diff --git a/packages/nestjs-role/src/role-core.module-definition.ts b/packages/nestjs-role/src/role-core.module-definition.ts new file mode 100644 index 000000000..1b93f032a --- /dev/null +++ b/packages/nestjs-role/src/role-core.module-definition.ts @@ -0,0 +1,123 @@ +import { + ConfigurableModuleBuilder, + type DynamicModule, + type Provider, +} from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { AssignRoleHandler } from './application/commands/handlers/assign-role.handler.js'; +import { AssignRolesHandler } from './application/commands/handlers/assign-roles.handler.js'; +import { CreateRoleHandler } from './application/commands/handlers/create-role.handler.js'; +import { RemoveRoleHandler } from './application/commands/handlers/remove-role.handler.js'; +import { ReplaceRoleHandler } from './application/commands/handlers/replace-role.handler.js'; +import { RevokeRoleHandler } from './application/commands/handlers/revoke-role.handler.js'; +import { RevokeRolesHandler } from './application/commands/handlers/revoke-roles.handler.js'; +import { UpdateRoleHandler } from './application/commands/handlers/update-role.handler.js'; +import { GetAssignedRolesHandler } from './application/queries/handlers/get-assigned-roles.handler.js'; +import { GetRoleAssignmentHandler } from './application/queries/handlers/get-role-assignment.handler.js'; +import { GetRoleHandler } from './application/queries/handlers/get-role.handler.js'; +import { IsAssignedRoleHandler } from './application/queries/handlers/is-assigned-role.handler.js'; +import { IsAssignedRolesHandler } from './application/queries/handlers/is-assigned-roles.handler.js'; +import { RoleContextOverlay } from './gateways/role-context.overlay.js'; +import { type RoleExtrasInterface } from './infrastructure/config/interfaces/role-extras.interface.js'; +import { type RoleOptionsInterface } from './infrastructure/config/interfaces/role-options.interface.js'; +import { RoleAssignmentRepositoryResolver } from './infrastructure/persistence/role-assignment-repository.resolver.js'; +import { RoleAssignmentMapper } from './infrastructure/persistence/role-assignment.mapper.js'; +import { RoleRepositoryResolver } from './infrastructure/persistence/role-repository.resolver.js'; +import { RoleMapper } from './infrastructure/persistence/role.mapper.js'; +import { + ROLE_ASSIGNMENT_CUSTOM_REPOSITORY_TOKEN, + ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN, + ROLE_CUSTOM_REPOSITORY_TOKEN, + ROLE_REPOSITORY_RESOLVER_TOKEN, +} from './role.constants.js'; + +const RAW_OPTIONS_TOKEN = Symbol('__ROLE_MODULE_RAW_OPTIONS_TOKEN__'); + +export const { + ConfigurableModuleClass: RoleCoreModuleClass, + OPTIONS_TYPE: ROLE_CORE_OPTIONS_TYPE, + ASYNC_OPTIONS_TYPE: ROLE_CORE_ASYNC_OPTIONS_TYPE, +} = new ConfigurableModuleBuilder({ + moduleName: 'RoleCore', + optionsInjectionToken: RAW_OPTIONS_TOKEN, +}) + .setExtras({ global: true }, definitionTransform) + .build(); + +export type RoleCoreOptions = typeof ROLE_CORE_OPTIONS_TYPE; +export type RoleCoreAsyncOptions = typeof ROLE_CORE_ASYNC_OPTIONS_TYPE; + +function definitionTransform( + definition: DynamicModule, + { global, providers: overrideProviders, repositories }: RoleExtrasInterface, +): DynamicModule { + const { providers = [], imports = [] } = definition; + + return { + ...definition, + global, + imports: createRoleImports({ imports }), + providers: createRoleProviders({ + providers: [...providers, ...(overrideProviders ?? [])], + repositories, + }), + exports: [RAW_OPTIONS_TOKEN, ...createRoleExports()], + }; +} + +export function createRoleImports(options: { + imports: DynamicModule['imports']; +}): DynamicModule['imports'] { + return [...(options.imports || []), CqrsModule.forRoot()]; +} + +export function createRoleProviders(options: { + providers?: Provider[]; + repositories?: RoleExtrasInterface['repositories']; +}): Provider[] { + return [ + RoleMapper, + RoleAssignmentMapper, + { + provide: ROLE_CUSTOM_REPOSITORY_TOKEN, + useValue: options.repositories?.role ?? null, + }, + { + provide: ROLE_ASSIGNMENT_CUSTOM_REPOSITORY_TOKEN, + useValue: options.repositories?.roleAssignment ?? null, + }, + { + provide: ROLE_REPOSITORY_RESOLVER_TOKEN, + useClass: RoleRepositoryResolver, + }, + { + provide: ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN, + useClass: RoleAssignmentRepositoryResolver, + }, + // Command handlers + CreateRoleHandler, + UpdateRoleHandler, + ReplaceRoleHandler, + RemoveRoleHandler, + AssignRoleHandler, + AssignRolesHandler, + RevokeRoleHandler, + RevokeRolesHandler, + // Query handlers + GetRoleHandler, + GetRoleAssignmentHandler, + GetAssignedRolesHandler, + IsAssignedRoleHandler, + IsAssignedRolesHandler, + { provide: APP_INTERCEPTOR, useClass: RoleContextOverlay }, + ...(options.providers ?? []), + ]; +} + +export function createRoleExports(): Required< + Pick +>['exports'] { + return [RoleMapper, RoleAssignmentMapper]; +} diff --git a/packages/nestjs-role/src/role.constants.ts b/packages/nestjs-role/src/role.constants.ts index 5c948d2b5..93ee4c950 100644 --- a/packages/nestjs-role/src/role.constants.ts +++ b/packages/nestjs-role/src/role.constants.ts @@ -1,9 +1,6 @@ -export const ROLE_MODULE_OPTIONS_TOKEN = 'ROLE_MODULE_OPTIONS_TOKEN'; -export const ROLE_MODULE_SETTINGS_TOKEN = 'ROLE_MODULE_SETTINGS_TOKEN'; -export const ROLE_MODULE_REPOSITORIES_TOKEN = 'ROLE_MODULE_REPOSITORIES_TOKEN'; -export const ROLE_MODULE_DEFAULT_SETTINGS_TOKEN = - 'ROLE_MODULE_DEFAULT_SETTINGS_TOKEN'; -export const ROLE_MODULE_ROLE_ENTITY_KEY = 'role'; -export const ROLE_MODULE_ROLE_ASSIGNMENT_KEY = 'roleAssignments'; -export const ROLE_MODULE_USER_ROLE_ENTITY_KEY = 'user-role'; -export const ROLE_MODULE_API_KEY_ROLE_ENTITY_KEY = 'api-key-role'; +export const ROLE_REPOSITORY_RESOLVER_TOKEN = 'ROLE_REPOSITORY_RESOLVER_TOKEN'; +export const ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN = + 'ROLE_ASSIGNMENT_REPOSITORY_RESOLVER_TOKEN'; +export const ROLE_CUSTOM_REPOSITORY_TOKEN = 'ROLE_CUSTOM_REPOSITORY_TOKEN'; +export const ROLE_ASSIGNMENT_CUSTOM_REPOSITORY_TOKEN = + 'ROLE_ASSIGNMENT_CUSTOM_REPOSITORY_TOKEN'; diff --git a/packages/nestjs-role/src/role.factory.spec.ts b/packages/nestjs-role/src/role.factory.spec.ts deleted file mode 100644 index 3a443b5f4..000000000 --- a/packages/nestjs-role/src/role.factory.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { RoleFactory } from './role.factory'; -import { RoleModule } from './role.module'; - -import { AppModuleFixture } from './__fixtures__/app.module.fixture'; -import { RoleEntityFixture } from './__fixtures__/entities/role-entity.fixture'; - -describe('RoleModule', () => { - let roleModule: RoleModule; - let seedingSource: SeedingSource; - let roleFactory: RoleFactory; - - beforeEach(async () => { - const testModule: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - - seedingSource = new SeedingSource({ - dataSource: testModule.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - roleFactory = new RoleFactory({ entity: RoleEntityFixture, seedingSource }); - - roleModule = testModule.get(RoleModule); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('factory', () => { - it('should create a role', async () => { - expect(roleModule).toBeInstanceOf(RoleModule); - - const role = await roleFactory.create(); - - expect(role).toBeInstanceOf(RoleEntityFixture); - expect(typeof role.name).toEqual('string'); - expect(role.name.length > 0).toEqual(true); - expect(typeof role.description).toEqual('string'); - expect(role.description.length > 0).toEqual(true); - }); - }); -}); diff --git a/packages/nestjs-role/src/role.factory.ts b/packages/nestjs-role/src/role.factory.ts deleted file mode 100644 index 814e2332b..000000000 --- a/packages/nestjs-role/src/role.factory.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { faker } from '@faker-js/faker'; - -import { RoleEntityInterface } from '@concepta/nestjs-common'; -import { Factory } from '@concepta/typeorm-seeding'; - -/** - * Role factory - */ -export class RoleFactory extends Factory { - /** - * List of used names. - */ - usedNames: Record = {}; - - /** - * Factory callback function. - */ - protected async entity( - role: RoleEntityInterface, - ): Promise { - // set the name - role.name = this.generateName(); - - // set the description - role.description = faker.lorem.sentence(); - - // return the new role - return role; - } - - /** - * Generate a unique name. - */ - protected generateName(): string { - // the name - let name: string; - - // keep trying to get a unique name - do { - name = faker.lorem.word(); - } while (this.usedNames[name]); - - // add to used names - this.usedNames[name] = true; - - // return it - return name; - } -} diff --git a/packages/nestjs-role/src/role.module-definition.ts b/packages/nestjs-role/src/role.module-definition.ts deleted file mode 100644 index aefe0231a..000000000 --- a/packages/nestjs-role/src/role.module-definition.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { - ConfigurableModuleBuilder, - DynamicModule, - Provider, -} from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; - -import { - RepositoryInterface, - RoleAssignmentInterface, - createSettingsProvider, - getDynamicRepositoryToken, - RoleEntityInterface, -} from '@concepta/nestjs-common'; - -import { roleDefaultConfig } from './config/role-default.config'; -import { RoleMissingEntitiesOptionsException } from './exceptions/role-missing-entities-options.exception'; -import { RoleOptionsExtrasInterface } from './interfaces/role-options-extras.interface'; -import { RoleOptionsInterface } from './interfaces/role-options.interface'; -import { RoleSettingsInterface } from './interfaces/role-settings.interface'; -import { - ROLE_MODULE_REPOSITORIES_TOKEN, - ROLE_MODULE_ROLE_ENTITY_KEY, - ROLE_MODULE_SETTINGS_TOKEN, -} from './role.constants'; -import { RoleModelService } from './services/role-model.service'; -import { RoleService } from './services/role.service'; - -const RAW_OPTIONS_TOKEN = Symbol('__ROLE_MODULE_RAW_OPTIONS_TOKEN__'); - -export const { - ConfigurableModuleClass: RoleModuleClass, - OPTIONS_TYPE: ROLE_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: ROLE_ASYNC_OPTIONS_TYPE, -} = new ConfigurableModuleBuilder({ - moduleName: 'Role', - optionsInjectionToken: RAW_OPTIONS_TOKEN, -}) - .setExtras({ global: false }, definitionTransform) - .build(); - -export type RoleOptions = Omit; -export type RoleAsyncOptions = Omit; - -function definitionTransform( - definition: DynamicModule, - extras: RoleOptionsExtrasInterface, -): DynamicModule { - const { providers = [], imports = [] } = definition; - const { global = false, entities } = extras; - - if (!entities) { - throw new RoleMissingEntitiesOptionsException(); - } - - return { - ...definition, - global, - imports: createRoleImports({ imports, entities }), - providers: createRoleProviders({ entities, providers }), - exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createRoleExports()], - }; -} - -export function createRoleImports( - options: Pick & - Pick, -): DynamicModule['imports'] { - return [ - ...(options.imports ?? []), - ConfigModule.forFeature(roleDefaultConfig), - ]; -} - -export function createRoleProviders( - options: { - overrides?: RoleOptions; - providers?: Provider[]; - } & Pick, -): Provider[] { - return [ - ...(options.providers ?? []), - createRoleSettingsProvider(options.overrides), - createRoleModelServiceProvider(options.overrides), - ...createRoleRepositoriesProviders({ - entities: options.overrides?.entities ?? options.entities, - }), - RoleService, - ]; -} - -export function createRoleExports(): Required< - Pick ->['exports'] { - return [ - ROLE_MODULE_SETTINGS_TOKEN, - ROLE_MODULE_REPOSITORIES_TOKEN, - RoleService, - RoleModelService, - ]; -} - -export function createRoleSettingsProvider( - optionsOverrides?: RoleOptions, -): Provider { - return createSettingsProvider({ - settingsToken: ROLE_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: roleDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createRoleModelServiceProvider( - optionsOverrides?: RoleOptions, -): Provider { - return { - provide: RoleModelService, - inject: [ - RAW_OPTIONS_TOKEN, - getDynamicRepositoryToken(ROLE_MODULE_ROLE_ENTITY_KEY), - ], - useFactory: async ( - options: RoleOptionsInterface, - roleRepo: RepositoryInterface, - ) => - optionsOverrides?.roleModelService ?? - options.roleModelService ?? - new RoleModelService(roleRepo), - }; -} - -export function createRoleRepositoriesProviders( - options: Pick, -): Provider[] { - const { entities } = options; - - const reposToInject = []; - const keyTracker: Record = {}; - - let repoIdx = 0; - - // add role entity - reposToInject[repoIdx] = getDynamicRepositoryToken( - ROLE_MODULE_ROLE_ENTITY_KEY, - ); - keyTracker[ROLE_MODULE_ROLE_ENTITY_KEY] = repoIdx++; - - // now get all role assignments - if (entities) { - for (const entityKey of entities) { - reposToInject[repoIdx] = getDynamicRepositoryToken(entityKey); - keyTracker[entityKey] = repoIdx++; - } - } - - return [ - { - provide: ROLE_MODULE_REPOSITORIES_TOKEN, - useFactory: ( - ...args: RepositoryInterface< - RoleEntityInterface | RoleAssignmentInterface - >[] - ) => { - const repoInstances: Record< - string, - RepositoryInterface - > = {}; - - // Add the role repository - repoInstances[ROLE_MODULE_ROLE_ENTITY_KEY] = - args[keyTracker[ROLE_MODULE_ROLE_ENTITY_KEY]]; - - // Add all assignment repositories - if (entities) { - for (const entityKey of entities) { - repoInstances[entityKey] = args[keyTracker[entityKey]]; - } - } - - return repoInstances; - }, - inject: reposToInject, - }, - ]; -} diff --git a/packages/nestjs-role/src/role.module.spec.ts b/packages/nestjs-role/src/role.module.spec.ts deleted file mode 100644 index f9aae5f66..000000000 --- a/packages/nestjs-role/src/role.module.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { - RepositoryInterface, - getDynamicRepositoryToken, -} from '@concepta/nestjs-common'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { ROLE_MODULE_ROLE_ENTITY_KEY } from './role.constants'; -import { RoleModule } from './role.module'; -import { RoleModelService } from './services/role-model.service'; -import { RoleService } from './services/role.service'; - -import { AppModuleFixture } from './__fixtures__/app.module.fixture'; -import { RoleEntityFixture } from './__fixtures__/entities/role-entity.fixture'; - -describe('RoleModule', () => { - let roleModule: RoleModule; - let roleService: RoleService; - let roleModelService: RoleModelService; - let roleDynamicRepo: RepositoryInterface; - - beforeEach(async () => { - const testModule: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - - roleModule = testModule.get(RoleModule); - roleDynamicRepo = testModule.get( - getDynamicRepositoryToken(ROLE_MODULE_ROLE_ENTITY_KEY), - ); - roleService = testModule.get(RoleService); - roleModelService = testModule.get(RoleModelService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('module', () => { - it('should be loaded', async () => { - expect(roleModule).toBeInstanceOf(RoleModule); - expect(roleDynamicRepo).toBeInstanceOf(TypeOrmRepositoryAdapter); - expect(roleService).toBeInstanceOf(RoleService); - expect(roleModelService).toBeInstanceOf(RoleModelService); - expect(roleModelService['repo']).toBeInstanceOf(TypeOrmRepositoryAdapter); - expect(roleModelService['repo'].find).toBeInstanceOf(Function); - }); - }); -}); diff --git a/packages/nestjs-role/src/role.module.ts b/packages/nestjs-role/src/role.module.ts index c7230f9e1..d90cf8781 100644 --- a/packages/nestjs-role/src/role.module.ts +++ b/packages/nestjs-role/src/role.module.ts @@ -1,29 +1,68 @@ import { DynamicModule, Module } from '@nestjs/common'; +import { createRoleAssignmentRepositoryProvider } from './infrastructure/utils/create-role-assignment-repository-provider.js'; +import { createRoleRepositoryProvider } from './infrastructure/utils/create-role-repository-provider.js'; import { - RoleAsyncOptions, - RoleModuleClass, - RoleOptions, -} from './role.module-definition'; + RoleCoreAsyncOptions, + RoleCoreModuleClass, + RoleCoreOptions, +} from './role-core.module-definition.js'; + +type RoleOptions = Omit; +type RoleAsyncOptions = Omit; /** * Role Module */ @Module({}) -export class RoleModule extends RoleModuleClass { +export class RoleModule { static register(options: RoleOptions): DynamicModule { - return super.register(options); + return { + module: RoleModule, + imports: [RoleCoreModuleClass.register({ ...options, global: false })], + }; } static registerAsync(options: RoleAsyncOptions): DynamicModule { - return super.registerAsync(options); + return { + module: RoleModule, + imports: [ + RoleCoreModuleClass.registerAsync({ ...options, global: false }), + ], + }; } static forRoot(options: RoleOptions): DynamicModule { - return super.register({ ...options, global: true }); + return { + module: RoleModule, + imports: [RoleCoreModuleClass.register({ ...options, global: true })], + }; } static forRootAsync(options: RoleAsyncOptions): DynamicModule { - return super.registerAsync({ ...options, global: true }); + return { + module: RoleModule, + imports: [ + RoleCoreModuleClass.registerAsync({ ...options, global: true }), + ], + }; + } + + static forFeature(config: { + roleEntityKey: string; + assignmentEntityKeys: string[]; + }): DynamicModule { + const providers = [ + createRoleRepositoryProvider(config.roleEntityKey), + ...config.assignmentEntityKeys.map((entityKey) => + createRoleAssignmentRepositoryProvider(entityKey), + ), + ]; + + return { + module: RoleModule, + providers, + exports: providers, + }; } } diff --git a/packages/nestjs-role/src/role.seeder.ts b/packages/nestjs-role/src/role.seeder.ts deleted file mode 100644 index 5509461c2..000000000 --- a/packages/nestjs-role/src/role.seeder.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Seeder } from '@concepta/typeorm-seeding'; - -import { RoleFactory } from './role.factory'; - -/** - * Role seeder - */ -export class RoleSeeder extends Seeder { - /** - * Runner - */ - public async run(): Promise { - // number of roles to create - const createAmount = process.env?.ROLE_MODULE_SEEDER_AMOUNT - ? Number(process.env.ROLE_MODULE_SEEDER_AMOUNT) - : 50; - - // the factory - const roleFactory = this.factory(RoleFactory); - - // create a bunch - await roleFactory.createMany(createAmount); - } -} diff --git a/packages/nestjs-role/src/role.types.spec.ts b/packages/nestjs-role/src/role.types.spec.ts deleted file mode 100644 index dd38581b1..000000000 --- a/packages/nestjs-role/src/role.types.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { RoleAssignmentResource, RoleResource } from './role.types'; - -describe('Role Types', () => { - describe('RoleResource enum', () => { - it('should match', async () => { - expect(RoleResource.One).toEqual('role'); - expect(RoleResource.Many).toEqual('role-list'); - }); - }); - describe('RoleAssignmentResource enum', () => { - it('should match', async () => { - expect(RoleAssignmentResource.One).toEqual('role-assignment'); - expect(RoleAssignmentResource.Many).toEqual('role-assignment-list'); - }); - }); -}); diff --git a/packages/nestjs-role/src/role.types.ts b/packages/nestjs-role/src/role.types.ts deleted file mode 100644 index 3198eb17a..000000000 --- a/packages/nestjs-role/src/role.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export enum RoleResource { - 'One' = 'role', - 'Many' = 'role-list', -} - -export enum RoleAssignmentResource { - 'One' = 'role-assignment', - 'Many' = 'role-assignment-list', -} diff --git a/packages/nestjs-role/src/seeding.ts b/packages/nestjs-role/src/seeding.ts deleted file mode 100644 index 899ecaa6d..000000000 --- a/packages/nestjs-role/src/seeding.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * These exports all you to import seeding related classes - * and tools without loading the entire module which - * runs all of it's decorators and meta data. - */ -export { RoleFactory } from './role.factory'; -export { RoleSeeder } from './role.seeder'; diff --git a/packages/nestjs-role/src/services/role-model.service.ts b/packages/nestjs-role/src/services/role-model.service.ts deleted file mode 100644 index dbbf2bc68..000000000 --- a/packages/nestjs-role/src/services/role-model.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ModelService, - RepositoryInterface, - RoleCreatableInterface, - RoleUpdatableInterface, - InjectDynamicRepository, - RoleEntityInterface, -} from '@concepta/nestjs-common'; - -import { RoleCreateDto } from '../dto/role-create.dto'; -import { RoleUpdateDto } from '../dto/role-update.dto'; -import { RoleModelServiceInterface } from '../interfaces/role-model-service.interface'; -import { ROLE_MODULE_ROLE_ENTITY_KEY } from '../role.constants'; - -/** - * Role model service - */ -@Injectable() -export class RoleModelService - extends ModelService< - RoleEntityInterface, - RoleCreatableInterface, - RoleUpdatableInterface - > - implements RoleModelServiceInterface -{ - protected createDto = RoleCreateDto; - protected updateDto = RoleUpdateDto; - - /** - * Constructor - * - * @param repo - instance of the role repo - */ - constructor( - @InjectDynamicRepository(ROLE_MODULE_ROLE_ENTITY_KEY) - repo: RepositoryInterface, - ) { - super(repo); - } -} diff --git a/packages/nestjs-role/src/services/role.service.spec.ts b/packages/nestjs-role/src/services/role.service.spec.ts deleted file mode 100644 index 9fdb8f6be..000000000 --- a/packages/nestjs-role/src/services/role.service.spec.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { - ReferenceIdInterface, - RepositoryInterface, - getDynamicRepositoryToken, -} from '@concepta/nestjs-common'; -import { CrudModule } from '@concepta/nestjs-crud'; -import { - TypeOrmExtModule, - TypeOrmRepositoryAdapter, -} from '@concepta/nestjs-typeorm-ext'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { RoleAssignmentConflictException } from '../exceptions/role-assignment-conflict.exception'; -import { RoleFactory } from '../role.factory'; -import { RoleModule } from '../role.module'; -import { RoleService } from '../services/role.service'; - -import { ApiKeyEntityFixture } from '../__fixtures__/entities/api-key-entity.fixture'; -import { ApiKeyRoleEntityFixture } from '../__fixtures__/entities/api-key-role-entity.fixture'; -import { RoleEntityFixture } from '../__fixtures__/entities/role-entity.fixture'; -import { UserEntityFixture } from '../__fixtures__/entities/user-entity.fixture'; -import { UserRoleEntityFixture } from '../__fixtures__/entities/user-role-entity.fixture'; -import { UserRoleFactoryFixture } from '../__fixtures__/factories/user-role.factory.fixture'; -import { UserFactoryFixture } from '../__fixtures__/factories/user.factory.fixture'; - -describe('RoleModule', () => { - let testModule: TestingModule; - let seedingSource: SeedingSource; - let roleModule: RoleModule; - let roleService: RoleService; - let roleRepo: RepositoryInterface; - - let testRole1: ReferenceIdInterface; - let testRole2: ReferenceIdInterface; - let testRole3: ReferenceIdInterface; - let testUser: UserEntityFixture; - - let connectionNumber = 1; - - beforeEach(async () => { - const connectionName = `test_${connectionNumber++}`; - - testModule = await Test.createTestingModule({ - imports: [ - TypeOrmExtModule.forRoot({ - name: connectionName, - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [ - RoleEntityFixture, - UserEntityFixture, - UserRoleEntityFixture, - ApiKeyEntityFixture, - ApiKeyRoleEntityFixture, - ], - }), - RoleModule.registerAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - role: { - entity: RoleEntityFixture, - dataSource: connectionName, - }, - userRole: { - entity: UserRoleEntityFixture, - dataSource: connectionName, - }, - }), - ], - entities: ['userRole'], - useFactory: () => ({ - settings: { - assignments: { - user: { entityKey: 'userRole' }, - }, - }, - }), - }), - CrudModule.forRoot({}), - ], - }).compile(); - - seedingSource = new SeedingSource({ - dataSource: testModule.get(getDataSourceToken(connectionName)), - }); - - await seedingSource.initialize(); - - const roleFactory = new RoleFactory({ - entity: RoleEntityFixture, - seedingSource, - }); - - [testRole1, testRole2, testRole3] = await roleFactory.createMany(3); - - const userFactory = new UserFactoryFixture({ seedingSource }); - testUser = await userFactory.create(); - - const userRoleFactory = new UserRoleFactoryFixture({ seedingSource }); - - await userRoleFactory.create({ - roleId: testRole1.id, - assigneeId: testUser.id, - }); - - roleModule = testModule.get(RoleModule); - roleService = testModule.get(RoleService); - roleRepo = testModule.get(getDynamicRepositoryToken('role')); - }); - - afterEach(() => { - jest.clearAllMocks(); - testModule.close(); - }); - - describe('module', () => { - it('should be loaded', async () => { - expect(roleModule).toBeInstanceOf(RoleModule); - }); - it('should be have expected services', async () => { - expect(roleService).toBeInstanceOf(RoleService); - }); - it('should be have expected repos', async () => { - expect(roleRepo).toBeInstanceOf(TypeOrmRepositoryAdapter); - }); - }); - - describe('getAssignedRoles', () => { - it('should return assigned roles', async () => { - const assignedRoles: Partial[] = - await roleService.getAssignedRoles({ - assignment: 'user', - assignee: testUser, - }); - - expect(assignedRoles).toBeInstanceOf(Array); - expect(assignedRoles.length).toEqual(1); - }); - }); - - describe('isAssignedRole', () => { - it('should be assigned to one', async () => { - const result = await roleService.isAssignedRole({ - assignment: 'user', - role: testRole1, - assignee: testUser, - }); - expect(result).toEqual(true); - }); - - it('should not be assigned to one', async () => { - expect( - await roleService.isAssignedRole({ - assignment: 'user', - role: testRole2, - assignee: testUser, - }), - ).toEqual(false); - }); - }); - - describe('isAssignedRoles', () => { - it('should be assigned to all', async () => { - expect( - await roleService.isAssignedRoles({ - assignment: 'user', - roles: [testRole1], - assignee: testUser, - }), - ).toEqual(true); - }); - - it('should not be assigned to all', async () => { - expect( - await roleService.isAssignedRoles({ - assignment: 'user', - roles: [testRole1, testRole2], - assignee: testUser, - }), - ).toEqual(false); - }); - - it('impossible to be assigned to none', async () => { - expect( - await roleService.isAssignedRoles({ - assignment: 'user', - roles: [], - assignee: testUser, - }), - ).toEqual(false); - }); - }); - - describe('assignRole', () => { - it('should assign a role to an assignee', async () => { - const assignedRole = await roleService.assignRole({ - assignment: 'user', - role: testRole2, - assignee: testUser, - }); - - expect(assignedRole).toBeDefined(); - expect(assignedRole.roleId).toEqual(testRole2.id); - expect(assignedRole.assigneeId).toEqual(testUser.id); - }); - - it('should throw conflict error if the role is already assigned', async () => { - await expect( - roleService.assignRole({ - assignment: 'user', - role: testRole1, - assignee: testUser, - }), - ).rejects.toThrow(RoleAssignmentConflictException); - }); - }); - - describe('assignRoles', () => { - it('should assign multiple roles to an assignee', async () => { - const rolesToAssign = [testRole2, testRole3]; - - const assignedRoles = await roleService.assignRoles({ - assignment: 'user', - roles: rolesToAssign, - assignee: testUser, - }); - - expect(assignedRoles).toHaveLength(2); - expect(assignedRoles[0].roleId).toEqual(testRole2.id); - expect(assignedRoles[0].assigneeId).toEqual(testUser.id); - expect(assignedRoles[1].roleId).toEqual(testRole3.id); - expect(assignedRoles[1].assigneeId).toEqual(testUser.id); - }); - - it('should throw conflict error if any role is already assigned', async () => { - const rolesToAssign = [testRole1, testRole2]; - - await expect( - roleService.assignRoles({ - assignment: 'user', - roles: rolesToAssign, - assignee: testUser, - }), - ).rejects.toThrow(RoleAssignmentConflictException); - }); - }); - - describe('revokeRole', () => { - it('should revoke a role from an assignee', async () => { - await roleService.revokeRole({ - assignment: 'user', - role: testRole1, - assignee: testUser, - }); - - const isAssigned = await roleService.isAssignedRole({ - assignment: 'user', - role: testRole1, - assignee: testUser, - }); - - expect(isAssigned).toBe(false); - }); - - it('should not throw an error if the role assignment does not exist', async () => { - await expect( - roleService.revokeRole({ - assignment: 'user', - role: testRole2, - assignee: testUser, - }), - ).resolves.toBeUndefined(); - }); - }); - - describe('revokeRoles', () => { - it('should revoke multiple roles from an assignee', async () => { - await roleService.assignRoles({ - assignment: 'user', - roles: [testRole2, testRole3], - assignee: testUser, - }); - - await roleService.revokeRoles({ - assignment: 'user', - roles: [testRole2, testRole3], - assignee: testUser, - }); - - const isAssigned = await roleService.isAssignedRoles({ - assignment: 'user', - roles: [testRole1], - assignee: testUser, - }); - expect(isAssigned).toBe(true); - - const isRole2Assigned = await roleService.isAssignedRole({ - assignment: 'user', - role: testRole2, - assignee: testUser, - }); - - expect(isRole2Assigned).toBe(false); - - const isRole3Assigned = await roleService.isAssignedRole({ - assignment: 'user', - role: testRole2, - assignee: testUser, - }); - - expect(isRole3Assigned).toBe(false); - }); - - it('should revoke all roles from an assignee', async () => { - await roleService.assignRoles({ - assignment: 'user', - roles: [testRole2, testRole3], - assignee: testUser, - }); - - await roleService.revokeRoles({ - assignment: 'user', - roles: [testRole1, testRole2, testRole3], - assignee: testUser, - }); - - const assignedRoles = await roleService.getAssignedRoles({ - assignment: 'user', - assignee: testUser, - }); - - expect(assignedRoles).toHaveLength(0); - }); - - it('should not throw an error if none of the role assignments exist', async () => { - await expect( - roleService.revokeRoles({ - assignment: 'user', - roles: [testRole2], - assignee: testUser, - }), - ).resolves.toBeUndefined(); - }); - }); -}); diff --git a/packages/nestjs-role/src/services/role.service.ts b/packages/nestjs-role/src/services/role.service.ts deleted file mode 100644 index 2a97df85a..000000000 --- a/packages/nestjs-role/src/services/role.service.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { - ReferenceAssignment, - ReferenceIdInterface, - RepositoryInterface, - ModelQueryException, - RoleAssignmentEntityInterface, -} from '@concepta/nestjs-common'; - -import { RoleAssignmentConflictException } from '../exceptions/role-assignment-conflict.exception'; -import { RoleAssignmentNotFoundException } from '../exceptions/role-assignment-not-found.exception'; -import { RoleEntityNotFoundException } from '../exceptions/role-entity-not-found.exception'; -import { RoleAssignmentContext } from '../interfaces/role-assignment-context'; -import { RoleAssignmentOptionsInterface } from '../interfaces/role-assignment-options.interface'; -import { RoleServiceInterface } from '../interfaces/role-service.interface'; -import { RoleSettingsInterface } from '../interfaces/role-settings.interface'; -import { RolesAssignmentOptionsInterface } from '../interfaces/roles-assignment-options.interface'; -import { - ROLE_MODULE_REPOSITORIES_TOKEN, - ROLE_MODULE_SETTINGS_TOKEN, -} from '../role.constants'; - -@Injectable() -export class RoleService implements RoleServiceInterface { - constructor( - @Inject(ROLE_MODULE_SETTINGS_TOKEN) - protected readonly settings: RoleSettingsInterface, - @Inject(ROLE_MODULE_REPOSITORIES_TOKEN) - private allRoleRepos: Record< - string, - RepositoryInterface - >, - ) {} - - /** - * Get all roles for assignee. - * - * @param options - The assignment, assignee to check - */ - async getAssignedRoles( - options: RoleAssignmentContext, - ): Promise { - const { assignment, assignee } = options; - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - // try to find the relationships - try { - // make the query - const assignments = await assignmentRepo.find({ - where: { - assigneeId: assignee.id, - }, - }); - - // return the roles - return assignments.map((assignment) => ({ id: assignment.roleId })); - } catch (e) { - throw new ModelQueryException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - /** - * Check if the assignee is a member of one role. - * - * @param options - The assignment, role and assignee to check - */ - async isAssignedRole( - options: RoleAssignmentOptionsInterface, - ): Promise { - const { assignment, role, assignee } = options; - - // get the assignment repo - const assignmentRepo = this.getAssignmentRepo(assignment); - - // try to find the relationship - try { - // make the query - const assignment = await assignmentRepo.findOne({ - where: { - roleId: role.id, - assigneeId: assignee.id, - }, - }); - - // return true if we found an assignment - return assignment ? true : false; - } catch (e) { - throw new ModelQueryException(assignmentRepo.entityName(), { - originalError: e, - }); - } - } - - /** - * Check if the assignee is a member of every role. - * - * @param options - The assignment, roles and assignee to check - */ - async isAssignedRoles( - options: RolesAssignmentOptionsInterface, - ): Promise { - const { assignment, roles, assignee } = options; - - // get all assigned roles - const assignedRoles = await this.getAssignedRoles({ - assignment, - assignee, - }); - - // get any roles to check? - if (roles.length) { - // create an array of all ids - const assignedRoleIds = assignedRoles.map( - (assignedRole) => assignedRole.id, - ); - // is in every role? - return roles.every((role) => { - return assignedRoleIds.includes(role.id); - }); - } else { - // no roles to check! - return false; - } - } - - /** - * Assign a role to an assignee. - * - * @param options - The assignment, role and assignee - */ - async assignRole( - options: RoleAssignmentOptionsInterface, - ): Promise { - const { assignment, role, assignee } = options; - const assignmentRepo = this.getAssignmentRepo(assignment); - - // check if the role is already assigned - const isAlreadyAssigned = await this.isAssignedRole({ - assignment, - role, - assignee, - }); - - if (isAlreadyAssigned) { - throw new RoleAssignmentConflictException( - assignment, - role.id, - assignee.id, - ); - } - // TODO: Review this change to validate the transacion being used - // create the new role assignment entity - const roleAssignment = assignmentRepo.create({ - roleId: role.id, - assigneeId: assignee.id, - }); - - return assignmentRepo.save(roleAssignment); - } - - /** - * Assign multiple roles to an assignee. - * - * @param options - The assignment, roles and assignee - */ - async assignRoles( - options: RolesAssignmentOptionsInterface, - ): Promise { - const { assignment, roles, assignee } = options; - const assignmentRepo = this.getAssignmentRepo(assignment); - - // prepare the bulk data for assignment - const roleAssignments: RoleAssignmentEntityInterface[] = []; - - for (const role of roles) { - // check if the role is already assigned - const isAlreadyAssigned = await this.isAssignedRole({ - assignment, - role, - assignee, - }); - - if (isAlreadyAssigned) { - // skip this role if it is already assigned - throw new RoleAssignmentConflictException( - assignment, - role.id, - assignee.id, - ); - } - - // create and add the new role assignment entity to the bulk array - const roleAssignment: RoleAssignmentEntityInterface = - assignmentRepo.create({ - roleId: role.id, - assigneeId: assignee.id, - }); - - roleAssignments.push(roleAssignment); - } - - return assignmentRepo.save(roleAssignments); - } - - /** - * Revoke a role from an assignee. - * - * @param options - The assignment, role and assignee - */ - async revokeRole( - options: RoleAssignmentOptionsInterface, - ): Promise { - const { assignment, role, assignee } = options; - const assignmentRepo = this.getAssignmentRepo(assignment); - - if (role?.id && assignee?.id) { - const roleAssignment = await assignmentRepo.find({ - where: { - roleId: role.id, - assigneeId: assignee.id, - }, - }); - - if (roleAssignment) { - await assignmentRepo.remove(roleAssignment); - } - } - } - - /** - * Revoke multiple roles from an assignee. - * - * @param options - The assignment, roles and assignee - */ - async revokeRoles( - options: RolesAssignmentOptionsInterface, - ): Promise { - const { assignment, roles, assignee } = options; - const assignmentRepo = this.getAssignmentRepo(assignment); - - for (const role of roles) { - if (role?.id && assignee?.id) { - const roleAssignment = await assignmentRepo.find({ - where: { - roleId: role.id, - assigneeId: assignee.id, - }, - }); - - if (roleAssignment) { - await assignmentRepo.remove(roleAssignment); - } - } - } - } - - /** - * Get the assignment repo for the given assignment. - * - * @internal - * @param assignment - The role assignment - */ - protected getAssignmentRepo( - assignment: ReferenceAssignment, - ): RepositoryInterface { - // have entity key for given assignment? - if (this.settings.assignments[assignment]) { - // yes, set it - const entityKey = this.settings.assignments[assignment].entityKey; - // repo matching assignment was injected? - if (this.allRoleRepos[entityKey]) { - // yes, return it - return this.allRoleRepos[entityKey]; - } else { - // bad entity key - throw new RoleEntityNotFoundException(entityKey); - } - } else { - // bad assignment - throw new RoleAssignmentNotFoundException(assignment); - } - } -} diff --git a/packages/nestjs-role/tsconfig.json b/packages/nestjs-role/tsconfig.json index ef9980950..edc11225e 100644 --- a/packages/nestjs-role/tsconfig.json +++ b/packages/nestjs-role/tsconfig.json @@ -4,10 +4,13 @@ "composite": true, "rootDir": "./src", "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", "typeRoots": [ "./node_modules/@types", - "../../node_modules/@types" - ] + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] }, "include": [ "src/**/*.ts" diff --git a/packages/nestjs-samples/package.json b/packages/nestjs-samples/package.json index e34d23388..ba24e6648 100644 --- a/packages/nestjs-samples/package.json +++ b/packages/nestjs-samples/package.json @@ -28,7 +28,7 @@ "@nestjs/common": "^11.1.9", "@nestjs/core": "^11.1.9", "@nestjs/platform-express": "^11.1.9", - "@nestjs/swagger": "^11.2.2", + "@nestjs/swagger": "11.2.2", "rxjs": "^7.8.1" }, "devDependencies": { diff --git a/packages/nestjs-samples/src/01-event/app.e2e-spec.ts b/packages/nestjs-samples/src/01-event/app.e2e-spec.ts index 8a428443a..6303a515b 100644 --- a/packages/nestjs-samples/src/01-event/app.e2e-spec.ts +++ b/packages/nestjs-samples/src/01-event/app.e2e-spec.ts @@ -1,10 +1,10 @@ import supertest from 'supertest'; -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { AppModule } from './app.module'; -import { CreateOrderDto } from './order/dto/create-order.dto'; +import { type CreateOrderDto } from './order/dto/create-order.dto'; import { OrderCreatedListener, OrderCreatedListenerAsync, diff --git a/packages/nestjs-samples/src/01-event/app.module.spec.ts b/packages/nestjs-samples/src/01-event/app.module.spec.ts index 71cdb8189..49f9300bb 100644 --- a/packages/nestjs-samples/src/01-event/app.module.spec.ts +++ b/packages/nestjs-samples/src/01-event/app.module.spec.ts @@ -1,4 +1,4 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { AppModule } from './app.module'; import { diff --git a/packages/nestjs-samples/src/01-event/order/events/order-created.event.ts b/packages/nestjs-samples/src/01-event/order/events/order-created.event.ts index ff5ca34b9..b33197fe4 100644 --- a/packages/nestjs-samples/src/01-event/order/events/order-created.event.ts +++ b/packages/nestjs-samples/src/01-event/order/events/order-created.event.ts @@ -1,8 +1,8 @@ import { EventAsync, - EventAsyncInterface, + type EventAsyncInterface, Event, - EventInterface, + type EventInterface, } from '@concepta/nestjs-event'; export type OrderCreatedEventInterface = { diff --git a/packages/nestjs-samples/src/01-event/order/listeners/order-created.listener.ts b/packages/nestjs-samples/src/01-event/order/listeners/order-created.listener.ts index 3e25a9276..f9fd18e14 100644 --- a/packages/nestjs-samples/src/01-event/order/listeners/order-created.listener.ts +++ b/packages/nestjs-samples/src/01-event/order/listeners/order-created.listener.ts @@ -1,9 +1,9 @@ import { EventListenerOn } from '@concepta/nestjs-event'; import { - OrderCreatedEvent, - OrderCreatedEventAsync, - OrderCreatedEventInterface, + type OrderCreatedEvent, + type OrderCreatedEventAsync, + type OrderCreatedEventInterface, } from '../events/order-created.event'; // example listener class diff --git a/packages/nestjs-samples/src/02-logger/app.e2e-spec.ts b/packages/nestjs-samples/src/02-logger/app.e2e-spec.ts index 6de3ec040..982e27ec2 100644 --- a/packages/nestjs-samples/src/02-logger/app.e2e-spec.ts +++ b/packages/nestjs-samples/src/02-logger/app.e2e-spec.ts @@ -1,14 +1,14 @@ import supertest from 'supertest'; -import { INestApplication } from '@nestjs/common'; +import { type INestApplication } from '@nestjs/common'; import { HttpAdapterHost } from '@nestjs/core'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { ExceptionsFilter } from '@concepta/nestjs-common'; import { LoggerService } from '@concepta/nestjs-logger'; import { AppModule } from './app.module'; -import { CreateOrderDto } from './order/dto/create-order.dto'; +import { type CreateOrderDto } from './order/dto/create-order.dto'; // import { // FastifyAdapter, diff --git a/packages/nestjs-samples/src/02-logger/app.module.spec.ts b/packages/nestjs-samples/src/02-logger/app.module.spec.ts index 9fa31741e..5614d567a 100644 --- a/packages/nestjs-samples/src/02-logger/app.module.spec.ts +++ b/packages/nestjs-samples/src/02-logger/app.module.spec.ts @@ -1,4 +1,4 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { LoggerService } from '@concepta/nestjs-logger'; diff --git a/packages/nestjs-samples/src/03-authentication/app.e2e-spec.ts b/packages/nestjs-samples/src/03-authentication/app.e2e-spec.ts deleted file mode 100644 index e64cfc168..000000000 --- a/packages/nestjs-samples/src/03-authentication/app.e2e-spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { mock } from 'jest-mock-extended'; -import supertest from 'supertest'; - -import { INestApplication, Logger } from '@nestjs/common'; -import { HttpAdapterHost } from '@nestjs/core'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { - AuthenticationResponseInterface, - ExceptionsFilter, - RepositoryInterface, -} from '@concepta/nestjs-common'; - -import { AppModule } from './app.module'; -import { AuthLocalControllerFixture } from './auth-local.controller.fixture'; -import { AuthRefreshControllerFixture } from './auth-refresh.controller.fixture'; -import { UserDto } from './user/user.controller'; -import { UserEntity } from './user/user.entity'; - -const sleep = (ms: number) => { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -}; - -describe('AppController (e2e)', () => { - describe('Authentication', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - controllers: [AuthLocalControllerFixture], - }) - .overrideProvider('USER_MODULE_USER_ENTITY_REPO_TOKEN') - .useValue(mock()) - .overrideProvider('USER_MODULE_USER_CUSTOM_REPO_TOKEN') - .useValue(mock>()) - .compile(); - - app = moduleFixture.createNestApplication(); - const exceptionsFilter = app.get(HttpAdapterHost); - app.useGlobalFilters(new ExceptionsFilter(exceptionsFilter)); - - await app.init(); - }); - - afterEach(async () => { - jest.clearAllMocks(); - await app.close(); - }); - - it('POST /auth/login', async () => { - const sign = { - username: 'first_user', - password: 'AS12378', - }; - - const response: { body: AuthenticationResponseInterface } = - await supertest(app.getHttpServer()) - .post('/auth/login') - .send(sign) - .expect(201); - - expect(response.body.accessToken).toBeDefined(); - expect(response.body.refreshToken).toBeDefined(); - }); - - it('POST /auth/login no-auth', async () => { - const sign = { - username: 'first_user_2', - password: 'AS12378', - }; - - await supertest(app.getHttpServer()) - .post('/auth/login') - .send(sign) - .expect(401); - - return; - }); - - it('GET /user', async () => { - const sign = { - username: 'first_user', - password: 'AS12378', - }; - - const response: { body: AuthenticationResponseInterface } = - await supertest(app.getHttpServer()) - .post('/auth/login') - .send(sign) - .expect(201); - - const getUsers: { body: UserDto[] } = await supertest(app.getHttpServer()) - .get('/custom/user/all') - .set('Authorization', `bearer ${response.body.accessToken}`) - .expect(200); - - expect(getUsers.body).toBeDefined(); - expect(getUsers.body[0].username).toBe('user1'); - }); - - it('GET /user Not Authorized', async () => { - await supertest(app.getHttpServer()).get('/custom/user/all').expect(401); - }); - }); - - describe('Authentication Refresh', () => { - let app: INestApplication; - let globalAccessToken: string; - let globalRefreshToken: string; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - controllers: [AuthRefreshControllerFixture, AuthLocalControllerFixture], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - - const sign = { - username: 'first_user', - password: 'AS12378', - }; - - const response: { body: AuthenticationResponseInterface } = - await supertest(app.getHttpServer()) - .post('/auth/login') - .send(sign) - .expect(201); - - jest.spyOn(Logger.prototype, 'log').mockImplementation(() => null); - jest.spyOn(Logger.prototype, 'error').mockImplementation(() => null); - - globalAccessToken = response.body.accessToken; - globalRefreshToken = response.body.refreshToken; - }); - - afterEach(async () => { - jest.clearAllMocks(); - await app.close(); - }); - - it('GET /refresh correct token', async () => { - await sleep(1000); - - // call to refresh token - const responseTokenRefresh: { body: AuthenticationResponseInterface } = - await supertest(app.getHttpServer()) - .post('/token/refresh') - .send({ - refreshToken: globalRefreshToken, - }) - .expect(201); - - expect(responseTokenRefresh.body.accessToken).toBeDefined(); - - // tokens needs to be different - expect( - responseTokenRefresh.body.accessToken != globalAccessToken, - ).toBeTruthy(); - }); - - it('GET /refresh with invalid token', async () => { - await sleep(1000); - - await supertest(app.getHttpServer()) - .post('/token/refresh') - .send({ - refreshToken: 'invalid-token', - }) - .expect(500); - }); - - it('GET /refresh with token as null', async () => { - await sleep(1000); - - await supertest(app.getHttpServer()) - .post('/token/refresh') - .send({ - refreshToken: null, - }) - .expect(401); - }); - - it('GET /refresh without token', async () => { - await sleep(1000); - - // call to refresh token - await supertest(app.getHttpServer()) - .post('/token/refresh') - .send() - .expect(401); - }); - - it('GET /refresh token Expired', async () => { - const expiredToken = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNjQ0NDQ0NDEwLCJleHAiOjE2NDQ0NDQ0MTF9.ST7bECqz6CDFrgJTPyXGBjMv3wUOJj7swHM12xAfSWU'; - await sleep(1000); - - // call to refresh token - await supertest(app.getHttpServer()) - .post('/token/refresh') - .send({ - refreshToken: expiredToken, - }) - .expect(500); - }); - - it('GET /refresh token with wrong secret', async () => { - const tokenForWrongSecret = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNjQ0NDQ0NTM4LCJleHAiOjE2NDQ0NDgxMzh9.wNJJGie8pn3nvtBJ7Jk5EThb0hwcArMVLExjEct6alo'; - - await sleep(1000); - - // call to refresh token - await supertest(app.getHttpServer()) - .post('/token/refresh') - .send({ - refreshToken: tokenForWrongSecret, - }) - .expect(500); - }); - }); -}); diff --git a/packages/nestjs-samples/src/03-authentication/app.module.spec.ts b/packages/nestjs-samples/src/03-authentication/app.module.spec.ts deleted file mode 100644 index e76441f30..000000000 --- a/packages/nestjs-samples/src/03-authentication/app.module.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { - AuthLocalUserModelService, - AuthLocalUserModelServiceInterface, -} from '@concepta/nestjs-auth-local'; -import { IssueTokenService } from '@concepta/nestjs-authentication'; - -import { AppModule } from './app.module'; - -describe('AppModule', () => { - it('should be imported', async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - const issueTokenService = module.get(IssueTokenService); - const userModelService = module.get( - AuthLocalUserModelService, - ); - - expect(module).toBeInstanceOf(TestingModule); - expect(issueTokenService).toBeInstanceOf(IssueTokenService); - expect(userModelService).toBeInstanceOf(Object); - - await module.close(); - }); -}); diff --git a/packages/nestjs-samples/src/03-authentication/app.module.ts b/packages/nestjs-samples/src/03-authentication/app.module.ts deleted file mode 100644 index 11d55bf32..000000000 --- a/packages/nestjs-samples/src/03-authentication/app.module.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { AuthLocalModule } from '@concepta/nestjs-auth-local'; -import { AuthRefreshModule } from '@concepta/nestjs-auth-refresh'; -import { AuthenticationModule } from '@concepta/nestjs-authentication'; -import { CrudModule } from '@concepta/nestjs-crud'; -import { JwtModule } from '@concepta/nestjs-jwt'; -import { PasswordModule } from '@concepta/nestjs-password'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { - UserModule, - UserModelServiceInterface, - UserModelService, -} from '@concepta/nestjs-user'; - -import { createUserRepository } from './user/create-user-repository'; -import { CustomUserController } from './user/user.controller'; -import { UserEntity } from './user/user.entity'; - -@Module({ - imports: [ - TypeOrmExtModule.forRoot({ - type: 'sqlite', - database: ':memory:', - entities: [UserEntity], - }), - AuthLocalModule.registerAsync({ - inject: [UserModelService], - useFactory: (userModelService: UserModelServiceInterface) => ({ - userModelService, - }), - }), - AuthJwtModule.registerAsync({ - inject: [UserModelService], - useFactory: (userModelService: UserModelServiceInterface) => ({ - userModelService, - }), - }), - AuthRefreshModule.registerAsync({ - inject: [UserModelService], - useFactory: (userModelService: UserModelServiceInterface) => ({ - userModelService, - }), - }), - AuthenticationModule.forRoot({}), - JwtModule.forRoot({}), - PasswordModule.forRoot({}), - CrudModule.forRoot({}), - UserModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - user: { - entity: UserEntity, - repositoryFactory: createUserRepository, - }, - }), - ], - useFactory: () => ({}), - }), - ], - controllers: [CustomUserController], - exports: [AuthenticationModule, AuthRefreshModule, AuthLocalModule], -}) -export class AppModule {} diff --git a/packages/nestjs-samples/src/03-authentication/auth-local.controller.fixture.ts b/packages/nestjs-samples/src/03-authentication/auth-local.controller.fixture.ts deleted file mode 100644 index fad157ff0..000000000 --- a/packages/nestjs-samples/src/03-authentication/auth-local.controller.fixture.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; -import { - ApiBody, - ApiOkResponse, - ApiTags, - ApiUnauthorizedResponse, -} from '@nestjs/swagger'; - -import { - AuthLocalGuard, - AuthLocalIssueTokenService, - AuthLocalLoginDto, -} from '@concepta/nestjs-auth-local'; -import { - AuthUser, - IssueTokenServiceInterface, - AuthenticationJwtResponseDto, - AuthPublic, -} from '@concepta/nestjs-authentication'; -import { - AuthenticatedUserInterface, - AuthenticationResponseInterface, -} from '@concepta/nestjs-common'; - -/** - * Auth Local controller - */ -@Controller('auth/login') -@UseGuards(AuthLocalGuard) -@AuthPublic() -@ApiTags('auth') -export class AuthLocalControllerFixture { - constructor( - @Inject(AuthLocalIssueTokenService) - private issueTokenService: IssueTokenServiceInterface, - ) {} - - /** - * Login - */ - @ApiBody({ - type: AuthLocalLoginDto, - description: 'DTO containing username and password.', - }) - @ApiOkResponse({ - type: AuthenticationJwtResponseDto, - description: 'DTO containing an access token and a refresh token.', - }) - @ApiUnauthorizedResponse() - @Post() - async login( - @AuthUser() user: AuthenticatedUserInterface, - ): Promise { - return this.issueTokenService.responsePayload(user.id); - } -} diff --git a/packages/nestjs-samples/src/03-authentication/auth-refresh.controller.fixture.ts b/packages/nestjs-samples/src/03-authentication/auth-refresh.controller.fixture.ts deleted file mode 100644 index 10ef4010f..000000000 --- a/packages/nestjs-samples/src/03-authentication/auth-refresh.controller.fixture.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; -import { - ApiBody, - ApiOkResponse, - ApiTags, - ApiUnauthorizedResponse, -} from '@nestjs/swagger'; - -import { - AuthRefreshGuard, - AuthRefreshIssueTokenService, - AuthRefreshDto, -} from '@concepta/nestjs-auth-refresh'; -import { - IssueTokenServiceInterface, - AuthUser, - AuthenticationJwtResponseDto, - AuthPublic, -} from '@concepta/nestjs-authentication'; -import { - AuthenticatedUserInterface, - AuthenticationResponseInterface, -} from '@concepta/nestjs-common'; - -/** - * Auth Local controller - */ -@Controller('token/refresh') -@UseGuards(AuthRefreshGuard) -@AuthPublic() -@ApiTags('auth') -export class AuthRefreshControllerFixture { - constructor( - @Inject(AuthRefreshIssueTokenService) - private issueTokenService: IssueTokenServiceInterface, - ) {} - - /** - * Login - */ - @ApiBody({ - type: AuthRefreshDto, - description: 'DTO containing a refresh token.', - }) - @ApiOkResponse({ - type: AuthenticationJwtResponseDto, - description: 'DTO containing an access token and a refresh token.', - }) - @ApiUnauthorizedResponse() - @Post() - async refresh( - @AuthUser() user: AuthenticatedUserInterface, - ): Promise { - return this.issueTokenService.responsePayload(user.id); - } -} diff --git a/packages/nestjs-samples/src/03-authentication/main.ts b/packages/nestjs-samples/src/03-authentication/main.ts deleted file mode 100644 index 5ffec68c6..000000000 --- a/packages/nestjs-samples/src/03-authentication/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NestFactory } from '@nestjs/core'; - -import { AppModule } from './app.module'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - - await app.listen(3000); -} -bootstrap(); diff --git a/packages/nestjs-samples/src/03-authentication/user/create-user-repository.ts b/packages/nestjs-samples/src/03-authentication/user/create-user-repository.ts deleted file mode 100644 index 56794736b..000000000 --- a/packages/nestjs-samples/src/03-authentication/user/create-user-repository.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { DataSource, FindOneOptions } from 'typeorm'; - -import { UserEntityInterface } from '@concepta/nestjs-common'; - -import { UserEntity } from './user.entity'; - -export function createUserRepository(dataSource: DataSource) { - /** - * Fake user "database" - */ - const users: UserEntity[] = [ - { - id: '1', - email: 'first_user@dispostable.com', - username: 'first_user', - active: true, - // hashed for AS12378 - passwordHash: - '$2b$10$9y97gOLiusyKnzu7LRdMmOCVpp/xwddaa8M6KtgenvUDao5I.8mJS', - passwordSalt: '$2b$10$9y97gOLiusyKnzu7LRdMmO', - dateCreated: new Date(), - dateUpdated: new Date(), - dateDeleted: new Date(), - version: 1, - }, - { - id: '2', - email: 'second_user@dispostable.com', - username: 'second_user', - active: true, - // hashed for AS12378 - passwordHash: - '$2b$10$9y97gOLiusyKnzu7LRdMmOCVpp/xwddaa8M6KtgenvUDao5I.8mJS', - passwordSalt: '$2b$10$9y97gOLiusyKnzu7LRdMmO', - dateCreated: new Date(), - dateUpdated: new Date(), - dateDeleted: new Date(), - version: 1, - }, - ]; - - return dataSource.getRepository(UserEntity).extend({ - async findOne( - optionsOrConditions?: - | string - | number - | Date - | FindOneOptions, - ): Promise { - const user = users.find((user) => { - if ( - typeof optionsOrConditions === 'object' && - 'where' in optionsOrConditions && - typeof optionsOrConditions.where === 'object' && - ('id' in optionsOrConditions.where || - 'username' in optionsOrConditions.where) - ) { - return ( - user.id === optionsOrConditions.where.id || - user.username === optionsOrConditions.where.username - ); - } - }); - - return user ? user : null; - }, - }); -} diff --git a/packages/nestjs-samples/src/03-authentication/user/user.controller.ts b/packages/nestjs-samples/src/03-authentication/user/user.controller.ts deleted file mode 100644 index 94c96420d..000000000 --- a/packages/nestjs-samples/src/03-authentication/user/user.controller.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; - -import { JwtAuthGuard } from '@concepta/nestjs-auth-jwt'; -import { ReferenceUsername } from '@concepta/nestjs-common'; - -export class UserDto { - constructor(username: ReferenceUsername) { - this.username = username; - } - username: ReferenceUsername; -} - -/** - * Custom User controller - */ -@Controller('custom/user') -@ApiTags('user') -export class CustomUserController { - /** - * Login - */ - @ApiOkResponse() - @UseGuards(JwtAuthGuard) - @Get('all') - get(): UserDto[] { - return [new UserDto('user1'), new UserDto('user2')]; - } -} diff --git a/packages/nestjs-samples/src/03-authentication/user/user.entity.ts b/packages/nestjs-samples/src/03-authentication/user/user.entity.ts deleted file mode 100644 index 400f06c7e..000000000 --- a/packages/nestjs-samples/src/03-authentication/user/user.entity.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Entity } from 'typeorm'; - -import { UserSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -@Entity() -export class UserEntity extends UserSqliteEntity {} diff --git a/packages/nestjs-samples/src/04-email/app.module.spec.ts b/packages/nestjs-samples/src/04-email/app.module.spec.ts index 6334a1c1c..ab6449917 100644 --- a/packages/nestjs-samples/src/04-email/app.module.spec.ts +++ b/packages/nestjs-samples/src/04-email/app.module.spec.ts @@ -1,4 +1,4 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; import { EmailService } from '@concepta/nestjs-email'; diff --git a/packages/nestjs-samples/src/05-user/app.module.ts b/packages/nestjs-samples/src/05-user/app.module.ts deleted file mode 100644 index 5c37ac2e2..000000000 --- a/packages/nestjs-samples/src/05-user/app.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { CrudModule } from '@concepta/nestjs-crud'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { UserModule } from '@concepta/nestjs-user'; - -import { default as dbConfig } from './ormconfig'; -import { UserEntity } from './user/user.entity'; - -@Module({ - imports: [ - TypeOrmExtModule.forRoot(dbConfig), - CrudModule.forRoot({}), - UserModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - user: { - entity: UserEntity, - }, - }), - ], - }), - ], -}) -export class AppModule {} diff --git a/packages/nestjs-samples/src/05-user/app.seeder.ts b/packages/nestjs-samples/src/05-user/app.seeder.ts deleted file mode 100644 index 45e1eb561..000000000 --- a/packages/nestjs-samples/src/05-user/app.seeder.ts +++ /dev/null @@ -1 +0,0 @@ -export { UserSeeder } from '@concepta/nestjs-user/dist/seeding'; diff --git a/packages/nestjs-samples/src/05-user/main.ts b/packages/nestjs-samples/src/05-user/main.ts deleted file mode 100644 index 5ffec68c6..000000000 --- a/packages/nestjs-samples/src/05-user/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NestFactory } from '@nestjs/core'; - -import { AppModule } from './app.module'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - - await app.listen(3000); -} -bootstrap(); diff --git a/packages/nestjs-samples/src/05-user/ormconfig.ts b/packages/nestjs-samples/src/05-user/ormconfig.ts deleted file mode 100644 index 926910004..000000000 --- a/packages/nestjs-samples/src/05-user/ormconfig.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TypeOrmModuleOptions } from '@nestjs/typeorm'; - -import { UserEntity } from './user/user.entity'; - -const config: TypeOrmModuleOptions = { - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [UserEntity], -}; - -export default config; diff --git a/packages/nestjs-samples/src/05-user/user/user.entity.ts b/packages/nestjs-samples/src/05-user/user/user.entity.ts deleted file mode 100644 index 663f4ee7a..000000000 --- a/packages/nestjs-samples/src/05-user/user/user.entity.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Entity } from 'typeorm'; - -import { UserPostgresEntity } from '@concepta/nestjs-typeorm-ext'; - -@Entity() -export class UserEntity extends UserPostgresEntity {} diff --git a/packages/nestjs-samples/src/06-typeorm-ext/README.md b/packages/nestjs-samples/src/06-typeorm-ext/README.md deleted file mode 100644 index 7bbc288c1..000000000 --- a/packages/nestjs-samples/src/06-typeorm-ext/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Nest.Js TypeOrmExt Sample - -## Description - -## Libraries Documentation - -A list of all the necessary Rockets libraries that the TypeOrmExt Module Sample needs. - -- [@concepta/nestjs-common](https://www.google.com) -- [@concepta/nestjs-auth-local](https://github.com) -- [@concepta/nestjs-authentication](https://github.com) -- [@concepta/nestjs-email](https://github.com) -- [@concepta/nestjs-event](https://github.com) -- [@concepta/nestjs-jwt](https://github.com) -- [@concepta/nestjs-logger](https://github.com) -- [@concepta/nestjs-password](https://github.com) -- [@concepta/nestjs-typeorm-ext](https://github.com) -- [@concepta/nestjs-user](https://github.com) - -### Setup a new Nest.js project - -```zsh -npm i -g @nestjs/cli -nest new project-name -``` - -### Prerequisites - -These libraries are necessary for the TypeOrm Module to run: - -```zsh -# install necessary 3rd party libraries -yarn add @concepta/nestjs-common @concepta/nestjs-common @concepta/nestjs-auth-local @concepta/nestjs-authentication @concepta/nestjs-email @concepta/nestjs-event @concepta/nestjs-jwt @concepta/nestjs-logger @concepta/nestjs-password @concepta/nestjs-typeorm-ext @concepta/nestjs-user -``` - -### Basic Sample Implementation - -- [app.module.ts](packages/nestjs-samples/src/06-typeorm-ext/app.module.ts) -- [custom-repository](packages/nestjs-samples/src/06-typeorm-ext/app.module.ts) -- [custom-user](packages/nestjs-samples/src/06-typeorm-ext/app.module.ts) - -📝 License - -Copyright © 2022 Rockets. diff --git a/packages/nestjs-samples/src/06-typeorm-ext/app.module.spec.ts b/packages/nestjs-samples/src/06-typeorm-ext/app.module.spec.ts deleted file mode 100644 index e7f5b9563..000000000 --- a/packages/nestjs-samples/src/06-typeorm-ext/app.module.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { - RepositoryInterface, - getDynamicRepositoryToken, -} from '@concepta/nestjs-common'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; -import { UserModule, UserModelService } from '@concepta/nestjs-user'; - -import { AppModule } from './app.module'; -import { UserEntity } from './user/user.entity'; - -describe('AppModule', () => { - let userModule: UserModule; - let userModelService: UserModelService; - let userRepo: RepositoryInterface; - - beforeEach(async () => { - const testModule: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - userModule = testModule.get(UserModule); - userRepo = testModule.get(getDynamicRepositoryToken('user')); - userModelService = testModule.get(UserModelService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('module', () => { - it('should be loaded', async () => { - expect(userModule).toBeInstanceOf(UserModule); - expect(userRepo).toBeInstanceOf(TypeOrmRepositoryAdapter); - expect(userModelService).toBeInstanceOf(UserModelService); - expect(userModelService['repo']).toBeInstanceOf(TypeOrmRepositoryAdapter); - expect(userModelService['repo'].find).toBeInstanceOf(Function); - }); - }); -}); diff --git a/packages/nestjs-samples/src/06-typeorm-ext/app.module.ts b/packages/nestjs-samples/src/06-typeorm-ext/app.module.ts deleted file mode 100644 index 7622ec6f2..000000000 --- a/packages/nestjs-samples/src/06-typeorm-ext/app.module.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DataSource } from 'typeorm'; - -import { Module } from '@nestjs/common'; - -import { CrudModule } from '@concepta/nestjs-crud'; -import { PasswordModule } from '@concepta/nestjs-password'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { UserModule } from '@concepta/nestjs-user'; - -import { UserEntity } from './user/user.entity'; - -@Module({ - imports: [ - TypeOrmExtModule.forRoot({ - type: 'sqlite', - database: ':memory:', - entities: [UserEntity], - }), - CrudModule.forRoot({}), - PasswordModule.forRoot({}), - UserModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - user: { - entity: UserEntity, - repositoryFactory: (dataSource: DataSource) => - dataSource.getRepository(UserEntity).extend({}), - }, - }), - ], - useFactory: () => ({}), - }), - ], -}) -export class AppModule {} diff --git a/packages/nestjs-samples/src/06-typeorm-ext/main.ts b/packages/nestjs-samples/src/06-typeorm-ext/main.ts deleted file mode 100644 index 5ffec68c6..000000000 --- a/packages/nestjs-samples/src/06-typeorm-ext/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NestFactory } from '@nestjs/core'; - -import { AppModule } from './app.module'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - - await app.listen(3000); -} -bootstrap(); diff --git a/packages/nestjs-samples/src/06-typeorm-ext/user/user.entity.ts b/packages/nestjs-samples/src/06-typeorm-ext/user/user.entity.ts deleted file mode 100644 index 810369872..000000000 --- a/packages/nestjs-samples/src/06-typeorm-ext/user/user.entity.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Column, Entity } from 'typeorm'; - -import { UserSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -@Entity() -export class UserEntity extends UserSqliteEntity { - @Column() - customColumn!: string; -} diff --git a/packages/nestjs-samples/tsconfig.json b/packages/nestjs-samples/tsconfig.json index c67e94c23..72ef603a7 100644 --- a/packages/nestjs-samples/tsconfig.json +++ b/packages/nestjs-samples/tsconfig.json @@ -12,9 +12,5 @@ "include": [ "src/**/*.ts" ], - "references": [ - { - "path": "../nestjs-auth-refresh" - } - ] + "references": [] } diff --git a/packages/nestjs-swagger-ui/package.json b/packages/nestjs-swagger-ui/package.json index fd94f54a2..adb16202c 100644 --- a/packages/nestjs-swagger-ui/package.json +++ b/packages/nestjs-swagger-ui/package.json @@ -15,7 +15,7 @@ "@concepta/nestjs-common": "^7.0.0-alpha.10", "@nestjs/common": "^11.1.9", "@nestjs/config": "^4.0.2", - "@nestjs/swagger": "^11.2.2" + "@nestjs/swagger": "11.2.2" }, "devDependencies": { "@nestjs/core": "^11.1.9", diff --git a/packages/nestjs-swagger-ui/src/config/swagger-ui-default.config.ts b/packages/nestjs-swagger-ui/src/config/swagger-ui-default.config.ts index b0daa3218..6df70ab48 100644 --- a/packages/nestjs-swagger-ui/src/config/swagger-ui-default.config.ts +++ b/packages/nestjs-swagger-ui/src/config/swagger-ui-default.config.ts @@ -1,6 +1,6 @@ import { registerAs } from '@nestjs/config'; -import { SwaggerUiSettingsInterface } from '../interfaces/swagger-ui-settings.interface'; +import { type SwaggerUiSettingsInterface } from '../interfaces/swagger-ui-settings.interface'; import { SWAGGER_UI_DEFAULT_SETTINGS_TOKEN } from '../swagger-ui.constants'; /** diff --git a/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-options-extras.interface.ts b/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-options-extras.interface.ts index 9614f70af..36dcc307a 100644 --- a/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-options-extras.interface.ts +++ b/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-options-extras.interface.ts @@ -1,4 +1,6 @@ -import { DynamicModule } from '@nestjs/common'; +import { type DynamicModule } from '@nestjs/common'; -export interface SwaggerUiOptionsExtrasInterface - extends Pick {} +export interface SwaggerUiOptionsExtrasInterface extends Pick< + DynamicModule, + 'global' +> {} diff --git a/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-options.interface.ts b/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-options.interface.ts index 8848283d5..3368ab40c 100644 --- a/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-options.interface.ts +++ b/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-options.interface.ts @@ -1,6 +1,6 @@ -import { DocumentBuilder } from '@nestjs/swagger'; +import { type DocumentBuilder } from '@nestjs/swagger'; -import { SwaggerUiSettingsInterface } from './swagger-ui-settings.interface'; +import { type SwaggerUiSettingsInterface } from './swagger-ui-settings.interface'; export interface SwaggerUiOptionsInterface { settings?: SwaggerUiSettingsInterface; diff --git a/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-settings.interface.ts b/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-settings.interface.ts index 2a87ce5b3..60d04e2f5 100644 --- a/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-settings.interface.ts +++ b/packages/nestjs-swagger-ui/src/interfaces/swagger-ui-settings.interface.ts @@ -1,4 +1,7 @@ -import { SwaggerCustomOptions, SwaggerDocumentOptions } from '@nestjs/swagger'; +import { + type SwaggerCustomOptions, + type SwaggerDocumentOptions, +} from '@nestjs/swagger'; export interface SwaggerUiSettingsInterface { // ui diff --git a/packages/nestjs-swagger-ui/src/swagger-ui.module-definition.ts b/packages/nestjs-swagger-ui/src/swagger-ui.module-definition.ts index e63451262..ddeaa2003 100644 --- a/packages/nestjs-swagger-ui/src/swagger-ui.module-definition.ts +++ b/packages/nestjs-swagger-ui/src/swagger-ui.module-definition.ts @@ -1,16 +1,16 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { createSettingsProvider } from '@concepta/nestjs-common'; import { swaggerUiDefaultConfig } from './config/swagger-ui-default.config'; -import { SwaggerUiOptionsExtrasInterface } from './interfaces/swagger-ui-options-extras.interface'; -import { SwaggerUiOptionsInterface } from './interfaces/swagger-ui-options.interface'; -import { SwaggerUiSettingsInterface } from './interfaces/swagger-ui-settings.interface'; +import { type SwaggerUiOptionsExtrasInterface } from './interfaces/swagger-ui-options-extras.interface'; +import { type SwaggerUiOptionsInterface } from './interfaces/swagger-ui-options.interface'; +import { type SwaggerUiSettingsInterface } from './interfaces/swagger-ui-settings.interface'; import { SWAGGER_UI_MODULE_DOCUMENT_BUILDER_TOKEN, SWAGGER_UI_MODULE_SETTINGS_TOKEN, diff --git a/packages/nestjs-swagger-ui/src/swagger-ui.module.spec.ts b/packages/nestjs-swagger-ui/src/swagger-ui.module.spec.ts index f94eff1d8..884d78858 100644 --- a/packages/nestjs-swagger-ui/src/swagger-ui.module.spec.ts +++ b/packages/nestjs-swagger-ui/src/swagger-ui.module.spec.ts @@ -1,6 +1,6 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, type TestingModule } from '@nestjs/testing'; -import { SwaggerUiSettingsInterface } from './interfaces/swagger-ui-settings.interface'; +import { type SwaggerUiSettingsInterface } from './interfaces/swagger-ui-settings.interface'; import { SWAGGER_UI_MODULE_SETTINGS_TOKEN } from './swagger-ui.constants'; import { SwaggerUiModule } from './swagger-ui.module'; import { SwaggerUiService } from './swagger-ui.service'; diff --git a/packages/nestjs-swagger-ui/src/swagger-ui.service.spec.ts b/packages/nestjs-swagger-ui/src/swagger-ui.service.spec.ts index b2e3932e6..726603361 100644 --- a/packages/nestjs-swagger-ui/src/swagger-ui.service.spec.ts +++ b/packages/nestjs-swagger-ui/src/swagger-ui.service.spec.ts @@ -1,5 +1,5 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; import { SwaggerUiModule } from './swagger-ui.module'; import { SwaggerUiService } from './swagger-ui.service'; diff --git a/packages/nestjs-swagger-ui/src/utils/create-default-document-builder.ts b/packages/nestjs-swagger-ui/src/utils/create-default-document-builder.ts index c8b23861f..c40ad16ce 100644 --- a/packages/nestjs-swagger-ui/src/utils/create-default-document-builder.ts +++ b/packages/nestjs-swagger-ui/src/utils/create-default-document-builder.ts @@ -1,6 +1,6 @@ import { DocumentBuilder } from '@nestjs/swagger'; -import { SwaggerUiSettingsInterface } from '../interfaces/swagger-ui-settings.interface'; +import { type SwaggerUiSettingsInterface } from '../interfaces/swagger-ui-settings.interface'; export function createDefaultDocumentBuilder( settings: SwaggerUiSettingsInterface, diff --git a/packages/nestjs-typeorm-ext/README.md b/packages/nestjs-typeorm-ext/README.md deleted file mode 100644 index 290c6c086..000000000 --- a/packages/nestjs-typeorm-ext/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# Rockets NestJS TypeOrm Extended - -Extremely powerful extension of the NestJS TypeOrm module that allows your -dynamic modules to accept drop-in replacements of custom entities -and repositories at registration time. - -## Project - -[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-typeorm-ext)](https://www.npmjs.com/package/@concepta/nestjs-typeorm-ext) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-typeorm-ext)](https://www.npmjs.com/package/@concepta/nestjs-typeorm-ext) -[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) -[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) - -## Overview - -The TypeOrm Ext module provides a powerful wrapper around the -[@nestjs/typeorm](https://www.npmjs.com/package/@nestjs/typeorm) module. - -While still using the identical configuration options of the TypeOrm module, -you can increase the extensibility of your custom module by designing it -to accept custom entity and repository overrides. - -This pattern allows you to publish modules that are loosely coupled to their -own entity and repository definitions. This enables implementations of your -module to define their own concrete data storage. - -## Installation - -`yarn add @concepta/nestjs-typeorm-ext` - -## Module Design - -Designing your module to use this extension is fairly straight forward, -but a bit too verbose for this readme. - -To see how this was implemented in our -[UserModule](https://github.com/conceptadev/rockets/blob/main/packages/nestjs-user) -please refer to that module's -[user.module.ts](https://github.com/conceptadev/rockets/blob/main/packages/nestjs-user/src/user.module.ts) - -## Usage - -app.module.ts - -```ts -// ... -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { UserModule } from '@concepta/nestjs-user'; -import { CustomUserRepository } from 'path/to/custom-user.repository'; -import { CustomUser } from 'path/to/custom-user.entity'; - -@Module({ - imports: [ - TypeOrmExtModule.forRoot({ - type: 'postgres', - url: 'postgres://user:pass@localhost:5432/postgres', - entities: [CustomUser], - }), - UserModule.forRoot({ - entities: { - user: { entity: CustomUser, repository: CustomUserRepository }, - }, - }), - ], -}) -export class AppModule {} -``` - -## Configuration - -### Data Source Options - -The module options are identical the the NestJS TypeOrm module. diff --git a/packages/nestjs-typeorm-ext/package.json b/packages/nestjs-typeorm-ext/package.json deleted file mode 100644 index add232d3e..000000000 --- a/packages/nestjs-typeorm-ext/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@concepta/nestjs-typeorm-ext", - "version": "7.0.0-alpha.10", - "description": "Rockets NestJS TypeORM Extended", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "license": "BSD-3-Clause", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" - ], - "dependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/typeorm": "^11.0.0" - }, - "devDependencies": { - "@concepta/typeorm-seeding": "^4.0.0", - "@faker-js/faker": "^8.4.1", - "@nestjs/testing": "^11.1.9", - "sqlite3": "^5.1.4" - }, - "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", - "typeorm": "^0.3.0" - } -} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/model/test-model.service.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/model/test-model.service.fixture.ts deleted file mode 100644 index bbc5ee227..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/model/test-model.service.fixture.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ModelService, - RepositoryInterface, - InjectDynamicRepository, -} from '@concepta/nestjs-common'; - -import { TestCreateDtoFixture } from '../repository/dto/test-create.dto.fixture'; -import { TestUpdateDtoFixture } from '../repository/dto/test-update.dto.fixture'; -import { TestCreatableInterfaceFixture } from '../repository/interface/test-creatable.interface.fixture'; -import { TestInterfaceFixture } from '../repository/interface/test-entity.interface.fixture'; -import { TestUpdatableInterfaceFixture } from '../repository/interface/test-updatable.interface.fixture'; -import { AUDIT_TOKEN } from '../repository/test.constants.fixture'; -import { TestEntityFixture } from '../repository/test.entity.fixture'; - -@Injectable() -export class TestModelServiceFixture extends ModelService< - TestInterfaceFixture, - TestCreatableInterfaceFixture, - TestUpdatableInterfaceFixture -> { - protected createDto = TestCreateDtoFixture; - protected updateDto = TestUpdateDtoFixture; - - constructor( - @InjectDynamicRepository(AUDIT_TOKEN) - repo: RepositoryInterface, - ) { - super(repo); - } -} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/photo/interfaces/photo-entity.interface.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/photo/interfaces/photo-entity.interface.fixture.ts deleted file mode 100644 index 92a4d8457..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/photo/interfaces/photo-entity.interface.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ReferenceIdInterface } from '@concepta/nestjs-common'; - -export interface PhotoEntityInterfaceFixture extends ReferenceIdInterface { - name: string; - description: string; - filename: string; - views: number; - isPublished: boolean; - deletedAt: Date | null; -} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.constants.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.constants.fixture.ts deleted file mode 100644 index 6d5e3e108..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.constants.fixture.ts +++ /dev/null @@ -1 +0,0 @@ -export const PHOTO_MODULE_OPTIONS_TOKEN = 'PHOTO_MODULE_OPTIONS_TOKEN'; diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.entity.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.entity.fixture.ts deleted file mode 100644 index d9265a95c..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.entity.fixture.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - Column, - DeleteDateColumn, - Entity, - PrimaryGeneratedColumn, -} from 'typeorm'; - -import { ReferenceId } from '@concepta/nestjs-common'; - -import { PhotoEntityInterfaceFixture } from './interfaces/photo-entity.interface.fixture'; - -@Entity() -export class PhotoEntityFixture implements PhotoEntityInterfaceFixture { - @PrimaryGeneratedColumn('uuid') - id!: ReferenceId; - - @Column({ length: 500 }) - name!: string; - - @Column('text') - description!: string; - - @Column('text') - filename!: string; - - @Column('int', { default: 0 }) - views = 0; - - @Column('boolean') - isPublished = true; - - @DeleteDateColumn({ nullable: true }) - deletedAt: Date | null = null; -} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.module.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.module.fixture.ts deleted file mode 100644 index e3786be4d..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.module.fixture.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { TypeOrmExtModule } from '../../typeorm-ext.module'; - -import { PhotoEntityFixture } from './photo.entity.fixture'; -import { createPhotoRepositoryFixture } from './photo.repository.fixture'; - -@Module({ - imports: [ - TypeOrmExtModule.forFeature({ - photo: { - entity: PhotoEntityFixture, - repositoryFactory: createPhotoRepositoryFixture, - }, - }), - ], -}) -export class PhotoModuleFixture {} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.repository.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.repository.fixture.ts deleted file mode 100644 index 79ded79d7..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/photo/photo.repository.fixture.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { DataSource, Repository } from 'typeorm'; - -import { PhotoEntityFixture } from './photo.entity.fixture'; - -interface CustomFixtureMethods { - customMethod(): null; -} -export interface PhotoRepositoryFixtureInterface - extends Repository, - CustomFixtureMethods {} - -export const createPhotoRepositoryFixture = ( - dataSource: DataSource, -): PhotoRepositoryFixtureInterface => { - return dataSource - .getRepository(PhotoEntityFixture) - .extend({ - customMethod(): null { - return null; - }, - }); -}; diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/app.module.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/app.module.fixture.ts deleted file mode 100644 index 778fa65ec..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/app.module.fixture.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { TypeOrmExtModule } from '../../typeorm-ext.module'; - -import { ormConfig } from './ormconfig.fixture'; -import { TestModuleFixture } from './test.module.fixture'; - -@Module({ - imports: [TypeOrmExtModule.forRoot(ormConfig), TestModuleFixture], -}) -export class AppModuleFixture {} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/dto/test-create.dto.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/dto/test-create.dto.fixture.ts deleted file mode 100644 index f67120018..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/dto/test-create.dto.fixture.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { IsOptional, IsString, MinLength } from 'class-validator'; - -import { TestCreatableInterfaceFixture } from '../interface/test-creatable.interface.fixture'; - -export class TestCreateDtoFixture implements TestCreatableInterfaceFixture { - @IsString() - @MinLength(2) - firstName = ''; - - @IsOptional() - @IsString() - @MinLength(2) - lastName?: string; -} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/dto/test-update.dto.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/dto/test-update.dto.fixture.ts deleted file mode 100644 index c0c4157a3..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/dto/test-update.dto.fixture.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IsOptional, IsString, MinLength } from 'class-validator'; - -import { TestUpdatableInterfaceFixture } from '../interface/test-updatable.interface.fixture'; - -export class TestUpdateDtoFixture implements TestUpdatableInterfaceFixture { - @IsString() - id!: string; - - @IsOptional() - @IsString() - @MinLength(2) - firstName?: string; - - @IsOptional() - @IsString() - @MinLength(2) - lastName?: string; -} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/dto/test.dto.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/dto/test.dto.fixture.ts deleted file mode 100644 index 2a6508e90..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/dto/test.dto.fixture.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ReferenceId, CommonEntityDto } from '@concepta/nestjs-common'; - -import { TestInterfaceFixture } from '../interface/test-entity.interface.fixture'; - -export class TestDtoFixture - extends CommonEntityDto - implements TestInterfaceFixture -{ - id: ReferenceId = ''; - - firstName = ''; - - lastName = ''; -} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/interface/test-creatable.interface.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/interface/test-creatable.interface.fixture.ts deleted file mode 100644 index 7b36b9fe8..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/interface/test-creatable.interface.fixture.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { TestInterfaceFixture } from './test-entity.interface.fixture'; - -export interface TestCreatableInterfaceFixture - extends Pick {} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/interface/test-entity.interface.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/interface/test-entity.interface.fixture.ts deleted file mode 100644 index 86db49071..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/interface/test-entity.interface.fixture.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { AuditInterface, ReferenceIdInterface } from '@concepta/nestjs-common'; - -export interface TestInterfaceFixture - extends ReferenceIdInterface, - AuditInterface { - firstName: string; - lastName?: string; -} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/interface/test-updatable.interface.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/interface/test-updatable.interface.fixture.ts deleted file mode 100644 index 1ea2a0202..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/interface/test-updatable.interface.fixture.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { TestInterfaceFixture } from './test-entity.interface.fixture'; - -export interface TestUpdatableInterfaceFixture - extends Pick, - Partial> {} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/ormconfig.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/ormconfig.fixture.ts deleted file mode 100644 index 74e12ff80..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/ormconfig.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DataSourceOptions } from 'typeorm'; - -import { TestEntityFixture } from './test.entity.fixture'; - -export const ormConfig: DataSourceOptions = { - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [TestEntityFixture], -}; diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/services/test-typeorm-repository.service.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/services/test-typeorm-repository.service.fixture.ts deleted file mode 100644 index 5af217631..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/services/test-typeorm-repository.service.fixture.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Repository } from 'typeorm'; - -import { Injectable } from '@nestjs/common'; - -import { InjectDynamicRepository } from '@concepta/nestjs-common'; - -import { TypeOrmRepositoryAdapter } from '../../../repository/typeorm-repository.adapter'; -import { AUDIT_TOKEN } from '../test.constants.fixture'; -import { TestEntityFixture } from '../test.entity.fixture'; - -@Injectable() -export class TestTypeOrmRepositoryServiceFixture extends TypeOrmRepositoryAdapter { - constructor( - @InjectDynamicRepository(AUDIT_TOKEN) - repo: Repository, - ) { - super(repo); - } -} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/services/typeorm-repository.adapter.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/services/typeorm-repository.adapter.fixture.ts deleted file mode 100644 index 2c0f2db55..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/services/typeorm-repository.adapter.fixture.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Repository } from 'typeorm'; - -import { Injectable } from '@nestjs/common'; - -import { InjectDynamicRepository } from '@concepta/nestjs-common'; - -import { TypeOrmRepositoryAdapter } from '../../../repository/typeorm-repository.adapter'; -import { AUDIT_TOKEN } from '../test.constants.fixture'; -import { TestEntityFixture } from '../test.entity.fixture'; - -@Injectable() -export class TypeOrmRepositoryAdapterFixture extends TypeOrmRepositoryAdapter { - constructor( - @InjectDynamicRepository(AUDIT_TOKEN) - repo: Repository, - ) { - super(repo); - } -} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.constants.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.constants.fixture.ts deleted file mode 100644 index 5eff92070..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.constants.fixture.ts +++ /dev/null @@ -1 +0,0 @@ -export const AUDIT_TOKEN = 'audit'; diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.entity.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.entity.fixture.ts deleted file mode 100644 index 93873d6fe..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.entity.fixture.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Column, Entity } from 'typeorm'; - -import { CommonSqliteEntity } from '../../entities/common/common-sqlite.entity'; - -import { TestInterfaceFixture } from './interface/test-entity.interface.fixture'; - -@Entity() -export class TestEntityFixture - extends CommonSqliteEntity - implements TestInterfaceFixture -{ - @Column() - firstName!: string; - - @Column({ nullable: true }) - lastName!: string; -} diff --git a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.module.fixture.ts b/packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.module.fixture.ts deleted file mode 100644 index c0d901152..000000000 --- a/packages/nestjs-typeorm-ext/src/__fixtures__/repository/test.module.fixture.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { TypeOrmExtModule } from '../../typeorm-ext.module'; -import { TestModelServiceFixture } from '../model/test-model.service.fixture'; - -import { TypeOrmRepositoryAdapterFixture } from './services/typeorm-repository.adapter.fixture'; -import { TestEntityFixture } from './test.entity.fixture'; - -@Module({ - imports: [ - TypeOrmExtModule.forFeature({ - audit: { - entity: TestEntityFixture, - }, - }), - ], - providers: [TypeOrmRepositoryAdapterFixture, TestModelServiceFixture], - exports: [TypeOrmRepositoryAdapterFixture, TestModelServiceFixture], -}) -export class TestModuleFixture {} diff --git a/packages/nestjs-typeorm-ext/src/entities/cache/cache-postgres.entity.ts b/packages/nestjs-typeorm-ext/src/entities/cache/cache-postgres.entity.ts deleted file mode 100644 index ebe750a9a..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/cache/cache-postgres.entity.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Column, Unique } from 'typeorm'; - -import { ReferenceId, CacheInterface } from '@concepta/nestjs-common'; - -import { CommonPostgresEntity } from '../common/common-postgres.entity'; - -/** - * Cache Postgres Entity - */ -@Unique(['key', 'type', 'assigneeId']) -export abstract class CachePostgresEntity - extends CommonPostgresEntity - implements CacheInterface -{ - @Column() - type!: string; - - @Column() - key!: string; - - @Column({ type: 'jsonb', nullable: true }) - data!: string | null; - - @Column({ type: 'timestamptz', nullable: true }) - expirationDate!: Date | null; - - @Column({ type: 'uuid' }) - assigneeId!: ReferenceId; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/cache/cache-sqlite.entity.ts b/packages/nestjs-typeorm-ext/src/entities/cache/cache-sqlite.entity.ts deleted file mode 100644 index 58b05b565..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/cache/cache-sqlite.entity.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Column, Unique } from 'typeorm'; - -import { ReferenceId, CacheInterface } from '@concepta/nestjs-common'; - -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; - -/** - * Cache Sqlite Entity - */ - -@Unique(['key', 'type', 'assigneeId']) -export abstract class CacheSqliteEntity - extends CommonSqliteEntity - implements CacheInterface -{ - @Column() - key!: string; - - @Column() - type!: string; - - @Column({ type: 'text', nullable: true }) - data!: string; - - @Column({ type: 'datetime', nullable: true }) - expirationDate!: Date | null; - - @Column({ type: 'uuid' }) - assigneeId!: ReferenceId; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/common/common-postgres.entity.ts b/packages/nestjs-typeorm-ext/src/entities/common/common-postgres.entity.ts deleted file mode 100644 index ffdf7ad5f..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/common/common-postgres.entity.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { PrimaryGeneratedColumn } from 'typeorm'; - -import { AuditInterface, ReferenceIdInterface } from '@concepta/nestjs-common'; - -import { AuditPostgresEntity } from '../audit/audit-postgres.entity'; - -export abstract class CommonPostgresEntity - extends AuditPostgresEntity - implements ReferenceIdInterface, AuditInterface -{ - @PrimaryGeneratedColumn('uuid') - id!: string; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/common/common-sqlite.entity.ts b/packages/nestjs-typeorm-ext/src/entities/common/common-sqlite.entity.ts deleted file mode 100644 index e393497ff..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/common/common-sqlite.entity.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { PrimaryGeneratedColumn } from 'typeorm'; - -import { AuditInterface, ReferenceIdInterface } from '@concepta/nestjs-common'; - -import { AuditSqlLiteEntity } from '../audit/audit-sqlite.entity'; - -export abstract class CommonSqliteEntity - extends AuditSqlLiteEntity - implements ReferenceIdInterface, AuditInterface -{ - @PrimaryGeneratedColumn('uuid') - id!: string; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/federated/federated-postgres.entity.ts b/packages/nestjs-typeorm-ext/src/entities/federated/federated-postgres.entity.ts deleted file mode 100644 index 3220743cd..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/federated/federated-postgres.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Column, Entity } from 'typeorm'; - -import { - ReferenceIdInterface, - FederatedEntityInterface, -} from '@concepta/nestjs-common'; - -import { CommonPostgresEntity } from '../common/common-postgres.entity'; - -/** - * Federated Postgres Entity - */ -@Entity() -export class FederatedPostgresEntity - extends CommonPostgresEntity - implements FederatedEntityInterface -{ - /** - * provider - */ - @Column() - provider!: string; - - /** - * subject - */ - @Column() - subject!: string; - - /** - * User - */ - user!: ReferenceIdInterface; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/federated/federated-sqlite.entity.ts b/packages/nestjs-typeorm-ext/src/entities/federated/federated-sqlite.entity.ts deleted file mode 100644 index 4c7cc493f..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/federated/federated-sqlite.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Column, Entity } from 'typeorm'; - -import { - ReferenceIdInterface, - FederatedEntityInterface, -} from '@concepta/nestjs-common'; - -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; - -/** - * Federated Sqlite Entity - */ -@Entity() -export class FederatedSqliteEntity - extends CommonSqliteEntity - implements FederatedEntityInterface -{ - /** - * provider - */ - @Column() - provider!: string; - - /** - * subject - */ - @Column() - subject!: string; - - /** - * User - */ - user!: ReferenceIdInterface; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/invitation/invitation-postgres.entity.ts b/packages/nestjs-typeorm-ext/src/entities/invitation/invitation-postgres.entity.ts deleted file mode 100644 index ffe36c890..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/invitation/invitation-postgres.entity.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Column } from 'typeorm'; - -import { PlainLiteralObject } from '@nestjs/common'; - -import { - ReferenceActive, - ReferenceId, - InvitationEntityInterface, -} from '@concepta/nestjs-common'; - -import { CommonPostgresEntity } from '../common/common-postgres.entity'; - -// TODO check this entity later -export abstract class InvitationPostgresEntity - extends CommonPostgresEntity - implements InvitationEntityInterface -{ - @Column('boolean', { default: true }) - active!: ReferenceActive; - - @Column() - code!: string; - - @Column() - category!: string; - - @Column({ type: 'jsonb' }) - constraints!: PlainLiteralObject; - - @Column({ type: 'uuid' }) - userId!: ReferenceId; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/invitation/invitation-sqlite.entity.ts b/packages/nestjs-typeorm-ext/src/entities/invitation/invitation-sqlite.entity.ts deleted file mode 100644 index 76914acaf..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/invitation/invitation-sqlite.entity.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Column } from 'typeorm'; - -import { PlainLiteralObject } from '@nestjs/common'; - -import { - ReferenceActive, - ReferenceId, - InvitationEntityInterface, -} from '@concepta/nestjs-common'; - -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; - -export abstract class InvitationSqliteEntity - extends CommonSqliteEntity - implements InvitationEntityInterface -{ - @Column('boolean', { default: true }) - active!: ReferenceActive; - - @Column() - code!: string; - - @Column() - category!: string; - - @Column({ type: 'simple-json', nullable: true }) - constraints!: PlainLiteralObject; - - @Column({ type: 'uuid' }) - userId!: ReferenceId; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/org/index.ts b/packages/nestjs-typeorm-ext/src/entities/org/index.ts deleted file mode 100644 index f195bbcdd..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/org/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { OrgPostgresEntity } from './org-postgres.entity'; -export { OrgSqliteEntity } from './org-sqlite.entity'; -export { OrgMemberPostgresEntity } from './org-member-postgres.entity'; -export { OrgMemberSqliteEntity } from './org-member-sqlite.entity'; -export { OrgProfilePostgresEntity } from './org-profile-postgres.entity'; -export { OrgProfileSqliteEntity } from './org-profile-sqlite.entity'; diff --git a/packages/nestjs-typeorm-ext/src/entities/otp/otp-postgres.entity.ts b/packages/nestjs-typeorm-ext/src/entities/otp/otp-postgres.entity.ts deleted file mode 100644 index 363b943f5..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/otp/otp-postgres.entity.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Column } from 'typeorm'; - -import { ReferenceId, OtpInterface } from '@concepta/nestjs-common'; - -import { CommonPostgresEntity } from '../common/common-postgres.entity'; - -/** - * Otp Postgres Entity - */ -export abstract class OtpPostgresEntity - extends CommonPostgresEntity - implements OtpInterface -{ - @Column() - category!: string; - - @Column({ nullable: true }) - type!: string; - - @Column() - passcode!: string; - - @Column({ type: 'timestamptz' }) - expirationDate!: Date; - - @Column({ default: true }) - active!: boolean; - - @Column({ type: 'uuid' }) - assigneeId!: ReferenceId; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/otp/otp-sqlite.entity.ts b/packages/nestjs-typeorm-ext/src/entities/otp/otp-sqlite.entity.ts deleted file mode 100644 index 1620c4bfc..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/otp/otp-sqlite.entity.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Column } from 'typeorm'; - -import { ReferenceId, OtpInterface } from '@concepta/nestjs-common'; - -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; - -/** - * Otp Sqlite Entity - */ -export abstract class OtpSqliteEntity - extends CommonSqliteEntity - implements OtpInterface -{ - @Column() - category!: string; - - @Column({ nullable: true }) - type!: string; - - @Column() - passcode!: string; - - @Column({ type: 'datetime' }) - expirationDate!: Date; - - @Column({ default: true }) - active!: boolean; - - @Column({ type: 'uuid' }) - assigneeId!: ReferenceId; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/role/role-assignment-postgres.entity.ts b/packages/nestjs-typeorm-ext/src/entities/role/role-assignment-postgres.entity.ts deleted file mode 100644 index ee03f0520..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/role/role-assignment-postgres.entity.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Column, Unique } from 'typeorm'; - -import { - ReferenceId, - RoleAssignmentEntityInterface, -} from '@concepta/nestjs-common'; - -import { CommonPostgresEntity } from '../common/common-postgres.entity'; - -/** - * Role Assignment Postgres Entity - */ -@Unique(['roleId', 'assigneeId']) -export abstract class RoleAssignmentPostgresEntity - extends CommonPostgresEntity - implements RoleAssignmentEntityInterface -{ - /** - * Role ID - */ - @Column({ type: 'uuid' }) - roleId!: ReferenceId; - - /** - * Assignee ID - */ - @Column({ type: 'uuid' }) - assigneeId!: ReferenceId; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/role/role-assignment-sqlite.entity.ts b/packages/nestjs-typeorm-ext/src/entities/role/role-assignment-sqlite.entity.ts deleted file mode 100644 index ec1443dfa..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/role/role-assignment-sqlite.entity.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Column, Unique } from 'typeorm'; - -import { - ReferenceId, - RoleAssignmentEntityInterface, -} from '@concepta/nestjs-common'; - -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; - -/** - * Role Assignment Sqlite Entity - */ -@Unique(['roleId', 'assigneeId']) -export abstract class RoleAssignmentSqliteEntity - extends CommonSqliteEntity - implements RoleAssignmentEntityInterface -{ - /** - * Role ID - */ - @Column({ type: 'uuid' }) - roleId!: ReferenceId; - - /** - * Assignee ID - */ - @Column({ type: 'uuid' }) - assigneeId!: ReferenceId; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/role/role-postgres.entity.ts b/packages/nestjs-typeorm-ext/src/entities/role/role-postgres.entity.ts deleted file mode 100644 index 6a193d8c5..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/role/role-postgres.entity.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Column } from 'typeorm'; - -import { RoleEntityInterface } from '@concepta/nestjs-common'; - -import { CommonPostgresEntity } from '../common/common-postgres.entity'; - -/** - * Role Postgres Entity - */ -export abstract class RolePostgresEntity - extends CommonPostgresEntity - implements RoleEntityInterface -{ - /** - * Name - */ - @Column() - name!: string; - - /** - * Description - */ - @Column() - description!: string; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/role/role-sqlite.entity.ts b/packages/nestjs-typeorm-ext/src/entities/role/role-sqlite.entity.ts deleted file mode 100644 index 27cfd0c06..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/role/role-sqlite.entity.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Column } from 'typeorm'; - -import { RoleEntityInterface } from '@concepta/nestjs-common'; - -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; - -/** - * Role Sqlite Entity - */ -export abstract class RoleSqliteEntity - extends CommonSqliteEntity - implements RoleEntityInterface -{ - @Column() - name!: string; - - @Column({ nullable: true }) - description!: string; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/user-password-history/user-password-history-postgres.entity.ts b/packages/nestjs-typeorm-ext/src/entities/user-password-history/user-password-history-postgres.entity.ts deleted file mode 100644 index 3cf0c34a5..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/user-password-history/user-password-history-postgres.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Column } from 'typeorm'; - -import { - ReferenceId, - UserPasswordHistoryEntityInterface, -} from '@concepta/nestjs-common'; - -import { CommonPostgresEntity } from '../common/common-postgres.entity'; - -/** - * User Entity - */ -export abstract class UserPasswordHistoryPostgresEntity - extends CommonPostgresEntity - implements UserPasswordHistoryEntityInterface -{ - /** - * Password hash - */ - @Column({ type: 'text', nullable: true }) - passwordHash!: string; - - /** - * Password salt - */ - @Column({ type: 'text', nullable: true }) - passwordSalt!: string; - - /** - * User ID - */ - @Column({ type: 'uuid' }) - userId!: ReferenceId; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/user-password-history/user-password-history-sqlite.entity.ts b/packages/nestjs-typeorm-ext/src/entities/user-password-history/user-password-history-sqlite.entity.ts deleted file mode 100644 index b236ea5fb..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/user-password-history/user-password-history-sqlite.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Column } from 'typeorm'; - -import { - ReferenceId, - UserPasswordHistoryEntityInterface, -} from '@concepta/nestjs-common'; - -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; - -/** - * User Entity - */ -export abstract class UserPasswordHistorySqliteEntity - extends CommonSqliteEntity - implements UserPasswordHistoryEntityInterface -{ - /** - * Password hash - */ - @Column({ type: 'text', nullable: true }) - passwordHash!: string; - - /** - * Password salt - */ - @Column({ type: 'text', nullable: true }) - passwordSalt!: string; - - /** - * User ID - */ - @Column({ type: 'uuid' }) - userId!: ReferenceId; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/user-profile/user-profile-postgres.entity.ts b/packages/nestjs-typeorm-ext/src/entities/user-profile/user-profile-postgres.entity.ts deleted file mode 100644 index 65af651e2..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/user-profile/user-profile-postgres.entity.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Column } from 'typeorm'; - -import { UserProfileEntityInterface } from '@concepta/nestjs-common'; - -import { CommonPostgresEntity } from '../common/common-postgres.entity'; - -/** - * User Profile Postgres Entity - */ -export abstract class UserProfilePostgresEntity - extends CommonPostgresEntity - implements UserProfileEntityInterface -{ - /** - * User ID - */ - @Column({ type: 'uuid' }) - userId!: string; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/user-profile/user-profile-sqlite.entity.ts b/packages/nestjs-typeorm-ext/src/entities/user-profile/user-profile-sqlite.entity.ts deleted file mode 100644 index 0d09597e7..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/user-profile/user-profile-sqlite.entity.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Column } from 'typeorm'; - -import { UserProfileEntityInterface } from '@concepta/nestjs-common'; - -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; - -/** - * User Profile Sqlite Entity - */ -export abstract class UserProfileSqliteEntity - extends CommonSqliteEntity - implements UserProfileEntityInterface -{ - @Column({ type: 'uuid' }) - userId!: string; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/user/user-postgres.entity.ts b/packages/nestjs-typeorm-ext/src/entities/user/user-postgres.entity.ts deleted file mode 100644 index 9d0b27796..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/user/user-postgres.entity.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Column } from 'typeorm'; - -import { UserEntityInterface } from '@concepta/nestjs-common'; - -import { CommonPostgresEntity } from '../common/common-postgres.entity'; - -/** - * User Entity - */ -export abstract class UserPostgresEntity - extends CommonPostgresEntity - implements UserEntityInterface -{ - /** - * Email - */ - @Column({ unique: true }) - email!: string; - - /** - * Username - */ - @Column({ unique: true }) - username!: string; - - /** - * Active - */ - @Column({ default: true }) - active!: boolean; - - /** - * Password hash - */ - @Column({ type: 'text', nullable: true }) - passwordHash!: string; - - /** - * Password salt - */ - @Column({ type: 'text', nullable: true }) - passwordSalt!: string; -} diff --git a/packages/nestjs-typeorm-ext/src/entities/user/user-sqlite.entity.ts b/packages/nestjs-typeorm-ext/src/entities/user/user-sqlite.entity.ts deleted file mode 100644 index fd1bab257..000000000 --- a/packages/nestjs-typeorm-ext/src/entities/user/user-sqlite.entity.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Column } from 'typeorm'; - -import { UserEntityInterface } from '@concepta/nestjs-common'; - -import { CommonSqliteEntity } from '../common/common-sqlite.entity'; - -/** - * User Entity - */ -export abstract class UserSqliteEntity - extends CommonSqliteEntity - implements UserEntityInterface -{ - /** - * Email - */ - @Column({ unique: true }) - email!: string; - - /** - * Username - */ - @Column({ unique: true }) - username!: string; - - /** - * Active - */ - @Column({ default: true }) - active!: boolean; - - /** - * Password hash - */ - @Column({ type: 'text', nullable: true }) - passwordHash!: string; - - /** - * Password salt - */ - @Column({ type: 'text', nullable: true }) - passwordSalt!: string; -} diff --git a/packages/nestjs-typeorm-ext/src/index.ts b/packages/nestjs-typeorm-ext/src/index.ts deleted file mode 100644 index 361e5e7b0..000000000 --- a/packages/nestjs-typeorm-ext/src/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -export { TypeOrmExtModule } from './typeorm-ext.module'; -export { TypeOrmExtOptions } from './typeorm-ext.types'; - -export { TypeOrmExtEntityOptionInterface } from './interfaces/typeorm-ext-entity-options.interface'; - -export { TypeOrmRepositoryAdapter } from './repository/typeorm-repository.adapter'; - -// base entities -export { AuditPostgresEntity } from './entities/audit/audit-postgres.entity'; -export { AuditSqlLiteEntity } from './entities/audit/audit-sqlite.entity'; -export { CommonPostgresEntity } from './entities/common/common-postgres.entity'; -export { CommonSqliteEntity } from './entities/common/common-sqlite.entity'; - -// user entities -export { UserPostgresEntity } from './entities/user/user-postgres.entity'; -export { UserSqliteEntity } from './entities/user/user-sqlite.entity'; - -// user password history entities -export { UserPasswordHistoryPostgresEntity } from './entities/user-password-history/user-password-history-postgres.entity'; -export { UserPasswordHistorySqliteEntity } from './entities/user-password-history/user-password-history-sqlite.entity'; - -// user profile entities -export { UserProfilePostgresEntity } from './entities/user-profile/user-profile-postgres.entity'; -export { UserProfileSqliteEntity } from './entities/user-profile/user-profile-sqlite.entity'; - -// org entities -export { OrgPostgresEntity } from './entities/org/org-postgres.entity'; -export { OrgSqliteEntity } from './entities/org/org-sqlite.entity'; -export { OrgMemberPostgresEntity } from './entities/org/org-member-postgres.entity'; -export { OrgMemberSqliteEntity } from './entities/org/org-member-sqlite.entity'; -export { OrgProfilePostgresEntity } from './entities/org/org-profile-postgres.entity'; -export { OrgProfileSqliteEntity } from './entities/org/org-profile-sqlite.entity'; - -// OTP entities -export { OtpPostgresEntity } from './entities/otp/otp-postgres.entity'; -export { OtpSqliteEntity } from './entities/otp/otp-sqlite.entity'; -// role entities -export { RolePostgresEntity } from './entities/role/role-postgres.entity'; -export { RoleSqliteEntity } from './entities/role/role-sqlite.entity'; - -// role assignment entities -export { RoleAssignmentPostgresEntity } from './entities/role/role-assignment-postgres.entity'; -export { RoleAssignmentSqliteEntity } from './entities/role/role-assignment-sqlite.entity'; -// report entities -export { ReportPostgresEntity } from './entities/report/report-postgres.entity'; -export { ReportSqliteEntity } from './entities/report/report-sqlite.entity'; -// federated entities -export { FederatedPostgresEntity } from './entities/federated/federated-postgres.entity'; -export { FederatedSqliteEntity } from './entities/federated/federated-sqlite.entity'; -// cache entities -export { CachePostgresEntity } from './entities/cache/cache-postgres.entity'; -export { CacheSqliteEntity } from './entities/cache/cache-sqlite.entity'; -// invitation entities -export { InvitationPostgresEntity } from './entities/invitation/invitation-postgres.entity'; -export { InvitationSqliteEntity } from './entities/invitation/invitation-sqlite.entity'; -// file entities -export { FilePostgresEntity } from './entities/file/file-postgres.entity'; -export { FileSqliteEntity } from './entities/file/file-sqlite.entity'; diff --git a/packages/nestjs-typeorm-ext/src/interfaces/data-source.interface.ts b/packages/nestjs-typeorm-ext/src/interfaces/data-source.interface.ts deleted file mode 100644 index 3e18006c9..000000000 --- a/packages/nestjs-typeorm-ext/src/interfaces/data-source.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface DataSourceInterface { - driver: { - transactionSupport: string; - options: { - type: string; - }; - }; -} diff --git a/packages/nestjs-typeorm-ext/src/interfaces/typeorm-ext-entity-options.interface.ts b/packages/nestjs-typeorm-ext/src/interfaces/typeorm-ext-entity-options.interface.ts deleted file mode 100644 index 28cf2c617..000000000 --- a/packages/nestjs-typeorm-ext/src/interfaces/typeorm-ext-entity-options.interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { DataSource, Repository } from 'typeorm'; - -import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; - -import { ReferenceIdInterface } from '@concepta/nestjs-common'; - -import { TypeOrmExtDataSourceToken } from '../typeorm-ext.types'; - -export interface TypeOrmExtEntityOptionInterface< - T extends ReferenceIdInterface = ReferenceIdInterface, -> { - entity: EntityClassOrSchema; - repositoryFactory?: (dataSource: DataSource) => Repository; - dataSource?: TypeOrmExtDataSourceToken; -} diff --git a/packages/nestjs-typeorm-ext/src/model/test-model.service.spec.ts b/packages/nestjs-typeorm-ext/src/model/test-model.service.spec.ts deleted file mode 100644 index afc42ecec..000000000 --- a/packages/nestjs-typeorm-ext/src/model/test-model.service.spec.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { randomUUID } from 'crypto'; - -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { - ModelMutateException, - ModelValidationException, - ModelIdNoMatchException, - ModelService, -} from '@concepta/nestjs-common'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { TestModelServiceFixture } from '../__fixtures__/model/test-model.service.fixture'; -import { AppModuleFixture } from '../__fixtures__/repository/app.module.fixture'; -import { TestEntityFixture } from '../__fixtures__/repository/test.entity.fixture'; -import { TestFactoryFixture } from '../__fixtures__/repository/test.factory.fixture'; -import { TestModuleFixture } from '../__fixtures__/repository/test.module.fixture'; - -describe(ModelService, () => { - const WRONG_UUID = '3bfd065e-0c30-11ed-861d-0242ac120002'; - let app: INestApplication; - let testModuleFixture: TestModuleFixture; - let testModelService: TestModelServiceFixture; - let seedingSource: SeedingSource; - let testFactory: TestFactoryFixture; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - - app = moduleFixture.createNestApplication(); - testModuleFixture = moduleFixture.get(TestModuleFixture); - - testModelService = moduleFixture.get( - TestModelServiceFixture, - ); - - seedingSource = new SeedingSource({ - dataSource: app.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - testFactory = new TestFactoryFixture({ - entity: TestEntityFixture, - seedingSource, - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should be loaded', async () => { - expect(testModuleFixture).toBeInstanceOf(TestModuleFixture); - }); - - describe(ModelService.prototype.byId, () => { - it('success', async () => { - const testObject = await testFactory.create(); - const result = await testModelService.byId(testObject.id); - - expect(result).toBeInstanceOf(TestEntityFixture); - expect(result?.version).toBe(testObject.version); - }); - - it('wrong id', async () => { - const result = await testModelService.byId(randomUUID()); - expect(result?.version).toBe(undefined); - }); - }); - - describe(ModelService.prototype.create, () => { - it('success', async () => { - const savedData = await testModelService.create({ - firstName: 'Bob', - }); - - expect(savedData).toBeInstanceOf(TestEntityFixture); - expect(savedData.id.length).toBeGreaterThan(0); - expect(savedData.version).toEqual(1); - }); - - it('exception', async () => { - jest - .spyOn(testModelService['repo'], 'save') - .mockImplementationOnce(() => { - throw Error(); - }); - - const t = async () => { - return testModelService.create({ firstName: 'Bob' }); - }; - - await expect(t).rejects.toThrow(ModelMutateException); - }); - - it('invalid', async () => { - const t = async () => { - return testModelService.create({ firstName: 'B' }); - }; - - await expect(t).rejects.toThrow(ModelValidationException); - }); - }); - - describe(ModelService.prototype.update, () => { - it('success', async () => { - const testObject = await testFactory.create({ firstName: 'Bob' }); - - expect(testObject.firstName).toBe('Bob'); - expect(testObject.version).toBe(1); - - const entity = await testModelService.update({ - id: testObject.id, - firstName: 'Bill', - }); - - expect(entity).toBeInstanceOf(TestEntityFixture); - expect(entity.firstName).toBe('Bill'); - expect(entity.version).toBe(2); - }); - - it('not found', async () => { - const t = async () => { - return testModelService.update({ - id: WRONG_UUID, - }); - }; - - await expect(t()).rejects.toThrow(ModelIdNoMatchException); - }); - - it('found but not valid', async () => { - const testObject = await testFactory.create(); - const t = async () => { - return testModelService.update({ - id: testObject.id, - firstName: 'A', - }); - }; - - await expect(t).rejects.toThrow(ModelValidationException); - }); - - it('found, valid, but exception on save', async () => { - const testObject = await testFactory.create(); - - jest - .spyOn(testModelService['repo'], 'save') - .mockImplementationOnce(() => { - throw new Error(); - }); - - const t = async () => { - return testModelService.update({ - id: testObject.id, - }); - }; - - await expect(t).rejects.toThrow(ModelMutateException); - }); - }); - - describe(ModelService.prototype.replace, () => { - it('success', async () => { - const pastDate = new Date(); - pastDate.setMilliseconds(pastDate.getMilliseconds() - 100); - const testObject = await testFactory.create({ - firstName: 'Bob', - dateCreated: pastDate, - dateUpdated: pastDate, - dateDeleted: null, - version: 5, - }); - - expect(testObject).toBeInstanceOf(TestEntityFixture); - expect(testObject.firstName).toEqual('Bob'); - expect(testObject.version).toEqual(5); - - const entity = await testModelService.replace({ - id: testObject.id, - firstName: 'Bill', - }); - - expect(entity).toBeInstanceOf(TestEntityFixture); - expect(entity.firstName).toEqual('Bill'); - expect(entity.dateCreated).toEqual(testObject.dateCreated); - expect(entity.dateUpdated).not.toEqual(testObject.dateUpdated); - expect(entity.dateDeleted).toEqual(null); - expect(entity.version).toEqual(6); - }); - - it('not found', async () => { - const t = async () => { - return testModelService.replace({ - id: WRONG_UUID, - firstName: 'Bill', - }); - }; - - await expect(t).rejects.toThrow(ModelIdNoMatchException); - }); - - it('found but not valid', async () => { - const testObject = await testFactory.create(); - - const t = async () => { - return testModelService.replace({ - id: testObject.id, - firstName: 'B', - }); - }; - - await expect(t).rejects.toThrow(ModelValidationException); - }); - - it('found, valid, but exception on save', async () => { - const testObject = await testFactory.create(); - - jest - .spyOn(testModelService['repo'], 'save') - .mockImplementationOnce(() => { - throw new Error(); - }); - - const t = async () => { - return testModelService.replace({ - id: testObject.id, - firstName: 'Bill', - }); - }; - - await expect(t).rejects.toThrow(ModelMutateException); - }); - }); - - describe(ModelService.prototype.remove, () => { - it('success', async () => { - const testObject = await testFactory.create(); - - const remove = jest.spyOn(testModelService['repo'], 'remove'); - - await testModelService.remove({ id: testObject.id }); - - expect(remove).toHaveBeenCalledTimes(1); - - const foundObject = await testModelService.byId(testObject.id); - - expect(foundObject).toEqual(null); - }); - - it('id does not match', async () => { - const t = async () => { - return testModelService.remove({ - id: WRONG_UUID, - }); - }; - - await expect(t).rejects.toThrow(ModelIdNoMatchException); - }); - - it('exception', async () => { - const testObject = await testFactory.create(); - - const t = async () => { - return testModelService.remove(testObject); - }; - - jest - .spyOn(testModelService['repo'], 'remove') - .mockImplementationOnce(() => { - throw new Error(); - }); - - await expect(t).rejects.toThrow(ModelMutateException); - }); - }); -}); diff --git a/packages/nestjs-typeorm-ext/src/repository/typeorm-repository.adapter.spec.ts b/packages/nestjs-typeorm-ext/src/repository/typeorm-repository.adapter.spec.ts deleted file mode 100644 index 2fb471c22..000000000 --- a/packages/nestjs-typeorm-ext/src/repository/typeorm-repository.adapter.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { ModelQueryException } from '@concepta/nestjs-common'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { TypeOrmRepositoryAdapter } from './typeorm-repository.adapter'; - -import { AppModuleFixture } from '../__fixtures__/repository/app.module.fixture'; -import { TypeOrmRepositoryAdapterFixture } from '../__fixtures__/repository/services/typeorm-repository.adapter.fixture'; -import { TestEntityFixture } from '../__fixtures__/repository/test.entity.fixture'; -import { TestModuleFixture } from '../__fixtures__/repository/test.module.fixture'; - -describe(TypeOrmRepositoryAdapter, () => { - let app: INestApplication; - let testModuleFixture: TestModuleFixture; - let testService: TypeOrmRepositoryAdapter; - let seedingSource: SeedingSource; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - - app = moduleFixture.createNestApplication(); - testModuleFixture = moduleFixture.get(TestModuleFixture); - - testService = moduleFixture.get( - TypeOrmRepositoryAdapterFixture, - ); - - seedingSource = new SeedingSource({ - dataSource: app.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should be loaded', async () => { - expect(testModuleFixture).toBeInstanceOf(TestModuleFixture); - expect(testService).toBeInstanceOf(TypeOrmRepositoryAdapterFixture); - }); - - describe(TypeOrmRepositoryAdapter.prototype['findOne'], () => { - it('query exception', async () => { - jest.spyOn(testService['repo'], 'findOne').mockImplementationOnce(() => { - throw new Error(); - }); - - await expect(testService['findOne']({})).rejects.toThrow( - ModelQueryException, - ); - }); - }); -}); diff --git a/packages/nestjs-typeorm-ext/src/repository/typeorm-repository.adapter.ts b/packages/nestjs-typeorm-ext/src/repository/typeorm-repository.adapter.ts deleted file mode 100644 index 961158b4d..000000000 --- a/packages/nestjs-typeorm-ext/src/repository/typeorm-repository.adapter.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { - FindManyOptions, - FindOneOptions, - LessThan, - LessThanOrEqual, - MoreThan, - MoreThanOrEqual, - Repository, -} from 'typeorm'; - -import { PlainLiteralObject } from '@nestjs/common'; - -import { - DeepPartial, - RepositoryInternals, - RepositoryInterface, - ModelQueryException, -} from '@concepta/nestjs-common'; - -/** - * Abstract service - */ -export class TypeOrmRepositoryAdapter - implements RepositoryInterface -{ - /** - * Constructor - * - * @param repo - instance of the repo - */ - constructor(public readonly repo: Repository) {} - - /** - * Find wrapper. - * - * @param options - Find many optionsq - */ - async find( - options?: RepositoryInternals.FindManyOptions, - ): Promise { - try { - // type assertion - const cleanOptions: FindManyOptions | undefined = options; - // call the repo find - return this.repo.find(cleanOptions); - } catch (e) { - // fatal orm error - throw new ModelQueryException(this.entityName(), { - originalError: e, - }); - } - } - - /** - * Find One wrapper. - * - * @param options - Find one options - */ - async findOne( - options: RepositoryInternals.FindOneOptions, - ): Promise { - try { - // call the repo find one - return this.repo.findOne(options as FindOneOptions); - } catch (e) { - // fatal orm error - // TODO: remove metadata? - throw new ModelQueryException(this.entityName(), { - originalError: e, - }); - } - } - - /** - * Get the entity name from the repository metadata. - */ - entityName(): string { - return this.repo.metadata?.name || this.repo.metadata?.targetName; - } - - async count( - options?: RepositoryInternals.FindManyOptions, - ): Promise { - return this.repo.count(options as FindManyOptions); - } - - create(entityLike: DeepPartial | never): Entity { - return this.repo.create(entityLike as Entity); - } - - async save>( - entities: T[], - options?: RepositoryInternals.SaveOptions, - ): Promise<(T & Entity)[]>; - async save>( - entity: T, - options?: RepositoryInternals.SaveOptions, - ): Promise; - async save>( - entities: T | T[], - options?: RepositoryInternals.SaveOptions, - ): Promise<(T & Entity) | (T & Entity)[]> { - if (Array.isArray(entities)) { - return this.repo.save(entities, options); - } else { - return this.repo.save(entities, options); - } - } - - async remove(entities: Entity[]): Promise; - async remove(entity: Entity): Promise; - async remove(entity: Entity | Entity[]): Promise { - if (Array.isArray(entity)) { - return this.repo.remove(entity); - } else { - return this.repo.remove(entity); - } - } - - /** - * Soft remove entities (sets delete date) - */ - async softRemove(entities: Entity[]): Promise; - async softRemove(entity: Entity): Promise; - async softRemove(entity: Entity | Entity[]): Promise { - if (Array.isArray(entity)) { - return this.repo.softRemove(entity as DeepPartial[]); - } else { - return this.repo.softRemove(entity as DeepPartial); - } - } - - /** - * Recover soft-deleted entities - */ - async recover(entities: Entity[]): Promise; - async recover(entity: Entity): Promise; - async recover(entity: Entity | Entity[]): Promise { - if (Array.isArray(entity)) { - return this.repo.recover(entity as DeepPartial[]); - } else { - return this.repo.recover(entity as DeepPartial); - } - } - - merge( - mergeIntoEntity: Entity, - ...entityLikes: DeepPartial[] - ): Entity { - return this.repo.merge(mergeIntoEntity, ...entityLikes); - } - - gt(value: T) { - return MoreThan(value); - } - - gte(value: T) { - return MoreThanOrEqual(value); - } - - lt(value: T) { - return LessThan(value); - } - - lte(value: T) { - return LessThanOrEqual(value); - } -} diff --git a/packages/nestjs-typeorm-ext/src/typeorm-ext.constants.ts b/packages/nestjs-typeorm-ext/src/typeorm-ext.constants.ts deleted file mode 100644 index 955ceb12d..000000000 --- a/packages/nestjs-typeorm-ext/src/typeorm-ext.constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * The token to which all type orm extended module options are set. - */ -export const TYPEORM_EXT_MODULE_OPTIONS_TOKEN = - 'TYPEORM_EXT_MODULE_OPTIONS_TOKEN'; - -/** - * The TypeOrm default data source name - */ -export const TYPEORM_EXT_MODULE_DEFAULT_DATA_SOURCE_NAME = 'default'; diff --git a/packages/nestjs-typeorm-ext/src/typeorm-ext.module-definition.ts b/packages/nestjs-typeorm-ext/src/typeorm-ext.module-definition.ts deleted file mode 100644 index 92385af43..000000000 --- a/packages/nestjs-typeorm-ext/src/typeorm-ext.module-definition.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ConfigurableModuleBuilder, DynamicModule } from '@nestjs/common'; - -import { TYPEORM_EXT_MODULE_OPTIONS_TOKEN } from './typeorm-ext.constants'; -import { TypeOrmExtOptions as xTypeOrmExtOptions } from './typeorm-ext.types'; - -export const { - ConfigurableModuleClass: TypeOrmExtModuleClass, - OPTIONS_TYPE: TYPEORM_EXT_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: TYPEORM_EXT_ASYNC_OPTIONS_TYPE, -} = new ConfigurableModuleBuilder({ - moduleName: 'TypeOrmExt', - optionsInjectionToken: TYPEORM_EXT_MODULE_OPTIONS_TOKEN, -}) - .setExtras({}, (definition: DynamicModule) => { - return { - ...definition, - global: true, - exports: [TYPEORM_EXT_MODULE_OPTIONS_TOKEN], - }; - }) - .setClassMethodName('forRoot') - .build(); - -export type TypeOrmExtOptions = typeof TYPEORM_EXT_OPTIONS_TYPE; -export type TypeOrmExtAsyncOptions = typeof TYPEORM_EXT_ASYNC_OPTIONS_TYPE; diff --git a/packages/nestjs-typeorm-ext/src/typeorm-ext.module.async.spec.ts b/packages/nestjs-typeorm-ext/src/typeorm-ext.module.async.spec.ts deleted file mode 100644 index c71d8dc20..000000000 --- a/packages/nestjs-typeorm-ext/src/typeorm-ext.module.async.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { getDynamicRepositoryToken } from '@concepta/nestjs-common'; - -import { TypeOrmRepositoryAdapter } from './repository/typeorm-repository.adapter'; -import { TypeOrmExtModule } from './typeorm-ext.module'; - -import { PhotoEntityFixture } from './__fixtures__/photo/photo.entity.fixture'; -import { PhotoModuleFixture } from './__fixtures__/photo/photo.module.fixture'; -import { PhotoRepositoryFixtureInterface } from './__fixtures__/photo/photo.repository.fixture'; - -describe('AppModule', () => { - let photoModule: PhotoModuleFixture; - let photoCustomRepo: PhotoRepositoryFixtureInterface; - - beforeEach(async () => { - const testModule: TestingModule = await Test.createTestingModule({ - imports: [ - TypeOrmExtModule.forRoot({ - type: 'sqlite', - database: ':memory:', - entities: [PhotoEntityFixture], - }), - PhotoModuleFixture, - ], - }).compile(); - - photoModule = testModule.get(PhotoModuleFixture); - photoCustomRepo = testModule.get(getDynamicRepositoryToken('photo')); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('module', () => { - it('should be loaded', async () => { - expect(photoModule).toBeInstanceOf(PhotoModuleFixture); - expect(photoCustomRepo).toBeInstanceOf(TypeOrmRepositoryAdapter); - }); - - it.skip('should use custom repository', async () => { - expect(photoCustomRepo['customMethod']).toBeInstanceOf(Function); - }); - }); -}); diff --git a/packages/nestjs-typeorm-ext/src/typeorm-ext.module.sync.spec.ts b/packages/nestjs-typeorm-ext/src/typeorm-ext.module.sync.spec.ts deleted file mode 100644 index c40f95a7b..000000000 --- a/packages/nestjs-typeorm-ext/src/typeorm-ext.module.sync.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { getDynamicRepositoryToken } from '@concepta/nestjs-common'; - -import { TypeOrmRepositoryAdapter } from './repository/typeorm-repository.adapter'; -import { TypeOrmExtModule } from './typeorm-ext.module'; - -import { PhotoEntityFixture } from './__fixtures__/photo/photo.entity.fixture'; -import { PhotoModuleFixture } from './__fixtures__/photo/photo.module.fixture'; -import { PhotoRepositoryFixtureInterface } from './__fixtures__/photo/photo.repository.fixture'; - -describe('AppModule', () => { - let photoModule: PhotoModuleFixture; - let photoCustomRepo: PhotoRepositoryFixtureInterface; - - beforeEach(async () => { - const testModule: TestingModule = await Test.createTestingModule({ - imports: [ - TypeOrmExtModule.forRoot({ - type: 'sqlite', - database: ':memory:', - entities: [PhotoEntityFixture], - }), - PhotoModuleFixture, - ], - }).compile(); - - photoModule = testModule.get(PhotoModuleFixture); - photoCustomRepo = testModule.get(getDynamicRepositoryToken('photo')); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('module', () => { - it('should be loaded', async () => { - expect(photoModule).toBeInstanceOf(PhotoModuleFixture); - expect(photoCustomRepo).toBeInstanceOf(TypeOrmRepositoryAdapter); - }); - it.skip('should use custom repository', async () => { - expect(photoCustomRepo['customMethod']).toBeInstanceOf(Function); - }); - }); -}); diff --git a/packages/nestjs-typeorm-ext/src/typeorm-ext.module.ts b/packages/nestjs-typeorm-ext/src/typeorm-ext.module.ts deleted file mode 100644 index df6671c09..000000000 --- a/packages/nestjs-typeorm-ext/src/typeorm-ext.module.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { DynamicModule, Global, Module, Provider } from '@nestjs/common'; -import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; - -import { TypeOrmExtEntityOptionInterface } from './interfaces/typeorm-ext-entity-options.interface'; -import { - TYPEORM_EXT_MODULE_DEFAULT_DATA_SOURCE_NAME, - TYPEORM_EXT_MODULE_OPTIONS_TOKEN, -} from './typeorm-ext.constants'; -import { - TypeOrmExtModuleClass, - TypeOrmExtOptions, - TypeOrmExtAsyncOptions, -} from './typeorm-ext.module-definition'; -import { TypeOrmExtDataSourceToken } from './typeorm-ext.types'; -import { createDynamicRepositoryProvider } from './utils/create-dynamic-repository-provider'; -import { resolveDataSourceName } from './utils/resolve-data-source-name'; - -@Global() -@Module({}) -export class TypeOrmExtModule extends TypeOrmExtModuleClass { - static forRoot(options: TypeOrmExtOptions) { - const module = super.forRoot(options); - - if (!module.imports) { - module.imports = []; - } - - module.imports.push( - TypeOrmModule.forRootAsync({ - name: options?.name - ? options.name - : TYPEORM_EXT_MODULE_DEFAULT_DATA_SOURCE_NAME, - inject: [TYPEORM_EXT_MODULE_OPTIONS_TOKEN], - useFactory: async (options: TypeOrmModuleOptions) => options, - }), - ); - - return module; - } - - static forRootAsync(options: TypeOrmExtAsyncOptions) { - const module = super.forRootAsync(options); - - if (!module.imports) { - module.imports = []; - } - - module.imports.push( - TypeOrmModule.forRootAsync({ - inject: [TYPEORM_EXT_MODULE_OPTIONS_TOKEN], - useFactory: async (options: TypeOrmModuleOptions) => options, - }), - ); - - return module; - } - - static forFeature>( - entityOptions: T, - ): DynamicModule { - const dataSources: Record = {}; - - const entitiesByDS: Record = {}; - - const imports: DynamicModule[] = []; - - const providers: Provider[] = []; - - for (const entityKey in entityOptions) { - const { - entity, - repositoryFactory, - dataSource = TYPEORM_EXT_MODULE_DEFAULT_DATA_SOURCE_NAME, - } = entityOptions[entityKey]; - - const dsName = resolveDataSourceName(dataSource); - - if (dsName in dataSources === false) { - dataSources[dsName] = dataSource; - } - - if (dsName in entitiesByDS === false) { - entitiesByDS[dsName] = []; - } - - entitiesByDS[dsName].push(entity); - - providers.push( - createDynamicRepositoryProvider( - entityKey, - entity, - dataSource, - repositoryFactory, - ), - ); - } - - for (const dsName in entitiesByDS) { - imports.push( - TypeOrmModule.forFeature(entitiesByDS[dsName], dataSources[dsName]), - ); - } - - return { - module: TypeOrmExtModule, - imports, - providers, - exports: providers, - }; - } -} diff --git a/packages/nestjs-typeorm-ext/src/typeorm-ext.types.ts b/packages/nestjs-typeorm-ext/src/typeorm-ext.types.ts deleted file mode 100644 index 5b46106a3..000000000 --- a/packages/nestjs-typeorm-ext/src/typeorm-ext.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { DataSource, DataSourceOptions } from 'typeorm'; - -import { TypeOrmModuleOptions } from '@nestjs/typeorm'; - -export type TypeOrmExtOptions = TypeOrmModuleOptions; - -export type TypeOrmExtDataSourceToken = DataSource | DataSourceOptions | string; diff --git a/packages/nestjs-typeorm-ext/src/utils/create-dynamic-repository-provider.ts b/packages/nestjs-typeorm-ext/src/utils/create-dynamic-repository-provider.ts deleted file mode 100644 index 011322ca9..000000000 --- a/packages/nestjs-typeorm-ext/src/utils/create-dynamic-repository-provider.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { DataSource } from 'typeorm'; - -import { Provider } from '@nestjs/common'; -import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm'; -import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; - -import { getDynamicRepositoryToken } from '@concepta/nestjs-common'; - -import { TypeOrmExtEntityOptionInterface } from '../interfaces/typeorm-ext-entity-options.interface'; -import { TypeOrmRepositoryAdapter } from '../repository/typeorm-repository.adapter'; -import { TYPEORM_EXT_MODULE_DEFAULT_DATA_SOURCE_NAME } from '../typeorm-ext.constants'; -import { TypeOrmExtDataSourceToken } from '../typeorm-ext.types'; - -/** - * Create dynamic repository provider function - * - * @param key - repository key - * @param entity - the entity - * @param dataSource - the data source - * @param repositoryFactory - the repository - * @returns Repository provider - */ -export function createDynamicRepositoryProvider( - key: string, - entity: EntityClassOrSchema, - dataSource: TypeOrmExtDataSourceToken = TYPEORM_EXT_MODULE_DEFAULT_DATA_SOURCE_NAME, - repositoryFactory?: TypeOrmExtEntityOptionInterface['repositoryFactory'], -): Provider { - if (repositoryFactory) { - return { - provide: getDynamicRepositoryToken(key), - inject: [getDataSourceToken(dataSource)], - useFactory: (dataSource: DataSource) => { - return new TypeOrmRepositoryAdapter(repositoryFactory(dataSource)); - }, - }; - } else { - return { - provide: getDynamicRepositoryToken(key), - inject: [getRepositoryToken(entity, dataSource)], - useFactory: (repoInstance) => { - return new TypeOrmRepositoryAdapter(repoInstance); - }, - }; - } -} diff --git a/packages/nestjs-typeorm-ext/src/utils/resolve-data-source-name.ts b/packages/nestjs-typeorm-ext/src/utils/resolve-data-source-name.ts deleted file mode 100644 index b467cf253..000000000 --- a/packages/nestjs-typeorm-ext/src/utils/resolve-data-source-name.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TYPEORM_EXT_MODULE_DEFAULT_DATA_SOURCE_NAME } from '../typeorm-ext.constants'; -import { TypeOrmExtDataSourceToken } from '../typeorm-ext.types'; - -export function resolveDataSourceName( - dataSource: TypeOrmExtDataSourceToken, -): string { - return typeof dataSource === 'string' - ? dataSource - : (dataSource.name ?? TYPEORM_EXT_MODULE_DEFAULT_DATA_SOURCE_NAME); -} diff --git a/packages/nestjs-typeorm-ext/tsconfig.json b/packages/nestjs-typeorm-ext/tsconfig.json deleted file mode 100644 index 5501fce2b..000000000 --- a/packages/nestjs-typeorm-ext/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": [ - "./node_modules/@types", - "../../node_modules/@types" - ] - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/nestjs-typeorm-ext/typedoc.json b/packages/nestjs-typeorm-ext/typedoc.json deleted file mode 100644 index 944fda5ad..000000000 --- a/packages/nestjs-typeorm-ext/typedoc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "entryPoints": ["src/index.ts"] -} \ No newline at end of file diff --git a/packages/nestjs-user/README.md b/packages/nestjs-user/README.md index 28d0c865b..2cb81874a 100644 --- a/packages/nestjs-user/README.md +++ b/packages/nestjs-user/README.md @@ -1,55 +1,567 @@ -# Rockets NestJS User +# @concepta/nestjs-user -A module for managing a basic User entity, including controller with full -CRUD, DTOs, sample data factory and seeder. +User management module for NestJS using DDD/CQRS. Provides user CRUD +and credential management with password policies (reuse prevention, current +password validation). ## Project [![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-user)](https://www.npmjs.com/package/@concepta/nestjs-user) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-user)](https://www.npmjs.com/package/@concepta/nestjs-user) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/nestjs-user)](https://www.npmjs.com/package/@concepta/nestjs-user) [![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) [![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) -[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/nestjs-modules/peer/@nestjs/common/feature/version-8?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-user%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +## Table of Contents + +- [Installation](#installation) +- [Module Registration](#module-registration) +- [Architecture Overview](#architecture-overview) +- [Domain Aggregates](#domain-aggregates) +- [Commands](#commands) +- [Queries](#queries) +- [Domain Events](#domain-events) +- [Password Management](#password-management) +- [CRUD Gateway (Optional)](#crud-gateway-optional) +- [Schemas](#schemas) +- [Exceptions](#exceptions) +- [Environment Variables](#environment-variables) +- [Seeding (Optional)](#seeding-optional) +- [Entry Points](#entry-points) ## Installation -`yarn add @concepta/nestjs-user` +```sh +yarn add @concepta/nestjs-user @nestjs/common @nestjs/config @nestjs/core +``` + +This package is ESM-only and requires Node.js >= 22.12 and NestJS 12. + +### Dependencies + +| Package | Notes | +| --- | --- | +| `@concepta/nestjs-core` | Core interfaces, event context, and utilities | +| `@concepta/nestjs-repository` | Repository abstraction and transaction scope | +| `@concepta/nestjs-password` | Password hashing and validation | +| `zod` | Schema validation and serialization (Standard Schema) | + +### Peer Dependencies -## Usage +| Package | Required | Notes | +| --- | --- | --- | +| `@nestjs/common` | Yes | NestJS core — install explicitly, no longer bundled | +| `@nestjs/config` | Yes | Module option registration | +| `@nestjs/core` | Yes | Module reference and reflection — install explicitly | +| `@nestjs/cqrs` | No | Optional peer — required in practice for the CQRS buses | +| `typeorm` | No | Only when using TypeORM repository driver | +| `@concepta/nestjs-crud` | Yes | The main entry imports `paginatedSchema` from it | +| `@concepta/typeorm-seeding` | No | Only when using database seeding | +| `@faker-js/faker` | No | Only when using the seed factory | + +## Module Registration + +### forRoot / forRootAsync + +Global registration. Required once per application. ```ts -// ... -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; +import { + CreatePasswordCommand, + PasswordModule, + ValidateCurrentPasswordCommand, + ValidatePasswordHistoryCommand, +} from '@concepta/nestjs-password'; import { UserModule } from '@concepta/nestjs-user'; -import { CrudModule } from '@concepta/nestjs-crud'; @Module({ imports: [ - TypeOrmExtModule.forRoot({ - type: 'postgres', - url: 'postgres://user:pass@localhost:5432/postgres', + TypeOrmModule.forRoot({ /* ... */ }), + RepositoryModule.forRoot({}), + PasswordModule.forRoot({}), + + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: 'user', entity: UserEntity }, + { key: 'user-credentials', entity: UserCredentialEntity }, + ], + }), + + UserModule.forRoot({ + entities: { + user: 'user', + credentials: 'user-credentials', + }, + ports: { + password: { + createCommand: CreatePasswordCommand, + validateCurrentCommand: ValidateCurrentPasswordCommand, + validateHistoryCommand: ValidatePasswordHistoryCommand, + }, + }, + settings: { + password: { + reuseAfterDays: 730, + requireCurrent: true, + }, + }, }), - CrudModule.forRoot({}), - UserModule.forRoot({}), ], }) export class AppModule {} ``` -## Configuration +### register / registerAsync + +Non-global variants of `forRoot`. Identical options, scoped to the importing +module. + +### Options + +`forRoot()` and `registerAsync()` accept `UserOptionsInterface` merged with +`UserExtrasInterface` (extras are passed to `setExtras` on the +`ConfigurableModuleBuilder`): + +```ts +interface UserExtrasInterface { + global?: boolean; + providers?: Provider[]; + entities: { + user: string; // Entity key for users (required) + credentials?: string; // Entity key for credentials (optional) + }; + repositories?: { + user?: Type; + userCredentials?: Type; + }; +} + +interface UserOptionsInterface { + settings?: UserSettingsInterface; + ports?: { + password?: UserPasswordPortSettings; + }; +} + +interface UserSettingsInterface { + password?: { + reuseAfterDays?: number; // Days before password reuse (default: 730) + requireCurrent?: boolean; // Require current password on update (default: false) + }; +} + +interface UserPasswordPortSettings { + createCommand: Type; + validateCurrentCommand: Type; + validateHistoryCommand?: Type; +} +``` + +The `ports.password` commands are dispatched by `UserPasswordPort` (exported +from the main entry along with `CreatePasswordCommandInterface`, +`ValidateCurrentPasswordCommandInterface`, and +`ValidatePasswordHistoryCommandInterface`) to hash and validate passwords. +`@concepta/nestjs-password` ships compatible commands +(`CreatePasswordCommand`, `ValidateCurrentPasswordCommand`, +`ValidatePasswordHistoryCommand`), or you can supply your own implementations +of the command interfaces. When `validateHistoryCommand` is omitted, history +validation always passes. + +When `entities.credentials` is omitted, credential-related providers +(`UserCredentialsService`, password policy, credential command handlers) +are not registered. When `entities.credentials` IS configured, +`ports.password` is required — the module throws at bootstrap +(`UserModule: ports.password is required when credentials entity is +configured`) if it is missing. + +## Architecture Overview + +```text +Gateway (HTTP) + | +Application (Commands / Queries / Listeners) + | +Domain (User + UserCredentials aggregates, Events, Services, Policies) + | +Infrastructure (Repositories, Mappers, Schemas, Config) +``` + +- **Domain** -- `User` aggregate extending `DomainAggregate`, + `UserCredentials` aggregate extending + `DomainAggregate`, domain events, + `UserCredentialsService`, `UserPasswordPolicy` +- **Application** -- 7 commands and 4 queries dispatched via `@nestjs/cqrs` +- **Infrastructure** -- `UserRepository` and `UserCredentialsRepository` with + ctx-first signatures, `UserMapper` and `UserCredentialsMapper` for + entity-to-aggregate conversion (DI-injected), Zod schemas, config +- **Gateway** -- HTTP request handlers bridging `@concepta/nestjs-crud` + to domain commands (optional) + +## Domain Aggregates + +### User + +Extends `DomainAggregate`. + +| Property | Type | +| --- | --- | +| `id` | `string` | +| `email` | `string` | +| `username` | `string` | +| `active` | `boolean` | +| `version` | `number` | +| `meta` | `AggregateMetaInterface` (dateCreated, dateUpdated, dateDeleted) | + +| Method | Description | Event | +| --- | --- | --- | +| `User.create(ctx, props)` | Create with generated UUID | `UserCreatedEvent` | +| `User.createWithId(ctx, id, props)` | Create with explicit ID | `UserCreatedEvent` | +| `update(ctx, dto)` | Partial update (email, active) | `UserUpdatedEvent` | +| `remove(ctx)` | Mark for removal | `UserRemovedEvent` | +| `toPlain()` | Returns `{ id, version, ...props, ...meta }` | -- | + +Reconstitution from a database entity is handled by `UserMapper`. + +### UserCredentials + +Extends `DomainAggregate`. + +| Property | Type | +| --- | --- | +| `id` | `string` | +| `userId` | `string` | +| `passwordHash` | `string \| null` | +| `passwordSalt` | `string \| null` | +| `active` | `boolean` | +| `validFrom` | `Date` | +| `validTo` | `Date \| null` | +| `version` | `number` | +| `meta` | `AggregateMetaInterface` | + +| Method | Description | Event | +| --- | --- | --- | +| `UserCredentials.create(ctx, props)` | Create credentials | `UserCredentialsCreatedEvent` | +| `deactivate(ctx)` | Deactivate and set validTo | `UserCredentialsDeactivatedEvent` | +| `toPlain()` | Returns `{ id, version, ...props, ...meta }` | -- | + +Reconstitution from a database entity is handled by `UserCredentialsMapper`. + +Credential events exclude `passwordHash` and `passwordSalt` from their +payloads for security. + +## Commands + +All commands execute within a `TransactionScope`. Domain events are committed +on transaction success and uncommitted on rollback. + +### User Commands + +| Command | Input | Returns | Description | +| --- | --- | --- | --- | +| `CreateUserCommand` | `ctx, dto` | `User` | Create a new user (auto-creates credentials if password provided) | +| `UpdateUserCommand` | `ctx, id, dto` | `User` | Partial update (email, active) | +| `RemoveUserCommand` | `ctx, id` | `void` | Hard delete | + +### Password Commands + +| Command | Input | Returns | Description | +| --- | --- | --- | --- | +| `SetUserPasswordCommand` | `ctx, userId, password` | `void` | Set initial password (fails if active credentials exist) | +| `UpdateUserPasswordCommand` | `ctx, userId, passwordDto` | `void` | Update password with policy enforcement | +| `CreateUserCredentialCommand` | `ctx, userId, password` | `UserCredentials` | Create credentials for a user (handled by `CreateUserCredentialHandler`) | +| `UpdateUserCredentialCommand` | `ctx, userId, passwordDto` | `void` | Rotate credentials with policy enforcement (handled by `UpdateUserCredentialHandler`) | + +### Dispatching a Command + +```ts +import { CommandBus } from '@nestjs/cqrs'; +import { CreateUserCommand, User } from '@concepta/nestjs-user'; + +const user = await this.commandBus.execute( + new CreateUserCommand(ctx, { + email: 'alice@example.com', + username: 'alice', + active: true, + }), +); +``` -- [Seeding](#seeding) - - [ENV](#env) +## Queries -### Seeding +| Query | Input | Returns | Description | +| --- | --- | --- | --- | +| `GetUserQuery` | `ctx, id` | `User` | Get by ID | +| `GetUserByEmailQuery` | `ctx, email` | `User \| null` | Find by email | +| `GetUserByUsernameQuery` | `ctx, username` | `User \| null` | Find by username | +| `GetUserBySubjectQuery` | `ctx, subject` | `User \| null` | Find by subject | -Configurations specific to (optional) database seeding. +## Domain Events + +| Event | Payload | Emitted by | +| --- | --- | --- | +| `UserCreatedEvent` | `eventContext, user` | `User.create` | +| `UserUpdatedEvent` | `eventContext, user` | `User.update` | +| `UserRemovedEvent` | `eventContext, user` | `User.remove` | +| `UserCredentialsCreatedEvent` | `eventContext, credentials` (no password fields) | `UserCredentials.create` | +| `UserCredentialsDeactivatedEvent` | `eventContext, credentials` (no password fields) | `UserCredentials.deactivate` | + +Events are published after the transaction commits. + +## Password Management + +### UserCredentialsService + +The service orchestrates credential lifecycle within transactions: + +- **`setPassword(ctx, userId, password)`** -- Creates initial credentials. + Fails with `UserCredentialsAlreadyExistException` if active credentials + exist. +- **`updatePassword(ctx, userId, passwordDto)`** -- Deactivates current + credentials and creates new ones. Enforces password policy. + +### UserPasswordPolicy + +Configurable via settings or environment variables: + +| Setting | Default | Description | +| --- | --- | --- | +| `reuseAfterDays` | `730` | Days before a password can be reused. `0` disables. | +| `requireCurrent` | `false` | Require current password when updating. | + +When `reuseAfterDays > 0`, the service checks credential history within the +lookback window and throws `UserPasswordHistoryViolationException` if the new +password matches a recent one. + +When `requireCurrent` is true, the service validates the provided current +password against the active credentials and throws +`UserPasswordCurrentInvalidException` on mismatch. + +## CRUD Gateway (Optional) + +The module exports request classes and request handlers that bridge HTTP +operations to the CQRS bus. Wire them into a controller via +`CrudModule.forFeature()` from `@concepta/nestjs-crud`. + +```ts +import { + CreateUserRequest, + CreateUserRequestHandler, + UpdateUserRequest, + UpdateUserRequestHandler, + DeleteUserRequest, + DeleteUserRequestHandler, + UpdateUserPasswordRequest, + UpdateUserPasswordRequestHandler, + ListUsersRequest, + ListUsersRequestHandler, + ReadUserRequest, + ReadUserRequestHandler, +} from '@concepta/nestjs-user/optional/crud'; +``` + +### Available Request/Handler Pairs + +| Operation | Request | Handler | +| --- | --- | --- | +| List | `ListUsersRequest` | `ListUsersRequestHandler` | +| Read | `ReadUserRequest` | `ReadUserRequestHandler` | +| Create | `CreateUserRequest` | `CreateUserRequestHandler` | +| Update | `UpdateUserRequest` | `UpdateUserRequestHandler` | +| Delete | `DeleteUserRequest` | `DeleteUserRequestHandler` | +| Update Password | `UpdateUserPasswordRequest` | `UpdateUserPasswordRequestHandler` | + +### Wiring Example + +Schemas are passed to `CrudModule.forFeature()` for request validation and +response serialization: + +```ts +import { Module } from '@nestjs/common'; +import { Operation } from '@concepta/nestjs-core'; +import { CrudCqrsResolver, CrudModule } from '@concepta/nestjs-crud'; +import { + UserInterface, + userCreateSchema, + userUpdateSchema, + userPasswordUpdateSchema, + userSchema, + userPaginatedSchema, +} from '@concepta/nestjs-user'; +import { + CreateUserRequest, + CreateUserRequestHandler, + UpdateUserRequest, + UpdateUserRequestHandler, + DeleteUserRequest, + DeleteUserRequestHandler, + UpdateUserPasswordRequest, + UpdateUserPasswordRequestHandler, + ListUsersRequest, + ListUsersRequestHandler, + ReadUserRequest, + ReadUserRequestHandler, +} from '@concepta/nestjs-user/optional/crud'; + +@Module({ + imports: [ + // ... CoreModule, CqrsModule, RepositoryModule, PasswordModule, + // UserModule, CrudModule.forRoot({ defaultResolver: CrudCqrsResolver }) + + CrudModule.forFeature({ + crud: { + controller: { + entity: 'user', + path: 'user', + resolver: CrudCqrsResolver, + transactional: true, + request: { body: userCreateSchema }, + response: { + resource: userSchema, + paginated: userPaginatedSchema, + }, + }, + operations: [ + { + operation: Operation.List, + query: ListUsersRequest, + queryHandler: ListUsersRequestHandler, + }, + { + operation: Operation.Read, + query: ReadUserRequest, + queryHandler: ReadUserRequestHandler, + }, + { + operation: Operation.Create, + request: { body: userCreateSchema }, + command: CreateUserRequest, + commandHandler: CreateUserRequestHandler, + }, + { + operation: Operation.Update, + request: { body: userUpdateSchema }, + command: UpdateUserRequest, + commandHandler: UpdateUserRequestHandler, + }, + { + operation: Operation.Delete, + command: DeleteUserRequest, + commandHandler: DeleteUserRequestHandler, + }, + ], + }, + }), + + // Password update controller (PATCH /password/:id) + CrudModule.forFeature({ + crud: { + controller: { + entity: 'user', + path: 'password', + resolver: CrudCqrsResolver, + transactional: true, + request: { body: userPasswordUpdateSchema }, + response: { resource: userSchema }, + }, + operations: [ + { + operation: Operation.Update, + request: { body: userPasswordUpdateSchema }, + command: UpdateUserPasswordRequest, + commandHandler: UpdateUserPasswordRequestHandler, + }, + ], + }, + }), + ], +}) +export class AppModule {} +``` + +Builder-generated controllers derive request body validation from +`operations[].request.body` automatically. If you write a `@CrudController` +class by hand instead, supply the schema either on the operation decorator's +`request.body` or explicitly via `@CrudBody({ schema })` — the validation +pipe resolves the explicit schema first, then the operation's own +`request.body`. + +## Schemas + +All schemas are Zod v4 objects (Standard Schema compatible), replacing the +legacy class-validator DTO classes. + +### Core Schemas + +| Schema | Entry | Fields | +| --- | --- | --- | +| `userSchema` | main | `id`, `email`, `username`, `active`, `version`, audit fields (named OpenAPI component `User`) | +| `userCreateSchema` | main | `username`, `email` (validated email), optional `active`, optional `password` (plaintext, min 8 chars) | +| `userUpdateSchema` | main | optional `email` (validated email), optional `active` | +| `userPasswordSchema` | main | `password` (min 8 chars) | +| `userPasswordUpdateSchema` | main | `password` (min 8 chars), optional `passwordCurrent` | +| `userPasswordHashSchema` | main | `passwordHash` (kept for API parity; not wired to any CRUD operation) | +| `userPaginatedSchema` | main (also re-exported from `optional/crud`) | Paginated user list response (named OpenAPI component `UserPaginated`) | +| `userCreateBatchSchema` | `optional/crud` | Batch create request (`bulk` array of `userCreateSchema`) | + +Notes: + +- `userCreateSchema` accepts a plaintext `password` — `passwordHash` and + `passwordSalt` are NOT public input anymore. This is a deliberate bug fix: + the legacy `UserCreateDto` exposed `passwordHash` (never a valid external + input — hashes are always computed internally via `UserPasswordPort`) and + silently stripped `password`, so creating a user through the HTTP CRUD + endpoint never actually set a password. With the schema, `password` works + end-to-end. +- `userUpdateSchema` has no `id` field — the route param is authoritative + (the update handler reads `id` from `context.params.id`, never from the + body). + +## Exceptions + +| Exception | HTTP Status | Error Code | +| --- | --- | --- | +| `UserException` | -- | `USER_ERROR` | +| `UserNotFoundException` | 404 | `USER_NOT_FOUND_ERROR` | +| `UserCredentialsAlreadyExistException` | 409 | `USER_CREDENTIALS_ALREADY_EXIST` | +| `UserPasswordCurrentInvalidException` | 400 | `USER_PASSWORD_CURRENT_INVALID` | +| `UserPasswordHistoryViolationException` | 400 | `USER_PASSWORD_HISTORY_VIOLATION` | + +All exceptions extend `UserException`, which extends `RuntimeException` from +`@concepta/nestjs-core`. `RuntimeException` extends NestJS's `HttpException`, +so no exception filter registration is needed — errors serialize over the +wire as `{ statusCode, message, errorCode, error? }` (no `timestamp`). + +## Environment Variables + +| Variable | Default | Description | +| --- | --- | --- | +| `USER_PASSWORD_REUSE_AFTER_DAYS` | `730` | Days before password reuse allowed | +| `USER_PASSWORD_REQUIRE_CURRENT` | `false` | Require current password on update | + +## Seeding (Optional) + +When `@concepta/typeorm-seeding` and `@faker-js/faker` are installed, a +`UserFactory` is available for generating seed data. + +```ts +import { UserFactory } from '@concepta/nestjs-user/optional/seeding'; +``` -#### ENV +| Variable | Default | Description | +| --- | --- | --- | +| `USER_MODULE_SEEDER_AMOUNT` | `50` | Number of additional users to create | +| `USER_MODULE_SEEDER_SUPERADMIN_USERNAME` | `superadmin` | Super admin username | -Configurations available via environment. +## Entry Points -| Variable | Type | Default | | -| ---------------------------------------- | ---------- | -------------- | ------------------------------------ | -| `USER_MODULE_SEEDER_AMOUNT` | `` | `50` | number of additional users to create | -| `USER_MODULE_SEEDER_SUPERADMIN_USERNAME` | `` | `'superadmin'` | super admin username | +| Import Path | Contents | +| --- | --- | +| `@concepta/nestjs-user` | Module, aggregates, commands, queries, events, handlers, schemas, repositories, ports, exceptions, domain interfaces | +| `@concepta/nestjs-user/optional/crud` | CRUD request/handler classes, `userPaginatedSchema`, `userCreateBatchSchema` | +| `@concepta/nestjs-user/optional/typeorm` | `UserSqliteEntity`, `UserPostgresEntity`, `UserCredentialSqliteEntity`, `UserCredentialPostgresEntity` | +| `@concepta/nestjs-user/optional/seeding` | `UserFactory`, `UserCredentialFactory`, `UserSeeder` | diff --git a/packages/nestjs-user/package.json b/packages/nestjs-user/package.json index 7b7780a9c..dd1b30e44 100644 --- a/packages/nestjs-user/package.json +++ b/packages/nestjs-user/package.json @@ -1,42 +1,71 @@ { "name": "@concepta/nestjs-user", - "version": "7.0.0-alpha.10", + "version": "8.0.0-alpha.10", "description": "Rockets NestJS User", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./optional/crud": { + "types": "./dist/optional-crud.d.ts", + "default": "./dist/optional-crud.js" + }, + "./optional/seeding": { + "types": "./dist/optional-seeding.d.ts", + "default": "./dist/optional-seeding.js" + }, + "./optional/typeorm": { + "types": "./dist/optional-typeorm.d.ts", + "default": "./dist/optional-typeorm.js" + } + }, "license": "BSD-3-Clause", "publishConfig": { "access": "public" }, "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + "dist/**/!(*.spec|*.e2e-spec|*.fixture|*.mock).{js,d.ts}", + "!dist/**/__tests__/**", + "!dist/**/__fixtures__/**" ], "dependencies": { - "@concepta/nestjs-access-control": "^7.0.0-alpha.10", - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@concepta/nestjs-event": "^7.0.0-alpha.10", - "@concepta/nestjs-password": "^7.0.0-alpha.10", - "@nestjs/common": "^11.1.9", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.1.9", - "@nestjs/swagger": "^11.2.2" + "@concepta/nestjs-core": "^8.0.0-alpha.10", + "@concepta/nestjs-password": "8.0.0-alpha.10", + "@concepta/nestjs-repository": "8.0.0-alpha.10", + "zod": "^4.4.3" }, "devDependencies": { - "@concepta/nestjs-auth-jwt": "^7.0.0-alpha.10", - "@concepta/nestjs-authentication": "^7.0.0-alpha.10", - "@concepta/nestjs-crud": "^7.0.0-alpha.10", - "@concepta/nestjs-jwt": "^7.0.0-alpha.10", - "@concepta/nestjs-typeorm-ext": "^7.0.0-alpha.10", + "@concepta/nestjs-crud": "8.0.0-alpha.10", + "@concepta/nestjs-repository-typeorm": "8.0.0-alpha.10", "@concepta/typeorm-seeding": "^4.0.0", "@faker-js/faker": "^8.4.1", - "@nestjs/testing": "^11.1.9", - "@nestjs/typeorm": "^11.0.0", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", + "@nestjs/swagger": "^12.0.1", + "@nestjs/testing": "^12.0.1", + "@nestjs/typeorm": "^12.0.1", "accesscontrol": "^2.2.1", - "supertest": "^6.3.4" + "rxjs": "^7.8.1", + "supertest": "^6.3.4", + "vitest-mock-extended": "^4.0.0" }, "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", + "@concepta/nestjs-crud": "8.0.0-alpha.10", + "@nestjs/common": "^12.0.1", + "@nestjs/config": "^12.0.0", + "@nestjs/core": "^12.0.1", + "@nestjs/cqrs": "^12.0.0", "typeorm": "^0.3.0" + }, + "peerDependenciesMeta": { + "@nestjs/cqrs": { + "optional": true + } } } diff --git a/packages/nestjs-user/src/__fixtures__/app.module.crud.fixture.ts b/packages/nestjs-user/src/__fixtures__/app.module.crud.fixture.ts deleted file mode 100644 index 1bda76558..000000000 --- a/packages/nestjs-user/src/__fixtures__/app.module.crud.fixture.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { AccessControl } from 'accesscontrol'; - -import { Global, Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { AccessControlModule } from '@concepta/nestjs-access-control'; -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { AuthenticationModule } from '@concepta/nestjs-authentication'; -import { CrudModule } from '@concepta/nestjs-crud'; -import { EventModule } from '@concepta/nestjs-event'; -import { JwtModule } from '@concepta/nestjs-jwt'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { UserAccessQueryService } from '../services/user-access-query.service'; -import { USER_MODULE_USER_ENTITY_KEY } from '../user.constants'; -import { UserResource } from '../user.types'; - -import { UserCrudControllerFixture } from './controllers/user-crud.controller.fixture'; -import { ormConfig } from './ormconfig.fixture'; -import { UserCrudModelServiceFixture } from './services/user-crud-model.service.fixture'; -import { UserCrudServiceFixture } from './services/user-crud.service.fixture'; -import { UserTypeOrmCrudAdapterFixture } from './services/user-typeorm-crud.adapter.fixture'; -import { UserEntityFixture } from './user.entity.fixture'; - -const rules = new AccessControl(); -rules - .grant('user') - .resource(UserResource.One) - .createOwn() - .readOwn() - .updateOwn() - .deleteOwn(); - -@Global() -@Module({ - imports: [ - TypeOrmModule.forRoot(ormConfig), - TypeOrmModule.forFeature([UserEntityFixture]), - TypeOrmExtModule.forFeature({ - [USER_MODULE_USER_ENTITY_KEY]: { - entity: UserEntityFixture, - }, - }), - CrudModule.forRoot({}), - EventModule.forRoot({}), - JwtModule.forRoot({}), - AuthJwtModule.forRootAsync({ - inject: [UserCrudModelServiceFixture], - useFactory: (userModelService: UserCrudModelServiceFixture) => ({ - userModelService, - }), - }), - AuthenticationModule.forRoot({}), - AccessControlModule.forRoot({ - settings: { rules }, - queryServices: [UserAccessQueryService], - }), - ], - providers: [ - UserCrudModelServiceFixture, - UserTypeOrmCrudAdapterFixture, - UserCrudServiceFixture, - ], - exports: [UserCrudModelServiceFixture, UserCrudServiceFixture], - controllers: [UserCrudControllerFixture], -}) -export class AppModuleCrudFixture {} diff --git a/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts b/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts deleted file mode 100644 index 41c49ef9f..000000000 --- a/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { AccessControl } from 'accesscontrol'; - -import { Module } from '@nestjs/common'; - -import { AccessControlModule } from '@concepta/nestjs-access-control'; -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { AuthenticationModule } from '@concepta/nestjs-authentication'; -import { EventModule } from '@concepta/nestjs-event'; -import { JwtModule } from '@concepta/nestjs-jwt'; -import { PasswordModule } from '@concepta/nestjs-password'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { UserModelServiceInterface } from '../interfaces/user-model-service.interface'; -import { UserModule } from '../user.module'; -import { UserResource } from '../user.types'; - -import { createUserRepositoryFixture } from './create-user-repository.fixture'; -import { ormConfig } from './ormconfig.fixture'; -import { UserModelCustomService } from './services/user-model.custom.service'; -import { UserEntityFixture } from './user.entity.fixture'; -import { UserModuleCustomFixture } from './user.module.custom.fixture'; - -const rules = new AccessControl(); -rules - .grant('user') - .resource(UserResource.One) - .createOwn() - .readOwn() - .updateOwn() - .deleteOwn(); - -@Module({ - imports: [ - UserModuleCustomFixture, - TypeOrmExtModule.forRoot(ormConfig), - EventModule.forRoot({}), - JwtModule.forRoot({}), - AuthJwtModule.forRootAsync({ - inject: [UserModelCustomService], - useFactory: (userModelService: UserModelServiceInterface) => ({ - userModelService, - }), - }), - AuthenticationModule.forRoot({}), - PasswordModule.forRoot({}), - AccessControlModule.forRoot({ settings: { rules } }), - UserModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - user: { - entity: UserEntityFixture, - repositoryFactory: createUserRepositoryFixture, - }, - }), - ], - inject: [UserModelCustomService], - useFactory: async (userModelService: UserModelServiceInterface) => ({ - userModelService, - settings: { - passwordHistory: { - enabled: true, - }, - }, - }), - }), - ], -}) -export class AppModuleCustomFixture {} diff --git a/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts deleted file mode 100644 index 22ba0a424..000000000 --- a/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { AccessControl } from 'accesscontrol'; - -import { Module } from '@nestjs/common'; - -import { AccessControlModule } from '@concepta/nestjs-access-control'; -import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; -import { AuthenticationModule } from '@concepta/nestjs-authentication'; -import { EventModule } from '@concepta/nestjs-event'; -import { JwtModule } from '@concepta/nestjs-jwt'; -import { PasswordModule } from '@concepta/nestjs-password'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { UserModelServiceInterface } from '../interfaces/user-model-service.interface'; -import { UserAccessQueryService } from '../services/user-access-query.service'; -import { UserModelService } from '../services/user-model.service'; -import { UserModule } from '../user.module'; -import { UserResource } from '../user.types'; - -import { InvitationAcceptedEventAsync } from './events/invitation-accepted.event'; -import { ormConfig } from './ormconfig.fixture'; -import { UserPasswordHistoryEntityFixture } from './user-password-history.entity.fixture'; -import { UserEntityFixture } from './user.entity.fixture'; - -const rules = new AccessControl(); -rules - .grant('user') - .resource(UserResource.One) - .createOwn() - .readOwn() - .updateOwn() - .deleteOwn(); - -@Module({ - imports: [ - TypeOrmExtModule.forRoot(ormConfig), - EventModule.forRoot({}), - JwtModule.forRoot({}), - AuthJwtModule.forRootAsync({ - inject: [UserModelService], - useFactory: (userModelService: UserModelServiceInterface) => ({ - userModelService, - }), - }), - AuthenticationModule.forRoot({}), - PasswordModule.forRoot({}), - AccessControlModule.forRoot({ - settings: { rules }, - queryServices: [UserAccessQueryService], - }), - UserModule.forRootAsync({ - imports: [ - TypeOrmExtModule.forFeature({ - user: { - entity: UserEntityFixture, - }, - 'user-password-history': { - entity: UserPasswordHistoryEntityFixture, - }, - }), - ], - useFactory: () => ({ - settings: { - invitationAcceptedEvent: InvitationAcceptedEventAsync, - passwordHistory: { - enabled: true, - limitDays: 99, - }, - }, - }), - }), - ], -}) -export class AppModuleFixture {} diff --git a/packages/nestjs-user/src/__fixtures__/app.module.user-profile.fixture.ts b/packages/nestjs-user/src/__fixtures__/app.module.user-profile.fixture.ts deleted file mode 100644 index 5ece504a1..000000000 --- a/packages/nestjs-user/src/__fixtures__/app.module.user-profile.fixture.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { - ConfigurableCrudOptions, - ConfigurableCrudOptionsTransformer, - CrudModule, -} from '@concepta/nestjs-crud'; -import { EventModule } from '@concepta/nestjs-event'; -import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; - -import { USER_MODULE_USER_PROFILE_ENTITY_KEY } from '../user.constants'; -import { UserProfileCrudBuilder } from '../utils/user-profile.crud-builder'; - -import { UserProfileCreateDtoFixture } from './dto/user-profile-create.dto.fixture'; -import { UserProfileUpdateDtoFixture } from './dto/user-profile-update.dto.fixture'; -import { UserProfileDtoFixture } from './dto/user-profile.dto.fixture'; -import { ormConfig } from './ormconfig.fixture'; -import { UserCrudModelServiceFixture } from './services/user-crud-model.service.fixture'; -import { UserProfileTypeOrmCrudAdapterFixture } from './services/user-profile-typeorm-crud.adapter.fixture'; -import { USER_PROFILE_CRUD_OPTIONS_DEFAULT } from './user-constants.fixtures'; -import { UserProfileEntityFixture } from './user-profile.entity.fixture'; - -type UserProfileExtras = { - model: { - type: typeof UserProfileDtoFixture; - }; - createOne: { - dto: typeof UserProfileCreateDtoFixture; - }; - updateOne: { - dto: typeof UserProfileUpdateDtoFixture; - }; -}; - -const extras: UserProfileExtras = { - model: { - type: UserProfileDtoFixture, - }, - createOne: { - dto: UserProfileCreateDtoFixture, - }, - updateOne: { - dto: UserProfileUpdateDtoFixture, - }, -}; - -// update config to use new dto -const myOptionsTransform: ConfigurableCrudOptionsTransformer< - UserProfileEntityFixture, - UserProfileExtras -> = ( - options: ConfigurableCrudOptions, - extras?: UserProfileExtras, -): ConfigurableCrudOptions => { - if (!extras) return options; - - options.controller.model.type = extras.model.type; - if ('adapter' in options.service) { - options.service.adapter = - UserProfileTypeOrmCrudAdapterFixture; - } - if (options.createOne) options.createOne.dto = extras.createOne.dto; - if (options.updateOne) options.updateOne.dto = extras.updateOne.dto; - return options; -}; - -// define profile with custom dtos -const userProfileCrudBuilder = new UserProfileCrudBuilder< - UserProfileEntityFixture, - UserProfileCreateDtoFixture, - UserProfileUpdateDtoFixture, - UserProfileCreateDtoFixture, - UserProfileExtras ->(USER_PROFILE_CRUD_OPTIONS_DEFAULT); -userProfileCrudBuilder.setExtras(extras, myOptionsTransform); - -const { ConfigurableControllerClass, ConfigurableServiceProvider } = - userProfileCrudBuilder.build(); - -@Global() -@Module({ - imports: [ - TypeOrmModule.forRoot(ormConfig), - TypeOrmExtModule.forFeature({ - [USER_MODULE_USER_PROFILE_ENTITY_KEY]: { - entity: UserProfileEntityFixture, - }, - }), - CrudModule.forRoot({}), - EventModule.forRoot({}), - ], - providers: [ - UserProfileTypeOrmCrudAdapterFixture, - UserCrudModelServiceFixture, - ConfigurableServiceProvider, - ], - exports: [UserCrudModelServiceFixture, ConfigurableServiceProvider], - controllers: [ConfigurableControllerClass], -}) -export class AppModuleUserProfileFixture {} diff --git a/packages/nestjs-user/src/__fixtures__/controllers/user-crud.controller.fixture.ts b/packages/nestjs-user/src/__fixtures__/controllers/user-crud.controller.fixture.ts deleted file mode 100644 index 5bbd486eb..000000000 --- a/packages/nestjs-user/src/__fixtures__/controllers/user-crud.controller.fixture.ts +++ /dev/null @@ -1,171 +0,0 @@ -// import { Param } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; - -import { - AccessControlCreateMany, - AccessControlCreateOne, - AccessControlDeleteOne, - AccessControlQuery, - AccessControlReadMany, - AccessControlReadOne, - AccessControlRecoverOne, - AccessControlUpdateOne, -} from '@concepta/nestjs-access-control'; -import { - UserCreatableInterface, - UserUpdatableInterface, - UserEntityInterface, -} from '@concepta/nestjs-common'; -import { - CrudBody, - CrudCreateOne, - CrudDeleteOne, - CrudReadOne, - CrudRequest, - CrudRequestInterface, - CrudUpdateOne, - CrudControllerInterface, - CrudController, - CrudCreateMany, - CrudReadMany, - CrudRecoverOne, -} from '@concepta/nestjs-crud'; - -import { UserCreateManyDto } from '../../dto/user-create-many.dto'; -import { UserCreateDto } from '../../dto/user-create.dto'; -import { UserPaginatedDto } from '../../dto/user-paginated.dto'; -import { UserUpdateDto } from '../../dto/user-update.dto'; -import { UserDto } from '../../dto/user.dto'; -import { UserAccessQueryService } from '../../services/user-access-query.service'; -import { UserResource } from '../../user.types'; -import { UserCrudServiceFixture } from '../services/user-crud.service.fixture'; - -/** - * User controller. - */ -@CrudController({ - path: 'user', - model: { - type: UserDto, - paginatedType: UserPaginatedDto, - }, -}) -@AccessControlQuery({ - service: UserAccessQueryService, -}) -@ApiTags('user') -export class UserCrudControllerFixture - implements - CrudControllerInterface< - UserEntityInterface, - UserCreatableInterface, - UserUpdatableInterface - > -{ - /** - * Constructor. - * - * @param userCrudService - instance of the user crud service - */ - constructor( - private userCrudService: UserCrudServiceFixture, // private userPasswordService: UserPasswordService, - ) {} - - /** - * Get many - * - * @param crudRequest - the CRUD request object - */ - @CrudReadMany() - @AccessControlReadMany(UserResource.Many) - async getMany( - @CrudRequest() crudRequest: CrudRequestInterface, - ) { - return this.userCrudService.getMany(crudRequest); - } - - /** - * Get one - * - * @param crudRequest - the CRUD request object - */ - @CrudReadOne() - @AccessControlReadOne(UserResource.One) - async getOne( - @CrudRequest() crudRequest: CrudRequestInterface, - ) { - return this.userCrudService.getOne(crudRequest); - } - - /** - * Create many - * - * @param crudRequest - the CRUD request object - * @param userCreateManyDto - user create many dto - */ - @CrudCreateMany() - @AccessControlCreateMany(UserResource.Many) - async createMany( - @CrudRequest() crudRequest: CrudRequestInterface, - @CrudBody() userCreateManyDto: UserCreateManyDto, - ) { - // call crud service to create - return this.userCrudService.createMany(crudRequest, userCreateManyDto); - } - - /** - * Create one - * - * @param crudRequest - the CRUD request object - * @param userCreateDto - user create dto - */ - @CrudCreateOne() - @AccessControlCreateOne(UserResource.One) - async createOne( - @CrudRequest() crudRequest: CrudRequestInterface, - @CrudBody() userCreateDto: UserCreateDto, - ) { - return this.userCrudService.createOne(crudRequest, userCreateDto); - } - - /** - * Update one - * - * @param crudRequest - the CRUD request object - * @param userUpdateDto - user update dto - */ - @CrudUpdateOne() - @AccessControlUpdateOne(UserResource.One) - async updateOne( - @CrudRequest() crudRequest: CrudRequestInterface, - @CrudBody() userUpdateDto: UserUpdateDto, - ) { - return this.userCrudService.updateOne(crudRequest, userUpdateDto); - } - - /** - * Delete one - * - * @param crudRequest - the CRUD request object - */ - @CrudDeleteOne() - @AccessControlDeleteOne(UserResource.One) - async deleteOne( - @CrudRequest() crudRequest: CrudRequestInterface, - ) { - return this.userCrudService.deleteOne(crudRequest); - } - - /** - * Recover one - * - * @param crudRequest - the CRUD request object - */ - @CrudRecoverOne() - @AccessControlRecoverOne(UserResource.One) - async recoverOne( - @CrudRequest() crudRequest: CrudRequestInterface, - ) { - return this.userCrudService.recoverOne(crudRequest); - } -} diff --git a/packages/nestjs-user/src/__fixtures__/create-user-repository.fixture.ts b/packages/nestjs-user/src/__fixtures__/create-user-repository.fixture.ts deleted file mode 100644 index 09fe82343..000000000 --- a/packages/nestjs-user/src/__fixtures__/create-user-repository.fixture.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { DataSource, FindOneOptions } from 'typeorm'; - -import { UserEntityInterface } from '@concepta/nestjs-common'; - -import { UserEntityFixture } from './user.entity.fixture'; - -export function createUserRepositoryFixture(dataSource: DataSource) { - /** - * Fake user "database" - */ - const users: UserEntityFixture[] = [ - { - id: '1', - email: 'first_user@dispostable.com', - username: 'first_user', - active: true, - // hashed for AS12378 - passwordHash: - '$2b$10$9y97gOLiusyKnzu7LRdMmOCVpp/xwddaa8M6KtgenvUDao5I.8mJS', - passwordSalt: '$2b$10$9y97gOLiusyKnzu7LRdMmO', - dateCreated: new Date(), - dateUpdated: new Date(), - dateDeleted: new Date(), - version: 1, - }, - { - id: '2', - email: 'second_user@dispostable.com', - username: 'second_user', - active: true, - // hashed for AS12378 - passwordHash: - '$2b$10$9y97gOLiusyKnzu7LRdMmOCVpp/xwddaa8M6KtgenvUDao5I.8mJS', - passwordSalt: '$2b$10$9y97gOLiusyKnzu7LRdMmO', - dateCreated: new Date(), - dateUpdated: new Date(), - dateDeleted: new Date(), - version: 1, - }, - ]; - - return dataSource.getRepository(UserEntityFixture).extend({ - async findOne( - optionsOrConditions?: - | string - | number - | Date - // | ObjectID - | FindOneOptions, - ): Promise { - return ( - users.find((user) => { - if ( - typeof optionsOrConditions === 'object' && - 'id' in optionsOrConditions && - 'username' in optionsOrConditions - ) - return ( - user?.id === optionsOrConditions['id'] || - user?.username === optionsOrConditions['username'] - ); - }) ?? null - ); - }, - }); -} diff --git a/packages/nestjs-user/src/__fixtures__/dto/user-profile-create.dto.fixture.ts b/packages/nestjs-user/src/__fixtures__/dto/user-profile-create.dto.fixture.ts deleted file mode 100644 index bdab9153b..000000000 --- a/packages/nestjs-user/src/__fixtures__/dto/user-profile-create.dto.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IntersectionType, PickType } from '@nestjs/swagger'; - -import { UserProfileCreateDto } from '../../dto/profile/user-profile-create.dto'; - -import { UserProfileDtoFixture } from './user-profile.dto.fixture'; - -export class UserProfileCreateDtoFixture extends IntersectionType( - UserProfileCreateDto, - PickType(UserProfileDtoFixture, ['firstName'] as const), -) {} diff --git a/packages/nestjs-user/src/__fixtures__/dto/user-profile-update.dto.fixture.ts b/packages/nestjs-user/src/__fixtures__/dto/user-profile-update.dto.fixture.ts deleted file mode 100644 index 7b8d52458..000000000 --- a/packages/nestjs-user/src/__fixtures__/dto/user-profile-update.dto.fixture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IntersectionType, PartialType, PickType } from '@nestjs/swagger'; - -import { UserProfileUpdateDto } from '../../dto/profile/user-profile-update.dto'; - -import { UserProfileDtoFixture } from './user-profile.dto.fixture'; - -export class UserProfileUpdateDtoFixture extends IntersectionType( - UserProfileUpdateDto, - PartialType(PickType(UserProfileDtoFixture, ['firstName'] as const)), -) {} diff --git a/packages/nestjs-user/src/__fixtures__/dto/user-profile.dto.fixture.ts b/packages/nestjs-user/src/__fixtures__/dto/user-profile.dto.fixture.ts deleted file mode 100644 index 54092d24e..000000000 --- a/packages/nestjs-user/src/__fixtures__/dto/user-profile.dto.fixture.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { UserProfileDto } from '../../dto/profile/user-profile.dto'; - -@Exclude() -export class UserProfileDtoFixture extends UserProfileDto { - @Expose() - @ApiProperty() - @IsString() - firstName!: string; -} diff --git a/packages/nestjs-user/src/__fixtures__/events/invitation-accepted.event.ts b/packages/nestjs-user/src/__fixtures__/events/invitation-accepted.event.ts deleted file mode 100644 index ea636a30a..000000000 --- a/packages/nestjs-user/src/__fixtures__/events/invitation-accepted.event.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { InvitationAcceptedEventPayloadInterface } from '@concepta/nestjs-common'; -import { EventAsync } from '@concepta/nestjs-event'; - -export class InvitationAcceptedEventAsync extends EventAsync< - InvitationAcceptedEventPayloadInterface, - boolean -> {} diff --git a/packages/nestjs-user/src/__fixtures__/ormconfig.fixture.ts b/packages/nestjs-user/src/__fixtures__/ormconfig.fixture.ts deleted file mode 100644 index 6b3d59a33..000000000 --- a/packages/nestjs-user/src/__fixtures__/ormconfig.fixture.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DataSourceOptions } from 'typeorm'; - -import { UserPasswordHistoryEntityFixture } from './user-password-history.entity.fixture'; -import { UserProfileEntityFixture } from './user-profile.entity.fixture'; -import { UserEntityFixture } from './user.entity.fixture'; - -export const ormConfig: DataSourceOptions = { - type: 'sqlite', - database: ':memory:', - synchronize: true, - entities: [ - UserEntityFixture, - UserProfileEntityFixture, - UserPasswordHistoryEntityFixture, - ], -}; diff --git a/packages/nestjs-user/src/__fixtures__/services/user-crud-model.service.fixture.ts b/packages/nestjs-user/src/__fixtures__/services/user-crud-model.service.fixture.ts deleted file mode 100644 index a653b911d..000000000 --- a/packages/nestjs-user/src/__fixtures__/services/user-crud-model.service.fixture.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - UserCreatableInterface, - UserUpdatableInterface, - UserReplaceableInterface, - UserEntityInterface, -} from '@concepta/nestjs-common'; - -import { UserModelServiceInterface } from '../../interfaces/user-model-service.interface'; - -@Injectable() -export class UserCrudModelServiceFixture - implements UserModelServiceInterface -{ - async byId(_id: string): Promise { - return null; // No-op - } - - async byEmail(_email: string): Promise { - return null; // No-op - } - - async bySubject(_subject: string): Promise { - return null; // No-op - } - - async byUsername(_username: string): Promise { - return null; // No-op - } - - async create(_data: UserCreatableInterface): Promise { - return {} as UserEntityInterface; // No-op - } - - async update(_data: UserUpdatableInterface): Promise { - return {} as UserEntityInterface; // No-op - } - - async replace(_data: UserReplaceableInterface): Promise { - return {} as UserEntityInterface; // No-op - } - - async remove( - _data: Pick, - ): Promise { - return {} as UserEntityInterface; // No-op - } -} diff --git a/packages/nestjs-user/src/__fixtures__/services/user-crud.service.fixture.ts b/packages/nestjs-user/src/__fixtures__/services/user-crud.service.fixture.ts deleted file mode 100644 index 9ab32af88..000000000 --- a/packages/nestjs-user/src/__fixtures__/services/user-crud.service.fixture.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { UserEntityInterface } from '@concepta/nestjs-common'; -import { CrudService } from '@concepta/nestjs-crud'; -import { CrudAdapter } from '@concepta/nestjs-crud/src/crud/adapters/crud.adapter'; - -import { UserTypeOrmCrudAdapterFixture } from './user-typeorm-crud.adapter.fixture'; - -/** - * User CRUD service fixture - */ -@Injectable() -export class UserCrudServiceFixture extends CrudService { - /** - * Constructor - * - * @param crudAdapter - instance of the user crud adapter. - */ - constructor( - @Inject(UserTypeOrmCrudAdapterFixture) - protected readonly crudAdapter: CrudAdapter, - ) { - super(crudAdapter); - } -} diff --git a/packages/nestjs-user/src/__fixtures__/services/user-model.custom.service.ts b/packages/nestjs-user/src/__fixtures__/services/user-model.custom.service.ts deleted file mode 100644 index f6e24f6de..000000000 --- a/packages/nestjs-user/src/__fixtures__/services/user-model.custom.service.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { UserModelService } from '../../services/user-model.service'; - -@Injectable() -export class UserModelCustomService extends UserModelService { - /** - * Dummy property for easily identifying service override. - */ - hello? = 'world'; -} diff --git a/packages/nestjs-user/src/__fixtures__/services/user-profile-typeorm-crud.adapter.fixture.ts b/packages/nestjs-user/src/__fixtures__/services/user-profile-typeorm-crud.adapter.fixture.ts deleted file mode 100644 index fa3539015..000000000 --- a/packages/nestjs-user/src/__fixtures__/services/user-profile-typeorm-crud.adapter.fixture.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - InjectDynamicRepository, - UserProfileEntityInterface, -} from '@concepta/nestjs-common'; -import { TypeOrmCrudAdapter } from '@concepta/nestjs-crud'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { USER_MODULE_USER_PROFILE_ENTITY_KEY } from '../../user.constants'; - -/** - * User Profile TypeOrm CRUD adapter fixture - */ -@Injectable() -export class UserProfileTypeOrmCrudAdapterFixture< - T extends UserProfileEntityInterface, -> extends TypeOrmCrudAdapter { - /** - * Constructor - * - * @param repoAdapter - instance of the user profile repository adapter. - */ - constructor( - @InjectDynamicRepository(USER_MODULE_USER_PROFILE_ENTITY_KEY) - repoAdapter: TypeOrmRepositoryAdapter, - ) { - super(repoAdapter); - } -} diff --git a/packages/nestjs-user/src/__fixtures__/services/user-typeorm-crud.adapter.fixture.ts b/packages/nestjs-user/src/__fixtures__/services/user-typeorm-crud.adapter.fixture.ts deleted file mode 100644 index 80d033cc6..000000000 --- a/packages/nestjs-user/src/__fixtures__/services/user-typeorm-crud.adapter.fixture.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - InjectDynamicRepository, - UserEntityInterface, -} from '@concepta/nestjs-common'; -import { TypeOrmCrudAdapter } from '@concepta/nestjs-crud'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { USER_MODULE_USER_ENTITY_KEY } from '../../user.constants'; - -/** - * User TypeOrm CRUD adapter fixture - */ -@Injectable() -export class UserTypeOrmCrudAdapterFixture extends TypeOrmCrudAdapter { - /** - * Constructor - * - * @param userRepoAdapter - instance of the user repository adapter. - */ - constructor( - @InjectDynamicRepository(USER_MODULE_USER_ENTITY_KEY) - userRepoAdapter: TypeOrmRepositoryAdapter, - ) { - super(userRepoAdapter); - } -} diff --git a/packages/nestjs-user/src/__fixtures__/user-constants.fixtures.ts b/packages/nestjs-user/src/__fixtures__/user-constants.fixtures.ts deleted file mode 100644 index 963c1b3ed..000000000 --- a/packages/nestjs-user/src/__fixtures__/user-constants.fixtures.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ApiTags } from '@nestjs/swagger'; - -import { - AccessControlReadMany, - AccessControlReadOne, - AccessControlCreateOne, - AccessControlUpdateOne, - AccessControlReplaceOne, - AccessControlDeleteOne, - AccessControlRecoverOne, -} from '@concepta/nestjs-access-control'; -import { ConfigurableCrudOptions } from '@concepta/nestjs-crud'; - -import { UserProfileCreateDto } from '../dto/profile/user-profile-create.dto'; -import { UserProfilePaginatedDto } from '../dto/profile/user-profile-paginated.dto'; -import { UserProfileUpdateDto } from '../dto/profile/user-profile-update.dto'; -import { UserProfileDto } from '../dto/profile/user-profile.dto'; -import { USER_MODULE_CONFIGURABLE_CRUD_PROFILE_SERVICE_TOKEN } from '../user.constants'; -import { UserProfileResource } from '../user.types'; - -import { UserProfileTypeOrmCrudAdapterFixture } from './services/user-profile-typeorm-crud.adapter.fixture'; -import { UserProfileEntityFixture } from './user-profile.entity.fixture'; - -export const USER_PROFILE_CRUD_OPTIONS_DEFAULT: ConfigurableCrudOptions = - { - service: { - adapterToken: - UserProfileTypeOrmCrudAdapterFixture, - serviceToken: USER_MODULE_CONFIGURABLE_CRUD_PROFILE_SERVICE_TOKEN, - }, - controller: { - path: 'user-profile', - model: { - type: UserProfileDto, - paginatedType: UserProfilePaginatedDto, - }, - extraDecorators: [ApiTags('user-profile')], - }, - getMany: { - extraDecorators: [AccessControlReadMany(UserProfileResource.Many)], - }, - getOne: { - extraDecorators: [AccessControlReadOne(UserProfileResource.One)], - }, - createOne: { - dto: UserProfileCreateDto, - extraDecorators: [AccessControlCreateOne(UserProfileResource.One)], - }, - updateOne: { - dto: UserProfileUpdateDto, - extraDecorators: [AccessControlUpdateOne(UserProfileResource.One)], - }, - replaceOne: { - dto: UserProfileUpdateDto, - extraDecorators: [AccessControlReplaceOne(UserProfileResource.One)], - }, - deleteOne: { - extraDecorators: [AccessControlDeleteOne(UserProfileResource.One)], - }, - recoverOne: { - path: 'recover/:id', - extraDecorators: [AccessControlRecoverOne(UserProfileResource.One)], - }, - }; diff --git a/packages/nestjs-user/src/__fixtures__/user-password-history.entity.fixture.ts b/packages/nestjs-user/src/__fixtures__/user-password-history.entity.fixture.ts deleted file mode 100644 index b7c6da10a..000000000 --- a/packages/nestjs-user/src/__fixtures__/user-password-history.entity.fixture.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Entity } from 'typeorm'; - -import { UserPasswordHistorySqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -@Entity() -export class UserPasswordHistoryEntityFixture extends UserPasswordHistorySqliteEntity {} diff --git a/packages/nestjs-user/src/__fixtures__/user-profile.entity.fixture.ts b/packages/nestjs-user/src/__fixtures__/user-profile.entity.fixture.ts deleted file mode 100644 index 6f96f8127..000000000 --- a/packages/nestjs-user/src/__fixtures__/user-profile.entity.fixture.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Column, Entity } from 'typeorm'; - -import { UserProfileEntityInterface } from '@concepta/nestjs-common'; -import { UserProfileSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -/** - * User Profile Entity Fixture - */ -@Entity() -export class UserProfileEntityFixture - extends UserProfileSqliteEntity - implements UserProfileEntityInterface -{ - @Column({ nullable: true }) - firstName!: string; -} diff --git a/packages/nestjs-user/src/__fixtures__/user.entity.fixture.ts b/packages/nestjs-user/src/__fixtures__/user.entity.fixture.ts deleted file mode 100644 index 0ef2b8de4..000000000 --- a/packages/nestjs-user/src/__fixtures__/user.entity.fixture.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Entity } from 'typeorm'; - -import { UserSqliteEntity } from '@concepta/nestjs-typeorm-ext'; - -@Entity() -export class UserEntityFixture extends UserSqliteEntity {} diff --git a/packages/nestjs-user/src/__fixtures__/user.module.custom.fixture.ts b/packages/nestjs-user/src/__fixtures__/user.module.custom.fixture.ts deleted file mode 100644 index 753698870..000000000 --- a/packages/nestjs-user/src/__fixtures__/user.module.custom.fixture.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { UserModelCustomService } from './services/user-model.custom.service'; -import { UserEntityFixture } from './user.entity.fixture'; - -@Global() -@Module({ - imports: [TypeOrmModule.forFeature([UserEntityFixture])], - providers: [UserModelCustomService], - exports: [UserModelCustomService], -}) -export class UserModuleCustomFixture {} diff --git a/packages/nestjs-user/src/__tests__/exception-fault.spec.ts b/packages/nestjs-user/src/__tests__/exception-fault.spec.ts new file mode 100644 index 000000000..c472f5e9f --- /dev/null +++ b/packages/nestjs-user/src/__tests__/exception-fault.spec.ts @@ -0,0 +1,67 @@ +import { fileURLToPath } from 'url'; + +import { + RuntimeException, + type RuntimeExceptionFault, +} from '@concepta/nestjs-core'; +import { collectRuntimeExceptionClassNames } from '@concepta/nestjs-core/testing'; + +import { UserNotFoundException } from '../application/exceptions/user-not-found.exception.js'; +import { UserCredentialsAlreadyExistException } from '../domain/exceptions/user-credentials-already-exist.exception.js'; +import { UserPasswordCurrentInvalidException } from '../domain/exceptions/user-password-current-invalid.exception.js'; +import { UserPasswordHistoryViolationException } from '../domain/exceptions/user-password-history-violation.exception.js'; +import { UserException } from '../domain/exceptions/user.exception.js'; + +/** + * Anti-drift check: every `RuntimeException` subclass in this package states + * an expected `fault` here. + */ +const CASES: { + name: string; + build: () => RuntimeException; + fault: RuntimeExceptionFault; +}[] = [ + { + name: 'UserException (default)', + build: () => new UserException(), + fault: 'internal', + }, + { + name: 'UserNotFoundException', + build: () => new UserNotFoundException({ id: 'id' }), + fault: 'client', + }, + { + name: 'UserCredentialsAlreadyExistException', + build: () => new UserCredentialsAlreadyExistException(), + fault: 'client', + }, + { + name: 'UserPasswordCurrentInvalidException', + build: () => new UserPasswordCurrentInvalidException(), + fault: 'client', + }, + { + name: 'UserPasswordHistoryViolationException', + build: () => new UserPasswordHistoryViolationException(), + fault: 'client', + }, +]; + +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +describe('exception fault classification', () => { + it.each(CASES)('$name has fault=$fault', ({ build, fault }) => { + expect(build().fault).toEqual(fault); + }); + + it('every RuntimeException subclass in this package is listed above', async () => { + const discovered = await collectRuntimeExceptionClassNames( + SRC_DIR, + RuntimeException, + ); + const expected = new Set(CASES.map((c) => c.build().constructor.name)); + const missing = discovered.filter((name) => !expected.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/nestjs-user/src/__tests__/fixtures/app-repo-strict-password.module.fixture.ts b/packages/nestjs-user/src/__tests__/fixtures/app-repo-strict-password.module.fixture.ts new file mode 100644 index 000000000..3118bb91c --- /dev/null +++ b/packages/nestjs-user/src/__tests__/fixtures/app-repo-strict-password.module.fixture.ts @@ -0,0 +1,60 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { + CreatePasswordCommand, + PasswordModule, + PasswordStrengthEnum, + ValidateCurrentPasswordCommand, + ValidatePasswordHistoryCommand, +} from '@concepta/nestjs-password'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { UserModule } from '../../user.module.js'; + +import { UserCredentialEntityFixture } from './entities/user-credential.entity.fixture.js'; +import { UserEntityFixture } from './entities/user.entity.fixture.js'; +import { ormConfig } from './ormconfig.fixture.js'; + +const USER_ENTITY_KEY = 'user'; +const USER_CREDENTIALS_ENTITY_KEY = 'user-credentials'; + +/** + * Same shape as `AppRepoModuleFixture`, but with `minPasswordStrength` + * forced to `VeryStrong` regardless of `NODE_ENV` — regression coverage for + * #469 needs a password that reliably fails strength validation. + */ +@Module({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + RepositoryModule.forRoot({}), + PasswordModule.forRoot({ + settings: { minPasswordStrength: PasswordStrengthEnum.VeryStrong }, + }), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: USER_ENTITY_KEY, entity: UserEntityFixture }, + { + key: USER_CREDENTIALS_ENTITY_KEY, + entity: UserCredentialEntityFixture, + }, + ], + }), + UserModule.forRoot({ + entities: { + user: USER_ENTITY_KEY, + credentials: USER_CREDENTIALS_ENTITY_KEY, + }, + ports: { + password: { + createCommand: CreatePasswordCommand, + validateCurrentCommand: ValidateCurrentPasswordCommand, + validateHistoryCommand: ValidatePasswordHistoryCommand, + }, + }, + }), + ], +}) +export class AppRepoStrictPasswordModuleFixture {} diff --git a/packages/nestjs-user/src/__tests__/fixtures/app-repo.module.fixture.ts b/packages/nestjs-user/src/__tests__/fixtures/app-repo.module.fixture.ts new file mode 100644 index 000000000..053a56ac7 --- /dev/null +++ b/packages/nestjs-user/src/__tests__/fixtures/app-repo.module.fixture.ts @@ -0,0 +1,52 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { + CreatePasswordCommand, + PasswordModule, + ValidateCurrentPasswordCommand, + ValidatePasswordHistoryCommand, +} from '@concepta/nestjs-password'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { UserModule } from '../../user.module.js'; + +import { UserCredentialEntityFixture } from './entities/user-credential.entity.fixture.js'; +import { UserEntityFixture } from './entities/user.entity.fixture.js'; +import { ormConfig } from './ormconfig.fixture.js'; + +const USER_ENTITY_KEY = 'user'; +const USER_CREDENTIALS_ENTITY_KEY = 'user-credentials'; + +@Module({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + RepositoryModule.forRoot({}), + PasswordModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: USER_ENTITY_KEY, entity: UserEntityFixture }, + { + key: USER_CREDENTIALS_ENTITY_KEY, + entity: UserCredentialEntityFixture, + }, + ], + }), + UserModule.forRoot({ + entities: { + user: USER_ENTITY_KEY, + credentials: USER_CREDENTIALS_ENTITY_KEY, + }, + ports: { + password: { + createCommand: CreatePasswordCommand, + validateCurrentCommand: ValidateCurrentPasswordCommand, + validateHistoryCommand: ValidatePasswordHistoryCommand, + }, + }, + }), + ], +}) +export class AppRepoModuleFixture {} diff --git a/packages/nestjs-user/src/__tests__/fixtures/entities/user-credential.entity.fixture.ts b/packages/nestjs-user/src/__tests__/fixtures/entities/user-credential.entity.fixture.ts new file mode 100644 index 000000000..c5f2e35a6 --- /dev/null +++ b/packages/nestjs-user/src/__tests__/fixtures/entities/user-credential.entity.fixture.ts @@ -0,0 +1,6 @@ +import { Entity } from 'typeorm'; + +import { UserCredentialSqliteEntity } from '../../../infrastructure/persistence/typeorm/user-credential-sqlite.entity.js'; + +@Entity() +export class UserCredentialEntityFixture extends UserCredentialSqliteEntity {} diff --git a/packages/nestjs-user/src/__tests__/fixtures/entities/user.entity.fixture.ts b/packages/nestjs-user/src/__tests__/fixtures/entities/user.entity.fixture.ts new file mode 100644 index 000000000..9e961064d --- /dev/null +++ b/packages/nestjs-user/src/__tests__/fixtures/entities/user.entity.fixture.ts @@ -0,0 +1,6 @@ +import { Entity } from 'typeorm'; + +import { UserSqliteEntity } from '../../../infrastructure/persistence/typeorm/user-sqlite.entity.js'; + +@Entity() +export class UserEntityFixture extends UserSqliteEntity {} diff --git a/packages/nestjs-user/src/__tests__/fixtures/ormconfig.fixture.ts b/packages/nestjs-user/src/__tests__/fixtures/ormconfig.fixture.ts new file mode 100644 index 000000000..5b72527a0 --- /dev/null +++ b/packages/nestjs-user/src/__tests__/fixtures/ormconfig.fixture.ts @@ -0,0 +1,11 @@ +import { type DataSourceOptions } from 'typeorm'; + +import { UserCredentialEntityFixture } from './entities/user-credential.entity.fixture.js'; +import { UserEntityFixture } from './entities/user.entity.fixture.js'; + +export const ormConfig: DataSourceOptions = { + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [UserEntityFixture, UserCredentialEntityFixture], +}; diff --git a/packages/nestjs-user/src/__tests__/helpers/mock.helpers.ts b/packages/nestjs-user/src/__tests__/helpers/mock.helpers.ts new file mode 100644 index 000000000..97710ffb4 --- /dev/null +++ b/packages/nestjs-user/src/__tests__/helpers/mock.helpers.ts @@ -0,0 +1,123 @@ +import { type Mocked } from 'vitest'; +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { type EventPublisher } from '@nestjs/cqrs'; + +import { AppContextHost } from '@concepta/nestjs-core'; +import { + TrxCtx, + type TransactionScope, + type TransactionContextInterface, +} from '@concepta/nestjs-repository'; + +import { type UserCredentials } from '../../domain/aggregates/user-credentials.js'; +import { type User } from '../../domain/aggregates/user.js'; +import { type UserCredentialEntityInterface } from '../../domain/interfaces/user-credential-entity.interface.js'; +import { type UserEntityInterface } from '../../domain/interfaces/user-entity.interface.js'; +import { type UserPasswordPort } from '../../domain/ports/user-password.port.js'; +import { type UserCredentialsRepositoryInterface } from '../../domain/repositories/user-credentials-repository.interface.js'; +import { type UserRepositoryInterface } from '../../domain/repositories/user-repository.interface.js'; +import { type UserCredentialsService } from '../../domain/services/user-credentials.service.js'; +import { UserCredentialsMapper } from '../../infrastructure/persistence/user-credentials.mapper.js'; +import { UserMapper } from '../../infrastructure/persistence/user.mapper.js'; + +export function createMockTxScope(): DeepMockProxy { + const trxHandle = { + onCommit: vi.fn(), + onRollback: vi.fn(), + }; + + const mockHost = new AppContextHost(); + mockHost.defineOverlay(TrxCtx, { + trx: trxHandle, + } as unknown as TransactionContextInterface); + const mockTxCtx = mockHost.with(TrxCtx); + + const txScope = mockDeep(); + txScope.run.mockImplementation((_ctx, fn) => fn(mockTxCtx)); + + return txScope; +} + +export function createMockEventPublisher(): DeepMockProxy { + const publisher = mockDeep(); + publisher.mergeObjectContext.mockImplementation((obj) => { + obj.commit = vi.fn(); + obj.uncommit = vi.fn(); + return obj; + }); + return publisher; +} + +export function createMockUserRepository(): Mocked { + return { + get: vi.fn(), + findByEmail: vi.fn(), + findByUsername: vi.fn(), + save: vi.fn(), + remove: vi.fn(), + }; +} + +export function createMockUserCredentialsRepository(): Mocked { + return { + findActiveByUserId: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn(), + }; +} + +export function createMockPasswordPort(): DeepMockProxy { + return mockDeep(); +} + +export function createMockUserCredentialsService(): DeepMockProxy { + return mockDeep(); +} + +export function createMockUserEntity( + overrides: Partial = {}, +): UserEntityInterface { + return { + id: 'user-1', + email: 'a@b.com', + username: 'john', + active: true, + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + ...overrides, + }; +} + +export function createMockUserCredentialEntity( + overrides: Partial = {}, +): UserCredentialEntityInterface { + return { + id: 'cred-1', + userId: 'user-1', + passwordHash: 'old-hash', + active: true, + validFrom: new Date('2026-01-01'), + validTo: null, + dateCreated: new Date('2026-01-01'), + dateUpdated: new Date('2026-01-01'), + dateDeleted: null, + version: 1, + ...overrides, + }; +} + +const userMapper = new UserMapper(); +const credentialsMapper = new UserCredentialsMapper(); + +export function toUserDomain(entity: UserEntityInterface): User { + return userMapper.toDomain(entity); +} + +export function toUserCredentialsDomain( + entity: UserCredentialEntityInterface, +): UserCredentials { + return credentialsMapper.toDomain(entity); +} diff --git a/packages/nestjs-user/src/application/commands/handlers/__tests__/create-user-credential.handler.spec.ts b/packages/nestjs-user/src/application/commands/handlers/__tests__/create-user-credential.handler.spec.ts new file mode 100644 index 000000000..bc94c9aba --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/__tests__/create-user-credential.handler.spec.ts @@ -0,0 +1,48 @@ +import { AppContextHost } from '@concepta/nestjs-core'; + +import { + createMockTxScope, + createMockUserCredentialsService, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { type UserCredentials } from '../../../../domain/aggregates/user-credentials.js'; +import { CreateUserCredentialCommand } from '../../impl/create-user-credential.command.js'; +import { CreateUserCredentialHandler } from '../create-user-credential.handler.js'; + +describe(CreateUserCredentialHandler.name, () => { + const userCredentialsService = createMockUserCredentialsService(); + const txScope = createMockTxScope(); + const mockCredentials = {} as UserCredentials; + + let handler: CreateUserCredentialHandler; + + beforeEach(() => { + vi.clearAllMocks(); + userCredentialsService.setPassword.mockResolvedValue(mockCredentials); + handler = new CreateUserCredentialHandler(userCredentialsService, txScope); + }); + + it('should delegate to userCredentialsService.setPassword', async () => { + const result = await handler.execute( + new CreateUserCredentialCommand({}, 'user-1', 'secret'), + ); + + expect(result).toBe(mockCredentials); + expect(userCredentialsService.setPassword).toHaveBeenCalledTimes(1); + const [ctx, , userId, password] = + userCredentialsService.setPassword.mock.calls[0]; + expect(ctx).toBeInstanceOf(AppContextHost); + expect(userId).toBe('user-1'); + expect(password).toBe('secret'); + }); + + it('should delegate an already-hashed password storage object as-is', async () => { + const passwordStorage = { passwordHash: 'hashed' }; + + await handler.execute( + new CreateUserCredentialCommand({}, 'user-1', passwordStorage), + ); + + const [, , , password] = userCredentialsService.setPassword.mock.calls[0]; + expect(password).toBe(passwordStorage); + }); +}); diff --git a/packages/nestjs-user/src/application/commands/handlers/__tests__/create-user.handler.e2e-spec.ts b/packages/nestjs-user/src/application/commands/handlers/__tests__/create-user.handler.e2e-spec.ts new file mode 100644 index 000000000..3799f5655 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/__tests__/create-user.handler.e2e-spec.ts @@ -0,0 +1,56 @@ +import { type INestApplication } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { AppRepoStrictPasswordModuleFixture } from '../../../../__tests__/fixtures/app-repo-strict-password.module.fixture.js'; +import { type UserRepositoryInterface } from '../../../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../../../user.constants.js'; +import { CreateUserCommand } from '../../impl/create-user.command.js'; + +/** + * Regression coverage for #469 through the real production code path — a + * weak password must be rejected before the user row is written, on any + * transaction-factory configuration, not merely rolled back afterward. + */ +describe(CreateUserCommand.name + ' (e2e)', () => { + let app: INestApplication; + let commandBus: CommandBus; + let userRepository: UserRepositoryInterface; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppRepoStrictPasswordModuleFixture], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + commandBus = app.get(CommandBus); + userRepository = app.get(USER_REPOSITORY_TOKEN); + }); + + afterEach(async () => { + await app?.close(); + }); + + it('should reject a weak password without persisting the user', async () => { + await expect( + commandBus.execute( + new CreateUserCommand( + {}, + { + email: 'weak-password@example.com', + username: 'weakpassworduser', + password: 'password123', + }, + ), + ), + ).rejects.toThrow(); + + const found = await userRepository.findByEmail( + {}, + 'weak-password@example.com', + ); + expect(found).toBeNull(); + }); +}); diff --git a/packages/nestjs-user/src/application/commands/handlers/__tests__/create-user.handler.spec.ts b/packages/nestjs-user/src/application/commands/handlers/__tests__/create-user.handler.spec.ts new file mode 100644 index 000000000..13b1ba6f4 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/__tests__/create-user.handler.spec.ts @@ -0,0 +1,93 @@ +import { createMockCommandBus } from '@concepta/nestjs-core/testing'; + +import { + createMockEventPublisher, + createMockPasswordPort, + createMockTxScope, + createMockUserRepository, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { User } from '../../../../domain/aggregates/user.js'; +import { type UserCreatableInterface } from '../../../../domain/interfaces/user-creatable.interface.js'; +import { type CreateUserCredentialCommand } from '../../impl/create-user-credential.command.js'; +import { CreateUserCommand } from '../../impl/create-user.command.js'; +import { CreateUserHandler } from '../create-user.handler.js'; + +describe(CreateUserHandler.name, () => { + const userRepository = createMockUserRepository(); + const commandBus = createMockCommandBus(); + const eventPublisher = createMockEventPublisher(); + const txScope = createMockTxScope(); + const passwordPort = createMockPasswordPort(); + + let handler: CreateUserHandler; + + beforeEach(() => { + vi.clearAllMocks(); + passwordPort.create.mockResolvedValue({ passwordHash: 'hashed' }); + handler = new CreateUserHandler( + userRepository, + commandBus, + eventPublisher, + txScope, + passwordPort, + ); + }); + + it('should create and save a user', async () => { + const dto: UserCreatableInterface = { + email: 'a@b.com', + username: 'john', + }; + + const result = await handler.execute(new CreateUserCommand({}, dto)); + + expect(result).toBeInstanceOf(User); + expect(result.email).toBe('a@b.com'); + expect(result.username).toBe('john'); + expect(userRepository.save).toHaveBeenCalledTimes(1); + }); + + it('should hash the password before saving and dispatch it as storage', async () => { + const dto: UserCreatableInterface = { + email: 'a@b.com', + username: 'john', + password: 'secret', + }; + + const result = await handler.execute(new CreateUserCommand({}, dto)); + + expect(passwordPort.create).toHaveBeenCalledWith('secret'); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + const dispatched = commandBus.execute.mock + .calls[0][0] as CreateUserCredentialCommand; + expect(dispatched.userId).toBe(result.id); + expect(dispatched.password).toEqual({ passwordHash: 'hashed' }); + }); + + it('should not dispatch credential command when no password', async () => { + const dto: UserCreatableInterface = { + email: 'a@b.com', + username: 'john', + }; + + await handler.execute(new CreateUserCommand({}, dto)); + + expect(commandBus.execute).not.toHaveBeenCalled(); + }); + + it('should reject a weak password before saving the user', async () => { + const dto: UserCreatableInterface = { + email: 'a@b.com', + username: 'john', + password: 'weak', + }; + passwordPort.create.mockRejectedValue(new Error('password not strong')); + + await expect( + handler.execute(new CreateUserCommand({}, dto)), + ).rejects.toThrow('password not strong'); + + expect(userRepository.save).not.toHaveBeenCalled(); + expect(txScope.run).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nestjs-user/src/application/commands/handlers/__tests__/remove-user.handler.spec.ts b/packages/nestjs-user/src/application/commands/handlers/__tests__/remove-user.handler.spec.ts new file mode 100644 index 000000000..7ccf09239 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/__tests__/remove-user.handler.spec.ts @@ -0,0 +1,42 @@ +import { + createMockEventPublisher, + createMockTxScope, + createMockUserEntity, + createMockUserRepository, + toUserDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { User } from '../../../../domain/aggregates/user.js'; +import { UserNotFoundException } from '../../../exceptions/user-not-found.exception.js'; +import { RemoveUserCommand } from '../../impl/remove-user.command.js'; +import { RemoveUserHandler } from '../remove-user.handler.js'; + +describe(RemoveUserHandler.name, () => { + const userRepository = createMockUserRepository(); + const eventPublisher = createMockEventPublisher(); + const txScope = createMockTxScope(); + + let handler: RemoveUserHandler; + + beforeEach(() => { + vi.clearAllMocks(); + handler = new RemoveUserHandler(userRepository, txScope, eventPublisher); + }); + + it('should remove and return user when found', async () => { + userRepository.get.mockResolvedValue(toUserDomain(createMockUserEntity())); + + const result = await handler.execute(new RemoveUserCommand({}, 'user-1')); + + expect(result).toBeInstanceOf(User); + expect(result.id).toBe('user-1'); + expect(userRepository.remove).toHaveBeenCalledTimes(1); + }); + + it('should throw UserNotFoundException when not found', async () => { + userRepository.get.mockResolvedValue(null); + + await expect( + handler.execute(new RemoveUserCommand({}, 'missing')), + ).rejects.toThrow(UserNotFoundException); + }); +}); diff --git a/packages/nestjs-user/src/application/commands/handlers/__tests__/set-user-password.handler.spec.ts b/packages/nestjs-user/src/application/commands/handlers/__tests__/set-user-password.handler.spec.ts new file mode 100644 index 000000000..5612a87b7 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/__tests__/set-user-password.handler.spec.ts @@ -0,0 +1,51 @@ +import { createMockCommandBus } from '@concepta/nestjs-core/testing'; + +import { + createMockTxScope, + createMockUserEntity, + createMockUserRepository, + toUserDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { type UserCredentials } from '../../../../domain/aggregates/user-credentials.js'; +import { UserNotFoundException } from '../../../exceptions/user-not-found.exception.js'; +import { type CreateUserCredentialCommand } from '../../impl/create-user-credential.command.js'; +import { SetUserPasswordCommand } from '../../impl/set-user-password.command.js'; +import { SetUserPasswordHandler } from '../set-user-password.handler.js'; + +describe(SetUserPasswordHandler.name, () => { + const userRepository = createMockUserRepository(); + const commandBus = createMockCommandBus(); + const txScope = createMockTxScope(); + const mockCredentials = {} as UserCredentials; + + let handler: SetUserPasswordHandler; + + beforeEach(() => { + vi.clearAllMocks(); + commandBus.execute.mockResolvedValue(mockCredentials); + handler = new SetUserPasswordHandler(userRepository, commandBus, txScope); + }); + + it('should dispatch CreateUserCredentialCommand when user found', async () => { + userRepository.get.mockResolvedValue(toUserDomain(createMockUserEntity())); + + const result = await handler.execute( + new SetUserPasswordCommand({}, 'user-1', 'secret'), + ); + + expect(result).toBe(mockCredentials); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + const dispatched = commandBus.execute.mock + .calls[0][0] as CreateUserCredentialCommand; + expect(dispatched.userId).toBe('user-1'); + expect(dispatched.password).toBe('secret'); + }); + + it('should throw UserNotFoundException when not found', async () => { + userRepository.get.mockResolvedValue(null); + + await expect( + handler.execute(new SetUserPasswordCommand({}, 'missing', 'secret')), + ).rejects.toThrow(UserNotFoundException); + }); +}); diff --git a/packages/nestjs-user/src/application/commands/handlers/__tests__/update-user-credential.handler.spec.ts b/packages/nestjs-user/src/application/commands/handlers/__tests__/update-user-credential.handler.spec.ts new file mode 100644 index 000000000..77880d735 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/__tests__/update-user-credential.handler.spec.ts @@ -0,0 +1,52 @@ +import { AppContextHost } from '@concepta/nestjs-core'; + +import { + createMockTxScope, + createMockUserCredentialsService, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { UpdateUserCredentialCommand } from '../../impl/update-user-credential.command.js'; +import { UpdateUserCredentialHandler } from '../update-user-credential.handler.js'; + +describe(UpdateUserCredentialHandler.name, () => { + const userCredentialsService = createMockUserCredentialsService(); + const txScope = createMockTxScope(); + + let handler: UpdateUserCredentialHandler; + + beforeEach(() => { + vi.clearAllMocks(); + handler = new UpdateUserCredentialHandler(userCredentialsService, txScope); + }); + + it('should delegate to userCredentialsService.updatePassword', async () => { + const passwordDto = { password: 'new-pass', passwordCurrent: 'old-pass' }; + + await handler.execute( + new UpdateUserCredentialCommand({}, 'user-1', passwordDto), + ); + + expect(userCredentialsService.updatePassword).toHaveBeenCalledTimes(1); + const [ctx, , userId, password, passwordCurrent] = + userCredentialsService.updatePassword.mock.calls[0]; + expect(ctx).toBeInstanceOf(AppContextHost); + expect(userId).toBe('user-1'); + expect(password).toBe('new-pass'); + expect(passwordCurrent).toBe('old-pass'); + }); + + it('should work without passwordCurrent', async () => { + const passwordDto = { password: 'new-pass' }; + + await handler.execute( + new UpdateUserCredentialCommand({}, 'user-1', passwordDto), + ); + + expect(userCredentialsService.updatePassword).toHaveBeenCalledTimes(1); + const [ctx, , userId, password, passwordCurrent] = + userCredentialsService.updatePassword.mock.calls[0]; + expect(ctx).toBeInstanceOf(AppContextHost); + expect(userId).toBe('user-1'); + expect(password).toBe('new-pass'); + expect(passwordCurrent).toBeUndefined(); + }); +}); diff --git a/packages/nestjs-user/src/application/commands/handlers/__tests__/update-user-password.handler.spec.ts b/packages/nestjs-user/src/application/commands/handlers/__tests__/update-user-password.handler.spec.ts new file mode 100644 index 000000000..aaebba827 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/__tests__/update-user-password.handler.spec.ts @@ -0,0 +1,57 @@ +import { createMockCommandBus } from '@concepta/nestjs-core/testing'; + +import { + createMockTxScope, + createMockUserEntity, + createMockUserRepository, + toUserDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { UserNotFoundException } from '../../../exceptions/user-not-found.exception.js'; +import { type UpdateUserCredentialCommand } from '../../impl/update-user-credential.command.js'; +import { UpdateUserPasswordCommand } from '../../impl/update-user-password.command.js'; +import { UpdateUserPasswordHandler } from '../update-user-password.handler.js'; + +describe(UpdateUserPasswordHandler.name, () => { + const userRepository = createMockUserRepository(); + const commandBus = createMockCommandBus(); + const txScope = createMockTxScope(); + + let handler: UpdateUserPasswordHandler; + + beforeEach(() => { + vi.clearAllMocks(); + handler = new UpdateUserPasswordHandler( + userRepository, + commandBus, + txScope, + ); + }); + + it('should dispatch UpdateUserCredentialCommand when user found', async () => { + userRepository.get.mockResolvedValue(toUserDomain(createMockUserEntity())); + + const passwordDto = { password: 'new-pass', passwordCurrent: 'old-pass' }; + + await handler.execute( + new UpdateUserPasswordCommand({}, 'user-1', passwordDto), + ); + + expect(commandBus.execute).toHaveBeenCalledTimes(1); + const dispatched = commandBus.execute.mock + .calls[0][0] as UpdateUserCredentialCommand; + expect(dispatched.userId).toBe('user-1'); + expect(dispatched.passwordDto).toBe(passwordDto); + }); + + it('should throw UserNotFoundException when not found', async () => { + userRepository.get.mockResolvedValue(null); + + await expect( + handler.execute( + new UpdateUserPasswordCommand({}, 'missing', { + password: 'new', + }), + ), + ).rejects.toThrow(UserNotFoundException); + }); +}); diff --git a/packages/nestjs-user/src/application/commands/handlers/__tests__/update-user.handler.spec.ts b/packages/nestjs-user/src/application/commands/handlers/__tests__/update-user.handler.spec.ts new file mode 100644 index 000000000..ed60195d9 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/__tests__/update-user.handler.spec.ts @@ -0,0 +1,45 @@ +import { + createMockEventPublisher, + createMockTxScope, + createMockUserEntity, + createMockUserRepository, + toUserDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { User } from '../../../../domain/aggregates/user.js'; +import { UserNotFoundException } from '../../../exceptions/user-not-found.exception.js'; +import { UpdateUserCommand } from '../../impl/update-user.command.js'; +import { UpdateUserHandler } from '../update-user.handler.js'; + +describe(UpdateUserHandler.name, () => { + const userRepository = createMockUserRepository(); + const eventPublisher = createMockEventPublisher(); + const txScope = createMockTxScope(); + + let handler: UpdateUserHandler; + + beforeEach(() => { + vi.clearAllMocks(); + handler = new UpdateUserHandler(userRepository, eventPublisher, txScope); + }); + + it('should update and return user when found', async () => { + userRepository.get.mockResolvedValue(toUserDomain(createMockUserEntity())); + + const result = await handler.execute( + new UpdateUserCommand({}, 'user-1', { active: false }), + ); + + expect(result).toBeInstanceOf(User); + expect(result.active).toBe(false); + expect(result.version).toBe(2); + expect(userRepository.save).toHaveBeenCalledTimes(1); + }); + + it('should throw UserNotFoundException when not found', async () => { + userRepository.get.mockResolvedValue(null); + + await expect( + handler.execute(new UpdateUserCommand({}, 'missing', { active: false })), + ).rejects.toThrow(UserNotFoundException); + }); +}); diff --git a/packages/nestjs-user/src/application/commands/handlers/create-user-credential.handler.ts b/packages/nestjs-user/src/application/commands/handlers/create-user-credential.handler.ts new file mode 100644 index 000000000..1c33356e9 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/create-user-credential.handler.ts @@ -0,0 +1,35 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { UserCredentials } from '../../../domain/aggregates/user-credentials.js'; +import { UserCredentialsService } from '../../../domain/services/user-credentials.service.js'; +import { CreateUserCredentialCommand } from '../impl/create-user-credential.command.js'; + +@CommandHandler(CreateUserCredentialCommand) +export class CreateUserCredentialHandler implements ICommandHandler< + CreateUserCredentialCommand, + UserCredentials +> { + constructor( + private readonly userCredentialsService: UserCredentialsService, + private readonly txScope: TransactionScope, + ) {} + + async execute( + command: CreateUserCredentialCommand, + ): Promise { + const { ctx, userId, password } = command; + return this.txScope.run(ctx, async (txCtx) => { + const eventContext = createEventContext(txCtx, {}, {}); + + return this.userCredentialsService.setPassword( + txCtx, + eventContext, + userId, + password, + ); + }); + } +} diff --git a/packages/nestjs-user/src/application/commands/handlers/create-user.handler.ts b/packages/nestjs-user/src/application/commands/handlers/create-user.handler.ts new file mode 100644 index 000000000..cb68bba0c --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/create-user.handler.ts @@ -0,0 +1,69 @@ +import { Inject, Optional } from '@nestjs/common'; +import { + CommandBus, + CommandHandler, + EventPublisher, + ICommandHandler, +} from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { User } from '../../../domain/aggregates/user.js'; +import { UserPasswordPort } from '../../../domain/ports/user-password.port.js'; +import { UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../../user.constants.js'; +import { CreateUserCredentialCommand } from '../impl/create-user-credential.command.js'; +import { CreateUserCommand } from '../impl/create-user.command.js'; + +@CommandHandler(CreateUserCommand) +export class CreateUserHandler implements ICommandHandler { + constructor( + @Inject(USER_REPOSITORY_TOKEN) + private readonly userRepository: UserRepositoryInterface, + private readonly commandBus: CommandBus, + private readonly eventPublisher: EventPublisher, + private readonly txScope: TransactionScope, + // Only provided when a credentials entity/repository is configured + // (see createUserCredentialProviders) — CreateUserHandler itself is + // registered unconditionally, so this must stay optional. + @Optional() + private readonly passwordPort?: UserPasswordPort, + ) {} + + async execute(command: CreateUserCommand): Promise { + const { ctx, dto } = command; + const userEventContext = createEventContext(ctx, {}, {}); + + // Validate and hash the password before anything is written, so a weak + // password fails here rather than leaving a persisted, credential-less + // user behind for a rollback to clean up. + const passwordStorage = dto.password + ? await this.passwordPort?.create(dto.password) + : undefined; + + return this.txScope.run(ctx, async (txCtx) => { + const user = this.eventPublisher.mergeObjectContext( + User.create(userEventContext, dto), + ); + + await this.userRepository.save(txCtx, user); + + // create initial credentials if password provided + if (dto.password) { + await this.commandBus.execute( + new CreateUserCredentialCommand( + txCtx, + user.id, + passwordStorage ?? dto.password, + ), + ); + } + + txCtx.trx.onCommit(() => user.commit()); + txCtx.trx.onRollback(() => user.uncommit()); + + return user; + }); + } +} diff --git a/packages/nestjs-user/src/application/commands/handlers/remove-user.handler.ts b/packages/nestjs-user/src/application/commands/handlers/remove-user.handler.ts new file mode 100644 index 000000000..226d9fcca --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/remove-user.handler.ts @@ -0,0 +1,48 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { User } from '../../../domain/aggregates/user.js'; +import { UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../../user.constants.js'; +import { UserNotFoundException } from '../../exceptions/user-not-found.exception.js'; +import { RemoveUserCommand } from '../impl/remove-user.command.js'; + +@CommandHandler(RemoveUserCommand) +export class RemoveUserHandler implements ICommandHandler< + RemoveUserCommand, + User +> { + constructor( + @Inject(USER_REPOSITORY_TOKEN) + private readonly userRepository: UserRepositoryInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: RemoveUserCommand): Promise { + const { ctx, id } = command; + const eventContext = createEventContext(ctx, {}, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const existing = await this.userRepository.get(txCtx, id); + + if (!existing) { + throw new UserNotFoundException({ id }); + } + + const user = this.eventPublisher.mergeObjectContext(existing); + + user.remove(eventContext); + + await this.userRepository.remove(txCtx, user); + + txCtx.trx.onCommit(() => user.commit()); + txCtx.trx.onRollback(() => user.uncommit()); + + return user; + }); + } +} diff --git a/packages/nestjs-user/src/application/commands/handlers/set-user-password.handler.ts b/packages/nestjs-user/src/application/commands/handlers/set-user-password.handler.ts new file mode 100644 index 000000000..bc022797e --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/set-user-password.handler.ts @@ -0,0 +1,40 @@ +import { Inject } from '@nestjs/common'; +import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { UserCredentials } from '../../../domain/aggregates/user-credentials.js'; +import { UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../../user.constants.js'; +import { UserNotFoundException } from '../../exceptions/user-not-found.exception.js'; +import { CreateUserCredentialCommand } from '../impl/create-user-credential.command.js'; +import { SetUserPasswordCommand } from '../impl/set-user-password.command.js'; + +@CommandHandler(SetUserPasswordCommand) +export class SetUserPasswordHandler implements ICommandHandler< + SetUserPasswordCommand, + UserCredentials +> { + constructor( + @Inject(USER_REPOSITORY_TOKEN) + private readonly userRepository: UserRepositoryInterface, + private readonly commandBus: CommandBus, + private readonly txScope: TransactionScope, + ) {} + + async execute(command: SetUserPasswordCommand): Promise { + const { ctx, userId, password } = command; + return this.txScope.run(ctx, async (txCtx) => { + // verify user exists + const user = await this.userRepository.get(txCtx, userId); + + if (!user) { + throw new UserNotFoundException({ id: userId }); + } + + return this.commandBus.execute( + new CreateUserCredentialCommand(txCtx, userId, password), + ); + }); + } +} diff --git a/packages/nestjs-user/src/application/commands/handlers/update-user-credential.handler.ts b/packages/nestjs-user/src/application/commands/handlers/update-user-credential.handler.ts new file mode 100644 index 000000000..1ec61343a --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/update-user-credential.handler.ts @@ -0,0 +1,33 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { UserCredentialsService } from '../../../domain/services/user-credentials.service.js'; +import { UpdateUserCredentialCommand } from '../impl/update-user-credential.command.js'; + +@CommandHandler(UpdateUserCredentialCommand) +export class UpdateUserCredentialHandler implements ICommandHandler< + UpdateUserCredentialCommand, + void +> { + constructor( + private readonly userCredentialsService: UserCredentialsService, + private readonly txScope: TransactionScope, + ) {} + + async execute(command: UpdateUserCredentialCommand): Promise { + const { ctx, userId, passwordDto } = command; + return this.txScope.run(ctx, async (txCtx) => { + const eventContext = createEventContext(txCtx, {}, {}); + + await this.userCredentialsService.updatePassword( + txCtx, + eventContext, + userId, + passwordDto.password, + passwordDto.passwordCurrent, + ); + }); + } +} diff --git a/packages/nestjs-user/src/application/commands/handlers/update-user-password.handler.ts b/packages/nestjs-user/src/application/commands/handlers/update-user-password.handler.ts new file mode 100644 index 000000000..05df2347e --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/update-user-password.handler.ts @@ -0,0 +1,39 @@ +import { Inject } from '@nestjs/common'; +import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../../user.constants.js'; +import { UserNotFoundException } from '../../exceptions/user-not-found.exception.js'; +import { UpdateUserCredentialCommand } from '../impl/update-user-credential.command.js'; +import { UpdateUserPasswordCommand } from '../impl/update-user-password.command.js'; + +@CommandHandler(UpdateUserPasswordCommand) +export class UpdateUserPasswordHandler implements ICommandHandler< + UpdateUserPasswordCommand, + void +> { + constructor( + @Inject(USER_REPOSITORY_TOKEN) + private readonly userRepository: UserRepositoryInterface, + private readonly commandBus: CommandBus, + private readonly txScope: TransactionScope, + ) {} + + async execute(command: UpdateUserPasswordCommand): Promise { + const { ctx, userId, passwordDto } = command; + return this.txScope.run(ctx, async (txCtx) => { + // verify user exists + const user = await this.userRepository.get(txCtx, userId); + + if (!user) { + throw new UserNotFoundException({ id: userId }); + } + + await this.commandBus.execute( + new UpdateUserCredentialCommand(txCtx, userId, passwordDto), + ); + }); + } +} diff --git a/packages/nestjs-user/src/application/commands/handlers/update-user.handler.ts b/packages/nestjs-user/src/application/commands/handlers/update-user.handler.ts new file mode 100644 index 000000000..52e7fcb8f --- /dev/null +++ b/packages/nestjs-user/src/application/commands/handlers/update-user.handler.ts @@ -0,0 +1,45 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; + +import { createEventContext } from '@concepta/nestjs-core'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { User } from '../../../domain/aggregates/user.js'; +import { UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../../user.constants.js'; +import { UserNotFoundException } from '../../exceptions/user-not-found.exception.js'; +import { UpdateUserCommand } from '../impl/update-user.command.js'; + +@CommandHandler(UpdateUserCommand) +export class UpdateUserHandler implements ICommandHandler { + constructor( + @Inject(USER_REPOSITORY_TOKEN) + private readonly userRepository: UserRepositoryInterface, + private readonly eventPublisher: EventPublisher, + private readonly txScope: TransactionScope, + ) {} + + async execute(command: UpdateUserCommand): Promise { + const { ctx, id, dto } = command; + const eventContext = createEventContext(ctx, {}, {}); + + return this.txScope.run(ctx, async (txCtx) => { + const existing = await this.userRepository.get(txCtx, id); + + if (!existing) { + throw new UserNotFoundException({ id }); + } + + const user = this.eventPublisher.mergeObjectContext(existing); + + user.update(eventContext, dto); + + await this.userRepository.save(txCtx, user); + + txCtx.trx.onCommit(() => user.commit()); + txCtx.trx.onRollback(() => user.uncommit()); + + return user; + }); + } +} diff --git a/packages/nestjs-user/src/application/commands/impl/__tests__/commands.spec.ts b/packages/nestjs-user/src/application/commands/impl/__tests__/commands.spec.ts new file mode 100644 index 000000000..12a49d7a6 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/impl/__tests__/commands.spec.ts @@ -0,0 +1,83 @@ +import { type PasswordUpdateInterface } from '@concepta/nestjs-password'; + +import { type UserCreatableInterface } from '../../../../domain/interfaces/user-creatable.interface.js'; +import { type UserUpdatableInterface } from '../../../../domain/interfaces/user-updatable.interface.js'; +import { CreateUserCredentialCommand } from '../create-user-credential.command.js'; +import { CreateUserCommand } from '../create-user.command.js'; +import { RemoveUserCommand } from '../remove-user.command.js'; +import { SetUserPasswordCommand } from '../set-user-password.command.js'; +import { UpdateUserCredentialCommand } from '../update-user-credential.command.js'; +import { UpdateUserPasswordCommand } from '../update-user-password.command.js'; +import { UpdateUserCommand } from '../update-user.command.js'; + +describe(CreateUserCommand.name, () => { + it('should store ctx and dto', () => { + const dto: UserCreatableInterface = { + email: 'a@b.com', + username: 'john', + }; + const cmd = new CreateUserCommand({}, dto); + expect(cmd.dto).toBe(dto); + }); +}); + +describe(UpdateUserCommand.name, () => { + it('should store ctx, id, and dto', () => { + const dto: Partial = { active: false }; + const cmd = new UpdateUserCommand({}, 'user-1', dto); + expect(cmd.id).toBe('user-1'); + expect(cmd.dto).toBe(dto); + }); +}); + +describe(RemoveUserCommand.name, () => { + it('should store ctx and id', () => { + const cmd = new RemoveUserCommand({}, 'user-1'); + expect(cmd.id).toBe('user-1'); + }); +}); + +describe(SetUserPasswordCommand.name, () => { + it('should store ctx, userId, and password', () => { + const cmd = new SetUserPasswordCommand({}, 'user-1', 'pass123'); + expect(cmd.userId).toBe('user-1'); + expect(cmd.password).toBe('pass123'); + }); +}); + +describe(CreateUserCredentialCommand.name, () => { + it('should store ctx, userId, and password', () => { + const cmd = new CreateUserCredentialCommand({}, 'user-1', 'pass123'); + expect(cmd.userId).toBe('user-1'); + expect(cmd.password).toBe('pass123'); + }); + + it('should store an already-hashed password storage object', () => { + const passwordStorage = { passwordHash: 'hashed' }; + const cmd = new CreateUserCredentialCommand({}, 'user-1', passwordStorage); + expect(cmd.password).toBe(passwordStorage); + }); +}); + +describe(UpdateUserPasswordCommand.name, () => { + it('should store ctx, userId, and passwordDto', () => { + const passwordDto: PasswordUpdateInterface = { + password: 'new-pass', + passwordCurrent: 'old-pass', + }; + const cmd = new UpdateUserPasswordCommand({}, 'user-1', passwordDto); + expect(cmd.userId).toBe('user-1'); + expect(cmd.passwordDto).toBe(passwordDto); + }); +}); + +describe(UpdateUserCredentialCommand.name, () => { + it('should store ctx, userId, and passwordDto', () => { + const passwordDto: PasswordUpdateInterface = { + password: 'new-pass', + }; + const cmd = new UpdateUserCredentialCommand({}, 'user-1', passwordDto); + expect(cmd.userId).toBe('user-1'); + expect(cmd.passwordDto).toBe(passwordDto); + }); +}); diff --git a/packages/nestjs-user/src/application/commands/impl/create-user-credential.command.ts b/packages/nestjs-user/src/application/commands/impl/create-user-credential.command.ts new file mode 100644 index 000000000..2b30408d9 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/impl/create-user-credential.command.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; +import { type PasswordStorageInterface } from '@concepta/nestjs-password'; + +import { type UserCredentials } from '../../../domain/aggregates/user-credentials.js'; + +export class CreateUserCredentialCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly userId: ReferenceId, + public readonly password: string | PasswordStorageInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-user/src/application/commands/impl/create-user.command.ts b/packages/nestjs-user/src/application/commands/impl/create-user.command.ts new file mode 100644 index 000000000..10a58d968 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/impl/create-user.command.ts @@ -0,0 +1,14 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type User } from '../../../domain/aggregates/user.js'; +import { type UserCreatableInterface } from '../../../domain/interfaces/user-creatable.interface.js'; + +export class CreateUserCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly dto: UserCreatableInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-user/src/application/commands/impl/remove-user.command.ts b/packages/nestjs-user/src/application/commands/impl/remove-user.command.ts new file mode 100644 index 000000000..d248996da --- /dev/null +++ b/packages/nestjs-user/src/application/commands/impl/remove-user.command.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type User } from '../../../domain/aggregates/user.js'; + +export class RemoveUserCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-user/src/application/commands/impl/set-user-password.command.ts b/packages/nestjs-user/src/application/commands/impl/set-user-password.command.ts new file mode 100644 index 000000000..05c2762cd --- /dev/null +++ b/packages/nestjs-user/src/application/commands/impl/set-user-password.command.ts @@ -0,0 +1,16 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type UserCredentials } from '../../../domain/aggregates/user-credentials.js'; + +export class SetUserPasswordCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly userId: ReferenceId, + public readonly password: string, + ) { + super(); + } +} diff --git a/packages/nestjs-user/src/application/commands/impl/update-user-credential.command.ts b/packages/nestjs-user/src/application/commands/impl/update-user-credential.command.ts new file mode 100644 index 000000000..709814de9 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/impl/update-user-credential.command.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; +import { type PasswordUpdateInterface } from '@concepta/nestjs-password'; + +export class UpdateUserCredentialCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly userId: ReferenceId, + public readonly passwordDto: PasswordUpdateInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-user/src/application/commands/impl/update-user-password.command.ts b/packages/nestjs-user/src/application/commands/impl/update-user-password.command.ts new file mode 100644 index 000000000..be3b73a70 --- /dev/null +++ b/packages/nestjs-user/src/application/commands/impl/update-user-password.command.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; +import { type PasswordUpdateInterface } from '@concepta/nestjs-password'; + +export class UpdateUserPasswordCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly userId: ReferenceId, + public readonly passwordDto: PasswordUpdateInterface, + ) { + super(); + } +} diff --git a/packages/nestjs-user/src/application/commands/impl/update-user.command.ts b/packages/nestjs-user/src/application/commands/impl/update-user.command.ts new file mode 100644 index 000000000..db234cecf --- /dev/null +++ b/packages/nestjs-user/src/application/commands/impl/update-user.command.ts @@ -0,0 +1,17 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Command } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type User } from '../../../domain/aggregates/user.js'; +import { type UserUpdatableInterface } from '../../../domain/interfaces/user-updatable.interface.js'; + +export class UpdateUserCommand extends Command { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly id: ReferenceId, + public readonly dto: Partial, + ) { + super(); + } +} diff --git a/packages/nestjs-user/src/application/exceptions/user-not-found.exception.ts b/packages/nestjs-user/src/application/exceptions/user-not-found.exception.ts new file mode 100644 index 000000000..c4e19187b --- /dev/null +++ b/packages/nestjs-user/src/application/exceptions/user-not-found.exception.ts @@ -0,0 +1,29 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type RuntimeException } from '@concepta/nestjs-core'; + +import { UserException } from '../../domain/exceptions/user.exception.js'; + +export class UserNotFoundException extends UserException { + declare context: RuntimeException['context'] & { + id: string; + }; + + constructor(options: { id: string; message?: string }) { + const { id, message = 'User not found for id=%s' } = options; + + super({ + httpStatus: HttpStatus.NOT_FOUND, + message, + messageParams: [id], + fault: 'client', + }); + + this.errorCode = 'USER_NOT_FOUND_ERROR'; + + this.context = { + ...this.context, + id, + }; + } +} diff --git a/packages/nestjs-user/src/application/queries/handlers/__tests__/query-handlers.spec.ts b/packages/nestjs-user/src/application/queries/handlers/__tests__/query-handlers.spec.ts new file mode 100644 index 000000000..447e2fd50 --- /dev/null +++ b/packages/nestjs-user/src/application/queries/handlers/__tests__/query-handlers.spec.ts @@ -0,0 +1,128 @@ +import { type Mocked } from 'vitest'; + +import { + createMockUserEntity, + createMockUserRepository, + toUserDomain, +} from '../../../../__tests__/helpers/mock.helpers.js'; +import { type UserRepositoryInterface } from '../../../../domain/repositories/user-repository.interface.js'; +import { GetUserByEmailQuery } from '../../impl/get-user-by-email.query.js'; +import { GetUserBySubjectQuery } from '../../impl/get-user-by-subject.query.js'; +import { GetUserByUsernameQuery } from '../../impl/get-user-by-username.query.js'; +import { GetUserQuery } from '../../impl/get-user.query.js'; +import { GetUserByEmailHandler } from '../get-user-by-email.handler.js'; +import { GetUserBySubjectHandler } from '../get-user-by-subject.handler.js'; +import { GetUserByUsernameHandler } from '../get-user-by-username.handler.js'; +import { GetUserHandler } from '../get-user.handler.js'; + +const mockUser = toUserDomain(createMockUserEntity()); + +describe(GetUserHandler.name, () => { + let handler: GetUserHandler; + let repo: Mocked; + + beforeEach(() => { + repo = createMockUserRepository(); + handler = new GetUserHandler(repo); + }); + + it('should return user when found', async () => { + repo.get.mockResolvedValue(mockUser); + const result = await handler.execute(new GetUserQuery({}, 'user-1')); + expect(result).toBe(mockUser); + expect(repo.get).toHaveBeenCalledWith(expect.any(Object), 'user-1'); + }); + + it('should return null when not found', async () => { + repo.get.mockResolvedValue(null); + const result = await handler.execute(new GetUserQuery({}, 'missing')); + expect(result).toBeNull(); + }); +}); + +describe(GetUserByEmailHandler.name, () => { + let handler: GetUserByEmailHandler; + let repo: Mocked; + + beforeEach(() => { + repo = createMockUserRepository(); + handler = new GetUserByEmailHandler(repo); + }); + + it('should return user when found', async () => { + repo.findByEmail.mockResolvedValue(mockUser); + const result = await handler.execute( + new GetUserByEmailQuery({}, 'a@b.com'), + ); + expect(result).toBe(mockUser); + expect(repo.findByEmail).toHaveBeenCalledWith( + expect.any(Object), + 'a@b.com', + ); + }); + + it('should return null when not found', async () => { + repo.findByEmail.mockResolvedValue(null); + const result = await handler.execute( + new GetUserByEmailQuery({}, 'missing@b.com'), + ); + expect(result).toBeNull(); + }); +}); + +describe(GetUserByUsernameHandler.name, () => { + let handler: GetUserByUsernameHandler; + let repo: Mocked; + + beforeEach(() => { + repo = createMockUserRepository(); + handler = new GetUserByUsernameHandler(repo); + }); + + it('should return user when found', async () => { + repo.findByUsername.mockResolvedValue(mockUser); + const result = await handler.execute( + new GetUserByUsernameQuery({}, 'john'), + ); + expect(result).toBe(mockUser); + expect(repo.findByUsername).toHaveBeenCalledWith( + expect.any(Object), + 'john', + ); + }); + + it('should return null when not found', async () => { + repo.findByUsername.mockResolvedValue(null); + const result = await handler.execute( + new GetUserByUsernameQuery({}, 'missing'), + ); + expect(result).toBeNull(); + }); +}); + +describe(GetUserBySubjectHandler.name, () => { + let handler: GetUserBySubjectHandler; + let repo: Mocked; + + beforeEach(() => { + repo = createMockUserRepository(); + handler = new GetUserBySubjectHandler(repo); + }); + + it('should return user when found', async () => { + repo.get.mockResolvedValue(mockUser); + const result = await handler.execute( + new GetUserBySubjectQuery({}, 'sub-1'), + ); + expect(result).toBe(mockUser); + expect(repo.get).toHaveBeenCalledWith(expect.any(Object), 'sub-1'); + }); + + it('should return null when not found', async () => { + repo.get.mockResolvedValue(null); + const result = await handler.execute( + new GetUserBySubjectQuery({}, 'missing'), + ); + expect(result).toBeNull(); + }); +}); diff --git a/packages/nestjs-user/src/application/queries/handlers/get-user-by-email.handler.ts b/packages/nestjs-user/src/application/queries/handlers/get-user-by-email.handler.ts new file mode 100644 index 000000000..276ae7670 --- /dev/null +++ b/packages/nestjs-user/src/application/queries/handlers/get-user-by-email.handler.ts @@ -0,0 +1,20 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { User } from '../../../domain/aggregates/user.js'; +import { UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../../user.constants.js'; +import { GetUserByEmailQuery } from '../impl/get-user-by-email.query.js'; + +@QueryHandler(GetUserByEmailQuery) +export class GetUserByEmailHandler implements IQueryHandler { + constructor( + @Inject(USER_REPOSITORY_TOKEN) + private readonly userRepository: UserRepositoryInterface, + ) {} + + async execute(query: GetUserByEmailQuery): Promise { + const { ctx, email } = query; + return this.userRepository.findByEmail(ctx, email); + } +} diff --git a/packages/nestjs-user/src/application/queries/handlers/get-user-by-subject.handler.ts b/packages/nestjs-user/src/application/queries/handlers/get-user-by-subject.handler.ts new file mode 100644 index 000000000..ec9eecdba --- /dev/null +++ b/packages/nestjs-user/src/application/queries/handlers/get-user-by-subject.handler.ts @@ -0,0 +1,20 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { User } from '../../../domain/aggregates/user.js'; +import { UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../../user.constants.js'; +import { GetUserBySubjectQuery } from '../impl/get-user-by-subject.query.js'; + +@QueryHandler(GetUserBySubjectQuery) +export class GetUserBySubjectHandler implements IQueryHandler { + constructor( + @Inject(USER_REPOSITORY_TOKEN) + private readonly userRepository: UserRepositoryInterface, + ) {} + + async execute(query: GetUserBySubjectQuery): Promise { + const { ctx, subject } = query; + return this.userRepository.get(ctx, subject); + } +} diff --git a/packages/nestjs-user/src/application/queries/handlers/get-user-by-username.handler.ts b/packages/nestjs-user/src/application/queries/handlers/get-user-by-username.handler.ts new file mode 100644 index 000000000..f4b06745b --- /dev/null +++ b/packages/nestjs-user/src/application/queries/handlers/get-user-by-username.handler.ts @@ -0,0 +1,20 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { User } from '../../../domain/aggregates/user.js'; +import { UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../../user.constants.js'; +import { GetUserByUsernameQuery } from '../impl/get-user-by-username.query.js'; + +@QueryHandler(GetUserByUsernameQuery) +export class GetUserByUsernameHandler implements IQueryHandler { + constructor( + @Inject(USER_REPOSITORY_TOKEN) + private readonly userRepository: UserRepositoryInterface, + ) {} + + async execute(query: GetUserByUsernameQuery): Promise { + const { ctx, username } = query; + return this.userRepository.findByUsername(ctx, username); + } +} diff --git a/packages/nestjs-user/src/application/queries/handlers/get-user.handler.ts b/packages/nestjs-user/src/application/queries/handlers/get-user.handler.ts new file mode 100644 index 000000000..8bf5f6671 --- /dev/null +++ b/packages/nestjs-user/src/application/queries/handlers/get-user.handler.ts @@ -0,0 +1,21 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { User } from '../../../domain/aggregates/user.js'; +import { UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../../user.constants.js'; +import { GetUserQuery } from '../impl/get-user.query.js'; + +@QueryHandler(GetUserQuery) +export class GetUserHandler implements IQueryHandler { + constructor( + @Inject(USER_REPOSITORY_TOKEN) + private readonly userRepository: UserRepositoryInterface, + ) {} + + async execute(query: GetUserQuery): Promise { + const { ctx, id } = query; + + return this.userRepository.get(ctx, id); + } +} diff --git a/packages/nestjs-user/src/application/queries/impl/__tests__/queries.spec.ts b/packages/nestjs-user/src/application/queries/impl/__tests__/queries.spec.ts new file mode 100644 index 000000000..00f6dbc9f --- /dev/null +++ b/packages/nestjs-user/src/application/queries/impl/__tests__/queries.spec.ts @@ -0,0 +1,32 @@ +import { GetUserByEmailQuery } from '../get-user-by-email.query.js'; +import { GetUserBySubjectQuery } from '../get-user-by-subject.query.js'; +import { GetUserByUsernameQuery } from '../get-user-by-username.query.js'; +import { GetUserQuery } from '../get-user.query.js'; + +describe(GetUserQuery.name, () => { + it('should store ctx and id', () => { + const query = new GetUserQuery({}, 'user-1'); + expect(query.id).toBe('user-1'); + }); +}); + +describe(GetUserByEmailQuery.name, () => { + it('should store ctx and email', () => { + const query = new GetUserByEmailQuery({}, 'a@b.com'); + expect(query.email).toBe('a@b.com'); + }); +}); + +describe(GetUserByUsernameQuery.name, () => { + it('should store ctx and username', () => { + const query = new GetUserByUsernameQuery({}, 'john'); + expect(query.username).toBe('john'); + }); +}); + +describe(GetUserBySubjectQuery.name, () => { + it('should store ctx and subject', () => { + const query = new GetUserBySubjectQuery({}, 'sub-1'); + expect(query.subject).toBe('sub-1'); + }); +}); diff --git a/packages/nestjs-user/src/application/queries/impl/get-user-by-email.query.ts b/packages/nestjs-user/src/application/queries/impl/get-user-by-email.query.ts new file mode 100644 index 000000000..1f210fd48 --- /dev/null +++ b/packages/nestjs-user/src/application/queries/impl/get-user-by-email.query.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type ReferenceEmail } from '@concepta/nestjs-core'; + +import { type User } from '../../../domain/aggregates/user.js'; + +export class GetUserByEmailQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly email: ReferenceEmail, + ) { + super(); + } +} diff --git a/packages/nestjs-user/src/application/queries/impl/get-user-by-subject.query.ts b/packages/nestjs-user/src/application/queries/impl/get-user-by-subject.query.ts new file mode 100644 index 000000000..922f2fba5 --- /dev/null +++ b/packages/nestjs-user/src/application/queries/impl/get-user-by-subject.query.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type ReferenceSubject } from '@concepta/nestjs-core'; + +import { type User } from '../../../domain/aggregates/user.js'; + +export class GetUserBySubjectQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly subject: ReferenceSubject, + ) { + super(); + } +} diff --git a/packages/nestjs-user/src/application/queries/impl/get-user-by-username.query.ts b/packages/nestjs-user/src/application/queries/impl/get-user-by-username.query.ts new file mode 100644 index 000000000..fd0ffffb4 --- /dev/null +++ b/packages/nestjs-user/src/application/queries/impl/get-user-by-username.query.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type ReferenceUsername } from '@concepta/nestjs-core'; + +import { type User } from '../../../domain/aggregates/user.js'; + +export class GetUserByUsernameQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly username: ReferenceUsername, + ) { + super(); + } +} diff --git a/packages/nestjs-user/src/application/queries/impl/get-user.query.ts b/packages/nestjs-user/src/application/queries/impl/get-user.query.ts new file mode 100644 index 000000000..8ffe20ada --- /dev/null +++ b/packages/nestjs-user/src/application/queries/impl/get-user.query.ts @@ -0,0 +1,15 @@ +import { type PlainLiteralObject } from '@nestjs/common'; +import { Query } from '@nestjs/cqrs'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type User } from '../../../domain/aggregates/user.js'; + +export class GetUserQuery extends Query { + constructor( + public readonly ctx: PlainLiteralObject, + public readonly id: ReferenceId, + ) { + super(); + } +} diff --git a/packages/nestjs-user/src/application/utils/__tests__/assert-user-id.util.spec.ts b/packages/nestjs-user/src/application/utils/__tests__/assert-user-id.util.spec.ts new file mode 100644 index 000000000..897850980 --- /dev/null +++ b/packages/nestjs-user/src/application/utils/__tests__/assert-user-id.util.spec.ts @@ -0,0 +1,41 @@ +import { HttpStatus } from '@nestjs/common'; + +import { UserException } from '../../../domain/exceptions/user.exception.js'; +import { assertUserId } from '../assert-user-id.util.js'; + +describe('assertUserId', () => { + it('should not throw for a valid string id', () => { + expect(() => assertUserId('abc-123')).not.toThrow(); + }); + + it('should throw UserException for an empty string', () => { + expect(() => assertUserId('')).toThrow(UserException); + }); + + it('should throw UserException for a whitespace-only string', () => { + expect(() => assertUserId(' ')).toThrow(UserException); + }); + + it('should throw UserException for undefined', () => { + expect(() => assertUserId(undefined)).toThrow(UserException); + }); + + it('should throw UserException for null', () => { + expect(() => assertUserId(null)).toThrow(UserException); + }); + + it('should throw UserException for a number', () => { + expect(() => assertUserId(42)).toThrow(UserException); + }); + + it('should throw with httpStatus BAD_REQUEST and a safe message', () => { + try { + assertUserId(42); + throw new Error('Expected UserException'); + } catch (e) { + expect(e).toBeInstanceOf(UserException); + expect((e as UserException).httpStatus).toBe(HttpStatus.BAD_REQUEST); + expect((e as UserException).safeMessage).toBe('Invalid id'); + } + }); +}); diff --git a/packages/nestjs-user/src/application/utils/assert-user-id.util.ts b/packages/nestjs-user/src/application/utils/assert-user-id.util.ts new file mode 100644 index 000000000..91fed5a41 --- /dev/null +++ b/packages/nestjs-user/src/application/utils/assert-user-id.util.ts @@ -0,0 +1,26 @@ +import { HttpStatus } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { UserException } from '../../domain/exceptions/user.exception.js'; + +/** + * Asserts that `value` is a non-empty string id. + * + * Classified `fault: 'client'` for the common case of a caller sending a + * malformed id directly. A controller whose id param is configured with + * `type: 'number'` (see `CrudParams`) will also route through here on every + * request — that's a module wiring mistake, not a client one, but the + * distinction isn't visible from inside this assertion. + */ +export function assertUserId(value: unknown): asserts value is ReferenceId { + if (typeof value !== 'string' || value.trim() === '') { + throw new UserException({ + message: 'Expected user id to be a non-empty string, got %s', + messageParams: [typeof value], + safeMessage: 'Invalid id', + httpStatus: HttpStatus.BAD_REQUEST, + fault: 'client', + }); + } +} diff --git a/packages/nestjs-user/src/config/user-default.config.ts b/packages/nestjs-user/src/config/user-default.config.ts deleted file mode 100644 index 580d3af77..000000000 --- a/packages/nestjs-user/src/config/user-default.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -import { UserSettingsInterface } from '../interfaces/user-settings.interface'; -import { - USER_MODULE_DEFAULT_SETTINGS_TOKEN, - USER_MODULE_USER_PASSWORD_HISTORY_LIMIT_DAYS_DEFAULT, -} from '../user.constants'; - -/** - * Default configuration for User module. - */ -export const userDefaultConfig = registerAs( - USER_MODULE_DEFAULT_SETTINGS_TOKEN, - (): UserSettingsInterface => { - // password history tracking is disabled by default - const enabled = process.env?.USER_PASSWORD_HISTORY_ENABLED === 'true'; - - // determine default limitation days - const limitDays = process.env?.USER_PASSWORD_HISTORY_MAX_DAYS?.length - ? Number(process.env?.USER_PASSWORD_HISTORY_MAX_DAYS) - : USER_MODULE_USER_PASSWORD_HISTORY_LIMIT_DAYS_DEFAULT; - - return { - passwordHistory: { - enabled, - limitDays: isNaN(limitDays) || limitDays < 1 ? undefined : limitDays, - }, - }; - }, -); diff --git a/packages/nestjs-user/src/controllers/user-crud.controller.e2e-spec.ts b/packages/nestjs-user/src/controllers/user-crud.controller.e2e-spec.ts deleted file mode 100644 index b6e6b9989..000000000 --- a/packages/nestjs-user/src/controllers/user-crud.controller.e2e-spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import supertest from 'supertest'; - -import { - CallHandler, - ExecutionContext, - INestApplication, -} from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { - AccessControlFilter, - AccessControlGuard, -} from '@concepta/nestjs-access-control'; -import { AuthJwtGuard } from '@concepta/nestjs-auth-jwt'; -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { UserFactory } from '../user.factory'; -import { UserSeeder } from '../user.seeder'; - -import { AppModuleCrudFixture } from '../__fixtures__/app.module.crud.fixture'; -import { UserEntityFixture } from '../__fixtures__/user.entity.fixture'; - -describe('UserCrudController (e2e)', () => { - describe('Normal CRUD flow', () => { - let app: INestApplication; - let seedingSource: SeedingSource; - let authJwtGuard: AuthJwtGuard; - let accessControlGuard: AccessControlGuard; - let accessControlFilter: AccessControlFilter; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleCrudFixture], - }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - - authJwtGuard = app.get(AuthJwtGuard); - jest.spyOn(authJwtGuard, 'canActivate').mockResolvedValue(true); - - accessControlGuard = app.get(AccessControlGuard); - jest.spyOn(accessControlGuard, 'canActivate').mockResolvedValue(true); - - accessControlFilter = app.get(AccessControlFilter); - jest - .spyOn(accessControlFilter, 'intercept') - .mockImplementation((_context: ExecutionContext, next: CallHandler) => { - return Promise.resolve(next.handle()); - }); - - seedingSource = new SeedingSource({ - dataSource: app.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - const userSeeder = new UserSeeder({ - factories: [new UserFactory({ entity: UserEntityFixture })], - }); - - await seedingSource.run.one(userSeeder); - }); - - afterEach(async () => { - jest.clearAllMocks(); - if (app) await app.close(); - }); - - it('GET /user', async () => { - await supertest(app.getHttpServer()).get('/user?limit=10').expect(200); - }); - - it('GET /user/:id', async () => { - // get a user so we have an id - const response = await supertest(app.getHttpServer()) - .get('/user?limit=1') - .expect(200); - - // get one using that id - await supertest(app.getHttpServer()) - .get(`/user/${response.body.data[0].id}`) - .expect(200); - }); - - it('POST /user', async () => { - await supertest(app.getHttpServer()) - .post('/user') - .send({ - username: 'user1', - email: 'user1@dispostable.com', - password: 'pass1', - }) - .expect(201); - }); - - it('POST /user (no password)', async () => { - await supertest(app.getHttpServer()) - .post('/user') - .send({ - username: 'user1', - email: 'user1@dispostable.com', - }) - .expect(201); - }); - - it('DELETE /user/:id', async () => { - // get a user so we have an id - const response = await supertest(app.getHttpServer()) - .get('/user?limit=1') - .expect(200); - - // delete one using that id - await supertest(app.getHttpServer()) - .delete(`/user/${response.body.data[0].id}`) - .expect(200); - }); - }); -}); diff --git a/packages/nestjs-user/src/domain/aggregates/__tests__/user-credentials.spec.ts b/packages/nestjs-user/src/domain/aggregates/__tests__/user-credentials.spec.ts new file mode 100644 index 000000000..11b078642 --- /dev/null +++ b/packages/nestjs-user/src/domain/aggregates/__tests__/user-credentials.spec.ts @@ -0,0 +1,88 @@ +import { createTestEventContext } from '@concepta/nestjs-core/testing'; + +import { UserCredentialsMapper } from '../../../infrastructure/persistence/user-credentials.mapper.js'; +import { type UserCredentialEntityInterface } from '../../interfaces/user-credential-entity.interface.js'; +import { UserCredentials } from '../user-credentials.js'; + +const eventContext = createTestEventContext({}, {}); +const mapper = new UserCredentialsMapper(); + +const mockEntity: UserCredentialEntityInterface = { + id: 'cred-1', + userId: 'user-1', + passwordHash: 'hash', + active: true, + validFrom: new Date('2024-01-01'), + validTo: null, + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-01'), + dateDeleted: null, + version: 1, +}; + +describe(UserCredentials.name, () => { + describe('create', () => { + it('should create credentials with generated id', () => { + const creds = UserCredentials.create(eventContext, { + userId: 'user-1', + passwordHash: 'hash', + }); + + expect(creds.id).toBeDefined(); + expect(creds.userId).toBe('user-1'); + expect(creds.passwordHash).toBe('hash'); + expect(creds.active).toBe(true); + expect(creds.version).toBe(1); + expect(creds.validFrom).toBeInstanceOf(Date); + expect(creds.validTo).toBeNull(); + expect(creds.meta.dateCreated).toBeInstanceOf(Date); + expect(creds.meta.dateUpdated).toBeInstanceOf(Date); + expect(creds.meta.dateDeleted).toBeNull(); + }); + }); + + describe('createWithId', () => { + it('should use the provided id', () => { + const creds = UserCredentials.createWithId(eventContext, 'custom-id', { + userId: 'user-1', + passwordHash: 'hash', + }); + + expect(creds.id).toBe('custom-id'); + }); + }); + + describe('constructor', () => { + it('should wrap entity with correct getters', () => { + const creds = mapper.toDomain(mockEntity); + + expect(creds.id).toBe('cred-1'); + expect(creds.userId).toBe('user-1'); + expect(creds.passwordHash).toBe('hash'); + expect(creds.active).toBe(true); + expect(creds.version).toBe(1); + }); + }); + + describe('toPlain', () => { + it('should return a plain copy', () => { + const creds = mapper.toDomain(mockEntity); + const plain = creds.toPlain(); + + expect(plain).toEqual(mockEntity); + expect(plain).not.toBe(mockEntity); + }); + }); + + describe('deactivate', () => { + it('should set active to false and increment version', () => { + const creds = mapper.toDomain(mockEntity); + + creds.deactivate(eventContext); + + expect(creds.active).toBe(false); + expect(creds.validTo).toBeInstanceOf(Date); + expect(creds.version).toBe(2); + }); + }); +}); diff --git a/packages/nestjs-user/src/domain/aggregates/__tests__/user.spec.ts b/packages/nestjs-user/src/domain/aggregates/__tests__/user.spec.ts new file mode 100644 index 000000000..c267bf84a --- /dev/null +++ b/packages/nestjs-user/src/domain/aggregates/__tests__/user.spec.ts @@ -0,0 +1,114 @@ +import { createTestEventContext } from '@concepta/nestjs-core/testing'; + +import { + createMockUserEntity, + toUserDomain, +} from '../../../__tests__/helpers/mock.helpers.js'; +import { User } from '../user.js'; + +const eventContext = createTestEventContext({}, {}); +const mockEntity = createMockUserEntity(); + +describe(User.name, () => { + describe('create', () => { + it('should create a user with generated id', () => { + const user = User.create(eventContext, { + email: 'a@b.com', + username: 'john', + }); + + expect(user.id).toBeDefined(); + expect(user.email).toBe('a@b.com'); + expect(user.username).toBe('john'); + expect(user.active).toBe(true); + expect(user.version).toBe(1); + expect(user.meta.dateCreated).toBeInstanceOf(Date); + expect(user.meta.dateUpdated).toBeInstanceOf(Date); + expect(user.meta.dateDeleted).toBeNull(); + }); + + it('should default active to true', () => { + const user = User.create(eventContext, { + email: 'a@b.com', + username: 'john', + }); + + expect(user.active).toBe(true); + }); + + it('should respect explicit active value', () => { + const user = User.create(eventContext, { + email: 'a@b.com', + username: 'john', + active: false, + }); + + expect(user.active).toBe(false); + }); + }); + + describe('createWithId', () => { + it('should use the provided id', () => { + const user = User.createWithId(eventContext, 'custom-id', { + email: 'a@b.com', + username: 'john', + }); + + expect(user.id).toBe('custom-id'); + }); + }); + + describe('constructor', () => { + it('should wrap entity with correct getters', () => { + const user = toUserDomain(mockEntity); + + expect(user.id).toBe('user-1'); + expect(user.email).toBe('a@b.com'); + expect(user.username).toBe('john'); + expect(user.active).toBe(true); + expect(user.version).toBe(1); + }); + }); + + describe('toPlain', () => { + it('should return a plain copy', () => { + const user = toUserDomain(mockEntity); + const plain = user.toPlain(); + + expect(plain).toEqual(mockEntity); + expect(plain).not.toBe(mockEntity); + }); + }); + + describe('update', () => { + it('should merge dto and increment version', () => { + const user = toUserDomain(mockEntity); + const beforeUpdate = user.meta.dateUpdated; + + user.update(eventContext, { active: false }); + + expect(user.active).toBe(false); + expect(user.version).toBe(2); + expect(user.meta.dateUpdated.getTime()).toBeGreaterThanOrEqual( + beforeUpdate.getTime(), + ); + }); + + it('should preserve unchanged fields', () => { + const user = toUserDomain(mockEntity); + + user.update(eventContext, { active: false }); + + expect(user.email).toBe('a@b.com'); + expect(user.username).toBe('john'); + }); + }); + + describe('remove', () => { + it('should not throw', () => { + const user = toUserDomain(mockEntity); + + expect(() => user.remove(eventContext)).not.toThrow(); + }); + }); +}); diff --git a/packages/nestjs-user/src/domain/aggregates/user-credentials.ts b/packages/nestjs-user/src/domain/aggregates/user-credentials.ts new file mode 100644 index 000000000..5147074e7 --- /dev/null +++ b/packages/nestjs-user/src/domain/aggregates/user-credentials.ts @@ -0,0 +1,92 @@ +import { randomUUID } from 'crypto'; + +import { + type DomainFactory, + type EventContextHost, +} from '@concepta/nestjs-core'; +import { DomainAggregate } from '@concepta/nestjs-core/aggregate'; + +import { UserCredentialsCreatedEvent } from '../events/user-credentials-created.event.js'; +import { UserCredentialsDeactivatedEvent } from '../events/user-credentials-deactivated.event.js'; +import { type UserCredentialCreatableInterface } from '../interfaces/user-credential-creatable.interface.js'; +import { type UserCredentialInterface } from '../interfaces/user-credential.interface.js'; + +export class UserCredentials extends DomainAggregate { + get userId() { + return this.props.userId; + } + + get passwordHash() { + return this.props.passwordHash; + } + + get active() { + return this.props.active; + } + + get validFrom() { + return this.props.validFrom; + } + + get validTo() { + return this.props.validTo; + } + + private toEventPayload() { + const { passwordHash: _ph, ...payload } = this.props; + return payload; + } + + static create( + eventContext: EventContextHost, + props: UserCredentialCreatableInterface, + ): UserCredentials { + return UserCredentials.createWithId(eventContext, randomUUID(), props); + } + + static createWithId( + eventContext: EventContextHost, + id: string, + props: UserCredentialCreatableInterface, + ): UserCredentials { + const now = new Date(); + + const credentials = new UserCredentials(id, { + userId: props.userId, + passwordHash: props.passwordHash, + active: true, + validFrom: now, + validTo: null, + }); + + credentials.apply( + new UserCredentialsCreatedEvent( + eventContext, + credentials.toEventPayload(), + ), + ); + + return credentials; + } + + deactivate(eventContext: EventContextHost): void { + const now = new Date(); + + this.props = { + ...this.props, + active: false, + validTo: now, + }; + + this.incrementVersion(); + + this.apply( + new UserCredentialsDeactivatedEvent(eventContext, this.toEventPayload()), + ); + } +} + +UserCredentials satisfies DomainFactory< + UserCredentialCreatableInterface, + UserCredentials +>; diff --git a/packages/nestjs-user/src/domain/aggregates/user.ts b/packages/nestjs-user/src/domain/aggregates/user.ts new file mode 100644 index 000000000..cb745c5b4 --- /dev/null +++ b/packages/nestjs-user/src/domain/aggregates/user.ts @@ -0,0 +1,69 @@ +import { randomUUID } from 'crypto'; + +import { + type DomainFactory, + type EventContextHost, +} from '@concepta/nestjs-core'; +import { DomainAggregate } from '@concepta/nestjs-core/aggregate'; + +import { UserCreatedEvent } from '../events/user-created.event.js'; +import { UserRemovedEvent } from '../events/user-removed.event.js'; +import { UserUpdatedEvent } from '../events/user-updated.event.js'; +import { type UserCreatableInterface } from '../interfaces/user-creatable.interface.js'; +import { type UserUpdatableInterface } from '../interfaces/user-updatable.interface.js'; +import { type UserInterface } from '../interfaces/user.interface.js'; + +export class User extends DomainAggregate { + get email() { + return this.props.email; + } + + get username() { + return this.props.username; + } + + get active() { + return this.props.active; + } + + static create( + eventContext: EventContextHost, + props: UserCreatableInterface, + ): User { + return User.createWithId(eventContext, randomUUID(), props); + } + + static createWithId( + eventContext: EventContextHost, + id: string, + props: UserCreatableInterface, + ): User { + const user = new User(id, { + email: props.email, + username: props.username, + active: props.active ?? true, + }); + + user.apply(new UserCreatedEvent(eventContext, user.toPlain())); + + return user; + } + + update( + eventContext: EventContextHost, + dto: Partial, + ): void { + this.props = { + ...this.props, + ...dto, + }; + this.incrementVersion(); + this.apply(new UserUpdatedEvent(eventContext, this.toPlain())); + } + + remove(eventContext: EventContextHost): void { + this.apply(new UserRemovedEvent(eventContext, this.toPlain())); + } +} + +User satisfies DomainFactory; diff --git a/packages/nestjs-user/src/domain/collections/__tests__/user-credentials.collection.spec.ts b/packages/nestjs-user/src/domain/collections/__tests__/user-credentials.collection.spec.ts new file mode 100644 index 000000000..453401ed1 --- /dev/null +++ b/packages/nestjs-user/src/domain/collections/__tests__/user-credentials.collection.spec.ts @@ -0,0 +1,49 @@ +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +import { UserPasswordHistoryViolationException } from '../../exceptions/user-password-history-violation.exception.js'; +import { type UserPasswordPort } from '../../ports/user-password.port.js'; +import { UserCredentialsCollection } from '../user-credentials.collection.js'; + +describe(UserCredentialsCollection.name, () => { + const entries = [ + { id: 'cred-1', passwordHash: 'hash1' }, + { id: 'cred-2', passwordHash: 'hash2' }, + ]; + + const mockPasswordPort: DeepMockProxy = + mockDeep(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('notReused', () => { + it('should resolve when password is not reused', async () => { + mockPasswordPort.validateHistory.mockResolvedValue(true); + + const collection = new UserCredentialsCollection( + entries, + mockPasswordPort, + ); + + await expect(collection.notReused('new-pass')).resolves.toBeUndefined(); + expect(mockPasswordPort.validateHistory).toHaveBeenCalledWith( + 'new-pass', + entries, + ); + }); + + it('should throw when password is reused', async () => { + mockPasswordPort.validateHistory.mockResolvedValue(false); + + const collection = new UserCredentialsCollection( + entries, + mockPasswordPort, + ); + + await expect(collection.notReused('old-pass')).rejects.toThrow( + UserPasswordHistoryViolationException, + ); + }); + }); +}); diff --git a/packages/nestjs-user/src/domain/collections/user-credentials.collection.ts b/packages/nestjs-user/src/domain/collections/user-credentials.collection.ts new file mode 100644 index 000000000..5ca455bd0 --- /dev/null +++ b/packages/nestjs-user/src/domain/collections/user-credentials.collection.ts @@ -0,0 +1,23 @@ +import { type ReferenceIdInterface } from '@concepta/nestjs-core'; +import { type PasswordStorageInterface } from '@concepta/nestjs-password'; + +import { UserPasswordHistoryViolationException } from '../exceptions/user-password-history-violation.exception.js'; +import { type UserPasswordPort } from '../ports/user-password.port.js'; + +export class UserCredentialsCollection { + constructor( + readonly entries: (ReferenceIdInterface & PasswordStorageInterface)[], + private readonly passwordPort: UserPasswordPort, + ) {} + + async notReused(password: string): Promise { + const isValid = await this.passwordPort.validateHistory( + password, + this.entries, + ); + + if (!isValid) { + throw new UserPasswordHistoryViolationException(); + } + } +} diff --git a/packages/nestjs-user/src/domain/events/interfaces/user-credentials-event-payload.interface.ts b/packages/nestjs-user/src/domain/events/interfaces/user-credentials-event-payload.interface.ts new file mode 100644 index 000000000..dcd68acf9 --- /dev/null +++ b/packages/nestjs-user/src/domain/events/interfaces/user-credentials-event-payload.interface.ts @@ -0,0 +1,6 @@ +import { type UserCredentialInterface } from '../../interfaces/user-credential.interface.js'; + +export interface UserCredentialsEventPayloadInterface extends Omit< + UserCredentialInterface, + 'passwordHash' +> {} diff --git a/packages/nestjs-user/src/domain/events/user-created.event.ts b/packages/nestjs-user/src/domain/events/user-created.event.ts new file mode 100644 index 000000000..82b54415c --- /dev/null +++ b/packages/nestjs-user/src/domain/events/user-created.event.ts @@ -0,0 +1,12 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type UserInterface } from '../interfaces/user.interface.js'; + +export class UserCreatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly user: UserInterface, + ) {} +} diff --git a/packages/nestjs-user/src/domain/events/user-credentials-created.event.ts b/packages/nestjs-user/src/domain/events/user-credentials-created.event.ts new file mode 100644 index 000000000..609fe8ce4 --- /dev/null +++ b/packages/nestjs-user/src/domain/events/user-credentials-created.event.ts @@ -0,0 +1,12 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type UserCredentialsEventPayloadInterface } from './interfaces/user-credentials-event-payload.interface.js'; + +export class UserCredentialsCreatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly credentials: UserCredentialsEventPayloadInterface, + ) {} +} diff --git a/packages/nestjs-user/src/domain/events/user-credentials-deactivated.event.ts b/packages/nestjs-user/src/domain/events/user-credentials-deactivated.event.ts new file mode 100644 index 000000000..66cd3eee6 --- /dev/null +++ b/packages/nestjs-user/src/domain/events/user-credentials-deactivated.event.ts @@ -0,0 +1,12 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type UserCredentialsEventPayloadInterface } from './interfaces/user-credentials-event-payload.interface.js'; + +export class UserCredentialsDeactivatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly credentials: UserCredentialsEventPayloadInterface, + ) {} +} diff --git a/packages/nestjs-user/src/domain/events/user-removed.event.ts b/packages/nestjs-user/src/domain/events/user-removed.event.ts new file mode 100644 index 000000000..cade3cd6d --- /dev/null +++ b/packages/nestjs-user/src/domain/events/user-removed.event.ts @@ -0,0 +1,12 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type UserInterface } from '../interfaces/user.interface.js'; + +export class UserRemovedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly user: UserInterface, + ) {} +} diff --git a/packages/nestjs-user/src/domain/events/user-updated.event.ts b/packages/nestjs-user/src/domain/events/user-updated.event.ts new file mode 100644 index 000000000..8c68cb930 --- /dev/null +++ b/packages/nestjs-user/src/domain/events/user-updated.event.ts @@ -0,0 +1,12 @@ +import { type IEvent } from '@nestjs/cqrs'; + +import { type EventContextHost } from '@concepta/nestjs-core'; + +import { type UserInterface } from '../interfaces/user.interface.js'; + +export class UserUpdatedEvent implements IEvent { + constructor( + public readonly eventContext: EventContextHost, + public readonly user: UserInterface, + ) {} +} diff --git a/packages/nestjs-user/src/domain/exceptions/user-credentials-already-exist.exception.ts b/packages/nestjs-user/src/domain/exceptions/user-credentials-already-exist.exception.ts new file mode 100644 index 000000000..71f25abc8 --- /dev/null +++ b/packages/nestjs-user/src/domain/exceptions/user-credentials-already-exist.exception.ts @@ -0,0 +1,15 @@ +import { HttpStatus } from '@nestjs/common'; + +import { UserException } from './user.exception.js'; + +export class UserCredentialsAlreadyExistException extends UserException { + constructor(options?: { message?: string; originalError?: unknown }) { + super({ + message: options?.message ?? 'User credentials already exist', + httpStatus: HttpStatus.CONFLICT, + originalError: options?.originalError, + fault: 'client', + }); + this.errorCode = 'USER_CREDENTIALS_ALREADY_EXIST'; + } +} diff --git a/packages/nestjs-user/src/domain/exceptions/user-password-current-invalid.exception.ts b/packages/nestjs-user/src/domain/exceptions/user-password-current-invalid.exception.ts new file mode 100644 index 000000000..a193136f5 --- /dev/null +++ b/packages/nestjs-user/src/domain/exceptions/user-password-current-invalid.exception.ts @@ -0,0 +1,15 @@ +import { HttpStatus } from '@nestjs/common'; + +import { UserException } from './user.exception.js'; + +export class UserPasswordCurrentInvalidException extends UserException { + constructor(options?: { message?: string; originalError?: unknown }) { + super({ + message: options?.message ?? 'Current password is not valid', + httpStatus: HttpStatus.BAD_REQUEST, + originalError: options?.originalError, + fault: 'client', + }); + this.errorCode = 'USER_PASSWORD_CURRENT_INVALID'; + } +} diff --git a/packages/nestjs-user/src/domain/exceptions/user-password-history-violation.exception.ts b/packages/nestjs-user/src/domain/exceptions/user-password-history-violation.exception.ts new file mode 100644 index 000000000..8746dbfa0 --- /dev/null +++ b/packages/nestjs-user/src/domain/exceptions/user-password-history-violation.exception.ts @@ -0,0 +1,15 @@ +import { HttpStatus } from '@nestjs/common'; + +import { UserException } from './user.exception.js'; + +export class UserPasswordHistoryViolationException extends UserException { + constructor(options?: { message?: string; originalError?: unknown }) { + super({ + message: options?.message ?? 'Password has been used too recently', + httpStatus: HttpStatus.BAD_REQUEST, + originalError: options?.originalError, + fault: 'client', + }); + this.errorCode = 'USER_PASSWORD_HISTORY_VIOLATION'; + } +} diff --git a/packages/nestjs-user/src/domain/exceptions/user.exception.ts b/packages/nestjs-user/src/domain/exceptions/user.exception.ts new file mode 100644 index 000000000..03971e219 --- /dev/null +++ b/packages/nestjs-user/src/domain/exceptions/user.exception.ts @@ -0,0 +1,11 @@ +import { + RuntimeException, + type RuntimeExceptionOptions, +} from '@concepta/nestjs-core'; + +export class UserException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super(options); + this.errorCode = 'USER_ERROR'; + } +} diff --git a/packages/nestjs-user/src/domain/interfaces/user-creatable.interface.ts b/packages/nestjs-user/src/domain/interfaces/user-creatable.interface.ts new file mode 100644 index 000000000..12d20574e --- /dev/null +++ b/packages/nestjs-user/src/domain/interfaces/user-creatable.interface.ts @@ -0,0 +1,9 @@ +import { type PasswordPlainInterface } from '@concepta/nestjs-password'; + +import { type UserInterface } from './user.interface.js'; + +export interface UserCreatableInterface + extends + Pick, + Partial>, + Partial {} diff --git a/packages/nestjs-user/src/domain/interfaces/user-credential-creatable.interface.ts b/packages/nestjs-user/src/domain/interfaces/user-credential-creatable.interface.ts new file mode 100644 index 000000000..7a1b35e88 --- /dev/null +++ b/packages/nestjs-user/src/domain/interfaces/user-credential-creatable.interface.ts @@ -0,0 +1,6 @@ +import { type UserCredentialInterface } from './user-credential.interface.js'; + +export interface UserCredentialCreatableInterface extends Pick< + UserCredentialInterface, + 'userId' | 'passwordHash' +> {} diff --git a/packages/nestjs-user/src/domain/interfaces/user-credential-entity.interface.ts b/packages/nestjs-user/src/domain/interfaces/user-credential-entity.interface.ts new file mode 100644 index 000000000..eb8957c98 --- /dev/null +++ b/packages/nestjs-user/src/domain/interfaces/user-credential-entity.interface.ts @@ -0,0 +1,14 @@ +import { + type AuditInterface, + type ReferenceIdInterface, + type ReferenceVersionInterface, +} from '@concepta/nestjs-core'; + +import { type UserCredentialInterface } from './user-credential.interface.js'; + +export interface UserCredentialEntityInterface + extends + ReferenceIdInterface, + ReferenceVersionInterface, + UserCredentialInterface, + AuditInterface {} diff --git a/packages/nestjs-user/src/domain/interfaces/user-credential.interface.ts b/packages/nestjs-user/src/domain/interfaces/user-credential.interface.ts new file mode 100644 index 000000000..025520f9c --- /dev/null +++ b/packages/nestjs-user/src/domain/interfaces/user-credential.interface.ts @@ -0,0 +1,13 @@ +import { type ReferenceActiveInterface } from '@concepta/nestjs-core'; +import { type PasswordStorageInterface } from '@concepta/nestjs-password'; + +import { type UserOwnableInterface } from './user-ownable.interface.js'; + +export interface UserCredentialInterface + extends + PasswordStorageInterface, + UserOwnableInterface, + ReferenceActiveInterface { + validFrom: Date; + validTo: Date | null; +} diff --git a/packages/nestjs-user/src/domain/interfaces/user-entity.interface.ts b/packages/nestjs-user/src/domain/interfaces/user-entity.interface.ts new file mode 100644 index 000000000..29780c27a --- /dev/null +++ b/packages/nestjs-user/src/domain/interfaces/user-entity.interface.ts @@ -0,0 +1,14 @@ +import { + type AuditInterface, + type ReferenceIdInterface, + type ReferenceVersionInterface, +} from '@concepta/nestjs-core'; + +import { type UserInterface } from './user.interface.js'; + +export interface UserEntityInterface + extends + ReferenceIdInterface, + ReferenceVersionInterface, + UserInterface, + AuditInterface {} diff --git a/packages/nestjs-user/src/domain/interfaces/user-ownable.interface.ts b/packages/nestjs-user/src/domain/interfaces/user-ownable.interface.ts new file mode 100644 index 000000000..99776e769 --- /dev/null +++ b/packages/nestjs-user/src/domain/interfaces/user-ownable.interface.ts @@ -0,0 +1,8 @@ +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type UserInterface } from './user.interface.js'; + +export interface UserOwnableInterface { + userId: ReferenceId; + user?: UserInterface; +} diff --git a/packages/nestjs-user/src/domain/interfaces/user-relation.interface.ts b/packages/nestjs-user/src/domain/interfaces/user-relation.interface.ts new file mode 100644 index 000000000..c0c9f7fa9 --- /dev/null +++ b/packages/nestjs-user/src/domain/interfaces/user-relation.interface.ts @@ -0,0 +1,8 @@ +import { type ReferenceId } from '@concepta/nestjs-core'; + +/** + * Belongs to user. + */ +export interface UserRelationInterface { + userId: T; +} diff --git a/packages/nestjs-user/src/domain/interfaces/user-updatable.interface.ts b/packages/nestjs-user/src/domain/interfaces/user-updatable.interface.ts new file mode 100644 index 000000000..09fa2a248 --- /dev/null +++ b/packages/nestjs-user/src/domain/interfaces/user-updatable.interface.ts @@ -0,0 +1,5 @@ +import { type UserCreatableInterface } from './user-creatable.interface.js'; + +export interface UserUpdatableInterface extends Partial< + Pick +> {} diff --git a/packages/nestjs-user/src/domain/interfaces/user.interface.ts b/packages/nestjs-user/src/domain/interfaces/user.interface.ts new file mode 100644 index 000000000..798a42101 --- /dev/null +++ b/packages/nestjs-user/src/domain/interfaces/user.interface.ts @@ -0,0 +1,11 @@ +import { + type ReferenceActiveInterface, + type ReferenceEmailInterface, + type ReferenceUsernameInterface, +} from '@concepta/nestjs-core'; + +export interface UserInterface + extends + ReferenceEmailInterface, + ReferenceUsernameInterface, + ReferenceActiveInterface {} diff --git a/packages/nestjs-user/src/domain/policies/__tests__/user-password.policy.spec.ts b/packages/nestjs-user/src/domain/policies/__tests__/user-password.policy.spec.ts new file mode 100644 index 000000000..7e068f3cf --- /dev/null +++ b/packages/nestjs-user/src/domain/policies/__tests__/user-password.policy.spec.ts @@ -0,0 +1,46 @@ +import { UserPasswordPolicy } from '../user-password.policy.js'; + +describe(UserPasswordPolicy.name, () => { + describe('defaults', () => { + const policy = new UserPasswordPolicy(); + + it('should not restrict reuse by default', () => { + expect(policy.reuseRestricted).toBe(false); + }); + + it('should not require current password by default', () => { + expect(policy.requireCurrent).toBe(false); + }); + + it('should return undefined for reuseLimitDate when not restricted', () => { + expect(policy.reuseLimitDate).toBeUndefined(); + }); + }); + + describe('custom settings', () => { + it('should restrict reuse when reuseAfterDays > 0', () => { + const policy = new UserPasswordPolicy({ reuseAfterDays: 30 }); + + expect(policy.reuseRestricted).toBe(true); + }); + + it('should return a date in the past for reuseLimitDate', () => { + const policy = new UserPasswordPolicy({ reuseAfterDays: 30 }); + const limitDate = policy.reuseLimitDate; + + expect(limitDate).toBeInstanceOf(Date); + expect(limitDate!.getTime()).toBeLessThan(Date.now()); + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const diffMs = Math.abs(limitDate!.getTime() - thirtyDaysAgo.getTime()); + expect(diffMs).toBeLessThan(1000); + }); + + it('should require current password when configured', () => { + const policy = new UserPasswordPolicy({ requireCurrent: true }); + + expect(policy.requireCurrent).toBe(true); + }); + }); +}); diff --git a/packages/nestjs-user/src/domain/policies/user-password.policy.ts b/packages/nestjs-user/src/domain/policies/user-password.policy.ts new file mode 100644 index 000000000..e438e52b5 --- /dev/null +++ b/packages/nestjs-user/src/domain/policies/user-password.policy.ts @@ -0,0 +1,33 @@ +export interface PasswordPolicySettings { + reuseAfterDays?: number; + requireCurrent?: boolean; +} + +const DEFAULTS: Required = { + reuseAfterDays: 0, + requireCurrent: false, +}; + +export class UserPasswordPolicy { + private readonly settings: Required; + + constructor(settings?: PasswordPolicySettings) { + this.settings = { ...DEFAULTS, ...settings }; + } + + get reuseRestricted(): boolean { + return this.settings.reuseAfterDays > 0; + } + + get requireCurrent(): boolean { + return this.settings.requireCurrent; + } + + get reuseLimitDate(): Date | undefined { + if (!this.reuseRestricted) return undefined; + + const limitDate = new Date(); + limitDate.setDate(limitDate.getDate() - this.settings.reuseAfterDays); + return limitDate; + } +} diff --git a/packages/nestjs-user/src/domain/ports/user-password.port.ts b/packages/nestjs-user/src/domain/ports/user-password.port.ts new file mode 100644 index 000000000..be9c36b15 --- /dev/null +++ b/packages/nestjs-user/src/domain/ports/user-password.port.ts @@ -0,0 +1,61 @@ +import { Injectable, Type } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { ReferenceIdInterface } from '@concepta/nestjs-core'; +import { PasswordStorageInterface } from '@concepta/nestjs-password'; + +export interface CreatePasswordCommandInterface { + password: string; +} + +export interface ValidateCurrentPasswordCommandInterface { + password: string; + target: PasswordStorageInterface; +} + +export interface ValidatePasswordHistoryCommandInterface { + password: string; + targets: PasswordStorageInterface[]; +} + +export interface UserPasswordPortSettings { + createCommand: Type; + validateCurrentCommand: Type; + validateHistoryCommand?: Type; +} + +@Injectable() +export class UserPasswordPort { + constructor( + private readonly portSettings: UserPasswordPortSettings, + private readonly commandBus: CommandBus, + ) {} + + async create(password: string): Promise { + return this.commandBus.execute( + new this.portSettings.createCommand(password), + ); + } + + async validateCurrent( + password: string, + target: ReferenceIdInterface & PasswordStorageInterface, + ): Promise { + return this.commandBus.execute( + new this.portSettings.validateCurrentCommand(password, target), + ); + } + + async validateHistory( + password: string, + targets: PasswordStorageInterface[], + ): Promise { + if (!this.portSettings.validateHistoryCommand) { + return true; + } + + return this.commandBus.execute( + new this.portSettings.validateHistoryCommand(password, targets), + ); + } +} diff --git a/packages/nestjs-user/src/domain/repositories/user-credentials-repository.interface.ts b/packages/nestjs-user/src/domain/repositories/user-credentials-repository.interface.ts new file mode 100644 index 000000000..b0b28f895 --- /dev/null +++ b/packages/nestjs-user/src/domain/repositories/user-credentials-repository.interface.ts @@ -0,0 +1,20 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; + +import { type UserCredentials } from '../aggregates/user-credentials.js'; + +export interface UserCredentialsRepositoryInterface { + findActiveByUserId( + ctx: PlainLiteralObject, + userId: ReferenceId, + ): Promise; + + findByUserId( + ctx: PlainLiteralObject, + userId: ReferenceId, + limitDate?: Date, + ): Promise; + + save(ctx: PlainLiteralObject, entry: UserCredentials): Promise; +} diff --git a/packages/nestjs-user/src/domain/repositories/user-repository.interface.ts b/packages/nestjs-user/src/domain/repositories/user-repository.interface.ts new file mode 100644 index 000000000..1d1d7a78d --- /dev/null +++ b/packages/nestjs-user/src/domain/repositories/user-repository.interface.ts @@ -0,0 +1,27 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type ReferenceEmail, + type ReferenceId, + type ReferenceUsername, +} from '@concepta/nestjs-core'; + +import { type User } from '../aggregates/user.js'; + +export interface UserRepositoryInterface { + get(ctx: PlainLiteralObject, id: ReferenceId): Promise; + + findByEmail( + ctx: PlainLiteralObject, + email: ReferenceEmail, + ): Promise; + + findByUsername( + ctx: PlainLiteralObject, + username: ReferenceUsername, + ): Promise; + + save(ctx: PlainLiteralObject, user: User): Promise; + + remove(ctx: PlainLiteralObject, user: User): Promise; +} diff --git a/packages/nestjs-user/src/domain/services/__tests__/user-credentials.service.e2e-spec.ts b/packages/nestjs-user/src/domain/services/__tests__/user-credentials.service.e2e-spec.ts new file mode 100644 index 000000000..e26c16015 --- /dev/null +++ b/packages/nestjs-user/src/domain/services/__tests__/user-credentials.service.e2e-spec.ts @@ -0,0 +1,84 @@ +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { AppContextHost } from '@concepta/nestjs-core'; +import { createTestEventContext } from '@concepta/nestjs-core/testing'; + +import { AppRepoModuleFixture } from '../../../__tests__/fixtures/app-repo.module.fixture.js'; +import { + USER_CREDENTIALS_REPOSITORY_TOKEN, + USER_REPOSITORY_TOKEN, +} from '../../../user.constants.js'; +import { User } from '../../aggregates/user.js'; +import { type UserCredentialsRepositoryInterface } from '../../repositories/user-credentials-repository.interface.js'; +import { type UserRepositoryInterface } from '../../repositories/user-repository.interface.js'; +import { UserCredentialsService } from '../user-credentials.service.js'; + +/** + * Regression coverage for #468 through a real production code path — + * `setPassword` and `updatePassword` each open their own `TransactionScope.run()`. + * Calling both against the same request-lived `AppContextHost`, then doing a + * plain repository read on that same context, is exactly the shape of the + * reported bug (`RecoveryService.updatePassword` in nestjs-authentication + * follows this same pattern). + */ +describe(UserCredentialsService.name + ' (e2e)', () => { + let app: INestApplication; + let service: UserCredentialsService; + let userRepository: UserRepositoryInterface; + let credentialsRepository: UserCredentialsRepositoryInterface; + const eventContext = createTestEventContext({}, {}); + + let testUser: User; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppRepoModuleFixture], + }).compile(); + + // CommandBus handler registration (e.g. CreatePasswordCommand) only + // happens on application bootstrap, not on compile(). + app = moduleFixture.createNestApplication(); + await app.init(); + + service = app.get(UserCredentialsService); + userRepository = app.get(USER_REPOSITORY_TOKEN); + credentialsRepository = app.get( + USER_CREDENTIALS_REPOSITORY_TOKEN, + ); + + testUser = User.create(eventContext, { + email: 'trx-scope-test@example.com', + username: 'trxscopetestuser', + }); + await userRepository.save({}, testUser); + }); + + afterEach(async () => { + await app?.close(); + }); + + it('should run setPassword then updatePassword on one context, and serve a plain read after', async () => { + const ctx = new AppContextHost(); + + await service.setPassword(ctx, eventContext, testUser.id, 'first-password'); + await service.updatePassword( + ctx, + eventContext, + testUser.id, + 'second-password', + ); + + // Plain, non-transactional read on the same ctx both runs used. + const active = await credentialsRepository.findActiveByUserId( + ctx, + testUser.id, + ); + + expect(active).not.toBeNull(); + expect(active!.userId).toBe(testUser.id); + + const history = await credentialsRepository.findByUserId(ctx, testUser.id); + expect(history).toHaveLength(2); + }); +}); diff --git a/packages/nestjs-user/src/domain/services/__tests__/user-credentials.service.spec.ts b/packages/nestjs-user/src/domain/services/__tests__/user-credentials.service.spec.ts new file mode 100644 index 000000000..fe4ee26c1 --- /dev/null +++ b/packages/nestjs-user/src/domain/services/__tests__/user-credentials.service.spec.ts @@ -0,0 +1,225 @@ +import { createTestEventContext } from '@concepta/nestjs-core/testing'; + +import { + createMockEventPublisher, + createMockPasswordPort, + createMockTxScope, + createMockUserCredentialEntity, + createMockUserCredentialsRepository, + toUserCredentialsDomain, +} from '../../../__tests__/helpers/mock.helpers.js'; +import { UserCredentialsAlreadyExistException } from '../../exceptions/user-credentials-already-exist.exception.js'; +import { UserPasswordCurrentInvalidException } from '../../exceptions/user-password-current-invalid.exception.js'; +import { UserPasswordHistoryViolationException } from '../../exceptions/user-password-history-violation.exception.js'; +import { UserPasswordPolicy } from '../../policies/user-password.policy.js'; +import { UserCredentialsService } from '../user-credentials.service.js'; + +describe(UserCredentialsService.name, () => { + const eventContext = createTestEventContext({}, {}); + const mockCredentialEntity = createMockUserCredentialEntity(); + + function setup(policy?: UserPasswordPolicy) { + const userCredentialsRepository = createMockUserCredentialsRepository(); + const txScope = createMockTxScope(); + const eventPublisher = createMockEventPublisher(); + const passwordPort = createMockPasswordPort(); + + passwordPort.create.mockResolvedValue({ + passwordHash: 'new-hash', + }); + + const service = new UserCredentialsService( + userCredentialsRepository, + txScope, + eventPublisher, + passwordPort, + policy ?? new UserPasswordPolicy(), + ); + + return { + service, + userCredentialsRepository, + passwordPort, + }; + } + + describe('setPassword', () => { + it('should return new credentials when none exist', async () => { + const { service, userCredentialsRepository } = setup(); + userCredentialsRepository.findActiveByUserId.mockResolvedValue(null); + + const result = await service.setPassword( + {}, + eventContext, + 'user-1', + 'pass', + ); + + expect(result.userId).toBe('user-1'); + expect(result.passwordHash).toBe('new-hash'); + expect(result.active).toBe(true); + expect(userCredentialsRepository.save).toHaveBeenCalled(); + }); + + it('should hash a plain password via the password port', async () => { + const { service, userCredentialsRepository, passwordPort } = setup(); + userCredentialsRepository.findActiveByUserId.mockResolvedValue(null); + + await service.setPassword({}, eventContext, 'user-1', 'pass'); + + expect(passwordPort.create).toHaveBeenCalledWith('pass'); + }); + + it('should store an already-hashed password storage object as-is', async () => { + const { service, userCredentialsRepository, passwordPort } = setup(); + userCredentialsRepository.findActiveByUserId.mockResolvedValue(null); + + const result = await service.setPassword({}, eventContext, 'user-1', { + passwordHash: 'pre-hashed', + }); + + expect(passwordPort.create).not.toHaveBeenCalled(); + expect(result.passwordHash).toBe('pre-hashed'); + }); + + it('should throw when active credentials already exist', async () => { + const { service, userCredentialsRepository } = setup(); + userCredentialsRepository.findActiveByUserId.mockResolvedValue( + toUserCredentialsDomain(mockCredentialEntity), + ); + + await expect( + service.setPassword({}, eventContext, 'user-1', 'pass'), + ).rejects.toThrow(UserCredentialsAlreadyExistException); + }); + }); + + describe('updatePassword', () => { + describe('default policy', () => { + it('should create new credentials when none exist', async () => { + const { service, userCredentialsRepository, passwordPort } = setup(); + userCredentialsRepository.findActiveByUserId.mockResolvedValue(null); + + await expect( + service.updatePassword({}, eventContext, 'user-1', 'new-pass'), + ).resolves.toBeUndefined(); + + expect(passwordPort.create).toHaveBeenCalledWith('new-pass'); + expect(userCredentialsRepository.save).toHaveBeenCalled(); + }); + + it('should deactivate existing and create new', async () => { + const { service, userCredentialsRepository } = setup(); + const existing = toUserCredentialsDomain(mockCredentialEntity); + userCredentialsRepository.findActiveByUserId.mockResolvedValue( + existing, + ); + + await service.updatePassword({}, eventContext, 'user-1', 'new-pass'); + + expect(existing.active).toBe(false); + expect(userCredentialsRepository.save).toHaveBeenCalledTimes(2); + }); + }); + + describe('requireCurrent policy', () => { + const policy = new UserPasswordPolicy({ requireCurrent: true }); + + it('should throw when no active credentials exist', async () => { + const { service, userCredentialsRepository } = setup(policy); + userCredentialsRepository.findActiveByUserId.mockResolvedValue(null); + + await expect( + service.updatePassword( + {}, + eventContext, + 'user-1', + 'new-pass', + 'current', + ), + ).rejects.toThrow(UserPasswordCurrentInvalidException); + }); + + it('should throw when passwordCurrent not provided', async () => { + const { service, userCredentialsRepository } = setup(policy); + userCredentialsRepository.findActiveByUserId.mockResolvedValue( + toUserCredentialsDomain(mockCredentialEntity), + ); + + await expect( + service.updatePassword({}, eventContext, 'user-1', 'new-pass'), + ).rejects.toThrow(UserPasswordCurrentInvalidException); + }); + + it('should throw when current password is invalid', async () => { + const { service, userCredentialsRepository, passwordPort } = + setup(policy); + userCredentialsRepository.findActiveByUserId.mockResolvedValue( + toUserCredentialsDomain(mockCredentialEntity), + ); + passwordPort.validateCurrent.mockResolvedValue(false); + + await expect( + service.updatePassword( + {}, + eventContext, + 'user-1', + 'new-pass', + 'wrong', + ), + ).rejects.toThrow(UserPasswordCurrentInvalidException); + }); + + it('should proceed when current password is valid', async () => { + const { service, userCredentialsRepository, passwordPort } = + setup(policy); + userCredentialsRepository.findActiveByUserId.mockResolvedValue( + toUserCredentialsDomain(mockCredentialEntity), + ); + passwordPort.validateCurrent.mockResolvedValue(true); + + await expect( + service.updatePassword( + {}, + eventContext, + 'user-1', + 'new-pass', + 'correct', + ), + ).resolves.toBeUndefined(); + }); + }); + + describe('reuse restriction policy', () => { + const policy = new UserPasswordPolicy({ reuseAfterDays: 30 }); + + it('should throw when password was previously used', async () => { + const { service, userCredentialsRepository, passwordPort } = + setup(policy); + userCredentialsRepository.findActiveByUserId.mockResolvedValue(null); + userCredentialsRepository.findByUserId.mockResolvedValue([ + toUserCredentialsDomain(mockCredentialEntity), + ]); + passwordPort.validateHistory.mockResolvedValue(false); + + await expect( + service.updatePassword({}, eventContext, 'user-1', 'old-pass'), + ).rejects.toThrow(UserPasswordHistoryViolationException); + }); + + it('should proceed when password is not reused', async () => { + const { service, userCredentialsRepository, passwordPort } = + setup(policy); + userCredentialsRepository.findActiveByUserId.mockResolvedValue(null); + userCredentialsRepository.findByUserId.mockResolvedValue([ + toUserCredentialsDomain(mockCredentialEntity), + ]); + passwordPort.validateHistory.mockResolvedValue(true); + + await expect( + service.updatePassword({}, eventContext, 'user-1', 'unique-pass'), + ).resolves.toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/nestjs-user/src/domain/services/user-credentials.service.ts b/packages/nestjs-user/src/domain/services/user-credentials.service.ts new file mode 100644 index 000000000..603276cd1 --- /dev/null +++ b/packages/nestjs-user/src/domain/services/user-credentials.service.ts @@ -0,0 +1,177 @@ +import { Inject, Injectable, PlainLiteralObject } from '@nestjs/common'; +import { EventPublisher } from '@nestjs/cqrs'; + +import { + EventContextHost, + ReferenceId, + ReferenceIdInterface, +} from '@concepta/nestjs-core'; +import { + isPasswordStorage, + PasswordStorageInterface, +} from '@concepta/nestjs-password'; +import { TransactionScope } from '@concepta/nestjs-repository'; + +import { USER_CREDENTIALS_REPOSITORY_TOKEN } from '../../user.constants.js'; +import { UserCredentials } from '../aggregates/user-credentials.js'; +import { UserCredentialsCollection } from '../collections/user-credentials.collection.js'; +import { UserCredentialsAlreadyExistException } from '../exceptions/user-credentials-already-exist.exception.js'; +import { UserPasswordCurrentInvalidException } from '../exceptions/user-password-current-invalid.exception.js'; +import { UserPasswordPolicy } from '../policies/user-password.policy.js'; +import { UserPasswordPort } from '../ports/user-password.port.js'; +import { UserCredentialsRepositoryInterface } from '../repositories/user-credentials-repository.interface.js'; + +@Injectable() +export class UserCredentialsService { + constructor( + @Inject(USER_CREDENTIALS_REPOSITORY_TOKEN) + private readonly userCredentialsRepository: UserCredentialsRepositoryInterface, + private readonly txScope: TransactionScope, + private readonly eventPublisher: EventPublisher, + private readonly passwordPort: UserPasswordPort, + private readonly passwordPolicy: UserPasswordPolicy, + ) {} + + async setPassword( + ctx: PlainLiteralObject, + eventContext: EventContextHost, + userId: ReferenceId, + password: string | PasswordStorageInterface, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const existing = await this.userCredentialsRepository.findActiveByUserId( + txCtx, + userId, + ); + + if (existing) { + throw new UserCredentialsAlreadyExistException(); + } + + const passwordStorage = isPasswordStorage(password) + ? password + : await this.passwordPort.create(password); + + return this.createCredentials( + txCtx, + eventContext, + userId, + passwordStorage, + ); + }); + } + + async updatePassword( + ctx: PlainLiteralObject, + eventContext: EventContextHost, + userId: ReferenceId, + password: string, + passwordCurrent?: string, + ): Promise { + await this.txScope.run(ctx, async (txCtx) => { + // fetch active credentials + const activeCredentials = + await this.userCredentialsRepository.findActiveByUserId(txCtx, userId); + + // validate current password if required by policy + if (this.passwordPolicy.requireCurrent) { + if (!activeCredentials || !passwordCurrent) { + throw new UserPasswordCurrentInvalidException(); + } + + await this.validateCurrentPassword(activeCredentials, passwordCurrent); + } + + // validate against history + await this.validateHistory(txCtx, userId, password); + + // hash + const passwordStorage = await this.passwordPort.create(password); + + if (activeCredentials) { + await this.deactivateCredentials( + txCtx, + eventContext, + activeCredentials, + ); + } + + await this.createCredentials( + txCtx, + eventContext, + userId, + passwordStorage, + ); + }); + } + + protected async createCredentials( + ctx: PlainLiteralObject, + eventContext: EventContextHost, + userId: ReferenceId, + passwordStorage: PasswordStorageInterface, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const credentials = UserCredentials.create(eventContext, { + userId, + passwordHash: passwordStorage.passwordHash, + }); + + const merged = this.eventPublisher.mergeObjectContext(credentials); + await this.userCredentialsRepository.save(txCtx, merged); + txCtx.trx.onCommit(() => merged.commit()); + txCtx.trx.onRollback(() => merged.uncommit()); + + return merged; + }); + } + + protected async deactivateCredentials( + ctx: PlainLiteralObject, + eventContext: EventContextHost, + credentials: UserCredentials, + ): Promise { + return this.txScope.run(ctx, async (txCtx) => { + const merged = this.eventPublisher.mergeObjectContext(credentials); + merged.deactivate(eventContext); + await this.userCredentialsRepository.save(txCtx, merged); + txCtx.trx.onCommit(() => merged.commit()); + txCtx.trx.onRollback(() => merged.uncommit()); + }); + } + + protected async validateCurrentPassword( + target: ReferenceIdInterface & PasswordStorageInterface, + passwordCurrent: string, + ): Promise { + const currentIsValid = await this.passwordPort.validateCurrent( + passwordCurrent, + target, + ); + + if (!currentIsValid) { + throw new UserPasswordCurrentInvalidException(); + } + } + + protected async validateHistory( + ctx: PlainLiteralObject, + userId: ReferenceId, + password: string, + ): Promise { + if (!this.passwordPolicy.reuseRestricted) return; + + const history = await this.userCredentialsRepository.findByUserId( + ctx, + userId, + this.passwordPolicy.reuseLimitDate, + ); + + const collection = new UserCredentialsCollection( + history, + this.passwordPort, + ); + + await collection.notReused(password); + } +} diff --git a/packages/nestjs-user/src/dto/profile/user-profile-create.dto.ts b/packages/nestjs-user/src/dto/profile/user-profile-create.dto.ts deleted file mode 100644 index 34f6298f5..000000000 --- a/packages/nestjs-user/src/dto/profile/user-profile-create.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { PickType } from '@nestjs/swagger'; - -import { UserProfileCreatableInterface } from '@concepta/nestjs-common'; - -import { UserProfileDto } from './user-profile.dto'; - -/** - * User Profile Create DTO - */ -@Exclude() -export class UserProfileCreateDto - extends PickType(UserProfileDto, ['userId'] as const) - implements UserProfileCreatableInterface {} diff --git a/packages/nestjs-user/src/dto/profile/user-profile-paginated.dto.ts b/packages/nestjs-user/src/dto/profile/user-profile-paginated.dto.ts deleted file mode 100644 index d0a93a51a..000000000 --- a/packages/nestjs-user/src/dto/profile/user-profile-paginated.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { UserProfileInterface } from '@concepta/nestjs-common'; -import { CrudResponsePaginatedDto } from '@concepta/nestjs-crud'; - -import { UserProfileDto } from './user-profile.dto'; - -/** - * User Profile paginated DTO - */ -@Exclude() -export class UserProfilePaginatedDto extends CrudResponsePaginatedDto { - @Expose() - @ApiProperty({ - type: UserProfileDto, - isArray: true, - description: 'Array of User Profiles', - }) - @Type(() => UserProfileDto) - data: UserProfileDto[] = []; -} diff --git a/packages/nestjs-user/src/dto/profile/user-profile-update.dto.ts b/packages/nestjs-user/src/dto/profile/user-profile-update.dto.ts deleted file mode 100644 index a87186425..000000000 --- a/packages/nestjs-user/src/dto/profile/user-profile-update.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Exclude } from 'class-transformer'; - -/** - * User Profile Update DTO - * - * This is just a placeholder. You need to define your custom properties in your DTO. - */ -@Exclude() -export class UserProfileUpdateDto {} diff --git a/packages/nestjs-user/src/dto/profile/user-profile.dto.ts b/packages/nestjs-user/src/dto/profile/user-profile.dto.ts deleted file mode 100644 index d2d60d0ef..000000000 --- a/packages/nestjs-user/src/dto/profile/user-profile.dto.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; -import { IsOptional, IsString, ValidateNested } from 'class-validator'; - -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -import { - UserInterface, - UserProfileInterface, - CommonEntityDto, -} from '@concepta/nestjs-common'; - -import { UserDto } from '../user.dto'; - -/** - * User Profile DTO - */ -@Exclude() -export class UserProfileDto - extends CommonEntityDto - implements UserProfileInterface -{ - /** - * Active - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'The user id of this profile', - }) - @IsString() - userId!: string; - - /** - * Owner - */ - @Expose() - @ApiPropertyOptional({ - type: UserDto, - description: 'The user of this profile', - }) - @IsOptional() - @ValidateNested() - @Type(() => UserDto) - user?: UserInterface; -} diff --git a/packages/nestjs-user/src/dto/user-create-many.dto.ts b/packages/nestjs-user/src/dto/user-create-many.dto.ts deleted file mode 100644 index 00064a6c4..000000000 --- a/packages/nestjs-user/src/dto/user-create-many.dto.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { UserCreatableInterface } from '@concepta/nestjs-common'; -import { CrudCreateManyDto } from '@concepta/nestjs-crud'; - -import { UserCreateDto } from './user-create.dto'; - -/** - * User DTO - */ -@Exclude() -export class UserCreateManyDto extends CrudCreateManyDto { - @Expose() - @ApiProperty({ - type: UserCreateDto, - isArray: true, - description: 'Array of Users to create', - }) - @Type(() => UserCreateDto) - @IsArray() - @ArrayNotEmpty() - bulk: UserCreateDto[] = []; -} diff --git a/packages/nestjs-user/src/dto/user-create.dto.ts b/packages/nestjs-user/src/dto/user-create.dto.ts deleted file mode 100644 index 5bbe465c6..000000000 --- a/packages/nestjs-user/src/dto/user-create.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { IntersectionType, PartialType, PickType } from '@nestjs/swagger'; - -import { UserCreatableInterface } from '@concepta/nestjs-common'; - -import { UserPasswordHashDto } from './user-password-hash.dto'; -import { UserDto } from './user.dto'; - -/** - * User Create DTO - */ -@Exclude() -export class UserCreateDto - extends IntersectionType( - PickType(UserDto, ['username', 'email'] as const), - PartialType(PickType(UserDto, ['active'] as const)), - PartialType(UserPasswordHashDto), - ) - implements UserCreatableInterface {} diff --git a/packages/nestjs-user/src/dto/user-paginated.dto.ts b/packages/nestjs-user/src/dto/user-paginated.dto.ts deleted file mode 100644 index f73ce0fc1..000000000 --- a/packages/nestjs-user/src/dto/user-paginated.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Exclude, Expose, Type } from 'class-transformer'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { UserInterface } from '@concepta/nestjs-common'; -import { CrudResponsePaginatedDto } from '@concepta/nestjs-crud'; - -import { UserDto } from './user.dto'; - -/** - * User paginated DTO - */ -@Exclude() -export class UserPaginatedDto extends CrudResponsePaginatedDto { - @Expose() - @ApiProperty({ - type: UserDto, - isArray: true, - description: 'Array of Users', - }) - @Type(() => UserDto) - data: UserDto[] = []; -} diff --git a/packages/nestjs-user/src/dto/user-password-hash.dto.ts b/packages/nestjs-user/src/dto/user-password-hash.dto.ts deleted file mode 100644 index a036ee118..000000000 --- a/packages/nestjs-user/src/dto/user-password-hash.dto.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { PasswordStorageInterface } from '@concepta/nestjs-common'; - -/** - * User plain password - */ -@Exclude() -export class UserPasswordHashDto implements PasswordStorageInterface { - @Expose({ toClassOnly: true }) - @ApiProperty({ - type: 'string', - description: 'Password hash', - }) - @IsString() - passwordHash!: string; - - @Expose({ toClassOnly: true }) - @ApiProperty({ - type: 'string', - description: 'Password salt', - }) - @IsString() - passwordSalt!: string; -} diff --git a/packages/nestjs-user/src/dto/user-password-history-create.dto.ts b/packages/nestjs-user/src/dto/user-password-history-create.dto.ts deleted file mode 100644 index 7ab20779a..000000000 --- a/packages/nestjs-user/src/dto/user-password-history-create.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { PickType } from '@nestjs/swagger'; - -import { UserPasswordHistoryCreatableInterface } from '@concepta/nestjs-common'; - -import { UserPasswordHistoryDto } from './user-password-history.dto'; - -/** - * User Password History Create DTO - */ -@Exclude() -export class UserPasswordHistoryCreateDto - extends PickType(UserPasswordHistoryDto, [ - 'passwordHash', - 'passwordSalt', - 'userId', - ] as const) - implements UserPasswordHistoryCreatableInterface {} diff --git a/packages/nestjs-user/src/dto/user-password-history.dto.ts b/packages/nestjs-user/src/dto/user-password-history.dto.ts deleted file mode 100644 index 7ad2694e5..000000000 --- a/packages/nestjs-user/src/dto/user-password-history.dto.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsString, IsUUID } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { - CommonEntityDto, - UserPasswordHistoryInterface, -} from '@concepta/nestjs-common'; - -/** - * User Password History DTO - */ -@Exclude() -export class UserPasswordHistoryDto - extends CommonEntityDto - implements UserPasswordHistoryInterface -{ - /** - * Password Hash - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'Password Hash', - }) - @IsString() - passwordHash!: string; - - /** - * Password Salt - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'Password Salt', - }) - @IsString() - passwordSalt!: string; - - /** - * User ID - */ - @Expose() - @ApiProperty({ - type: 'string', - format: 'uuid', - description: 'User ID', - }) - @IsUUID() - userId!: string; -} diff --git a/packages/nestjs-user/src/dto/user-password-update.dto.ts b/packages/nestjs-user/src/dto/user-password-update.dto.ts deleted file mode 100644 index a7b5c50d7..000000000 --- a/packages/nestjs-user/src/dto/user-password-update.dto.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsOptional, IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { PasswordPlainCurrentInterface } from '@concepta/nestjs-common'; - -import { UserPasswordDto } from './user-password.dto'; - -/** - * User update password DTO - */ -@Exclude() -export class UserPasswordUpdateDto - extends UserPasswordDto - implements PasswordPlainCurrentInterface -{ - @Expose({ toClassOnly: true }) - @ApiProperty({ - type: 'string', - description: 'Current password to validate', - }) - @IsOptional() - @IsString() - passwordCurrent!: string; -} diff --git a/packages/nestjs-user/src/dto/user-password.dto.ts b/packages/nestjs-user/src/dto/user-password.dto.ts deleted file mode 100644 index d15c3e5d8..000000000 --- a/packages/nestjs-user/src/dto/user-password.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { PasswordPlainInterface } from '@concepta/nestjs-common'; - -/** - * User plain password - */ -@Exclude() -export class UserPasswordDto implements PasswordPlainInterface { - @Expose({ toClassOnly: true }) - @ApiProperty({ - type: 'string', - description: 'Plain text password to set', - }) - @IsString() - password!: string; -} diff --git a/packages/nestjs-user/src/dto/user-update.dto.ts b/packages/nestjs-user/src/dto/user-update.dto.ts deleted file mode 100644 index e15d51ca8..000000000 --- a/packages/nestjs-user/src/dto/user-update.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Exclude } from 'class-transformer'; - -import { IntersectionType, PartialType, PickType } from '@nestjs/swagger'; - -import { UserUpdatableInterface } from '@concepta/nestjs-common'; - -import { UserPasswordHashDto } from './user-password-hash.dto'; -import { UserDto } from './user.dto'; - -/** - * User Update DTO - */ -@Exclude() -export class UserUpdateDto - extends IntersectionType( - PickType(UserDto, ['id'] as const), - PartialType(PickType(UserDto, ['email', 'active'] as const)), - PartialType(UserPasswordHashDto), - ) - implements UserUpdatableInterface {} diff --git a/packages/nestjs-user/src/dto/user.dto.ts b/packages/nestjs-user/src/dto/user.dto.ts deleted file mode 100644 index f2833d9bb..000000000 --- a/packages/nestjs-user/src/dto/user.dto.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Exclude, Expose } from 'class-transformer'; -import { IsBoolean, IsEmail, IsString } from 'class-validator'; - -import { ApiProperty } from '@nestjs/swagger'; - -import { CommonEntityDto, UserInterface } from '@concepta/nestjs-common'; - -/** - * User DTO - */ -@Exclude() -export class UserDto extends CommonEntityDto implements UserInterface { - /** - * Email - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'Email', - }) - @IsEmail() - email: string = ''; - - /** - * Username - */ - @Expose() - @ApiProperty({ - type: 'string', - description: 'Username', - }) - @IsString() - username: string = ''; - - /** - * Active - */ - @Expose() - @ApiProperty({ - type: 'boolean', - description: 'Active', - }) - @IsBoolean() - active!: boolean; -} diff --git a/packages/nestjs-user/src/exceptions/user-bad-request-exception.ts b/packages/nestjs-user/src/exceptions/user-bad-request-exception.ts deleted file mode 100644 index b1eceec0e..000000000 --- a/packages/nestjs-user/src/exceptions/user-bad-request-exception.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { UserException } from './user-exception'; - -export class UserBadRequestException extends UserException { - constructor(options?: RuntimeExceptionOptions) { - super({ - httpStatus: HttpStatus.BAD_REQUEST, - ...options, - }); - - this.errorCode = 'USER_BAD_REQUEST_ERROR'; - } -} diff --git a/packages/nestjs-user/src/exceptions/user-exception.ts b/packages/nestjs-user/src/exceptions/user-exception.ts deleted file mode 100644 index 52dcad3a6..000000000 --- a/packages/nestjs-user/src/exceptions/user-exception.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { - RuntimeException, - RuntimeExceptionOptions, -} from '@concepta/nestjs-common'; -/** - * Generic user exception. - */ -export class UserException extends RuntimeException { - constructor(options?: RuntimeExceptionOptions) { - super(options); - this.errorCode = 'USER_ERROR'; - } -} diff --git a/packages/nestjs-user/src/exceptions/user-missing-entities-options.exception.ts b/packages/nestjs-user/src/exceptions/user-missing-entities-options.exception.ts deleted file mode 100644 index dab47d182..000000000 --- a/packages/nestjs-user/src/exceptions/user-missing-entities-options.exception.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { UserException } from './user-exception'; - -export class UserMissingEntitiesOptionsException extends UserException { - constructor() { - super({ - message: 'You must provide the entities option', - }); - this.errorCode = 'USER_MISSING_ENTITIES_OPTION'; - } -} diff --git a/packages/nestjs-user/src/exceptions/user-not-found-exception.ts b/packages/nestjs-user/src/exceptions/user-not-found-exception.ts deleted file mode 100644 index f2d500b24..000000000 --- a/packages/nestjs-user/src/exceptions/user-not-found-exception.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -import { RuntimeExceptionOptions } from '@concepta/nestjs-common'; - -import { UserException } from './user-exception'; - -export class UserNotFoundException extends UserException { - constructor(options?: RuntimeExceptionOptions) { - super({ - message: 'The user was not found', - httpStatus: HttpStatus.NOT_FOUND, - ...options, - }); - - this.errorCode = 'USER_NOT_FOUND_ERROR'; - } -} diff --git a/packages/nestjs-user/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts b/packages/nestjs-user/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts new file mode 100644 index 000000000..242a90aaf --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/__tests__/fixtures/app-crud.module.fixture.ts @@ -0,0 +1,160 @@ +import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { CqrsModule } from '@nestjs/cqrs'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { CoreModule, UseHooks, Operation } from '@concepta/nestjs-core'; +import { CrudCqrsResolver, CrudModule } from '@concepta/nestjs-crud'; +import { + CreatePasswordCommand, + PasswordModule, + PasswordUpdateInterface, + ValidateCurrentPasswordCommand, + ValidatePasswordHistoryCommand, +} from '@concepta/nestjs-password'; +import { RepositoryModule } from '@concepta/nestjs-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import { UserCredentialEntityFixture } from '../../../../__tests__/fixtures/entities/user-credential.entity.fixture.js'; +import { UserEntityFixture } from '../../../../__tests__/fixtures/entities/user.entity.fixture.js'; +import { ormConfig } from '../../../../__tests__/fixtures/ormconfig.fixture.js'; +import { UserInterface } from '../../../../domain/interfaces/user.interface.js'; +import { userPasswordUpdateSchema } from '../../../../infrastructure/schemas/password/user-password-update.schema.js'; +import { userCreateSchema } from '../../../../infrastructure/schemas/user-create.schema.js'; +import { userPaginatedSchema } from '../../../../infrastructure/schemas/user-paginated.schema.js'; +import { userUpdateSchema } from '../../../../infrastructure/schemas/user-update.schema.js'; +import { userSchema } from '../../../../infrastructure/schemas/user.schema.js'; +import { UserModule } from '../../../../user.module.js'; +import { CreateUserRequestHandler } from '../../commands/handlers/create-user-request.handler.js'; +import { DeleteUserRequestHandler } from '../../commands/handlers/delete-user-request.handler.js'; +import { UpdateUserPasswordRequestHandler } from '../../commands/handlers/update-user-password-request.handler.js'; +import { UpdateUserRequestHandler } from '../../commands/handlers/update-user-request.handler.js'; +import { CreateUserRequest } from '../../commands/impl/create-user.request.js'; +import { DeleteUserRequest } from '../../commands/impl/delete-user.request.js'; +import { UpdateUserPasswordRequest } from '../../commands/impl/update-user-password.request.js'; +import { UpdateUserRequest } from '../../commands/impl/update-user.request.js'; +import { ListUsersRequestHandler } from '../../queries/handlers/list-users-request.handler.js'; +import { ReadUserRequestHandler } from '../../queries/handlers/read-user-request.handler.js'; +import { ListUsersRequest } from '../../queries/impl/list-users.request.js'; +import { ReadUserRequest } from '../../queries/impl/read-user.request.js'; + +import { AuthorizedUserOverlayFixture } from './authorized-user.local.fixture.js'; +import { FakeAuthInterceptorFixture } from './fake-auth.interceptor.fixture.js'; +import { UserScopeHookFixture } from './user-scope.hook.fixture.js'; + +const USER_ENTITY_KEY_FIXTURE = 'user'; +const USER_CREDENTIALS_ENTITY_KEY_FIXTURE = 'user-credentials'; + +@Module({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + CqrsModule.forRoot(), + RepositoryModule.forRoot({}), + CrudModule.forRoot({ + defaultResolver: CrudCqrsResolver, + }), + CoreModule.forRoot(), + PasswordModule.forRoot({}), + RepositoryModule.forFeature({ + module: TypeOrmRepositoryModule, + entities: [ + { key: USER_ENTITY_KEY_FIXTURE, entity: UserEntityFixture }, + { + key: USER_CREDENTIALS_ENTITY_KEY_FIXTURE, + entity: UserCredentialEntityFixture, + }, + ], + }), + UserModule.forRoot({ + entities: { + user: USER_ENTITY_KEY_FIXTURE, + credentials: USER_CREDENTIALS_ENTITY_KEY_FIXTURE, + }, + ports: { + password: { + createCommand: CreatePasswordCommand, + validateCurrentCommand: ValidateCurrentPasswordCommand, + validateHistoryCommand: ValidatePasswordHistoryCommand, + }, + }, + }), + CrudModule.forFeature({ + crud: { + controller: { + entity: USER_ENTITY_KEY_FIXTURE, + path: 'user', + resolver: CrudCqrsResolver, + transactional: true, + request: { body: userCreateSchema }, + response: { + resource: userSchema, + paginated: userPaginatedSchema, + }, + }, + operations: [ + { + operation: Operation.List, + query: ListUsersRequest, + queryHandler: ListUsersRequestHandler, + }, + { + operation: Operation.Read, + query: ReadUserRequest, + queryHandler: ReadUserRequestHandler, + }, + { + operation: Operation.Create, + request: { body: userCreateSchema }, + command: CreateUserRequest, + commandHandler: CreateUserRequestHandler, + }, + { + operation: Operation.Update, + request: { body: userUpdateSchema }, + command: UpdateUserRequest, + commandHandler: UpdateUserRequestHandler, + }, + { + operation: Operation.Delete, + command: DeleteUserRequest, + commandHandler: DeleteUserRequestHandler, + }, + ], + }, + }), + CrudModule.forFeature({ + crud: { + controller: { + entity: USER_ENTITY_KEY_FIXTURE, + path: 'password', + resolver: CrudCqrsResolver, + transactional: true, + request: { body: userPasswordUpdateSchema }, + response: { resource: userSchema }, + extraDecorators: [UseHooks(UserScopeHookFixture)], + }, + operations: [ + { + operation: Operation.Update, + request: { body: userPasswordUpdateSchema }, + command: UpdateUserPasswordRequest, + commandHandler: UpdateUserPasswordRequestHandler, + }, + ], + }, + }), + ], + providers: [ + UserScopeHookFixture, + FakeAuthInterceptorFixture, + { + provide: APP_INTERCEPTOR, + useExisting: FakeAuthInterceptorFixture, + }, + { + provide: APP_INTERCEPTOR, + useClass: AuthorizedUserOverlayFixture, + }, + ], +}) +export class AppModuleCrudFixture {} diff --git a/packages/nestjs-user/src/gateways/http/__tests__/fixtures/authorized-user.local.fixture.ts b/packages/nestjs-user/src/gateways/http/__tests__/fixtures/authorized-user.local.fixture.ts new file mode 100644 index 000000000..26f9fbe14 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/__tests__/fixtures/authorized-user.local.fixture.ts @@ -0,0 +1,24 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; + +import { + ContextOverlayInterceptor, + getAppContext, + OverlayRef, +} from '@concepta/nestjs-core'; + +import { UserEntityInterface } from '../../../../domain/interfaces/user-entity.interface.js'; + +export const AuthorizedUserRef = new OverlayRef< + 'withAuthorizedUser', + UserEntityInterface +>('withAuthorizedUser'); + +@Injectable() +export class AuthorizedUserOverlayFixture extends ContextOverlayInterceptor { + readonly ref = AuthorizedUserRef; + + async attach(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + getAppContext(req).defineOverlay(this.ref, req.user); + } +} diff --git a/packages/nestjs-user/src/gateways/http/__tests__/fixtures/fake-auth.interceptor.fixture.ts b/packages/nestjs-user/src/gateways/http/__tests__/fixtures/fake-auth.interceptor.fixture.ts new file mode 100644 index 000000000..c59984c76 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/__tests__/fixtures/fake-auth.interceptor.fixture.ts @@ -0,0 +1,21 @@ +import { Observable } from 'rxjs'; + +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; + +import { UserEntityInterface } from '../../../../domain/interfaces/user-entity.interface.js'; + +@Injectable() +export class FakeAuthInterceptorFixture implements NestInterceptor { + user: UserEntityInterface | undefined; + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + request.user = this.user; + return next.handle(); + } +} diff --git a/packages/nestjs-user/src/gateways/http/__tests__/fixtures/user-scope.hook.fixture.ts b/packages/nestjs-user/src/gateways/http/__tests__/fixtures/user-scope.hook.fixture.ts new file mode 100644 index 000000000..2c8a10228 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/__tests__/fixtures/user-scope.hook.fixture.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; + +import { AppContextInterface } from '@concepta/nestjs-core'; +import { + BeforeFindOne, + RepoHook, + RepositoryFindOneOptions, + RepoSpec, + Where, +} from '@concepta/nestjs-repository'; + +import { UserCredentialEntityInterface } from '../../../../domain/interfaces/user-credential-entity.interface.js'; +import { UserEntityInterface } from '../../../../domain/interfaces/user-entity.interface.js'; + +import { AuthorizedUserRef } from './authorized-user.local.fixture.js'; + +@RepoHook() +@Injectable() +export class UserScopeHookFixture { + @BeforeFindOne(RepoSpec.isEntity('user')) + async scopeUserLookup( + options: RepositoryFindOneOptions, + ctx?: AppContextInterface, + ): Promise> { + const authorizedUser = ctx?.supports(AuthorizedUserRef) + ? ctx.with(AuthorizedUserRef) + : undefined; + + if (!authorizedUser?.id) { + return options; + } + + const condition = Where.eq('id', authorizedUser.id); + + return { + ...options, + where: options.where ? Where.and(options.where, condition) : condition, + }; + } + + @BeforeFindOne(RepoSpec.isEntity('user-credentials')) + async scopeCredentialsLookup( + options: RepositoryFindOneOptions, + ctx?: AppContextInterface, + ): Promise> { + const authorizedUser = ctx?.supports(AuthorizedUserRef) + ? ctx.with(AuthorizedUserRef) + : undefined; + + if (!authorizedUser?.id) { + return options; + } + + const userCondition = Where.eq('userId', authorizedUser.id); + + return { + ...options, + where: options.where + ? Where.and(options.where, userCondition) + : userCondition, + }; + } +} diff --git a/packages/nestjs-user/src/gateways/http/__tests__/user-crud.controller.e2e-spec.ts b/packages/nestjs-user/src/gateways/http/__tests__/user-crud.controller.e2e-spec.ts new file mode 100644 index 000000000..5930f4074 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/__tests__/user-crud.controller.e2e-spec.ts @@ -0,0 +1,249 @@ +import supertest from 'supertest'; +import { type Repository } from 'typeorm'; +import { type MockInstance } from 'vitest'; + +import { type INestApplication } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm'; + +import { TransactionScope } from '@concepta/nestjs-repository'; +import { SeedingSource } from '@concepta/typeorm-seeding'; + +import { UserCredentialEntityFixture } from '../../../__tests__/fixtures/entities/user-credential.entity.fixture.js'; +import { UserEntityFixture } from '../../../__tests__/fixtures/entities/user.entity.fixture.js'; +import { type UserEntityInterface } from '../../../domain/interfaces/user-entity.interface.js'; +import { UserFactory } from '../../../infrastructure/seeding/user.factory.js'; +import { UserSeeder } from '../../../infrastructure/seeding/user.seeder.js'; + +import { AppModuleCrudFixture } from './fixtures/app-crud.module.fixture.js'; +import { FakeAuthInterceptorFixture } from './fixtures/fake-auth.interceptor.fixture.js'; + +describe('UserCrudController (e2e)', () => { + let app: INestApplication; + let seedingSource: SeedingSource; + let txSpy: MockInstance; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModuleCrudFixture], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + + const txScope = app.get(TransactionScope); + txSpy = vi.spyOn(txScope, 'run'); + + seedingSource = new SeedingSource({ + dataSource: app.get(getDataSourceToken()), + }); + + await seedingSource.initialize(); + + const userSeeder = new UserSeeder({ + factories: [new UserFactory({ entity: UserEntityFixture })], + }); + + await seedingSource.run.one(userSeeder); + }); + + afterEach(async () => { + vi.clearAllMocks(); + return app ? await app.close() : undefined; + }); + + describe('User CRUD', () => { + it('GET /user', async () => { + const res = await supertest(app.getHttpServer()) + .get('/user?limit=10') + .expect(200); + + expect(res.body).toEqual({ + count: expect.any(Number), + total: expect.any(Number), + page: 1, + pageCount: expect.any(Number), + limit: 10, + data: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + username: expect.any(String), + email: expect.any(String), + }), + ]), + }); + }); + + it('GET /user/:id', async () => { + const listRes = await supertest(app.getHttpServer()) + .get('/user?limit=1') + .expect(200); + + const user = listRes.body.data[0]; + + const res = await supertest(app.getHttpServer()) + .get(`/user/${user.id}`) + .expect(200); + + expect(res.body).toEqual( + expect.objectContaining({ + id: user.id, + username: user.username, + email: user.email, + }), + ); + }); + + it('POST /user', async () => { + const res = await supertest(app.getHttpServer()) + .post('/user') + .send({ + username: 'user1', + email: 'user1@dispostable.com', + password: 'password1', + }) + .expect(201); + + expect(res.body).toEqual( + expect.objectContaining({ + id: expect.any(String), + username: 'user1', + email: 'user1@dispostable.com', + }), + ); + }); + + it('POST /user (no password)', async () => { + const res = await supertest(app.getHttpServer()) + .post('/user') + .send({ + username: 'user1', + email: 'user1@dispostable.com', + }) + .expect(201); + + expect(res.body).toEqual( + expect.objectContaining({ + id: expect.any(String), + username: 'user1', + email: 'user1@dispostable.com', + }), + ); + }); + + it("POST /user (with password) actually creates a UserCredentials row (regression: the legacy UserCreateDto exposed passwordHash instead of password, so excludeAll+excludeExtraneousValues silently stripped an HTTP client's password before CreateUserHandler ever read dto.password — no credentials were ever created)", async () => { + const credentialsRepository: Repository = + app.get(getRepositoryToken(UserCredentialEntityFixture)); + + const res = await supertest(app.getHttpServer()) + .post('/user') + .send({ + username: 'credentialed-user', + email: 'credentialed-user@dispostable.com', + password: 'realpassword', + }) + .expect(201); + + const credentials = await credentialsRepository.findOne({ + where: { userId: res.body.id }, + }); + + expect(credentials).not.toBeNull(); + expect(credentials?.passwordHash).toEqual(expect.any(String)); + expect(credentials?.passwordHash.length).toBeGreaterThan(0); + }); + + it('DELETE /user/:id', async () => { + const listRes = await supertest(app.getHttpServer()) + .get('/user?limit=1') + .expect(200); + + await supertest(app.getHttpServer()) + .delete(`/user/${listRes.body.data[0].id}`) + .expect(204); + }); + }); + + describe('@Transactional', () => { + it('should use transaction for POST /user', async () => { + await supertest(app.getHttpServer()) + .post('/user') + .send({ username: 'tx-test', email: 'tx@test.com' }) + .expect(201); + + expect(txSpy).toHaveBeenCalled(); + }); + + it('should NOT use transaction for GET /user (list)', async () => { + txSpy.mockClear(); + + await supertest(app.getHttpServer()).get('/user?limit=1').expect(200); + + expect(txSpy).not.toHaveBeenCalled(); + }); + + it('should NOT use transaction for GET /user/:id (read)', async () => { + const listRes = await supertest(app.getHttpServer()) + .get('/user?limit=1') + .expect(200); + + txSpy.mockClear(); + + await supertest(app.getHttpServer()) + .get(`/user/${listRes.body.data[0].id}`) + .expect(200); + + expect(txSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Password CRUD', () => { + let userA: UserEntityInterface; + let userB: UserEntityInterface; + let fakeAuth: FakeAuthInterceptorFixture; + + beforeEach(async () => { + fakeAuth = app.get(FakeAuthInterceptorFixture); + + // Create two users with known passwords + const resA = await supertest(app.getHttpServer()) + .post('/user') + .send({ + username: 'user-a', + email: 'user-a@test.com', + password: 'passwordA', + }) + .expect(201); + + userA = resA.body; + + const resB = await supertest(app.getHttpServer()) + .post('/user') + .send({ + username: 'user-b', + email: 'user-b@test.com', + password: 'passwordB', + }) + .expect(201); + + userB = resB.body; + }); + + it('PATCH /password/:id (update own password)', async () => { + fakeAuth.user = userA; + + await supertest(app.getHttpServer()) + .patch(`/password/${userA.id}`) + .send({ password: 'newPasswordA' }) + .expect(200); + }); + + it('PATCH /password/:id (scoped — cannot update another user)', async () => { + fakeAuth.user = userA; + + await supertest(app.getHttpServer()) + .patch(`/password/${userB.id}`) + .send({ password: 'hackedPassword' }) + .expect(404); + }); + }); +}); diff --git a/packages/nestjs-user/src/gateways/http/__tests__/user-crud.swagger.e2e-spec.ts b/packages/nestjs-user/src/gateways/http/__tests__/user-crud.swagger.e2e-spec.ts new file mode 100644 index 000000000..5d021872b --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/__tests__/user-crud.swagger.e2e-spec.ts @@ -0,0 +1,85 @@ +import { type INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { standardSchemaConverter } from '@concepta/nestjs-core'; + +import { AppModuleCrudFixture } from './fixtures/app-crud.module.fixture.js'; + +describe('UserController swagger (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModuleCrudFixture], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + return app ? await app.close() : undefined; + }); + + it('registers User and UserPaginated as named, $ref-reused components', () => { + const config = new DocumentBuilder() + .setTitle('user') + .setVersion('1.0') + .build(); + const document = SwaggerModule.createDocument(app, config, { + standardSchemaConverter, + }); + + expect(document.components?.schemas?.User).toBeDefined(); + expect(document.components?.schemas?.UserPaginated).toBeDefined(); + + const readResponse = + document.paths?.['/user/{id}']?.get?.responses?.['200']; + const listResponse = document.paths?.['/user']?.get?.responses?.['200']; + + if (!readResponse || !('content' in readResponse)) { + throw new Error( + 'expected the read response to be a content-bearing response object', + ); + } + if (!listResponse || !('content' in listResponse)) { + throw new Error( + 'expected the list response to be a content-bearing response object', + ); + } + + expect(readResponse.content?.['application/json']?.schema).toEqual({ + $ref: '#/components/schemas/User', + }); + expect(listResponse.content?.['application/json']?.schema).toEqual({ + $ref: '#/components/schemas/UserPaginated', + }); + }); + + it('documents the schema-based POST request body inline, since userCreateSchema is not a named component (no withNamedComponent)', () => { + const config = new DocumentBuilder() + .setTitle('user') + .setVersion('1.0') + .build(); + const document = SwaggerModule.createDocument(app, config, { + standardSchemaConverter, + }); + + const createBody = document.paths?.['/user']?.post?.requestBody; + if (!createBody || !('content' in createBody)) { + throw new Error( + 'expected the create request body to be a content-bearing request body object', + ); + } + + const schema = createBody.content?.['application/json']?.schema; + if (!schema || !('type' in schema)) { + throw new Error('expected an inline object schema, not a $ref'); + } + + expect(schema.type).toBe('object'); + expect(schema.properties).toBeDefined(); + // userCreateSchema was never passed through withNamedComponent. + expect(document.components?.schemas?.UserCreate).toBeUndefined(); + }); +}); diff --git a/packages/nestjs-user/src/gateways/http/commands/handlers/create-user-request.handler.ts b/packages/nestjs-user/src/gateways/http/commands/handlers/create-user-request.handler.ts new file mode 100644 index 000000000..b3d19f0ad --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/commands/handlers/create-user-request.handler.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { CreateUserCommand } from '../../../../application/commands/impl/create-user.command.js'; +import { User } from '../../../../domain/aggregates/user.js'; +import { CreateUserRequest } from '../impl/create-user.request.js'; + +@Injectable() +export class CreateUserRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: CreateUserRequest) { + const { context, dto } = command; + const user = await this.commandBus.execute( + new CreateUserCommand(context, dto), + ); + return user.toPlain(); + } +} diff --git a/packages/nestjs-user/src/gateways/http/commands/handlers/delete-user-request.handler.ts b/packages/nestjs-user/src/gateways/http/commands/handlers/delete-user-request.handler.ts new file mode 100644 index 000000000..c45cccfe6 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/commands/handlers/delete-user-request.handler.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { RemoveUserCommand } from '../../../../application/commands/impl/remove-user.command.js'; +import { assertUserId } from '../../../../application/utils/assert-user-id.util.js'; +import { User } from '../../../../domain/aggregates/user.js'; +import { DeleteUserRequest } from '../impl/delete-user.request.js'; + +@Injectable() +export class DeleteUserRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: DeleteUserRequest) { + const { context } = command; + const { id } = context.params; + const { returnDeleted = false } = context.options?.route ?? {}; + + assertUserId(id); + + const user = await this.commandBus.execute( + new RemoveUserCommand(context, id), + ); + + return returnDeleted ? user.toPlain() : null; + } +} diff --git a/packages/nestjs-user/src/gateways/http/commands/handlers/update-user-password-request.handler.ts b/packages/nestjs-user/src/gateways/http/commands/handlers/update-user-password-request.handler.ts new file mode 100644 index 000000000..42c088bf4 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/commands/handlers/update-user-password-request.handler.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { UpdateUserPasswordCommand } from '../../../../application/commands/impl/update-user-password.command.js'; +import { assertUserId } from '../../../../application/utils/assert-user-id.util.js'; +import { UpdateUserPasswordRequest } from '../impl/update-user-password.request.js'; + +@Injectable() +export class UpdateUserPasswordRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: UpdateUserPasswordRequest): Promise { + const { context, dto } = command; + const id = context.params.id; + + assertUserId(id); + + await this.commandBus.execute( + new UpdateUserPasswordCommand(context, id, dto), + ); + + return null; + } +} diff --git a/packages/nestjs-user/src/gateways/http/commands/handlers/update-user-request.handler.ts b/packages/nestjs-user/src/gateways/http/commands/handlers/update-user-request.handler.ts new file mode 100644 index 000000000..ab085f0a7 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/commands/handlers/update-user-request.handler.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { UpdateUserCommand } from '../../../../application/commands/impl/update-user.command.js'; +import { assertUserId } from '../../../../application/utils/assert-user-id.util.js'; +import { User } from '../../../../domain/aggregates/user.js'; +import { UpdateUserRequest } from '../impl/update-user.request.js'; + +@Injectable() +export class UpdateUserRequestHandler { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: UpdateUserRequest) { + const { context, dto } = command; + const { id } = context.params; + + assertUserId(id); + + const user = await this.commandBus.execute( + new UpdateUserCommand(context, id, dto), + ); + return user.toPlain(); + } +} diff --git a/packages/nestjs-user/src/gateways/http/commands/impl/create-user.request.ts b/packages/nestjs-user/src/gateways/http/commands/impl/create-user.request.ts new file mode 100644 index 000000000..423852ea7 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/commands/impl/create-user.request.ts @@ -0,0 +1,9 @@ +import { CrudCreateCommand } from '@concepta/nestjs-crud'; + +import { type UserCreatableInterface } from '../../../../domain/interfaces/user-creatable.interface.js'; +import { type UserInterface } from '../../../../domain/interfaces/user.interface.js'; + +export class CreateUserRequest extends CrudCreateCommand< + UserInterface, + UserCreatableInterface +> {} diff --git a/packages/nestjs-user/src/gateways/http/commands/impl/delete-user.request.ts b/packages/nestjs-user/src/gateways/http/commands/impl/delete-user.request.ts new file mode 100644 index 000000000..ce19cfc4b --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/commands/impl/delete-user.request.ts @@ -0,0 +1,5 @@ +import { CrudDeleteCommand } from '@concepta/nestjs-crud'; + +import { type UserInterface } from '../../../../domain/interfaces/user.interface.js'; + +export class DeleteUserRequest extends CrudDeleteCommand {} diff --git a/packages/nestjs-user/src/gateways/http/commands/impl/update-user-password.request.ts b/packages/nestjs-user/src/gateways/http/commands/impl/update-user-password.request.ts new file mode 100644 index 000000000..b1438cbdd --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/commands/impl/update-user-password.request.ts @@ -0,0 +1,7 @@ +import { CrudUpdateCommand } from '@concepta/nestjs-crud'; +import { type PasswordUpdateInterface } from '@concepta/nestjs-password'; + +export class UpdateUserPasswordRequest extends CrudUpdateCommand< + PasswordUpdateInterface, + PasswordUpdateInterface +> {} diff --git a/packages/nestjs-user/src/gateways/http/commands/impl/update-user.request.ts b/packages/nestjs-user/src/gateways/http/commands/impl/update-user.request.ts new file mode 100644 index 000000000..94c090ce4 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/commands/impl/update-user.request.ts @@ -0,0 +1,9 @@ +import { CrudUpdateCommand } from '@concepta/nestjs-crud'; + +import { type UserUpdatableInterface } from '../../../../domain/interfaces/user-updatable.interface.js'; +import { type UserInterface } from '../../../../domain/interfaces/user.interface.js'; + +export class UpdateUserRequest extends CrudUpdateCommand< + UserInterface, + UserUpdatableInterface +> {} diff --git a/packages/nestjs-user/src/gateways/http/queries/handlers/list-users-request.handler.ts b/packages/nestjs-user/src/gateways/http/queries/handlers/list-users-request.handler.ts new file mode 100644 index 000000000..bb1ebdedf --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/queries/handlers/list-users-request.handler.ts @@ -0,0 +1,5 @@ +import { CrudListHandler } from '@concepta/nestjs-crud'; + +import { type UserInterface } from '../../../../domain/interfaces/user.interface.js'; + +export class ListUsersRequestHandler extends CrudListHandler {} diff --git a/packages/nestjs-user/src/gateways/http/queries/handlers/read-user-request.handler.ts b/packages/nestjs-user/src/gateways/http/queries/handlers/read-user-request.handler.ts new file mode 100644 index 000000000..f58e2e2b9 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/queries/handlers/read-user-request.handler.ts @@ -0,0 +1,5 @@ +import { CrudReadHandler } from '@concepta/nestjs-crud'; + +import { type UserInterface } from '../../../../domain/interfaces/user.interface.js'; + +export class ReadUserRequestHandler extends CrudReadHandler {} diff --git a/packages/nestjs-user/src/gateways/http/queries/impl/list-users.request.ts b/packages/nestjs-user/src/gateways/http/queries/impl/list-users.request.ts new file mode 100644 index 000000000..3ece34d25 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/queries/impl/list-users.request.ts @@ -0,0 +1,5 @@ +import { CrudListQuery } from '@concepta/nestjs-crud'; + +import { type UserInterface } from '../../../../domain/interfaces/user.interface.js'; + +export class ListUsersRequest extends CrudListQuery {} diff --git a/packages/nestjs-user/src/gateways/http/queries/impl/read-user.request.ts b/packages/nestjs-user/src/gateways/http/queries/impl/read-user.request.ts new file mode 100644 index 000000000..92c07c8f9 --- /dev/null +++ b/packages/nestjs-user/src/gateways/http/queries/impl/read-user.request.ts @@ -0,0 +1,5 @@ +import { CrudReadQuery } from '@concepta/nestjs-crud'; + +import { type UserInterface } from '../../../../domain/interfaces/user.interface.js'; + +export class ReadUserRequest extends CrudReadQuery {} diff --git a/packages/nestjs-user/src/index.ts b/packages/nestjs-user/src/index.ts index 804a460ba..a94f5731d 100644 --- a/packages/nestjs-user/src/index.ts +++ b/packages/nestjs-user/src/index.ts @@ -1,34 +1,95 @@ -export { UserModule } from './user.module'; -export { UserProfileCrudBuilder } from './utils/user-profile.crud-builder'; - -export { UserModelService } from './services/user-model.service'; -export { UserPasswordService } from './services/user-password.service'; -export { UserAccessQueryService } from './services/user-access-query.service'; - -export { UserModelServiceInterface } from './interfaces/user-model-service.interface'; -export { UserPasswordServiceInterface } from './interfaces/user-password-service.interface'; -export { UserEntitiesOptionsInterface } from './interfaces/user-entities-options.interface'; - -export { UserCreateManyDto } from './dto/user-create-many.dto'; -export { UserCreateDto } from './dto/user-create.dto'; -export { UserPaginatedDto } from './dto/user-paginated.dto'; -export { UserPasswordDto } from './dto/user-password.dto'; -export { UserPasswordUpdateDto } from './dto/user-password-update.dto'; -export { UserPasswordHashDto } from './dto/user-password-hash.dto'; -export { UserUpdateDto } from './dto/user-update.dto'; -export { UserDto } from './dto/user.dto'; - -// Interfaces now in nestjs-common -// Entities moved to nestjs-typeorm-ext - -export { UserProfileDto } from './dto/profile/user-profile.dto'; -export { UserProfileCreateDto } from './dto/profile/user-profile-create.dto'; -export { UserProfileUpdateDto } from './dto/profile/user-profile-update.dto'; -export { UserProfilePaginatedDto } from './dto/profile/user-profile-paginated.dto'; - -export { UserResource } from './user.types'; - -export { UserException } from './exceptions/user-exception'; -export { UserBadRequestException } from './exceptions/user-bad-request-exception'; -export { UserNotFoundException } from './exceptions/user-not-found-exception'; -export { UserMissingEntitiesOptionsException } from './exceptions/user-missing-entities-options.exception'; +// module +export { UserModule } from './user.module.js'; + +// domain aggregates +export { User } from './domain/aggregates/user.js'; +export { UserCredentials } from './domain/aggregates/user-credentials.js'; + +// repositories +export { UserRepository } from './infrastructure/persistence/user.repository.js'; +export { UserCredentialsRepository } from './infrastructure/persistence/user-credentials.repository.js'; +export { UserRepositoryInterface } from './domain/repositories/user-repository.interface.js'; +export { UserCredentialsRepositoryInterface } from './domain/repositories/user-credentials-repository.interface.js'; + +// schemas (Zod / Standard Schema) +export { userSchema } from './infrastructure/schemas/user.schema.js'; +export { userPaginatedSchema } from './infrastructure/schemas/user-paginated.schema.js'; +export { userCreateSchema } from './infrastructure/schemas/user-create.schema.js'; +export { userUpdateSchema } from './infrastructure/schemas/user-update.schema.js'; +export { userPasswordSchema } from './infrastructure/schemas/password/user-password.schema.js'; +export { userPasswordUpdateSchema } from './infrastructure/schemas/password/user-password-update.schema.js'; +export { userPasswordHashSchema } from './infrastructure/schemas/password/user-password-hash.schema.js'; + +// commands +export { CreateUserCommand } from './application/commands/impl/create-user.command.js'; +export { UpdateUserCommand } from './application/commands/impl/update-user.command.js'; +export { RemoveUserCommand } from './application/commands/impl/remove-user.command.js'; +export { CreateUserCredentialCommand } from './application/commands/impl/create-user-credential.command.js'; +export { UpdateUserCredentialCommand } from './application/commands/impl/update-user-credential.command.js'; +export { SetUserPasswordCommand } from './application/commands/impl/set-user-password.command.js'; +export { UpdateUserPasswordCommand } from './application/commands/impl/update-user-password.command.js'; + +// events +export { UserCreatedEvent } from './domain/events/user-created.event.js'; +export { UserUpdatedEvent } from './domain/events/user-updated.event.js'; +export { UserRemovedEvent } from './domain/events/user-removed.event.js'; +export { UserCredentialsCreatedEvent } from './domain/events/user-credentials-created.event.js'; +export { UserCredentialsDeactivatedEvent } from './domain/events/user-credentials-deactivated.event.js'; + +// queries +export { GetUserQuery } from './application/queries/impl/get-user.query.js'; +export { GetUserByEmailQuery } from './application/queries/impl/get-user-by-email.query.js'; +export { GetUserByUsernameQuery } from './application/queries/impl/get-user-by-username.query.js'; +export { GetUserBySubjectQuery } from './application/queries/impl/get-user-by-subject.query.js'; + +// command handlers +export { CreateUserHandler } from './application/commands/handlers/create-user.handler.js'; +export { UpdateUserHandler } from './application/commands/handlers/update-user.handler.js'; +export { RemoveUserHandler } from './application/commands/handlers/remove-user.handler.js'; +export { CreateUserCredentialHandler } from './application/commands/handlers/create-user-credential.handler.js'; +export { UpdateUserCredentialHandler } from './application/commands/handlers/update-user-credential.handler.js'; +export { SetUserPasswordHandler } from './application/commands/handlers/set-user-password.handler.js'; +export { UpdateUserPasswordHandler } from './application/commands/handlers/update-user-password.handler.js'; + +// query handlers +export { GetUserHandler } from './application/queries/handlers/get-user.handler.js'; +export { GetUserByEmailHandler } from './application/queries/handlers/get-user-by-email.handler.js'; +export { GetUserBySubjectHandler } from './application/queries/handlers/get-user-by-subject.handler.js'; +export { GetUserByUsernameHandler } from './application/queries/handlers/get-user-by-username.handler.js'; + +// domain services +export { UserCredentialsService } from './domain/services/user-credentials.service.js'; + +// ports +export { + UserPasswordPort, + UserPasswordPortSettings, + CreatePasswordCommandInterface, + ValidateCurrentPasswordCommandInterface, + ValidatePasswordHistoryCommandInterface, +} from './domain/ports/user-password.port.js'; + +// domain interfaces +export { UserInterface } from './domain/interfaces/user.interface.js'; +export { UserEntityInterface } from './domain/interfaces/user-entity.interface.js'; +export { UserCreatableInterface } from './domain/interfaces/user-creatable.interface.js'; +export { UserUpdatableInterface } from './domain/interfaces/user-updatable.interface.js'; +export { UserOwnableInterface } from './domain/interfaces/user-ownable.interface.js'; +export { UserRelationInterface } from './domain/interfaces/user-relation.interface.js'; +export { UserCredentialInterface } from './domain/interfaces/user-credential.interface.js'; +export { UserCredentialEntityInterface } from './domain/interfaces/user-credential-entity.interface.js'; +export { UserCredentialCreatableInterface } from './domain/interfaces/user-credential-creatable.interface.js'; + +// config interfaces +export { UserOptionsInterface } from './infrastructure/config/interfaces/user-options.interface.js'; +export { UserExtrasInterface } from './infrastructure/config/interfaces/user-extras.interface.js'; +export { UserSettingsInterface } from './infrastructure/config/interfaces/user-settings.interface.js'; +export { UserCredentialsEventPayloadInterface } from './domain/events/interfaces/user-credentials-event-payload.interface.js'; +export { PasswordPolicySettings } from './domain/policies/user-password.policy.js'; + +// exceptions +export { UserException } from './domain/exceptions/user.exception.js'; +export { UserNotFoundException } from './application/exceptions/user-not-found.exception.js'; +export { UserCredentialsAlreadyExistException } from './domain/exceptions/user-credentials-already-exist.exception.js'; +export { UserPasswordCurrentInvalidException } from './domain/exceptions/user-password-current-invalid.exception.js'; +export { UserPasswordHistoryViolationException } from './domain/exceptions/user-password-history-violation.exception.js'; diff --git a/packages/nestjs-user/src/infrastructure/config/interfaces/user-extras.interface.ts b/packages/nestjs-user/src/infrastructure/config/interfaces/user-extras.interface.ts new file mode 100644 index 000000000..9727880da --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/config/interfaces/user-extras.interface.ts @@ -0,0 +1,16 @@ +import { type DynamicModule, type Provider, type Type } from '@nestjs/common'; + +import { type UserCredentialsRepositoryInterface } from '../../../domain/repositories/user-credentials-repository.interface.js'; +import { type UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; + +export interface UserExtrasInterface extends Pick { + providers?: Provider[]; + entities: { + user: string; + credentials?: string; + }; + repositories?: { + user?: Type; + userCredentials?: Type; + }; +} diff --git a/packages/nestjs-user/src/infrastructure/config/interfaces/user-options.interface.ts b/packages/nestjs-user/src/infrastructure/config/interfaces/user-options.interface.ts new file mode 100644 index 000000000..4dd599ce9 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/config/interfaces/user-options.interface.ts @@ -0,0 +1,10 @@ +import { type UserPasswordPortSettings } from '../../../domain/ports/user-password.port.js'; + +import { type UserSettingsInterface } from './user-settings.interface.js'; + +export interface UserOptionsInterface { + settings?: UserSettingsInterface; + ports?: { + password?: UserPasswordPortSettings; + }; +} diff --git a/packages/nestjs-user/src/infrastructure/config/interfaces/user-settings.interface.ts b/packages/nestjs-user/src/infrastructure/config/interfaces/user-settings.interface.ts new file mode 100644 index 000000000..2904b2fff --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/config/interfaces/user-settings.interface.ts @@ -0,0 +1,5 @@ +import { type PasswordPolicySettings } from '../../../domain/policies/user-password.policy.js'; + +export interface UserSettingsInterface { + password?: PasswordPolicySettings; +} diff --git a/packages/nestjs-user/src/infrastructure/config/user-default.config.ts b/packages/nestjs-user/src/infrastructure/config/user-default.config.ts new file mode 100644 index 000000000..f0f836f33 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/config/user-default.config.ts @@ -0,0 +1,30 @@ +import { registerAs } from '@nestjs/config'; + +import { + USER_MODULE_DEFAULT_SETTINGS_TOKEN, + USER_MODULE_USER_PASSWORD_REUSE_AFTER_DAYS_DEFAULT, +} from '../../user.constants.js'; + +import { type UserSettingsInterface } from './interfaces/user-settings.interface.js'; + +export const userDefaultConfig = registerAs( + USER_MODULE_DEFAULT_SETTINGS_TOKEN, + (): UserSettingsInterface => { + const reuseAfterDays = process.env?.USER_PASSWORD_REUSE_AFTER_DAYS?.length + ? Number(process.env?.USER_PASSWORD_REUSE_AFTER_DAYS) + : USER_MODULE_USER_PASSWORD_REUSE_AFTER_DAYS_DEFAULT; + + const requireCurrent = + process.env?.USER_PASSWORD_REQUIRE_CURRENT === 'true'; + + return { + password: { + reuseAfterDays: + isNaN(reuseAfterDays) || reuseAfterDays < 1 + ? undefined + : reuseAfterDays, + requireCurrent, + }, + }; + }, +); diff --git a/packages/nestjs-user/src/infrastructure/persistence/__tests__/user-credentials.repository.e2e-spec.ts b/packages/nestjs-user/src/infrastructure/persistence/__tests__/user-credentials.repository.e2e-spec.ts new file mode 100644 index 000000000..579c1c785 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/__tests__/user-credentials.repository.e2e-spec.ts @@ -0,0 +1,231 @@ +import { randomUUID } from 'crypto'; + +import { Test, type TestingModule } from '@nestjs/testing'; + +import { createTestEventContext } from '@concepta/nestjs-core/testing'; + +import { AppRepoModuleFixture } from '../../../__tests__/fixtures/app-repo.module.fixture.js'; +import { UserCredentials } from '../../../domain/aggregates/user-credentials.js'; +import { User } from '../../../domain/aggregates/user.js'; +import { type UserCredentialEntityInterface } from '../../../domain/interfaces/user-credential-entity.interface.js'; +import { type UserCredentialsRepositoryInterface } from '../../../domain/repositories/user-credentials-repository.interface.js'; +import { type UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; +import { + USER_CREDENTIALS_REPOSITORY_TOKEN, + USER_REPOSITORY_TOKEN, +} from '../../../user.constants.js'; +import { UserCredentialsMapper } from '../user-credentials.mapper.js'; +import { UserCredentialsRepository } from '../user-credentials.repository.js'; + +describe(UserCredentialsRepository.name + ' (e2e)', () => { + let moduleFixture: TestingModule; + let credentialsRepository: UserCredentialsRepositoryInterface; + let userRepository: UserRepositoryInterface; + const eventContext = createTestEventContext({}, {}); + const credentialsMapper = new UserCredentialsMapper(); + + let testUser: User; + + beforeEach(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [AppRepoModuleFixture], + }).compile(); + + credentialsRepository = + moduleFixture.get( + USER_CREDENTIALS_REPOSITORY_TOKEN, + ); + + userRepository = moduleFixture.get( + USER_REPOSITORY_TOKEN, + ); + + testUser = User.create(eventContext, { + email: 'cred-test@example.com', + username: 'credtestuser', + }); + await userRepository.save({}, testUser); + }); + + afterEach(async () => { + await moduleFixture?.close(); + }); + + describe('findActiveByUserId', () => { + it('should return active credentials for user', async () => { + const creds = UserCredentials.create(eventContext, { + userId: testUser.id, + passwordHash: 'hash1', + }); + await credentialsRepository.save({}, creds); + + const found = await credentialsRepository.findActiveByUserId( + {}, + testUser.id, + ); + + expect(found).toBeInstanceOf(UserCredentials); + expect(found!.userId).toBe(testUser.id); + expect(found!.active).toBe(true); + }); + + it('should return null when no credentials exist', async () => { + const found = await credentialsRepository.findActiveByUserId( + {}, + testUser.id, + ); + + expect(found).toBeNull(); + }); + + it('should not return inactive credentials', async () => { + const creds = UserCredentials.create(eventContext, { + userId: testUser.id, + passwordHash: 'hash1', + }); + creds.deactivate(eventContext); + await credentialsRepository.save({}, creds); + + const found = await credentialsRepository.findActiveByUserId( + {}, + testUser.id, + ); + + expect(found).toBeNull(); + }); + }); + + describe('findByUserId', () => { + it('should return all credentials (active and inactive)', async () => { + const creds1 = UserCredentials.create(eventContext, { + userId: testUser.id, + passwordHash: 'hash1', + }); + await credentialsRepository.save({}, creds1); + + creds1.deactivate(eventContext); + await credentialsRepository.save({}, creds1); + + const creds2 = UserCredentials.create(eventContext, { + userId: testUser.id, + passwordHash: 'hash2', + }); + await credentialsRepository.save({}, creds2); + + const found = await credentialsRepository.findByUserId({}, testUser.id); + + expect(found).toHaveLength(2); + }); + + it('should return empty array when none exist', async () => { + const found = await credentialsRepository.findByUserId({}, testUser.id); + + expect(found).toHaveLength(0); + }); + + it('should filter by limitDate', async () => { + const sixtyDaysAgo = new Date(); + sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60); + + const oldEntity: UserCredentialEntityInterface = { + id: randomUUID(), + userId: testUser.id, + passwordHash: 'old-hash', + active: false, + validFrom: sixtyDaysAgo, + validTo: new Date(sixtyDaysAgo.getTime() + 86400000), + dateCreated: sixtyDaysAgo, + dateUpdated: sixtyDaysAgo, + dateDeleted: null, + version: 1, + }; + const oldCreds = credentialsMapper.toDomain(oldEntity); + await credentialsRepository.save({}, oldCreds); + + const recentCreds = UserCredentials.create(eventContext, { + userId: testUser.id, + passwordHash: 'recent-hash', + }); + await credentialsRepository.save({}, recentCreds); + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const found = await credentialsRepository.findByUserId( + {}, + testUser.id, + thirtyDaysAgo, + ); + + expect(found).toHaveLength(1); + expect(found[0].id).toBe(recentCreds.id); + }); + + it('should order by validFrom descending', async () => { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const olderEntity: UserCredentialEntityInterface = { + id: randomUUID(), + userId: testUser.id, + passwordHash: 'older-hash', + active: false, + validFrom: thirtyDaysAgo, + validTo: null, + dateCreated: thirtyDaysAgo, + dateUpdated: thirtyDaysAgo, + dateDeleted: null, + version: 1, + }; + const olderCreds = credentialsMapper.toDomain(olderEntity); + await credentialsRepository.save({}, olderCreds); + + const newerCreds = UserCredentials.create(eventContext, { + userId: testUser.id, + passwordHash: 'newer-hash', + }); + await credentialsRepository.save({}, newerCreds); + + const found = await credentialsRepository.findByUserId({}, testUser.id); + + expect(found).toHaveLength(2); + expect(found[0].id).toBe(newerCreds.id); + expect(found[1].id).toBe(olderCreds.id); + }); + }); + + describe('save', () => { + it('should persist new credentials', async () => { + const creds = UserCredentials.create(eventContext, { + userId: testUser.id, + passwordHash: 'hash1', + }); + + await credentialsRepository.save({}, creds); + + const found = await credentialsRepository.findActiveByUserId( + {}, + testUser.id, + ); + expect(found).not.toBeNull(); + expect(found!.id).toBe(creds.id); + }); + + it('should update existing credentials', async () => { + const creds = UserCredentials.create(eventContext, { + userId: testUser.id, + passwordHash: 'hash1', + }); + await credentialsRepository.save({}, creds); + + creds.deactivate(eventContext); + await credentialsRepository.save({}, creds); + + const found = await credentialsRepository.findActiveByUserId( + {}, + testUser.id, + ); + expect(found).toBeNull(); + }); + }); +}); diff --git a/packages/nestjs-user/src/infrastructure/persistence/__tests__/user-credentials.repository.spec.ts b/packages/nestjs-user/src/infrastructure/persistence/__tests__/user-credentials.repository.spec.ts new file mode 100644 index 000000000..079ad9ca5 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/__tests__/user-credentials.repository.spec.ts @@ -0,0 +1,113 @@ +import { mock, type MockProxy } from 'vitest-mock-extended'; + +import { type RepositoryInterface } from '@concepta/nestjs-repository'; + +import { UserCredentials } from '../../../domain/aggregates/user-credentials.js'; +import { type UserCredentialEntityInterface } from '../../../domain/interfaces/user-credential-entity.interface.js'; +import { UserCredentialsMapper } from '../user-credentials.mapper.js'; +import { UserCredentialsRepository } from '../user-credentials.repository.js'; + +const credentialsMapper = new UserCredentialsMapper(); + +const mockEntity: UserCredentialEntityInterface = { + id: 'cred-1', + userId: 'user-1', + passwordHash: 'hash', + active: true, + validFrom: new Date('2024-01-01'), + validTo: null, + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-01'), + dateDeleted: null, + version: 1, +}; + +describe(UserCredentialsRepository.name, () => { + const innerRepo: MockProxy< + RepositoryInterface + > = mock>(); + let repository: UserCredentialsRepository; + + beforeEach(() => { + vi.clearAllMocks(); + repository = new UserCredentialsRepository( + innerRepo, + new UserCredentialsMapper(), + ); + }); + + describe('findActiveByUserId', () => { + it('should return UserCredentials when found', async () => { + innerRepo.findOne.mockResolvedValue(mockEntity); + + const result = await repository.findActiveByUserId({}, 'user-1'); + + expect(result).toBeInstanceOf(UserCredentials); + expect(result!.userId).toBe('user-1'); + expect(result!.active).toBe(true); + }); + + it('should return null when not found', async () => { + innerRepo.findOne.mockResolvedValue(null); + + const result = await repository.findActiveByUserId({}, 'missing'); + + expect(result).toBeNull(); + }); + }); + + describe('findByUserId', () => { + it('should return array of UserCredentials', async () => { + innerRepo.find.mockResolvedValue([mockEntity]); + + const result = await repository.findByUserId({}, 'user-1'); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(UserCredentials); + }); + + it('should return empty array when none found', async () => { + innerRepo.find.mockResolvedValue([]); + + const result = await repository.findByUserId({}, 'user-1'); + + expect(result).toHaveLength(0); + }); + + it('should pass limitDate filter when provided', async () => { + innerRepo.find.mockResolvedValue([]); + const limitDate = new Date('2024-06-01'); + + await repository.findByUserId({}, 'user-1', limitDate); + + expect(innerRepo.find).toHaveBeenCalledTimes(1); + const options = innerRepo.find.mock.calls[0][0]; + expect(options).toBeDefined(); + }); + + it('should work without limitDate', async () => { + innerRepo.find.mockResolvedValue([]); + + await repository.findByUserId({}, 'user-1'); + + expect(innerRepo.find).toHaveBeenCalledTimes(1); + }); + }); + + describe('save', () => { + it('should stamp and upsert the plain entity', async () => { + innerRepo.upsert.mockResolvedValue(mockEntity); + + const entry = credentialsMapper.toDomain(mockEntity); + const stampSpy = vi.spyOn(entry, 'stampUpdated'); + + await repository.save({}, entry); + + expect(stampSpy).toHaveBeenCalledTimes(1); + expect(innerRepo.upsert).toHaveBeenCalledWith( + credentialsMapper.toPersistence(entry), + { ctx: {} }, + ); + }); + }); +}); diff --git a/packages/nestjs-user/src/infrastructure/persistence/__tests__/user.repository.e2e-spec.ts b/packages/nestjs-user/src/infrastructure/persistence/__tests__/user.repository.e2e-spec.ts new file mode 100644 index 000000000..f15c47230 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/__tests__/user.repository.e2e-spec.ts @@ -0,0 +1,143 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { createTestEventContext } from '@concepta/nestjs-core/testing'; + +import { AppRepoModuleFixture } from '../../../__tests__/fixtures/app-repo.module.fixture.js'; +import { User } from '../../../domain/aggregates/user.js'; +import { type UserRepositoryInterface } from '../../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../../user.constants.js'; +import { UserRepository } from '../user.repository.js'; + +describe(UserRepository.name + ' (e2e)', () => { + let moduleFixture: TestingModule; + let userRepository: UserRepositoryInterface; + const eventContext = createTestEventContext({}, {}); + + beforeEach(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [AppRepoModuleFixture], + }).compile(); + + userRepository = moduleFixture.get( + USER_REPOSITORY_TOKEN, + ); + }); + + afterEach(async () => { + await moduleFixture?.close(); + }); + + describe('get', () => { + it('should return User when found', async () => { + const user = User.create(eventContext, { + email: 'a@b.com', + username: 'john', + }); + await userRepository.save({}, user); + + const found = await userRepository.get({}, user.id); + + expect(found).toBeInstanceOf(User); + expect(found!.id).toBe(user.id); + expect(found!.email).toBe('a@b.com'); + expect(found!.username).toBe('john'); + expect(found!.active).toBe(true); + }); + + it('should return null when not found', async () => { + const found = await userRepository.get({}, 'nonexistent'); + + expect(found).toBeNull(); + }); + }); + + describe('findByEmail', () => { + it('should return User when found', async () => { + const user = User.create(eventContext, { + email: 'find-by-email@test.com', + username: 'emailuser', + }); + await userRepository.save({}, user); + + const found = await userRepository.findByEmail( + {}, + 'find-by-email@test.com', + ); + + expect(found).toBeInstanceOf(User); + expect(found!.email).toBe('find-by-email@test.com'); + }); + + it('should return null when not found', async () => { + const found = await userRepository.findByEmail({}, 'nobody@example.com'); + + expect(found).toBeNull(); + }); + }); + + describe('findByUsername', () => { + it('should return User when found', async () => { + const user = User.create(eventContext, { + email: 'u@b.com', + username: 'uniqueuser', + }); + await userRepository.save({}, user); + + const found = await userRepository.findByUsername({}, 'uniqueuser'); + + expect(found).toBeInstanceOf(User); + expect(found!.username).toBe('uniqueuser'); + }); + + it('should return null when not found', async () => { + const found = await userRepository.findByUsername({}, 'ghost'); + + expect(found).toBeNull(); + }); + }); + + describe('save', () => { + it('should persist a new user', async () => { + const user = User.create(eventContext, { + email: 'new@b.com', + username: 'newuser', + }); + + await userRepository.save({}, user); + + const found = await userRepository.get({}, user.id); + expect(found).not.toBeNull(); + expect(found!.id).toBe(user.id); + }); + + it('should update an existing user', async () => { + const user = User.create(eventContext, { + email: 'update@b.com', + username: 'updateuser', + }); + await userRepository.save({}, user); + + user.update(eventContext, { email: 'updated@b.com' }); + await userRepository.save({}, user); + + const found = await userRepository.get({}, user.id); + expect(found!.email).toBe('updated@b.com'); + expect(found!.version).toBe(2); + }); + }); + + describe('remove', () => { + it('should delete the user', async () => { + const user = User.create(eventContext, { + email: 'delete@b.com', + username: 'deleteuser', + }); + await userRepository.save({}, user); + + await userRepository.remove({}, user); + + const found = await userRepository.get({}, user.id); + expect(found).toBeNull(); + }); + }); +}); diff --git a/packages/nestjs-user/src/infrastructure/persistence/__tests__/user.repository.spec.ts b/packages/nestjs-user/src/infrastructure/persistence/__tests__/user.repository.spec.ts new file mode 100644 index 000000000..ad73967ac --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/__tests__/user.repository.spec.ts @@ -0,0 +1,110 @@ +import { mock, type MockProxy } from 'vitest-mock-extended'; + +import { type RepositoryInterface } from '@concepta/nestjs-repository'; + +import { createMockUserEntity } from '../../../__tests__/helpers/mock.helpers.js'; +import { User } from '../../../domain/aggregates/user.js'; +import { type UserEntityInterface } from '../../../domain/interfaces/user-entity.interface.js'; +import { UserMapper } from '../user.mapper.js'; +import { UserRepository } from '../user.repository.js'; + +const userMapper = new UserMapper(); + +const mockEntity = createMockUserEntity(); + +describe(UserRepository.name, () => { + const innerRepo: MockProxy> = + mock>(); + let repository: UserRepository; + + beforeEach(() => { + vi.clearAllMocks(); + repository = new UserRepository(innerRepo, new UserMapper()); + }); + + describe('get', () => { + it('should return User when found', async () => { + innerRepo.findOne.mockResolvedValue(mockEntity); + + const result = await repository.get({}, 'user-1'); + + expect(result).toBeInstanceOf(User); + expect(result!.id).toBe('user-1'); + expect(result!.email).toBe('a@b.com'); + }); + + it('should return null when not found', async () => { + innerRepo.findOne.mockResolvedValue(null); + + const result = await repository.get({}, 'missing'); + + expect(result).toBeNull(); + }); + }); + + describe('findByEmail', () => { + it('should return User when found', async () => { + innerRepo.findOne.mockResolvedValue(mockEntity); + + const result = await repository.findByEmail({}, 'a@b.com'); + + expect(result).toBeInstanceOf(User); + expect(result!.email).toBe('a@b.com'); + }); + + it('should return null when not found', async () => { + innerRepo.findOne.mockResolvedValue(null); + + const result = await repository.findByEmail({}, 'missing@b.com'); + + expect(result).toBeNull(); + }); + }); + + describe('findByUsername', () => { + it('should return User when found', async () => { + innerRepo.findOne.mockResolvedValue(mockEntity); + + const result = await repository.findByUsername({}, 'john'); + + expect(result).toBeInstanceOf(User); + expect(result!.username).toBe('john'); + }); + + it('should return null when not found', async () => { + innerRepo.findOne.mockResolvedValue(null); + + const result = await repository.findByUsername({}, 'missing'); + + expect(result).toBeNull(); + }); + }); + + describe('save', () => { + it('should stamp and upsert the plain entity', async () => { + innerRepo.upsert.mockResolvedValue(mockEntity); + + const user = userMapper.toDomain(mockEntity); + const stampSpy = vi.spyOn(user, 'stampUpdated'); + + await repository.save({}, user); + + expect(stampSpy).toHaveBeenCalledTimes(1); + expect(innerRepo.upsert).toHaveBeenCalledWith( + userMapper.toPersistence(user), + { ctx: {} }, + ); + }); + }); + + describe('remove', () => { + it('should delete the user', async () => { + innerRepo.delete.mockResolvedValue(mockEntity); + + const user = userMapper.toDomain(mockEntity); + await repository.remove({}, user); + + expect(innerRepo.delete).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-credential-postgres.entity.ts b/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-credential-postgres.entity.ts new file mode 100644 index 000000000..c6b0367c9 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-credential-postgres.entity.ts @@ -0,0 +1,26 @@ +import { Column } from 'typeorm'; + +import { ReferenceId } from '@concepta/nestjs-core'; +import { CommonPostgresEntity } from '@concepta/nestjs-repository-typeorm'; + +import { UserCredentialEntityInterface } from '../../../domain/interfaces/user-credential-entity.interface.js'; + +export abstract class UserCredentialPostgresEntity + extends CommonPostgresEntity + implements UserCredentialEntityInterface +{ + @Column({ type: 'text' }) + passwordHash!: string; + + @Column({ type: 'uuid' }) + userId!: ReferenceId; + + @Column({ default: true }) + active!: boolean; + + @Column({ type: 'timestamptz' }) + validFrom!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + validTo!: Date | null; +} diff --git a/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-credential-sqlite.entity.ts b/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-credential-sqlite.entity.ts new file mode 100644 index 000000000..87b26426f --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-credential-sqlite.entity.ts @@ -0,0 +1,26 @@ +import { Column } from 'typeorm'; + +import { ReferenceId } from '@concepta/nestjs-core'; +import { CommonSqliteEntity } from '@concepta/nestjs-repository-typeorm'; + +import { UserCredentialEntityInterface } from '../../../domain/interfaces/user-credential-entity.interface.js'; + +export abstract class UserCredentialSqliteEntity + extends CommonSqliteEntity + implements UserCredentialEntityInterface +{ + @Column({ type: 'text' }) + passwordHash!: string; + + @Column({ type: 'uuid' }) + userId!: ReferenceId; + + @Column({ default: true }) + active!: boolean; + + @Column({ type: 'datetime' }) + validFrom!: Date; + + @Column({ type: 'datetime', nullable: true }) + validTo!: Date | null; +} diff --git a/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-postgres.entity.ts b/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-postgres.entity.ts new file mode 100644 index 000000000..65e3c6a63 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-postgres.entity.ts @@ -0,0 +1,31 @@ +import { Column } from 'typeorm'; + +import { CommonPostgresEntity } from '@concepta/nestjs-repository-typeorm'; + +import { UserEntityInterface } from '../../../domain/interfaces/user-entity.interface.js'; + +/** + * User Entity + */ +export abstract class UserPostgresEntity + extends CommonPostgresEntity + implements UserEntityInterface +{ + /** + * Email + */ + @Column({ unique: true }) + email!: string; + + /** + * Username + */ + @Column({ unique: true }) + username!: string; + + /** + * Active + */ + @Column({ default: true }) + active!: boolean; +} diff --git a/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-sqlite.entity.ts b/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-sqlite.entity.ts new file mode 100644 index 000000000..c00d48be6 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/typeorm/user-sqlite.entity.ts @@ -0,0 +1,31 @@ +import { Column } from 'typeorm'; + +import { CommonSqliteEntity } from '@concepta/nestjs-repository-typeorm'; + +import { UserEntityInterface } from '../../../domain/interfaces/user-entity.interface.js'; + +/** + * User Entity + */ +export abstract class UserSqliteEntity + extends CommonSqliteEntity + implements UserEntityInterface +{ + /** + * Email + */ + @Column({ unique: true }) + email!: string; + + /** + * Username + */ + @Column({ unique: true }) + username!: string; + + /** + * Active + */ + @Column({ default: true }) + active!: boolean; +} diff --git a/packages/nestjs-user/src/infrastructure/persistence/user-credentials.mapper.ts b/packages/nestjs-user/src/infrastructure/persistence/user-credentials.mapper.ts new file mode 100644 index 000000000..9d2cef767 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/user-credentials.mapper.ts @@ -0,0 +1,21 @@ +import { DomainMapper } from '@concepta/nestjs-core/aggregate'; + +import { UserCredentials } from '../../domain/aggregates/user-credentials.js'; +import { type UserCredentialEntityInterface } from '../../domain/interfaces/user-credential-entity.interface.js'; +import { type UserCredentialInterface } from '../../domain/interfaces/user-credential.interface.js'; + +export class UserCredentialsMapper extends DomainMapper< + UserCredentialEntityInterface, + UserCredentialInterface, + UserCredentials +> { + createAggregate(entity: UserCredentialEntityInterface): UserCredentials { + const { id, version, dateCreated, dateUpdated, dateDeleted, ...props } = + entity; + return new UserCredentials(id, props, version, { + dateCreated, + dateUpdated, + dateDeleted, + }); + } +} diff --git a/packages/nestjs-user/src/infrastructure/persistence/user-credentials.repository.ts b/packages/nestjs-user/src/infrastructure/persistence/user-credentials.repository.ts new file mode 100644 index 000000000..40eeb40fc --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/user-credentials.repository.ts @@ -0,0 +1,62 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { type ReferenceId } from '@concepta/nestjs-core'; +import { + OrderBy, + type RepositoryInterface, + Where, +} from '@concepta/nestjs-repository'; + +import { type UserCredentials } from '../../domain/aggregates/user-credentials.js'; +import { type UserCredentialEntityInterface } from '../../domain/interfaces/user-credential-entity.interface.js'; +import { type UserCredentialsRepositoryInterface } from '../../domain/repositories/user-credentials-repository.interface.js'; + +import { type UserCredentialsMapper } from './user-credentials.mapper.js'; + +export class UserCredentialsRepository implements UserCredentialsRepositoryInterface { + constructor( + protected readonly repository: RepositoryInterface, + private readonly mapper: UserCredentialsMapper, + ) {} + + async findActiveByUserId( + ctx: PlainLiteralObject, + userId: ReferenceId, + ): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.and(w.eq('userId', userId), w.eq('active', true)), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByUserId( + ctx: PlainLiteralObject, + userId: ReferenceId, + limitDate?: Date, + ): Promise { + const w = Where.for(); + + const conditions = [w.eq('userId', userId)]; + + if (limitDate) { + conditions.push(w.gte('validFrom', limitDate)); + } + + const entities = await this.repository.find({ + where: w.and(...conditions), + order: [OrderBy.desc('validFrom')], + ctx, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async save(ctx: PlainLiteralObject, entry: UserCredentials): Promise { + entry.stampUpdated(); + await this.repository.upsert(this.mapper.toPersistence(entry), { ctx }); + } +} diff --git a/packages/nestjs-user/src/infrastructure/persistence/user.mapper.ts b/packages/nestjs-user/src/infrastructure/persistence/user.mapper.ts new file mode 100644 index 000000000..9ffae99e8 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/user.mapper.ts @@ -0,0 +1,21 @@ +import { DomainMapper } from '@concepta/nestjs-core/aggregate'; + +import { User } from '../../domain/aggregates/user.js'; +import { type UserEntityInterface } from '../../domain/interfaces/user-entity.interface.js'; +import { type UserInterface } from '../../domain/interfaces/user.interface.js'; + +export class UserMapper extends DomainMapper< + UserEntityInterface, + UserInterface, + User +> { + createAggregate(entity: UserEntityInterface): User { + const { id, version, dateCreated, dateUpdated, dateDeleted, ...props } = + entity; + return new User(id, props, version, { + dateCreated, + dateUpdated, + dateDeleted, + }); + } +} diff --git a/packages/nestjs-user/src/infrastructure/persistence/user.repository.ts b/packages/nestjs-user/src/infrastructure/persistence/user.repository.ts new file mode 100644 index 000000000..44eca3790 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/persistence/user.repository.ts @@ -0,0 +1,69 @@ +import { type PlainLiteralObject } from '@nestjs/common'; + +import { + type ReferenceEmail, + type ReferenceId, + type ReferenceUsername, +} from '@concepta/nestjs-core'; +import { type RepositoryInterface, Where } from '@concepta/nestjs-repository'; + +import { type User } from '../../domain/aggregates/user.js'; +import { type UserEntityInterface } from '../../domain/interfaces/user-entity.interface.js'; +import { type UserRepositoryInterface } from '../../domain/repositories/user-repository.interface.js'; + +import { type UserMapper } from './user.mapper.js'; + +export class UserRepository implements UserRepositoryInterface { + constructor( + protected readonly repository: RepositoryInterface, + private readonly mapper: UserMapper, + ) {} + + async get(ctx: PlainLiteralObject, id: ReferenceId): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.eq('id', id), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByEmail( + ctx: PlainLiteralObject, + email: ReferenceEmail, + ): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.eq('email', email), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByUsername( + ctx: PlainLiteralObject, + username: ReferenceUsername, + ): Promise { + const w = Where.for(); + + const entity = await this.repository.findOne({ + where: w.eq('username', username), + ctx, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async save(ctx: PlainLiteralObject, user: User): Promise { + user.stampUpdated(); + await this.repository.upsert(this.mapper.toPersistence(user), { ctx }); + } + + async remove(ctx: PlainLiteralObject, user: User): Promise { + await this.repository.delete(this.mapper.toPersistence(user), { ctx }); + } +} diff --git a/packages/nestjs-user/src/infrastructure/schemas/password/user-password-hash.schema.ts b/packages/nestjs-user/src/infrastructure/schemas/password/user-password-hash.schema.ts new file mode 100644 index 000000000..f454b4c20 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/schemas/password/user-password-hash.schema.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; +import { type PasswordStorageInterface } from '@concepta/nestjs-password'; + +/** + * Kept for public-API/export parity (still exported from `index.ts`) even + * though it's no longer part of `userCreateSchema` — see that file for why + * the create path now uses plaintext `password` instead. Not consumed by + * any wired CRUD operation. Not a named OpenAPI component. + */ +export const userPasswordHashSchema = withOpenApi( + conformsTo()( + z.object({ + passwordHash: z.string().meta({ description: 'Password hash' }), + }), + ), +); diff --git a/packages/nestjs-user/src/infrastructure/schemas/password/user-password-update.schema.ts b/packages/nestjs-user/src/infrastructure/schemas/password/user-password-update.schema.ts new file mode 100644 index 000000000..2fcb34e4d --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/schemas/password/user-password-update.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { withOpenApi } from '@concepta/nestjs-core'; + +import { userPasswordSchema } from './user-password.schema.js'; + +/** + * Used only as a request body — not a named OpenAPI component. `password` + * is required (inherited from `userPasswordSchema`); `passwordCurrent` is + * optional — its conditional requirement (the `requireCurrent` policy) is + * enforced by `UserCredentialsService` as a runtime domain exception + * (`UserPasswordCurrentInvalidException`), not by schema validation. + */ +export const userPasswordUpdateSchema = withOpenApi( + userPasswordSchema.extend({ + passwordCurrent: z + .string() + .optional() + .meta({ description: 'Current password to validate' }), + }), +); diff --git a/packages/nestjs-user/src/infrastructure/schemas/password/user-password.schema.spec.ts b/packages/nestjs-user/src/infrastructure/schemas/password/user-password.schema.spec.ts new file mode 100644 index 000000000..df5b1ac9a --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/schemas/password/user-password.schema.spec.ts @@ -0,0 +1,44 @@ +import { userPasswordHashSchema } from './user-password-hash.schema.js'; +import { userPasswordUpdateSchema } from './user-password-update.schema.js'; +import { userPasswordSchema } from './user-password.schema.js'; + +describe('userPasswordSchema', () => { + it('accepts a valid password (>= 8 chars)', () => { + expect(userPasswordSchema.parse({ password: 'longenough' })).toEqual({ + password: 'longenough', + }); + }); + + it('rejects a too-short password', () => { + expect(userPasswordSchema.safeParse({ password: 'short' }).success).toBe( + false, + ); + }); +}); + +describe('userPasswordHashSchema', () => { + it('accepts a valid passwordHash', () => { + expect(userPasswordHashSchema.parse({ passwordHash: 'hashed' })).toEqual({ + passwordHash: 'hashed', + }); + }); +}); + +describe('userPasswordUpdateSchema', () => { + it('accepts password + passwordCurrent', () => { + const payload = { password: 'longenough', passwordCurrent: 'oldpass1' }; + expect(userPasswordUpdateSchema.parse(payload)).toEqual(payload); + }); + + it('accepts passwordCurrent omitted (default policy has requireCurrent: false)', () => { + const payload = { password: 'longenough' }; + expect(userPasswordUpdateSchema.parse(payload)).toEqual(payload); + }); + + it('rejects a missing password', () => { + expect( + userPasswordUpdateSchema.safeParse({ passwordCurrent: 'oldpass1' }) + .success, + ).toBe(false); + }); +}); diff --git a/packages/nestjs-user/src/infrastructure/schemas/password/user-password.schema.ts b/packages/nestjs-user/src/infrastructure/schemas/password/user-password.schema.ts new file mode 100644 index 000000000..327031a61 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/schemas/password/user-password.schema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; +import { type PasswordPlainInterface } from '@concepta/nestjs-password'; + +/** + * Used only as a request body — not a named OpenAPI component (no + * `withNamedComponent`). + */ +export const userPasswordSchema = withOpenApi( + conformsTo()( + z.object({ + password: z + .string() + .min(8) + .meta({ description: 'Plain text password to set' }), + }), + ), +); diff --git a/packages/nestjs-user/src/infrastructure/schemas/user-create-batch.schema.ts b/packages/nestjs-user/src/infrastructure/schemas/user-create-batch.schema.ts new file mode 100644 index 000000000..fbdf88e29 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/schemas/user-create-batch.schema.ts @@ -0,0 +1,8 @@ +import { withOpenApi } from '@concepta/nestjs-core'; +import { createBatchSchema } from '@concepta/nestjs-crud'; + +import { userCreateSchema } from './user-create.schema.js'; + +export const userCreateBatchSchema = withOpenApi( + createBatchSchema(userCreateSchema), +); diff --git a/packages/nestjs-user/src/infrastructure/schemas/user-create.schema.ts b/packages/nestjs-user/src/infrastructure/schemas/user-create.schema.ts new file mode 100644 index 000000000..96dc9eca3 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/schemas/user-create.schema.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type UserCreatableInterface } from '../../domain/interfaces/user-creatable.interface.js'; + +/** + * Used only as a request body — not a named OpenAPI component (no + * `withNamedComponent`). Authored fresh (not `.pick()`-derived from + * `userSchema`) because `email` here carries `.email()` format validation, + * matching the legacy `@IsEmail()` on `UserCreateDto` — see `user.schema.ts` + * for why the response schema deliberately does NOT share this check. + * + * `password` (plaintext, optional) — NOT `passwordHash` — matching + * `UserCreatableInterface`'s actual `Partial` and + * what `CreateUserHandler` actually reads (`dto.password`). The legacy + * `UserCreateDto` mistakenly exposed `passwordHash` instead, which combined + * with `excludeAll`+`excludeExtraneousValues:true` silently stripped any + * `password` an HTTP client sent — user creation via the CRUD endpoint never + * actually set a password. Verified safe to fix: no code path anywhere + * (seeding, federated provisioning, internal commands) ever supplies + * `passwordHash` as external input — it's always computed internally via + * `UserPasswordPort.create()`. + */ +export const userCreateSchema = withOpenApi( + conformsTo()( + z.object({ + username: z.string().meta({ description: 'Username' }), + email: z.string().email().meta({ description: 'Email' }), + active: z.boolean().optional().meta({ description: 'Active' }), + password: z + .string() + .min(8) + .optional() + .meta({ description: 'Plain text password to set' }), + }), + ), +); diff --git a/packages/nestjs-user/src/infrastructure/schemas/user-paginated.schema.ts b/packages/nestjs-user/src/infrastructure/schemas/user-paginated.schema.ts new file mode 100644 index 000000000..f00bc8229 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/schemas/user-paginated.schema.ts @@ -0,0 +1,9 @@ +import { withNamedComponent } from '@concepta/nestjs-core'; +import { paginatedSchema } from '@concepta/nestjs-crud'; + +import { userSchema } from './user.schema.js'; + +export const userPaginatedSchema = withNamedComponent( + paginatedSchema(userSchema), + 'UserPaginated', +); diff --git a/packages/nestjs-user/src/infrastructure/schemas/user-update.schema.ts b/packages/nestjs-user/src/infrastructure/schemas/user-update.schema.ts new file mode 100644 index 000000000..ef9e04f34 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/schemas/user-update.schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { conformsTo, withOpenApi } from '@concepta/nestjs-core'; + +import { type UserUpdatableInterface } from '../../domain/interfaces/user-updatable.interface.js'; + +/** + * Used only as a request body — not a named OpenAPI component (no + * `withNamedComponent`). No `id` field — the legacy `UserUpdateDto` required + * `id` in the body (via `PickType(UserDto, ['id'])`), but + * `update-user-request.handler.ts` reads `id` from the ROUTE PARAM + * (`context.params.id`), never from `dto.id`; `UserUpdatableInterface` + * doesn't declare `id` either. Dropping it matches actual behavior — the + * route param stays authoritative — and isn't a behavior change since + * nothing sends or needs `id` in the body today (no e2e ever exercised it). + * + * `email` carries `.email()` (matching legacy `@IsEmail()`, inherited via + * `UserUpdateDto`'s `PickType(UserDto, ['email', ...])`) — see + * `user.schema.ts` for why the response schema doesn't share this check. + */ +export const userUpdateSchema = withOpenApi( + conformsTo()( + z.object({ + email: z.string().email().optional().meta({ description: 'Email' }), + active: z.boolean().optional().meta({ description: 'Active' }), + }), + ), +); diff --git a/packages/nestjs-user/src/infrastructure/schemas/user.schema.spec.ts b/packages/nestjs-user/src/infrastructure/schemas/user.schema.spec.ts new file mode 100644 index 000000000..c73c4b717 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/schemas/user.schema.spec.ts @@ -0,0 +1,113 @@ +import { userCreateBatchSchema } from './user-create-batch.schema.js'; +import { userCreateSchema } from './user-create.schema.js'; +import { userPaginatedSchema } from './user-paginated.schema.js'; +import { userUpdateSchema } from './user-update.schema.js'; +import { userSchema } from './user.schema.js'; + +const validUser = { + id: 'abc', + version: 1, + dateCreated: new Date('2024-01-01'), + dateUpdated: new Date('2024-01-02'), + dateDeleted: null, + email: 'john@example.com', + username: 'john', + active: true, +}; + +describe('userSchema', () => { + it('accepts a valid user entity', () => { + expect(userSchema.parse(validUser)).toEqual(validUser); + }); + + it('accepts a non-`.email()`-shaped string for email unchanged (response never re-validates format)', () => { + const result = userSchema.parse({ ...validUser, email: 'not-an-email' }); + expect(result.email).toBe('not-an-email'); + }); + + it('strips unknown keys', () => { + const result = userSchema.parse({ ...validUser, _internal: 'x' }); + expect(result).not.toHaveProperty('_internal'); + }); +}); + +describe('userCreateSchema', () => { + const validCreate = { username: 'john', email: 'john@example.com' }; + + it('accepts a valid create payload without a password', () => { + expect(userCreateSchema.parse(validCreate)).toEqual(validCreate); + }); + + it('accepts an optional password (>= 8 chars)', () => { + const payload = { ...validCreate, password: 'longenough' }; + expect(userCreateSchema.parse(payload)).toEqual(payload); + }); + + it('accepts an optional active flag', () => { + const payload = { ...validCreate, active: false }; + expect(userCreateSchema.parse(payload)).toEqual(payload); + }); + + it('rejects a malformed email', () => { + expect( + userCreateSchema.safeParse({ ...validCreate, email: 'not-an-email' }) + .success, + ).toBe(false); + }); + + it('rejects a too-short password', () => { + expect( + userCreateSchema.safeParse({ ...validCreate, password: 'short' }).success, + ).toBe(false); + }); + + it('has no passwordHash field (fixes the silent-drop bug — see file docstring)', () => { + expect(userCreateSchema.shape).not.toHaveProperty('passwordHash'); + }); +}); + +describe('userUpdateSchema', () => { + it('accepts an empty payload (both fields optional)', () => { + expect(userUpdateSchema.parse({})).toEqual({}); + }); + + it('accepts email + active', () => { + const payload = { email: 'new@example.com', active: false }; + expect(userUpdateSchema.parse(payload)).toEqual(payload); + }); + + it('rejects a malformed email', () => { + expect(userUpdateSchema.safeParse({ email: 'not-an-email' }).success).toBe( + false, + ); + }); + + it('has no id field (route param is authoritative — see file docstring)', () => { + expect(userUpdateSchema.shape).not.toHaveProperty('id'); + }); +}); + +describe('userPaginatedSchema', () => { + it('accepts a paginated list of user entities', () => { + const payload = { + data: [validUser], + limit: 10, + count: 1, + total: 1, + page: 1, + pageCount: 1, + }; + expect(userPaginatedSchema.parse(payload)).toEqual(payload); + }); +}); + +describe('userCreateBatchSchema', () => { + it('accepts a bulk array of user create payloads', () => { + const payload = { bulk: [{ username: 'john', email: 'john@example.com' }] }; + expect(userCreateBatchSchema.parse(payload)).toEqual(payload); + }); + + it('rejects an empty bulk array (matching legacy @ArrayNotEmpty())', () => { + expect(userCreateBatchSchema.safeParse({ bulk: [] }).success).toBe(false); + }); +}); diff --git a/packages/nestjs-user/src/infrastructure/schemas/user.schema.ts b/packages/nestjs-user/src/infrastructure/schemas/user.schema.ts new file mode 100644 index 000000000..91edf2c44 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/schemas/user.schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { conformsTo, withNamedComponent } from '@concepta/nestjs-core'; +import { domainAggregateSchema } from '@concepta/nestjs-core/aggregate'; + +import { type UserInterface } from '../../domain/interfaces/user.interface.js'; + +/** + * `email` is intentionally a plain `z.string()` here (no `.email()` format + * check) — legacy `UserDto.email` carried `@IsEmail()`, but response + * serialization (`instanceToPlain`) never ran class-validator, only + * `ValidationPipe` (input) did. So today GET responses never validate email + * format, only writes do. Format validation lives on `userCreateSchema`/ + * `userUpdateSchema` instead — see those files. Adding `.email()` here would + * introduce a new fail-closed 500 on read for any already-persisted, + * legacy-malformed email that doesn't exist today. + */ +export const userSchema = withNamedComponent( + conformsTo()( + domainAggregateSchema.extend({ + email: z.string().meta({ description: 'Email' }), + username: z.string().meta({ description: 'Username' }), + active: z.boolean().meta({ description: 'Active' }), + }), + ), + 'User', +); diff --git a/packages/nestjs-user/src/infrastructure/seeding/user-credential.factory.ts b/packages/nestjs-user/src/infrastructure/seeding/user-credential.factory.ts new file mode 100644 index 000000000..7ee653d35 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/seeding/user-credential.factory.ts @@ -0,0 +1,36 @@ +import { PasswordStorageService } from '@concepta/nestjs-password'; +import { Factory } from '@concepta/typeorm-seeding'; + +import { type UserCredentialEntityInterface } from '../../domain/interfaces/user-credential-entity.interface.js'; + +/** + * User credential factory + * + * WARNING: Development-only factory. Never use in production seeding. + */ +export class UserCredentialFactory extends Factory { + private static readonly DEV_SEED_PASSWORD = + process.env.USER_SEED_PASSWORD ?? 'Test1233'; + + private _passwordStorageService = new PasswordStorageService(); + + /** + * Factory callback function. + */ + protected async entity( + userCredentials: UserCredentialEntityInterface, + ): Promise { + if (process.env.NODE_ENV === 'production') { + throw new Error('UserCredentialFactory must not be used in production'); + } + + const passwordStore = await this._passwordStorageService.hash( + UserCredentialFactory.DEV_SEED_PASSWORD, + ); + + // TypeORM requires entity class instances (not plain objects) + userCredentials.passwordHash = passwordStore.passwordHash; + + return userCredentials; + } +} diff --git a/packages/nestjs-user/src/infrastructure/seeding/user.factory.ts b/packages/nestjs-user/src/infrastructure/seeding/user.factory.ts new file mode 100644 index 000000000..2be72ef6a --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/seeding/user.factory.ts @@ -0,0 +1,48 @@ +import { faker } from '@faker-js/faker'; + +import { Factory } from '@concepta/typeorm-seeding'; + +import { type UserEntityInterface } from '../../domain/interfaces/user-entity.interface.js'; + +/** + * User factory + */ +export class UserFactory extends Factory { + /** + * List of used usernames. + */ + private usedUsernames = new Set(); + + /** + * Factory callback function. + */ + protected async entity( + user: UserEntityInterface, + ): Promise { + // TypeORM requires entity class instances (not plain objects) + user.username = this.generateUniqueUsername(); + user.email = faker.internet.email(); + + return user; + } + + /** + * Generate a unique username. + */ + protected generateUniqueUsername(): string { + const maxAttempts = 1000; + let username: string; + let attempts = 0; + + do { + if (attempts++ >= maxAttempts) { + throw new Error('Unable to generate unique username'); + } + username = faker.internet.userName().toLowerCase(); + } while (this.usedUsernames.has(username)); + + this.usedUsernames.add(username); + + return username; + } +} diff --git a/packages/nestjs-user/src/infrastructure/seeding/user.seeder.ts b/packages/nestjs-user/src/infrastructure/seeding/user.seeder.ts new file mode 100644 index 000000000..5d0f1b83d --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/seeding/user.seeder.ts @@ -0,0 +1,33 @@ +import { Seeder } from '@concepta/typeorm-seeding'; + +import { UserFactory } from './user.factory.js'; + +/** + * User seeder + */ +export class UserSeeder extends Seeder { + /** + * Runner + */ + public async run(): Promise { + // number of users to create + const rawAmount = Number(process.env?.USER_MODULE_SEEDER_AMOUNT); + const createAmount = isNaN(rawAmount) || rawAmount < 1 ? 50 : rawAmount; + + // super admin username + const superadmin = process.env?.USER_MODULE_SEEDER_SUPERADMIN_USERNAME + ? process.env?.USER_MODULE_SEEDER_SUPERADMIN_USERNAME + : 'superadmin'; + + // the factory + const userFactory = this.factory(UserFactory); + + // create a super admin user + await userFactory.create({ + username: superadmin, + }); + + // create a bunch more + await userFactory.createMany(createAmount); + } +} diff --git a/packages/nestjs-user/src/infrastructure/utils/create-password-policy-provider.ts b/packages/nestjs-user/src/infrastructure/utils/create-password-policy-provider.ts new file mode 100644 index 000000000..553e8a11d --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/utils/create-password-policy-provider.ts @@ -0,0 +1,15 @@ +import { type Provider } from '@nestjs/common'; + +import { UserPasswordPolicy } from '../../domain/policies/user-password.policy.js'; +import { USER_MODULE_SETTINGS_TOKEN } from '../../user.constants.js'; +import { type UserSettingsInterface } from '../config/interfaces/user-settings.interface.js'; + +export function createPasswordPolicyProvider(): Provider { + return { + provide: UserPasswordPolicy, + inject: [USER_MODULE_SETTINGS_TOKEN], + useFactory: (settings: UserSettingsInterface) => { + return new UserPasswordPolicy(settings?.password); + }, + }; +} diff --git a/packages/nestjs-user/src/infrastructure/utils/create-user-credentials-repository-provider.ts b/packages/nestjs-user/src/infrastructure/utils/create-user-credentials-repository-provider.ts new file mode 100644 index 000000000..e9b38e027 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/utils/create-user-credentials-repository-provider.ts @@ -0,0 +1,54 @@ +import { type Provider, type Type } from '@nestjs/common'; + +import { + getDynamicRepositoryToken, + type RepositoryInterface, +} from '@concepta/nestjs-repository'; + +import { type UserCredentialEntityInterface } from '../../domain/interfaces/user-credential-entity.interface.js'; +import { type UserCredentialsRepositoryInterface } from '../../domain/repositories/user-credentials-repository.interface.js'; +import { USER_CREDENTIALS_REPOSITORY_TOKEN } from '../../user.constants.js'; +import { UserCredentialsMapper } from '../persistence/user-credentials.mapper.js'; +import { UserCredentialsRepository } from '../persistence/user-credentials.repository.js'; + +export function createUserCredentialsRepositoryProvider( + entityKey?: string, + customRepository?: Type, +): Provider[] { + if (customRepository) { + return [ + { + provide: USER_CREDENTIALS_REPOSITORY_TOKEN, + useClass: customRepository, + }, + ]; + } + + if (!entityKey) { + return []; + } + + return [ + { + provide: USER_CREDENTIALS_REPOSITORY_TOKEN, + inject: [ + { + token: getDynamicRepositoryToken(entityKey), + optional: true, + }, + UserCredentialsMapper, + ], + useFactory: ( + repository: + | RepositoryInterface + | undefined, + mapper: UserCredentialsMapper, + ) => { + if (repository) { + return new UserCredentialsRepository(repository, mapper); + } + return undefined; + }, + }, + ]; +} diff --git a/packages/nestjs-user/src/infrastructure/utils/create-user-repository-provider.ts b/packages/nestjs-user/src/infrastructure/utils/create-user-repository-provider.ts new file mode 100644 index 000000000..7b75ffdc9 --- /dev/null +++ b/packages/nestjs-user/src/infrastructure/utils/create-user-repository-provider.ts @@ -0,0 +1,34 @@ +import { type Provider, type Type } from '@nestjs/common'; + +import { + getDynamicRepositoryToken, + type RepositoryInterface, +} from '@concepta/nestjs-repository'; + +import { type UserEntityInterface } from '../../domain/interfaces/user-entity.interface.js'; +import { type UserRepositoryInterface } from '../../domain/repositories/user-repository.interface.js'; +import { USER_REPOSITORY_TOKEN } from '../../user.constants.js'; +import { UserMapper } from '../persistence/user.mapper.js'; +import { UserRepository } from '../persistence/user.repository.js'; + +export function createUserRepositoryProvider( + entityKey: string, + customRepository?: Type, +): Provider[] { + if (customRepository) { + return [{ provide: USER_REPOSITORY_TOKEN, useClass: customRepository }]; + } + + return [ + { + provide: USER_REPOSITORY_TOKEN, + inject: [getDynamicRepositoryToken(entityKey), UserMapper], + useFactory: ( + repository: RepositoryInterface, + mapper: UserMapper, + ) => { + return new UserRepository(repository, mapper); + }, + }, + ]; +} diff --git a/packages/nestjs-user/src/interfaces/user-entities-options.interface.ts b/packages/nestjs-user/src/interfaces/user-entities-options.interface.ts deleted file mode 100644 index c20d5d0e5..000000000 --- a/packages/nestjs-user/src/interfaces/user-entities-options.interface.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - RepositoryEntityOptionInterface, - UserEntityInterface, - UserPasswordHistoryEntityInterface, - UserProfileEntityInterface, -} from '@concepta/nestjs-common'; - -import { - USER_MODULE_USER_ENTITY_KEY, - USER_MODULE_USER_PROFILE_ENTITY_KEY, - USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY, -} from '../user.constants'; - -export interface UserEntitiesOptionsInterface { - [USER_MODULE_USER_ENTITY_KEY]: RepositoryEntityOptionInterface; - [USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY]?: RepositoryEntityOptionInterface; - [USER_MODULE_USER_PROFILE_ENTITY_KEY]?: RepositoryEntityOptionInterface; -} diff --git a/packages/nestjs-user/src/interfaces/user-model-service.interface.ts b/packages/nestjs-user/src/interfaces/user-model-service.interface.ts deleted file mode 100644 index 43a6b3116..000000000 --- a/packages/nestjs-user/src/interfaces/user-model-service.interface.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - ByEmailInterface, - ByIdInterface, - BySubjectInterface, - ByUsernameInterface, - CreateOneInterface, - ReferenceEmail, - ReferenceSubject, - ReferenceUsername, - RemoveOneInterface, - ReplaceOneInterface, - UpdateOneInterface, - UserCreatableInterface, - UserUpdatableInterface, - UserReplaceableInterface, - UserEntityInterface, -} from '@concepta/nestjs-common'; - -export interface UserModelServiceInterface< - Entity extends UserEntityInterface = UserEntityInterface, - Creatable extends UserCreatableInterface = UserCreatableInterface, - Updatable extends UserUpdatableInterface = UserUpdatableInterface, - Replaceable extends UserReplaceableInterface = UserReplaceableInterface, - Removable extends Pick = Pick, -> extends ByIdInterface, - ByEmailInterface, - BySubjectInterface, - ByUsernameInterface, - CreateOneInterface, - UpdateOneInterface, - ReplaceOneInterface, - RemoveOneInterface {} diff --git a/packages/nestjs-user/src/interfaces/user-options-extras.interface.ts b/packages/nestjs-user/src/interfaces/user-options-extras.interface.ts deleted file mode 100644 index e9a6b1f8e..000000000 --- a/packages/nestjs-user/src/interfaces/user-options-extras.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DynamicModule } from '@nestjs/common'; - -export interface UserOptionsExtrasInterface - extends Pick {} diff --git a/packages/nestjs-user/src/interfaces/user-options.interface.ts b/packages/nestjs-user/src/interfaces/user-options.interface.ts deleted file mode 100644 index 6ec2735fb..000000000 --- a/packages/nestjs-user/src/interfaces/user-options.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { CanAccess } from '@concepta/nestjs-access-control'; - -import { UserModelServiceInterface } from './user-model-service.interface'; -import { UserPasswordHistoryServiceInterface } from './user-password-history-service.interface'; -import { UserPasswordServiceInterface } from './user-password-service.interface'; -import { UserSettingsInterface } from './user-settings.interface'; - -export interface UserOptionsInterface { - settings?: UserSettingsInterface; - userModelService?: UserModelServiceInterface; - userPasswordService?: UserPasswordServiceInterface; - userPasswordHistoryService?: UserPasswordHistoryServiceInterface; - userAccessQueryService?: CanAccess; -} diff --git a/packages/nestjs-user/src/interfaces/user-password-history-service.interface.ts b/packages/nestjs-user/src/interfaces/user-password-history-service.interface.ts deleted file mode 100644 index c857aa333..000000000 --- a/packages/nestjs-user/src/interfaces/user-password-history-service.interface.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - ReferenceId, - ReferenceIdInterface, - PasswordStorageInterface, -} from '@concepta/nestjs-common'; - -export interface UserPasswordHistoryServiceInterface { - /** - * Get the password history for the user id. - * - * Object must have reference id and password storage interface. - * - * @param userId - The id of the user - * @returns The password history for the user - */ - getHistory: ( - userId: ReferenceId, - ) => Promise<(ReferenceIdInterface & PasswordStorageInterface)[]>; - - /** - * Push one password history for the user id. - * - * Object must have reference id and password storage interface. - * - * @param userId - The id of the user - * @param passwordStore - One password history for the user - */ - pushHistory: ( - userId: ReferenceId, - passwordStore: PasswordStorageInterface, - ) => Promise; -} diff --git a/packages/nestjs-user/src/interfaces/user-password-service.interface.ts b/packages/nestjs-user/src/interfaces/user-password-service.interface.ts deleted file mode 100644 index 197bd66de..000000000 --- a/packages/nestjs-user/src/interfaces/user-password-service.interface.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - ReferenceId, - ReferenceIdInterface, - AuthenticatedUserInterface, - PasswordPlainCurrentInterface, - PasswordPlainInterface, - PasswordStorageInterface, -} from '@concepta/nestjs-common'; - -export interface UserPasswordServiceInterface { - /** - * Get the object containing the password store by user id. - * - * Object must have reference id and password storage interface. - * - * @param userId - The id of the user that is being updated - * @returns The user being updated - */ - getPasswordStore: ( - userId: ReferenceId, - ) => Promise; - - /** - * Set the password and save in database. - * - * @param passwordDto - The object containing the password, and optionally the current password. - * @param userToUpdateId - The id of the user being updated. - * @param authorizedUser - The authorized user - */ - setPassword: ( - passwordDto: PasswordPlainInterface & - Partial, - userToUpdateId?: ReferenceId, - authorizedUser?: AuthenticatedUserInterface, - ) => Promise; -} diff --git a/packages/nestjs-user/src/interfaces/user-settings.interface.ts b/packages/nestjs-user/src/interfaces/user-settings.interface.ts deleted file mode 100644 index 6ab26b39b..000000000 --- a/packages/nestjs-user/src/interfaces/user-settings.interface.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { InvitationAcceptedEventPayloadInterface } from '@concepta/nestjs-common'; -import { - EventAsyncInterface, - EventClassInterface, -} from '@concepta/nestjs-event'; - -export interface UserSettingsInterface { - invitationAcceptedEvent?: EventClassInterface< - EventAsyncInterface - >; - passwordHistory?: { - /** - * password history feature toggle - */ - enabled?: boolean; - /** - * number of days that password history limitation applies for - */ - limitDays?: number | undefined; - }; -} diff --git a/packages/nestjs-user/src/listeners/invitation-accepted-listener.ts b/packages/nestjs-user/src/listeners/invitation-accepted-listener.ts deleted file mode 100644 index b1697e3d8..000000000 --- a/packages/nestjs-user/src/listeners/invitation-accepted-listener.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; - -import { - INVITATION_MODULE_CATEGORY_USER_KEY, - InvitationAcceptedEventPayloadInterface, -} from '@concepta/nestjs-common'; -import { EventAsyncInterface, EventListenerOn } from '@concepta/nestjs-event'; - -import { UserException } from '../exceptions/user-exception'; -import { UserNotFoundException } from '../exceptions/user-not-found-exception'; -import { UserModelServiceInterface } from '../interfaces/user-model-service.interface'; -import { UserSettingsInterface } from '../interfaces/user-settings.interface'; -import { UserModelService } from '../services/user-model.service'; -import { USER_MODULE_SETTINGS_TOKEN } from '../user.constants'; - -@Injectable() -export class InvitationAcceptedListener - extends EventListenerOn< - EventAsyncInterface - > - implements OnModuleInit -{ - constructor( - @Inject(USER_MODULE_SETTINGS_TOKEN) - private settings: UserSettingsInterface, - @Inject(UserModelService) - private userModelService: UserModelServiceInterface, - ) { - super(); - } - - onModuleInit() { - if (this.settings.invitationAcceptedEvent) { - this.on(this.settings.invitationAcceptedEvent); - } - } - - async listen( - event: EventAsyncInterface< - InvitationAcceptedEventPayloadInterface, - boolean - >, - ) { - // check only for invitation of type category - if ( - event.payload.invitation.category === INVITATION_MODULE_CATEGORY_USER_KEY - ) { - const userId = event.payload.invitation.userId; - - if (typeof userId !== 'string') { - throw new UserException({ - message: - 'The invitation accepted event payload received has invalid content. The payload must have the "invitation.user" property.', - }); - } - - const user = await this.userModelService.byId(userId); - - if (!user) { - throw new UserNotFoundException(); - } - - await this.userModelService.update({ ...user }); - - return true; - } - - // return true by default - return true; - } -} diff --git a/packages/nestjs-user/src/optional-crud.ts b/packages/nestjs-user/src/optional-crud.ts new file mode 100644 index 000000000..873495c4e --- /dev/null +++ b/packages/nestjs-user/src/optional-crud.ts @@ -0,0 +1,19 @@ +// user schemas (Zod / Standard Schema) +export { userPaginatedSchema } from './infrastructure/schemas/user-paginated.schema.js'; +export { userCreateBatchSchema } from './infrastructure/schemas/user-create-batch.schema.js'; + +// user requests +export { CreateUserRequest } from './gateways/http/commands/impl/create-user.request.js'; +export { UpdateUserRequest } from './gateways/http/commands/impl/update-user.request.js'; +export { DeleteUserRequest } from './gateways/http/commands/impl/delete-user.request.js'; +export { UpdateUserPasswordRequest } from './gateways/http/commands/impl/update-user-password.request.js'; +export { ListUsersRequest } from './gateways/http/queries/impl/list-users.request.js'; +export { ReadUserRequest } from './gateways/http/queries/impl/read-user.request.js'; + +// user request handlers +export { CreateUserRequestHandler } from './gateways/http/commands/handlers/create-user-request.handler.js'; +export { UpdateUserRequestHandler } from './gateways/http/commands/handlers/update-user-request.handler.js'; +export { DeleteUserRequestHandler } from './gateways/http/commands/handlers/delete-user-request.handler.js'; +export { UpdateUserPasswordRequestHandler } from './gateways/http/commands/handlers/update-user-password-request.handler.js'; +export { ListUsersRequestHandler } from './gateways/http/queries/handlers/list-users-request.handler.js'; +export { ReadUserRequestHandler } from './gateways/http/queries/handlers/read-user-request.handler.js'; diff --git a/packages/nestjs-user/src/optional-seeding.ts b/packages/nestjs-user/src/optional-seeding.ts new file mode 100644 index 000000000..eea712d3a --- /dev/null +++ b/packages/nestjs-user/src/optional-seeding.ts @@ -0,0 +1,8 @@ +/** + * These exports allow you to import seeding related classes + * and tools without loading the entire module which + * runs all of its decorators and meta data. + */ +export { UserFactory } from './infrastructure/seeding/user.factory.js'; +export { UserCredentialFactory } from './infrastructure/seeding/user-credential.factory.js'; +export { UserSeeder } from './infrastructure/seeding/user.seeder.js'; diff --git a/packages/nestjs-user/src/optional-typeorm.ts b/packages/nestjs-user/src/optional-typeorm.ts new file mode 100644 index 000000000..7025aa650 --- /dev/null +++ b/packages/nestjs-user/src/optional-typeorm.ts @@ -0,0 +1,4 @@ +export { UserSqliteEntity } from './infrastructure/persistence/typeorm/user-sqlite.entity.js'; +export { UserPostgresEntity } from './infrastructure/persistence/typeorm/user-postgres.entity.js'; +export { UserCredentialSqliteEntity } from './infrastructure/persistence/typeorm/user-credential-sqlite.entity.js'; +export { UserCredentialPostgresEntity } from './infrastructure/persistence/typeorm/user-credential-postgres.entity.js'; diff --git a/packages/nestjs-user/src/seeding.ts b/packages/nestjs-user/src/seeding.ts deleted file mode 100644 index 574f5a088..000000000 --- a/packages/nestjs-user/src/seeding.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * These exports all you to import seeding related classes - * and tools without loading the entire module which - * runs all of it's decorators and meta data. - */ -export { UserFactory } from './user.factory'; -export { UserSeeder } from './user.seeder'; diff --git a/packages/nestjs-user/src/services/user-access-query.service.spec.ts b/packages/nestjs-user/src/services/user-access-query.service.spec.ts deleted file mode 100644 index c659ee3da..000000000 --- a/packages/nestjs-user/src/services/user-access-query.service.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { - AccessControlContext, - ActionEnum, -} from '@concepta/nestjs-access-control'; - -import { UserPasswordDto } from '../dto/user-password.dto'; -import { UserDto } from '../dto/user.dto'; -import { UserResource } from '../user.types'; - -import { UserAccessQueryService } from './user-access-query.service'; - -describe(UserAccessQueryService.name, () => { - let service: UserAccessQueryService; - let context: jest.Mocked; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [UserAccessQueryService], - }).compile(); - - service = module.get(UserAccessQueryService); - context = { - getQuery: jest.fn(), - getUser: jest.fn(), - getRequest: jest.fn(), - } as unknown as jest.Mocked; - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe(UserAccessQueryService.prototype.canAccess.name, () => { - it('should delegate to canUpdatePassword method', async () => { - // Arrange - const expectedResult = true; - const mockCanUpdatePassword = jest - .fn() - .mockResolvedValueOnce(expectedResult); - jest - .spyOn(service.constructor.prototype, 'canUpdatePassword') - .mockImplementationOnce(mockCanUpdatePassword); - - // Act - const result = await service.canAccess(context); - - // Assert - expect(result).toBe(expectedResult); - expect(mockCanUpdatePassword).toHaveBeenCalledWith(context); - }); - }); - - describe('canUpdatePassword', () => { - it('should return true when resource is not UserResource.One or action is not UPDATE', async () => { - // Arrange - jest.spyOn(context, 'getQuery').mockReturnValueOnce({ - resource: 'other-resource', - action: ActionEnum.CREATE, - }); - - // Act - const result = await service.canAccess(context); - - // Assert - expect(result).toBe(true); - }); - - it('should return true when user IDs match and password is provided', async () => { - // Arrange - const userId = '123'; - const userAuthorizedDto = { id: userId } as UserDto; - const userParamDto = { id: userId } as UserDto; - const userPasswordDto = { password: 'new-password' } as UserPasswordDto; - - jest.spyOn(context, 'getQuery').mockReturnValueOnce({ - resource: UserResource.One, - action: ActionEnum.UPDATE, - }); - jest.spyOn(context, 'getUser').mockReturnValueOnce(userAuthorizedDto); - jest - .spyOn(context, 'getRequest') - .mockImplementationOnce((property?: string) => { - if (property === 'params') return userParamDto; - if (property === 'body') return userPasswordDto; - return null; - }); - - // Act - const result = await service.canAccess(context); - - // Assert - expect(result).toBe(true); - }); - - it('should return false when user IDs do not match', async () => { - // Arrange - const userAuthorizedDto = { id: '123' }; - const userParamDto = { id: '456' }; - const userPasswordDto = { password: 'new-password' }; - - jest.spyOn(context, 'getQuery').mockReturnValueOnce({ - resource: UserResource.One, - action: ActionEnum.UPDATE, - }); - jest.spyOn(context, 'getUser').mockReturnValueOnce(userAuthorizedDto); - jest - .spyOn(context, 'getRequest') - .mockImplementation((property?: string) => { - if (property === 'params') return userParamDto; - if (property === 'body') return userPasswordDto; - return null; - }); - - // Act - const result = await service.canAccess(context); - - // Assert - expect(result).toBe(false); - }); - - it('should return true when password is not provided', async () => { - // Arrange - const userId = '123'; - const userAuthorizedDto = { id: userId } as UserDto; - const userParamDto = { id: userId } as UserDto; - const userPasswordDto = {} as UserPasswordDto; - - jest.spyOn(context, 'getQuery').mockReturnValueOnce({ - resource: UserResource.One, - action: ActionEnum.UPDATE, - }); - jest.spyOn(context, 'getUser').mockReturnValueOnce(userAuthorizedDto); - jest - .spyOn(context, 'getRequest') - .mockImplementationOnce((property?: string) => { - if (property === 'params') return userParamDto; - if (property === 'body') return userPasswordDto; - return null; - }); - - // Act - const result = await service.canAccess(context); - - // Assert - expect(result).toBe(true); - }); - }); -}); diff --git a/packages/nestjs-user/src/services/user-access-query.service.ts b/packages/nestjs-user/src/services/user-access-query.service.ts deleted file mode 100644 index f8d7e2fd1..000000000 --- a/packages/nestjs-user/src/services/user-access-query.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { plainToInstance } from 'class-transformer'; - -import { Injectable } from '@nestjs/common'; - -import { - CanAccess, - AccessControlContext, - ActionEnum, -} from '@concepta/nestjs-access-control'; - -import { UserPasswordDto } from '../dto/user-password.dto'; -import { UserDto } from '../dto/user.dto'; -import { UserResource } from '../user.types'; - -@Injectable() -export class UserAccessQueryService implements CanAccess { - async canAccess(context: AccessControlContext): Promise { - return this.canUpdatePassword(context); - } - - protected async canUpdatePassword( - context: AccessControlContext, - ): Promise { - const { resource, action } = context.getQuery(); - - if (resource === UserResource.One && action === ActionEnum.UPDATE) { - const userAuthorizedDto = plainToInstance(UserDto, context.getUser()); - - const params = context.getRequest('params'); - const userParamDto = plainToInstance(UserDto, params); - - const body = context.getRequest('body'); - const userPasswordDto = plainToInstance(UserPasswordDto, body); - - if (userParamDto.id && userPasswordDto?.password) { - return userParamDto.id === userAuthorizedDto.id; - } - } - - // does not apply - return true; - } -} diff --git a/packages/nestjs-user/src/services/user-model.service.ts b/packages/nestjs-user/src/services/user-model.service.ts deleted file mode 100644 index 4cc73fecf..000000000 --- a/packages/nestjs-user/src/services/user-model.service.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - UserCreatableInterface, - UserUpdatableInterface, - UserReplaceableInterface, - RepositoryInterface, - ModelService, - ReferenceUsername, - ReferenceSubject, - ReferenceEmail, - InjectDynamicRepository, - UserEntityInterface, -} from '@concepta/nestjs-common'; - -import { UserCreateDto } from '../dto/user-create.dto'; -import { UserUpdateDto } from '../dto/user-update.dto'; -import { UserModelServiceInterface } from '../interfaces/user-model-service.interface'; -import { USER_MODULE_USER_ENTITY_KEY } from '../user.constants'; - -/** - * User model service - */ -@Injectable() -export class UserModelService - extends ModelService< - UserEntityInterface, - UserCreatableInterface, - UserUpdatableInterface, - UserReplaceableInterface - > - implements UserModelServiceInterface -{ - protected createDto = UserCreateDto; - protected updateDto = UserUpdateDto; - - /** - * Constructor - * - * @param repo - instance of the user repo - */ - constructor( - @InjectDynamicRepository(USER_MODULE_USER_ENTITY_KEY) - repo: RepositoryInterface, - ) { - super(repo); - } - - /** - * Get user for the given email. - * - * @param email - the email - */ - async byEmail(email: ReferenceEmail): Promise { - return this.repo.findOne({ where: { email } }); - } - - /** - * Get user for the given subject. - * - * @param subject - the subject - */ - async bySubject( - subject: ReferenceSubject, - ): Promise { - return this.repo.findOne({ where: { id: subject } }); - } - - /** - * Get user for the given username. - * - * @param username - the username - */ - async byUsername( - username: ReferenceUsername, - ): Promise { - return this.repo.findOne({ where: { username } }); - } -} diff --git a/packages/nestjs-user/src/services/user-password-history-model.service.ts b/packages/nestjs-user/src/services/user-password-history-model.service.ts deleted file mode 100644 index 2675d6578..000000000 --- a/packages/nestjs-user/src/services/user-password-history-model.service.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - ModelService, - ReferenceId, - RepositoryInterface, - InjectDynamicRepository, - UserPasswordHistoryEntityInterface, - UserPasswordHistoryCreatableInterface, -} from '@concepta/nestjs-common'; - -import { UserPasswordHistoryCreateDto } from '../dto/user-password-history-create.dto'; -import { USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY } from '../user.constants'; - -@Injectable() -export class UserPasswordHistoryModelService extends ModelService< - UserPasswordHistoryEntityInterface, - UserPasswordHistoryCreatableInterface, - never, - never -> { - constructor( - @InjectDynamicRepository(USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY) - protected readonly userPasswordHistoryRepo: RepositoryInterface, - ) { - super(userPasswordHistoryRepo); - } - - async byUserId(userId: ReferenceId) { - return this.userPasswordHistoryRepo.find({ - where: { - userId, - }, - }); - } - - protected createDto = UserPasswordHistoryCreateDto; - protected updateDto!: never; -} diff --git a/packages/nestjs-user/src/services/user-password-history-service.spec.ts b/packages/nestjs-user/src/services/user-password-history-service.spec.ts deleted file mode 100644 index e607f5db2..000000000 --- a/packages/nestjs-user/src/services/user-password-history-service.spec.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { - ReferenceId, - PasswordStorageInterface, - UserPasswordHistoryEntityInterface, -} from '@concepta/nestjs-common'; - -import { UserException } from '../exceptions/user-exception'; -import { UserSettingsInterface } from '../interfaces/user-settings.interface'; -import { USER_MODULE_SETTINGS_TOKEN } from '../user.constants'; - -import { UserPasswordHistoryModelService } from './user-password-history-model.service'; -import { UserPasswordHistoryService } from './user-password-history.service'; - -describe(UserPasswordHistoryService.name, () => { - let service: UserPasswordHistoryService; - let userPasswordHistoryModelService: jest.Mocked; - let userSettings: UserSettingsInterface; - - const mockUserId: ReferenceId = 'test-user-id'; - const mockPasswordStore: PasswordStorageInterface = { - passwordHash: 'hashed-password', - passwordSalt: 'salt', - }; - - const mockHistoryItem: UserPasswordHistoryEntityInterface = { - id: 'test-id', - userId: mockUserId, - passwordHash: mockPasswordStore.passwordHash, - passwordSalt: mockPasswordStore.passwordSalt, - dateCreated: new Date(), - dateUpdated: new Date(), - dateDeleted: null, - version: 1, - }; - - beforeEach(async () => { - userSettings = { - passwordHistory: { - limitDays: undefined, - }, - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - UserPasswordHistoryService, - { - provide: USER_MODULE_SETTINGS_TOKEN, - useValue: userSettings, - }, - { - provide: UserPasswordHistoryModelService, - useValue: { - find: jest.fn(), - create: jest.fn(), - gt: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get( - UserPasswordHistoryService, - ); - userPasswordHistoryModelService = module.get( - UserPasswordHistoryModelService, - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - jest.useRealTimers(); - }); - - describe(UserPasswordHistoryService.prototype.pushHistory.name, () => { - it('should successfully push password history', async () => { - // Arrange - userPasswordHistoryModelService.create.mockResolvedValue(mockHistoryItem); - - // Act - await service.pushHistory(mockUserId, mockPasswordStore); - - // Assert - expect(userPasswordHistoryModelService.create).toHaveBeenCalledWith({ - userId: mockUserId, - ...mockPasswordStore, - }); - }); - - it('should throw UserException when model service create fails', async () => { - // Arrange - const error = new Error('Database error'); - userPasswordHistoryModelService.create.mockRejectedValue(error); - - // Act & Assert - await expect( - service.pushHistory(mockUserId, mockPasswordStore), - ).rejects.toThrow(UserException); - expect(userPasswordHistoryModelService.create).toHaveBeenCalledWith({ - userId: mockUserId, - ...mockPasswordStore, - }); - }); - }); - - describe(UserPasswordHistoryService.prototype.getHistory.name, () => { - it('should successfully get password history', async () => { - // Arrange - const mockHistory = [mockHistoryItem]; - userPasswordHistoryModelService.find.mockResolvedValue(mockHistory); - - // Act - const result = await service.getHistory(mockUserId); - - // Assert - expect(result).toEqual(mockHistory); - expect(userPasswordHistoryModelService.find).toHaveBeenCalled(); - }); - - it('should return empty array when no history found', async () => { - // Arrange - userPasswordHistoryModelService.find.mockResolvedValue([]); - - // Act - const result = await service.getHistory(mockUserId); - - // Assert - expect(result).toEqual([]); - expect(userPasswordHistoryModelService.find).toHaveBeenCalled(); - }); - - it('should throw UserException when model service find fails', async () => { - // Arrange - const error = new Error('Database error'); - userPasswordHistoryModelService.find.mockRejectedValue(error); - - // Act & Assert - await expect(service.getHistory(mockUserId)).rejects.toThrow( - UserException, - ); - expect(userPasswordHistoryModelService.find).toHaveBeenCalled(); - }); - }); - - describe( - UserPasswordHistoryService.prototype['getHistoryFindManyOptions'].name, - () => { - it('should return basic query options when no limitDays setting', () => { - // Arrange - userSettings.passwordHistory = {}; - - // Act - const result = service['getHistoryFindManyOptions'](mockUserId); - - // Assert - expect(result).toEqual({ - where: { - userId: mockUserId, - }, - order: { - dateCreated: 'ASC', - }, - }); - }); - }, - ); -}); diff --git a/packages/nestjs-user/src/services/user-password-history.service.ts b/packages/nestjs-user/src/services/user-password-history.service.ts deleted file mode 100644 index 9b51d1057..000000000 --- a/packages/nestjs-user/src/services/user-password-history.service.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; - -import { - ReferenceId, - ReferenceIdInterface, - RepositoryInternals, - PasswordStorageInterface, - UserPasswordHistoryEntityInterface, -} from '@concepta/nestjs-common'; - -import { UserException } from '../exceptions/user-exception'; -import { UserPasswordHistoryServiceInterface } from '../interfaces/user-password-history-service.interface'; -import { UserSettingsInterface } from '../interfaces/user-settings.interface'; -import { USER_MODULE_SETTINGS_TOKEN } from '../user.constants'; - -import { UserPasswordHistoryModelService } from './user-password-history-model.service'; - -@Injectable() -export class UserPasswordHistoryService - implements UserPasswordHistoryServiceInterface -{ - constructor( - @Inject(USER_MODULE_SETTINGS_TOKEN) - protected readonly userSettings: UserSettingsInterface, - @Inject(UserPasswordHistoryModelService) - protected readonly userPasswordHistoryModelService: UserPasswordHistoryModelService, - ) {} - - async getHistory( - userId: ReferenceId, - ): Promise<(ReferenceIdInterface & PasswordStorageInterface)[]> { - let history: (ReferenceIdInterface & PasswordStorageInterface)[] | null; - - try { - // try to find the history - history = await this.userPasswordHistoryModelService.find( - this.getHistoryFindManyOptions(userId), - ); - } catch (e: unknown) { - throw new UserException({ - message: - 'Cannot update password, error while getting password history by user id', - originalError: e, - }); - } - - // return history if found or empty array - return history ?? []; - } - - async pushHistory( - userId: string, - passwordStore: PasswordStorageInterface, - ): Promise { - try { - await this.userPasswordHistoryModelService.create({ - userId, - ...passwordStore, - }); - } catch (e: unknown) { - throw new UserException({ - message: - 'Cannot update password, error while pushing password history by user id', - originalError: e, - }); - } - } - - protected getHistoryFindManyOptions( - userId: ReferenceId, - ): RepositoryInternals.FindManyOptions { - // the base query - const query: RepositoryInternals.FindManyOptions & { - where: { - userId: ReferenceId; - dateCreated?: ReturnType; - }; - } = { - where: { - userId, - }, - order: { - dateCreated: 'ASC', - }, - }; - - // is there a limit days setting? - if (this.userSettings?.passwordHistory?.limitDays) { - // our limit date - const limitDate = new Date(); - // subtract limit days setting - limitDate.setDate( - limitDate.getDate() - this.userSettings.passwordHistory.limitDays, - ); - // set the created at query - query.where.dateCreated = - this.userPasswordHistoryModelService.gt(limitDate); - } - - return query; - } -} diff --git a/packages/nestjs-user/src/services/user-password-service.spec.ts b/packages/nestjs-user/src/services/user-password-service.spec.ts deleted file mode 100644 index 80d3b1485..000000000 --- a/packages/nestjs-user/src/services/user-password-service.spec.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { ReferenceId, PasswordStorageInterface } from '@concepta/nestjs-common'; -import { - PasswordCreationService, - PasswordStorageService, -} from '@concepta/nestjs-password'; - -import { UserException } from '../exceptions/user-exception'; -import { UserNotFoundException } from '../exceptions/user-not-found-exception'; - -import { UserModelService } from './user-model.service'; -import { UserPasswordHistoryService } from './user-password-history.service'; -import { UserPasswordService } from './user-password.service'; - -describe(UserPasswordService.name, () => { - let service: UserPasswordService; - let userModelService: jest.Mocked; - let passwordCreationService: jest.Mocked; - let passwordStorageService: jest.Mocked; - let userPasswordHistoryService: jest.Mocked; - - // Common mock data - const mockUserId: ReferenceId = 'test-user-id'; - const mockPasswordStore: PasswordStorageInterface = { - passwordHash: 'hashed-password', - passwordSalt: 'salt', - }; - - const createMockUser = (overrides = {}) => ({ - id: mockUserId, - email: 'test@example.com', - username: 'testuser', - active: true, - dateCreated: new Date(), - dateUpdated: new Date(), - dateDeleted: null, - version: 1, - ...mockPasswordStore, - ...overrides, - }); - - const createMockHashedPassword = (overrides = {}) => ({ - passwordHash: 'hashed-new-password', - passwordSalt: 'new-salt', - ...overrides, - }); - - const createMockHistoryItem = (overrides = {}) => ({ - id: 'history-id', - ...mockPasswordStore, - ...overrides, - }); - - const createMockAuthorizedUser = (overrides = {}) => ({ - id: mockUserId, - ...overrides, - }); - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - UserPasswordService, - { - provide: UserModelService, - useValue: { - byId: jest.fn(), - update: jest.fn(), - }, - }, - { - provide: PasswordCreationService, - useValue: { - validateCurrent: jest.fn(), - validateHistory: jest.fn(), - }, - }, - { - provide: PasswordStorageService, - useValue: { - hash: jest.fn(), - }, - }, - { - provide: UserPasswordHistoryService, - useValue: { - pushHistory: jest.fn(), - getHistory: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get(UserPasswordService); - userModelService = module.get(UserModelService); - passwordCreationService = module.get(PasswordCreationService); - passwordStorageService = module.get(PasswordStorageService); - userPasswordHistoryService = module.get(UserPasswordHistoryService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe(UserPasswordService.prototype.setPassword.name, () => { - it('should set a new password for a user', async () => { - // Arrange - const mockPassword = 'new-password'; - const mockHashedPassword = createMockHashedPassword(); - const mockUser = createMockUser(); - - // Mock the service methods - userModelService.byId.mockResolvedValue(mockUser); - passwordStorageService.hash.mockResolvedValue(mockHashedPassword); - passwordCreationService.validateCurrent.mockResolvedValue(true); - passwordCreationService.validateHistory.mockResolvedValue(true); - - // Act - await service.setPassword({ password: mockPassword }, mockUserId); - - // Assert - expect(userModelService.byId).toHaveBeenCalledWith(mockUserId); - expect(passwordStorageService.hash).toHaveBeenCalledWith(mockPassword); - expect(userModelService.update).toHaveBeenCalledWith({ - id: mockUserId, - passwordHash: mockHashedPassword.passwordHash, - passwordSalt: mockHashedPassword.passwordSalt, - }); - expect(userPasswordHistoryService.pushHistory).toHaveBeenCalledWith( - mockUserId, - mockHashedPassword, - ); - }); - - it('should validate current password when user is updating their own password', async () => { - // Arrange - const mockCurrentPassword = 'current-password'; - const mockTargetUser = createMockUser(); - const mockAuthorizedUser = createMockAuthorizedUser(); - - // Mock the service method - passwordCreationService.validateCurrent.mockResolvedValue(true); - - // Act - const result = await service['validateCurrent']( - mockTargetUser, - mockCurrentPassword, - mockAuthorizedUser, - ); - - // Assert - expect(result).toBe(true); - expect(passwordCreationService.validateCurrent).toHaveBeenCalledWith({ - password: mockCurrentPassword, - target: mockTargetUser, - }); - }); - - it('should validate password history', async () => { - // Arrange - const mockPassword = 'new-password'; - const mockHashedPassword = createMockHashedPassword(); - const mockUser = createMockUser(); - const mockHistory = [createMockHistoryItem()]; - - // Mock the service methods - userModelService.byId.mockResolvedValue(mockUser); - passwordStorageService.hash.mockResolvedValue(mockHashedPassword); - passwordCreationService.validateCurrent.mockResolvedValue(true); - passwordCreationService.validateHistory.mockResolvedValue(true); - userPasswordHistoryService.getHistory.mockResolvedValue(mockHistory); - - // Act - await service.setPassword({ password: mockPassword }, mockUserId); - - // Assert - expect(userPasswordHistoryService.getHistory).toHaveBeenCalledWith( - mockUserId, - ); - expect(passwordCreationService.validateHistory).toHaveBeenCalledWith({ - password: mockPassword, - targets: mockHistory, - }); - }); - - it('should push password to history after update', async () => { - // Arrange - const mockPassword = 'new-password'; - const mockHashedPassword = createMockHashedPassword(); - const mockUser = createMockUser(); - - // Mock the service methods - userModelService.byId.mockResolvedValue(mockUser); - passwordStorageService.hash.mockResolvedValue(mockHashedPassword); - passwordCreationService.validateCurrent.mockResolvedValue(true); - passwordCreationService.validateHistory.mockResolvedValue(true); - - // Act - await service.setPassword({ password: mockPassword }, mockUserId); - - // Assert - expect(userPasswordHistoryService.pushHistory).toHaveBeenCalledWith( - mockUserId, - mockHashedPassword, - ); - }); - - it('should throw exception when validation fails', async () => { - // Arrange - const mockPassword = 'new-password'; - const mockCurrentPassword = 'current-password'; - const mockUser = createMockUser(); - const mockAuthorizedUser = createMockAuthorizedUser(); - - // Mock the service methods - userModelService.byId.mockResolvedValue(mockUser); - passwordCreationService.validateCurrent.mockResolvedValue(false); - - // Act & Assert - await expect( - service.setPassword( - { password: mockPassword, passwordCurrent: mockCurrentPassword }, - mockUserId, - mockAuthorizedUser, - ), - ).rejects.toThrow(UserException); - }); - }); - - describe(UserPasswordService.prototype.getPasswordStore.name, () => { - it('should return user with password store when user exists', async () => { - // Arrange - const mockUser = createMockUser(); - - // Mock the service method - userModelService.byId.mockResolvedValue(mockUser); - - // Act - const result = await service.getPasswordStore(mockUserId); - - // Assert - expect(userModelService.byId).toHaveBeenCalledWith(mockUserId); - expect(result).toEqual({ - ...mockUser, - passwordHash: mockPasswordStore.passwordHash, - passwordSalt: mockPasswordStore.passwordSalt, - }); - }); - - it('should throw UserNotFoundException when user does not exist', async () => { - // Arrange - userModelService.byId.mockResolvedValue(null); - - // Act & Assert - await expect(service.getPasswordStore(mockUserId)).rejects.toThrow( - UserNotFoundException, - ); - expect(userModelService.byId).toHaveBeenCalledWith(mockUserId); - }); - - it('should throw UserException when database error occurs', async () => { - // Arrange - const mockError = new Error('Database error'); - userModelService.byId.mockRejectedValue(mockError); - - // Act & Assert - await expect(service.getPasswordStore(mockUserId)).rejects.toThrow( - UserException, - ); - expect(userModelService.byId).toHaveBeenCalledWith(mockUserId); - }); - }); - - describe(UserPasswordService.prototype['validateCurrent'].name, () => { - it('should return true when user is not updating their own password', async () => { - // Arrange - const mockTargetUser = createMockUser({ id: 'target-user-id' }); - const mockAuthorizedUser = createMockAuthorizedUser({ - id: 'different-user-id', - }); - - // Act - const result = await service['validateCurrent']( - mockTargetUser, - 'some-password', - mockAuthorizedUser, - ); - - // Assert - expect(result).toBe(true); - expect(passwordCreationService.validateCurrent).not.toHaveBeenCalled(); - }); - - it('should validate current password when user is updating their own password', async () => { - // Arrange - const mockTargetUser = createMockUser(); - const mockAuthorizedUser = createMockAuthorizedUser(); - const mockCurrentPassword = 'current-password'; - - // Mock the service method - passwordCreationService.validateCurrent.mockResolvedValue(true); - - // Act - const result = await service['validateCurrent']( - mockTargetUser, - mockCurrentPassword, - mockAuthorizedUser, - ); - - // Assert - expect(result).toBe(true); - expect(passwordCreationService.validateCurrent).toHaveBeenCalledWith({ - password: mockCurrentPassword, - target: mockTargetUser, - }); - }); - - it('should throw exception when current password is invalid', async () => { - // Arrange - const mockTargetUser = createMockUser(); - const mockAuthorizedUser = createMockAuthorizedUser(); - const mockCurrentPassword = 'invalid-password'; - - // Mock the service method - passwordCreationService.validateCurrent.mockResolvedValue(false); - - // Act & Assert - await expect( - service['validateCurrent']( - mockTargetUser, - mockCurrentPassword, - mockAuthorizedUser, - ), - ).rejects.toThrow(UserException); - expect(passwordCreationService.validateCurrent).toHaveBeenCalledWith({ - password: mockCurrentPassword, - target: mockTargetUser, - }); - }); - }); - - describe(UserPasswordService.prototype['validateHistory'].name, () => { - it('should validate password against history when history service exists', async () => { - // Arrange - const mockUser = createMockUser(); - const mockPassword = 'new-password'; - const mockHistory = [createMockHistoryItem()]; - - // Mock the service methods - userPasswordHistoryService.getHistory.mockResolvedValue(mockHistory); - passwordCreationService.validateHistory.mockResolvedValue(true); - - // Act - const result = await service['validateHistory'](mockUser, mockPassword); - - // Assert - expect(userPasswordHistoryService.getHistory).toHaveBeenCalledWith( - mockUserId, - ); - expect(passwordCreationService.validateHistory).toHaveBeenCalledWith({ - password: mockPassword, - targets: mockHistory, - }); - expect(result).toBe(true); - }); - - it('should throw exception when password has been used recently', async () => { - // Arrange - const mockUser = createMockUser(); - const mockPassword = 'recently-used-password'; - const mockHistory = [createMockHistoryItem()]; - - // Mock the service methods - userPasswordHistoryService.getHistory.mockResolvedValue(mockHistory); - passwordCreationService.validateHistory.mockResolvedValue(false); - - // Act & Assert - await expect( - service['validateHistory'](mockUser, mockPassword), - ).rejects.toThrow(UserException); - expect(userPasswordHistoryService.getHistory).toHaveBeenCalledWith( - mockUserId, - ); - expect(passwordCreationService.validateHistory).toHaveBeenCalledWith({ - password: mockPassword, - targets: mockHistory, - }); - }); - - it('should return true when history service does not exist', async () => { - // Arrange - const mockUser = createMockUser(); - const mockPassword = 'new-password'; - - // Create a new instance of the service without the history service - const module: TestingModule = await Test.createTestingModule({ - providers: [ - UserPasswordService, - { - provide: UserModelService, - useValue: { - byId: jest.fn(), - update: jest.fn(), - }, - }, - { - provide: PasswordCreationService, - useValue: { - validateCurrent: jest.fn(), - validateHistory: jest.fn(), - }, - }, - { - provide: PasswordStorageService, - useValue: { - hash: jest.fn(), - }, - }, - ], - }).compile(); - - const serviceWithoutHistory = - module.get(UserPasswordService); - - // Act - const result = await serviceWithoutHistory['validateHistory']( - mockUser, - mockPassword, - ); - - // Assert - expect(result).toBe(true); - expect(passwordCreationService.validateHistory).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/nestjs-user/src/services/user-password.service.ts b/packages/nestjs-user/src/services/user-password.service.ts deleted file mode 100644 index 4a2b0f79d..000000000 --- a/packages/nestjs-user/src/services/user-password.service.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { - forwardRef, - HttpStatus, - Inject, - Injectable, - Optional, -} from '@nestjs/common'; - -import { - ReferenceId, - ReferenceIdInterface, - isPasswordStorage, - AuthenticatedUserInterface, - PasswordPlainCurrentInterface, - PasswordPlainInterface, - PasswordStorageInterface, -} from '@concepta/nestjs-common'; -import { - PasswordCreationService, - PasswordCreationServiceInterface, - PasswordStorageService, - PasswordStorageServiceInterface, -} from '@concepta/nestjs-password'; - -import { UserException } from '../exceptions/user-exception'; -import { UserNotFoundException } from '../exceptions/user-not-found-exception'; -import { UserModelServiceInterface } from '../interfaces/user-model-service.interface'; -import { UserPasswordHistoryServiceInterface } from '../interfaces/user-password-history-service.interface'; -import { UserPasswordServiceInterface } from '../interfaces/user-password-service.interface'; - -import { UserModelService } from './user-model.service'; -import { UserPasswordHistoryService } from './user-password-history.service'; - -/** - * User password service - */ -@Injectable() -export class UserPasswordService implements UserPasswordServiceInterface { - /** - * Constructor - * - * @param userModelService - user model service - * @param passwordCreationService - password creation service - * @param passwordStorageService - password storage service - * @param userPasswordHistoryService - user password history creation service - */ - constructor( - @Inject(forwardRef(() => UserModelService)) - protected readonly userModelService: UserModelServiceInterface, - @Inject(PasswordCreationService) - protected readonly passwordCreationService: PasswordCreationServiceInterface, - @Inject(PasswordStorageService) - protected readonly passwordStorageService: PasswordStorageServiceInterface, - @Optional() - @Inject(UserPasswordHistoryService) - private userPasswordHistoryService?: UserPasswordHistoryServiceInterface, - ) {} - - async setPassword( - passwordDto: PasswordPlainInterface & - Partial, - userToUpdateId?: ReferenceId, - authorizedUser?: AuthenticatedUserInterface, - ): Promise { - // break out the password - const { password } = passwordDto; - - // user to update - let userToUpdate: - | (ReferenceIdInterface & PasswordStorageInterface) - | undefined = undefined; - - // are we updating? - if (userToUpdateId) { - // yes, get the user - userToUpdate = await this.getPasswordStore(userToUpdateId); - - // call current password validation helper - await this.validateCurrent( - userToUpdate, - passwordDto?.passwordCurrent, - authorizedUser, - ); - - // call password history validation helper - await this.validateHistory(userToUpdate, password); - } - - // call the password creation service - const passwordHashed = await this.passwordStorageService.hash(password); - - // push password history if necessary - if ( - this.userPasswordHistoryService && - userToUpdate && - isPasswordStorage(passwordHashed) - ) { - await this.userPasswordHistoryService.pushHistory( - userToUpdate.id, - passwordHashed, - ); - } - - // update the user - if (userToUpdate) { - await this.userModelService.update({ - id: userToUpdate.id, - passwordHash: passwordHashed.passwordHash, - passwordSalt: passwordHashed.passwordSalt, - }); - } - } - - async getPasswordStore( - userId: ReferenceId, - ): Promise { - let user: (ReferenceIdInterface & Partial) | null; - - try { - // try to find the user - user = await this.userModelService.byId(userId); - } catch (e: unknown) { - throw new UserException({ - message: 'Cannot update password, error while getting user by id', - originalError: e, - }); - } - - // did we get a user? - if (user) { - // break out the stored password - const { passwordHash, passwordSalt } = user; - - // return the user with asserted storage types - return { - ...user, - passwordHash: typeof passwordHash === 'string' ? passwordHash : '', - passwordSalt: typeof passwordSalt === 'string' ? passwordSalt : '', - }; - } - - // throw an exception by default - throw new UserNotFoundException({ - message: 'Impossible to update password if user is not found', - }); - } - - protected async validateCurrent( - target: ReferenceIdInterface & PasswordStorageInterface, - password?: string, - authorizedUser?: AuthenticatedUserInterface, - ): Promise { - // is the user updating their own password? - if (target.id === authorizedUser?.id) { - // call current password validation helper - const currentIsValid = await this.passwordCreationService.validateCurrent( - { - password, - target, - }, - ); - - if (currentIsValid) { - return true; - } else { - throw new UserException({ - message: `Current password is not valid`, - httpStatus: HttpStatus.BAD_REQUEST, - }); - } - } - - // return true by default - return true; - } - - protected async validateHistory( - user: ReferenceIdInterface, - password: string, - ): Promise { - // was a history service injected? - if (this.userPasswordHistoryService) { - // get password history for user - const passwordHistory = await this.userPasswordHistoryService.getHistory( - user.id, - ); - - // call password history validation helper - const isValid = await this.passwordCreationService.validateHistory({ - password, - targets: passwordHistory, - }); - - if (!isValid) { - throw new UserException({ - message: `Password has been used too recently.`, - httpStatus: HttpStatus.BAD_REQUEST, - }); - } - } - - // return true by default - return true; - } -} diff --git a/packages/nestjs-user/src/user-password-history.factory.ts b/packages/nestjs-user/src/user-password-history.factory.ts deleted file mode 100644 index 3344be939..000000000 --- a/packages/nestjs-user/src/user-password-history.factory.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { UserPasswordHistoryEntityInterface } from '@concepta/nestjs-common'; -import { PasswordStorageService } from '@concepta/nestjs-password'; -import { Factory } from '@concepta/typeorm-seeding'; - -/** - * User password history factory - */ -export class UserPasswordHistoryFactory extends Factory { - private _passwordStorageService = new PasswordStorageService(); - - /** - * Factory callback function. - */ - protected async entity( - userPasswordHistory: UserPasswordHistoryEntityInterface, - ): Promise { - // generate fake password store - const passwordStore = await this._passwordStorageService.hash('Test1233'); - - userPasswordHistory.passwordHash = passwordStore.passwordHash; - userPasswordHistory.passwordSalt = passwordStore.passwordSalt; - - // return the new user - return userPasswordHistory; - } -} diff --git a/packages/nestjs-user/src/user-profile.factory.ts b/packages/nestjs-user/src/user-profile.factory.ts deleted file mode 100644 index 1acb8d0b7..000000000 --- a/packages/nestjs-user/src/user-profile.factory.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { UserProfileEntityInterface } from '@concepta/nestjs-common'; -import { Factory } from '@concepta/typeorm-seeding'; - -import { UserFactory } from './user.factory'; - -/** - * User profile factory - */ -export class UserProfileFactory extends Factory { - protected async finalize( - userProfile: UserProfileEntityInterface, - ): Promise { - // missing user? - if (!userProfile.userId) { - // get the user factory - const userFactory = this.factory(UserFactory); - - // set the user on the profile - const user = await userFactory.create(); - userProfile.userId = user.id; - } - } -} diff --git a/packages/nestjs-user/src/user-profile.seeder.ts b/packages/nestjs-user/src/user-profile.seeder.ts deleted file mode 100644 index 9a2203be4..000000000 --- a/packages/nestjs-user/src/user-profile.seeder.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Seeder } from '@concepta/typeorm-seeding'; - -import { UserProfileFactory } from './user-profile.factory'; - -/** - * User Profile seeder - */ -export class UserProfileSeeder extends Seeder { - /** - * Runner - */ - public async run(): Promise { - // number of users to create - const createAmount = process.env?.USER_MODULE_SEEDER_AMOUNT - ? Number(process.env.USER_MODULE_SEEDER_AMOUNT) - : 50; - - // the factory - const userProfileFactory = this.factory(UserProfileFactory); - - // create a bunch - await userProfileFactory.createMany(createAmount); - } -} diff --git a/packages/nestjs-user/src/user.constants.ts b/packages/nestjs-user/src/user.constants.ts index c9a0c2e66..e000a6ab6 100644 --- a/packages/nestjs-user/src/user.constants.ts +++ b/packages/nestjs-user/src/user.constants.ts @@ -1,12 +1,8 @@ -export const USER_MODULE_OPTIONS_TOKEN = 'USER_MODULE_OPTIONS_TOKEN'; export const USER_MODULE_SETTINGS_TOKEN = 'USER_MODULE_SETTINGS_TOKEN'; export const USER_MODULE_DEFAULT_SETTINGS_TOKEN = 'USER_MODULE_DEFAULT_SETTINGS_TOKEN'; -export const USER_MODULE_CONFIGURABLE_CRUD_PROFILE_SERVICE_TOKEN = Symbol( - '__USER_MODULE_CONFIGURABLE_CRUD_PROFILE_SERVICE_TOKEN__', -); -export const USER_MODULE_USER_ENTITY_KEY = 'user'; -export const USER_MODULE_USER_PROFILE_ENTITY_KEY = 'user-profile'; -export const USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY = - 'user-password-history'; -export const USER_MODULE_USER_PASSWORD_HISTORY_LIMIT_DAYS_DEFAULT = 365 * 2; +export const USER_MODULE_USER_PASSWORD_REUSE_AFTER_DAYS_DEFAULT = 365 * 2; + +export const USER_REPOSITORY_TOKEN = 'USER_REPOSITORY_TOKEN'; +export const USER_CREDENTIALS_REPOSITORY_TOKEN = + 'USER_CREDENTIALS_REPOSITORY_TOKEN'; diff --git a/packages/nestjs-user/src/user.factory.ts b/packages/nestjs-user/src/user.factory.ts deleted file mode 100644 index 44fec0f9b..000000000 --- a/packages/nestjs-user/src/user.factory.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { faker } from '@faker-js/faker'; - -import { UserEntityInterface } from '@concepta/nestjs-common'; -import { Factory } from '@concepta/typeorm-seeding'; - -/** - * User factory - */ -export class UserFactory extends Factory { - /** - * List of used usernames. - */ - usedUsernames: Record = {}; - - /** - * Factory callback function. - */ - protected async entity( - user: UserEntityInterface, - ): Promise { - // set the username - user.username = this.generateUniqueUsername(); - - // fake email address - user.email = faker.internet.email(); - - // return the new user - return user; - } - - /** - * Generate a unique username. - */ - protected generateUniqueUsername(): string { - // the username - let username: string; - - // keep trying to get a unique username - do { - username = faker.internet.userName().toLowerCase(); - } while (this.usedUsernames[username]); - - // add to used usernames - this.usedUsernames[username] = true; - - // return it - return username; - } -} diff --git a/packages/nestjs-user/src/user.module-definition.ts b/packages/nestjs-user/src/user.module-definition.ts index edc30525f..37709b99e 100644 --- a/packages/nestjs-user/src/user.module-definition.ts +++ b/packages/nestjs-user/src/user.module-definition.ts @@ -1,259 +1,175 @@ import { ConfigurableModuleBuilder, - DynamicModule, - Provider, + type DynamicModule, + type Provider, + type Type, } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; - -import { - RepositoryInterface, - createSettingsProvider, - getDynamicRepositoryToken, - UserEntityInterface, - UserPasswordHistoryEntityInterface, -} from '@concepta/nestjs-common'; -import { - PasswordCreationService, - PasswordStorageService, -} from '@concepta/nestjs-password'; - -import { userDefaultConfig } from './config/user-default.config'; -import { UserModelServiceInterface } from './interfaces/user-model-service.interface'; -import { UserOptionsExtrasInterface } from './interfaces/user-options-extras.interface'; -import { UserOptionsInterface } from './interfaces/user-options.interface'; -import { UserSettingsInterface } from './interfaces/user-settings.interface'; -import { InvitationAcceptedListener } from './listeners/invitation-accepted-listener'; -import { UserAccessQueryService } from './services/user-access-query.service'; -import { UserModelService } from './services/user-model.service'; -import { UserPasswordHistoryModelService } from './services/user-password-history-model.service'; -import { UserPasswordHistoryService } from './services/user-password-history.service'; -import { UserPasswordService } from './services/user-password.service'; -import { - USER_MODULE_SETTINGS_TOKEN, - USER_MODULE_USER_ENTITY_KEY, - USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY, -} from './user.constants'; +import { CommandBus, CqrsModule } from '@nestjs/cqrs'; + +import { createSettingsProvider } from '@concepta/nestjs-core'; + +import { CreateUserCredentialHandler } from './application/commands/handlers/create-user-credential.handler.js'; +import { CreateUserHandler } from './application/commands/handlers/create-user.handler.js'; +import { RemoveUserHandler } from './application/commands/handlers/remove-user.handler.js'; +import { SetUserPasswordHandler } from './application/commands/handlers/set-user-password.handler.js'; +import { UpdateUserCredentialHandler } from './application/commands/handlers/update-user-credential.handler.js'; +import { UpdateUserPasswordHandler } from './application/commands/handlers/update-user-password.handler.js'; +import { UpdateUserHandler } from './application/commands/handlers/update-user.handler.js'; +import { GetUserByEmailHandler } from './application/queries/handlers/get-user-by-email.handler.js'; +import { GetUserBySubjectHandler } from './application/queries/handlers/get-user-by-subject.handler.js'; +import { GetUserByUsernameHandler } from './application/queries/handlers/get-user-by-username.handler.js'; +import { GetUserHandler } from './application/queries/handlers/get-user.handler.js'; +import { UserPasswordPort } from './domain/ports/user-password.port.js'; +import { type UserCredentialsRepositoryInterface } from './domain/repositories/user-credentials-repository.interface.js'; +import { UserCredentialsService } from './domain/services/user-credentials.service.js'; +import { type UserExtrasInterface } from './infrastructure/config/interfaces/user-extras.interface.js'; +import { type UserOptionsInterface } from './infrastructure/config/interfaces/user-options.interface.js'; +import { type UserSettingsInterface } from './infrastructure/config/interfaces/user-settings.interface.js'; +import { userDefaultConfig } from './infrastructure/config/user-default.config.js'; +import { UserCredentialsMapper } from './infrastructure/persistence/user-credentials.mapper.js'; +import { UserMapper } from './infrastructure/persistence/user.mapper.js'; +import { createPasswordPolicyProvider } from './infrastructure/utils/create-password-policy-provider.js'; +import { createUserCredentialsRepositoryProvider } from './infrastructure/utils/create-user-credentials-repository-provider.js'; +import { createUserRepositoryProvider } from './infrastructure/utils/create-user-repository-provider.js'; +import { USER_MODULE_SETTINGS_TOKEN } from './user.constants.js'; const RAW_OPTIONS_TOKEN = Symbol('__USER_MODULE_RAW_OPTIONS_TOKEN__'); export const { ConfigurableModuleClass: UserModuleClass, OPTIONS_TYPE: USER_OPTIONS_TYPE, - ASYNC_OPTIONS_TYPE: User_ASYNC_OPTIONS_TYPE, + ASYNC_OPTIONS_TYPE: USER_ASYNC_OPTIONS_TYPE, } = new ConfigurableModuleBuilder({ moduleName: 'User', optionsInjectionToken: RAW_OPTIONS_TOKEN, }) - .setExtras({ global: false }, definitionTransform) + .setExtras( + { global: false, entities: { user: 'user' } }, + definitionTransform, + ) .build(); -export type UserOptions = Omit; -export type UserAsyncOptions = Omit; +export type UserOptions = typeof USER_OPTIONS_TYPE; +export type UserAsyncOptions = typeof USER_ASYNC_OPTIONS_TYPE; function definitionTransform( definition: DynamicModule, - extras: UserOptionsExtrasInterface, + { + global, + providers: overrideProviders, + entities, + repositories, + }: UserExtrasInterface, ): DynamicModule { const { providers = [], imports = [] } = definition; - const { global = false } = extras; return { ...definition, global, imports: createUserImports({ imports }), - providers: createUserProviders({ providers }), + providers: createUserProviders({ + providers: [...providers, ...(overrideProviders ?? [])], + entities, + repositories, + }), exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createUserExports()], }; } -export function createUserImports( - options: Pick, -): Required>['imports'] { +export function createUserImports(options: { + imports: DynamicModule['imports']; +}): DynamicModule['imports'] { return [ - ...(options.imports ?? []), + ...(options.imports || []), ConfigModule.forFeature(userDefaultConfig), + CqrsModule.forRoot(), ]; } export function createUserProviders(options: { overrides?: UserOptions; providers?: Provider[]; + entities: UserExtrasInterface['entities']; + repositories?: UserExtrasInterface['repositories']; }): Provider[] { return [ - ...(options.providers ?? []), - PasswordCreationService, - InvitationAcceptedListener, createUserSettingsProvider(options.overrides), - createUserModelServiceProvider(options.overrides), - createUserPasswordServiceProvider(options.overrides), - createUserPasswordHistoryServiceProvider(options.overrides), - createUserPasswordHistoryModelServiceProvider(), - createUserAccessQueryServiceProvider(options.overrides), + UserMapper, + // User repository + ...createUserRepositoryProvider( + options.entities.user, + options.repositories?.user, + ), + // User CRUD command handlers + CreateUserHandler, + UpdateUserHandler, + RemoveUserHandler, + // Password command handlers (dispatch to credential commands via CommandBus) + SetUserPasswordHandler, + UpdateUserPasswordHandler, + // User CRUD query handlers + GetUserHandler, + GetUserByEmailHandler, + GetUserByUsernameHandler, + GetUserBySubjectHandler, + // Credentials infrastructure (only when credentials entity is configured) + ...createUserCredentialProviders( + options.entities.credentials, + options.repositories?.userCredentials, + ), + // Consumer overrides (last provider for a token wins) + ...(options.providers ?? []), ]; } -export function createUserExports(): Required< - Pick ->['exports'] { +function createUserCredentialProviders( + entityKey?: string, + customRepository?: Type, +): Provider[] { + if (!entityKey && !customRepository) { + return []; + } + return [ - USER_MODULE_SETTINGS_TOKEN, - UserModelService, - UserPasswordService, - UserPasswordHistoryService, - UserPasswordHistoryModelService, - UserAccessQueryService, + createPasswordPolicyProvider(), + createUserPasswordPortProvider(), + UserCredentialsMapper, + ...createUserCredentialsRepositoryProvider(entityKey, customRepository), + CreateUserCredentialHandler, + UpdateUserCredentialHandler, + UserCredentialsService, ]; } -export function createUserSettingsProvider( - optionsOverrides?: UserOptions, -): Provider { - return createSettingsProvider({ - settingsToken: USER_MODULE_SETTINGS_TOKEN, - optionsToken: RAW_OPTIONS_TOKEN, - settingsKey: userDefaultConfig.KEY, - optionsOverrides, - }); -} - -export function createUserModelServiceProvider( - optionsOverrides?: UserOptions, -): Provider { - return { - provide: UserModelService, - inject: [ - RAW_OPTIONS_TOKEN, - getDynamicRepositoryToken(USER_MODULE_USER_ENTITY_KEY), - ], - useFactory: async ( - options: UserOptionsInterface, - userRepo: RepositoryInterface, - ) => - optionsOverrides?.userModelService ?? - options.userModelService ?? - new UserModelService(userRepo), - }; -} - -export function createUserPasswordServiceProvider( - optionsOverrides?: UserOptions, -): Provider { - return { - provide: UserPasswordService, - inject: [ - RAW_OPTIONS_TOKEN, - UserModelService, - PasswordCreationService, - PasswordStorageService, - { - token: UserPasswordHistoryService, - optional: true, - }, - ], - useFactory: async ( - options: UserOptionsInterface, - userModelService: UserModelServiceInterface, - passwordCreationService: PasswordCreationService, - passwordStorageService: PasswordStorageService, - userPasswordHistoryService?: UserPasswordHistoryService, - ) => - optionsOverrides?.userPasswordService ?? - options.userPasswordService ?? - new UserPasswordService( - userModelService, - passwordCreationService, - passwordStorageService, - userPasswordHistoryService, - ), - }; +export function createUserExports(): Required< + Pick +>['exports'] { + return [USER_MODULE_SETTINGS_TOKEN]; } -export function createUserPasswordHistoryModelServiceProvider(): Provider { +function createUserPasswordPortProvider(): Provider { return { - provide: UserPasswordHistoryModelService, - inject: [ - USER_MODULE_SETTINGS_TOKEN, - { - token: getDynamicRepositoryToken( - USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY, - ), - optional: true, - }, - ], - useFactory: async ( - settings: UserSettingsInterface, - userPasswordHistoryRepoToken?: RepositoryInterface, - ) => { - if ( - settings?.passwordHistory?.enabled === true && - userPasswordHistoryRepoToken - ) { - return new UserPasswordHistoryModelService( - userPasswordHistoryRepoToken, + provide: UserPasswordPort, + inject: [RAW_OPTIONS_TOKEN, CommandBus], + useFactory: (options: UserOptionsInterface, commandBus: CommandBus) => { + if (!options.ports?.password) { + throw new Error( + 'UserModule: ports.password is required when credentials entity is configured', ); } + return new UserPasswordPort(options.ports.password, commandBus); }, }; } -export function createUserPasswordHistoryServiceProvider( - optionsOverrides?: UserOptions, -): Provider { - return { - provide: UserPasswordHistoryService, - inject: [ - RAW_OPTIONS_TOKEN, - USER_MODULE_SETTINGS_TOKEN, - { - token: getDynamicRepositoryToken( - USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY, - ), - optional: true, - }, - { - token: UserPasswordHistoryModelService, - optional: true, - }, - ], - useFactory: async ( - options: UserOptionsInterface, - settings: UserSettingsInterface, - userPasswordHistoryRepoToken?: RepositoryInterface, - userPasswordHistoryModelService?: UserPasswordHistoryModelService, - ) => { - // if password history is enabled? - if (settings?.passwordHistory?.enabled === true) { - // look for an overriding service - const overridingServiceOption = - optionsOverrides?.userPasswordHistoryService ?? - options.userPasswordHistoryService; - - // user overriding service, or create default service - if (overridingServiceOption) { - return overridingServiceOption; - } else if ( - userPasswordHistoryRepoToken && - userPasswordHistoryModelService - ) { - return new UserPasswordHistoryService( - settings, - userPasswordHistoryModelService, - ); - } - } - }, - }; -} - -export function createUserAccessQueryServiceProvider( +export function createUserSettingsProvider( optionsOverrides?: UserOptions, ): Provider { - return { - provide: UserAccessQueryService, - inject: [RAW_OPTIONS_TOKEN, UserPasswordService], - useFactory: async (options: UserOptionsInterface) => - optionsOverrides?.userAccessQueryService ?? - options.userAccessQueryService ?? - new UserAccessQueryService(), - }; + return createSettingsProvider({ + settingsToken: USER_MODULE_SETTINGS_TOKEN, + optionsToken: RAW_OPTIONS_TOKEN, + settingsKey: userDefaultConfig.KEY, + optionsOverrides, + }); } diff --git a/packages/nestjs-user/src/user.module.custom.spec.ts b/packages/nestjs-user/src/user.module.custom.spec.ts deleted file mode 100644 index 161671528..000000000 --- a/packages/nestjs-user/src/user.module.custom.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { UserModelServiceInterface } from './interfaces/user-model-service.interface'; -import { UserModelService } from './services/user-model.service'; -import { UserPasswordService } from './services/user-password.service'; - -import { AppModuleCustomFixture } from './__fixtures__/app.module.custom.fixture'; -import { UserModelCustomService } from './__fixtures__/services/user-model.custom.service'; -import { UserModuleCustomFixture } from './__fixtures__/user.module.custom.fixture'; - -describe('AppModule', () => { - let testModule: TestingModule; - let userModule: UserModuleCustomFixture; - let userModelService: UserModelServiceInterface; - let userModelCustomService: UserModelCustomService; - let userPasswordService: UserPasswordService; - - beforeEach(async () => { - testModule = await Test.createTestingModule({ - imports: [AppModuleCustomFixture], - }).compile(); - - userModule = testModule.get( - UserModuleCustomFixture, - ); - userModelService = testModule.get(UserModelService); - userModelCustomService = testModule.get( - UserModelCustomService, - ); - userPasswordService = - testModule.get(UserPasswordService); - }); - - afterEach(async () => { - jest.clearAllMocks(); - if (testModule) await testModule.close(); - }); - - describe('module', () => { - it('should be loaded', async () => { - expect(userModule).toBeInstanceOf(UserModuleCustomFixture); - expect(userModelService).toBeInstanceOf(UserModelService); - expect(userModelCustomService).toBeInstanceOf(UserModelCustomService); - expect(userPasswordService).toBeInstanceOf(UserPasswordService); - }); - }); -}); diff --git a/packages/nestjs-user/src/user.module.spec.ts b/packages/nestjs-user/src/user.module.spec.ts deleted file mode 100644 index 1b04a956a..000000000 --- a/packages/nestjs-user/src/user.module.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { - RepositoryInterface, - getDynamicRepositoryToken, -} from '@concepta/nestjs-common'; -import { - PasswordCreationService, - PasswordStorageService, -} from '@concepta/nestjs-password'; -import { TypeOrmRepositoryAdapter } from '@concepta/nestjs-typeorm-ext'; - -import { UserModelServiceInterface } from './interfaces/user-model-service.interface'; -import { UserAccessQueryService } from './services/user-access-query.service'; -import { UserModelService } from './services/user-model.service'; -import { UserPasswordHistoryModelService } from './services/user-password-history-model.service'; -import { UserPasswordHistoryService } from './services/user-password-history.service'; -import { UserPasswordService } from './services/user-password.service'; -import { - USER_MODULE_USER_ENTITY_KEY, - USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY, -} from './user.constants'; -import { UserModule } from './user.module'; - -import { AppModuleFixture } from './__fixtures__/app.module.fixture'; -import { UserEntityFixture } from './__fixtures__/user.entity.fixture'; - -describe('AppModule', () => { - let testModule: TestingModule; - let userModule: UserModule; - let userModelService: UserModelServiceInterface; - let userPasswordService: UserPasswordService; - let userPasswordHistoryService: UserPasswordHistoryService; - let userPasswordHistoryModelService: UserPasswordHistoryModelService; - let userAccessQueryService: UserAccessQueryService; - let userRepo: RepositoryInterface; - let userPasswordHistoryRepo: RepositoryInterface; - - beforeEach(async () => { - testModule = await Test.createTestingModule({ - imports: [AppModuleFixture], - }).compile(); - - userModule = testModule.get(UserModule); - userRepo = testModule.get( - getDynamicRepositoryToken(USER_MODULE_USER_ENTITY_KEY), - ); - userPasswordHistoryRepo = testModule.get( - getDynamicRepositoryToken(USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY), - ); - userModelService = testModule.get(UserModelService); - userPasswordService = - testModule.get(UserPasswordService); - userPasswordHistoryService = testModule.get( - UserPasswordHistoryService, - ); - userPasswordHistoryModelService = - testModule.get( - UserPasswordHistoryModelService, - ); - userAccessQueryService = testModule.get( - UserAccessQueryService, - ); - }); - - afterEach(async () => { - jest.clearAllMocks(); - if (testModule) await testModule.close(); - }); - - describe('module', () => { - it('should be loaded', async () => { - expect(userModule).toBeInstanceOf(UserModule); - expect(userRepo).toBeInstanceOf(TypeOrmRepositoryAdapter); - expect(userPasswordHistoryRepo).toBeInstanceOf(TypeOrmRepositoryAdapter); - expect(userModelService).toBeInstanceOf(UserModelService); - expect(userPasswordService).toBeInstanceOf(UserPasswordService); - expect(userPasswordService['userModelService']).toBeInstanceOf( - UserModelService, - ); - expect(userPasswordService['passwordCreationService']).toBeInstanceOf( - PasswordCreationService, - ); - expect(userPasswordService['passwordStorageService']).toBeInstanceOf( - PasswordStorageService, - ); - expect(userPasswordService['userPasswordHistoryService']).toBeInstanceOf( - UserPasswordHistoryService, - ); - expect(userPasswordHistoryService).toBeInstanceOf( - UserPasswordHistoryService, - ); - expect( - userPasswordHistoryService['userPasswordHistoryModelService'], - ).toBeInstanceOf(UserPasswordHistoryModelService); - expect(userPasswordHistoryModelService).toBeInstanceOf( - UserPasswordHistoryModelService, - ); - expect( - userPasswordHistoryModelService['userPasswordHistoryRepo'], - ).toBeInstanceOf(TypeOrmRepositoryAdapter); - expect(userAccessQueryService).toBeInstanceOf(UserAccessQueryService); - }); - }); -}); diff --git a/packages/nestjs-user/src/user.module.ts b/packages/nestjs-user/src/user.module.ts index 887270565..1e6964c12 100644 --- a/packages/nestjs-user/src/user.module.ts +++ b/packages/nestjs-user/src/user.module.ts @@ -4,26 +4,26 @@ import { UserAsyncOptions, UserModuleClass, UserOptions, -} from './user.module-definition'; +} from './user.module-definition.js'; + +type UserRegistrationOptions = Omit; +type UserAsyncRegistrationOptions = Omit; -/** - * User Module - */ @Module({}) export class UserModule extends UserModuleClass { - static register(options: UserOptions): DynamicModule { - return super.register(options); + static register(options: UserRegistrationOptions): DynamicModule { + return super.register({ ...options, global: false }); } - static registerAsync(options: UserAsyncOptions): DynamicModule { - return super.registerAsync(options); + static registerAsync(options: UserAsyncRegistrationOptions): DynamicModule { + return super.registerAsync({ ...options, global: false }); } - static forRoot(options: UserOptions): DynamicModule { + static forRoot(options: UserRegistrationOptions): DynamicModule { return super.register({ ...options, global: true }); } - static forRootAsync(options: UserAsyncOptions): DynamicModule { + static forRootAsync(options: UserAsyncRegistrationOptions): DynamicModule { return super.registerAsync({ ...options, global: true }); } } diff --git a/packages/nestjs-user/src/user.seeder.ts b/packages/nestjs-user/src/user.seeder.ts deleted file mode 100644 index 466c21ce5..000000000 --- a/packages/nestjs-user/src/user.seeder.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { UserEntityInterface } from '@concepta/nestjs-common'; -import { PasswordStorageService } from '@concepta/nestjs-password'; -import { Seeder } from '@concepta/typeorm-seeding'; - -import { UserFactory } from './user.factory'; - -/** - * User seeder - */ -export class UserSeeder extends Seeder { - /** - * Reusable password storage service - */ - private passwordStorageService = new PasswordStorageService(); - - /** - * Runner - */ - public async run(): Promise { - // number of users to create - const createAmount = process.env?.USER_MODULE_SEEDER_AMOUNT - ? Number(process.env.USER_MODULE_SEEDER_AMOUNT) - : 50; - - // super admin username - const superadmin = process.env?.USER_MODULE_SEEDER_SUPERADMIN_USERNAME - ? process.env?.USER_MODULE_SEEDER_SUPERADMIN_USERNAME - : 'superadmin'; - - // the factory - const userFactory = this.factory(UserFactory); - - // create a super admin user - await userFactory - .map(async (user) => this.setPassword(user)) - .create({ - username: superadmin, - }); - - // create a bunch more - await userFactory - .map(async (user) => this.setPassword(user)) - .createMany(createAmount); - } - - /** - * Set a password for the given user. - * - * @param user - Object implementing the required interface. - * @param password - The password to set. - */ - protected async setPassword( - user: UserEntityInterface, - password = 'Test1234', - ) { - // hash it - const hashed = await this.passwordStorageService.hash(password); - - // set password and salt - user.passwordHash = hashed.passwordHash; - user.passwordSalt = hashed.passwordSalt; - } -} diff --git a/packages/nestjs-user/src/user.types.spec.ts b/packages/nestjs-user/src/user.types.spec.ts deleted file mode 100644 index 40e8cae86..000000000 --- a/packages/nestjs-user/src/user.types.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { UserResource } from './user.types'; - -describe('User Types', () => { - describe('UserResource enum', () => { - it('should match', async () => { - expect(UserResource.One).toEqual('user'); - expect(UserResource.Many).toEqual('user-list'); - }); - }); -}); diff --git a/packages/nestjs-user/src/user.types.ts b/packages/nestjs-user/src/user.types.ts deleted file mode 100644 index c78527c06..000000000 --- a/packages/nestjs-user/src/user.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export enum UserResource { - 'One' = 'user', - 'Many' = 'user-list', -} - -export enum UserProfileResource { - 'One' = 'user-profile', - 'Many' = 'user-profile-list', -} diff --git a/packages/nestjs-user/src/utils/user-profile.crud-builder.e2e-spec.ts b/packages/nestjs-user/src/utils/user-profile.crud-builder.e2e-spec.ts deleted file mode 100644 index a94cedc96..000000000 --- a/packages/nestjs-user/src/utils/user-profile.crud-builder.e2e-spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import supertest from 'supertest'; - -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; - -import { SeedingSource } from '@concepta/typeorm-seeding'; - -import { UserProfileFactory } from '../user-profile.factory'; -import { UserProfileSeeder } from '../user-profile.seeder'; -import { UserFactory } from '../user.factory'; - -import { AppModuleUserProfileFixture } from '../__fixtures__/app.module.user-profile.fixture'; -import { UserProfileEntityFixture } from '../__fixtures__/user-profile.entity.fixture'; -import { UserEntityFixture } from '../__fixtures__/user.entity.fixture'; - -describe('User Profile Crud Builder (e2e)', () => { - let app: INestApplication; - let seedingSource: SeedingSource; - - const userFactory = new UserFactory({ - entity: UserEntityFixture, - }); - - const userProfileFactory = new UserProfileFactory({ - entity: UserProfileEntityFixture, - factories: [userFactory], - }); - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModuleUserProfileFixture], - }).compile(); - app = moduleFixture.createNestApplication(); - await app.init(); - - seedingSource = new SeedingSource({ - dataSource: app.get(getDataSourceToken()), - }); - - await seedingSource.initialize(); - - const userProfileSeeder = new UserProfileSeeder({ - factories: [userProfileFactory], - }); - - await seedingSource.run.one(userProfileSeeder); - }); - - afterEach(async () => { - jest.clearAllMocks(); - return app ? await app.close() : undefined; - }); - - it('GET /user-profile', async () => { - const response = await supertest(app.getHttpServer()) - .get('/user-profile?limit=10') - .expect(200) - .expect((res) => res.body.data.length === 10); - expect(response); - }); - - it('GET /user-profile/:id', async () => { - // get an user so we have an id - const response = await supertest(app.getHttpServer()) - .get('/user-profile?limit=1') - .expect(200); - - // get one using that id - await supertest(app.getHttpServer()) - .get(`/user-profile/${response.body.data[0].id}`) - .expect(200); - }); - - it('POST /user-profile', async () => { - // need an user - const user = await userFactory.create(); - - await supertest(app.getHttpServer()) - .post('/user-profile') - .send({ userId: user.id, firstName: 'Foo' }) - .expect(201); - }); - - it('Patch /user-profile/:id - should update an existing user profile', async () => { - // need an user - const user = await userFactory.create(); - - // create user profile first - const createResponse = await supertest(app.getHttpServer()) - .post('/user-profile') - .send({ userId: user.id, firstName: 'Bar' }) - .expect(201); - - const id = createResponse.body.id; - const updatedResponse = await supertest(app.getHttpServer()) - .patch(`/user-profile/${id}`) - .send({ - firstName: 'Updated Profile', - }) - .expect(200); - - expect(updatedResponse.body.firstName).toEqual('Updated Profile'); - }); - - it('DELETE /user-profile/:id', async () => { - // get an user so we have an id - const response = await supertest(app.getHttpServer()) - .get('/user-profile?limit=1') - .expect(200); - - // delete one using that id - await supertest(app.getHttpServer()) - .delete(`/user-profile/${response.body.data[0].id}`) - .expect(200); - }); -}); diff --git a/packages/nestjs-user/src/utils/user-profile.crud-builder.ts b/packages/nestjs-user/src/utils/user-profile.crud-builder.ts deleted file mode 100644 index 984a704c8..000000000 --- a/packages/nestjs-user/src/utils/user-profile.crud-builder.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { PlainLiteralObject } from '@nestjs/common'; - -import { - DeepPartial, - UserProfileCreatableInterface, - UserProfileEntityInterface, -} from '@concepta/nestjs-common'; -import { - ConfigurableCrudBuilder, - ConfigurableCrudOptions, -} from '@concepta/nestjs-crud'; - -export class UserProfileCrudBuilder< - Entity extends UserProfileEntityInterface = UserProfileEntityInterface, - Creatable extends DeepPartial & - UserProfileCreatableInterface = DeepPartial & - UserProfileCreatableInterface, - Updatable extends DeepPartial = DeepPartial, - Replaceable extends Creatable = Creatable, - ExtraOptions extends PlainLiteralObject = PlainLiteralObject, -> extends ConfigurableCrudBuilder< - Entity, - Creatable, - Updatable, - Replaceable, - ExtraOptions -> { - constructor(options: ConfigurableCrudOptions) { - super(options); - } -} diff --git a/packages/nestjs-user/tsconfig.json b/packages/nestjs-user/tsconfig.json index 0fe61fc61..eb511332e 100644 --- a/packages/nestjs-user/tsconfig.json +++ b/packages/nestjs-user/tsconfig.json @@ -4,10 +4,13 @@ "composite": true, "rootDir": "./src", "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", "typeRoots": [ "./node_modules/@types", - "../../node_modules/@types" - ] + "../../node_modules/@types", + "../../node_modules" + ], + "types": ["node", "vitest/globals"] }, "include": [ "src/**/*.ts" diff --git a/packages/typeorm-common/README.md b/packages/typeorm-common/README.md deleted file mode 100644 index e407c6ef6..000000000 --- a/packages/typeorm-common/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Rockets TypeORM Common - -THIS PACKAGE IS DEPRECATED AND SHOULD NO LONGER BE PUBLISHED - -The common module contains commonly used TypeORM embeds, utilities. - -## Project - -[![NPM Latest](https://img.shields.io/npm/v/@concepta/typeorm-common)](https://www.npmjs.com/package/@concepta/typeorm-common) -[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/typeorm-common)](https://www.npmjs.com/package/@concepta/typeorm-common) -[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) -[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) - -## Installation - -`yarn add @concepta/typeorm-common` diff --git a/packages/typeorm-common/package.json b/packages/typeorm-common/package.json deleted file mode 100644 index 3eda98156..000000000 --- a/packages/typeorm-common/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@concepta/typeorm-common", - "version": "7.0.0-alpha.10", - "description": "Rockets TypeORM Common", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "license": "BSD-3-Clause", - "private": true, - "files": [ - "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" - ], - "dependencies": { - "@faker-js/faker": "^8.4.1", - "@nestjs/common": "^11.1.9" - }, - "devDependencies": { - "@concepta/nestjs-common": "^7.0.0-alpha.10", - "@concepta/nestjs-typeorm-ext": "^7.0.0-alpha.10", - "@concepta/typeorm-seeding": "^4.0.0", - "@faker-js/faker": "^8.4.1", - "@nestjs/typeorm": "^11.0.0", - "jest-mock-extended": "^4.0.0" - }, - "peerDependencies": { - "@nestjs/testing": "^10.4.1", - "class-transformer": "*", - "class-validator": "*", - "typeorm": "^0.3.0" - } -} diff --git a/packages/typeorm-common/src/index.ts b/packages/typeorm-common/src/index.ts deleted file mode 100644 index 4b4925260..000000000 --- a/packages/typeorm-common/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** - * @deprecated - the typeorm-common module is deprecated, refer to the nestjs-typeorm-ext model - */ diff --git a/packages/typeorm-common/src/interfaces/entity-manager-option.interface.ts b/packages/typeorm-common/src/interfaces/entity-manager-option.interface.ts deleted file mode 100644 index c39d581fd..000000000 --- a/packages/typeorm-common/src/interfaces/entity-manager-option.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EntityManager } from 'typeorm'; - -export interface EntityManagerOptionInterface { - entityManager?: EntityManager; -} diff --git a/packages/typeorm-common/src/interfaces/query-options.interface.ts b/packages/typeorm-common/src/interfaces/query-options.interface.ts deleted file mode 100644 index 87c7a779c..000000000 --- a/packages/typeorm-common/src/interfaces/query-options.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { TransactionProxy } from '../proxies/transaction.proxy'; - -export interface QueryOptionsInterface { - transaction?: TransactionProxy; -} diff --git a/packages/typeorm-common/src/interfaces/safe-transaction-options.interface.ts b/packages/typeorm-common/src/interfaces/safe-transaction-options.interface.ts deleted file mode 100644 index d5f096e0b..000000000 --- a/packages/typeorm-common/src/interfaces/safe-transaction-options.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TransactionProxy } from '../proxies/transaction.proxy'; -import { IsolationLevel } from '../types'; - -export interface SafeTransactionOptionsInterface { - strict?: boolean; - isolationLevel?: IsolationLevel; - transaction?: TransactionProxy; -} diff --git a/packages/typeorm-common/src/proxies/entity-manager.proxy.spec.ts b/packages/typeorm-common/src/proxies/entity-manager.proxy.spec.ts deleted file mode 100644 index c7bf05dd3..000000000 --- a/packages/typeorm-common/src/proxies/entity-manager.proxy.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { mock } from 'jest-mock-extended'; -import { EntityManager, Repository } from 'typeorm'; - -import { EntityManagerProxy } from './entity-manager.proxy'; -import { TransactionProxy } from './transaction.proxy'; - -class TestEntity {} -describe(EntityManagerProxy.name, () => { - let entityManager: EntityManager; - let entityManagerProxy: EntityManagerProxy; - let repositoryMock: Repository; - - beforeEach(() => { - entityManager = mock(); - entityManagerProxy = new EntityManagerProxy(entityManager); - repositoryMock = mock>(); - }); - - describe('entityManager()', () => { - it('should return the injected EntityManager', () => { - const result = entityManagerProxy.entityManager(); - expect(result).toBe(entityManager); - }); - }); - - describe('repository()', () => { - it('should return the original repository if no options provided', () => { - const result = entityManagerProxy.repository(repositoryMock); - expect(result).toBe(repositoryMock); - }); - - it('should return a repository from a transaction if transaction option provided', async () => { - const transactionProxy = mock(); - const transactionRepository = mock>(); - await transactionProxy.repository(repositoryMock); - - jest.spyOn(transactionProxy, 'repository').mockImplementationOnce(() => { - return transactionRepository; - }); - - const options = { transaction: transactionProxy }; - const result = entityManagerProxy.repository(repositoryMock, options); - expect(result).toBe(transactionRepository); - }); - }); - - describe('transaction()', () => { - it('should create a TransactionProxy with the EntityManager', () => { - const result = entityManagerProxy.transaction(); - expect(result).toBeInstanceOf(TransactionProxy); - }); - }); -}); diff --git a/packages/typeorm-common/src/proxies/entity-manager.proxy.ts b/packages/typeorm-common/src/proxies/entity-manager.proxy.ts deleted file mode 100644 index 6803f23e4..000000000 --- a/packages/typeorm-common/src/proxies/entity-manager.proxy.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { EntityManager, ObjectLiteral, Repository } from 'typeorm'; - -import { EntityManagerOptionInterface } from '../interfaces/entity-manager-option.interface'; -import { QueryOptionsInterface } from '../interfaces/query-options.interface'; -import { SafeTransactionOptionsInterface } from '../interfaces/safe-transaction-options.interface'; - -import { TransactionProxy } from './transaction.proxy'; - -export class EntityManagerProxy { - constructor(private _entityManager: EntityManager) {} - - entityManager() { - return this._entityManager; - } - - repository( - repository: Repository, - options?: QueryOptionsInterface & EntityManagerOptionInterface, - ): Repository { - if (options?.transaction) { - return options.transaction.repository(repository); - } else if ( - options?.entityManager && - options?.entityManager !== repository.manager - ) { - return options.entityManager.withRepository>(repository); - } else { - return repository; - } - } - - transaction(options?: SafeTransactionOptionsInterface): TransactionProxy { - return new TransactionProxy(this._entityManager, options); - } -} diff --git a/packages/typeorm-common/src/proxies/repository.proxy.ts b/packages/typeorm-common/src/proxies/repository.proxy.ts deleted file mode 100644 index 4cc525563..000000000 --- a/packages/typeorm-common/src/proxies/repository.proxy.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ObjectLiteral, Repository } from 'typeorm'; - -import { SafeTransactionOptionsInterface } from '../interfaces/safe-transaction-options.interface'; - -import { EntityManagerProxy } from './entity-manager.proxy'; -import { TransactionProxy } from './transaction.proxy'; - -export class RepositoryProxy { - private entityManagerProxy: EntityManagerProxy; - - constructor(private targetRepository: Repository) { - this.entityManagerProxy = new EntityManagerProxy(targetRepository.manager); - } - - entityManager() { - return this.entityManagerProxy.entityManager(); - } - - repository(): Repository { - return this.entityManagerProxy.repository(this.targetRepository); - } - - transaction(options?: SafeTransactionOptionsInterface): TransactionProxy { - return this.entityManagerProxy.transaction(options); - } -} diff --git a/packages/typeorm-common/src/proxies/transaction.proxy.ts b/packages/typeorm-common/src/proxies/transaction.proxy.ts deleted file mode 100644 index e26465b8c..000000000 --- a/packages/typeorm-common/src/proxies/transaction.proxy.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { EntityManager, ObjectLiteral, Repository } from 'typeorm'; - -import { SafeTransactionOptionsInterface } from '../interfaces/safe-transaction-options.interface'; -import { RunInTransactionCallback } from '../typeorm-common.types'; -import { safeTransaction } from '../utils/safe-transaction.util'; - -import { EntityManagerProxy } from './entity-manager.proxy'; - -type TransactionCallback = - | (() => Promise) - | ((transaction: TransactionProxy) => Promise); - -export class TransactionProxy { - private entityManagerProxy: EntityManagerProxy; - private parentTransaction?: TransactionProxy; - private transactionalEntityManager?: EntityManager; - - constructor( - entityManager: EntityManager, - private options?: SafeTransactionOptionsInterface, - ) { - this.entityManagerProxy = new EntityManagerProxy(entityManager); - this.parentTransaction = this.options?.transaction; - } - - repository( - targetRepository: Repository, - ): Repository { - if (this.parentTransaction) { - return this.parentTransaction.repository(targetRepository); - } else { - return this.entityManagerProxy.repository(targetRepository, { - entityManager: this.transactionalEntityManager, - }); - } - } - - async commit(runInTransaction: TransactionCallback): Promise { - if (this.parentTransaction) { - return runInTransaction(this); - } else { - return safeTransaction( - this.entityManagerProxy.entityManager(), - this.callback(runInTransaction), - this.options, - ); - } - } - - private callback( - runInTransaction: TransactionCallback, - ): RunInTransactionCallback { - return async (entityManager: EntityManager | undefined) => { - this.transactionalEntityManager = entityManager; - return runInTransaction(this); - }; - } -} diff --git a/packages/typeorm-common/src/testing/utils/create-entity-manager.mock.ts b/packages/typeorm-common/src/testing/utils/create-entity-manager.mock.ts deleted file mode 100644 index 20f63809d..000000000 --- a/packages/typeorm-common/src/testing/utils/create-entity-manager.mock.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function createEntityManagerMock() { - const EntityManagerMock = class { - public connection = { driver: { transactionSupport: 'simple' } }; - async transaction(...args: Array) { - if (args[0] instanceof Function) { - return args[0](); - } else if (args[1] instanceof Function) { - return args[1](); - } - } - }; - - return new EntityManagerMock(); -} diff --git a/packages/typeorm-common/src/typeorm-common.types.ts b/packages/typeorm-common/src/typeorm-common.types.ts deleted file mode 100644 index 9020392df..000000000 --- a/packages/typeorm-common/src/typeorm-common.types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EntityManager } from 'typeorm'; - -export type RunInTransactionCallback = ( - entityManager: EntityManager | undefined, -) => Promise; diff --git a/packages/typeorm-common/src/types.ts b/packages/typeorm-common/src/types.ts deleted file mode 100644 index 9aa8c1f7c..000000000 --- a/packages/typeorm-common/src/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type IsolationLevel = - | 'READ UNCOMMITTED' - | 'READ COMMITTED' - | 'REPEATABLE READ' - | 'SERIALIZABLE'; diff --git a/packages/typeorm-common/src/utils/safe-transaction.util.ts b/packages/typeorm-common/src/utils/safe-transaction.util.ts deleted file mode 100644 index b1023a9cd..000000000 --- a/packages/typeorm-common/src/utils/safe-transaction.util.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { EntityManager } from 'typeorm'; - -import { Logger } from '@nestjs/common'; - -import { RuntimeException } from '@concepta/nestjs-common'; - -import { SafeTransactionOptionsInterface } from '../interfaces/safe-transaction-options.interface'; -import { RunInTransactionCallback } from '../typeorm-common.types'; - -/** - * Safe transaction wrapper. - * - * Use this utility method to detect if the entity manager's driver supports transactions. - * - * To silently ignore drivers that don't support transactions, set strict mode to false. - * In this case, your `runInTransaction` callback will receive `undefined` for the value - * of `entityManager`. - * - * @param entityManager - Entity manager instance - * @param runInTransaction - Transaction callback - * @param options - Options - */ -export async function safeTransaction( - entityManager: EntityManager, - runInTransaction: RunInTransactionCallback, - options: SafeTransactionOptionsInterface = { strict: true }, -): Promise { - // get the driver - const { driver } = entityManager.connection; - - // does the driver has transaction support? - if (driver.transactionSupport === 'none') { - // no... log some debug info - Logger.debug( - `Transactions are not supported for the ${driver.options.type} database type.`, - ); - - // is strict mode enabled? - if (options.strict === true) { - // yes, bail out - const error = new RuntimeException( - `Safe transaction wrapper was called with strict enabled,` + - ` and the ${driver.options.type} database does not support transactions.`, - ); - // log - Logger.error(error); - // throw - throw error; - } - - // run the transaction with undefined manager value - return runInTransaction(undefined); - } else { - // is an isolation level set? - if (options?.isolationLevel) { - // yes, enforce it - return entityManager.transaction( - options.isolationLevel, - runInTransaction, - ); - } else { - // run without isolation level specified - return entityManager.transaction(runInTransaction); - } - } -} diff --git a/packages/typeorm-common/tsconfig.json b/packages/typeorm-common/tsconfig.json deleted file mode 100644 index bb399af79..000000000 --- a/packages/typeorm-common/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": [ - "./node_modules/@types", - "../../node_modules/@types" - ] - }, - "include": [ - "src/**/*.ts" - ], - "references": [ - { - "path": "../nestjs-typeorm-ext" - } - ] -} diff --git a/packages/typeorm-common/typedoc.json b/packages/typeorm-common/typedoc.json deleted file mode 100644 index 944fda5ad..000000000 --- a/packages/typeorm-common/typedoc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "entryPoints": ["src/index.ts"] -} \ No newline at end of file diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs new file mode 100644 index 000000000..d1f3615e3 --- /dev/null +++ b/scripts/smoke-test.mjs @@ -0,0 +1,124 @@ +/** + * Smoke test: validates that each v8 package's ESM build loads correctly. + * + * Loads directly from each package's dist/ directory to test our actual + * build output, bypassing node_modules workspace resolution. + * + * Checks: + * 1. package.json has {"type":"module"} + * 2. exports map: all default (.js) and types (.d.ts) files exist on disk + * 3. Relative imports in dist/index.js have .js extensions + * 4. import(dist/index.js) loads and the expected Module class is a function + * 5. Each subpath entry (./aggregate, ./optional/crud, etc.) loads without error + */ +import { readFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +const PACKAGES = [ + { dir: 'nestjs-core', export: 'CoreModule' }, + { dir: 'nestjs-repository', export: 'RepositoryModule' }, + { dir: 'nestjs-repository-typeorm', export: 'TypeOrmRepositoryModule' }, + { dir: 'nestjs-crud', export: 'CrudModule' }, + { dir: 'nestjs-cache', export: 'CacheModule' }, + { dir: 'nestjs-otp', export: 'OtpModule' }, + { dir: 'nestjs-role', export: 'RoleModule' }, + { dir: 'nestjs-password', export: 'PasswordModule' }, + { dir: 'nestjs-user', export: 'UserModule' }, + { dir: 'nestjs-invitation', export: 'InvitationModule' }, + { dir: 'nestjs-federated', export: 'FederatedModule' }, + { dir: 'nestjs-authentication', export: 'AuthenticationModule' }, + { dir: 'nestjs-access-control', export: 'AccessControlModule' }, +]; + +let passed = 0; +let failed = 0; + +process.stdout.write('smoke-test: validating ESM build\n\n'); + +for (const { dir, export: exportName } of PACKAGES) { + const pkgDir = join(ROOT, 'packages', dir); + const distDir = join(pkgDir, 'dist'); + const indexPath = join(distDir, 'index.js'); + + // 1. Type marker + const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')); + if (pkg.type !== 'module') { + process.stdout.write(` FAIL ${dir} — package.json has type "${pkg.type}", expected "module"\n`); + failed++; + continue; + } + + // 2. Walk exports map: verify all default (.js) and types (.d.ts) files exist on disk + let mapOk = true; + for (const [subpath, entry] of Object.entries(pkg.exports ?? {})) { + const jsFile = entry?.default; + const dtsFile = entry?.types; + if (jsFile && !existsSync(join(pkgDir, jsFile))) { + process.stdout.write(` FAIL ${dir} — exports["${subpath}"].default not on disk (run yarn build first): ${jsFile}\n`); + failed++; + mapOk = false; + } + if (dtsFile && !existsSync(join(pkgDir, dtsFile))) { + process.stdout.write(` FAIL ${dir} — exports["${subpath}"].types not on disk: ${dtsFile}\n`); + failed++; + mapOk = false; + } + } + if (!mapOk) continue; + + // 3. Spot-check .js extensions on relative imports in index.js + if (existsSync(indexPath)) { + const content = readFileSync(indexPath, 'utf8'); + const bare = (content.match(/from ['"](\.[^'"]+)['"]/g) ?? []).filter( + (m) => !/\.[cm]?js['"]/.test(m), + ); + if (bare.length > 0) { + process.stdout.write( + ` FAIL ${dir} — dist/index.js has extensionless imports: ${bare.slice(0, 2).join(', ')}\n`, + ); + failed++; + continue; + } + } + + // 4. Dynamic import + main export check + try { + const mod = await import(pathToFileURL(indexPath).href); + if (typeof mod[exportName] !== 'function') { + process.stdout.write(` FAIL ${dir} — ${exportName} is ${typeof mod[exportName]}, expected function\n`); + failed++; + continue; + } + process.stdout.write(` pass ${dir} → ${exportName}\n`); + passed++; + } catch (err) { + process.stdout.write(` FAIL ${dir} — ${err.message.split('\n')[0]}\n`); + failed++; + continue; + } + + // 5. Load each subpath entry and verify it loads without error. + // ./testing subpaths use vitest/vitest-mock-extended and can only run inside a + // Vitest test run — files are already verified to exist on disk in step 2, so + // loading is skipped here. + for (const [subpath, entry] of Object.entries(pkg.exports ?? {})) { + if (subpath === '.' || subpath === './testing') continue; + const jsFile = entry?.default; + if (!jsFile) continue; + try { + await import(pathToFileURL(join(pkgDir, jsFile)).href); + process.stdout.write(` pass ${dir} ${subpath}\n`); + passed++; + } catch (err) { + process.stdout.write(` FAIL ${dir} ${subpath} — ${err.message.split('\n')[0]}\n`); + failed++; + } + } +} + +process.stdout.write(`\n${passed} passed, ${failed} failed\n`); +if (failed > 0) process.exit(1); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 94ad69666..c955e8549 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -1,6 +1,18 @@ { "extends": "./tsconfig.json", "include": [ - "packages/*/src/**/*.ts" + "packages/nestjs-core/src/**/*.ts", + "packages/nestjs-repository/src/**/*.ts", + "packages/nestjs-repository-typeorm/src/**/*.ts", + "packages/nestjs-crud/src/**/*.ts", + "packages/nestjs-cache/src/**/*.ts", + "packages/nestjs-otp/src/**/*.ts", + "packages/nestjs-role/src/**/*.ts", + "packages/nestjs-password/src/**/*.ts", + "packages/nestjs-user/src/**/*.ts", + "packages/nestjs-invitation/src/**/*.ts", + "packages/nestjs-federated/src/**/*.ts", + "packages/nestjs-authentication/src/**/*.ts", + "packages/nestjs-access-control/src/**/*.ts" ] -} \ No newline at end of file +} diff --git a/tsconfig.jest.json b/tsconfig.jest.json deleted file mode 100644 index 7824f36a6..000000000 --- a/tsconfig.jest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "strict": true, - "removeComments": false - } -} diff --git a/tsconfig.json b/tsconfig.json index 9e005c8ac..32f46ab01 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { "strict": true, - "module": "commonjs", + "module": "nodenext", + "moduleResolution": "nodenext", "target": "es2017", "sourceMap": true, "incremental": true, @@ -13,105 +14,24 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "typeRoots": ["./node_modules/@types"] + "typeRoots": [ + "./node_modules/@types" + ] }, "files": [], "references": [ - { - "path": "packages/nestjs-common" - }, - { - "path": "packages/nestjs-access-control" - }, - { - "path": "packages/nestjs-email" - }, - { - "path": "packages/nestjs-event" - }, - { - "path": "packages/nestjs-jwt" - }, - { - "path": "packages/nestjs-logger" - }, - { - "path": "packages/nestjs-authentication" - }, - { - "path": "packages/nestjs-password" - }, - { - "path": "packages/nestjs-auth-local" - }, - { - "path": "packages/nestjs-auth-jwt" - }, - { - "path": "packages/nestjs-auth-github" - }, - { - "path": "packages/nestjs-typeorm-ext" - }, - { - "path": "packages/nestjs-role" - }, - { - "path": "packages/nestjs-user" - }, - { - "path": "packages/nestjs-org" - }, - { - "path": "packages/nestjs-auth-refresh" - }, - { - "path": "packages/nestjs-swagger-ui" - }, - { - "path": "packages/nestjs-federated" - }, - { - "path": "packages/typeorm-common" - }, - { - "path": "packages/nestjs-otp" - }, - { - "path": "packages/nestjs-auth-recovery" - }, - { - "path": "packages/nestjs-invitation" - }, - { - "path": "packages/nestjs-cache" - }, - { - "path": "packages/nestjs-auth-google" - }, - { - "path": "packages/nestjs-logger-coralogix" - }, - { - "path": "packages/nestjs-logger-sentry" - }, - { - "path": "packages/nestjs-file" - }, - { - "path": "packages/nestjs-auth-apple" - }, - { - "path": "packages/nestjs-report" - }, - { - "path": "packages/nestjs-auth-verify" - }, - { - "path": "packages/nestjs-samples" - }, - { - "path": "packages/nestjs-auth-router" - } + { "path": "packages/nestjs-core" }, + { "path": "packages/nestjs-repository" }, + { "path": "packages/nestjs-repository-typeorm" }, + { "path": "packages/nestjs-crud" }, + { "path": "packages/nestjs-cache" }, + { "path": "packages/nestjs-otp" }, + { "path": "packages/nestjs-role" }, + { "path": "packages/nestjs-password" }, + { "path": "packages/nestjs-user" }, + { "path": "packages/nestjs-invitation" }, + { "path": "packages/nestjs-federated" }, + { "path": "packages/nestjs-authentication" }, + { "path": "packages/nestjs-access-control" } ] } diff --git a/typedoc.json b/typedoc.json index 229816657..5a5e8be23 100644 --- a/typedoc.json +++ b/typedoc.json @@ -1,8 +1,21 @@ { - "entryPoints": ["packages/*"], + "entryPoints": [ + "packages/nestjs-core", + "packages/nestjs-repository", + "packages/nestjs-repository-typeorm", + "packages/nestjs-crud", + "packages/nestjs-cache", + "packages/nestjs-otp", + "packages/nestjs-role", + "packages/nestjs-password", + "packages/nestjs-user", + "packages/nestjs-invitation", + "packages/nestjs-federated", + "packages/nestjs-authentication", + "packages/nestjs-access-control" + ], "plugin": ["typedoc-plugin-coverage"], - "exclude": ["packages/nestjs-samples"], "name": "Rockets Core Documentation", "entryPointStrategy": "packages", "includeVersion": false -} \ No newline at end of file +} diff --git a/vitest.config-e2e.ts b/vitest.config-e2e.ts new file mode 100644 index 000000000..f060a0af6 --- /dev/null +++ b/vitest.config-e2e.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: [ + 'packages/nestjs-core/**/*.e2e-spec.ts', + 'packages/nestjs-repository/**/*.e2e-spec.ts', + 'packages/nestjs-repository-typeorm/**/*.e2e-spec.ts', + 'packages/nestjs-crud/**/*.e2e-spec.ts', + 'packages/nestjs-cache/**/*.e2e-spec.ts', + 'packages/nestjs-otp/**/*.e2e-spec.ts', + 'packages/nestjs-role/**/*.e2e-spec.ts', + 'packages/nestjs-password/**/*.e2e-spec.ts', + 'packages/nestjs-user/**/*.e2e-spec.ts', + 'packages/nestjs-invitation/**/*.e2e-spec.ts', + 'packages/nestjs-federated/**/*.e2e-spec.ts', + 'packages/nestjs-authentication/**/*.e2e-spec.ts', + 'packages/nestjs-access-control/**/*.e2e-spec.ts', + ], + exclude: ['**/node_modules/**', '**/dist/**'], + testTimeout: 30000, + hookTimeout: 30000, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..75258b6d5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: [ + 'packages/nestjs-core/**/*.spec.ts', + 'packages/nestjs-repository/**/*.spec.ts', + 'packages/nestjs-repository-typeorm/**/*.spec.ts', + 'packages/nestjs-crud/**/*.spec.ts', + 'packages/nestjs-cache/**/*.spec.ts', + 'packages/nestjs-otp/**/*.spec.ts', + 'packages/nestjs-role/**/*.spec.ts', + 'packages/nestjs-password/**/*.spec.ts', + 'packages/nestjs-user/**/*.spec.ts', + 'packages/nestjs-invitation/**/*.spec.ts', + 'packages/nestjs-federated/**/*.spec.ts', + 'packages/nestjs-authentication/**/*.spec.ts', + 'packages/nestjs-access-control/**/*.spec.ts', + ], + exclude: ['**/node_modules/**', '**/dist/**'], + testTimeout: 30000, + hookTimeout: 30000, + }, +}); diff --git a/yarn.lock b/yarn.lock index 8e61daf56..fbd520c15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,956 +5,155 @@ __metadata: version: 8 cacheKey: 10c0 -"@angular-devkit/core@npm:19.2.15": - version: 19.2.15 - resolution: "@angular-devkit/core@npm:19.2.15" +"@angular-devkit/core@npm:22.1.5": + version: 22.1.5 + resolution: "@angular-devkit/core@npm:22.1.5" dependencies: - ajv: "npm:8.17.1" + ajv: "npm:8.20.0" ajv-formats: "npm:3.0.1" jsonc-parser: "npm:3.3.1" - picomatch: "npm:4.0.2" - rxjs: "npm:7.8.1" - source-map: "npm:0.7.4" + picomatch: "npm:4.0.5" + rxjs: "npm:7.8.2" + source-map: "npm:0.7.6" peerDependencies: - chokidar: ^4.0.0 + chokidar: ^5.0.0 peerDependenciesMeta: chokidar: optional: true - checksum: 10c0/ed37170b30e8ff19ab785e2c5b717efb6bb73c261e3fe6b27ac61bcb781c60fe545ac0589dd3eabe75cf24f055210b65f386a03e804b32effa191fc7c9512e63 + checksum: 10c0/fc32c22e06eb1602bc4248862690c8bc4a71aa23d204ac24c2ba7ee377a7659c7c15209b21ecf3087fa9c9f48966a866228e4cad7dcf7b7c7e6c79759455351a languageName: node linkType: hard -"@angular-devkit/core@npm:19.2.17": - version: 19.2.17 - resolution: "@angular-devkit/core@npm:19.2.17" +"@angular-devkit/schematics-cli@npm:22.1.5": + version: 22.1.5 + resolution: "@angular-devkit/schematics-cli@npm:22.1.5" dependencies: - ajv: "npm:8.17.1" - ajv-formats: "npm:3.0.1" - jsonc-parser: "npm:3.3.1" - picomatch: "npm:4.0.2" - rxjs: "npm:7.8.1" - source-map: "npm:0.7.4" - peerDependencies: - chokidar: ^4.0.0 - peerDependenciesMeta: - chokidar: - optional: true - checksum: 10c0/721c34da992e7060156c1e523703f754b64524d0212efbbdf9a88ef794ef3c9ebb8e8994743f013c3b99c0a9201362ed2a8ecc2979a1bb72a02b2a6cd4887699 - languageName: node - linkType: hard - -"@angular-devkit/schematics-cli@npm:19.2.15": - version: 19.2.15 - resolution: "@angular-devkit/schematics-cli@npm:19.2.15" - dependencies: - "@angular-devkit/core": "npm:19.2.15" - "@angular-devkit/schematics": "npm:19.2.15" - "@inquirer/prompts": "npm:7.3.2" - ansi-colors: "npm:4.1.3" - symbol-observable: "npm:4.0.0" - yargs-parser: "npm:21.1.1" + "@angular-devkit/core": "npm:22.1.5" + "@angular-devkit/schematics": "npm:22.1.5" + "@inquirer/prompts": "npm:8.5.2" bin: schematics: bin/schematics.js - checksum: 10c0/d866bc9be9b06d82083e57bed4608bf696b4e9bfdede6d2f588b1298ead1e4e95a9a5ff0f50c7f2f2228c15a3a0d3aa6bcd2548bf5331755a7352e23f4b0a227 - languageName: node - linkType: hard - -"@angular-devkit/schematics@npm:19.2.15": - version: 19.2.15 - resolution: "@angular-devkit/schematics@npm:19.2.15" - dependencies: - "@angular-devkit/core": "npm:19.2.15" - jsonc-parser: "npm:3.3.1" - magic-string: "npm:0.30.17" - ora: "npm:5.4.1" - rxjs: "npm:7.8.1" - checksum: 10c0/363ae06957c1e05a00351c283f00da113d71a9e621f9233146601db936a329f95772867ca09c7693d7db4eec8c6c1756048984e6a299515e7f164f874ea8d3a4 + checksum: 10c0/9c96c7ed681d1cd64020cc097d326710348635b5310c18ee9b7198a6aabed28153b3499e09286a9016dd719349c60cadb11025cc305893805cbf80bf5199c136 languageName: node linkType: hard -"@angular-devkit/schematics@npm:19.2.17": - version: 19.2.17 - resolution: "@angular-devkit/schematics@npm:19.2.17" +"@angular-devkit/schematics@npm:22.1.5": + version: 22.1.5 + resolution: "@angular-devkit/schematics@npm:22.1.5" dependencies: - "@angular-devkit/core": "npm:19.2.17" + "@angular-devkit/core": "npm:22.1.5" jsonc-parser: "npm:3.3.1" - magic-string: "npm:0.30.17" - ora: "npm:5.4.1" - rxjs: "npm:7.8.1" - checksum: 10c0/393d2148f2a75efdeeadad7cb47bb55cf490c56928cec5f9acb18cd8098aa7a8de48e6e8f5063431a6fd7df569e0fb75bb0cfeb8a9a6e7924e6be625e1779b6f - languageName: node - linkType: hard - -"@aws-crypto/sha256-browser@npm:5.2.0": - version: 5.2.0 - resolution: "@aws-crypto/sha256-browser@npm:5.2.0" - dependencies: - "@aws-crypto/sha256-js": "npm:^5.2.0" - "@aws-crypto/supports-web-crypto": "npm:^5.2.0" - "@aws-crypto/util": "npm:^5.2.0" - "@aws-sdk/types": "npm:^3.222.0" - "@aws-sdk/util-locate-window": "npm:^3.0.0" - "@smithy/util-utf8": "npm:^2.0.0" - tslib: "npm:^2.6.2" - checksum: 10c0/05f6d256794df800fe9aef5f52f2ac7415f7f3117d461f85a6aecaa4e29e91527b6fd503681a17136fa89e9dd3d916e9c7e4cfb5eba222875cb6c077bdc1d00d - languageName: node - linkType: hard - -"@aws-crypto/sha256-js@npm:5.2.0, @aws-crypto/sha256-js@npm:^5.2.0": - version: 5.2.0 - resolution: "@aws-crypto/sha256-js@npm:5.2.0" - dependencies: - "@aws-crypto/util": "npm:^5.2.0" - "@aws-sdk/types": "npm:^3.222.0" - tslib: "npm:^2.6.2" - checksum: 10c0/6c48701f8336341bb104dfde3d0050c89c288051f6b5e9bdfeb8091cf3ffc86efcd5c9e6ff2a4a134406b019c07aca9db608128f8d9267c952578a3108db9fd1 - languageName: node - linkType: hard - -"@aws-crypto/supports-web-crypto@npm:^5.2.0": - version: 5.2.0 - resolution: "@aws-crypto/supports-web-crypto@npm:5.2.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/4d2118e29d68ca3f5947f1e37ce1fbb3239a0c569cc938cdc8ab8390d595609b5caf51a07c9e0535105b17bf5c52ea256fed705a07e9681118120ab64ee73af2 - languageName: node - linkType: hard - -"@aws-crypto/util@npm:^5.2.0": - version: 5.2.0 - resolution: "@aws-crypto/util@npm:5.2.0" - dependencies: - "@aws-sdk/types": "npm:^3.222.0" - "@smithy/util-utf8": "npm:^2.0.0" - tslib: "npm:^2.6.2" - checksum: 10c0/0362d4c197b1fd64b423966945130207d1fe23e1bb2878a18e361f7743c8d339dad3f8729895a29aa34fff6a86c65f281cf5167c4bf253f21627ae80b6dd2951 - languageName: node - linkType: hard - -"@aws-sdk/client-ses@npm:^3.731.1": - version: 3.931.0 - resolution: "@aws-sdk/client-ses@npm:3.931.0" - dependencies: - "@aws-crypto/sha256-browser": "npm:5.2.0" - "@aws-crypto/sha256-js": "npm:5.2.0" - "@aws-sdk/core": "npm:3.931.0" - "@aws-sdk/credential-provider-node": "npm:3.931.0" - "@aws-sdk/middleware-host-header": "npm:3.930.0" - "@aws-sdk/middleware-logger": "npm:3.930.0" - "@aws-sdk/middleware-recursion-detection": "npm:3.930.0" - "@aws-sdk/middleware-user-agent": "npm:3.931.0" - "@aws-sdk/region-config-resolver": "npm:3.930.0" - "@aws-sdk/types": "npm:3.930.0" - "@aws-sdk/util-endpoints": "npm:3.930.0" - "@aws-sdk/util-user-agent-browser": "npm:3.930.0" - "@aws-sdk/util-user-agent-node": "npm:3.931.0" - "@smithy/config-resolver": "npm:^4.4.3" - "@smithy/core": "npm:^3.18.2" - "@smithy/fetch-http-handler": "npm:^5.3.6" - "@smithy/hash-node": "npm:^4.2.5" - "@smithy/invalid-dependency": "npm:^4.2.5" - "@smithy/middleware-content-length": "npm:^4.2.5" - "@smithy/middleware-endpoint": "npm:^4.3.9" - "@smithy/middleware-retry": "npm:^4.4.9" - "@smithy/middleware-serde": "npm:^4.2.5" - "@smithy/middleware-stack": "npm:^4.2.5" - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/node-http-handler": "npm:^4.4.5" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/smithy-client": "npm:^4.9.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/url-parser": "npm:^4.2.5" - "@smithy/util-base64": "npm:^4.3.0" - "@smithy/util-body-length-browser": "npm:^4.2.0" - "@smithy/util-body-length-node": "npm:^4.2.1" - "@smithy/util-defaults-mode-browser": "npm:^4.3.8" - "@smithy/util-defaults-mode-node": "npm:^4.2.11" - "@smithy/util-endpoints": "npm:^3.2.5" - "@smithy/util-middleware": "npm:^4.2.5" - "@smithy/util-retry": "npm:^4.2.5" - "@smithy/util-utf8": "npm:^4.2.0" - "@smithy/util-waiter": "npm:^4.2.5" - tslib: "npm:^2.6.2" - checksum: 10c0/dc75dd6268b4d4d1b474917ec61e2f2c29685631155bd127a136f7cab540b385a49ebb8146e00aec3502a6f33977c9d444e7e21fcdcbc499bb3b00db18fd57fe - languageName: node - linkType: hard - -"@aws-sdk/client-sso@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/client-sso@npm:3.931.0" - dependencies: - "@aws-crypto/sha256-browser": "npm:5.2.0" - "@aws-crypto/sha256-js": "npm:5.2.0" - "@aws-sdk/core": "npm:3.931.0" - "@aws-sdk/middleware-host-header": "npm:3.930.0" - "@aws-sdk/middleware-logger": "npm:3.930.0" - "@aws-sdk/middleware-recursion-detection": "npm:3.930.0" - "@aws-sdk/middleware-user-agent": "npm:3.931.0" - "@aws-sdk/region-config-resolver": "npm:3.930.0" - "@aws-sdk/types": "npm:3.930.0" - "@aws-sdk/util-endpoints": "npm:3.930.0" - "@aws-sdk/util-user-agent-browser": "npm:3.930.0" - "@aws-sdk/util-user-agent-node": "npm:3.931.0" - "@smithy/config-resolver": "npm:^4.4.3" - "@smithy/core": "npm:^3.18.2" - "@smithy/fetch-http-handler": "npm:^5.3.6" - "@smithy/hash-node": "npm:^4.2.5" - "@smithy/invalid-dependency": "npm:^4.2.5" - "@smithy/middleware-content-length": "npm:^4.2.5" - "@smithy/middleware-endpoint": "npm:^4.3.9" - "@smithy/middleware-retry": "npm:^4.4.9" - "@smithy/middleware-serde": "npm:^4.2.5" - "@smithy/middleware-stack": "npm:^4.2.5" - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/node-http-handler": "npm:^4.4.5" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/smithy-client": "npm:^4.9.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/url-parser": "npm:^4.2.5" - "@smithy/util-base64": "npm:^4.3.0" - "@smithy/util-body-length-browser": "npm:^4.2.0" - "@smithy/util-body-length-node": "npm:^4.2.1" - "@smithy/util-defaults-mode-browser": "npm:^4.3.8" - "@smithy/util-defaults-mode-node": "npm:^4.2.11" - "@smithy/util-endpoints": "npm:^3.2.5" - "@smithy/util-middleware": "npm:^4.2.5" - "@smithy/util-retry": "npm:^4.2.5" - "@smithy/util-utf8": "npm:^4.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/bda0a7535665a214c80a5b033995fa62acfa3c39f62c7c760ea65c1995302601748d0c3fc71b33b97a1ef4aa91aad73e52df5cacf2903dd8873174f1a6357ed2 - languageName: node - linkType: hard - -"@aws-sdk/core@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/core@npm:3.931.0" - dependencies: - "@aws-sdk/types": "npm:3.930.0" - "@aws-sdk/xml-builder": "npm:3.930.0" - "@smithy/core": "npm:^3.18.2" - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/signature-v4": "npm:^5.3.5" - "@smithy/smithy-client": "npm:^4.9.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/util-base64": "npm:^4.3.0" - "@smithy/util-middleware": "npm:^4.2.5" - "@smithy/util-utf8": "npm:^4.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/db0da6775199f53d8e9ea6689c47473d7292f4edaac18073268647625ca1555d8993707dd21c02c39f591bde0d6ef4ab1f9c230e2e9b457027efa317586d0579 - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-env@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/credential-provider-env@npm:3.931.0" - dependencies: - "@aws-sdk/core": "npm:3.931.0" - "@aws-sdk/types": "npm:3.930.0" - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/fad42e214a1f26868788d1ecc27e06a8784cd75e784661c273170369f05c853121b834d7a58727a84c2913e711a1154b6de711e83d4dd2ad43dbc6fb03476023 - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-http@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/credential-provider-http@npm:3.931.0" - dependencies: - "@aws-sdk/core": "npm:3.931.0" - "@aws-sdk/types": "npm:3.930.0" - "@smithy/fetch-http-handler": "npm:^5.3.6" - "@smithy/node-http-handler": "npm:^4.4.5" - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/smithy-client": "npm:^4.9.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/util-stream": "npm:^4.5.6" - tslib: "npm:^2.6.2" - checksum: 10c0/d0cc959dd25923a695c36625f414f0e65cb01f3069bd7e8145b1f971ad50addc157e69db313daed3579007e28f49d87abb372423fad2f3f7cf22b54911b24831 - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-ini@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/credential-provider-ini@npm:3.931.0" - dependencies: - "@aws-sdk/core": "npm:3.931.0" - "@aws-sdk/credential-provider-env": "npm:3.931.0" - "@aws-sdk/credential-provider-http": "npm:3.931.0" - "@aws-sdk/credential-provider-process": "npm:3.931.0" - "@aws-sdk/credential-provider-sso": "npm:3.931.0" - "@aws-sdk/credential-provider-web-identity": "npm:3.931.0" - "@aws-sdk/nested-clients": "npm:3.931.0" - "@aws-sdk/types": "npm:3.930.0" - "@smithy/credential-provider-imds": "npm:^4.2.5" - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/shared-ini-file-loader": "npm:^4.4.0" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/e47edd1ce32debca62d0fe3237c65de3e8f937b3b5694a5f7f90c2247e3256b9e7fa3371ab5d696ca5a34a76b7e9904bbbbe46e981eebeb24e76c902ec16de2e - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-node@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/credential-provider-node@npm:3.931.0" - dependencies: - "@aws-sdk/credential-provider-env": "npm:3.931.0" - "@aws-sdk/credential-provider-http": "npm:3.931.0" - "@aws-sdk/credential-provider-ini": "npm:3.931.0" - "@aws-sdk/credential-provider-process": "npm:3.931.0" - "@aws-sdk/credential-provider-sso": "npm:3.931.0" - "@aws-sdk/credential-provider-web-identity": "npm:3.931.0" - "@aws-sdk/types": "npm:3.930.0" - "@smithy/credential-provider-imds": "npm:^4.2.5" - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/shared-ini-file-loader": "npm:^4.4.0" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/bfb113fb2d67a815ee7594b22d9c0b4d17c71e8a95f87f37dd24b1bf447c649c8a8480e18ada953e7ef8bbfb8bd3afe5393aff57f10b44366f755af548e424ef - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-process@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/credential-provider-process@npm:3.931.0" - dependencies: - "@aws-sdk/core": "npm:3.931.0" - "@aws-sdk/types": "npm:3.930.0" - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/shared-ini-file-loader": "npm:^4.4.0" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/87122b97d11c12faf880053bddff76aa37b71c23efbde05cb6cc6d74b942839b58c2d1bf2e5773711544e2e12127756604670a011c499a9a1874ce014551063d - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-sso@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/credential-provider-sso@npm:3.931.0" - dependencies: - "@aws-sdk/client-sso": "npm:3.931.0" - "@aws-sdk/core": "npm:3.931.0" - "@aws-sdk/token-providers": "npm:3.931.0" - "@aws-sdk/types": "npm:3.930.0" - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/shared-ini-file-loader": "npm:^4.4.0" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/de7508188bde34680c5d13634ad6eac2fbef6a61669d02d1bffc1e35c1b8d25c16a912ee9453785ed92521d39978fd779564834c8d15a002bd5cefb495234a5a - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-web-identity@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/credential-provider-web-identity@npm:3.931.0" - dependencies: - "@aws-sdk/core": "npm:3.931.0" - "@aws-sdk/nested-clients": "npm:3.931.0" - "@aws-sdk/types": "npm:3.930.0" - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/shared-ini-file-loader": "npm:^4.4.0" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/5e87be2d80f48a72e7cbc2b67db64eaa195e7d18229cb4cabd22830cf431e841b162c04f082cbf7d81185c1b45e50079c96484ea0d733e80156e7615b692ab5b - languageName: node - linkType: hard - -"@aws-sdk/middleware-host-header@npm:3.930.0": - version: 3.930.0 - resolution: "@aws-sdk/middleware-host-header@npm:3.930.0" - dependencies: - "@aws-sdk/types": "npm:3.930.0" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/1e63fba34977dc74004c627d3bc92c7ec226b8e3e4b10af38c0004b915e5e644d9a7ea84f17dfd111fadc67c67f2a668eeaa67216803a2c4b30bb8bd6efd1c42 - languageName: node - linkType: hard - -"@aws-sdk/middleware-logger@npm:3.930.0": - version: 3.930.0 - resolution: "@aws-sdk/middleware-logger@npm:3.930.0" - dependencies: - "@aws-sdk/types": "npm:3.930.0" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/f680f9d0da3d56260ec1943393c04dc5f4e88c305a777af868c2e640dcd9663f3facb49d96112e50a191d4890a41a134227de55191e00b8e6413f06959435a9e - languageName: node - linkType: hard - -"@aws-sdk/middleware-recursion-detection@npm:3.930.0": - version: 3.930.0 - resolution: "@aws-sdk/middleware-recursion-detection@npm:3.930.0" - dependencies: - "@aws-sdk/types": "npm:3.930.0" - "@aws/lambda-invoke-store": "npm:^0.1.1" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/274feb7edaf63dc1184b7a3ea0d139444dac4cf4d735e94afb183ff607bf3c90d3d158236b9ce08ecbc5c92287a3527242e546a6e829805f874dc71131d6e5e8 - languageName: node - linkType: hard - -"@aws-sdk/middleware-user-agent@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/middleware-user-agent@npm:3.931.0" - dependencies: - "@aws-sdk/core": "npm:3.931.0" - "@aws-sdk/types": "npm:3.930.0" - "@aws-sdk/util-endpoints": "npm:3.930.0" - "@smithy/core": "npm:^3.18.2" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/69dec95e1cc51dfddb9fd651e24396b4d1a87f38637f66636c0918ebc20cb358c3c8754f0d962f681c3c6d9cda3efb4940f1ce6e125e9ed679c8512c45380cc4 - languageName: node - linkType: hard - -"@aws-sdk/nested-clients@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/nested-clients@npm:3.931.0" - dependencies: - "@aws-crypto/sha256-browser": "npm:5.2.0" - "@aws-crypto/sha256-js": "npm:5.2.0" - "@aws-sdk/core": "npm:3.931.0" - "@aws-sdk/middleware-host-header": "npm:3.930.0" - "@aws-sdk/middleware-logger": "npm:3.930.0" - "@aws-sdk/middleware-recursion-detection": "npm:3.930.0" - "@aws-sdk/middleware-user-agent": "npm:3.931.0" - "@aws-sdk/region-config-resolver": "npm:3.930.0" - "@aws-sdk/types": "npm:3.930.0" - "@aws-sdk/util-endpoints": "npm:3.930.0" - "@aws-sdk/util-user-agent-browser": "npm:3.930.0" - "@aws-sdk/util-user-agent-node": "npm:3.931.0" - "@smithy/config-resolver": "npm:^4.4.3" - "@smithy/core": "npm:^3.18.2" - "@smithy/fetch-http-handler": "npm:^5.3.6" - "@smithy/hash-node": "npm:^4.2.5" - "@smithy/invalid-dependency": "npm:^4.2.5" - "@smithy/middleware-content-length": "npm:^4.2.5" - "@smithy/middleware-endpoint": "npm:^4.3.9" - "@smithy/middleware-retry": "npm:^4.4.9" - "@smithy/middleware-serde": "npm:^4.2.5" - "@smithy/middleware-stack": "npm:^4.2.5" - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/node-http-handler": "npm:^4.4.5" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/smithy-client": "npm:^4.9.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/url-parser": "npm:^4.2.5" - "@smithy/util-base64": "npm:^4.3.0" - "@smithy/util-body-length-browser": "npm:^4.2.0" - "@smithy/util-body-length-node": "npm:^4.2.1" - "@smithy/util-defaults-mode-browser": "npm:^4.3.8" - "@smithy/util-defaults-mode-node": "npm:^4.2.11" - "@smithy/util-endpoints": "npm:^3.2.5" - "@smithy/util-middleware": "npm:^4.2.5" - "@smithy/util-retry": "npm:^4.2.5" - "@smithy/util-utf8": "npm:^4.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/3db8b8711b87f1c3eceb6f3e740a738443bf41cb613192b6e3e9a0ff50b056b85551414833865abecfed7dcee1141992b5145d1fde531b667c7c41142f541637 + magic-string: "npm:1.0.0" + ora: "npm:9.4.1" + rxjs: "npm:7.8.2" + checksum: 10c0/049a190d5cbd80ae152d2bfc17f80c1eb808571e36bb67fa9ec912c2936acd43e5270f88b8ddf3c688e397cbd1665ee5f9f2607f5cd6a27acd0837b4d86f1145 languageName: node linkType: hard -"@aws-sdk/region-config-resolver@npm:3.930.0": - version: 3.930.0 - resolution: "@aws-sdk/region-config-resolver@npm:3.930.0" +"@apidevtools/json-schema-ref-parser@npm:14.0.1": + version: 14.0.1 + resolution: "@apidevtools/json-schema-ref-parser@npm:14.0.1" dependencies: - "@aws-sdk/types": "npm:3.930.0" - "@smithy/config-resolver": "npm:^4.4.3" - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/17977852c551b6b895acf365938d257c0bc172f3fc90f37ee8ade8057acdef90bc66319687aa2e6d16bb4aa87140fc55ae37589cf5111e1a467dfb5ce7421b8e + "@types/json-schema": "npm:^7.0.15" + js-yaml: "npm:^4.1.0" + checksum: 10c0/f8aff4d32f66b81be0e641da175d359ec3e4191f9c65343b30f90cfbcfdbdb78b13e57c4a0a8d0574c828294abde56400a031858f61cf38b3309a4213698dc0c languageName: node linkType: hard -"@aws-sdk/token-providers@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/token-providers@npm:3.931.0" - dependencies: - "@aws-sdk/core": "npm:3.931.0" - "@aws-sdk/nested-clients": "npm:3.931.0" - "@aws-sdk/types": "npm:3.930.0" - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/shared-ini-file-loader": "npm:^4.4.0" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/deef4531c3f74e0dfa7560600b98debc9b772f66b003d780557b5596bc82ae1f40ad55591fdcd8b9532ac68fb59d071219fb73fedd86d15d7aa652a0ccb1fea9 - languageName: node - linkType: hard - -"@aws-sdk/types@npm:3.930.0, @aws-sdk/types@npm:^3.222.0": - version: 3.930.0 - resolution: "@aws-sdk/types@npm:3.930.0" - dependencies: - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/8487d53c953cb8dc7437d9160c98438314c5f9f0d17f02ced2e8661f19aaaf71e860b700a8ec83bdda4bd831f71b3776e871953b5eb10db59d4d5067557f873b - languageName: node - linkType: hard - -"@aws-sdk/util-endpoints@npm:3.930.0": - version: 3.930.0 - resolution: "@aws-sdk/util-endpoints@npm:3.930.0" - dependencies: - "@aws-sdk/types": "npm:3.930.0" - "@smithy/types": "npm:^4.9.0" - "@smithy/url-parser": "npm:^4.2.5" - "@smithy/util-endpoints": "npm:^3.2.5" - tslib: "npm:^2.6.2" - checksum: 10c0/d8c20d133f434cd34609a1d514f1b0516029906a19dd2615a6816fd2ebf21f980fb63f889f7b98df8334bc4ad2a01cce7c7c05700d176331949a61c54c7e6df7 +"@apidevtools/openapi-schemas@npm:^2.1.0": + version: 2.1.0 + resolution: "@apidevtools/openapi-schemas@npm:2.1.0" + checksum: 10c0/f4aa0f9df32e474d166c84ef91bceb18fa1c4f44b5593879529154ef340846811ea57dc2921560f157f692262827d28d988dd6e19fb21f00320e9961964176b4 languageName: node linkType: hard -"@aws-sdk/util-locate-window@npm:^3.0.0": - version: 3.893.0 - resolution: "@aws-sdk/util-locate-window@npm:3.893.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/ed2232d1eff567a7fa96bed87d56f03ac183dc20ba0ea262edb35f0b66aea201b987f447a5c383adc5694c80275700345946c0ad3183b30a6f9ec2f89be789d8 - languageName: node - linkType: hard - -"@aws-sdk/util-user-agent-browser@npm:3.930.0": - version: 3.930.0 - resolution: "@aws-sdk/util-user-agent-browser@npm:3.930.0" - dependencies: - "@aws-sdk/types": "npm:3.930.0" - "@smithy/types": "npm:^4.9.0" - bowser: "npm:^2.11.0" - tslib: "npm:^2.6.2" - checksum: 10c0/aa379eddc8329b1545c877227c18b57dcbda47b3ba6f2b654767b2c595b4f54463a59aa99fcd522893ca636334b1e7943308963ebd49ed560e7165f465aed296 +"@apidevtools/swagger-methods@npm:^3.0.2": + version: 3.0.2 + resolution: "@apidevtools/swagger-methods@npm:3.0.2" + checksum: 10c0/8c390e8e50c0be7787ba0ba4c3758488bde7c66c2d995209b4b48c1f8bc988faf393cbb24a4bd1cd2d42ce5167c26538e8adea5c85eb922761b927e4dab9fa1c languageName: node linkType: hard -"@aws-sdk/util-user-agent-node@npm:3.931.0": - version: 3.931.0 - resolution: "@aws-sdk/util-user-agent-node@npm:3.931.0" - dependencies: - "@aws-sdk/middleware-user-agent": "npm:3.931.0" - "@aws-sdk/types": "npm:3.930.0" - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" +"@apidevtools/swagger-parser@npm:^12.1.0": + version: 12.1.0 + resolution: "@apidevtools/swagger-parser@npm:12.1.0" + dependencies: + "@apidevtools/json-schema-ref-parser": "npm:14.0.1" + "@apidevtools/openapi-schemas": "npm:^2.1.0" + "@apidevtools/swagger-methods": "npm:^3.0.2" + ajv: "npm:^8.17.1" + ajv-draft-04: "npm:^1.0.0" + call-me-maybe: "npm:^1.0.2" peerDependencies: - aws-crt: ">=1.0.0" - peerDependenciesMeta: - aws-crt: - optional: true - checksum: 10c0/0e6468753fdecb4c4bf6408bb53cdb77268b75d65bec75614ac2c4a98cd9776eca35928491f9731fb02fc431847de6d77526402147f99fd7c35c8676552f4b8f - languageName: node - linkType: hard - -"@aws-sdk/xml-builder@npm:3.930.0": - version: 3.930.0 - resolution: "@aws-sdk/xml-builder@npm:3.930.0" - dependencies: - "@smithy/types": "npm:^4.9.0" - fast-xml-parser: "npm:5.2.5" - tslib: "npm:^2.6.2" - checksum: 10c0/f46b8544ef54083944c179e85e3468023f5b960354f0c4e0c5261918c42d6a56a23807d3c88a73fe982b38f40e5d4e7e9e6885ebad7fec0df7be83dc7596abb6 - languageName: node - linkType: hard - -"@aws/lambda-invoke-store@npm:^0.1.1": - version: 0.1.1 - resolution: "@aws/lambda-invoke-store@npm:0.1.1" - checksum: 10c0/27c90d9af7cca7ff4870e87dc303516e6d09ebe18f5fa13813397cd6a37fd26cf3ff1715469e3c5323fea0404a55c110f35e21bcc3ea595a4f6ba6406ea1f103 + openapi-types: ">=7" + checksum: 10c0/ccac54e2f67c24c22fbfe8040a70642da20b72c8b0b21ba75c4e97b7444189eff09fb25fcdd8903f1081b9d4d3c78b57ed39c19397502e1bc64b517f0f3a8639 languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/code-frame@npm:7.27.1" +"@babel/code-frame@npm:^7.0.0": + version: 7.29.7 + resolution: "@babel/code-frame@npm:7.29.7" dependencies: - "@babel/helper-validator-identifier": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.29.7" js-tokens: "npm:^4.0.0" picocolors: "npm:^1.1.1" - checksum: 10c0/5dd9a18baa5fce4741ba729acc3a3272c49c25cb8736c4b18e113099520e7ef7b545a4096a26d600e4416157e63e87d66db46aa3fbf0a5f2286da2705c12da00 - languageName: node - linkType: hard - -"@babel/compat-data@npm:^7.27.2": - version: 7.28.5 - resolution: "@babel/compat-data@npm:7.28.5" - checksum: 10c0/702a25de73087b0eba325c1d10979eed7c9b6662677386ba7b5aa6eace0fc0676f78343bae080a0176ae26f58bd5535d73b9d0fbb547fef377692e8b249353a7 - languageName: node - linkType: hard - -"@babel/core@npm:^7.23.9, @babel/core@npm:^7.27.4": - version: 7.28.5 - resolution: "@babel/core@npm:7.28.5" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.5" - "@babel/helper-compilation-targets": "npm:^7.27.2" - "@babel/helper-module-transforms": "npm:^7.28.3" - "@babel/helpers": "npm:^7.28.4" - "@babel/parser": "npm:^7.28.5" - "@babel/template": "npm:^7.27.2" - "@babel/traverse": "npm:^7.28.5" - "@babel/types": "npm:^7.28.5" - "@jridgewell/remapping": "npm:^2.3.5" - convert-source-map: "npm:^2.0.0" - debug: "npm:^4.1.0" - gensync: "npm:^1.0.0-beta.2" - json5: "npm:^2.2.3" - semver: "npm:^6.3.1" - checksum: 10c0/535f82238027621da6bdffbdbe896ebad3558b311d6f8abc680637a9859b96edbf929ab010757055381570b29cf66c4a295b5618318d27a4273c0e2033925e72 - languageName: node - linkType: hard - -"@babel/generator@npm:^7.27.5, @babel/generator@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/generator@npm:7.28.5" - dependencies: - "@babel/parser": "npm:^7.28.5" - "@babel/types": "npm:^7.28.5" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/9f219fe1d5431b6919f1a5c60db8d5d34fe546c0d8f5a8511b32f847569234ffc8032beb9e7404649a143f54e15224ecb53a3d11b6bb85c3203e573d91fca752 - languageName: node - linkType: hard - -"@babel/helper-compilation-targets@npm:^7.27.2": - version: 7.27.2 - resolution: "@babel/helper-compilation-targets@npm:7.27.2" - dependencies: - "@babel/compat-data": "npm:^7.27.2" - "@babel/helper-validator-option": "npm:^7.27.1" - browserslist: "npm:^4.24.0" - lru-cache: "npm:^5.1.1" - semver: "npm:^6.3.1" - checksum: 10c0/f338fa00dcfea931804a7c55d1a1c81b6f0a09787e528ec580d5c21b3ecb3913f6cb0f361368973ce953b824d910d3ac3e8a8ee15192710d3563826447193ad1 - languageName: node - linkType: hard - -"@babel/helper-globals@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/helper-globals@npm:7.28.0" - checksum: 10c0/5a0cd0c0e8c764b5f27f2095e4243e8af6fa145daea2b41b53c0c1414fe6ff139e3640f4e2207ae2b3d2153a1abd346f901c26c290ee7cb3881dd922d4ee9232 - languageName: node - linkType: hard - -"@babel/helper-module-imports@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-module-imports@npm:7.27.1" - dependencies: - "@babel/traverse": "npm:^7.27.1" - "@babel/types": "npm:^7.27.1" - checksum: 10c0/e00aace096e4e29290ff8648455c2bc4ed982f0d61dbf2db1b5e750b9b98f318bf5788d75a4f974c151bd318fd549e81dbcab595f46b14b81c12eda3023f51e8 - languageName: node - linkType: hard - -"@babel/helper-module-transforms@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/helper-module-transforms@npm:7.28.3" - dependencies: - "@babel/helper-module-imports": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.3" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10c0/549be62515a6d50cd4cfefcab1b005c47f89bd9135a22d602ee6a5e3a01f27571868ada10b75b033569f24dc4a2bb8d04bfa05ee75c16da7ade2d0db1437fcdb - languageName: node - linkType: hard - -"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.27.1, @babel/helper-plugin-utils@npm:^7.8.0": - version: 7.27.1 - resolution: "@babel/helper-plugin-utils@npm:7.27.1" - checksum: 10c0/94cf22c81a0c11a09b197b41ab488d416ff62254ce13c57e62912c85700dc2e99e555225787a4099ff6bae7a1812d622c80fbaeda824b79baa10a6c5ac4cf69b - languageName: node - linkType: hard - -"@babel/helper-string-parser@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-string-parser@npm:7.27.1" - checksum: 10c0/8bda3448e07b5583727c103560bcf9c4c24b3c1051a4c516d4050ef69df37bb9a4734a585fe12725b8c2763de0a265aa1e909b485a4e3270b7cfd3e4dbe4b602 + checksum: 10c0/169fc2080169a40c1760155eaaaf739bcb882df0bec76a83adbda5493645bc17270a3434b8848c494b1933e96fe1d147370001e3cda09a39f43ae30f08ef2069 languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.27.1, @babel/helper-validator-identifier@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/helper-validator-identifier@npm:7.28.5" - checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c languageName: node linkType: hard -"@babel/helper-validator-option@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-validator-option@npm:7.27.1" - checksum: 10c0/6fec5f006eba40001a20f26b1ef5dbbda377b7b68c8ad518c05baa9af3f396e780bdfded24c4eef95d14bb7b8fd56192a6ed38d5d439b97d10efc5f1a191d148 +"@babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b languageName: node linkType: hard -"@babel/helpers@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/helpers@npm:7.28.4" +"@babel/parser@npm:^7.29.3, @babel/parser@npm:^7.6.0, @babel/parser@npm:^7.9.6": + version: 7.29.7 + resolution: "@babel/parser@npm:7.29.7" dependencies: - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.4" - checksum: 10c0/aaa5fb8098926dfed5f223adf2c5e4c7fbba4b911b73dfec2d7d3083f8ba694d201a206db673da2d9b3ae8c01793e795767654558c450c8c14b4c2175b4fcb44 - languageName: node - linkType: hard - -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.5, @babel/parser@npm:^7.6.0, @babel/parser@npm:^7.9.6": - version: 7.28.5 - resolution: "@babel/parser@npm:7.28.5" - dependencies: - "@babel/types": "npm:^7.28.5" + "@babel/types": "npm:^7.29.7" bin: parser: ./bin/babel-parser.js - checksum: 10c0/5bbe48bf2c79594ac02b490a41ffde7ef5aa22a9a88ad6bcc78432a6ba8a9d638d531d868bd1f104633f1f6bba9905746e15185b8276a3756c42b765d131b1ef - languageName: node - linkType: hard - -"@babel/plugin-syntax-async-generators@npm:^7.8.4": - version: 7.8.4 - resolution: "@babel/plugin-syntax-async-generators@npm:7.8.4" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.8.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/d13efb282838481348c71073b6be6245b35d4f2f964a8f71e4174f235009f929ef7613df25f8d2338e2d3e44bc4265a9f8638c6aaa136d7a61fe95985f9725c8 - languageName: node - linkType: hard - -"@babel/plugin-syntax-bigint@npm:^7.8.3": - version: 7.8.3 - resolution: "@babel/plugin-syntax-bigint@npm:7.8.3" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.8.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/686891b81af2bc74c39013655da368a480f17dd237bf9fbc32048e5865cb706d5a8f65438030da535b332b1d6b22feba336da8fa931f663b6b34e13147d12dde - languageName: node - linkType: hard - -"@babel/plugin-syntax-class-properties@npm:^7.12.13": - version: 7.12.13 - resolution: "@babel/plugin-syntax-class-properties@npm:7.12.13" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.12.13" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/95168fa186416195280b1264fb18afcdcdcea780b3515537b766cb90de6ce042d42dd6a204a39002f794ae5845b02afb0fd4861a3308a861204a55e68310a120 - languageName: node - linkType: hard - -"@babel/plugin-syntax-class-static-block@npm:^7.14.5": - version: 7.14.5 - resolution: "@babel/plugin-syntax-class-static-block@npm:7.14.5" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.14.5" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/4464bf9115f4a2d02ce1454411baf9cfb665af1da53709c5c56953e5e2913745b0fcce82982a00463d6facbdd93445c691024e310b91431a1e2f024b158f6371 - languageName: node - linkType: hard - -"@babel/plugin-syntax-import-attributes@npm:^7.24.7": - version: 7.27.1 - resolution: "@babel/plugin-syntax-import-attributes@npm:7.27.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/e66f7a761b8360419bbb93ab67d87c8a97465ef4637a985ff682ce7ba6918b34b29d81190204cf908d0933058ee7b42737423cd8a999546c21b3aabad4affa9a - languageName: node - linkType: hard - -"@babel/plugin-syntax-import-meta@npm:^7.10.4": - version: 7.10.4 - resolution: "@babel/plugin-syntax-import-meta@npm:7.10.4" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.10.4" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/0b08b5e4c3128523d8e346f8cfc86824f0da2697b1be12d71af50a31aff7a56ceb873ed28779121051475010c28d6146a6bfea8518b150b71eeb4e46190172ee - languageName: node - linkType: hard - -"@babel/plugin-syntax-json-strings@npm:^7.8.3": - version: 7.8.3 - resolution: "@babel/plugin-syntax-json-strings@npm:7.8.3" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.8.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/e98f31b2ec406c57757d115aac81d0336e8434101c224edd9a5c93cefa53faf63eacc69f3138960c8b25401315af03df37f68d316c151c4b933136716ed6906e - languageName: node - linkType: hard - -"@babel/plugin-syntax-jsx@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-syntax-jsx@npm:7.27.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/bc5afe6a458d5f0492c02a54ad98c5756a0c13bd6d20609aae65acd560a9e141b0876da5f358dce34ea136f271c1016df58b461184d7ae9c4321e0f98588bc84 - languageName: node - linkType: hard - -"@babel/plugin-syntax-logical-assignment-operators@npm:^7.10.4": - version: 7.10.4 - resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.10.4" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/2594cfbe29411ad5bc2ad4058de7b2f6a8c5b86eda525a993959438615479e59c012c14aec979e538d60a584a1a799b60d1b8942c3b18468cb9d99b8fd34cd0b - languageName: node - linkType: hard - -"@babel/plugin-syntax-nullish-coalescing-operator@npm:^7.8.3": - version: 7.8.3 - resolution: "@babel/plugin-syntax-nullish-coalescing-operator@npm:7.8.3" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.8.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/2024fbb1162899094cfc81152449b12bd0cc7053c6d4bda8ac2852545c87d0a851b1b72ed9560673cbf3ef6248257262c3c04aabf73117215c1b9cc7dd2542ce - languageName: node - linkType: hard - -"@babel/plugin-syntax-numeric-separator@npm:^7.10.4": - version: 7.10.4 - resolution: "@babel/plugin-syntax-numeric-separator@npm:7.10.4" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.10.4" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/c55a82b3113480942c6aa2fcbe976ff9caa74b7b1109ff4369641dfbc88d1da348aceb3c31b6ed311c84d1e7c479440b961906c735d0ab494f688bf2fd5b9bb9 - languageName: node - linkType: hard - -"@babel/plugin-syntax-object-rest-spread@npm:^7.8.3": - version: 7.8.3 - resolution: "@babel/plugin-syntax-object-rest-spread@npm:7.8.3" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.8.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/ee1eab52ea6437e3101a0a7018b0da698545230015fc8ab129d292980ec6dff94d265e9e90070e8ae5fed42f08f1622c14c94552c77bcac784b37f503a82ff26 - languageName: node - linkType: hard - -"@babel/plugin-syntax-optional-catch-binding@npm:^7.8.3": - version: 7.8.3 - resolution: "@babel/plugin-syntax-optional-catch-binding@npm:7.8.3" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.8.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/27e2493ab67a8ea6d693af1287f7e9acec206d1213ff107a928e85e173741e1d594196f99fec50e9dde404b09164f39dec5864c767212154ffe1caa6af0bc5af - languageName: node - linkType: hard - -"@babel/plugin-syntax-optional-chaining@npm:^7.8.3": - version: 7.8.3 - resolution: "@babel/plugin-syntax-optional-chaining@npm:7.8.3" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.8.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/46edddf2faa6ebf94147b8e8540dfc60a5ab718e2de4d01b2c0bdf250a4d642c2bd47cbcbb739febcb2bf75514dbcefad3c52208787994b8d0f8822490f55e81 - languageName: node - linkType: hard - -"@babel/plugin-syntax-private-property-in-object@npm:^7.14.5": - version: 7.14.5 - resolution: "@babel/plugin-syntax-private-property-in-object@npm:7.14.5" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.14.5" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/69822772561706c87f0a65bc92d0772cea74d6bc0911537904a676d5ff496a6d3ac4e05a166d8125fce4a16605bace141afc3611074e170a994e66e5397787f3 - languageName: node - linkType: hard - -"@babel/plugin-syntax-top-level-await@npm:^7.14.5": - version: 7.14.5 - resolution: "@babel/plugin-syntax-top-level-await@npm:7.14.5" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.14.5" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/14bf6e65d5bc1231ffa9def5f0ef30b19b51c218fcecaa78cd1bdf7939dfdf23f90336080b7f5196916368e399934ce5d581492d8292b46a2fb569d8b2da106f - languageName: node - linkType: hard - -"@babel/plugin-syntax-typescript@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-syntax-typescript@npm:7.27.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/11589b4c89c66ef02d57bf56c6246267851ec0c361f58929327dc3e070b0dab644be625bbe7fb4c4df30c3634bfdfe31244e1f517be397d2def1487dbbe3c37d + checksum: 10c0/65133038f80b54a714d6027cb77cee3f9a6b5c4c6842ce674301e13947cbcbfa8055e63acaf1b84c085d34226a14425b2c2b97b829e0e226d2e8f1299942a51d languageName: node linkType: hard "@babel/runtime@npm:^7.23.9": - version: 7.28.4 - resolution: "@babel/runtime@npm:7.28.4" - checksum: 10c0/792ce7af9750fb9b93879cc9d1db175701c4689da890e6ced242ea0207c9da411ccf16dc04e689cc01158b28d7898c40d75598f4559109f761c12ce01e959bf7 - languageName: node - linkType: hard - -"@babel/template@npm:^7.27.2": - version: 7.27.2 - resolution: "@babel/template@npm:7.27.2" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/parser": "npm:^7.27.2" - "@babel/types": "npm:^7.27.1" - checksum: 10c0/ed9e9022651e463cc5f2cc21942f0e74544f1754d231add6348ff1b472985a3b3502041c0be62dc99ed2d12cfae0c51394bf827452b98a2f8769c03b87aadc81 - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/traverse@npm:7.28.5" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.5" - "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.28.5" - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.5" - debug: "npm:^4.3.1" - checksum: 10c0/f6c4a595993ae2b73f2d4cd9c062f2e232174d293edd4abe1d715bd6281da8d99e47c65857e8d0917d9384c65972f4acdebc6749a7c40a8fcc38b3c7fb3e706f + version: 7.29.7 + resolution: "@babel/runtime@npm:7.29.7" + checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5, @babel/types@npm:^7.6.1, @babel/types@npm:^7.9.6": - version: 7.28.5 - resolution: "@babel/types@npm:7.28.5" +"@babel/types@npm:^7.29.0, @babel/types@npm:^7.29.7, @babel/types@npm:^7.6.1, @babel/types@npm:^7.9.6": + version: 7.29.7 + resolution: "@babel/types@npm:7.29.7" dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.28.5" - checksum: 10c0/a5a483d2100befbf125793640dec26b90b95fd233a94c19573325898a5ce1e52cdfa96e495c7dcc31b5eca5b66ce3e6d4a0f5a4a62daec271455959f208ab08a + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/b6623994c69717fa27294f5fa46d59140338e2d86c6c1c13085c84ef7d53086ee357fbf4fe9abe3dd3da75734dc77c4c0df2f90fb29e667558bb3b3fb705e88f languageName: node linkType: hard -"@bcoe/v8-coverage@npm:^0.2.3": - version: 0.2.3 - resolution: "@bcoe/v8-coverage@npm:0.2.3" - checksum: 10c0/6b80ae4cb3db53f486da2dc63b6e190a74c8c3cca16bb2733f234a0b6a9382b09b146488ae08e2b22cf00f6c83e20f3e040a2f7894f05c045c946d6a090b1d52 +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 languageName: node linkType: hard -"@borewit/text-codec@npm:^0.1.0": - version: 0.1.1 - resolution: "@borewit/text-codec@npm:0.1.1" - checksum: 10c0/c92606b355111053f9db47d485c8679cc09a5be0eb2738aad5b922d3744465f2fce47144ffb27d5106fa431d1d2e5a2e0140d0a22351dccf49693098702c0274 +"@borewit/text-codec@npm:^0.2.1": + version: 0.2.2 + resolution: "@borewit/text-codec@npm:0.2.2" + checksum: 10c0/2d3fb132bc6a132914a8fbf8e9ff2fa1ead210ecc395b28bb7355bd7719548a5e351ffe39f21c3bee8048f6cabd99eabd404bb5cc809cad9cba25abed19d271f languageName: node linkType: hard @@ -1180,727 +379,433 @@ __metadata: languageName: node linkType: hard -"@concepta/nestjs-access-control@npm:^7.0.0-alpha.10, @concepta/nestjs-access-control@workspace:packages/nestjs-access-control": +"@concepta/nestjs-access-control@workspace:packages/nestjs-access-control": version: 0.0.0-use.local resolution: "@concepta/nestjs-access-control@workspace:packages/nestjs-access-control" dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/config": "npm:^12.0.0" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/cqrs": "npm:^12.0.0" + "@nestjs/swagger": "npm:^12.0.1" + "@nestjs/testing": "npm:^12.0.1" "@types/supertest": "npm:^6.0.3" accesscontrol: "npm:^2.2.1" - jest-mock-extended: "npm:^4.0.0" rxjs: "npm:^7.8.1" supertest: "npm:^6.3.4" + vitest-mock-extended: "npm:^4.0.0" + peerDependencies: + "@nestjs/common": ^12.0.1 + "@nestjs/config": ^12.0.0 + "@nestjs/core": ^12.0.1 + "@nestjs/cqrs": ^12.0.0 + peerDependenciesMeta: + "@nestjs/cqrs": + optional: true languageName: unknown linkType: soft -"@concepta/nestjs-auth-apple@workspace:packages/nestjs-auth-apple": +"@concepta/nestjs-authentication@workspace:packages/nestjs-authentication": version: 0.0.0-use.local - resolution: "@concepta/nestjs-auth-apple@workspace:packages/nestjs-auth-apple" - dependencies: - "@concepta/nestjs-auth-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-authentication": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-crud": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-federated": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-password": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-user": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/passport": "npm:^11.0.5" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@types/passport-apple": "npm:^2.0.3" - jwks-rsa: "npm:^3.1.0" - passport-apple: "npm:^2.0.2" + resolution: "@concepta/nestjs-authentication@workspace:packages/nestjs-authentication" + dependencies: + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@concepta/nestjs-crud": "npm:8.0.0-alpha.10" + "@concepta/nestjs-otp": "npm:8.0.0-alpha.10" + "@concepta/nestjs-password": "npm:8.0.0-alpha.10" + "@concepta/nestjs-user": "npm:8.0.0-alpha.10" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/config": "npm:^12.0.0" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/cqrs": "npm:^12.0.0" + "@nestjs/jwt": "npm:^12.0.1" + "@nestjs/passport": "npm:^12.0.0" + "@nestjs/swagger": "npm:^12.0.1" + "@nestjs/testing": "npm:^12.0.1" + "@standard-schema/spec": "npm:^1.0.0" + "@types/passport-jwt": "npm:^4.0.1" + "@types/passport-local": "npm:^1.0.38" + "@types/passport-strategy": "npm:^0.2.38" + express: "npm:^4.21.0" + jsonwebtoken: "npm:^9.0.0" + ms: "npm:^2.1.3" + passport: "npm:^0.7.0" + passport-jwt: "npm:^4.0.1" + passport-local: "npm:^1.0.0" + passport-strategy: "npm:^1.0.0" + supertest: "npm:^6.3.4" + vitest-mock-extended: "npm:^4.0.0" + zod: "npm:^4.4.3" peerDependencies: - class-transformer: "*" - class-validator: "*" + "@nestjs/common": ^12.0.1 + "@nestjs/config": ^12.0.0 + "@nestjs/core": ^12.0.1 + "@nestjs/cqrs": ^12.0.0 + "@nestjs/swagger": ^12.0.1 rxjs: ^7.1.0 - typeorm: ^0.3.0 + peerDependenciesMeta: + "@nestjs/cqrs": + optional: true languageName: unknown linkType: soft -"@concepta/nestjs-auth-github@workspace:packages/nestjs-auth-github": +"@concepta/nestjs-cache@workspace:packages/nestjs-cache": version: 0.0.0-use.local - resolution: "@concepta/nestjs-auth-github@workspace:packages/nestjs-auth-github" - dependencies: - "@concepta/nestjs-auth-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-authentication": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-crud": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-federated": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-password": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-user": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/passport": "npm:^11.0.5" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@types/passport-github": "npm:^1.1.12" - passport-github: "npm:^1.1.0" + resolution: "@concepta/nestjs-cache@workspace:packages/nestjs-cache" + dependencies: + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@concepta/nestjs-crud": "npm:8.0.0-alpha.10" + "@concepta/nestjs-repository": "npm:8.0.0-alpha.10" + "@concepta/nestjs-repository-typeorm": "npm:8.0.0-alpha.10" + "@concepta/typeorm-seeding": "npm:^4.0.0" + "@faker-js/faker": "npm:^8.4.1" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/config": "npm:^12.0.0" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/cqrs": "npm:^12.0.0" + "@nestjs/swagger": "npm:^12.0.1" + "@nestjs/testing": "npm:^12.0.1" + "@nestjs/typeorm": "npm:^12.0.1" + "@types/supertest": "npm:^6.0.3" + supertest: "npm:^6.3.4" + vitest-mock-extended: "npm:^4.0.0" + zod: "npm:^4.4.3" peerDependencies: - class-transformer: "*" - class-validator: "*" - rxjs: ^7.1.0 + "@concepta/nestjs-crud": 8.0.0-alpha.10 + "@concepta/typeorm-seeding": ^4.0.0 + "@faker-js/faker": ^8.4.1 + "@nestjs/common": ^12.0.1 + "@nestjs/config": ^12.0.0 + "@nestjs/core": ^12.0.1 + "@nestjs/cqrs": ^12.0.0 typeorm: ^0.3.0 + peerDependenciesMeta: + "@concepta/nestjs-crud": + optional: true + "@concepta/typeorm-seeding": + optional: true + "@faker-js/faker": + optional: true + "@nestjs/cqrs": + optional: true languageName: unknown linkType: soft -"@concepta/nestjs-auth-google@npm:^7.0.0-alpha.10, @concepta/nestjs-auth-google@workspace:packages/nestjs-auth-google": +"@concepta/nestjs-core@npm:^8.0.0-alpha.10, @concepta/nestjs-core@workspace:packages/nestjs-core": version: 0.0.0-use.local - resolution: "@concepta/nestjs-auth-google@workspace:packages/nestjs-auth-google" - dependencies: - "@concepta/nestjs-auth-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-authentication": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-crud": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-federated": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-password": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-user": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/passport": "npm:^11.0.5" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@types/passport-google-oauth20": "npm:^2.0.16" - passport-google-oauth20: "npm:^2.0.0" + resolution: "@concepta/nestjs-core@workspace:packages/nestjs-core" + dependencies: + "@nestjs/common": "npm:^12.0.1" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/cqrs": "npm:^12.0.0" + "@nestjs/swagger": "npm:^12.0.1" + "@nestjs/testing": "npm:^12.0.1" + "@types/supertest": "npm:^6.0.3" + ms: "npm:^2.1.3" + rxjs: "npm:^7.8.1" + supertest: "npm:^6.3.4" + vitest-mock-extended: "npm:^4.0.0" + zod: "npm:^4.4.3" peerDependencies: - class-transformer: "*" - class-validator: "*" - rxjs: ^7.1.0 - typeorm: ^0.3.0 + "@nestjs/common": ^12.0.1 + "@nestjs/core": ^12.0.1 + "@nestjs/cqrs": ^12.0.0 + "@nestjs/swagger": ^12.0.1 + rxjs: ^7.8.1 + peerDependenciesMeta: + "@nestjs/cqrs": + optional: true languageName: unknown linkType: soft -"@concepta/nestjs-auth-jwt@npm:^7.0.0-alpha.10, @concepta/nestjs-auth-jwt@workspace:packages/nestjs-auth-jwt": +"@concepta/nestjs-crud@npm:8.0.0-alpha.10, @concepta/nestjs-crud@workspace:packages/nestjs-crud": version: 0.0.0-use.local - resolution: "@concepta/nestjs-auth-jwt@workspace:packages/nestjs-auth-jwt" - dependencies: - "@concepta/nestjs-authentication": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-jwt": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/testing": "npm:^11.1.9" - jest-mock-extended: "npm:^4.0.0" + resolution: "@concepta/nestjs-crud@workspace:packages/nestjs-crud" + dependencies: + "@apidevtools/swagger-parser": "npm:^12.1.0" + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@concepta/nestjs-repository": "npm:8.0.0-alpha.10" + "@concepta/nestjs-repository-typeorm": "npm:8.0.0-alpha.10" + "@concepta/typeorm-seeding": "npm:^4.0.0" + "@faker-js/faker": "npm:^8.4.1" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/config": "npm:^12.0.0" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/cqrs": "npm:^12.0.0" + "@nestjs/swagger": "npm:^12.0.1" + "@nestjs/testing": "npm:^12.0.1" + "@nestjs/typeorm": "npm:^12.0.1" + "@standard-schema/spec": "npm:^1.0.0" + deepmerge: "npm:^3.2.0" + qs: "npm:^6.14.0" + supertest: "npm:^6.3.4" + typeorm: "npm:^0.3.28" + vitest-mock-extended: "npm:^4.0.0" + zod: "npm:^4.4.3" peerDependencies: - class-validator: "*" + "@concepta/nestjs-repository-typeorm": 8.0.0-alpha.10 + "@nestjs/common": ^12.0.1 + "@nestjs/config": ^12.0.0 + "@nestjs/core": ^12.0.1 + "@nestjs/cqrs": ^12.0.0 + "@nestjs/swagger": ^12.0.1 rxjs: ^7.1.0 - typeorm: ^0.3.0 + peerDependenciesMeta: + "@concepta/nestjs-repository-typeorm": + optional: true + "@nestjs/cqrs": + optional: true languageName: unknown linkType: soft -"@concepta/nestjs-auth-local@npm:^7.0.0-alpha.10, @concepta/nestjs-auth-local@workspace:packages/nestjs-auth-local": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-auth-local@workspace:packages/nestjs-auth-local" - dependencies: - "@concepta/nestjs-auth-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-authentication": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-password": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@types/passport-local": "npm:^1.0.38" - "@types/supertest": "npm:^6.0.3" - jest-mock-extended: "npm:^4.0.0" - passport-local: "npm:^1.0.0" - supertest: "npm:^6.3.4" - peerDependencies: - class-transformer: "*" - class-validator: "*" - rxjs: ^7.1.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-auth-recovery@workspace:packages/nestjs-auth-recovery": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-auth-recovery@workspace:packages/nestjs-auth-recovery" - dependencies: - "@concepta/nestjs-auth-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-authentication": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-crud": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-email": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-otp": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-password": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-user": "npm:^7.0.0-alpha.10" - "@concepta/typeorm-seeding": "npm:^4.0.0" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" - jest-mock-extended: "npm:^4.0.0" - supertest: "npm:^6.3.4" - peerDependencies: - class-transformer: "*" - class-validator: "*" - rxjs: ^7.1.0 - typeorm: ^0.3.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-auth-refresh@npm:^7.0.0-alpha.10, @concepta/nestjs-auth-refresh@workspace:packages/nestjs-auth-refresh": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-auth-refresh@workspace:packages/nestjs-auth-refresh" - dependencies: - "@concepta/nestjs-authentication": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-jwt": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - jest-mock-extended: "npm:^4.0.0" - peerDependencies: - class-transformer: "*" - class-validator: "*" - rxjs: ^7.1.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-auth-router@workspace:packages/nestjs-auth-router": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-auth-router@workspace:packages/nestjs-auth-router" - dependencies: - "@concepta/nestjs-auth-google": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-authentication": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@types/express": "npm:^4.17.21" - supertest: "npm:^6.3.4" - peerDependencies: - class-transformer: "*" - class-validator: "*" - rxjs: ^7.8.1 - typeorm: ^0.3.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-auth-verify@workspace:packages/nestjs-auth-verify": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-auth-verify@workspace:packages/nestjs-auth-verify" - dependencies: - "@concepta/nestjs-auth-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-authentication": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-crud": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-email": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-otp": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-password": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-user": "npm:^7.0.0-alpha.10" - "@concepta/typeorm-seeding": "npm:^4.0.0" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" - jest-mock-extended: "npm:^4.0.0" - supertest: "npm:^6.3.4" - peerDependencies: - class-transformer: "*" - class-validator: "*" - rxjs: ^7.1.0 - typeorm: ^0.3.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-authentication@npm:^7.0.0-alpha.10, @concepta/nestjs-authentication@workspace:packages/nestjs-authentication": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-authentication@workspace:packages/nestjs-authentication" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-jwt": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/jwt": "npm:^11.0.1" - "@nestjs/passport": "npm:^11.0.5" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - jest-mock-extended: "npm:^4.0.0" - passport: "npm:^0.7.0" - passport-strategy: "npm:^1.0.0" - peerDependencies: - class-transformer: "*" - class-validator: "*" - rxjs: ^7.1.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-cache@workspace:packages/nestjs-cache": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-cache@workspace:packages/nestjs-cache" - dependencies: - "@concepta/nestjs-access-control": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-crud": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/typeorm-seeding": "npm:^4.0.0" - "@faker-js/faker": "npm:^8.4.1" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" - "@types/supertest": "npm:^6.0.3" - jest-mock-extended: "npm:^4.0.0" - supertest: "npm:^6.3.4" - peerDependencies: - class-transformer: "*" - class-validator: "*" - typeorm: ^0.3.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-common@npm:^7.0.0-alpha.10, @concepta/nestjs-common@workspace:packages/nestjs-common": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-common@workspace:packages/nestjs-common" - dependencies: - "@nestjs/common": "npm:^11.1.9" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@types/supertest": "npm:^6.0.3" - jest-mock-extended: "npm:^4.0.0" - ms: "npm:^2.1.3" - supertest: "npm:^6.3.4" - peerDependencies: - class-transformer: "*" - class-validator: "*" - languageName: unknown - linkType: soft - -"@concepta/nestjs-crud@npm:^7.0.0-alpha.10, @concepta/nestjs-crud@workspace:packages/nestjs-crud": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-crud@workspace:packages/nestjs-crud" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/typeorm-seeding": "npm:^4.0.0" - "@faker-js/faker": "npm:^8.4.1" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" - "@zmotivat0r/o0": "npm:^1.0.2" - deepmerge: "npm:^3.2.0" - jest-extended: "npm:^7.0.0" - jest-mock-extended: "npm:^4.0.0" - qs: "npm:^6.14.0" - supertest: "npm:^6.3.4" - peerDependencies: - "@concepta/nestjs-typeorm-ext": "*" - class-transformer: "*" - class-validator: "*" - rxjs: ^7.1.0 - typeorm: ^0.3.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-email@npm:^7.0.0-alpha.10, @concepta/nestjs-email@workspace:packages/nestjs-email": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-email@workspace:packages/nestjs-email" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/testing": "npm:^11.1.9" - class-validator: "npm:*" - jest-mock-extended: "npm:^4.0.0" - languageName: unknown - linkType: soft - -"@concepta/nestjs-event@npm:^7.0.0-alpha.10, @concepta/nestjs-event@workspace:packages/nestjs-event": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-event@workspace:packages/nestjs-event" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/testing": "npm:^11.1.9" - eventemitter2: "npm:^6.4.9" - languageName: unknown - linkType: soft - -"@concepta/nestjs-federated@npm:^7.0.0-alpha.10, @concepta/nestjs-federated@workspace:packages/nestjs-federated": +"@concepta/nestjs-federated@workspace:packages/nestjs-federated": version: 0.0.0-use.local resolution: "@concepta/nestjs-federated@workspace:packages/nestjs-federated" dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-user": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - peerDependencies: - class-transformer: "*" - class-validator: "*" - rxjs: ^7.1.0 - typeorm: ^0.3.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-file@npm:^7.0.0-alpha.10, @concepta/nestjs-file@workspace:packages/nestjs-file": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-file@workspace:packages/nestjs-file" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-user": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - jest-mock-extended: "npm:^4.0.0" + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@concepta/nestjs-repository": "npm:8.0.0-alpha.10" + "@concepta/nestjs-repository-typeorm": "npm:8.0.0-alpha.10" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/config": "npm:^12.0.0" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/cqrs": "npm:^12.0.0" + "@nestjs/testing": "npm:^12.0.1" + vitest-mock-extended: "npm:^4.0.0" + zod: "npm:^4.4.3" peerDependencies: - class-transformer: "*" - class-validator: "*" + "@concepta/nestjs-repository-typeorm": 8.0.0-alpha.10 + "@nestjs/common": ^12.0.1 + "@nestjs/config": ^12.0.0 + "@nestjs/core": ^12.0.1 + "@nestjs/cqrs": ^12.0.0 rxjs: ^7.1.0 typeorm: ^0.3.0 + peerDependenciesMeta: + "@concepta/nestjs-repository-typeorm": + optional: true + "@nestjs/cqrs": + optional: true + typeorm: + optional: true languageName: unknown linkType: soft -"@concepta/nestjs-invitation@npm:^7.0.0-alpha.10, @concepta/nestjs-invitation@workspace:packages/nestjs-invitation": +"@concepta/nestjs-invitation@workspace:packages/nestjs-invitation": version: 0.0.0-use.local resolution: "@concepta/nestjs-invitation@workspace:packages/nestjs-invitation" dependencies: - "@concepta/nestjs-access-control": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-crud": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-email": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-event": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-otp": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-password": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-user": "npm:^7.0.0-alpha.10" + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@concepta/nestjs-crud": "npm:8.0.0-alpha.10" + "@concepta/nestjs-otp": "npm:8.0.0-alpha.10" + "@concepta/nestjs-password": "npm:8.0.0-alpha.10" + "@concepta/nestjs-repository": "npm:8.0.0-alpha.10" + "@concepta/nestjs-repository-typeorm": "npm:8.0.0-alpha.10" + "@concepta/nestjs-user": "npm:8.0.0-alpha.10" "@concepta/typeorm-seeding": "npm:^4.0.0" "@faker-js/faker": "npm:^8.4.1" "@nestjs-modules/mailer": "npm:^1.11.2" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" - jest-mock-extended: "npm:^4.0.0" - supertest: "npm:^6.3.4" - peerDependencies: - class-transformer: "*" - class-validator: "*" - rxjs: ^7.1.0 - typeorm: ^0.3.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-jwt@npm:^7.0.0-alpha.10, @concepta/nestjs-jwt@workspace:packages/nestjs-jwt": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-jwt@workspace:packages/nestjs-jwt" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/jwt": "npm:^11.0.1" - "@nestjs/testing": "npm:^11.1.9" - "@types/jsonwebtoken": "npm:9.0.10" - "@types/passport-jwt": "npm:^3.0.13" - "@types/passport-strategy": "npm:^0.2.38" - express-serve-static-core: "npm:^0.1.1" - jest-mock-extended: "npm:^4.0.0" - jsonwebtoken: "npm:^9.0.2" - passport-jwt: "npm:^4.0.1" - passport-strategy: "npm:^1.0.0" - languageName: unknown - linkType: soft - -"@concepta/nestjs-logger-coralogix@workspace:packages/nestjs-logger-coralogix": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-logger-coralogix@workspace:packages/nestjs-logger-coralogix" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-logger": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/testing": "npm:^11.1.9" - coralogix-logger: "npm:^1.1.30" - jest-mock-extended: "npm:^4.0.0" - supertest: "npm:^6.3.4" - peerDependencies: - class-validator: "*" - rxjs: ^7.1.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-logger-sentry@workspace:packages/nestjs-logger-sentry": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-logger-sentry@workspace:packages/nestjs-logger-sentry" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-logger": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/testing": "npm:^11.1.9" - "@sentry/node": "npm:^8.26.0" - "@sentry/types": "npm:^8.26.0" - jest-mock-extended: "npm:^4.0.0" - supertest: "npm:^6.3.4" - peerDependencies: - class-validator: "*" - rxjs: ^7.1.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-logger@npm:^7.0.0-alpha.10, @concepta/nestjs-logger@workspace:packages/nestjs-logger": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-logger@workspace:packages/nestjs-logger" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/testing": "npm:^11.1.9" - fastify: "npm:^3.29.5" - jest-mock-extended: "npm:^4.0.0" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/config": "npm:^12.0.0" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/cqrs": "npm:^12.0.0" + "@nestjs/testing": "npm:^12.0.1" + "@nestjs/typeorm": "npm:^12.0.1" supertest: "npm:^6.3.4" + vitest-mock-extended: "npm:^4.0.0" + zod: "npm:^4.4.3" peerDependencies: - class-validator: "*" + "@concepta/nestjs-crud": 8.0.0-alpha.10 + "@concepta/nestjs-repository-typeorm": 8.0.0-alpha.10 + "@nestjs/common": ^12.0.1 + "@nestjs/config": ^12.0.0 + "@nestjs/core": ^12.0.1 + "@nestjs/cqrs": ^12.0.0 rxjs: ^7.1.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-org@workspace:packages/nestjs-org": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-org@workspace:packages/nestjs-org" - dependencies: - "@concepta/nestjs-access-control": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-event": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-invitation": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-password": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-user": "npm:^7.0.0-alpha.10" - "@concepta/typeorm-seeding": "npm:^4.0.0" - "@faker-js/faker": "npm:^8.4.1" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" - supertest: "npm:^6.3.4" - peerDependencies: - "@concepta/nestjs-crud": ^7.0.0-alpha.3 - class-transformer: "*" - class-validator: "*" typeorm: ^0.3.0 + peerDependenciesMeta: + "@concepta/nestjs-repository-typeorm": + optional: true + "@nestjs/cqrs": + optional: true languageName: unknown linkType: soft -"@concepta/nestjs-otp@npm:^7.0.0-alpha.10, @concepta/nestjs-otp@workspace:packages/nestjs-otp": +"@concepta/nestjs-otp@npm:8.0.0-alpha.10, @concepta/nestjs-otp@workspace:packages/nestjs-otp": version: 0.0.0-use.local resolution: "@concepta/nestjs-otp@workspace:packages/nestjs-otp" dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@concepta/nestjs-repository": "npm:8.0.0-alpha.10" + "@concepta/nestjs-repository-typeorm": "npm:8.0.0-alpha.10" "@concepta/typeorm-seeding": "npm:^4.0.0" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/testing": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" + "@faker-js/faker": "npm:^8.4.1" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/config": "npm:^12.0.0" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/cqrs": "npm:^12.0.0" + "@nestjs/testing": "npm:^12.0.1" + "@nestjs/typeorm": "npm:^12.0.1" + "@standard-schema/spec": "npm:^1.0.0" + vitest-mock-extended: "npm:^4.0.0" + zod: "npm:^4.4.3" peerDependencies: - class-transformer: "*" - class-validator: "*" + "@concepta/typeorm-seeding": ^4.0.0 + "@faker-js/faker": ^8.4.1 + "@nestjs/common": ^12.0.1 + "@nestjs/config": ^12.0.0 + "@nestjs/core": ^12.0.1 + "@nestjs/cqrs": ^12.0.0 + rxjs: ^7.1.0 typeorm: ^0.3.0 + peerDependenciesMeta: + "@concepta/typeorm-seeding": + optional: true + "@faker-js/faker": + optional: true + "@nestjs/cqrs": + optional: true + typeorm: + optional: true languageName: unknown linkType: soft -"@concepta/nestjs-password@npm:^7.0.0-alpha.10, @concepta/nestjs-password@workspace:packages/nestjs-password": +"@concepta/nestjs-password@npm:8.0.0-alpha.10, @concepta/nestjs-password@workspace:packages/nestjs-password": version: 0.0.0-use.local resolution: "@concepta/nestjs-password@workspace:packages/nestjs-password" dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/testing": "npm:^11.1.9" + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/config": "npm:^12.0.0" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/cqrs": "npm:^12.0.0" + "@nestjs/testing": "npm:^12.0.1" "@types/bcrypt": "npm:^5.0.2" "@types/zxcvbn": "npm:^4.4.4" bcrypt: "npm:^5.1.1" + vitest-mock-extended: "npm:^4.0.0" zxcvbn: "npm:^4.4.2" - languageName: unknown - linkType: soft - -"@concepta/nestjs-report@workspace:packages/nestjs-report": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-report@workspace:packages/nestjs-report" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-file": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-user": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - jest-mock-extended: "npm:^4.0.0" peerDependencies: - class-transformer: "*" - class-validator: "*" - rxjs: ^7.1.0 - typeorm: ^0.3.0 + "@nestjs/common": ^12.0.1 + "@nestjs/config": ^12.0.0 + "@nestjs/core": ^12.0.1 + "@nestjs/cqrs": ^12.0.0 + peerDependenciesMeta: + "@nestjs/cqrs": + optional: true languageName: unknown linkType: soft -"@concepta/nestjs-role@workspace:packages/nestjs-role": +"@concepta/nestjs-repository-typeorm@npm:8.0.0-alpha.10, @concepta/nestjs-repository-typeorm@workspace:packages/nestjs-repository-typeorm": version: 0.0.0-use.local - resolution: "@concepta/nestjs-role@workspace:packages/nestjs-role" + resolution: "@concepta/nestjs-repository-typeorm@workspace:packages/nestjs-repository-typeorm" dependencies: - "@concepta/nestjs-access-control": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-crud": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@concepta/nestjs-repository": "npm:8.0.0-alpha.10" "@concepta/typeorm-seeding": "npm:^4.0.0" "@faker-js/faker": "npm:^8.4.1" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" - supertest: "npm:^6.3.4" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/testing": "npm:^12.0.1" + "@nestjs/typeorm": "npm:^12.0.1" + "@tsyche/membrane": "npm:^0.7.0" + sqlite3: "npm:^5.1.4" + vitest-mock-extended: "npm:^4.0.0" peerDependencies: - class-transformer: "*" - class-validator: "*" + "@nestjs/common": ^12.0.1 typeorm: ^0.3.0 languageName: unknown linkType: soft -"@concepta/nestjs-samples@workspace:packages/nestjs-samples": +"@concepta/nestjs-repository@npm:8.0.0-alpha.10, @concepta/nestjs-repository@workspace:packages/nestjs-repository": version: 0.0.0-use.local - resolution: "@concepta/nestjs-samples@workspace:packages/nestjs-samples" - dependencies: - "@concepta/nestjs-auth-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-auth-local": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-auth-refresh": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-authentication": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-crud": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-email": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-event": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-logger": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-password": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-user": "npm:^7.0.0-alpha.10" - "@nestjs-modules/mailer": "npm:^1.11.2" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/platform-express": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" - "@types/supertest": "npm:^6.0.3" - jest-mock-extended: "npm:^4.0.0" - rxjs: "npm:^7.8.1" - supertest: "npm:^6.3.4" + resolution: "@concepta/nestjs-repository@workspace:packages/nestjs-repository" + dependencies: + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/testing": "npm:^12.0.1" + "@tsyche/membrane": "npm:^0.7.0" + vitest: "npm:^4.1.9" + vitest-mock-extended: "npm:^4.0.0" peerDependencies: - typeorm: ^0.3.0 - languageName: unknown - linkType: soft - -"@concepta/nestjs-swagger-ui@workspace:packages/nestjs-swagger-ui": - version: 0.0.0-use.local - resolution: "@concepta/nestjs-swagger-ui@workspace:packages/nestjs-swagger-ui" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" + "@nestjs/common": ^12.0.1 + "@nestjs/core": ^12.0.1 + rxjs: ^7.8.1 languageName: unknown linkType: soft -"@concepta/nestjs-typeorm-ext@npm:^7.0.0-alpha.10, @concepta/nestjs-typeorm-ext@workspace:packages/nestjs-typeorm-ext": +"@concepta/nestjs-role@workspace:packages/nestjs-role": version: 0.0.0-use.local - resolution: "@concepta/nestjs-typeorm-ext@workspace:packages/nestjs-typeorm-ext" + resolution: "@concepta/nestjs-role@workspace:packages/nestjs-role" dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@concepta/nestjs-crud": "npm:8.0.0-alpha.10" + "@concepta/nestjs-repository": "npm:8.0.0-alpha.10" + "@concepta/nestjs-repository-typeorm": "npm:8.0.0-alpha.10" "@concepta/typeorm-seeding": "npm:^4.0.0" "@faker-js/faker": "npm:^8.4.1" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/testing": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" - sqlite3: "npm:^5.1.4" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/cqrs": "npm:^12.0.0" + "@nestjs/swagger": "npm:^12.0.1" + "@nestjs/testing": "npm:^12.0.1" + "@nestjs/typeorm": "npm:^12.0.1" + supertest: "npm:^6.3.4" + vitest-mock-extended: "npm:^4.0.0" + zod: "npm:^4.4.3" peerDependencies: - class-transformer: "*" - class-validator: "*" + "@concepta/nestjs-crud": 8.0.0-alpha.10 + "@concepta/typeorm-seeding": ^4.0.0 + "@faker-js/faker": ^8.4.1 + "@nestjs/common": ^12.0.1 + "@nestjs/core": ^12.0.1 + "@nestjs/cqrs": ^12.0.0 typeorm: ^0.3.0 + peerDependenciesMeta: + "@concepta/typeorm-seeding": + optional: true + "@faker-js/faker": + optional: true + "@nestjs/cqrs": + optional: true + typeorm: + optional: true languageName: unknown linkType: soft -"@concepta/nestjs-user@npm:^7.0.0-alpha.10, @concepta/nestjs-user@workspace:packages/nestjs-user": +"@concepta/nestjs-user@npm:8.0.0-alpha.10, @concepta/nestjs-user@workspace:packages/nestjs-user": version: 0.0.0-use.local resolution: "@concepta/nestjs-user@workspace:packages/nestjs-user" dependencies: - "@concepta/nestjs-access-control": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-auth-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-authentication": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-crud": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-event": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-jwt": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-password": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" + "@concepta/nestjs-core": "npm:^8.0.0-alpha.10" + "@concepta/nestjs-crud": "npm:8.0.0-alpha.10" + "@concepta/nestjs-password": "npm:8.0.0-alpha.10" + "@concepta/nestjs-repository": "npm:8.0.0-alpha.10" + "@concepta/nestjs-repository-typeorm": "npm:8.0.0-alpha.10" "@concepta/typeorm-seeding": "npm:^4.0.0" "@faker-js/faker": "npm:^8.4.1" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/config": "npm:^4.0.2" - "@nestjs/core": "npm:^11.1.9" - "@nestjs/swagger": "npm:^11.2.2" - "@nestjs/testing": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" + "@nestjs/common": "npm:^12.0.1" + "@nestjs/config": "npm:^12.0.0" + "@nestjs/core": "npm:^12.0.1" + "@nestjs/cqrs": "npm:^12.0.0" + "@nestjs/swagger": "npm:^12.0.1" + "@nestjs/testing": "npm:^12.0.1" + "@nestjs/typeorm": "npm:^12.0.1" accesscontrol: "npm:^2.2.1" + rxjs: "npm:^7.8.1" supertest: "npm:^6.3.4" + vitest-mock-extended: "npm:^4.0.0" + zod: "npm:^4.4.3" peerDependencies: - class-transformer: "*" - class-validator: "*" + "@concepta/nestjs-crud": 8.0.0-alpha.10 + "@nestjs/common": ^12.0.1 + "@nestjs/config": ^12.0.0 + "@nestjs/core": ^12.0.1 + "@nestjs/cqrs": ^12.0.0 typeorm: ^0.3.0 + peerDependenciesMeta: + "@nestjs/cqrs": + optional: true languageName: unknown linkType: soft @@ -1913,25 +818,6 @@ __metadata: languageName: node linkType: hard -"@concepta/typeorm-common@workspace:packages/typeorm-common": - version: 0.0.0-use.local - resolution: "@concepta/typeorm-common@workspace:packages/typeorm-common" - dependencies: - "@concepta/nestjs-common": "npm:^7.0.0-alpha.10" - "@concepta/nestjs-typeorm-ext": "npm:^7.0.0-alpha.10" - "@concepta/typeorm-seeding": "npm:^4.0.0" - "@faker-js/faker": "npm:^8.4.1" - "@nestjs/common": "npm:^11.1.9" - "@nestjs/typeorm": "npm:^11.0.0" - jest-mock-extended: "npm:^4.0.0" - peerDependencies: - "@nestjs/testing": ^10.4.1 - class-transformer: "*" - class-validator: "*" - typeorm: ^0.3.0 - languageName: unknown - linkType: soft - "@concepta/typeorm-seeding@npm:^4.0.0": version: 4.0.0 resolution: "@concepta/typeorm-seeding@npm:4.0.0" @@ -1949,15 +835,6 @@ __metadata: languageName: node linkType: hard -"@cspotcode/source-map-support@npm:^0.8.0": - version: 0.8.1 - resolution: "@cspotcode/source-map-support@npm:0.8.1" - dependencies: - "@jridgewell/trace-mapping": "npm:0.3.9" - checksum: 10c0/05c5368c13b662ee4c122c7bfbe5dc0b613416672a829f3e78bc49a357a197e0218d6e74e7c66cfcd04e15a179acab080bd3c69658c9fbefd0e1ccd950a07fc6 - languageName: node - linkType: hard - "@css-inline/css-inline-darwin-arm64@npm:0.13.0": version: 0.13.0 resolution: "@css-inline/css-inline-darwin-arm64@npm:0.13.0" @@ -2048,62 +925,62 @@ __metadata: linkType: hard "@darraghor/eslint-plugin-nestjs-typed@npm:^6.9.3": - version: 6.9.3 - resolution: "@darraghor/eslint-plugin-nestjs-typed@npm:6.9.3" + version: 6.18.0 + resolution: "@darraghor/eslint-plugin-nestjs-typed@npm:6.18.0" dependencies: - "@typescript-eslint/scope-manager": "npm:^8.44.1" - "@typescript-eslint/type-utils": "npm:^8.44.1" - "@typescript-eslint/utils": "npm:^8.44.1" + "@typescript-eslint/scope-manager": "npm:^8.48.1" + "@typescript-eslint/type-utils": "npm:^8.48.1" + "@typescript-eslint/utils": "npm:^8.48.1" eslint-module-utils: "npm:2.12.1" - glob: "npm:11.0.3" + glob: "npm:11.1.0" reflect-metadata: "npm:0.2.2" ts-api-utils: "npm:2.1.0" peerDependencies: "@typescript-eslint/parser": ^7.0.0 || ^8.0.0 class-validator: "*" eslint: ">=9.18.0" - checksum: 10c0/c177a0aa7d18c893f409d5b1707dc9459e0bbce2dd5cea9635b6f87b2db6aa20fb3e28eeefad2a182dab7e0a60c7f9b12f40764438b59df8d580953064bdddd5 + checksum: 10c0/388722fdb8537448313f9d86018946ba2e96acf8046c3ee1a91ef7df3a78edda469070cfe1e2e6a44ed4e34c6f2205f7a58692162a49ff0d8823306b985cd309 languageName: node linkType: hard -"@emnapi/core@npm:^1.4.3": - version: 1.7.1 - resolution: "@emnapi/core@npm:1.7.1" +"@emnapi/core@npm:1.11.1": + version: 1.11.1 + resolution: "@emnapi/core@npm:1.11.1" dependencies: - "@emnapi/wasi-threads": "npm:1.1.0" + "@emnapi/wasi-threads": "npm:1.2.2" tslib: "npm:^2.4.0" - checksum: 10c0/f3740be23440b439333e3ae3832163f60c96c4e35337f3220ceba88f36ee89a57a871d27c94eb7a9ff98a09911ed9a2089e477ab549f4d30029f8b907f84a351 + checksum: 10c0/2c6defdac2d1d26090384655d7d6c9614fa553853b1760597686749e9375dc2aa0dae80a2615b81c254600f5d531d07d8466cde0d331a8caae64b93f3ca5937e languageName: node linkType: hard -"@emnapi/runtime@npm:^1.4.3": - version: 1.7.1 - resolution: "@emnapi/runtime@npm:1.7.1" +"@emnapi/runtime@npm:1.11.1": + version: 1.11.1 + resolution: "@emnapi/runtime@npm:1.11.1" dependencies: tslib: "npm:^2.4.0" - checksum: 10c0/26b851cd3e93877d8732a985a2ebf5152325bbacc6204ef5336a47359dedcc23faeb08cdfcb8bb389b5401b3e894b882bc1a1e55b4b7c1ed1e67c991a760ddd5 + checksum: 10c0/04332fb62076afc440aa23316c04bec42f584ca8b074e5507d08e2b33a47cbe0493b1aadb8f3c1057b64ae1e17f5bde1a7bc37f7facc9d0bc25c18197cbd366f languageName: node linkType: hard -"@emnapi/wasi-threads@npm:1.1.0": - version: 1.1.0 - resolution: "@emnapi/wasi-threads@npm:1.1.0" +"@emnapi/wasi-threads@npm:1.2.2": + version: 1.2.2 + resolution: "@emnapi/wasi-threads@npm:1.2.2" dependencies: tslib: "npm:^2.4.0" - checksum: 10c0/e6d54bf2b1e64cdd83d2916411e44e579b6ae35d5def0dea61a3c452d9921373044dff32a8b8473ae60c80692bdc39323e98b96a3f3d87ba6886b24dd0ef7ca1 + checksum: 10c0/f0dc8269d6b20ae5a7c7b36e7a6a333452009d461038ef4febb29da2f3f78c1e2b1576d7e8970a5c5789ed3caedc1f80f5b0c2a5373bdaf8d03b20432bb55747 languageName: node linkType: hard -"@es-joy/jsdoccomment@npm:~0.76.0": - version: 0.76.0 - resolution: "@es-joy/jsdoccomment@npm:0.76.0" +"@es-joy/jsdoccomment@npm:~0.78.0": + version: 0.78.0 + resolution: "@es-joy/jsdoccomment@npm:0.78.0" dependencies: "@types/estree": "npm:^1.0.8" - "@typescript-eslint/types": "npm:^8.46.0" + "@typescript-eslint/types": "npm:^8.46.4" comment-parser: "npm:1.4.1" esquery: "npm:^1.6.0" - jsdoc-type-pratt-parser: "npm:~6.10.0" - checksum: 10c0/8fe4edec7d60562787ea8c77193ebe8737a9e28ec3143d383506b63890d0ffd45a2813e913ad1f00f227cb10e3a1fb913e5a696b33d499dc564272ff1a6f3fdb + jsdoc-type-pratt-parser: "npm:~7.0.0" + checksum: 10c0/be18b8149303e8e7c9414b0b0453a0fa959c1c8db6f721b75178336e01b65a9f251db98ecfedfb1b3cfa5e717f3e2abdb06a0f8dbe45d3330a62262c5331c327 languageName: node linkType: hard @@ -2114,32 +991,32 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0": - version: 4.9.0 - resolution: "@eslint-community/eslint-utils@npm:4.9.0" +"@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": + version: 4.9.1 + resolution: "@eslint-community/eslint-utils@npm:4.9.1" dependencies: eslint-visitor-keys: "npm:^3.4.3" peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10c0/8881e22d519326e7dba85ea915ac7a143367c805e6ba1374c987aa2fbdd09195cc51183d2da72c0e2ff388f84363e1b220fd0d19bef10c272c63455162176817 + checksum: 10c0/dc4ab5e3e364ef27e33666b11f4b86e1a6c1d7cbf16f0c6ff87b1619b3562335e9201a3d6ce806221887ff780ec9d828962a290bb910759fd40a674686503f02 languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.12.1": +"@eslint-community/regexpp@npm:^4.12.1, @eslint-community/regexpp@npm:^4.12.2": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard -"@eslint/config-array@npm:^0.21.1": - version: 0.21.1 - resolution: "@eslint/config-array@npm:0.21.1" +"@eslint/config-array@npm:^0.21.2": + version: 0.21.2 + resolution: "@eslint/config-array@npm:0.21.2" dependencies: "@eslint/object-schema": "npm:^2.1.7" debug: "npm:^4.3.1" - minimatch: "npm:^3.1.2" - checksum: 10c0/2f657d4edd6ddcb920579b72e7a5b127865d4c3fb4dda24f11d5c4f445a93ca481aebdbd6bf3291c536f5d034458dbcbb298ee3b698bc6c9dd02900fe87eec3c + minimatch: "npm:^3.1.5" + checksum: 10c0/89dfe815d18456177c0a1f238daf4593107fd20298b3598e0103054360d3b8d09d967defd8318f031185d68df1f95cfa68becf1390a9c5c6887665f1475142e3 languageName: node linkType: hard @@ -2161,27 +1038,27 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^3.3.1": - version: 3.3.1 - resolution: "@eslint/eslintrc@npm:3.3.1" +"@eslint/eslintrc@npm:^3.3.5": + version: 3.3.5 + resolution: "@eslint/eslintrc@npm:3.3.5" dependencies: - ajv: "npm:^6.12.4" + ajv: "npm:^6.14.0" debug: "npm:^4.3.2" espree: "npm:^10.0.1" globals: "npm:^14.0.0" ignore: "npm:^5.2.0" import-fresh: "npm:^3.2.1" - js-yaml: "npm:^4.1.0" - minimatch: "npm:^3.1.2" + js-yaml: "npm:^4.1.1" + minimatch: "npm:^3.1.5" strip-json-comments: "npm:^3.1.1" - checksum: 10c0/b0e63f3bc5cce4555f791a4e487bf999173fcf27c65e1ab6e7d63634d8a43b33c3693e79f192cbff486d7df1be8ebb2bd2edc6e70ddd486cbfa84a359a3e3b41 + checksum: 10c0/9fb9f1ca65e46d6173966e3aaa5bd353e3a65d7f1f582bebf77f578fab7d7960a399fac1ecfb1e7d52bd61f5cefd6531087ca52a3a3c388f2e1b4f1ebd3da8b7 languageName: node linkType: hard -"@eslint/js@npm:9.39.1, @eslint/js@npm:^9.16.0, @eslint/js@npm:^9.39.1": - version: 9.39.1 - resolution: "@eslint/js@npm:9.39.1" - checksum: 10c0/6f7f26f8cdb7ad6327bbf9741973b6278eb946f18f70e35406e88194b0d5c522d0547a34a02f2a208eec95c5d1388cdf7ccb20039efd2e4cb6655615247a50f1 +"@eslint/js@npm:9.39.4, @eslint/js@npm:^9.16.0, @eslint/js@npm:^9.39.1": + version: 9.39.4 + resolution: "@eslint/js@npm:9.39.4" + checksum: 10c0/5aa7dea2cbc5decf7f5e3b0c6f86a084ccee0f792d288ca8e839f8bc1b64e03e227068968e49b26096e6f71fd857ab6e42691d1b993826b9a3883f1bdd7a0e46 languageName: node linkType: hard @@ -2291,22 +1168,6 @@ __metadata: languageName: node linkType: hard -"@fastify/ajv-compiler@npm:^1.0.0": - version: 1.1.0 - resolution: "@fastify/ajv-compiler@npm:1.1.0" - dependencies: - ajv: "npm:^6.12.6" - checksum: 10c0/e8c4c468f74db2d5d14ce3e765e8317e302413960f63474e6ef18039b1d711e2712e87808035f3e075b9fdf8f47af1b481979eb1ca8d82e0773ff6a9fd49d903 - languageName: node - linkType: hard - -"@fastify/error@npm:^2.0.0": - version: 2.0.0 - resolution: "@fastify/error@npm:2.0.0" - checksum: 10c0/32b98cb663e462e3d60a31a46306481f7b84657cfdd301dcd7fa9f77389f180e51c641dc4c6900d423daa264e70172bf23493fae031b6af4844b6cc7f17fe77d - languageName: node - linkType: hard - "@gar/promisify@npm:^1.0.1": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -2314,20 +1175,30 @@ __metadata: languageName: node linkType: hard -"@humanfs/core@npm:^0.19.1": - version: 0.19.1 - resolution: "@humanfs/core@npm:0.19.1" - checksum: 10c0/aa4e0152171c07879b458d0e8a704b8c3a89a8c0541726c6b65b81e84fd8b7564b5d6c633feadc6598307d34564bd53294b533491424e8e313d7ab6c7bc5dc67 +"@humanfs/core@npm:^0.19.2": + version: 0.19.2 + resolution: "@humanfs/core@npm:0.19.2" + dependencies: + "@humanfs/types": "npm:^0.15.0" + checksum: 10c0/d0a1d52d7b30c27d49475a53072d1510b81c5803e44b342fb8faf3887f1aa27593a1e6dc76a45268e7892d3f4e198146659281f6b6d55eacf3fd5a38bac30c5c languageName: node linkType: hard "@humanfs/node@npm:^0.16.6": - version: 0.16.7 - resolution: "@humanfs/node@npm:0.16.7" + version: 0.16.8 + resolution: "@humanfs/node@npm:0.16.8" dependencies: - "@humanfs/core": "npm:^0.19.1" + "@humanfs/core": "npm:^0.19.2" + "@humanfs/types": "npm:^0.15.0" "@humanwhocodes/retry": "npm:^0.4.0" - checksum: 10c0/9f83d3cf2cfa37383e01e3cdaead11cd426208e04c44adcdd291aa983aaf72d7d3598844d2fe9ce54896bb1bf8bd4b56883376611c8905a19c44684642823f30 + checksum: 10c0/56140579db811af4e160b195d45d0f29acf644d192c93fe24c9e594ebf06f19dfc157494a07c84540b8a071c0e4b37209c2362765d31734f4d0be869c2422e25 + languageName: node + linkType: hard + +"@humanfs/types@npm:^0.15.0": + version: 0.15.0 + resolution: "@humanfs/types@npm:0.15.0" + checksum: 10c0/fc26b9a024b0e55f7eaf64036df94345bf5d36d6a41ef80ef38e78f1f7430ce26cf435af736adae58913baae18eac3f38c18739054a3d379102015978eae862e languageName: node linkType: hard @@ -2352,656 +1223,504 @@ __metadata: languageName: node linkType: hard -"@inquirer/ansi@npm:^1.0.2": - version: 1.0.2 - resolution: "@inquirer/ansi@npm:1.0.2" - checksum: 10c0/8e408cc628923aa93402e66657482ccaa2ad5174f9db526d9a8b443f9011e9cd8f70f0f534f5fe3857b8a9df3bce1e25f66c96f666d6750490bd46e2b4f3b829 +"@inquirer/ansi@npm:^2.0.7": + version: 2.0.7 + resolution: "@inquirer/ansi@npm:2.0.7" + checksum: 10c0/a574f97a899f0d9346fa26b528b3f4a9ba6dcb9172288efb6b4314d8486470ed53d2f538200f66a25b843c6e0cbf83688c6d5174a8dc6eca853b291b09609c5a languageName: node linkType: hard -"@inquirer/checkbox@npm:^4.1.2, @inquirer/checkbox@npm:^4.2.0": - version: 4.3.2 - resolution: "@inquirer/checkbox@npm:4.3.2" +"@inquirer/checkbox@npm:^5.2.1": + version: 5.2.2 + resolution: "@inquirer/checkbox@npm:5.2.2" dependencies: - "@inquirer/ansi": "npm:^1.0.2" - "@inquirer/core": "npm:^10.3.2" - "@inquirer/figures": "npm:^1.0.15" - "@inquirer/type": "npm:^3.0.10" - yoctocolors-cjs: "npm:^2.1.3" + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/core": "npm:^12.0.0" + "@inquirer/figures": "npm:^2.0.8" + "@inquirer/type": "npm:^4.0.7" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/771d23bc6b16cd5c21a4f1073e98e306147f90c0e2487fe887ee054b8bf86449f1f9e6e6f9c218c1aa45ae3be2533197d53654abe9c0545981aebb0920d5f471 + checksum: 10c0/b71cc4e06a69e3a4bcc3ebbd29476d9ea205299837e8677939135eda59ec9fd9eb8d2250741a6d5060754d75b7003f74d26f44172618b55f0c731b0a5ebe1a54 languageName: node linkType: hard -"@inquirer/confirm@npm:^5.1.14, @inquirer/confirm@npm:^5.1.6": - version: 5.1.21 - resolution: "@inquirer/confirm@npm:5.1.21" +"@inquirer/checkbox@npm:^5.2.3": + version: 5.2.3 + resolution: "@inquirer/checkbox@npm:5.2.3" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/type": "npm:^3.0.10" + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/core": "npm:^12.0.1" + "@inquirer/figures": "npm:^2.0.8" + "@inquirer/type": "npm:^4.1.0" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/a95bbdbb17626c484735a4193ed6b6a6fbb078cf62116ec8e1667f647e534dd6618e688ecc7962585efcc56881b544b8c53db3914599bbf2ab842e7f224b0fca + checksum: 10c0/30c67c95d4097b0782516c7bac74aedd330a4ec9ab2638496f6d8b08e0a0e9935103f8f66156fe9c51c18a0aaca77036e0badf964501e30b9de5c52f26196e22 languageName: node linkType: hard -"@inquirer/core@npm:^10.3.2": - version: 10.3.2 - resolution: "@inquirer/core@npm:10.3.2" +"@inquirer/confirm@npm:^6.1.1": + version: 6.2.0 + resolution: "@inquirer/confirm@npm:6.2.0" dependencies: - "@inquirer/ansi": "npm:^1.0.2" - "@inquirer/figures": "npm:^1.0.15" - "@inquirer/type": "npm:^3.0.10" - cli-width: "npm:^4.1.0" - mute-stream: "npm:^2.0.0" - signal-exit: "npm:^4.1.0" - wrap-ansi: "npm:^6.2.0" - yoctocolors-cjs: "npm:^2.1.3" + "@inquirer/core": "npm:^12.0.0" + "@inquirer/type": "npm:^4.0.7" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/f0f27e07fe288e01e3949b4ad216c19751f025ce77c610366e08d8b0f7a135d064dc074732031d251584b454c576f1e5c849e4abe259186dd5d4974c8f85c13e + checksum: 10c0/dd673ce6db0e0e7ae96c5a4520ae24cb2e10f33e2347bdc99805aaaccd310c05a338c87f56d350a290ca60d9c65bbd1db8fd500e432ab6074d39140cafc793d6 languageName: node linkType: hard -"@inquirer/editor@npm:^4.2.15, @inquirer/editor@npm:^4.2.7": - version: 4.2.23 - resolution: "@inquirer/editor@npm:4.2.23" +"@inquirer/confirm@npm:^6.3.0": + version: 6.3.0 + resolution: "@inquirer/confirm@npm:6.3.0" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/external-editor": "npm:^1.0.3" - "@inquirer/type": "npm:^3.0.10" + "@inquirer/core": "npm:^12.0.1" + "@inquirer/type": "npm:^4.1.0" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/aa02028ee35ae039a4857b6a9490d295a1b3558f042e7454dee0aa36fbc83ac25586a2dfe0b46a5ea7ea151e3f5cb97a8ee6229131b4619f3b3466ad74b9519f + checksum: 10c0/4efebb84000ac085aac874f914fbcd130bdf3c12c68632c98a4f2793442d13e9b89f6c07976db7c6903a35495f4dbeda7c3107bb3ac27f9628ea9ff51ef05793 languageName: node linkType: hard -"@inquirer/expand@npm:^4.0.17, @inquirer/expand@npm:^4.0.9": - version: 4.0.23 - resolution: "@inquirer/expand@npm:4.0.23" +"@inquirer/core@npm:^12.0.0": + version: 12.0.0 + resolution: "@inquirer/core@npm:12.0.0" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/type": "npm:^3.0.10" - yoctocolors-cjs: "npm:^2.1.3" + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/figures": "npm:^2.0.8" + "@inquirer/type": "npm:^4.0.7" + cli-width: "npm:^4.1.0" + fast-wrap-ansi: "npm:^0.2.0" + mute-stream: "npm:^3.0.0" + signal-exit: "npm:^4.1.0" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/294c92652760c3d1a46c4b900a99fd553ea9e5734ba261d4e71d7b8499d86a8b15e38a2467ddb7c95c197daf7e472bdab209fc3f7c38cbc70842cd291f4ce39d + checksum: 10c0/070f1978e5f306f5e7588c1096ac11481276635233f6f683dd1fa0e2236faf26a00709a09aa37af9479c3bd94645e73d8000cf6834527883a5bc7007832fd5ad languageName: node linkType: hard -"@inquirer/external-editor@npm:^1.0.3": - version: 1.0.3 - resolution: "@inquirer/external-editor@npm:1.0.3" +"@inquirer/core@npm:^12.0.1": + version: 12.0.1 + resolution: "@inquirer/core@npm:12.0.1" dependencies: - chardet: "npm:^2.1.1" - iconv-lite: "npm:^0.7.0" + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/figures": "npm:^2.0.8" + "@inquirer/type": "npm:^4.1.0" + cli-width: "npm:^4.1.0" + fast-wrap-ansi: "npm:^0.2.0" + mute-stream: "npm:^3.0.0" + signal-exit: "npm:^4.1.0" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/82951cb7f3762dd78cca2ea291396841e3f4adfe26004b5badfed1cec4b6a04bb567dff94d0e41b35c61bdd7957317c64c22f58074d14b238d44e44d9e420019 - languageName: node - linkType: hard - -"@inquirer/figures@npm:^1.0.15": - version: 1.0.15 - resolution: "@inquirer/figures@npm:1.0.15" - checksum: 10c0/6e39a040d260ae234ae220180b7994ff852673e20be925f8aa95e78c7934d732b018cbb4d0ec39e600a410461bcb93dca771e7de23caa10630d255692e440f69 + checksum: 10c0/425124870ddd7798ceea7edb4fe7ee3c76c79472b1e51249ed5b460e9ba56ec3a794d989ad0a8b3ec7b1bed0dacf847d7e22ead7d08027b44818bf5e6eda21ff languageName: node linkType: hard -"@inquirer/input@npm:^4.1.6, @inquirer/input@npm:^4.2.1": - version: 4.3.1 - resolution: "@inquirer/input@npm:4.3.1" +"@inquirer/editor@npm:^5.2.2": + version: 5.3.0 + resolution: "@inquirer/editor@npm:5.3.0" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/type": "npm:^3.0.10" + "@inquirer/core": "npm:^12.0.0" + "@inquirer/external-editor": "npm:^3.0.4" + "@inquirer/type": "npm:^4.0.7" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/9e81d6ae56e5b59f96475ae1327e7e7beeef0d917b83762e0c2ed5a75239ad6b1a39fc05553ce45fe6f6de49681dade8704b5f1c11c2f555663a74d0ac998af3 + checksum: 10c0/151b1e0c4405d94d7d8e16618ba39de6e32efee563f99eb6c4c2225901467a211230327baad9c0940fae4a3c1e7f443908bf0849e509fbe5cd288f939d62d9f2 languageName: node linkType: hard -"@inquirer/number@npm:^3.0.17, @inquirer/number@npm:^3.0.9": - version: 3.0.23 - resolution: "@inquirer/number@npm:3.0.23" +"@inquirer/editor@npm:^5.3.1": + version: 5.3.1 + resolution: "@inquirer/editor@npm:5.3.1" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/type": "npm:^3.0.10" + "@inquirer/core": "npm:^12.0.1" + "@inquirer/external-editor": "npm:^3.0.4" + "@inquirer/type": "npm:^4.1.0" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/3944a524be2a2e0834822a0e483f2e2fd56ad597b5feeca2155b956821f88e22e07ce3816f66113b040601636ed7146865aee7d7afb2a06939acc77491330ccc + checksum: 10c0/7261667c9b4fa43bb230755188f3178c1afcac3fcb07ffedd5b4a326db390a79597a0844013d89163c03d0ca98b488872c6152ba020ff55914175cab9284da6c languageName: node linkType: hard -"@inquirer/password@npm:^4.0.17, @inquirer/password@npm:^4.0.9": - version: 4.0.23 - resolution: "@inquirer/password@npm:4.0.23" +"@inquirer/expand@npm:^5.1.1": + version: 5.1.2 + resolution: "@inquirer/expand@npm:5.1.2" dependencies: - "@inquirer/ansi": "npm:^1.0.2" - "@inquirer/core": "npm:^10.3.2" - "@inquirer/type": "npm:^3.0.10" + "@inquirer/core": "npm:^12.0.0" + "@inquirer/type": "npm:^4.0.7" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/9fd3d0462d02735bb1521c4e221d057a94d9aaac308e9a192e59d6c1e8efc707c2376ab627151d589bc3633f6b14b74b60b91c3d473a32adfd100ef1f6cfdef7 + checksum: 10c0/3ca2cd5e42802b0c7daad15c2d44c16ef04fffb6f434e676185afba9868aa561930c5a19032cae0066abfe7820949ed2776ca4d8bf03519009ef2cc7472db900 languageName: node linkType: hard -"@inquirer/prompts@npm:7.3.2": - version: 7.3.2 - resolution: "@inquirer/prompts@npm:7.3.2" +"@inquirer/expand@npm:^5.1.3": + version: 5.1.3 + resolution: "@inquirer/expand@npm:5.1.3" dependencies: - "@inquirer/checkbox": "npm:^4.1.2" - "@inquirer/confirm": "npm:^5.1.6" - "@inquirer/editor": "npm:^4.2.7" - "@inquirer/expand": "npm:^4.0.9" - "@inquirer/input": "npm:^4.1.6" - "@inquirer/number": "npm:^3.0.9" - "@inquirer/password": "npm:^4.0.9" - "@inquirer/rawlist": "npm:^4.0.9" - "@inquirer/search": "npm:^3.0.9" - "@inquirer/select": "npm:^4.0.9" + "@inquirer/core": "npm:^12.0.1" + "@inquirer/type": "npm:^4.1.0" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/a318d7c2a963f753f4868151f2ce5673e214f3a6597430e712bc59ef9605c831b71a6b52a9c5ea2f312b23063d2ee9fd633e127cdc9e4999e95ef15a5e90c7e1 + checksum: 10c0/da585217cfa6f042c89b5c5d82040a598c8a2b54d3f02f8385d95293ef324d993bb7c306fd4b414496c2763e178f9178d45767ac6d598c687bf76382e748a670 languageName: node linkType: hard -"@inquirer/prompts@npm:7.8.0": - version: 7.8.0 - resolution: "@inquirer/prompts@npm:7.8.0" +"@inquirer/external-editor@npm:^3.0.4": + version: 3.0.4 + resolution: "@inquirer/external-editor@npm:3.0.4" dependencies: - "@inquirer/checkbox": "npm:^4.2.0" - "@inquirer/confirm": "npm:^5.1.14" - "@inquirer/editor": "npm:^4.2.15" - "@inquirer/expand": "npm:^4.0.17" - "@inquirer/input": "npm:^4.2.1" - "@inquirer/number": "npm:^3.0.17" - "@inquirer/password": "npm:^4.0.17" - "@inquirer/rawlist": "npm:^4.1.5" - "@inquirer/search": "npm:^3.1.0" - "@inquirer/select": "npm:^4.3.1" + chardet: "npm:^2.1.1" + iconv-lite: "npm:^0.7.2" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/870496b7e9c5aa09198f47e95b5e73af4363c5736bfd1ef0e4722859cfd0dff56e0302e15bc3f7dcc4af7efb4654a7f6c3eb0351deb4a09f2094688c86136f55 + checksum: 10c0/ec50397f0132aca03e026690466dfb1a63203744926513eb26e0d950004e4c2359bfab24405ea7afa50d3cb1620707b81b1c2751bd5dbee2f8f7d1905be61f05 + languageName: node + linkType: hard + +"@inquirer/figures@npm:^2.0.8": + version: 2.0.8 + resolution: "@inquirer/figures@npm:2.0.8" + checksum: 10c0/49fd3b196c783c09d64c9f5692f9c26d0740169b99b55c97adc00e0f4e5665ec4097ab9bff40360c29c4b6b348b315b4eaadbab6cdeb5cb8335a8ac44ec62168 languageName: node linkType: hard -"@inquirer/rawlist@npm:^4.0.9, @inquirer/rawlist@npm:^4.1.5": - version: 4.1.11 - resolution: "@inquirer/rawlist@npm:4.1.11" +"@inquirer/input@npm:^5.1.2": + version: 5.1.3 + resolution: "@inquirer/input@npm:5.1.3" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/type": "npm:^3.0.10" - yoctocolors-cjs: "npm:^2.1.3" + "@inquirer/core": "npm:^12.0.0" + "@inquirer/type": "npm:^4.0.7" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/33792b40cd0fbf77f547c9c4805087dd1188342c6a5ca512c73b0b6c4d132225fc5ae8bc4fd5035309484da3698a90fcef17aad100b9ae57624fda7b07d92227 + checksum: 10c0/0a6d200ca16a0e422a51af4f5a879448850bb2b43bb7e4ae7815cd56c6ca9083ed333dedb7700009f61d3d227bb84e46a6cdd74f7863057b9134a122239acfe9 languageName: node linkType: hard -"@inquirer/search@npm:^3.0.9, @inquirer/search@npm:^3.1.0": - version: 3.2.2 - resolution: "@inquirer/search@npm:3.2.2" +"@inquirer/input@npm:^5.1.4": + version: 5.1.4 + resolution: "@inquirer/input@npm:5.1.4" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/figures": "npm:^1.0.15" - "@inquirer/type": "npm:^3.0.10" - yoctocolors-cjs: "npm:^2.1.3" + "@inquirer/core": "npm:^12.0.1" + "@inquirer/type": "npm:^4.1.0" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/e7849663a51fe95e3ce99d274c815b8dc8933d6a5ddcaaf6130bf43f5f10316062c9f7a37c2923a14b8dcd09d202b0bb9cc3eaf0adb0336f6a704ea52e03ef8c + checksum: 10c0/35856a48c2134e1fcfc0391af65854e75895c19bc098d59b274743655e55cf3d9bc6b682e25515fa8a6acdbc267c8e85ce7ae4791809a3d3f6f72538c1740417 languageName: node linkType: hard -"@inquirer/select@npm:^4.0.9, @inquirer/select@npm:^4.3.1": - version: 4.4.2 - resolution: "@inquirer/select@npm:4.4.2" +"@inquirer/number@npm:^4.1.1": + version: 4.2.0 + resolution: "@inquirer/number@npm:4.2.0" dependencies: - "@inquirer/ansi": "npm:^1.0.2" - "@inquirer/core": "npm:^10.3.2" - "@inquirer/figures": "npm:^1.0.15" - "@inquirer/type": "npm:^3.0.10" - yoctocolors-cjs: "npm:^2.1.3" + "@inquirer/core": "npm:^12.0.0" + "@inquirer/type": "npm:^4.0.7" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/6978a5a92928b4d439dd6b688f2db51fd49be209f24be224bb81ed8f75b76e0715e79bdb05dab2a33bbdc7091c779a99f8603fe0ca199f059528ca2b1d0d4944 + checksum: 10c0/ccde39bc940b324874d456b9dbf110e6c9d501a904229ea0f858689224e1d38e6b419932779d15c11b28af35f7b7b955337b1d173348a78bf999602f87eaf68f languageName: node linkType: hard -"@inquirer/type@npm:^3.0.10": - version: 3.0.10 - resolution: "@inquirer/type@npm:3.0.10" +"@inquirer/number@npm:^4.2.1": + version: 4.2.1 + resolution: "@inquirer/number@npm:4.2.1" + dependencies: + "@inquirer/core": "npm:^12.0.1" + "@inquirer/type": "npm:^4.1.0" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10c0/a846c7a570e3bf2657d489bcc5dcdc3179d24c7323719de1951dcdb722400ac76e5b2bfe9765d0a789bc1921fac810983d7999f021f30a78a6a174c23fc78dc9 - languageName: node - linkType: hard - -"@isaacs/balanced-match@npm:^4.0.1": - version: 4.0.1 - resolution: "@isaacs/balanced-match@npm:4.0.1" - checksum: 10c0/7da011805b259ec5c955f01cee903da72ad97c5e6f01ca96197267d3f33103d5b2f8a1af192140f3aa64526c593c8d098ae366c2b11f7f17645d12387c2fd420 - languageName: node - linkType: hard - -"@isaacs/brace-expansion@npm:^5.0.0": - version: 5.0.0 - resolution: "@isaacs/brace-expansion@npm:5.0.0" - dependencies: - "@isaacs/balanced-match": "npm:^4.0.1" - checksum: 10c0/b4d4812f4be53afc2c5b6c545001ff7a4659af68d4484804e9d514e183d20269bb81def8682c01a22b17c4d6aed14292c8494f7d2ac664e547101c1a905aa977 - languageName: node - linkType: hard - -"@isaacs/cliui@npm:^8.0.2": - version: 8.0.2 - resolution: "@isaacs/cliui@npm:8.0.2" - dependencies: - string-width: "npm:^5.1.2" - string-width-cjs: "npm:string-width@^4.2.0" - strip-ansi: "npm:^7.0.1" - strip-ansi-cjs: "npm:strip-ansi@^6.0.1" - wrap-ansi: "npm:^8.1.0" - wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0" - checksum: 10c0/b1bf42535d49f11dc137f18d5e4e63a28c5569de438a221c369483731e9dac9fb797af554e8bf02b6192d1e5eba6e6402cf93900c3d0ac86391d00d04876789e - languageName: node - linkType: hard - -"@isaacs/fs-minipass@npm:^4.0.0": - version: 4.0.1 - resolution: "@isaacs/fs-minipass@npm:4.0.1" - dependencies: - minipass: "npm:^7.0.4" - checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 - languageName: node - linkType: hard - -"@istanbuljs/load-nyc-config@npm:^1.0.0": - version: 1.1.0 - resolution: "@istanbuljs/load-nyc-config@npm:1.1.0" - dependencies: - camelcase: "npm:^5.3.1" - find-up: "npm:^4.1.0" - get-package-type: "npm:^0.1.0" - js-yaml: "npm:^3.13.1" - resolve-from: "npm:^5.0.0" - checksum: 10c0/dd2a8b094887da5a1a2339543a4933d06db2e63cbbc2e288eb6431bd832065df0c099d091b6a67436e71b7d6bf85f01ce7c15f9253b4cbebcc3b9a496165ba42 - languageName: node - linkType: hard - -"@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3": - version: 0.1.3 - resolution: "@istanbuljs/schema@npm:0.1.3" - checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a - languageName: node - linkType: hard - -"@jest/console@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/console@npm:30.2.0" - dependencies: - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - jest-message-util: "npm:30.2.0" - jest-util: "npm:30.2.0" - slash: "npm:^3.0.0" - checksum: 10c0/ecf7ca43698863095500710a5aa08c38b1731c9d89ba32f4d9da7424b53ce1e86b3db8ccbbb27b695f49b4f94bc1d7d0c63c751d73c83d59488a682bc98b7e70 + checksum: 10c0/06f23b36bf325c32ca218a669106c7e43422fd01e92b26caec6adc7eeeedef965275a029626fb0b9751f32a8d2d18c60347d141ba5a71872560b64332f977068 languageName: node linkType: hard -"@jest/core@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/core@npm:30.2.0" +"@inquirer/password@npm:^5.1.1": + version: 5.1.2 + resolution: "@inquirer/password@npm:5.1.2" dependencies: - "@jest/console": "npm:30.2.0" - "@jest/pattern": "npm:30.0.1" - "@jest/reporters": "npm:30.2.0" - "@jest/test-result": "npm:30.2.0" - "@jest/transform": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - ansi-escapes: "npm:^4.3.2" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - exit-x: "npm:^0.2.2" - graceful-fs: "npm:^4.2.11" - jest-changed-files: "npm:30.2.0" - jest-config: "npm:30.2.0" - jest-haste-map: "npm:30.2.0" - jest-message-util: "npm:30.2.0" - jest-regex-util: "npm:30.0.1" - jest-resolve: "npm:30.2.0" - jest-resolve-dependencies: "npm:30.2.0" - jest-runner: "npm:30.2.0" - jest-runtime: "npm:30.2.0" - jest-snapshot: "npm:30.2.0" - jest-util: "npm:30.2.0" - jest-validate: "npm:30.2.0" - jest-watcher: "npm:30.2.0" - micromatch: "npm:^4.0.8" - pretty-format: "npm:30.2.0" - slash: "npm:^3.0.0" + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/core": "npm:^12.0.0" + "@inquirer/type": "npm:^4.0.7" peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + "@types/node": ">=18" peerDependenciesMeta: - node-notifier: + "@types/node": optional: true - checksum: 10c0/03b3e35df3bbbbe28e2b53c0fe82d39b748d99b3bc88bb645c76593cdca44d7115f03ef6e6a1715f0862151d0ebab496199283def248fc05eb520f6aec6b20f3 - languageName: node - linkType: hard - -"@jest/diff-sequences@npm:30.0.1": - version: 30.0.1 - resolution: "@jest/diff-sequences@npm:30.0.1" - checksum: 10c0/3a840404e6021725ef7f86b11f7b2d13dd02846481264db0e447ee33b7ee992134e402cdc8b8b0ac969d37c6c0183044e382dedee72001cdf50cfb3c8088de74 - languageName: node - linkType: hard - -"@jest/environment@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/environment@npm:30.2.0" - dependencies: - "@jest/fake-timers": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - jest-mock: "npm:30.2.0" - checksum: 10c0/56a9f1b82ee2623c13eece7d58188be35bd6e5c3c4ee3fbaedb1c4d7242c1b57d020f1a26ab127fa9496fdc11306c7ad1c4a2b7eba1fc726a27ae0873e907e47 + checksum: 10c0/5b0fc1f7e81e64c9f379c3a303ee317cf237b470dc61e1b54afb29a670bceaf6369d6d0dc4b63471590fc79d82b6e1fdcdb7a4ccfe22117478596f9775302a66 languageName: node linkType: hard -"@jest/expect-utils@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/expect-utils@npm:30.2.0" +"@inquirer/password@npm:^5.2.0": + version: 5.2.0 + resolution: "@inquirer/password@npm:5.2.0" dependencies: - "@jest/get-type": "npm:30.1.0" - checksum: 10c0/e25a809ff2ab62292e2569f8d97f89168d27d078903f0306af5f70f1771b7efc62c458eca1dcb491ab1ed96cefedf403bd7acbb050c997105bc29b220fd9d61a + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/core": "npm:^12.0.1" + "@inquirer/type": "npm:^4.1.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/1ead60a0c7a752a69cf7723aef03054fbdc43958b6ce75c1d02eee06596cdc0aef9f363b04dc2590c70d179083058f9715a865f29854b6d0d0e1541794f1f827 languageName: node linkType: hard -"@jest/expect@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/expect@npm:30.2.0" +"@inquirer/prompts@npm:8.5.2": + version: 8.5.2 + resolution: "@inquirer/prompts@npm:8.5.2" dependencies: - expect: "npm:30.2.0" - jest-snapshot: "npm:30.2.0" - checksum: 10c0/3984879022780dd480301c560cef465156b29d610f2c698fcdf81ad76930411d7816eff7cb721e81a1d9aaa8c2240a73c20be9385d1978c14b405a2ac6c9104a + "@inquirer/checkbox": "npm:^5.2.1" + "@inquirer/confirm": "npm:^6.1.1" + "@inquirer/editor": "npm:^5.2.2" + "@inquirer/expand": "npm:^5.1.1" + "@inquirer/input": "npm:^5.1.2" + "@inquirer/number": "npm:^4.1.1" + "@inquirer/password": "npm:^5.1.1" + "@inquirer/rawlist": "npm:^5.3.1" + "@inquirer/search": "npm:^4.2.1" + "@inquirer/select": "npm:^5.2.1" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/253b92e31c6a1f8f00a778eda8196bb53fc931723f7db4a03937f38ccd4d07c987766d4f60b9250b5d90bfe30b04747dfa73927a9f6fb886bd48091ccb202535 languageName: node linkType: hard -"@jest/fake-timers@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/fake-timers@npm:30.2.0" +"@inquirer/prompts@npm:8.7.0": + version: 8.7.0 + resolution: "@inquirer/prompts@npm:8.7.0" dependencies: - "@jest/types": "npm:30.2.0" - "@sinonjs/fake-timers": "npm:^13.0.0" - "@types/node": "npm:*" - jest-message-util: "npm:30.2.0" - jest-mock: "npm:30.2.0" - jest-util: "npm:30.2.0" - checksum: 10c0/b29505528e546f08489535814f7dfcd3a2318660b987d605f44d41672e91a0c8c0dfc01e3dd1302e66e511409c3012d41e2e16703b214502b54ccc023773e3dc - languageName: node - linkType: hard - -"@jest/get-type@npm:30.1.0": - version: 30.1.0 - resolution: "@jest/get-type@npm:30.1.0" - checksum: 10c0/3e65fd5015f551c51ec68fca31bbd25b466be0e8ee8075d9610fa1c686ea1e70a942a0effc7b10f4ea9a338c24337e1ad97ff69d3ebacc4681b7e3e80d1b24ac + "@inquirer/checkbox": "npm:^5.2.3" + "@inquirer/confirm": "npm:^6.3.0" + "@inquirer/editor": "npm:^5.3.1" + "@inquirer/expand": "npm:^5.1.3" + "@inquirer/input": "npm:^5.1.4" + "@inquirer/number": "npm:^4.2.1" + "@inquirer/password": "npm:^5.2.0" + "@inquirer/rawlist": "npm:^5.3.3" + "@inquirer/search": "npm:^4.3.1" + "@inquirer/select": "npm:^5.2.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/0d373c37c8c2f405f964976fac381746bc28d8d1879e01b2f58400cc7f6338c77c0a6163ea938399a5e85e7d67d09060a50fbc7b5bcd77d38327299bdbe9ea74 languageName: node linkType: hard -"@jest/globals@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/globals@npm:30.2.0" +"@inquirer/rawlist@npm:^5.3.1": + version: 5.3.2 + resolution: "@inquirer/rawlist@npm:5.3.2" dependencies: - "@jest/environment": "npm:30.2.0" - "@jest/expect": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - jest-mock: "npm:30.2.0" - checksum: 10c0/7433a501e3122e94b24a7bacc44fdc3921b20abf67c9d795f5bdd169f1beac058cff8109e4fddf71fdc8b18e532cb88c55412ca9927966f354930d6bb3fcaf9c + "@inquirer/core": "npm:^12.0.0" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/5ea4af1142e111a49fe92d5d4695fb9eff9a787188a2b349ea0caca1c3ed60683fc0247f9a375148ba14c5d4f36c90f83fa08de8d4ddfd63df9027fd40ef8bf1 languageName: node linkType: hard -"@jest/pattern@npm:30.0.1": - version: 30.0.1 - resolution: "@jest/pattern@npm:30.0.1" +"@inquirer/rawlist@npm:^5.3.3": + version: 5.3.3 + resolution: "@inquirer/rawlist@npm:5.3.3" dependencies: - "@types/node": "npm:*" - jest-regex-util: "npm:30.0.1" - checksum: 10c0/32c5a7bfb6c591f004dac0ed36d645002ed168971e4c89bd915d1577031672870032594767557b855c5bc330aa1e39a2f54bf150d2ee88a7a0886e9cb65318bc + "@inquirer/core": "npm:^12.0.1" + "@inquirer/type": "npm:^4.1.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/41a5268328b75081fc46b2b7a26fbf0ecc0dbd40f151dc8d87c4b6c6c4e0cac0dffe8e91d1df76b07ee366e3bad72767163fcc9ab6cc2225aeb387cdde27ca36 languageName: node linkType: hard -"@jest/reporters@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/reporters@npm:30.2.0" +"@inquirer/search@npm:^4.2.1": + version: 4.3.0 + resolution: "@inquirer/search@npm:4.3.0" dependencies: - "@bcoe/v8-coverage": "npm:^0.2.3" - "@jest/console": "npm:30.2.0" - "@jest/test-result": "npm:30.2.0" - "@jest/transform": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - "@jridgewell/trace-mapping": "npm:^0.3.25" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - collect-v8-coverage: "npm:^1.0.2" - exit-x: "npm:^0.2.2" - glob: "npm:^10.3.10" - graceful-fs: "npm:^4.2.11" - istanbul-lib-coverage: "npm:^3.0.0" - istanbul-lib-instrument: "npm:^6.0.0" - istanbul-lib-report: "npm:^3.0.0" - istanbul-lib-source-maps: "npm:^5.0.0" - istanbul-reports: "npm:^3.1.3" - jest-message-util: "npm:30.2.0" - jest-util: "npm:30.2.0" - jest-worker: "npm:30.2.0" - slash: "npm:^3.0.0" - string-length: "npm:^4.0.2" - v8-to-istanbul: "npm:^9.0.1" + "@inquirer/core": "npm:^12.0.0" + "@inquirer/figures": "npm:^2.0.8" + "@inquirer/type": "npm:^4.0.7" peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + "@types/node": ">=18" peerDependenciesMeta: - node-notifier: + "@types/node": optional: true - checksum: 10c0/1f25d0896f857f220466cae3145a20f9e13e7d73aeccf87a1f8a5accb42bb7a564864ba63befa3494d76d1335b86c24d66054d62330c3dcffc9c2c5f4e740d6e + checksum: 10c0/7a5fd304dd2b8ebc1698954997aff5eb0a1e74ce89bb23f87041455d5f5d449c970f49179a477ceca5fe1beb86af919b49aa978c1f50a4145f909575295c9dc1 languageName: node linkType: hard -"@jest/schemas@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/schemas@npm:30.0.5" +"@inquirer/search@npm:^4.3.1": + version: 4.3.1 + resolution: "@inquirer/search@npm:4.3.1" dependencies: - "@sinclair/typebox": "npm:^0.34.0" - checksum: 10c0/449dcd7ec5c6505e9ac3169d1143937e67044ae3e66a729ce4baf31812dfd30535f2b3b2934393c97cfdf5984ff581120e6b38f62b8560c8b5b7cc07f4175f65 + "@inquirer/core": "npm:^12.0.1" + "@inquirer/figures": "npm:^2.0.8" + "@inquirer/type": "npm:^4.1.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/1e0d150c7930c5bfc6baf85fbda629d9020307fb9453fe2b64efc7f808ae62ada4eb79a0e0356b7472d1279eee02f47a89710121af7afc9b63fbbb2f14495c97 languageName: node linkType: hard -"@jest/snapshot-utils@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/snapshot-utils@npm:30.2.0" +"@inquirer/select@npm:^5.2.1": + version: 5.2.2 + resolution: "@inquirer/select@npm:5.2.2" dependencies: - "@jest/types": "npm:30.2.0" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - natural-compare: "npm:^1.4.0" - checksum: 10c0/df69ee3b95d64db6d1e79e39d5dc226e417b412a1d5113264b487eb3a8887366a7952c350c378e2292f8e83ec1b3be22040317b795e85eb431830cbde06d09d8 + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/core": "npm:^12.0.0" + "@inquirer/figures": "npm:^2.0.8" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/deffe20aed1953533c2cbc6ea66972524da0d01ffef49b39de4a083011d6b931c21b8f0c8d3a8e4e37e2d196fbfc4afe9b3dc51315708cb7687d2d79f7027d86 languageName: node linkType: hard -"@jest/source-map@npm:30.0.1": - version: 30.0.1 - resolution: "@jest/source-map@npm:30.0.1" +"@inquirer/select@npm:^5.2.3": + version: 5.2.3 + resolution: "@inquirer/select@npm:5.2.3" dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.25" - callsites: "npm:^3.1.0" - graceful-fs: "npm:^4.2.11" - checksum: 10c0/e7bda2786fc9f483d9dd7566c58c4bd948830997be862dfe80a3ae5550ff3f84753abb52e705d02ebe9db9f34ba7ebec4c2db11882048cdeef7a66f6332b3897 + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/core": "npm:^12.0.1" + "@inquirer/figures": "npm:^2.0.8" + "@inquirer/type": "npm:^4.1.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/f714eb669f0d63178d011f0658943c15537a57344152fdefabed74cf38cdbdaae5c98099a49e6d5c03c68255570a6da16818986fdb775017b40cdbce3cbd41b7 languageName: node linkType: hard -"@jest/test-result@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/test-result@npm:30.2.0" - dependencies: - "@jest/console": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - "@types/istanbul-lib-coverage": "npm:^2.0.6" - collect-v8-coverage: "npm:^1.0.2" - checksum: 10c0/87566d56b4f90630282c103f41ea9031f4647902f2cd9839bc49af6248301c1a95cbc4432a9512e61f6c6d778e8b925d0573588b26a211d3198c62471ba08c81 +"@inquirer/type@npm:^4.0.7": + version: 4.0.7 + resolution: "@inquirer/type@npm:4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/80678ac1c6e19ce309909e4a54a69adc95697ea3abc2cb92f17b1bc52f4caadbcb4003ae7339fb5a70c0d36d3bde975e1bb4450069662f41c953a0d28695bb70 languageName: node linkType: hard -"@jest/test-sequencer@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/test-sequencer@npm:30.2.0" - dependencies: - "@jest/test-result": "npm:30.2.0" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.2.0" - slash: "npm:^3.0.0" - checksum: 10c0/b8366e629b885bfc4b2b95f34f47405e70120eb8601f42de20ea4de308a5088d7bd9f535abf67a2a0d083a2b49864176e1333e036426a5d6b6bd02c1c4dda40b +"@inquirer/type@npm:^4.1.0": + version: 4.1.0 + resolution: "@inquirer/type@npm:4.1.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/70460dfbf0afcaa435799be786454fb8454c96f1392af0fe4e10298ee586c62d079c443a605f8de9d8ece4098c9099eaf328b0e34a1b57826a761beab3d895f3 languageName: node linkType: hard -"@jest/transform@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/transform@npm:30.2.0" +"@isaacs/cliui@npm:^8.0.2": + version: 8.0.2 + resolution: "@isaacs/cliui@npm:8.0.2" dependencies: - "@babel/core": "npm:^7.27.4" - "@jest/types": "npm:30.2.0" - "@jridgewell/trace-mapping": "npm:^0.3.25" - babel-plugin-istanbul: "npm:^7.0.1" - chalk: "npm:^4.1.2" - convert-source-map: "npm:^2.0.0" - fast-json-stable-stringify: "npm:^2.1.0" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.2.0" - jest-regex-util: "npm:30.0.1" - jest-util: "npm:30.2.0" - micromatch: "npm:^4.0.8" - pirates: "npm:^4.0.7" - slash: "npm:^3.0.0" - write-file-atomic: "npm:^5.0.1" - checksum: 10c0/c0f21576de9f7ad8a2647450b5cd127d7c60176c19a666230241d121b9f928b036dd19973363e4acd7db2f8b82caff2b624930f57471be6092d73a7775365606 - languageName: node - linkType: hard - -"@jest/types@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/types@npm:30.2.0" - dependencies: - "@jest/pattern": "npm:30.0.1" - "@jest/schemas": "npm:30.0.5" - "@types/istanbul-lib-coverage": "npm:^2.0.6" - "@types/istanbul-reports": "npm:^3.0.4" - "@types/node": "npm:*" - "@types/yargs": "npm:^17.0.33" - chalk: "npm:^4.1.2" - checksum: 10c0/ae121f6963bd9ed1cd9651db7be91bf14c05bff0d0eec4fca9fecf586bea4005e8f1de8cc9b8ef72e424ea96a309d123bef510b55a6a17a3b4b91a39d775e5cd + string-width: "npm:^5.1.2" + string-width-cjs: "npm:string-width@^4.2.0" + strip-ansi: "npm:^7.0.1" + strip-ansi-cjs: "npm:strip-ansi@^6.0.1" + wrap-ansi: "npm:^8.1.0" + wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0" + checksum: 10c0/b1bf42535d49f11dc137f18d5e4e63a28c5569de438a221c369483731e9dac9fb797af554e8bf02b6192d1e5eba6e6402cf93900c3d0ac86391d00d04876789e languageName: node linkType: hard -"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5": - version: 0.3.13 - resolution: "@jridgewell/gen-mapping@npm:0.3.13" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.5.0" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b +"@isaacs/cliui@npm:^9.0.0": + version: 9.0.0 + resolution: "@isaacs/cliui@npm:9.0.0" + checksum: 10c0/971063b7296419f85053dacd0a0285dcadaa3dfc139228b23e016c1a9848121ad4aa5e7fcca7522062014e1eb6239a7424188b9f2cba893a79c90aae5710319c languageName: node linkType: hard -"@jridgewell/remapping@npm:^2.3.5": - version: 2.3.5 - resolution: "@jridgewell/remapping@npm:2.3.5" +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194 + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 languageName: node linkType: hard -"@jridgewell/resolve-uri@npm:^3.0.3, @jridgewell/resolve-uri@npm:^3.1.0": +"@jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e languageName: node linkType: hard -"@jridgewell/source-map@npm:^0.3.3": - version: 0.3.11 - resolution: "@jridgewell/source-map@npm:0.3.11" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.25" - checksum: 10c0/50a4fdafe0b8f655cb2877e59fe81320272eaa4ccdbe6b9b87f10614b2220399ae3e05c16137a59db1f189523b42c7f88bd097ee991dbd7bc0e01113c583e844 - languageName: node - linkType: hard - -"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0": +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.5": version: 1.5.5 resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:0.3.9": - version: 0.3.9 - resolution: "@jridgewell/trace-mapping@npm:0.3.9" - dependencies: - "@jridgewell/resolve-uri": "npm:^3.0.3" - "@jridgewell/sourcemap-codec": "npm:^1.4.10" - checksum: 10c0/fa425b606d7c7ee5bfa6a31a7b050dd5814b4082f318e0e4190f991902181b4330f43f4805db1dd4f2433fd0ed9cc7a7b9c2683f1deeab1df1b0a98b1e24055b - languageName: node - linkType: hard - -"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": +"@jridgewell/trace-mapping@npm:^0.3.31": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -3840,15 +2559,15 @@ __metadata: languageName: node linkType: hard -"@microsoft/tsdoc-config@npm:0.18.0": - version: 0.18.0 - resolution: "@microsoft/tsdoc-config@npm:0.18.0" +"@microsoft/tsdoc-config@npm:0.18.1": + version: 0.18.1 + resolution: "@microsoft/tsdoc-config@npm:0.18.1" dependencies: "@microsoft/tsdoc": "npm:0.16.0" - ajv: "npm:~8.12.0" + ajv: "npm:~8.18.0" jju: "npm:~1.4.0" resolve: "npm:~1.22.2" - checksum: 10c0/6e2c3bfde3e5fa4c0360127c86fe016dcf1b09d0091d767c06ce916284d3f6aeea3617a33b855c5bb2615ab0f2840eeebd4c7f4a1f879f951828d213bf306cfd + checksum: 10c0/06507f7ced4fadf3e68368c60810c1e057403581f720e6cf96b4d6b6bc7a927232510da40425ffd67d5d918ec7cfba8baec56406687330f233f67eb11b9d8d65 languageName: node linkType: hard @@ -3869,14 +2588,15 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^0.2.11": - version: 0.2.12 - resolution: "@napi-rs/wasm-runtime@npm:0.2.12" +"@napi-rs/wasm-runtime@npm:^1.1.6": + version: 1.1.6 + resolution: "@napi-rs/wasm-runtime@npm:1.1.6" dependencies: - "@emnapi/core": "npm:^1.4.3" - "@emnapi/runtime": "npm:^1.4.3" - "@tybys/wasm-util": "npm:^0.10.0" - checksum: 10c0/6d07922c0613aab30c6a497f4df297ca7c54e5b480e00035e0209b872d5c6aab7162fc49477267556109c2c7ed1eb9c65a174e27e9b87568106a87b0a6e3ca7d + "@tybys/wasm-util": "npm:^0.10.3" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/344518bf3ef65051dda4c00969f293aa4a21ab7dc7822b3f48519b17cd5eaa3f0bc34898d115d50ba59b1817a0cb905d46f7a7223c8249239cd14c28db388e10 languageName: node linkType: hard @@ -3917,48 +2637,62 @@ __metadata: languageName: node linkType: hard -"@nestjs/cli@npm:^11.0.10": - version: 11.0.10 - resolution: "@nestjs/cli@npm:11.0.10" - dependencies: - "@angular-devkit/core": "npm:19.2.15" - "@angular-devkit/schematics": "npm:19.2.15" - "@angular-devkit/schematics-cli": "npm:19.2.15" - "@inquirer/prompts": "npm:7.8.0" - "@nestjs/schematics": "npm:^11.0.1" - ansis: "npm:4.1.0" - chokidar: "npm:4.0.3" +"@nestjs/cli@npm:^12.0.0": + version: 12.0.0 + resolution: "@nestjs/cli@npm:12.0.0" + dependencies: + "@angular-devkit/core": "npm:22.1.5" + "@angular-devkit/schematics": "npm:22.1.5" + "@angular-devkit/schematics-cli": "npm:22.1.5" + "@inquirer/prompts": "npm:8.7.0" + "@nestjs/schematics": "npm:^12.0.0" + ansis: "npm:4.3.1" + chokidar: "npm:5.0.0" cli-table3: "npm:0.6.5" - commander: "npm:4.1.1" - fork-ts-checker-webpack-plugin: "npm:9.1.0" - glob: "npm:11.0.3" - node-emoji: "npm:1.11.0" - ora: "npm:5.4.1" - tree-kill: "npm:1.2.2" + commander: "npm:15.0.0" + minimatch: "npm:10.2.6" + node-emoji: "npm:2.2.0" + ora: "npm:9.4.1" tsconfig-paths: "npm:4.2.0" - tsconfig-paths-webpack-plugin: "npm:4.2.0" - typescript: "npm:5.8.3" - webpack: "npm:5.100.2" - webpack-node-externals: "npm:3.0.0" + typescript: "npm:~6.0.2" peerDependencies: - "@swc/cli": ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 - "@swc/core": ^1.3.62 + "@rspack/core": ^1.7.7 || ^2.1.10 + "@swc/cli": ^0.8.0 + "@swc/core": ^1.15.18 + fork-ts-checker-webpack-plugin: ^9.1.0 + ts-loader: ^9.5.4 + tsconfig-paths-webpack-plugin: ^4.2.0 + webpack: ^5.105.4 + webpack-node-externals: ^3.0.0 peerDependenciesMeta: + "@rspack/core": + optional: true "@swc/cli": optional: true "@swc/core": optional: true + fork-ts-checker-webpack-plugin: + optional: true + ts-loader: + optional: true + tsconfig-paths-webpack-plugin: + optional: true + webpack: + optional: true + webpack-node-externals: + optional: true bin: nest: bin/nest.js - checksum: 10c0/6025e6e78f74da9dd8344edb5a3b3e6a52b0ef26b2aa9129d398f56c3e9a2f001ec575fb87f9ec091693337f5331ac1b9a35210eb174001d33a65d8e9c69aefa + checksum: 10c0/3378ba9186571d1d81bdf582db52fcaef32622694bdeff74e165163be9f9dc153ea2ef5027790fb1fcc3d8b4531c17521ba2d112398b99de94d64b0a1f795d12 languageName: node linkType: hard -"@nestjs/common@npm:^11.1.9": - version: 11.1.9 - resolution: "@nestjs/common@npm:11.1.9" +"@nestjs/common@npm:^12.0.1": + version: 12.0.1 + resolution: "@nestjs/common@npm:12.0.1" dependencies: - file-type: "npm:21.1.0" + "@standard-schema/spec": "npm:1.1.0" + file-type: "npm:22.0.2" iterare: "npm:1.2.1" load-esm: "npm:1.0.3" tslib: "npm:2.8.1" @@ -3973,39 +2707,39 @@ __metadata: optional: true class-validator: optional: true - checksum: 10c0/1189834d51eb4c50157f82fafd15e3098adc3bf2eba979f2603135af25559373306c0e98b787271f137259e04070ed4550d6fa3df988a034a989878a01a98f35 + checksum: 10c0/5a4c18372b0ebc4974e3e1032fa3e860702a28621e49d04634af7664752f5d71ae5794e97cf0efc9b6a48ba8886a077dc4f340bea4f323d3109ae84060d3952b languageName: node linkType: hard -"@nestjs/config@npm:^4.0.2": - version: 4.0.2 - resolution: "@nestjs/config@npm:4.0.2" +"@nestjs/config@npm:^12.0.0": + version: 12.0.0 + resolution: "@nestjs/config@npm:12.0.0" dependencies: - dotenv: "npm:16.4.7" - dotenv-expand: "npm:12.0.1" - lodash: "npm:4.17.21" + "@standard-schema/spec": "npm:1.1.0" + dotenv: "npm:17.4.2" + dotenv-expand: "npm:13.0.0" + es-toolkit: "npm:1.51.0" peerDependencies: - "@nestjs/common": ^10.0.0 || ^11.0.0 + "@nestjs/common": ^11.0.0 || ^12.0.0 rxjs: ^7.1.0 - checksum: 10c0/549bc8d784f68742c8020954be250639cd8d37a0dd233d01ebe511f67268e5fd109ffb1ae546ca9792a8484ef4cbb79374ae7e2a9587f59b13db1e9a16d443d6 + checksum: 10c0/d6241185a7a43a8eda2daae346f06aca497b53a5a5ffeea5449040a1469c3fa56867a722164b1cb290f747fb4c5c40a6650abe15f28a25098963318c01c37381 languageName: node linkType: hard -"@nestjs/core@npm:^11.1.9": - version: 11.1.9 - resolution: "@nestjs/core@npm:11.1.9" +"@nestjs/core@npm:^12.0.1": + version: 12.0.1 + resolution: "@nestjs/core@npm:12.0.1" dependencies: - "@nuxt/opencollective": "npm:0.4.1" fast-safe-stringify: "npm:2.1.1" iterare: "npm:1.2.1" - path-to-regexp: "npm:8.3.0" + path-to-regexp: "npm:8.4.2" tslib: "npm:2.8.1" uid: "npm:2.0.2" peerDependencies: - "@nestjs/common": ^11.0.0 - "@nestjs/microservices": ^11.0.0 - "@nestjs/platform-express": ^11.0.0 - "@nestjs/websockets": ^11.0.0 + "@nestjs/common": ^12.0.0 + "@nestjs/microservices": ^12.0.0 + "@nestjs/platform-express": ^12.0.0 + "@nestjs/websockets": ^12.0.0 reflect-metadata: ^0.1.12 || ^0.2.0 rxjs: ^7.1.0 peerDependenciesMeta: @@ -4015,94 +2749,111 @@ __metadata: optional: true "@nestjs/websockets": optional: true - checksum: 10c0/baf8b7085fef072ae9f4c9d0fee3e62a00037b0beaf92af25f3b2a31f25e6ef85f077cfde65e54995d71414e5ce4866b5b25419ad122afb82120cd9d05209347 + checksum: 10c0/ccf18a31ce5d1409466f722805cc8bd1f1525ce13ea4a19dfad83a930a35c9de602b9e8e55a6837a46856ee107966763db2bb79b4e2e691bc29f22d2728fd897 + languageName: node + linkType: hard + +"@nestjs/cqrs@npm:^12.0.0": + version: 12.0.0 + resolution: "@nestjs/cqrs@npm:12.0.0" + peerDependencies: + "@nestjs/common": ^12.0.0 + "@nestjs/core": ^12.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + rxjs: ^7.2.0 + checksum: 10c0/43856fb3805ad03473fa948b4ffce63d791efbf1d9f6681546953281cb7e7d6c64a5a72022d6546d632e5ff95f9914701f007315194652277cc17f5bc130afe6 languageName: node linkType: hard -"@nestjs/jwt@npm:^11.0.1": - version: 11.0.1 - resolution: "@nestjs/jwt@npm:11.0.1" +"@nestjs/jwt@npm:^12.0.1": + version: 12.0.1 + resolution: "@nestjs/jwt@npm:12.0.1" dependencies: "@types/jsonwebtoken": "npm:9.0.10" - jsonwebtoken: "npm:9.0.2" + jsonwebtoken: "npm:9.0.3" peerDependencies: - "@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 - checksum: 10c0/9514327aefb9570e2526bb68e84180330e751a66646f73f430e89f4980ab4d2c3dcfd6162fc7b2bbc60259a0bc1cba03a00e771799abc2796a8324f233f47b82 + "@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 + checksum: 10c0/35f79f8704ffd276ddc0156910108e8350251f599ea6a59fb1368ef45b1b9153f24306c8a121ddd2f3748fab36660b78d36bb785f5c81dd344075c0abdcc3d22 languageName: node linkType: hard -"@nestjs/mapped-types@npm:2.1.0": - version: 2.1.0 - resolution: "@nestjs/mapped-types@npm:2.1.0" +"@nestjs/mapped-types@npm:12.0.0": + version: 12.0.0 + resolution: "@nestjs/mapped-types@npm:12.0.0" peerDependencies: - "@nestjs/common": ^10.0.0 || ^11.0.0 + "@nestjs/common": ^10.0.0 || ^11.0.0 || ^12.0.0 class-transformer: ^0.4.0 || ^0.5.0 - class-validator: ^0.13.0 || ^0.14.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 reflect-metadata: ^0.1.12 || ^0.2.0 peerDependenciesMeta: class-transformer: optional: true class-validator: optional: true - checksum: 10c0/cd9f9236648d8a146a4e6890009415400cca7959c3976acdf6fec2ddddc73546d174e58f935b96c6b2319dc54c76e58a39bf47f41991bcd27d1cb55bca99474e + checksum: 10c0/6b5d61f402b163b985060b3f8484b04ae6c176a46e3a7b49d006a2b19614b0640b18c327faf494288c3c48ae8fbaf1142b83ecd2c02bc29ca9f7c3b7f7cfc9bf languageName: node linkType: hard -"@nestjs/passport@npm:^11.0.5": - version: 11.0.5 - resolution: "@nestjs/passport@npm:11.0.5" +"@nestjs/passport@npm:^12.0.0": + version: 12.0.0 + resolution: "@nestjs/passport@npm:12.0.0" peerDependencies: - "@nestjs/common": ^10.0.0 || ^11.0.0 + "@nestjs/common": ^11.0.0 || ^12.0.0 passport: ^0.5.0 || ^0.6.0 || ^0.7.0 - checksum: 10c0/24175f6791abf02b70c3c0705ce56fefd3981fdece0da7e58325f790ce71aa6932b31a87a2ceaa52b6d9d8edab3a7c049f6081e4b4d59bcdfcaa00383b64ae02 + checksum: 10c0/9aa3db90c590023f791055948152c42943775abb17e653971599b0bdb2514b6c84c93bf10303b37f4cd9ded27a116f21e8d2ee97d422e66fd6748a32a3dacbf3 languageName: node linkType: hard -"@nestjs/platform-express@npm:^11.1.9": - version: 11.1.9 - resolution: "@nestjs/platform-express@npm:11.1.9" +"@nestjs/platform-express@npm:^12.0.1": + version: 12.0.1 + resolution: "@nestjs/platform-express@npm:12.0.1" dependencies: - cors: "npm:2.8.5" - express: "npm:5.1.0" - multer: "npm:2.0.2" - path-to-regexp: "npm:8.3.0" + cors: "npm:2.8.6" + express: "npm:5.2.1" + multer: "npm:2.2.0" + path-to-regexp: "npm:8.4.2" tslib: "npm:2.8.1" peerDependencies: - "@nestjs/common": ^11.0.0 - "@nestjs/core": ^11.0.0 - checksum: 10c0/0d17a0b436a9020da3e2e439543ed7d2054201e7ae4b48adec741f467d946feeca8451873580cbaa60a0ffc29b7fd9f28f061c8c2d024877dd0fc163173da75c + "@nestjs/common": ^12.0.0 + "@nestjs/core": ^12.0.0 + checksum: 10c0/84083f9e85bcbd0609be09860c71096c623e3c21186f245e1172644169cad4d3951f9486e932dabbf4ff3ff74d072aaed0f1a7c7f69f9a39ce7f01879d981281 languageName: node linkType: hard -"@nestjs/schematics@npm:^11.0.1, @nestjs/schematics@npm:^11.0.9": - version: 11.0.9 - resolution: "@nestjs/schematics@npm:11.0.9" +"@nestjs/schematics@npm:^12.0.0": + version: 12.0.0 + resolution: "@nestjs/schematics@npm:12.0.0" dependencies: - "@angular-devkit/core": "npm:19.2.17" - "@angular-devkit/schematics": "npm:19.2.17" - comment-json: "npm:4.4.1" + "@angular-devkit/core": "npm:22.1.5" + "@angular-devkit/schematics": "npm:22.1.5" + comment-json: "npm:5.0.0" jsonc-parser: "npm:3.3.1" pluralize: "npm:8.0.0" peerDependencies: - typescript: ">=4.8.2" - checksum: 10c0/c7a367006335b5b54b170452560adeeacbf5d698d72bb99f8c116870a0fff310ddfa5eda550a50985889435021decad29c81c400ccaaaba06b7fc1efd0b82bff + prettier: ^3.0.0 + typescript: ">=6.0.0" + peerDependenciesMeta: + prettier: + optional: true + checksum: 10c0/df0027bfc7dc450a25a103f8d924e45b936fc7c793664acc91acbbe9aede194310dd95d501742c0c80aae01786ff56e74302e064c00ec06ea83059ec5f515f32 languageName: node linkType: hard -"@nestjs/swagger@npm:^11.2.2": - version: 11.2.2 - resolution: "@nestjs/swagger@npm:11.2.2" +"@nestjs/swagger@npm:^12.0.1": + version: 12.0.1 + resolution: "@nestjs/swagger@npm:12.0.1" dependencies: "@microsoft/tsdoc": "npm:0.16.0" - "@nestjs/mapped-types": "npm:2.1.0" - js-yaml: "npm:4.1.1" - lodash: "npm:4.17.21" - path-to-regexp: "npm:8.3.0" - swagger-ui-dist: "npm:5.30.2" + "@nestjs/mapped-types": "npm:12.0.0" + "@standard-schema/spec": "npm:1.1.0" + es-toolkit: "npm:1.51.0" + js-yaml: "npm:5.4.1" + path-to-regexp: "npm:8.4.2" + swagger-ui-dist: "npm:5.32.14" peerDependencies: - "@fastify/static": ^8.0.0 - "@nestjs/common": ^11.0.1 - "@nestjs/core": ^11.0.1 + "@fastify/static": ^8.0.0 || ^9.0.0 || ^10.0.0 + "@nestjs/common": ^12.0.0 + "@nestjs/core": ^12.0.0 class-transformer: "*" class-validator: "*" reflect-metadata: ^0.1.12 || ^0.2.0 @@ -4113,39 +2864,39 @@ __metadata: optional: true class-validator: optional: true - checksum: 10c0/e2ddba75f2bc543b22f66433747da6c5d8fe6b32277fa73312d8d11e2bb06eb8b2b11f6603c0d46f95c45689a24dd0dfb8317f060fd3dca87acca5da992e882c + checksum: 10c0/f77e53c452af97e91eed574de0aefc11dda5ec2fb0b767a5b23fe92cb586458990e399244c03fc96875fc118112ae7937e6f2d391a55617a907de3ed83cb9482 languageName: node linkType: hard -"@nestjs/testing@npm:^11.1.9": - version: 11.1.9 - resolution: "@nestjs/testing@npm:11.1.9" +"@nestjs/testing@npm:^12.0.1": + version: 12.0.1 + resolution: "@nestjs/testing@npm:12.0.1" dependencies: tslib: "npm:2.8.1" peerDependencies: - "@nestjs/common": ^11.0.0 - "@nestjs/core": ^11.0.0 - "@nestjs/microservices": ^11.0.0 - "@nestjs/platform-express": ^11.0.0 + "@nestjs/common": ^12.0.0 + "@nestjs/core": ^12.0.0 + "@nestjs/microservices": ^12.0.0 + "@nestjs/platform-express": ^12.0.0 peerDependenciesMeta: "@nestjs/microservices": optional: true "@nestjs/platform-express": optional: true - checksum: 10c0/29ab1c9358a49469832d48c15b0b773739ce837d20497b23a2d5142611d6d39c49285a3cbc07976488595e4110dd2e5a62016580c81bb221321f70e9f3b7565e + checksum: 10c0/79efea430bd359b4d98ad20b81d9f2b589a3dbcb680dbe6e105622395ef953574e2ec6d92b4ce02e64ee45b5873efa1f16d94a27b03da72e5051accf16837711 languageName: node linkType: hard -"@nestjs/typeorm@npm:^11.0.0": - version: 11.0.0 - resolution: "@nestjs/typeorm@npm:11.0.0" +"@nestjs/typeorm@npm:^12.0.1": + version: 12.0.1 + resolution: "@nestjs/typeorm@npm:12.0.1" peerDependencies: - "@nestjs/common": ^10.0.0 || ^11.0.0 - "@nestjs/core": ^10.0.0 || ^11.0.0 + "@nestjs/common": ^10.0.0 || ^11.0.0 || ^12.0.0 + "@nestjs/core": ^10.0.0 || ^11.0.0 || ^12.0.0 reflect-metadata: ^0.1.13 || ^0.2.0 rxjs: ^7.2.0 - typeorm: ^0.3.0 - checksum: 10c0/bdb96fc0d05cb653ffb8d90e44f866c7634fe4065db409e878e06ab1c3ae0333b5629c7590c1f94f2adf39ef7e95422dff34eadb2910f6eb7b6c598bcc65088a + typeorm: ^0.3.0 || ^1.0.0-dev + checksum: 10c0/18dfb0847e4fbb9ac6382552ee9e90750c761f325a4fdd8084306865c9b129370a47de93d0f1d40e40ba0feba93987279258683036c8cb385210caad549ff42e languageName: node linkType: hard @@ -4156,23 +2907,6 @@ __metadata: languageName: node linkType: hard -"@nodelib/fs.scandir@npm:2.1.5": - version: 2.1.5 - resolution: "@nodelib/fs.scandir@npm:2.1.5" - dependencies: - "@nodelib/fs.stat": "npm:2.0.5" - run-parallel: "npm:^1.1.9" - checksum: 10c0/732c3b6d1b1e967440e65f284bd06e5821fedf10a1bea9ed2bb75956ea1f30e08c44d3def9d6a230666574edbaf136f8cfd319c14fd1f87c66e6a44449afb2eb - languageName: node - linkType: hard - -"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": - version: 2.0.5 - resolution: "@nodelib/fs.stat@npm:2.0.5" - checksum: 10c0/88dafe5e3e29a388b07264680dc996c17f4bda48d163a9d4f5c1112979f0ce8ec72aa7116122c350b4e7976bc5566dc3ddb579be1ceaacc727872eb4ed93926d - languageName: node - linkType: hard - "@nodelib/fs.stat@npm:^1.1.2": version: 1.1.3 resolution: "@nodelib/fs.stat@npm:1.1.3" @@ -4180,45 +2914,13 @@ __metadata: languageName: node linkType: hard -"@nodelib/fs.walk@npm:^1.2.3": - version: 1.2.8 - resolution: "@nodelib/fs.walk@npm:1.2.8" +"@npmcli/fs@npm:^1.0.0": + version: 1.1.1 + resolution: "@npmcli/fs@npm:1.1.1" dependencies: - "@nodelib/fs.scandir": "npm:2.1.5" - fastq: "npm:^1.6.0" - checksum: 10c0/db9de047c3bb9b51f9335a7bb46f4fcfb6829fb628318c12115fbaf7d369bfce71c15b103d1fc3b464812d936220ee9bc1c8f762d032c9f6be9acc99249095b1 - languageName: node - linkType: hard - -"@npmcli/agent@npm:^4.0.0": - version: 4.0.0 - resolution: "@npmcli/agent@npm:4.0.0" - dependencies: - agent-base: "npm:^7.1.0" - http-proxy-agent: "npm:^7.0.0" - https-proxy-agent: "npm:^7.0.1" - lru-cache: "npm:^11.2.1" - socks-proxy-agent: "npm:^8.0.3" - checksum: 10c0/f7b5ce0f3dd42c3f8c6546e8433573d8049f67ef11ec22aa4704bc41483122f68bf97752e06302c455ead667af5cb753e6a09bff06632bc465c1cfd4c4b75a53 - languageName: node - linkType: hard - -"@npmcli/fs@npm:^1.0.0": - version: 1.1.1 - resolution: "@npmcli/fs@npm:1.1.1" - dependencies: - "@gar/promisify": "npm:^1.0.1" - semver: "npm:^7.3.5" - checksum: 10c0/4143c317a7542af9054018b71601e3c3392e6704e884561229695f099a71336cbd580df9a9ffb965d0024bf0ed593189ab58900fd1714baef1c9ee59c738c3e2 - languageName: node - linkType: hard - -"@npmcli/fs@npm:^4.0.0": - version: 4.0.0 - resolution: "@npmcli/fs@npm:4.0.0" - dependencies: - semver: "npm:^7.3.5" - checksum: 10c0/c90935d5ce670c87b6b14fab04a965a3b8137e585f8b2a6257263bd7f97756dd736cb165bb470e5156a9e718ecd99413dccc54b1138c1a46d6ec7cf325982fe5 + "@gar/promisify": "npm:^1.0.1" + semver: "npm:^7.3.5" + checksum: 10c0/4143c317a7542af9054018b71601e3c3392e6704e884561229695f099a71336cbd580df9a9ffb965d0024bf0ed593189ab58900fd1714baef1c9ee59c738c3e2 languageName: node linkType: hard @@ -4232,17 +2934,6 @@ __metadata: languageName: node linkType: hard -"@nuxt/opencollective@npm:0.4.1": - version: 0.4.1 - resolution: "@nuxt/opencollective@npm:0.4.1" - dependencies: - consola: "npm:^3.2.3" - bin: - opencollective: bin/opencollective.js - checksum: 10c0/ef2835d8635d2928152eff8b5a1ec42c145e2ab00cb02ff4bb61f0a6f5528afc9b169c06c32308c783779fe26855ebc67419743046caa80e582e814cff73187d - languageName: node - linkType: hard - "@octokit/auth-token@npm:^2.4.0": version: 2.5.0 resolution: "@octokit/auth-token@npm:2.5.0" @@ -4390,1438 +3081,396 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/api-logs@npm:0.53.0": - version: 0.53.0 - resolution: "@opentelemetry/api-logs@npm:0.53.0" - dependencies: - "@opentelemetry/api": "npm:^1.0.0" - checksum: 10c0/969ad3bbb74e3de6fdfe8eb9b3ab86d3dc284ca7bffd0ca67eef64efd08c97a4305696afe0b7b03e5d356f15d0a1a67ac517e5fa7d1ddee6fdc249eef2209fcb +"@oxc-project/types@npm:=0.137.0": + version: 0.137.0 + resolution: "@oxc-project/types@npm:0.137.0" + checksum: 10c0/5a6a50174e5ac79aebf38a120fe57be7a84c8bb0c77117f30de15183aa5ab0161e78364d2d3725397090e362e5c5f6eda754b53057b0b63983e3ee604f888aca languageName: node linkType: hard -"@opentelemetry/api-logs@npm:0.57.1": - version: 0.57.1 - resolution: "@opentelemetry/api-logs@npm:0.57.1" +"@paralleldrive/cuid2@npm:^2.2.2": + version: 2.3.1 + resolution: "@paralleldrive/cuid2@npm:2.3.1" dependencies: - "@opentelemetry/api": "npm:^1.3.0" - checksum: 10c0/e2a86c5b72ae5c6050408150a3a67af88e5cacab327003c8f38a5f493d5f197d7b8ada21642c81aabc0918031f593d42cb9faa02294db1ef6a66f0f5d181c2f5 + "@noble/hashes": "npm:^1.1.5" + checksum: 10c0/6576b73de49d826b0f33cbab88424dec1f6fa454a9e59a7b621f78c2cfdd2e59d7f48175826d698940a717f45eeb5e87a508583a7316e608f6a05a861a40c129 languageName: node linkType: hard -"@opentelemetry/api-logs@npm:0.57.2": - version: 0.57.2 - resolution: "@opentelemetry/api-logs@npm:0.57.2" - dependencies: - "@opentelemetry/api": "npm:^1.3.0" - checksum: 10c0/1e514d3fd4ca68e7e8b008794a95ee0562a5d9e1d3ebb02647b245afaa6c2d72cc14e99e3ea47a1d1007f8a965c62bfb6170e1aa26756230bea063cfde2898bf +"@pkgjs/parseargs@npm:^0.11.0": + version: 0.11.0 + resolution: "@pkgjs/parseargs@npm:0.11.0" + checksum: 10c0/5bd7576bb1b38a47a7fc7b51ac9f38748e772beebc56200450c4a817d712232b8f1d3ef70532c80840243c657d491cf6a6be1e3a214cff907645819fdc34aadd languageName: node linkType: hard -"@opentelemetry/api@npm:^1.0.0, @opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.8, @opentelemetry/api@npm:^1.9.0": - version: 1.9.0 - resolution: "@opentelemetry/api@npm:1.9.0" - checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add +"@pkgr/core@npm:^0.3.6": + version: 0.3.6 + resolution: "@pkgr/core@npm:0.3.6" + checksum: 10c0/153f0f4563f505faeba13c733efa0e05e467ce1c6b941055a5fd3b4560da60fbf1dff4b11da0075f034ddda11f2842b90395f60895dde5825875b616edccc11c languageName: node linkType: hard -"@opentelemetry/context-async-hooks@npm:^1.30.1": - version: 1.30.1 - resolution: "@opentelemetry/context-async-hooks@npm:1.30.1" - peerDependencies: - "@opentelemetry/api": ">=1.0.0 <1.10.0" - checksum: 10c0/3e8114d360060a5225226d2fcd8df08cd542246003790a7f011c0774bc60b8a931f46f4c6673f3977a7d9bba717de6ee028cae51b752c2567053d7f46ed3eba3 +"@rolldown/binding-android-arm64@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-android-arm64@npm:1.1.3" + conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@opentelemetry/core@npm:1.30.1, @opentelemetry/core@npm:^1.1.0, @opentelemetry/core@npm:^1.26.0, @opentelemetry/core@npm:^1.30.1, @opentelemetry/core@npm:^1.8.0": - version: 1.30.1 - resolution: "@opentelemetry/core@npm:1.30.1" - dependencies: - "@opentelemetry/semantic-conventions": "npm:1.28.0" - peerDependencies: - "@opentelemetry/api": ">=1.0.0 <1.10.0" - checksum: 10c0/4c25ba50a6137c2ba9ca563fb269378f3c9ca6fd1b3f15dbb6eff78eebf5656f281997cbb7be8e51c01649fd6ad091083fcd8a42dd9b5dfac907dc06d7cfa092 +"@rolldown/binding-darwin-arm64@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-darwin-arm64@npm:1.1.3" + conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@opentelemetry/instrumentation-amqplib@npm:^0.46.0": - version: 0.46.1 - resolution: "@opentelemetry/instrumentation-amqplib@npm:0.46.1" - dependencies: - "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.57.1" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/4a8b870ccaa64cfd200663ec14385aca7eeb7146124d82e566f3d48678f237c9a56661ae3401345fe0dce5c56366ae02a312dc7905eb4fd6e073df2cface30fb +"@rolldown/binding-darwin-x64@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-darwin-x64@npm:1.1.3" + conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@opentelemetry/instrumentation-connect@npm:0.43.0": - version: 0.43.0 - resolution: "@opentelemetry/instrumentation-connect@npm:0.43.0" - dependencies: - "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - "@types/connect": "npm:3.4.36" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/f296f35e9edd2b97aa34323b7b4eea9f7785aec2bd375b995f175305e784d463bc31a40ab5aed3c44eb8f2045b268de64f73f0876de9e5d5a3004c8e37f830dd +"@rolldown/binding-freebsd-x64@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-freebsd-x64@npm:1.1.3" + conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@opentelemetry/instrumentation-dataloader@npm:0.16.0": - version: 0.16.0 - resolution: "@opentelemetry/instrumentation-dataloader@npm:0.16.0" - dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/d1b9bcb8c4e4819bebee15e02f742c22209f4d3e0a6cfb83d772d095a9faad9384885fb455994b55c546004ef999fea4234765b9e8f59948b5baec585a7982fa +"@rolldown/binding-linux-arm-gnueabihf@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.1.3" + conditions: os=linux & cpu=arm languageName: node linkType: hard -"@opentelemetry/instrumentation-express@npm:0.47.0": - version: 0.47.0 - resolution: "@opentelemetry/instrumentation-express@npm:0.47.0" - dependencies: - "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/0383a6563c755f2891d632de5043322fbed5548fd2d000fd4ff8d112ca5e6db725df5e42e7ff2b4251e6719d27fb660865aef9d7c001e48177e58cb9b36d5199 +"@rolldown/binding-linux-arm64-gnu@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.1.3" + conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@opentelemetry/instrumentation-fastify@npm:0.44.1": - version: 0.44.1 - resolution: "@opentelemetry/instrumentation-fastify@npm:0.44.1" - dependencies: - "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/75bb26bf3a3b159175e125cf06c1c0127a342bc05b4563c21489b6c4ed27569036deba5ecbbb88397745f44126f78be9752e0a2bd9e3056b9b5a5adc0464e173 +"@rolldown/binding-linux-arm64-musl@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.1.3" + conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@opentelemetry/instrumentation-fs@npm:0.19.0": - version: 0.19.0 - resolution: "@opentelemetry/instrumentation-fs@npm:0.19.0" - dependencies: - "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.57.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/c51983e29076459728bac7b0541ee5c5e111b85583baf7c25e09a7cbf5667dae030dbce0669ae44d385528699febde1ae638b725671ff1afe3c67f70cf9fd4d3 +"@rolldown/binding-linux-ppc64-gnu@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.1.3" + conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@opentelemetry/instrumentation-generic-pool@npm:0.43.0": - version: 0.43.0 - resolution: "@opentelemetry/instrumentation-generic-pool@npm:0.43.0" - dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/de34c0cebf09fd4213f2447a08929a2d32dae397b921b27cf352eef2b09ac009606330fefae6ae851d3d1559e987c80b35118e588db6447e87366dd2009ddd12 +"@rolldown/binding-linux-s390x-gnu@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.1.3" + conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@opentelemetry/instrumentation-graphql@npm:0.47.0": - version: 0.47.0 - resolution: "@opentelemetry/instrumentation-graphql@npm:0.47.0" - dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/19c15ed4c00240834b354970d9efc46b91677a31b1ca7e92202b5bae67c19bd5beb45bf529683c0fde5a9085fc72fa45af3305a359830ca6d02d1bd8d6ff772b +"@rolldown/binding-linux-x64-gnu@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.1.3" + conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@opentelemetry/instrumentation-hapi@npm:0.45.1": - version: 0.45.1 - resolution: "@opentelemetry/instrumentation-hapi@npm:0.45.1" - dependencies: - "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/9248e640fc810298d869ace6db3a5755f8b2be2250e799961ed27d01e7fd6127c0652434933ebb02c820ea3ba362f6bc849e9b2c9519722a73d7b875ec2a1947 +"@rolldown/binding-linux-x64-musl@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.1.3" + conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@opentelemetry/instrumentation-http@npm:0.57.1": - version: 0.57.1 - resolution: "@opentelemetry/instrumentation-http@npm:0.57.1" - dependencies: - "@opentelemetry/core": "npm:1.30.1" - "@opentelemetry/instrumentation": "npm:0.57.1" - "@opentelemetry/semantic-conventions": "npm:1.28.0" - forwarded-parse: "npm:2.1.2" - semver: "npm:^7.5.2" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/69c83e4d45f5c9b0f7d80d47807c4d1df7735c248eee1412633d12493ed67e9c613accfc540df926de2e6905ae87cf7aaf84dc9ad4efb2d7127ab7c5c7ab1f2d +"@rolldown/binding-openharmony-arm64@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.1.3" + conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@opentelemetry/instrumentation-ioredis@npm:0.47.0": - version: 0.47.0 - resolution: "@opentelemetry/instrumentation-ioredis@npm:0.47.0" +"@rolldown/binding-wasm32-wasi@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.1.3" dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/redis-common": "npm:^0.36.2" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/f7137854d4357aa0d0222c50ee1be26baa5a06a394cdd5195f107cd69374ef4824c6169e39f9a71a0c0e69ad9cc99afec7abcf23af2e5b468bfb3433b0b8dee0 + "@emnapi/core": "npm:1.11.1" + "@emnapi/runtime": "npm:1.11.1" + "@napi-rs/wasm-runtime": "npm:^1.1.6" + conditions: cpu=wasm32 languageName: node linkType: hard -"@opentelemetry/instrumentation-kafkajs@npm:0.7.0": - version: 0.7.0 - resolution: "@opentelemetry/instrumentation-kafkajs@npm:0.7.0" - dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/8b9b43c0a9eaf7f922232395dc33b26f3fb8aa2004f5a68b7b391eb8be33c1871f9f1f57562482d4eab1203100c92ca982feb447f006877f9265ceffe02d538f +"@rolldown/binding-win32-arm64-msvc@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.1.3" + conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@opentelemetry/instrumentation-knex@npm:0.44.0": - version: 0.44.0 - resolution: "@opentelemetry/instrumentation-knex@npm:0.44.0" - dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/6749178f9aebb2d04d6208980aed40d2fa02a90bacd5ebe41397dda8fb445314221993a4c3c987f0d65a6b3ea400c1b36d0bf84427c87bbdafc9d07aedd14231 +"@rolldown/binding-win32-x64-msvc@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.1.3" + conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@opentelemetry/instrumentation-koa@npm:0.47.0": - version: 0.47.0 - resolution: "@opentelemetry/instrumentation-koa@npm:0.47.0" - dependencies: - "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/10d0aaa252ae582698b499af83d27634975693696819b486786ac4052089251f6dfb78afa490c3d200e50fa2147401b943e80a8986bc3f4d98f98e141063b880 +"@rolldown/pluginutils@npm:^1.0.0": + version: 1.0.1 + resolution: "@rolldown/pluginutils@npm:1.0.1" + checksum: 10c0/99d9b06d90196823e4d8c841f258db7a16e5dbba5824a2962b05d907b79f1ba929d56f22dd744fd530936e568c865ee56a719dc31e57e13bc0a8eb4764a8d8dd languageName: node linkType: hard -"@opentelemetry/instrumentation-lru-memoizer@npm:0.44.0": - version: 0.44.0 - resolution: "@opentelemetry/instrumentation-lru-memoizer@npm:0.44.0" - dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/b3ed1f92e5aef2828c2fea1b9737e23d4a1dbc0c5269913c26de097717a3cbdbdddf8f6b6f76f1eb57f577c99c854aecdf6ab6a9046e3a308cea2290755a69bc +"@rtsao/scc@npm:^1.1.0": + version: 1.1.0 + resolution: "@rtsao/scc@npm:1.1.0" + checksum: 10c0/b5bcfb0d87f7d1c1c7c0f7693f53b07866ed9fec4c34a97a8c948fb9a7c0082e416ce4d3b60beb4f5e167cbe04cdeefbf6771320f3ede059b9ce91188c409a5b languageName: node linkType: hard -"@opentelemetry/instrumentation-mongodb@npm:0.51.0": - version: 0.51.0 - resolution: "@opentelemetry/instrumentation-mongodb@npm:0.51.0" - dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/9d6f5517aa24674134568d87f7ccb014ddfcfa5621ebc2d69fb5cf9d41acd88a03fae5e2dc21a40974ac5a1eed7d7705dc94779a900247aee04c092cc031068d +"@scarf/scarf@npm:=1.4.0": + version: 1.4.0 + resolution: "@scarf/scarf@npm:1.4.0" + checksum: 10c0/332118bb488e7a70eaad068fb1a33f016d30442fb0498b37a80cb425c1e741853a5de1a04dce03526ed6265481ecf744aa6e13f072178d19e6b94b19f623ae1c languageName: node linkType: hard -"@opentelemetry/instrumentation-mongoose@npm:0.46.0": - version: 0.46.0 - resolution: "@opentelemetry/instrumentation-mongoose@npm:0.46.0" +"@selderee/plugin-htmlparser2@npm:~0.12.0": + version: 0.12.0 + resolution: "@selderee/plugin-htmlparser2@npm:0.12.0" dependencies: - "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" + domelementtype: "npm:~2.3.0" + domhandler: "npm:~5.0.3" peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/9426eb51277f93c728fd829abbeaac1d72f379f19e0c6b97f2c0f4b441c1d2942adc704465e65d6df26b0d7aff931370fd98932278c24f2d2f60363513f6f5ca + selderee: ~0.12.0 + checksum: 10c0/42930869fa52061b75b682aca3dd55c30a69cc5b9298f1503c6639ff9aa42fae3738f95541248e5197053e52b860d9e7c8820b5083bed3d8ea2956c33afe312d languageName: node linkType: hard -"@opentelemetry/instrumentation-mysql2@npm:0.45.0": - version: 0.45.0 - resolution: "@opentelemetry/instrumentation-mysql2@npm:0.45.0" - dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - "@opentelemetry/sql-common": "npm:^0.40.1" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/a9a884911e753b109654147784753f8251d97ce7382b674027fa273bc20cf8589dcacf6a96b402a28c2674c0553b1db9f299940b67a419a789bdfae95db7fc7f +"@sindresorhus/base62@npm:^1.0.0": + version: 1.0.0 + resolution: "@sindresorhus/base62@npm:1.0.0" + checksum: 10c0/9a14df0f058fdf4731c30f0f05728a4822144ee42236030039d7fa5a1a1072c2879feba8091fd4a17c8922d1056bc07bada77c31fddc3e15836fc05a266fd918 languageName: node linkType: hard -"@opentelemetry/instrumentation-mysql@npm:0.45.0": - version: 0.45.0 - resolution: "@opentelemetry/instrumentation-mysql@npm:0.45.0" - dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - "@types/mysql": "npm:2.15.26" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/58bf75a8c20b4265e114a1fde5a197b4754f3a085b3cf28538693e14f773ef1d4fea7c45b076504ba6a199b7b0959488fd4e01742c3945330e99923622688ece +"@sindresorhus/is@npm:^4.6.0": + version: 4.6.0 + resolution: "@sindresorhus/is@npm:4.6.0" + checksum: 10c0/33b6fb1d0834ec8dd7689ddc0e2781c2bfd8b9c4e4bacbcb14111e0ae00621f2c264b8a7d36541799d74888b5dccdf422a891a5cb5a709ace26325eedc81e22e languageName: node linkType: hard -"@opentelemetry/instrumentation-nestjs-core@npm:0.44.0": - version: 0.44.0 - resolution: "@opentelemetry/instrumentation-nestjs-core@npm:0.44.0" - dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/0c97c227aa61fb7fd83b5290e54c81c2b6894a41924a25900bedc4289599274785db00e0888576bb3312dac198beb01bcb824fa8839a806a4fa95d1d6d45df1c +"@sqltools/formatter@npm:^1.2.5": + version: 1.2.5 + resolution: "@sqltools/formatter@npm:1.2.5" + checksum: 10c0/4b4fa62b8cd4880784b71cc5edd4a13da04fda0a915c14282765a8ec1a900a495e69b322704413e2052d221b5646d9fb0e20e87911f9a8f438f33180eecb11a4 languageName: node linkType: hard -"@opentelemetry/instrumentation-pg@npm:0.50.0": - version: 0.50.0 - resolution: "@opentelemetry/instrumentation-pg@npm:0.50.0" - dependencies: - "@opentelemetry/core": "npm:^1.26.0" - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:1.27.0" - "@opentelemetry/sql-common": "npm:^0.40.1" - "@types/pg": "npm:8.6.1" - "@types/pg-pool": "npm:2.0.6" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/d90efe69422c1a1d8825920504bcf0cb4e9028fb15c470fc83fdfcff407cad1df31208b8e74a10ec68cfdfb2f4c8e708624f783ae7389cc029e0581ca6eb2408 +"@standard-schema/spec@npm:1.1.0, @standard-schema/spec@npm:^1.0.0, @standard-schema/spec@npm:^1.1.0": + version: 1.1.0 + resolution: "@standard-schema/spec@npm:1.1.0" + checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 languageName: node linkType: hard -"@opentelemetry/instrumentation-redis-4@npm:0.46.0": - version: 0.46.0 - resolution: "@opentelemetry/instrumentation-redis-4@npm:0.46.0" +"@tokenizer/inflate@npm:^0.4.1": + version: 0.4.1 + resolution: "@tokenizer/inflate@npm:0.4.1" dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/redis-common": "npm:^0.36.2" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/e1647197899c594244de65c60db9dcc5981c215b489b2af3f463e91c4700b4535f97c6bddb390b778dd57879b84b3faba95fcf1da5584b7a2f5bb265724fc0ec + debug: "npm:^4.4.3" + token-types: "npm:^6.1.1" + checksum: 10c0/9817516efe21d1ce3bdfb80a1f94efc8981064ce3873448ba79f4d81d96c0694c484c289bd042d346ae5536cf77f5aa9a367d39c3df700eb610761b7c306b4de languageName: node linkType: hard -"@opentelemetry/instrumentation-tedious@npm:0.18.0": - version: 0.18.0 - resolution: "@opentelemetry/instrumentation-tedious@npm:0.18.0" - dependencies: - "@opentelemetry/instrumentation": "npm:^0.57.0" - "@opentelemetry/semantic-conventions": "npm:^1.27.0" - "@types/tedious": "npm:^4.0.14" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/b5fbeb4ca80fdc7a064d6485d3a20c656ee09bc1962c59aaec5cceb5111afd80b34e78f4346c20c005e8b5f8b44b02248771856cbc40da07b5a83eb46ac32df1 +"@tokenizer/token@npm:^0.3.0": + version: 0.3.0 + resolution: "@tokenizer/token@npm:0.3.0" + checksum: 10c0/7ab9a822d4b5ff3f5bca7f7d14d46bdd8432528e028db4a52be7fbf90c7f495cc1af1324691dda2813c6af8dc4b8eb29de3107d4508165f9aa5b53e7d501f155 languageName: node linkType: hard -"@opentelemetry/instrumentation-undici@npm:0.10.0": - version: 0.10.0 - resolution: "@opentelemetry/instrumentation-undici@npm:0.10.0" - dependencies: - "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.57.0" - peerDependencies: - "@opentelemetry/api": ^1.7.0 - checksum: 10c0/a790fe4edc818d6a3670ff50b6fc3e62806bf50b4660af104ebd1c3fc954f834e46a7e25d4cb20f0351ab779158b9422f09c4a64a1cb5481f7021c69e12dcef4 +"@tootallnate/once@npm:1": + version: 1.1.2 + resolution: "@tootallnate/once@npm:1.1.2" + checksum: 10c0/8fe4d006e90422883a4fa9339dd05a83ff626806262e1710cee5758d493e8cbddf2db81c0e4690636dc840b02c9fda62877866ea774ebd07c1777ed5fafbdec6 + languageName: node + linkType: hard + +"@tsyche/membrane@npm:^0.7.0": + version: 0.7.0 + resolution: "@tsyche/membrane@npm:0.7.0" + checksum: 10c0/671128088ad473793106bf77be34810e9d0ff30482482fac2262c2a86a362eefda07886658a7ada416195c062099bf2ee98562ec8b1429d6f66731b20107ab42 languageName: node linkType: hard -"@opentelemetry/instrumentation@npm:0.57.1": - version: 0.57.1 - resolution: "@opentelemetry/instrumentation@npm:0.57.1" +"@tybys/wasm-util@npm:^0.10.3": + version: 0.10.3 + resolution: "@tybys/wasm-util@npm:0.10.3" dependencies: - "@opentelemetry/api-logs": "npm:0.57.1" - "@types/shimmer": "npm:^1.2.0" - import-in-the-middle: "npm:^1.8.1" - require-in-the-middle: "npm:^7.1.1" - semver: "npm:^7.5.2" - shimmer: "npm:^1.2.1" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/d09e90584e218f8c4127ba680c3643e7167cb15af5a94581220e70e03938a9738f661a9f6aa0e7dc78f50f1ff8d2fdfcc9f9cd6252bcfd226b40553bc9399e1e + tslib: "npm:^2.4.0" + checksum: 10c0/fd2bd2a79c6cd8c79ed1cf7a0fa375c64589264c88a27acaf9756d556b453ea222b62a4f68dd2fbb8b3a78b6bab3b1f4fb2431b6afc6aeda8344b53a521a1cd3 languageName: node linkType: hard -"@opentelemetry/instrumentation@npm:^0.49 || ^0.50 || ^0.51 || ^0.52.0 || ^0.53.0": - version: 0.53.0 - resolution: "@opentelemetry/instrumentation@npm:0.53.0" +"@types/bcrypt@npm:^5.0.2": + version: 5.0.2 + resolution: "@types/bcrypt@npm:5.0.2" dependencies: - "@opentelemetry/api-logs": "npm:0.53.0" - "@types/shimmer": "npm:^1.2.0" - import-in-the-middle: "npm:^1.8.1" - require-in-the-middle: "npm:^7.1.1" - semver: "npm:^7.5.2" - shimmer: "npm:^1.2.1" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/943e289926812272cb77cda5e0a6b662bc6a92812b66420ceeca1c764f2e3a13364f6bbed7c9e84a17ad677474101ea3c598ef6a6cca982c35bfd24be6f6a25e + "@types/node": "npm:*" + checksum: 10c0/dd7f05e183b9b1fc08ec499069febf197ab8e9c720766b5bbb5628395082e248f9a444c60882fe7788361fcadc302e21e055ab9c26a300f100e08791c353e6aa languageName: node linkType: hard -"@opentelemetry/instrumentation@npm:^0.57.0, @opentelemetry/instrumentation@npm:^0.57.1": - version: 0.57.2 - resolution: "@opentelemetry/instrumentation@npm:0.57.2" +"@types/body-parser@npm:*": + version: 1.19.6 + resolution: "@types/body-parser@npm:1.19.6" dependencies: - "@opentelemetry/api-logs": "npm:0.57.2" - "@types/shimmer": "npm:^1.2.0" - import-in-the-middle: "npm:^1.8.1" - require-in-the-middle: "npm:^7.1.1" - semver: "npm:^7.5.2" - shimmer: "npm:^1.2.1" - peerDependencies: - "@opentelemetry/api": ^1.3.0 - checksum: 10c0/79ca65b66357665d19f89da7027da25ea1c6b55ecdacb0a99534923743c80deb9282870db563de8ae284b13e7e0aab8413efa1937f199deeaef069e07c7e4875 + "@types/connect": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/542da05c924dce58ee23f50a8b981fee36921850c82222e384931fda3e106f750f7880c47be665217d72dbe445129049db6eb1f44e7a06b09d62af8f3cca8ea7 languageName: node linkType: hard -"@opentelemetry/redis-common@npm:^0.36.2": - version: 0.36.2 - resolution: "@opentelemetry/redis-common@npm:0.36.2" - checksum: 10c0/4cb831628551b9f13dca8d65897e300ff7be0e256b77f455a26fb053bbdfc7997b27d066ab1402ca929e7ac77598e0d593f91762d8af9f798c19ba1524e9d078 +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f languageName: node linkType: hard -"@opentelemetry/resources@npm:1.30.1, @opentelemetry/resources@npm:^1.30.1": - version: 1.30.1 - resolution: "@opentelemetry/resources@npm:1.30.1" +"@types/connect@npm:*": + version: 3.4.38 + resolution: "@types/connect@npm:3.4.38" dependencies: - "@opentelemetry/core": "npm:1.30.1" - "@opentelemetry/semantic-conventions": "npm:1.28.0" - peerDependencies: - "@opentelemetry/api": ">=1.0.0 <1.10.0" - checksum: 10c0/688e73258283c80662bfa9a858aaf73bf3b832a18d96e546d0dddfa6dcec556cdfa087a1d0df643435293406009e4122d7fb7eeea69aa87b539d3bab756fba74 + "@types/node": "npm:*" + checksum: 10c0/2e1cdba2c410f25649e77856505cd60223250fa12dff7a503e492208dbfdd25f62859918f28aba95315251fd1f5e1ffbfca1e25e73037189ab85dd3f8d0a148c languageName: node linkType: hard -"@opentelemetry/sdk-trace-base@npm:^1.22, @opentelemetry/sdk-trace-base@npm:^1.30.1": - version: 1.30.1 - resolution: "@opentelemetry/sdk-trace-base@npm:1.30.1" +"@types/conventional-commits-parser@npm:^5.0.0": + version: 5.0.2 + resolution: "@types/conventional-commits-parser@npm:5.0.2" dependencies: - "@opentelemetry/core": "npm:1.30.1" - "@opentelemetry/resources": "npm:1.30.1" - "@opentelemetry/semantic-conventions": "npm:1.28.0" - peerDependencies: - "@opentelemetry/api": ">=1.0.0 <1.10.0" - checksum: 10c0/77019dc3efaeceb41b4c54dd83b92f0ccd81ecceca544cbbe8e0aee4b2c8727724bdb9dcecfe00622c16d60946ae4beb69a5c0e7d85c4bc7ef425bd84f8b970c + "@types/node": "npm:*" + checksum: 10c0/598af5a5d699490e8bdd53b59757b514e41791cc7c857c45ed1d4ea50b90e7e5e64f59cd7f50da2c7d7c2d03ca0f1f865c6fe1a46065401b2dbf2e93645c4283 languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:1.27.0": - version: 1.27.0 - resolution: "@opentelemetry/semantic-conventions@npm:1.27.0" - checksum: 10c0/b859773ba06b7e53dd9c6b45a171bf3000e405733adbf462ae91004ed011bc80edb5beecb817fb344a085adfd06045ab5b729c9bd0f1479650ad377134fb798c +"@types/cookiejar@npm:^2.1.5": + version: 2.1.5 + resolution: "@types/cookiejar@npm:2.1.5" + checksum: 10c0/af38c3d84aebb3ccc6e46fb6afeeaac80fb26e63a487dd4db5a8b87e6ad3d4b845ba1116b2ae90d6f886290a36200fa433d8b1f6fe19c47da6b81872ce9a2764 languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:1.28.0": - version: 1.28.0 - resolution: "@opentelemetry/semantic-conventions@npm:1.28.0" - checksum: 10c0/deb8a0f744198071e70fea27143cf7c9f7ecb7e4d7b619488c917834ea09b31543c1c2bcea4ec5f3cf68797f0ef3549609c14e859013d9376400ac1499c2b9cb +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:^1.28.0": - version: 1.38.0 - resolution: "@opentelemetry/semantic-conventions@npm:1.38.0" - checksum: 10c0/ae93e39ac18bf47df2b11d43e9a0dc1673b9d33e5f1e7f357c92968e6329fb9a67cf8a447e9a7150948ee3f8178b38274db365b8fa775a8c54802e0c6ccdd2ca +"@types/ejs@npm:^3.1.5": + version: 3.1.5 + resolution: "@types/ejs@npm:3.1.5" + checksum: 10c0/13d994cf0323d7e0ad33b9384914ccd3b4cd8bf282eced3649b1621b66ee7c784ac2d120a9d7b1f43d6f873518248fb8c3221b06a649b847860b9c2389a0b0ed languageName: node linkType: hard -"@opentelemetry/sql-common@npm:^0.40.1": - version: 0.40.1 - resolution: "@opentelemetry/sql-common@npm:0.40.1" - dependencies: - "@opentelemetry/core": "npm:^1.1.0" - peerDependencies: - "@opentelemetry/api": ^1.1.0 - checksum: 10c0/60a70358f0c94f610e2995333e96b406626d67d03d38ed03b15a3461ad0f8d64afbf6275cca7cb58fe955ecdce832f3ffc9b73f9d88503bba5d2a620bbd6d351 +"@types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 languageName: node linkType: hard -"@paralleldrive/cuid2@npm:^2.2.2": - version: 2.3.1 - resolution: "@paralleldrive/cuid2@npm:2.3.1" +"@types/express-serve-static-core@npm:^4.17.33": + version: 4.19.8 + resolution: "@types/express-serve-static-core@npm:4.19.8" dependencies: - "@noble/hashes": "npm:^1.1.5" - checksum: 10c0/6576b73de49d826b0f33cbab88424dec1f6fa454a9e59a7b621f78c2cfdd2e59d7f48175826d698940a717f45eeb5e87a508583a7316e608f6a05a861a40c129 + "@types/node": "npm:*" + "@types/qs": "npm:*" + "@types/range-parser": "npm:*" + "@types/send": "npm:*" + checksum: 10c0/6fb58a85b209e0e421b29c52e0a51dbf7c039b711c604cf45d46470937a5c7c16b30aa5ce9bf7da0bd8a2e9361c95b5055599c0500a96bf4414d26c81f02d7fe languageName: node linkType: hard -"@pkgjs/parseargs@npm:^0.11.0": - version: 0.11.0 - resolution: "@pkgjs/parseargs@npm:0.11.0" - checksum: 10c0/5bd7576bb1b38a47a7fc7b51ac9f38748e772beebc56200450c4a817d712232b8f1d3ef70532c80840243c657d491cf6a6be1e3a214cff907645819fdc34aadd +"@types/express-serve-static-core@npm:^5.0.0": + version: 5.1.1 + resolution: "@types/express-serve-static-core@npm:5.1.1" + dependencies: + "@types/node": "npm:*" + "@types/qs": "npm:*" + "@types/range-parser": "npm:*" + "@types/send": "npm:*" + checksum: 10c0/ee88216e114368ef06bcafeceb74a7e8671b90900fb0ab1d49ff41542c3a344231ef0d922bf63daa79f0585f3eebe2ce5ec7f83facc581eff8bcdb136a225ef3 languageName: node linkType: hard -"@pkgr/core@npm:^0.2.9": - version: 0.2.9 - resolution: "@pkgr/core@npm:0.2.9" - checksum: 10c0/ac8e4e8138b1a7a4ac6282873aef7389c352f1f8b577b4850778f5182e4a39a5241facbe48361fec817f56d02b51691b383010843fb08b34a8e8ea3614688fd5 +"@types/express@npm:*": + version: 5.0.6 + resolution: "@types/express@npm:5.0.6" + dependencies: + "@types/body-parser": "npm:*" + "@types/express-serve-static-core": "npm:^5.0.0" + "@types/serve-static": "npm:^2" + checksum: 10c0/f1071e3389a955d4f9a38aae38634121c7cd9b3171ba4201ec9b56bd534aba07866839d278adc0dda05b942b05a901a02fd174201c3b1f70ce22b10b6c68f24b languageName: node linkType: hard -"@prisma/instrumentation@npm:5.22.0": - version: 5.22.0 - resolution: "@prisma/instrumentation@npm:5.22.0" +"@types/express@npm:^4.17.21": + version: 4.17.25 + resolution: "@types/express@npm:4.17.25" dependencies: - "@opentelemetry/api": "npm:^1.8" - "@opentelemetry/instrumentation": "npm:^0.49 || ^0.50 || ^0.51 || ^0.52.0 || ^0.53.0" - "@opentelemetry/sdk-trace-base": "npm:^1.22" - checksum: 10c0/2f8fafd996f6f774affd0f48c9112cba045bb7214b79e9108e355d854005a90587bdb5983bcdeea7f7886b29426a42ee1597012a5eb15fac8f7e437c5c430445 + "@types/body-parser": "npm:*" + "@types/express-serve-static-core": "npm:^4.17.33" + "@types/qs": "npm:*" + "@types/serve-static": "npm:^1" + checksum: 10c0/f42b616d2c9dbc50352c820db7de182f64ebbfa8dba6fb6c98e5f8f0e2ef3edde0131719d9dc6874803d25ad9ca2d53471d0fec2fbc60a6003a43d015bab72c4 languageName: node linkType: hard -"@rtsao/scc@npm:^1.1.0": - version: 1.1.0 - resolution: "@rtsao/scc@npm:1.1.0" - checksum: 10c0/b5bcfb0d87f7d1c1c7c0f7693f53b07866ed9fec4c34a97a8c948fb9a7c0082e416ce4d3b60beb4f5e167cbe04cdeefbf6771320f3ede059b9ce91188c409a5b +"@types/glob@npm:^7.1.1": + version: 7.2.0 + resolution: "@types/glob@npm:7.2.0" + dependencies: + "@types/minimatch": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/a8eb5d5cb5c48fc58c7ca3ff1e1ddf771ee07ca5043da6e4871e6757b4472e2e73b4cfef2644c38983174a4bc728c73f8da02845c28a1212f98cabd293ecae98 languageName: node linkType: hard -"@scarf/scarf@npm:=1.4.0": - version: 1.4.0 - resolution: "@scarf/scarf@npm:1.4.0" - checksum: 10c0/332118bb488e7a70eaad068fb1a33f016d30442fb0498b37a80cb425c1e741853a5de1a04dce03526ed6265481ecf744aa6e13f072178d19e6b94b19f623ae1c +"@types/http-errors@npm:*": + version: 2.0.5 + resolution: "@types/http-errors@npm:2.0.5" + checksum: 10c0/00f8140fbc504f47356512bd88e1910c2f07e04233d99c88c854b3600ce0523c8cd0ba7d1897667243282eb44c59abb9245959e2428b9de004f93937f52f7c15 languageName: node linkType: hard -"@selderee/plugin-htmlparser2@npm:^0.11.0": - version: 0.11.0 - resolution: "@selderee/plugin-htmlparser2@npm:0.11.0" +"@types/jest@npm:^27.5.2": + version: 27.5.2 + resolution: "@types/jest@npm:27.5.2" dependencies: - domhandler: "npm:^5.0.3" - selderee: "npm:^0.11.0" - checksum: 10c0/e938ba9aeb31a9cf30dcb2977ef41685c598bf744bedc88c57aa9e8b7e71b51781695cf99c08aac50773fd7714eba670bd2a079e46db0788abe40c6d220084eb - languageName: node - linkType: hard - -"@sentry/core@npm:8.55.0": - version: 8.55.0 - resolution: "@sentry/core@npm:8.55.0" - checksum: 10c0/51c1768f0bd940a060787b402dba9df3347c918ea4c0fdc300d45c37703ebbf6f7adee9fff332cfd6b23372b33c46e6d2f31a04227762d490aaddc14773894a0 - languageName: node - linkType: hard - -"@sentry/node@npm:^8.26.0": - version: 8.55.0 - resolution: "@sentry/node@npm:8.55.0" - dependencies: - "@opentelemetry/api": "npm:^1.9.0" - "@opentelemetry/context-async-hooks": "npm:^1.30.1" - "@opentelemetry/core": "npm:^1.30.1" - "@opentelemetry/instrumentation": "npm:^0.57.1" - "@opentelemetry/instrumentation-amqplib": "npm:^0.46.0" - "@opentelemetry/instrumentation-connect": "npm:0.43.0" - "@opentelemetry/instrumentation-dataloader": "npm:0.16.0" - "@opentelemetry/instrumentation-express": "npm:0.47.0" - "@opentelemetry/instrumentation-fastify": "npm:0.44.1" - "@opentelemetry/instrumentation-fs": "npm:0.19.0" - "@opentelemetry/instrumentation-generic-pool": "npm:0.43.0" - "@opentelemetry/instrumentation-graphql": "npm:0.47.0" - "@opentelemetry/instrumentation-hapi": "npm:0.45.1" - "@opentelemetry/instrumentation-http": "npm:0.57.1" - "@opentelemetry/instrumentation-ioredis": "npm:0.47.0" - "@opentelemetry/instrumentation-kafkajs": "npm:0.7.0" - "@opentelemetry/instrumentation-knex": "npm:0.44.0" - "@opentelemetry/instrumentation-koa": "npm:0.47.0" - "@opentelemetry/instrumentation-lru-memoizer": "npm:0.44.0" - "@opentelemetry/instrumentation-mongodb": "npm:0.51.0" - "@opentelemetry/instrumentation-mongoose": "npm:0.46.0" - "@opentelemetry/instrumentation-mysql": "npm:0.45.0" - "@opentelemetry/instrumentation-mysql2": "npm:0.45.0" - "@opentelemetry/instrumentation-nestjs-core": "npm:0.44.0" - "@opentelemetry/instrumentation-pg": "npm:0.50.0" - "@opentelemetry/instrumentation-redis-4": "npm:0.46.0" - "@opentelemetry/instrumentation-tedious": "npm:0.18.0" - "@opentelemetry/instrumentation-undici": "npm:0.10.0" - "@opentelemetry/resources": "npm:^1.30.1" - "@opentelemetry/sdk-trace-base": "npm:^1.30.1" - "@opentelemetry/semantic-conventions": "npm:^1.28.0" - "@prisma/instrumentation": "npm:5.22.0" - "@sentry/core": "npm:8.55.0" - "@sentry/opentelemetry": "npm:8.55.0" - import-in-the-middle: "npm:^1.11.2" - checksum: 10c0/fa18fa05ac25eb82f19ac58bfa136137d37162bc620206dd27ace3cfb3cedbf03acaced814725dbd25392976ea264678ba2b0da745cda563d456527873f5b89f - languageName: node - linkType: hard - -"@sentry/opentelemetry@npm:8.55.0": - version: 8.55.0 - resolution: "@sentry/opentelemetry@npm:8.55.0" - dependencies: - "@sentry/core": "npm:8.55.0" - peerDependencies: - "@opentelemetry/api": ^1.9.0 - "@opentelemetry/context-async-hooks": ^1.30.1 - "@opentelemetry/core": ^1.30.1 - "@opentelemetry/instrumentation": ^0.57.1 - "@opentelemetry/sdk-trace-base": ^1.30.1 - "@opentelemetry/semantic-conventions": ^1.28.0 - checksum: 10c0/8175aebd39064c288c0f0ec117f5b22daae67dbdb3827ffcc60ec32400833f6a5e1f7dc44b28ebabec3aa45952cf31e8237c216c4fdd7d7f3a228eabca583b58 + jest-matcher-utils: "npm:^27.0.0" + pretty-format: "npm:^27.0.0" + checksum: 10c0/29ef3da9b94a15736a67fc13956f385ac2ba2c6297f50d550446842c278f2e0d9f343dcd8e31c321ada5d8a1bd67bc1d79c7b6ff1802d55508c692123b3d9794 languageName: node linkType: hard -"@sentry/types@npm:^8.26.0": - version: 8.55.0 - resolution: "@sentry/types@npm:8.55.0" - dependencies: - "@sentry/core": "npm:8.55.0" - checksum: 10c0/fc0814eea9a4fd3b8acee9d8c79bd42b1193692ceaba332663f2ae781d96fbd46fc49a7b1253606f98d96487c2efda1113c2db0dff4ff6d11b8b8a879beecf7f - languageName: node - linkType: hard - -"@sinclair/typebox@npm:^0.34.0": - version: 0.34.41 - resolution: "@sinclair/typebox@npm:0.34.41" - checksum: 10c0/0fb61fc2f90c25e30b19b0096eb8ab3ccef401d3e2acfce42168ff0ee877ba5981c8243fa6b1035ac756cde95316724e978b2837dd642d7e4e095de03a999c90 - languageName: node - linkType: hard - -"@sindresorhus/base62@npm:^1.0.0": - version: 1.0.0 - resolution: "@sindresorhus/base62@npm:1.0.0" - checksum: 10c0/9a14df0f058fdf4731c30f0f05728a4822144ee42236030039d7fa5a1a1072c2879feba8091fd4a17c8922d1056bc07bada77c31fddc3e15836fc05a266fd918 - languageName: node - linkType: hard - -"@sinonjs/commons@npm:^3.0.1": - version: 3.0.1 - resolution: "@sinonjs/commons@npm:3.0.1" - dependencies: - type-detect: "npm:4.0.8" - checksum: 10c0/1227a7b5bd6c6f9584274db996d7f8cee2c8c350534b9d0141fc662eaf1f292ea0ae3ed19e5e5271c8fd390d27e492ca2803acd31a1978be2cdc6be0da711403 - languageName: node - linkType: hard - -"@sinonjs/fake-timers@npm:^13.0.0": - version: 13.0.5 - resolution: "@sinonjs/fake-timers@npm:13.0.5" - dependencies: - "@sinonjs/commons": "npm:^3.0.1" - checksum: 10c0/a707476efd523d2138ef6bba916c83c4a377a8372ef04fad87499458af9f01afc58f4f245c5fd062793d6d70587309330c6f96947b5bd5697961c18004dc3e26 - languageName: node - linkType: hard - -"@smithy/abort-controller@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/abort-controller@npm:4.2.5" - dependencies: - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/aaca4d8a87100f4b8805bb034cae9315b9bf813a029576d3417a1a1ecd5c1d9e92907349ffaf9d6606c4fc20483ac28864565c1e6dec6f2a7d8709522c8b5290 - languageName: node - linkType: hard - -"@smithy/config-resolver@npm:^4.4.3": - version: 4.4.3 - resolution: "@smithy/config-resolver@npm:4.4.3" - dependencies: - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/util-config-provider": "npm:^4.2.0" - "@smithy/util-endpoints": "npm:^3.2.5" - "@smithy/util-middleware": "npm:^4.2.5" - tslib: "npm:^2.6.2" - checksum: 10c0/e28844ea32776b2d2790e134bdfcb700f5a8f4bcd7aeac9869ddac635012eb2911d5abbddf36ae63703dff3af435015095b381b17a3cb4d2b1ba1c02cdc9f314 - languageName: node - linkType: hard - -"@smithy/core@npm:^3.18.2, @smithy/core@npm:^3.18.3": - version: 3.18.3 - resolution: "@smithy/core@npm:3.18.3" - dependencies: - "@smithy/middleware-serde": "npm:^4.2.5" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/util-base64": "npm:^4.3.0" - "@smithy/util-body-length-browser": "npm:^4.2.0" - "@smithy/util-middleware": "npm:^4.2.5" - "@smithy/util-stream": "npm:^4.5.6" - "@smithy/util-utf8": "npm:^4.2.0" - "@smithy/uuid": "npm:^1.1.0" - tslib: "npm:^2.6.2" - checksum: 10c0/6ac1ffcfe32d7bd19fc1cb7640d3a1ce05ad753b7c35e4b22b153a85c817acec9d4061434da284a578743b008e66c89797f0cdb30fd959e4c2a435e93be20213 - languageName: node - linkType: hard - -"@smithy/credential-provider-imds@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/credential-provider-imds@npm:4.2.5" - dependencies: - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/url-parser": "npm:^4.2.5" - tslib: "npm:^2.6.2" - checksum: 10c0/98efbb03e75d71392baac12755c677b72bbb239b84ff3e776aabc0d192f4501d35da8b81956b48e266501eeff37d3bde56ab188fefb5422bf107a0f20bfd7674 - languageName: node - linkType: hard - -"@smithy/fetch-http-handler@npm:^5.3.6": - version: 5.3.6 - resolution: "@smithy/fetch-http-handler@npm:5.3.6" - dependencies: - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/querystring-builder": "npm:^4.2.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/util-base64": "npm:^4.3.0" - tslib: "npm:^2.6.2" - checksum: 10c0/8ae0401c69cf941bc2716d0372fad715f7d80e23c5aba5e30ac3abc632a02de5895a417419064324c6853857c7bcffab45fc39393cc0b46d07a11b591015a68a - languageName: node - linkType: hard - -"@smithy/hash-node@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/hash-node@npm:4.2.5" - dependencies: - "@smithy/types": "npm:^4.9.0" - "@smithy/util-buffer-from": "npm:^4.2.0" - "@smithy/util-utf8": "npm:^4.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/e0c24b8b93be02a491303a014ba57e2bb746f3f8905df330d8a480c94480803e0f93d76cdbc3d8229b7673a22e68b23ee6f5ce4d6db1ac2c427cc36e804fedcf - languageName: node - linkType: hard - -"@smithy/invalid-dependency@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/invalid-dependency@npm:4.2.5" - dependencies: - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/0b3e7608d3c145ad557c04eb5b0f7f10dd93f5eaf1d36b724b0e4ff3c3f500893e19b8ecf02ede4822bc36c049a4e03b69890a37e776a4ac6cfcc8e2f6fa843e - languageName: node - linkType: hard - -"@smithy/is-array-buffer@npm:^2.2.0": - version: 2.2.0 - resolution: "@smithy/is-array-buffer@npm:2.2.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/2f2523cd8cc4538131e408eb31664983fecb0c8724956788b015aaf3ab85a0c976b50f4f09b176f1ed7bbe79f3edf80743be7a80a11f22cd9ce1285d77161aaf - languageName: node - linkType: hard - -"@smithy/is-array-buffer@npm:^4.2.0": - version: 4.2.0 - resolution: "@smithy/is-array-buffer@npm:4.2.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/8e3e21cff5929d627bbf4a9beded28bd54555cfd37772226290964af6950cc10d700776a2ce7553f34ddf88a2e7e3d4681de58c94e9805592d901fc0f32cb597 - languageName: node - linkType: hard - -"@smithy/middleware-content-length@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/middleware-content-length@npm:4.2.5" - dependencies: - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/672a29ab57b80dcebd841624c6a762980b17dc658ca0f7c948c0739fedacf3c6a43d0c3f63e79f13aa4069d9fb1f52266bcd5980d9e6907b2f62b918c286b861 - languageName: node - linkType: hard - -"@smithy/middleware-endpoint@npm:^4.3.10, @smithy/middleware-endpoint@npm:^4.3.9": - version: 4.3.10 - resolution: "@smithy/middleware-endpoint@npm:4.3.10" - dependencies: - "@smithy/core": "npm:^3.18.3" - "@smithy/middleware-serde": "npm:^4.2.5" - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/shared-ini-file-loader": "npm:^4.4.0" - "@smithy/types": "npm:^4.9.0" - "@smithy/url-parser": "npm:^4.2.5" - "@smithy/util-middleware": "npm:^4.2.5" - tslib: "npm:^2.6.2" - checksum: 10c0/8e186b624d5c7ce9a8d8bc7ae7dfdc62d815ffe00f03fca674b3998b8412e1c058066ded386951f7dae169fa73983ecea2f9de64f570c8f4f0ded7efb63bb929 - languageName: node - linkType: hard - -"@smithy/middleware-retry@npm:^4.4.9": - version: 4.4.10 - resolution: "@smithy/middleware-retry@npm:4.4.10" - dependencies: - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/service-error-classification": "npm:^4.2.5" - "@smithy/smithy-client": "npm:^4.9.6" - "@smithy/types": "npm:^4.9.0" - "@smithy/util-middleware": "npm:^4.2.5" - "@smithy/util-retry": "npm:^4.2.5" - "@smithy/uuid": "npm:^1.1.0" - tslib: "npm:^2.6.2" - checksum: 10c0/a55e0d862f80b136c5b2dda862d6d8e61c8931fafb17cb7529c272cdc390ca3266d183b0c990d2f20b964424f2c31e3c53846edb25151bdc42650ca7732f8e9e - languageName: node - linkType: hard - -"@smithy/middleware-serde@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/middleware-serde@npm:4.2.5" - dependencies: - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/df1399613620c091183fe2e04115b944403b96b44d271bdbfffdec0e3e1f2006543831e779dfd81d2ecfe755deb55c97ef23278f449333778b4e1ef8ac32f879 - languageName: node - linkType: hard - -"@smithy/middleware-stack@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/middleware-stack@npm:4.2.5" - dependencies: - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/c88476053920bb54dbf0c407b22cf5e17f497def265ee6bbdacd559144acb3142082e9f5439745da3d96655aa0aafdbb33cab14ba02ec4c3b108eab512c612b8 - languageName: node - linkType: hard - -"@smithy/node-config-provider@npm:^4.3.5": - version: 4.3.5 - resolution: "@smithy/node-config-provider@npm:4.3.5" - dependencies: - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/shared-ini-file-loader": "npm:^4.4.0" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/433eb6cab0a96fc7391351925098954265f630986777a0443f8e05f1d22b5b5ebba62cb26c4d9d0989eb747a0c4921bfa833593872715810cabc3998cf5e2816 - languageName: node - linkType: hard - -"@smithy/node-http-handler@npm:^4.4.5": - version: 4.4.5 - resolution: "@smithy/node-http-handler@npm:4.4.5" - dependencies: - "@smithy/abort-controller": "npm:^4.2.5" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/querystring-builder": "npm:^4.2.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/5385f20466e4ecf7e7fd9b1309077820fa65e213b806fce4ec08191c9af216da03bae6e03c5860fedf6d87c5aeba660721e1c4e0114a1d1a5d8a1cf840c30604 - languageName: node - linkType: hard - -"@smithy/property-provider@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/property-provider@npm:4.2.5" - dependencies: - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/bea8cf1758e90779476b5a44d722a63a658bee27a00e2f4f2b0b6e96ee14e2e66e3a23674c51619eb00c0472592a1d658249d7ee79cf19847ac10c698b3b67af - languageName: node - linkType: hard - -"@smithy/protocol-http@npm:^5.3.5": - version: 5.3.5 - resolution: "@smithy/protocol-http@npm:5.3.5" - dependencies: - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/15e6bfbf39a8740b5cce729b84d470835887442f0f662325eb55d1f02d8d790772595446bb7f776d2852ca6f6ff67d7a9f45a3eab0bc757997c82564a483f3dc - languageName: node - linkType: hard - -"@smithy/querystring-builder@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/querystring-builder@npm:4.2.5" - dependencies: - "@smithy/types": "npm:^4.9.0" - "@smithy/util-uri-escape": "npm:^4.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/1dbbf4792a90c7f4c3948526200a61b83c0444d86da6b925501611c11c4a12bdfe7e1870e66c10353128821cf5f9fedb509af85deb6c2015be0ef298a6d03972 - languageName: node - linkType: hard - -"@smithy/querystring-parser@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/querystring-parser@npm:4.2.5" - dependencies: - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/83c4200282469791a3266d8f44c6ce9128b0adb42ee9f097bac31fafa5bb62eb1cfcab29ff0641fe48d2585089109633eb1d99151dc91e4879dae563898fecdc - languageName: node - linkType: hard - -"@smithy/service-error-classification@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/service-error-classification@npm:4.2.5" - dependencies: - "@smithy/types": "npm:^4.9.0" - checksum: 10c0/d1a3ef99b4474ad71cd6279e581e174fd5421646618360200350c4d346b2227ddae14a71a88c32442e88b1261ed080e87df6b3d34298833be6cf5db95d266db4 - languageName: node - linkType: hard - -"@smithy/shared-ini-file-loader@npm:^4.4.0": - version: 4.4.0 - resolution: "@smithy/shared-ini-file-loader@npm:4.4.0" - dependencies: - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/a674622375df25685e793b0c777e856f439a79614240445b7f5982b263b5525f6f6f2c02ab4058db7e6a8988d9b1809181cc70bf4d06ea2a71608fecad6ea6d1 - languageName: node - linkType: hard - -"@smithy/signature-v4@npm:^5.3.5": - version: 5.3.5 - resolution: "@smithy/signature-v4@npm:5.3.5" - dependencies: - "@smithy/is-array-buffer": "npm:^4.2.0" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/util-hex-encoding": "npm:^4.2.0" - "@smithy/util-middleware": "npm:^4.2.5" - "@smithy/util-uri-escape": "npm:^4.2.0" - "@smithy/util-utf8": "npm:^4.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/e4e8f28fc53f9609f5d290d2f94f0736713a5269061b959e6be6da3ed2ef58511ba56c2727b4557349ae5201c0879555a28df4bd717e6d1789a52a678deef876 - languageName: node - linkType: hard - -"@smithy/smithy-client@npm:^4.9.5, @smithy/smithy-client@npm:^4.9.6": - version: 4.9.6 - resolution: "@smithy/smithy-client@npm:4.9.6" - dependencies: - "@smithy/core": "npm:^3.18.3" - "@smithy/middleware-endpoint": "npm:^4.3.10" - "@smithy/middleware-stack": "npm:^4.2.5" - "@smithy/protocol-http": "npm:^5.3.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/util-stream": "npm:^4.5.6" - tslib: "npm:^2.6.2" - checksum: 10c0/f5e232e020a617ef89f54292b50590c5770712b721e4c3f3c62286e38f86fc3193156673590345a96c1269283443b94b58598b2e9f3a0fd1aa2176a1f9879287 - languageName: node - linkType: hard - -"@smithy/types@npm:^4.9.0": - version: 4.9.0 - resolution: "@smithy/types@npm:4.9.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/7068428d2e98eafb7f7e03d10f919ae0e7ea2f339b5afca1631be3d6a6cb3512d5dc57ca95d4dab533a3ad587eeba3a1c77305eb4e563fbc067abda170482ff5 - languageName: node - linkType: hard - -"@smithy/url-parser@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/url-parser@npm:4.2.5" - dependencies: - "@smithy/querystring-parser": "npm:^4.2.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/1d8241eeaaaa6401e1de670c2ebcd3992f9abb175f399c92aec1b30de81ce8023f66e0b7079be966b0a891c878a798d4cb08a09f410bcb795799e8ae9057e99a - languageName: node - linkType: hard - -"@smithy/util-base64@npm:^4.3.0": - version: 4.3.0 - resolution: "@smithy/util-base64@npm:4.3.0" - dependencies: - "@smithy/util-buffer-from": "npm:^4.2.0" - "@smithy/util-utf8": "npm:^4.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/02dd536b9257914cc9a595a865faac64fc96db10468d52d0cba475df78764fc25ba255707ccd061ee197fca189d7859d70af8cf89b0b0c3e27c1c693676eb6e4 - languageName: node - linkType: hard - -"@smithy/util-body-length-browser@npm:^4.2.0": - version: 4.2.0 - resolution: "@smithy/util-body-length-browser@npm:4.2.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/15553c249088d59406c6917c19ed19810c7dbcc0967c44e5f3fbb2cc870c004b35f388c082b77f370a2c440a69ec7e8336c7a066af904812a66944dd5cb4c8cc - languageName: node - linkType: hard - -"@smithy/util-body-length-node@npm:^4.2.1": - version: 4.2.1 - resolution: "@smithy/util-body-length-node@npm:4.2.1" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/3c32306735af5b62f75375e976a531ab45f171dfb0dc23ee035478d2132eaf21f244c31b0f3e861c514ff97d8112055e74c98ed44595ad24bd31434d5fdaf4bf - languageName: node - linkType: hard - -"@smithy/util-buffer-from@npm:^2.2.0": - version: 2.2.0 - resolution: "@smithy/util-buffer-from@npm:2.2.0" - dependencies: - "@smithy/is-array-buffer": "npm:^2.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/223d6a508b52ff236eea01cddc062b7652d859dd01d457a4e50365af3de1e24a05f756e19433f6ccf1538544076b4215469e21a4ea83dc1d58d829725b0dbc5a - languageName: node - linkType: hard - -"@smithy/util-buffer-from@npm:^4.2.0": - version: 4.2.0 - resolution: "@smithy/util-buffer-from@npm:4.2.0" - dependencies: - "@smithy/is-array-buffer": "npm:^4.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/4842d5607240c11400db30762ef6cb4def8d13e3474c5a901a4e2a1783198f5b163ab6011cf24a7f0acbba9a4d7cc79db1d811dc8aa9da446448e52773223997 - languageName: node - linkType: hard - -"@smithy/util-config-provider@npm:^4.2.0": - version: 4.2.0 - resolution: "@smithy/util-config-provider@npm:4.2.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/0699b9980ef94eac8f491c2ac557dc47e01c6ae71dabcb4464cc064f8dbf0855797461dbec8ba1925d45f076e968b0df02f0691c636cd1043e560f67541a1d27 - languageName: node - linkType: hard - -"@smithy/util-defaults-mode-browser@npm:^4.3.8": - version: 4.3.9 - resolution: "@smithy/util-defaults-mode-browser@npm:4.3.9" - dependencies: - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/smithy-client": "npm:^4.9.6" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/819cf5a6b261a28977ad489833dceefd4cda6e9eaf7830e72fe8a5795bb3d270e845ceb4a5f071b4278fdd7bcf8446187b1b93a23cf60d513dddae3cc472495b - languageName: node - linkType: hard - -"@smithy/util-defaults-mode-node@npm:^4.2.11": - version: 4.2.12 - resolution: "@smithy/util-defaults-mode-node@npm:4.2.12" - dependencies: - "@smithy/config-resolver": "npm:^4.4.3" - "@smithy/credential-provider-imds": "npm:^4.2.5" - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/property-provider": "npm:^4.2.5" - "@smithy/smithy-client": "npm:^4.9.6" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/b24c9664abfc4af0bb898b7d397b1a5073fe142c6357a7c09b107c0fdfe6554b1b2bf93ad9b8085cc546cc157f566ea540b36c2956bdeed1f6f639b8b993ee70 - languageName: node - linkType: hard - -"@smithy/util-endpoints@npm:^3.2.5": - version: 3.2.5 - resolution: "@smithy/util-endpoints@npm:3.2.5" - dependencies: - "@smithy/node-config-provider": "npm:^4.3.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/919767b499062d804938471ff02220b74662bf0fc9b7ecf7e7aa6c29f8a23bbc9c68c53718c4bc70c802f7917e4729a37a95c63a3990904047352e36183ddae3 - languageName: node - linkType: hard - -"@smithy/util-hex-encoding@npm:^4.2.0": - version: 4.2.0 - resolution: "@smithy/util-hex-encoding@npm:4.2.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/aaa94a69f03d14d3f28125cc915ca421065735e2d05d7305f0958a50021b2fce4fc68a248328e6b5b612dbaa49e471d481ff513bf89554f659f0a49573e97312 - languageName: node - linkType: hard - -"@smithy/util-middleware@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/util-middleware@npm:4.2.5" - dependencies: - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/6b05a986ec2b992e3dc016148394e812064e33f0d70f30a57c9e2ae419cb7215a16430e2afff683abdf72cb686b06e43d0afa3a86abc72fbaa130976a7e2bbfb - languageName: node - linkType: hard - -"@smithy/util-retry@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/util-retry@npm:4.2.5" - dependencies: - "@smithy/service-error-classification": "npm:^4.2.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/3b330df346de40bdc49356f3fdf7164adefbd2b45d4beed6fd7d655569c2dcb1f52a7fd77d7a9ace8f6eeed9f5612cb02a60f66463972f934fae347e20c97b14 - languageName: node - linkType: hard - -"@smithy/util-stream@npm:^4.5.6": - version: 4.5.6 - resolution: "@smithy/util-stream@npm:4.5.6" - dependencies: - "@smithy/fetch-http-handler": "npm:^5.3.6" - "@smithy/node-http-handler": "npm:^4.4.5" - "@smithy/types": "npm:^4.9.0" - "@smithy/util-base64": "npm:^4.3.0" - "@smithy/util-buffer-from": "npm:^4.2.0" - "@smithy/util-hex-encoding": "npm:^4.2.0" - "@smithy/util-utf8": "npm:^4.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/42bb6f834b3f617cf2e421450cf43f7259c1cc4cd7c7ad230e4c929fed265ef7b9f3610977df497115978f3d7a80d569ea1abbbef8d595e6b2e1a4ccca3a37fa - languageName: node - linkType: hard - -"@smithy/util-uri-escape@npm:^4.2.0": - version: 4.2.0 - resolution: "@smithy/util-uri-escape@npm:4.2.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/1933e8d939dc52e1ee5e7d2397f4c208a9eac0283397a19ee72078d04db997ebe3ad39709b56aac586ffce10d1cf5ab17dfc068ea6ab030098fc06fe3532e085 - languageName: node - linkType: hard - -"@smithy/util-utf8@npm:^2.0.0": - version: 2.3.0 - resolution: "@smithy/util-utf8@npm:2.3.0" - dependencies: - "@smithy/util-buffer-from": "npm:^2.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/e18840c58cc507ca57fdd624302aefd13337ee982754c9aa688463ffcae598c08461e8620e9852a424d662ffa948fc64919e852508028d09e89ced459bd506ab - languageName: node - linkType: hard - -"@smithy/util-utf8@npm:^4.2.0": - version: 4.2.0 - resolution: "@smithy/util-utf8@npm:4.2.0" - dependencies: - "@smithy/util-buffer-from": "npm:^4.2.0" - tslib: "npm:^2.6.2" - checksum: 10c0/689a1f2295d52bec0dde7215a075d79ef32ad8b146cb610a529b2cab747d96978401fd31469c225e31f3042830c54403e64d39b28033df013c8de27a84b405a2 - languageName: node - linkType: hard - -"@smithy/util-waiter@npm:^4.2.5": - version: 4.2.5 - resolution: "@smithy/util-waiter@npm:4.2.5" - dependencies: - "@smithy/abort-controller": "npm:^4.2.5" - "@smithy/types": "npm:^4.9.0" - tslib: "npm:^2.6.2" - checksum: 10c0/5d822613ab32e95f4c69ac3f1763a14eb88965ae26c589d45b7921ecd849f0c38cd7aea2a0c3651ac2345699e67d86076595178a015516fd385ecb028a7afedf - languageName: node - linkType: hard - -"@smithy/uuid@npm:^1.1.0": - version: 1.1.0 - resolution: "@smithy/uuid@npm:1.1.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/f8a8bfcc0e241457636884e778e261d45d8a3aaad533775111170cac36ac666275b59ec6d86d3d5b8d470ff4b864202d2a1a188b3c0e0ed0c86a0b693acf1ecf - languageName: node - linkType: hard - -"@sqltools/formatter@npm:^1.2.5": - version: 1.2.5 - resolution: "@sqltools/formatter@npm:1.2.5" - checksum: 10c0/4b4fa62b8cd4880784b71cc5edd4a13da04fda0a915c14282765a8ec1a900a495e69b322704413e2052d221b5646d9fb0e20e87911f9a8f438f33180eecb11a4 - languageName: node - linkType: hard - -"@tokenizer/inflate@npm:^0.3.1": - version: 0.3.1 - resolution: "@tokenizer/inflate@npm:0.3.1" - dependencies: - debug: "npm:^4.4.1" - fflate: "npm:^0.8.2" - token-types: "npm:^6.0.0" - checksum: 10c0/6321c28ae6346e527837ef9a9242129213b57aa7b9a3752c2275f10e67985a3e0adbb3397db36c3ff3f982f16bfa53aab0f9e04d59955d86ed53fa80aec34be5 - languageName: node - linkType: hard - -"@tokenizer/token@npm:^0.3.0": - version: 0.3.0 - resolution: "@tokenizer/token@npm:0.3.0" - checksum: 10c0/7ab9a822d4b5ff3f5bca7f7d14d46bdd8432528e028db4a52be7fbf90c7f495cc1af1324691dda2813c6af8dc4b8eb29de3107d4508165f9aa5b53e7d501f155 - languageName: node - linkType: hard - -"@tootallnate/once@npm:1": - version: 1.1.2 - resolution: "@tootallnate/once@npm:1.1.2" - checksum: 10c0/8fe4d006e90422883a4fa9339dd05a83ff626806262e1710cee5758d493e8cbddf2db81c0e4690636dc840b02c9fda62877866ea774ebd07c1777ed5fafbdec6 - languageName: node - linkType: hard - -"@tsconfig/node10@npm:^1.0.7": - version: 1.0.12 - resolution: "@tsconfig/node10@npm:1.0.12" - checksum: 10c0/7bbbd7408cfaced86387a9b1b71cebc91c6fd701a120369735734da8eab1a4773fc079abd9f40c9e0b049e12586c8ac0e13f0da596bfd455b9b4c3faa813ebc5 - languageName: node - linkType: hard - -"@tsconfig/node12@npm:^1.0.7": - version: 1.0.11 - resolution: "@tsconfig/node12@npm:1.0.11" - checksum: 10c0/dddca2b553e2bee1308a056705103fc8304e42bb2d2cbd797b84403a223b25c78f2c683ec3e24a095e82cd435387c877239bffcb15a590ba817cd3f6b9a99fd9 - languageName: node - linkType: hard - -"@tsconfig/node14@npm:^1.0.0": - version: 1.0.3 - resolution: "@tsconfig/node14@npm:1.0.3" - checksum: 10c0/67c1316d065fdaa32525bc9449ff82c197c4c19092b9663b23213c8cbbf8d88b6ed6a17898e0cbc2711950fbfaf40388938c1c748a2ee89f7234fc9e7fe2bf44 - languageName: node - linkType: hard - -"@tsconfig/node16@npm:^1.0.2": - version: 1.0.4 - resolution: "@tsconfig/node16@npm:1.0.4" - checksum: 10c0/05f8f2734e266fb1839eb1d57290df1664fe2aa3b0fdd685a9035806daa635f7519bf6d5d9b33f6e69dd545b8c46bd6e2b5c79acb2b1f146e885f7f11a42a5bb - languageName: node - linkType: hard - -"@tybys/wasm-util@npm:^0.10.0": - version: 0.10.1 - resolution: "@tybys/wasm-util@npm:0.10.1" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10c0/b255094f293794c6d2289300c5fbcafbb5532a3aed3a5ffd2f8dc1828e639b88d75f6a376dd8f94347a44813fd7a7149d8463477a9a49525c8b2dcaa38c2d1e8 - languageName: node - linkType: hard - -"@types/babel__core@npm:^7.20.5": - version: 7.20.5 - resolution: "@types/babel__core@npm:7.20.5" - dependencies: - "@babel/parser": "npm:^7.20.7" - "@babel/types": "npm:^7.20.7" - "@types/babel__generator": "npm:*" - "@types/babel__template": "npm:*" - "@types/babel__traverse": "npm:*" - checksum: 10c0/bdee3bb69951e833a4b811b8ee9356b69a61ed5b7a23e1a081ec9249769117fa83aaaf023bb06562a038eb5845155ff663e2d5c75dd95c1d5ccc91db012868ff - languageName: node - linkType: hard - -"@types/babel__generator@npm:*": - version: 7.27.0 - resolution: "@types/babel__generator@npm:7.27.0" - dependencies: - "@babel/types": "npm:^7.0.0" - checksum: 10c0/9f9e959a8792df208a9d048092fda7e1858bddc95c6314857a8211a99e20e6830bdeb572e3587ae8be5429e37f2a96fcf222a9f53ad232f5537764c9e13a2bbd - languageName: node - linkType: hard - -"@types/babel__template@npm:*": - version: 7.4.4 - resolution: "@types/babel__template@npm:7.4.4" - dependencies: - "@babel/parser": "npm:^7.1.0" - "@babel/types": "npm:^7.0.0" - checksum: 10c0/cc84f6c6ab1eab1427e90dd2b76ccee65ce940b778a9a67be2c8c39e1994e6f5bbc8efa309f6cea8dc6754994524cd4d2896558df76d92e7a1f46ecffee7112b - languageName: node - linkType: hard - -"@types/babel__traverse@npm:*": - version: 7.28.0 - resolution: "@types/babel__traverse@npm:7.28.0" - dependencies: - "@babel/types": "npm:^7.28.2" - checksum: 10c0/b52d7d4e8fc6a9018fe7361c4062c1c190f5778cf2466817cb9ed19d69fbbb54f9a85ffedeb748ed8062d2cf7d4cc088ee739848f47c57740de1c48cbf0d0994 - languageName: node - linkType: hard - -"@types/bcrypt@npm:^5.0.2": - version: 5.0.2 - resolution: "@types/bcrypt@npm:5.0.2" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/dd7f05e183b9b1fc08ec499069febf197ab8e9c720766b5bbb5628395082e248f9a444c60882fe7788361fcadc302e21e055ab9c26a300f100e08791c353e6aa - languageName: node - linkType: hard - -"@types/body-parser@npm:*": - version: 1.19.6 - resolution: "@types/body-parser@npm:1.19.6" - dependencies: - "@types/connect": "npm:*" - "@types/node": "npm:*" - checksum: 10c0/542da05c924dce58ee23f50a8b981fee36921850c82222e384931fda3e106f750f7880c47be665217d72dbe445129049db6eb1f44e7a06b09d62af8f3cca8ea7 - languageName: node - linkType: hard - -"@types/connect@npm:*": - version: 3.4.38 - resolution: "@types/connect@npm:3.4.38" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/2e1cdba2c410f25649e77856505cd60223250fa12dff7a503e492208dbfdd25f62859918f28aba95315251fd1f5e1ffbfca1e25e73037189ab85dd3f8d0a148c - languageName: node - linkType: hard - -"@types/connect@npm:3.4.36": - version: 3.4.36 - resolution: "@types/connect@npm:3.4.36" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/0dd8fcf576e178e69cbc00d47be69d3198dca4d86734a00fc55de0df147982e0a5f34592117571c5979e92ce8f3e0596e31aa454496db8a43ab90c5ab1068f40 - languageName: node - linkType: hard - -"@types/conventional-commits-parser@npm:^5.0.0": - version: 5.0.2 - resolution: "@types/conventional-commits-parser@npm:5.0.2" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/598af5a5d699490e8bdd53b59757b514e41791cc7c857c45ed1d4ea50b90e7e5e64f59cd7f50da2c7d7c2d03ca0f1f865c6fe1a46065401b2dbf2e93645c4283 - languageName: node - linkType: hard - -"@types/cookiejar@npm:^2.1.5": - version: 2.1.5 - resolution: "@types/cookiejar@npm:2.1.5" - checksum: 10c0/af38c3d84aebb3ccc6e46fb6afeeaac80fb26e63a487dd4db5a8b87e6ad3d4b845ba1116b2ae90d6f886290a36200fa433d8b1f6fe19c47da6b81872ce9a2764 - languageName: node - linkType: hard - -"@types/ejs@npm:^3.1.5": - version: 3.1.5 - resolution: "@types/ejs@npm:3.1.5" - checksum: 10c0/13d994cf0323d7e0ad33b9384914ccd3b4cd8bf282eced3649b1621b66ee7c784ac2d120a9d7b1f43d6f873518248fb8c3221b06a649b847860b9c2389a0b0ed - languageName: node - linkType: hard - -"@types/eslint-scope@npm:^3.7.7": - version: 3.7.7 - resolution: "@types/eslint-scope@npm:3.7.7" - dependencies: - "@types/eslint": "npm:*" - "@types/estree": "npm:*" - checksum: 10c0/a0ecbdf2f03912679440550817ff77ef39a30fa8bfdacaf6372b88b1f931828aec392f52283240f0d648cf3055c5ddc564544a626bcf245f3d09fcb099ebe3cc - languageName: node - linkType: hard - -"@types/eslint@npm:*": - version: 9.6.1 - resolution: "@types/eslint@npm:9.6.1" - dependencies: - "@types/estree": "npm:*" - "@types/json-schema": "npm:*" - checksum: 10c0/69ba24fee600d1e4c5abe0df086c1a4d798abf13792d8cfab912d76817fe1a894359a1518557d21237fbaf6eda93c5ab9309143dee4c59ef54336d1b3570420e - languageName: node - linkType: hard - -"@types/estree@npm:*, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8": - version: 1.0.8 - resolution: "@types/estree@npm:1.0.8" - checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 - languageName: node - linkType: hard - -"@types/express-serve-static-core@npm:^4.17.33": - version: 4.19.7 - resolution: "@types/express-serve-static-core@npm:4.19.7" - dependencies: - "@types/node": "npm:*" - "@types/qs": "npm:*" - "@types/range-parser": "npm:*" - "@types/send": "npm:*" - checksum: 10c0/c239df87863b8515e68dcb18203a9e2ba6108f86fdc385090284464a57a6dca6abb60a961cb6a73fea2110576f4f8acefa1cb06b60d14b6b0e5104478e7d57d1 - languageName: node - linkType: hard - -"@types/express-serve-static-core@npm:^5.0.0": - version: 5.1.0 - resolution: "@types/express-serve-static-core@npm:5.1.0" - dependencies: - "@types/node": "npm:*" - "@types/qs": "npm:*" - "@types/range-parser": "npm:*" - "@types/send": "npm:*" - checksum: 10c0/1918233c68a0c69695f78331af1aed5fb5190f91da6309318f700adeb78573be840b5d206cb8eda804b65a9989fdeccdaaf84c1e95adc3615052749224b64519 - languageName: node - linkType: hard - -"@types/express@npm:*": - version: 5.0.5 - resolution: "@types/express@npm:5.0.5" - dependencies: - "@types/body-parser": "npm:*" - "@types/express-serve-static-core": "npm:^5.0.0" - "@types/serve-static": "npm:^1" - checksum: 10c0/e96da91c121b43e0e84301a4cfe165908382d016234c11213aeb4f7401cf1a8694e16e3947d21b5c20b3389358d48d60a8c5c38657e041726ac9e8c884d2b8f0 - languageName: node - linkType: hard - -"@types/express@npm:^4.17.20, @types/express@npm:^4.17.21": - version: 4.17.25 - resolution: "@types/express@npm:4.17.25" - dependencies: - "@types/body-parser": "npm:*" - "@types/express-serve-static-core": "npm:^4.17.33" - "@types/qs": "npm:*" - "@types/serve-static": "npm:^1" - checksum: 10c0/f42b616d2c9dbc50352c820db7de182f64ebbfa8dba6fb6c98e5f8f0e2ef3edde0131719d9dc6874803d25ad9ca2d53471d0fec2fbc60a6003a43d015bab72c4 - languageName: node - linkType: hard - -"@types/glob@npm:^7.1.1": - version: 7.2.0 - resolution: "@types/glob@npm:7.2.0" - dependencies: - "@types/minimatch": "npm:*" - "@types/node": "npm:*" - checksum: 10c0/a8eb5d5cb5c48fc58c7ca3ff1e1ddf771ee07ca5043da6e4871e6757b4472e2e73b4cfef2644c38983174a4bc728c73f8da02845c28a1212f98cabd293ecae98 - languageName: node - linkType: hard - -"@types/http-errors@npm:*": - version: 2.0.5 - resolution: "@types/http-errors@npm:2.0.5" - checksum: 10c0/00f8140fbc504f47356512bd88e1910c2f07e04233d99c88c854b3600ce0523c8cd0ba7d1897667243282eb44c59abb9245959e2428b9de004f93937f52f7c15 - languageName: node - linkType: hard - -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": - version: 2.0.6 - resolution: "@types/istanbul-lib-coverage@npm:2.0.6" - checksum: 10c0/3948088654f3eeb45363f1db158354fb013b362dba2a5c2c18c559484d5eb9f6fd85b23d66c0a7c2fcfab7308d0a585b14dadaca6cc8bf89ebfdc7f8f5102fb7 - languageName: node - linkType: hard - -"@types/istanbul-lib-report@npm:*": - version: 3.0.3 - resolution: "@types/istanbul-lib-report@npm:3.0.3" - dependencies: - "@types/istanbul-lib-coverage": "npm:*" - checksum: 10c0/247e477bbc1a77248f3c6de5dadaae85ff86ac2d76c5fc6ab1776f54512a745ff2a5f791d22b942e3990ddbd40f3ef5289317c4fca5741bedfaa4f01df89051c - languageName: node - linkType: hard - -"@types/istanbul-reports@npm:^3.0.4": - version: 3.0.4 - resolution: "@types/istanbul-reports@npm:3.0.4" - dependencies: - "@types/istanbul-lib-report": "npm:*" - checksum: 10c0/1647fd402aced5b6edac87274af14ebd6b3a85447ef9ad11853a70fd92a98d35f81a5d3ea9fcb5dbb5834e800c6e35b64475e33fcae6bfa9acc70d61497c54ee - languageName: node - linkType: hard - -"@types/jest@npm:^27.5.2": - version: 27.5.2 - resolution: "@types/jest@npm:27.5.2" - dependencies: - jest-matcher-utils: "npm:^27.0.0" - pretty-format: "npm:^27.0.0" - checksum: 10c0/29ef3da9b94a15736a67fc13956f385ac2ba2c6297f50d550446842c278f2e0d9f343dcd8e31c321ada5d8a1bd67bc1d79c7b6ff1802d55508c692123b3d9794 - languageName: node - linkType: hard - -"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9": +"@types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db @@ -5835,7 +3484,7 @@ __metadata: languageName: node linkType: hard -"@types/jsonwebtoken@npm:*, @types/jsonwebtoken@npm:9.0.10, @types/jsonwebtoken@npm:^9.0.4": +"@types/jsonwebtoken@npm:*, @types/jsonwebtoken@npm:9.0.10": version: 9.0.10 resolution: "@types/jsonwebtoken@npm:9.0.10" dependencies: @@ -5880,40 +3529,30 @@ __metadata: languageName: node linkType: hard -"@types/mysql@npm:2.15.26": - version: 2.15.26 - resolution: "@types/mysql@npm:2.15.26" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/3cf279e7db05d56c0544532a4380b9079f579092379a04c8138bd5cf88dda5b31208ac2d23ce7dbf4e3a3f43aaeed44e72f9f19f726518f308efe95a7435619a - languageName: node - linkType: hard - "@types/node@npm:*, @types/node@npm:>= 8": - version: 24.10.1 - resolution: "@types/node@npm:24.10.1" + version: 26.0.0 + resolution: "@types/node@npm:26.0.0" dependencies: - undici-types: "npm:~7.16.0" - checksum: 10c0/d6bca7a78f550fbb376f236f92b405d676003a8a09a1b411f55920ef34286ee3ee51f566203920e835478784df52662b5b2af89159d9d319352e9ea21801c002 + undici-types: "npm:~8.3.0" + checksum: 10c0/f36e21634fd8e8ded162ca486508bd8bb229d398a8d5541f6d8c1255968d4464e53327a77f403b216ef98e3b6f6956882eef83e4857d4bc2be9569dd55d37aae languageName: node linkType: hard "@types/node@npm:^20.19.25": - version: 20.19.25 - resolution: "@types/node@npm:20.19.25" + version: 20.19.43 + resolution: "@types/node@npm:20.19.43" dependencies: undici-types: "npm:~6.21.0" - checksum: 10c0/992f18cb03264e8dc2fd3cb64f428ee4997cb6d928dad68bf4b752eacac73062697ce7ce6a0e71a6d15af510814397a20597a72332dfec638e02fb3a382ad014 + checksum: 10c0/9bcec3b5295bdd77ff0b44a528a69f7e22028c347507ba2c69be47ec84e30299f45043b222e9c86c510e138c9c53b2419dd5cd34920602a4a5a381c288075318 languageName: node linkType: hard "@types/nodemailer@npm:^6.4.15": - version: 6.4.21 - resolution: "@types/nodemailer@npm:6.4.21" + version: 6.4.24 + resolution: "@types/nodemailer@npm:6.4.24" dependencies: - "@aws-sdk/client-ses": "npm:^3.731.1" "@types/node": "npm:*" - checksum: 10c0/3a0f4e497161ebd8069aed795093fcd022950004741d7b0f88ec5ef99d2fe4c717d5b47f46a7cda872998c6b7cb897606940faefa397f17c27cd948eff792364 + checksum: 10c0/7e500ee7f34b3e7ea88e7363a1dc9d93ca70d762db42e729604805e9ce576d25bd2f3c92342506625e09a3a9fd9788502457356e94d054c17c553ab3575091db languageName: node linkType: hard @@ -5924,55 +3563,13 @@ __metadata: languageName: node linkType: hard -"@types/oauth@npm:*": - version: 0.9.6 - resolution: "@types/oauth@npm:0.9.6" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/2f3e4ee1059fd28fc2cb6dd9d0973365a0630ea1fa305ac5455ea9666220b73d8ac42e5bee42367a0f12a1041ef103a16c55bf7803d0a82898319c3e32095b4a - languageName: node - linkType: hard - -"@types/passport-apple@npm:^2.0.3": - version: 2.0.3 - resolution: "@types/passport-apple@npm:2.0.3" - dependencies: - "@types/express": "npm:*" - "@types/passport-oauth2": "npm:*" - checksum: 10c0/8901dff3ddd23c8a9bb3d4588fd5ec3d77f479a88ee16d377236fd0d28207245cf5671798f46abef6bdb19c327b26dcea2ba5067d941ff1275d66088d267c4f6 - languageName: node - linkType: hard - -"@types/passport-github@npm:^1.1.12": - version: 1.1.13 - resolution: "@types/passport-github@npm:1.1.13" - dependencies: - "@types/express": "npm:*" - "@types/passport": "npm:*" - "@types/passport-oauth2": "npm:*" - checksum: 10c0/ec1246825308b9093e660feda98e6a751eab07f42fb132b2e06675a918bd25adfc7a172613dea32cc49e90c5e58a8dc5a556715081c9923a892177360d010708 - languageName: node - linkType: hard - -"@types/passport-google-oauth20@npm:^2.0.16": - version: 2.0.17 - resolution: "@types/passport-google-oauth20@npm:2.0.17" - dependencies: - "@types/express": "npm:*" - "@types/passport": "npm:*" - "@types/passport-oauth2": "npm:*" - checksum: 10c0/b29e30e970bb506a78bb4bc283958f814ee125c10fc17f16aff019cf08883e40a873f05b736c0d153e47180b6b2edb84531372d9629956e3e7f027a8af4db588 - languageName: node - linkType: hard - -"@types/passport-jwt@npm:^3.0.13": - version: 3.0.13 - resolution: "@types/passport-jwt@npm:3.0.13" +"@types/passport-jwt@npm:^4.0.1": + version: 4.0.1 + resolution: "@types/passport-jwt@npm:4.0.1" dependencies: - "@types/express": "npm:*" "@types/jsonwebtoken": "npm:*" "@types/passport-strategy": "npm:*" - checksum: 10c0/39aeafa92d869f38f17994dd02f8124d88dc6977b0fee56d8ef4c6d4ff86378456a4904644a83dabc8d3fd69c23d0503c8af14ee51a7ced5d54c3c0115c1422c + checksum: 10c0/0ced0eaa7bb379d674821108d9bc6758223f1a5f2b9790ec78d3eaaccce6a58a424cf8ed22b53d813740ec53d929e21d92cf794ef0fb30c732866750763c0d7a languageName: node linkType: hard @@ -5987,17 +3584,6 @@ __metadata: languageName: node linkType: hard -"@types/passport-oauth2@npm:*": - version: 1.8.0 - resolution: "@types/passport-oauth2@npm:1.8.0" - dependencies: - "@types/express": "npm:*" - "@types/oauth": "npm:*" - "@types/passport": "npm:*" - checksum: 10c0/1635679ed70044858570488fb46a14868099286f4fe785d47874c1a02706cf5b5cee07e5be0e75a5103a5c260ea9e903e0243d8b7992281680233e69f60e72fa - languageName: node - linkType: hard - "@types/passport-strategy@npm:*, @types/passport-strategy@npm:^0.2.38": version: 0.2.38 resolution: "@types/passport-strategy@npm:0.2.38" @@ -6017,37 +3603,6 @@ __metadata: languageName: node linkType: hard -"@types/pg-pool@npm:2.0.6": - version: 2.0.6 - resolution: "@types/pg-pool@npm:2.0.6" - dependencies: - "@types/pg": "npm:*" - checksum: 10c0/41965d4d0b677c54ce45d36add760e496d356b78019cb062d124af40287cf6b0fd4d86e3b0085f443856c185983a60c8b0795ff76d15683e2a93c62f5ac0125f - languageName: node - linkType: hard - -"@types/pg@npm:*": - version: 8.15.6 - resolution: "@types/pg@npm:8.15.6" - dependencies: - "@types/node": "npm:*" - pg-protocol: "npm:*" - pg-types: "npm:^2.2.0" - checksum: 10c0/7f93f83a4da0dc6133918f824d826fa34e78fb8cf86392d28a0e095c836c6910c014ced5d4b364d83e8485a65ce369adeb9663b14ba301241d4c0f80073007f3 - languageName: node - linkType: hard - -"@types/pg@npm:8.6.1": - version: 8.6.1 - resolution: "@types/pg@npm:8.6.1" - dependencies: - "@types/node": "npm:*" - pg-protocol: "npm:*" - pg-types: "npm:^2.2.0" - checksum: 10c0/8d16660c9a4f050d6d5e391c59f9a62e9d377a2a6a7eb5865f8828082dbdfeab700fd707e585f42d67b29e796b32863aea5bd6d5cbb8ceda2d598da5d0c61693 - languageName: node - linkType: hard - "@types/pug@npm:^2.0.10": version: 2.0.10 resolution: "@types/pug@npm:2.0.10" @@ -6056,9 +3611,9 @@ __metadata: linkType: hard "@types/qs@npm:*": - version: 6.14.0 - resolution: "@types/qs@npm:6.14.0" - checksum: 10c0/5b3036df6e507483869cdb3858201b2e0b64b4793dc4974f188caa5b5732f2333ab9db45c08157975054d3b070788b35088b4bc60257ae263885016ee2131310 + version: 6.15.1 + resolution: "@types/qs@npm:6.15.1" + checksum: 10c0/1dfdbcb4cf2a8f66d57f0b9a9fe6b1c7091cb816687b6698c1351eaf31f62e412cea9b7453a9637b570cd5fad8dced527e5a9e69b4fcc6e318daacd8b749f094 languageName: node linkType: hard @@ -6071,550 +3626,394 @@ __metadata: "@types/send@npm:*": version: 1.2.1 - resolution: "@types/send@npm:1.2.1" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/7673747f8c2d8e67f3b1b3b57e9d4d681801a4f7b526ecf09987bb9a84a61cf94aa411c736183884dc762c1c402a61681eb1ef200d8d45d7e5ec0ab67ea5f6c1 - languageName: node - linkType: hard - -"@types/send@npm:<1": - version: 0.17.6 - resolution: "@types/send@npm:0.17.6" - dependencies: - "@types/mime": "npm:^1" - "@types/node": "npm:*" - checksum: 10c0/a9d76797f0637738062f1b974e0fcf3d396a28c5dc18c3f95ecec5dabda82e223afbc2d56a0bca46b6326fd7bb229979916cea40de2270a98128fd94441b87c2 - languageName: node - linkType: hard - -"@types/serve-static@npm:^1": - version: 1.15.10 - resolution: "@types/serve-static@npm:1.15.10" - dependencies: - "@types/http-errors": "npm:*" - "@types/node": "npm:*" - "@types/send": "npm:<1" - checksum: 10c0/842fca14c9e80468f89b6cea361773f2dcd685d4616a9f59013b55e1e83f536e4c93d6d8e3ba5072d40c4e7e64085210edd6646b15d538ded94512940a23021f - languageName: node - linkType: hard - -"@types/shimmer@npm:^1.2.0": - version: 1.2.0 - resolution: "@types/shimmer@npm:1.2.0" - checksum: 10c0/6f7bfe1b55601cfc3ae713fc74a03341f3834253b8b91cb2add926d5949e4a63f7e666f59c2a6e40a883a5f9e2f3e3af10f9d3aed9b60fced0bda87659e58d8d - languageName: node - linkType: hard - -"@types/stack-utils@npm:^2.0.3": - version: 2.0.3 - resolution: "@types/stack-utils@npm:2.0.3" - checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c - languageName: node - linkType: hard - -"@types/superagent@npm:^8.1.0": - version: 8.1.9 - resolution: "@types/superagent@npm:8.1.9" - dependencies: - "@types/cookiejar": "npm:^2.1.5" - "@types/methods": "npm:^1.1.4" - "@types/node": "npm:*" - form-data: "npm:^4.0.0" - checksum: 10c0/12631f1d8b3a62e1f435bc885f6d64d1a2d1ae82b80f0c6d63d4d6372c40b6f1fee6b3da59ac18bb86250b1eb73583bf2d4b1f7882048c32468791c560c69b7c - languageName: node - linkType: hard - -"@types/supertest@npm:^6.0.3": - version: 6.0.3 - resolution: "@types/supertest@npm:6.0.3" - dependencies: - "@types/methods": "npm:^1.1.4" - "@types/superagent": "npm:^8.1.0" - checksum: 10c0/a2080f870154b09db123864a484fb633bc9e2a0f7294a194388df4c7effe5af9de36d5a5ebf819f72b404fa47b5e813c47d5a3a51354251fd2fa8589bfb64f2c - languageName: node - linkType: hard - -"@types/tedious@npm:^4.0.14": - version: 4.0.14 - resolution: "@types/tedious@npm:4.0.14" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/d2914f8e9b5b998e4275ec5f0130cba1c2fb47e75616b5c125a65ef6c1db2f1dc3f978c7900693856a15d72bbb4f4e94f805537a4ecb6dc126c64415d31c0590 - languageName: node - linkType: hard - -"@types/validator@npm:^13.11.8": - version: 13.15.8 - resolution: "@types/validator@npm:13.15.8" - checksum: 10c0/f85c5ee067631ebd69f004dc442c0675f09ecde10861c2996cae2e4f7919ae6d848771630934ab348f5a22f6c9ef980552cfd3d4625fa8735ad9ea5e67212852 - languageName: node - linkType: hard - -"@types/yargs-parser@npm:*": - version: 21.0.3 - resolution: "@types/yargs-parser@npm:21.0.3" - checksum: 10c0/e71c3bd9d0b73ca82e10bee2064c384ab70f61034bbfb78e74f5206283fc16a6d85267b606b5c22cb2a3338373586786fed595b2009825d6a9115afba36560a0 - languageName: node - linkType: hard - -"@types/yargs@npm:^17.0.33": - version: 17.0.34 - resolution: "@types/yargs@npm:17.0.34" - dependencies: - "@types/yargs-parser": "npm:*" - checksum: 10c0/7d4c6a6bc2b8dd4c7deaf507633fe6fd91424873add76b63c8263479223ea7a061bea86e7e0f3ed28cbe897338a934f3c04d802e8f67b7d2d3874924c94468c5 - languageName: node - linkType: hard - -"@types/zxcvbn@npm:^4.4.4": - version: 4.4.5 - resolution: "@types/zxcvbn@npm:4.4.5" - checksum: 10c0/b0f2f8a310de61860d66ee24964e9746cadcc166f3b44df384147ebddab186555bef701dcba0a47d04bc80dfa47d5de15f2a08e0abd713fe3a77304342ae68a8 - languageName: node - linkType: hard - -"@typescript-eslint/eslint-plugin@npm:8.46.4, @typescript-eslint/eslint-plugin@npm:^8.15.0": - version: 8.46.4 - resolution: "@typescript-eslint/eslint-plugin@npm:8.46.4" - dependencies: - "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.46.4" - "@typescript-eslint/type-utils": "npm:8.46.4" - "@typescript-eslint/utils": "npm:8.46.4" - "@typescript-eslint/visitor-keys": "npm:8.46.4" - graphemer: "npm:^1.4.0" - ignore: "npm:^7.0.0" - natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^2.1.0" - peerDependencies: - "@typescript-eslint/parser": ^8.46.4 - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/c487e55c2f35e89126a13a6997f06494c26a3c96b9a7685421e2d92929f3ab302c1c234f0add9113705fbad693b05b3b87cebe5219bc71b2af9ee7aa8e7dc12c - languageName: node - linkType: hard - -"@typescript-eslint/parser@npm:8.46.4, @typescript-eslint/parser@npm:^8.15.0": - version: 8.46.4 - resolution: "@typescript-eslint/parser@npm:8.46.4" - dependencies: - "@typescript-eslint/scope-manager": "npm:8.46.4" - "@typescript-eslint/types": "npm:8.46.4" - "@typescript-eslint/typescript-estree": "npm:8.46.4" - "@typescript-eslint/visitor-keys": "npm:8.46.4" - debug: "npm:^4.3.4" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/bef98fa9250d5720479c10f803ca66a2a0b382158a8b462fd1c710351f7b423570c273556fb828e64d8a87041d54d51fa5a5e1e88ebdc1c88da0ee1098f9405e - languageName: node - linkType: hard - -"@typescript-eslint/project-service@npm:8.46.4": - version: 8.46.4 - resolution: "@typescript-eslint/project-service@npm:8.46.4" - dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.46.4" - "@typescript-eslint/types": "npm:^8.46.4" - debug: "npm:^4.3.4" - peerDependencies: - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/81c5de7b85a2b1bff51ef27d25f11be992b7e550bfe34d4cbc4eb71f0fd03bcc1619644ac8efd594c515c894317f98db9176ef333004718d997c666791ca8b95 - languageName: node - linkType: hard - -"@typescript-eslint/scope-manager@npm:8.46.4, @typescript-eslint/scope-manager@npm:^8.44.1": - version: 8.46.4 - resolution: "@typescript-eslint/scope-manager@npm:8.46.4" - dependencies: - "@typescript-eslint/types": "npm:8.46.4" - "@typescript-eslint/visitor-keys": "npm:8.46.4" - checksum: 10c0/f614b5a95f1803a4298a5192c48f39327fa6085c0753cd67b03728767b8dee79020ebc8896974cba530fe039a5723e157eed74675683f1a4ed87959cd695c997 - languageName: node - linkType: hard - -"@typescript-eslint/tsconfig-utils@npm:8.46.4, @typescript-eslint/tsconfig-utils@npm:^8.46.4": - version: 8.46.4 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.4" - peerDependencies: - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/d8ed135c56a15be10822053490b22a4f32ca912deca2c6d3c93a8fec32572842af84d762f0d2ed142b99f1e8251d97402aed9ce9950ef3dc0a8c90e4e1e459fc - languageName: node - linkType: hard - -"@typescript-eslint/type-utils@npm:8.46.4, @typescript-eslint/type-utils@npm:^8.44.1": - version: 8.46.4 - resolution: "@typescript-eslint/type-utils@npm:8.46.4" - dependencies: - "@typescript-eslint/types": "npm:8.46.4" - "@typescript-eslint/typescript-estree": "npm:8.46.4" - "@typescript-eslint/utils": "npm:8.46.4" - debug: "npm:^4.3.4" - ts-api-utils: "npm:^2.1.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/d4e08a2d2d66b92a93a45c6efd1df272612982ac27204df9a989371f3a7d6eb5a069fc9898ca5b3a5ad70e2df1bc97e77b1f548e229608605b1a1cb33abc2c95 - languageName: node - linkType: hard - -"@typescript-eslint/types@npm:8.46.4, @typescript-eslint/types@npm:^8.46.0, @typescript-eslint/types@npm:^8.46.4": - version: 8.46.4 - resolution: "@typescript-eslint/types@npm:8.46.4" - checksum: 10c0/b92166dd9b6d8e4cf0a6a90354b6e94af8542d8ab341aed3955990e6599db7a583af638e22909a1417e41fd8a0ef5861c5ba12ad84b307c27d26f3e0c5e2020f - languageName: node - linkType: hard - -"@typescript-eslint/typescript-estree@npm:8.46.4": - version: 8.46.4 - resolution: "@typescript-eslint/typescript-estree@npm:8.46.4" - dependencies: - "@typescript-eslint/project-service": "npm:8.46.4" - "@typescript-eslint/tsconfig-utils": "npm:8.46.4" - "@typescript-eslint/types": "npm:8.46.4" - "@typescript-eslint/visitor-keys": "npm:8.46.4" - debug: "npm:^4.3.4" - fast-glob: "npm:^3.3.2" - is-glob: "npm:^4.0.3" - minimatch: "npm:^9.0.4" - semver: "npm:^7.6.0" - ts-api-utils: "npm:^2.1.0" - peerDependencies: - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/e115dbd8580801e9b8892a19056ccb91e7c912b587b22ee5a9b7ec03547eff89ad18ea18a31210ea779cf9f4ccec9428f98b62151c26709e19e7adbdd5ca990b - languageName: node - linkType: hard - -"@typescript-eslint/utils@npm:8.46.4, @typescript-eslint/utils@npm:^8.44.1, @typescript-eslint/utils@npm:~8.46.0": - version: 8.46.4 - resolution: "@typescript-eslint/utils@npm:8.46.4" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.7.0" - "@typescript-eslint/scope-manager": "npm:8.46.4" - "@typescript-eslint/types": "npm:8.46.4" - "@typescript-eslint/typescript-estree": "npm:8.46.4" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/6e4f4d51113f74edcfc83b135c73edf7c46919895659c2e7d5945ab084bc051ed5f980918d23a941d1a9f96a38c8ddc22c12b5aafa8e35ef3bb9d9c6b00b6c79 - languageName: node - linkType: hard - -"@typescript-eslint/visitor-keys@npm:8.46.4": - version: 8.46.4 - resolution: "@typescript-eslint/visitor-keys@npm:8.46.4" - dependencies: - "@typescript-eslint/types": "npm:8.46.4" - eslint-visitor-keys: "npm:^4.2.1" - checksum: 10c0/35dd6aa2b53fc3f4f214e9edf730cc69d0eb9f77ffd978354d092feda7358e60052e15d891fa8577e9ebee5fdea8083e02fe286dd3a96bbafcb1305dce15b80c - languageName: node - linkType: hard - -"@ungap/structured-clone@npm:^1.3.0": - version: 1.3.0 - resolution: "@ungap/structured-clone@npm:1.3.0" - checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a - languageName: node - linkType: hard - -"@unrs/resolver-binding-android-arm-eabi@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-android-arm-eabi@npm:1.11.1" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - -"@unrs/resolver-binding-android-arm64@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-android-arm64@npm:1.11.1" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"@unrs/resolver-binding-darwin-arm64@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-darwin-arm64@npm:1.11.1" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@unrs/resolver-binding-darwin-x64@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-darwin-x64@npm:1.11.1" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@unrs/resolver-binding-freebsd-x64@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-freebsd-x64@npm:1.11.1" - conditions: os=freebsd & cpu=x64 + resolution: "@types/send@npm:1.2.1" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/7673747f8c2d8e67f3b1b3b57e9d4d681801a4f7b526ecf09987bb9a84a61cf94aa411c736183884dc762c1c402a61681eb1ef200d8d45d7e5ec0ab67ea5f6c1 languageName: node linkType: hard -"@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1" - conditions: os=linux & cpu=arm +"@types/send@npm:<1": + version: 0.17.6 + resolution: "@types/send@npm:0.17.6" + dependencies: + "@types/mime": "npm:^1" + "@types/node": "npm:*" + checksum: 10c0/a9d76797f0637738062f1b974e0fcf3d396a28c5dc18c3f95ecec5dabda82e223afbc2d56a0bca46b6326fd7bb229979916cea40de2270a98128fd94441b87c2 languageName: node linkType: hard -"@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1" - conditions: os=linux & cpu=arm +"@types/serve-static@npm:^1": + version: 1.15.10 + resolution: "@types/serve-static@npm:1.15.10" + dependencies: + "@types/http-errors": "npm:*" + "@types/node": "npm:*" + "@types/send": "npm:<1" + checksum: 10c0/842fca14c9e80468f89b6cea361773f2dcd685d4616a9f59013b55e1e83f536e4c93d6d8e3ba5072d40c4e7e64085210edd6646b15d538ded94512940a23021f languageName: node linkType: hard -"@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1" - conditions: os=linux & cpu=arm64 & libc=glibc +"@types/serve-static@npm:^2": + version: 2.2.0 + resolution: "@types/serve-static@npm:2.2.0" + dependencies: + "@types/http-errors": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/a3c6126bdbf9685e6c7dc03ad34639666eff32754e912adeed9643bf3dd3aa0ff043002a7f69039306e310d233eb8e160c59308f95b0a619f32366bbc48ee094 languageName: node linkType: hard -"@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1" - conditions: os=linux & cpu=arm64 & libc=musl +"@types/superagent@npm:^8.1.0": + version: 8.1.10 + resolution: "@types/superagent@npm:8.1.10" + dependencies: + "@types/cookiejar": "npm:^2.1.5" + "@types/methods": "npm:^1.1.4" + "@types/node": "npm:*" + form-data: "npm:^4.0.0" + checksum: 10c0/cd911f7e926aae3f23caeebac055c46c5a63576561548356e0ca809147b372a27359112e02a569d527fd677cd90ee6d018b5cdf509a0a5ac70f74289bd29c9bc languageName: node linkType: hard -"@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1" - conditions: os=linux & cpu=ppc64 & libc=glibc +"@types/supertest@npm:^6.0.3": + version: 6.0.3 + resolution: "@types/supertest@npm:6.0.3" + dependencies: + "@types/methods": "npm:^1.1.4" + "@types/superagent": "npm:^8.1.0" + checksum: 10c0/a2080f870154b09db123864a484fb633bc9e2a0f7294a194388df4c7effe5af9de36d5a5ebf819f72b404fa47b5e813c47d5a3a51354251fd2fa8589bfb64f2c languageName: node linkType: hard -"@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1" - conditions: os=linux & cpu=riscv64 & libc=glibc +"@types/zxcvbn@npm:^4.4.4": + version: 4.4.5 + resolution: "@types/zxcvbn@npm:4.4.5" + checksum: 10c0/b0f2f8a310de61860d66ee24964e9746cadcc166f3b44df384147ebddab186555bef701dcba0a47d04bc80dfa47d5de15f2a08e0abd713fe3a77304342ae68a8 languageName: node linkType: hard -"@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1" - conditions: os=linux & cpu=riscv64 & libc=musl +"@typescript-eslint/eslint-plugin@npm:8.61.1, @typescript-eslint/eslint-plugin@npm:^8.15.0": + version: 8.61.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.61.1" + dependencies: + "@eslint-community/regexpp": "npm:^4.12.2" + "@typescript-eslint/scope-manager": "npm:8.61.1" + "@typescript-eslint/type-utils": "npm:8.61.1" + "@typescript-eslint/utils": "npm:8.61.1" + "@typescript-eslint/visitor-keys": "npm:8.61.1" + ignore: "npm:^7.0.5" + natural-compare: "npm:^1.4.0" + ts-api-utils: "npm:^2.5.0" + peerDependencies: + "@typescript-eslint/parser": ^8.61.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/3cb445622907283fb23e78f8bde4d1b04cca96da181a0c549b2775954c5dfa59f20e34c27f73ae3e189420e36637f59145bd97ce45c55017a6c3ac1871197951 languageName: node linkType: hard -"@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1" - conditions: os=linux & cpu=s390x & libc=glibc +"@typescript-eslint/parser@npm:8.61.1, @typescript-eslint/parser@npm:^8.15.0": + version: 8.61.1 + resolution: "@typescript-eslint/parser@npm:8.61.1" + dependencies: + "@typescript-eslint/scope-manager": "npm:8.61.1" + "@typescript-eslint/types": "npm:8.61.1" + "@typescript-eslint/typescript-estree": "npm:8.61.1" + "@typescript-eslint/visitor-keys": "npm:8.61.1" + debug: "npm:^4.4.3" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/6515afed5df24fd56fd0c96f94b2fc951236fc805e5d5ec925cbcae1319e5a41caea44284cd35b21f5ce7b812e567185c9dfe0882607135b947895509f23b253 languageName: node linkType: hard -"@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1" - conditions: os=linux & cpu=x64 & libc=glibc +"@typescript-eslint/project-service@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/project-service@npm:8.56.1" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.56.1" + "@typescript-eslint/types": "npm:^8.56.1" + debug: "npm:^4.4.3" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/ca61cde575233bc79046d73ddd330d183fb3cbb941fddc31919336317cda39885c59296e2e5401b03d9325a64a629e842fd66865705ff0d85d83ee3ee40871e8 languageName: node linkType: hard -"@unrs/resolver-binding-linux-x64-musl@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-x64-musl@npm:1.11.1" - conditions: os=linux & cpu=x64 & libc=musl +"@typescript-eslint/project-service@npm:8.61.1": + version: 8.61.1 + resolution: "@typescript-eslint/project-service@npm:8.61.1" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.61.1" + "@typescript-eslint/types": "npm:^8.61.1" + debug: "npm:^4.4.3" + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/4ced4f96cbe6b4b1f1f53d02e0e5d1efcb6ca02316d8ea11f9b328f7b3783a76e59b72ebbe233a00452de7466ac176f9836afe8a99c63acc94e8e2d1d31d370b languageName: node linkType: hard -"@unrs/resolver-binding-wasm32-wasi@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-wasm32-wasi@npm:1.11.1" +"@typescript-eslint/scope-manager@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/scope-manager@npm:8.56.1" dependencies: - "@napi-rs/wasm-runtime": "npm:^0.2.11" - conditions: cpu=wasm32 + "@typescript-eslint/types": "npm:8.56.1" + "@typescript-eslint/visitor-keys": "npm:8.56.1" + checksum: 10c0/89cc1af2635eee23f2aa2ff87c08f88f3ad972ebf67eaacdc604a4ef4178535682bad73fd086e6f3c542e4e5d874253349af10d58291d079cc29c6c7e9831de4 languageName: node linkType: hard -"@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1" - conditions: os=win32 & cpu=arm64 +"@typescript-eslint/scope-manager@npm:8.61.1, @typescript-eslint/scope-manager@npm:^8.48.1": + version: 8.61.1 + resolution: "@typescript-eslint/scope-manager@npm:8.61.1" + dependencies: + "@typescript-eslint/types": "npm:8.61.1" + "@typescript-eslint/visitor-keys": "npm:8.61.1" + checksum: 10c0/8558b56629acc28da1b90a8254eaf9862a38bf49c0a04680c767ea542f49d683c8c60c43676e0348491d1eac9af25cc67abe0a1bb3cfe90add42523faf48b029 languageName: node linkType: hard -"@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1" - conditions: os=win32 & cpu=ia32 +"@typescript-eslint/tsconfig-utils@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.56.1" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/d03b64d7ff19020beeefa493ae667c2e67a4547d25a3ecb9210a3a52afe980c093d772a91014bae699ee148bfb60cc659479e02bfc2946ea06954a8478ef1fe1 languageName: node linkType: hard -"@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1" - conditions: os=win32 & cpu=x64 +"@typescript-eslint/tsconfig-utils@npm:8.61.1, @typescript-eslint/tsconfig-utils@npm:^8.56.1, @typescript-eslint/tsconfig-utils@npm:^8.61.1": + version: 8.61.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.61.1" + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/ab732f3c329f0ee8ea9e7d6c7c7d91b508cc0cf6c48c17d95e5df348d3e475730bd7ce63ac1f90260c80a9339a8a6f2eec8f2a35e1793e956a447130291bb5e3 languageName: node linkType: hard -"@webassemblyjs/ast@npm:1.14.1, @webassemblyjs/ast@npm:^1.14.1": - version: 1.14.1 - resolution: "@webassemblyjs/ast@npm:1.14.1" +"@typescript-eslint/type-utils@npm:8.61.1, @typescript-eslint/type-utils@npm:^8.48.1": + version: 8.61.1 + resolution: "@typescript-eslint/type-utils@npm:8.61.1" dependencies: - "@webassemblyjs/helper-numbers": "npm:1.13.2" - "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" - checksum: 10c0/67a59be8ed50ddd33fbb2e09daa5193ac215bf7f40a9371be9a0d9797a114d0d1196316d2f3943efdb923a3d809175e1563a3cb80c814fb8edccd1e77494972b + "@typescript-eslint/types": "npm:8.61.1" + "@typescript-eslint/typescript-estree": "npm:8.61.1" + "@typescript-eslint/utils": "npm:8.61.1" + debug: "npm:^4.4.3" + ts-api-utils: "npm:^2.5.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/c6d112e80e82de3ad5a4f8a875c5caf267fa856df2eb97e3ef945de7b62ad07eff02e1dd9e8943cc2e1388180d4f31f1d97e0300d2e5e662245e322205af67dc languageName: node linkType: hard -"@webassemblyjs/floating-point-hex-parser@npm:1.13.2": - version: 1.13.2 - resolution: "@webassemblyjs/floating-point-hex-parser@npm:1.13.2" - checksum: 10c0/0e88bdb8b50507d9938be64df0867f00396b55eba9df7d3546eb5dc0ca64d62e06f8d881ec4a6153f2127d0f4c11d102b6e7d17aec2f26bb5ff95a5e60652412 +"@typescript-eslint/types@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/types@npm:8.56.1" + checksum: 10c0/e5a0318abddf0c4f98da3039cb10b3c0601c8601f7a9f7043630f0d622dabfe83a4cd833545ad3531fc846e46ca2874377277b392c2490dffec279d9242d827b languageName: node linkType: hard -"@webassemblyjs/helper-api-error@npm:1.13.2": - version: 1.13.2 - resolution: "@webassemblyjs/helper-api-error@npm:1.13.2" - checksum: 10c0/31be497f996ed30aae4c08cac3cce50c8dcd5b29660383c0155fce1753804fc55d47fcba74e10141c7dd2899033164e117b3bcfcda23a6b043e4ded4f1003dfb +"@typescript-eslint/types@npm:8.61.1, @typescript-eslint/types@npm:^8.46.4, @typescript-eslint/types@npm:^8.56.1, @typescript-eslint/types@npm:^8.61.1": + version: 8.61.1 + resolution: "@typescript-eslint/types@npm:8.61.1" + checksum: 10c0/165c3f0d3f4e1d04b310fe2a918c1012d037a0f0913895096eed40895fa72638e414a69e36182fc1bcc78efb9a4b7d81654b8768d8f1c3f43f3f2792694dfa49 languageName: node linkType: hard -"@webassemblyjs/helper-buffer@npm:1.14.1": - version: 1.14.1 - resolution: "@webassemblyjs/helper-buffer@npm:1.14.1" - checksum: 10c0/0d54105dc373c0fe6287f1091e41e3a02e36cdc05e8cf8533cdc16c59ff05a646355415893449d3768cda588af451c274f13263300a251dc11a575bc4c9bd210 +"@typescript-eslint/typescript-estree@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.56.1" + dependencies: + "@typescript-eslint/project-service": "npm:8.56.1" + "@typescript-eslint/tsconfig-utils": "npm:8.56.1" + "@typescript-eslint/types": "npm:8.56.1" + "@typescript-eslint/visitor-keys": "npm:8.56.1" + debug: "npm:^4.4.3" + minimatch: "npm:^10.2.2" + semver: "npm:^7.7.3" + tinyglobby: "npm:^0.2.15" + ts-api-utils: "npm:^2.4.0" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/92f4421dac41be289761200dc2ed85974fa451deacb09490ae1870a25b71b97218e609a90d4addba9ded5b2abdebc265c9db7f6e9ce6d29ed20e89b8487e9618 languageName: node linkType: hard -"@webassemblyjs/helper-numbers@npm:1.13.2": - version: 1.13.2 - resolution: "@webassemblyjs/helper-numbers@npm:1.13.2" +"@typescript-eslint/typescript-estree@npm:8.61.1": + version: 8.61.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.61.1" dependencies: - "@webassemblyjs/floating-point-hex-parser": "npm:1.13.2" - "@webassemblyjs/helper-api-error": "npm:1.13.2" - "@xtuc/long": "npm:4.2.2" - checksum: 10c0/9c46852f31b234a8fb5a5a9d3f027bc542392a0d4de32f1a9c0075d5e8684aa073cb5929b56df565500b3f9cc0a2ab983b650314295b9bf208d1a1651bfc825a + "@typescript-eslint/project-service": "npm:8.61.1" + "@typescript-eslint/tsconfig-utils": "npm:8.61.1" + "@typescript-eslint/types": "npm:8.61.1" + "@typescript-eslint/visitor-keys": "npm:8.61.1" + debug: "npm:^4.4.3" + minimatch: "npm:^10.2.2" + semver: "npm:^7.7.3" + tinyglobby: "npm:^0.2.15" + ts-api-utils: "npm:^2.5.0" + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/cc3f15e8e30dee0522e9f8b5879824cfe3d98aad6a64e863bf0a21c26eedb3b0e5c82c2b73d59f9d5bc1b66f67305132c7c71f11a542e04e152f92b58599b358 languageName: node linkType: hard -"@webassemblyjs/helper-wasm-bytecode@npm:1.13.2": - version: 1.13.2 - resolution: "@webassemblyjs/helper-wasm-bytecode@npm:1.13.2" - checksum: 10c0/c4355d14f369b30cf3cbdd3acfafc7d0488e086be6d578e3c9780bd1b512932352246be96e034e2a7fcfba4f540ec813352f312bfcbbfe5bcfbf694f82ccc682 +"@typescript-eslint/utils@npm:8.61.1, @typescript-eslint/utils@npm:^8.48.1": + version: 8.61.1 + resolution: "@typescript-eslint/utils@npm:8.61.1" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.9.1" + "@typescript-eslint/scope-manager": "npm:8.61.1" + "@typescript-eslint/types": "npm:8.61.1" + "@typescript-eslint/typescript-estree": "npm:8.61.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/afb5e6f39da3ee34c11cfc73a64045a0e5dadd74ae2a2fcf01d9494e3be00c849a3b9ca8b23570f596726611f00598a198d66cd82e3753f63b8bde69ead0e507 languageName: node linkType: hard -"@webassemblyjs/helper-wasm-section@npm:1.14.1": - version: 1.14.1 - resolution: "@webassemblyjs/helper-wasm-section@npm:1.14.1" +"@typescript-eslint/utils@npm:~8.56.0": + version: 8.56.1 + resolution: "@typescript-eslint/utils@npm:8.56.1" dependencies: - "@webassemblyjs/ast": "npm:1.14.1" - "@webassemblyjs/helper-buffer": "npm:1.14.1" - "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" - "@webassemblyjs/wasm-gen": "npm:1.14.1" - checksum: 10c0/1f9b33731c3c6dbac3a9c483269562fa00d1b6a4e7133217f40e83e975e636fd0f8736e53abd9a47b06b66082ecc976c7384391ab0a68e12d509ea4e4b948d64 + "@eslint-community/eslint-utils": "npm:^4.9.1" + "@typescript-eslint/scope-manager": "npm:8.56.1" + "@typescript-eslint/types": "npm:8.56.1" + "@typescript-eslint/typescript-estree": "npm:8.56.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/d9ffd9b2944a2c425e0532f71dc61e61d0a923d1a17733cf2777c2a4ae638307d12d44f63b33b6b3dc62f02f47db93ec49344ecefe17b76ee3e4fb0833325be3 languageName: node linkType: hard -"@webassemblyjs/ieee754@npm:1.13.2": - version: 1.13.2 - resolution: "@webassemblyjs/ieee754@npm:1.13.2" +"@typescript-eslint/visitor-keys@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.56.1" dependencies: - "@xtuc/ieee754": "npm:^1.2.0" - checksum: 10c0/2e732ca78c6fbae3c9b112f4915d85caecdab285c0b337954b180460290ccd0fb00d2b1dc4bb69df3504abead5191e0d28d0d17dfd6c9d2f30acac8c4961c8a7 + "@typescript-eslint/types": "npm:8.56.1" + eslint-visitor-keys: "npm:^5.0.0" + checksum: 10c0/86d97905dec1af964cc177c185933d040449acf6006096497f2e0093c6a53eb92b3ac1db9eb40a5a2e8d91160f558c9734331a9280797f09f284c38978b22190 languageName: node linkType: hard -"@webassemblyjs/leb128@npm:1.13.2": - version: 1.13.2 - resolution: "@webassemblyjs/leb128@npm:1.13.2" +"@typescript-eslint/visitor-keys@npm:8.61.1": + version: 8.61.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.61.1" dependencies: - "@xtuc/long": "npm:4.2.2" - checksum: 10c0/dad5ef9e383c8ab523ce432dfd80098384bf01c45f70eb179d594f85ce5db2f80fa8c9cba03adafd85684e6d6310f0d3969a882538975989919329ac4c984659 + "@typescript-eslint/types": "npm:8.61.1" + eslint-visitor-keys: "npm:^5.0.0" + checksum: 10c0/fa2075b891076fe640cfc36fd68299985f35c9b6782ff35cf231c1418e38dba56b28412bc6fced7fb8fabbd91444a02643f62e1ebe424aa45a885b8a8e60fbfd languageName: node linkType: hard -"@webassemblyjs/utf8@npm:1.13.2": - version: 1.13.2 - resolution: "@webassemblyjs/utf8@npm:1.13.2" - checksum: 10c0/d3fac9130b0e3e5a1a7f2886124a278e9323827c87a2b971e6d0da22a2ba1278ac9f66a4f2e363ecd9fac8da42e6941b22df061a119e5c0335f81006de9ee799 +"@vitest/coverage-v8@npm:^4.1.9": + version: 4.1.9 + resolution: "@vitest/coverage-v8@npm:4.1.9" + dependencies: + "@bcoe/v8-coverage": "npm:^1.0.2" + "@vitest/utils": "npm:4.1.9" + ast-v8-to-istanbul: "npm:^1.0.0" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-reports: "npm:^3.2.0" + magicast: "npm:^0.5.2" + obug: "npm:^2.1.1" + std-env: "npm:^4.0.0-rc.1" + tinyrainbow: "npm:^3.1.0" + peerDependencies: + "@vitest/browser": 4.1.9 + vitest: 4.1.9 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10c0/02bc408d5d8d5188bb569ae1df1f56903e9e19a6a19ddeff07140e1b344b82662f0669c7ee87b6f4f82c9d1d63a0296fe40c023f923a500a707463c8c05ec133 languageName: node linkType: hard -"@webassemblyjs/wasm-edit@npm:^1.14.1": - version: 1.14.1 - resolution: "@webassemblyjs/wasm-edit@npm:1.14.1" +"@vitest/expect@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/expect@npm:4.1.9" dependencies: - "@webassemblyjs/ast": "npm:1.14.1" - "@webassemblyjs/helper-buffer": "npm:1.14.1" - "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" - "@webassemblyjs/helper-wasm-section": "npm:1.14.1" - "@webassemblyjs/wasm-gen": "npm:1.14.1" - "@webassemblyjs/wasm-opt": "npm:1.14.1" - "@webassemblyjs/wasm-parser": "npm:1.14.1" - "@webassemblyjs/wast-printer": "npm:1.14.1" - checksum: 10c0/5ac4781086a2ca4b320bdbfd965a209655fe8a208ca38d89197148f8597e587c9a2c94fb6bd6f1a7dbd4527c49c6844fcdc2af981f8d793a97bf63a016aa86d2 + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/243bacaed2cba5e0ea4ec7465662fcec465a358a0e06381e337fac49426aa67a73b104fbb9d65d8bccadfba8f70e27f57ffb897aacfa140f579a556367357875 languageName: node linkType: hard -"@webassemblyjs/wasm-gen@npm:1.14.1": - version: 1.14.1 - resolution: "@webassemblyjs/wasm-gen@npm:1.14.1" +"@vitest/mocker@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/mocker@npm:4.1.9" dependencies: - "@webassemblyjs/ast": "npm:1.14.1" - "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" - "@webassemblyjs/ieee754": "npm:1.13.2" - "@webassemblyjs/leb128": "npm:1.13.2" - "@webassemblyjs/utf8": "npm:1.13.2" - checksum: 10c0/d678810d7f3f8fecb2e2bdadfb9afad2ec1d2bc79f59e4711ab49c81cec578371e22732d4966f59067abe5fba8e9c54923b57060a729d28d408e608beef67b10 + "@vitest/spy": "npm:4.1.9" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/707353b7435bbfd441cc754e4ee7bc5921b70d07b051c6e414b6bbe4ca369154702b0ddeb603389469fe87ca1983e002eb2d55044582661f54a1945dd27e5c82 languageName: node linkType: hard -"@webassemblyjs/wasm-opt@npm:1.14.1": - version: 1.14.1 - resolution: "@webassemblyjs/wasm-opt@npm:1.14.1" +"@vitest/pretty-format@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/pretty-format@npm:4.1.9" dependencies: - "@webassemblyjs/ast": "npm:1.14.1" - "@webassemblyjs/helper-buffer": "npm:1.14.1" - "@webassemblyjs/wasm-gen": "npm:1.14.1" - "@webassemblyjs/wasm-parser": "npm:1.14.1" - checksum: 10c0/515bfb15277ee99ba6b11d2232ddbf22aed32aad6d0956fe8a0a0a004a1b5a3a277a71d9a3a38365d0538ac40d1b7b7243b1a244ad6cd6dece1c1bb2eb5de7ee + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/5b96295f25ab885616230ad1355fc82f490bebb39cc707688d7c8969c08270d7e076ed8a10af4e762ed57145193c6061a1f549f136f0ded344f8db0c2b3fb3de languageName: node linkType: hard -"@webassemblyjs/wasm-parser@npm:1.14.1, @webassemblyjs/wasm-parser@npm:^1.14.1": - version: 1.14.1 - resolution: "@webassemblyjs/wasm-parser@npm:1.14.1" +"@vitest/runner@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/runner@npm:4.1.9" dependencies: - "@webassemblyjs/ast": "npm:1.14.1" - "@webassemblyjs/helper-api-error": "npm:1.13.2" - "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" - "@webassemblyjs/ieee754": "npm:1.13.2" - "@webassemblyjs/leb128": "npm:1.13.2" - "@webassemblyjs/utf8": "npm:1.13.2" - checksum: 10c0/95427b9e5addbd0f647939bd28e3e06b8deefdbdadcf892385b5edc70091bf9b92fa5faac3fce8333554437c5d85835afef8c8a7d9d27ab6ba01ffab954db8c6 + "@vitest/utils": "npm:4.1.9" + pathe: "npm:^2.0.3" + checksum: 10c0/d206b4891a64b1f55c346f832b0a7b489108094d8ae34438d3b53e78be7b45b139fa95ffa027c98c357bd532268ee573168de1943235b7eed32a9236ed5978bb languageName: node linkType: hard -"@webassemblyjs/wast-printer@npm:1.14.1": - version: 1.14.1 - resolution: "@webassemblyjs/wast-printer@npm:1.14.1" +"@vitest/snapshot@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/snapshot@npm:4.1.9" dependencies: - "@webassemblyjs/ast": "npm:1.14.1" - "@xtuc/long": "npm:4.2.2" - checksum: 10c0/8d7768608996a052545251e896eac079c98e0401842af8dd4de78fba8d90bd505efb6c537e909cd6dae96e09db3fa2e765a6f26492553a675da56e2db51f9d24 + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/c3099df12ad1f9c1e180441856c9eb82f1990f87ff16aafedd6fa19978eaff20bc59220b692a99fcc822daef86eab256ba3dadb49544b7bd625b57c49cd9d995 languageName: node linkType: hard -"@xtuc/ieee754@npm:^1.2.0": - version: 1.2.0 - resolution: "@xtuc/ieee754@npm:1.2.0" - checksum: 10c0/a8565d29d135039bd99ae4b2220d3e167d22cf53f867e491ed479b3f84f895742d0097f935b19aab90265a23d5d46711e4204f14c479ae3637fbf06c4666882f +"@vitest/spy@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/spy@npm:4.1.9" + checksum: 10c0/e51f328f55b76e8ba66e5e18f183484a8dc0a092685b101112d3e9fb8e989ddca162c98ddf00254476502c25bc05c4ec1e277fd6ad8bfc702464c08f6b5dd115 languageName: node linkType: hard -"@xtuc/long@npm:4.2.2": - version: 4.2.2 - resolution: "@xtuc/long@npm:4.2.2" - checksum: 10c0/8582cbc69c79ad2d31568c412129bf23d2b1210a1dfb60c82d5a1df93334da4ee51f3057051658569e2c196d8dc33bc05ae6b974a711d0d16e801e1d0647ccd1 +"@vitest/utils@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/utils@npm:4.1.9" + dependencies: + "@vitest/pretty-format": "npm:4.1.9" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/d55506c077fd72c091eb66f02926f0abf72801c87a085f565698289562f47befa114ae2c680ab8736dfe46abab0cfd6b8031f2ac519bafeb37578aa6e5ad03c5 languageName: node linkType: hard @@ -6629,21 +4028,14 @@ __metadata: languageName: node linkType: hard -"@zmotivat0r/o0@npm:^1.0.2": - version: 1.0.2 - resolution: "@zmotivat0r/o0@npm:1.0.2" - checksum: 10c0/9f3f5e632798b598c7abc841ece5f7ea94aafc9fd83a279ac7c19b418002ef13bbf92df80833ff5c9d971e74f21ca4c3e9c6eaa51f82d74f716c89543b2cc980 - languageName: node - linkType: hard - -"@zone-eu/mailsplit@npm:5.4.7": - version: 5.4.7 - resolution: "@zone-eu/mailsplit@npm:5.4.7" +"@zone-eu/mailsplit@npm:5.4.12": + version: 5.4.12 + resolution: "@zone-eu/mailsplit@npm:5.4.12" dependencies: libbase64: "npm:1.3.0" - libmime: "npm:5.3.7" + libmime: "npm:5.3.8" libqp: "npm:2.1.1" - checksum: 10c0/31cca6acde97cb8de2aea70742e8288f9e27753986677181635391f10c95fa52f0485767a5b342b561dd5ec3948bb3cc02a1134b56468aad66349987c9837388 + checksum: 10c0/1b16a17a79a8d183adb2a8bab554d8d14b6566ae11e1e4f425f217714f1352d69963e67b48767f31699331157a3a965d3d8ce444ce57b01857d9338a77635370 languageName: node linkType: hard @@ -6673,17 +4065,10 @@ __metadata: languageName: node linkType: hard -"abbrev@npm:^4.0.0": - version: 4.0.0 - resolution: "abbrev@npm:4.0.0" - checksum: 10c0/b4cc16935235e80702fc90192e349e32f8ef0ed151ef506aa78c81a7c455ec18375c4125414b99f84b2e055199d66383e787675f0bcd87da7a4dbd59f9eac1d5 - languageName: node - linkType: hard - -"abstract-logging@npm:^2.0.0": - version: 2.0.1 - resolution: "abstract-logging@npm:2.0.1" - checksum: 10c0/304879d9babcf6772260e5ddde632e6428e1f42f7a7a116d4689e97ad813a20e0ec2dd1e0a122f3617557f40091b9ca85735de4b48c17a2041268cb47b3f8ef1 +"abbrev@npm:^5.0.0": + version: 5.0.0 + resolution: "abbrev@npm:5.0.0" + checksum: 10c0/8e88f5c798ea4562d28c5a3e9ad69e3879890bc5d695d8f2dffb8609be4c890aacc8f80ef4553fdd2c6a62d70c2ce8bc57b38074e383beb7487bdafa9ed42ea5 languageName: node linkType: hard @@ -6697,6 +4082,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:~1.3.8": + version: 1.3.8 + resolution: "accepts@npm:1.3.8" + dependencies: + mime-types: "npm:~2.1.34" + negotiator: "npm:0.6.3" + checksum: 10c0/3a35c5f5586cfb9a21163ca47a5f77ac34fa8ceb5d17d2fa2c0d81f41cbd7f8c6fa52c77e2c039acc0f4d09e71abdc51144246900f6bef5e3c4b333f77d89362 + languageName: node + linkType: hard + "accesscontrol@npm:^2.2.1": version: 2.2.1 resolution: "accesscontrol@npm:2.2.1" @@ -6706,24 +4101,6 @@ __metadata: languageName: node linkType: hard -"acorn-import-attributes@npm:^1.9.5": - version: 1.9.5 - resolution: "acorn-import-attributes@npm:1.9.5" - peerDependencies: - acorn: ^8 - checksum: 10c0/5926eaaead2326d5a86f322ff1b617b0f698aa61dc719a5baa0e9d955c9885cc71febac3fb5bacff71bbf2c4f9c12db2056883c68c53eb962c048b952e1e013d - languageName: node - linkType: hard - -"acorn-import-phases@npm:^1.0.3": - version: 1.0.4 - resolution: "acorn-import-phases@npm:1.0.4" - peerDependencies: - acorn: ^8.14.0 - checksum: 10c0/338eb46fc1aed5544f628344cb9af189450b401d152ceadbf1f5746901a5d923016cd0e7740d5606062d374fdf6941c29bb515d2bd133c4f4242d5d4cd73a3c7 - languageName: node - linkType: hard - "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -6733,15 +4110,6 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^8.1.1": - version: 8.3.4 - resolution: "acorn-walk@npm:8.3.4" - dependencies: - acorn: "npm:^8.11.0" - checksum: 10c0/76537ac5fb2c37a64560feaf3342023dadc086c46da57da363e64c6148dc21b57d49ace26f949e225063acb6fb441eabffd89f7a3066de5ad37ab3e328927c62 - languageName: node - linkType: hard - "acorn@npm:^7.1.1": version: 7.4.1 resolution: "acorn@npm:7.4.1" @@ -6751,12 +4119,12 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.11.0, acorn@npm:^8.14.0, acorn@npm:^8.15.0, acorn@npm:^8.4.1, acorn@npm:^8.5.0, acorn@npm:^8.9.0": - version: 8.15.0 - resolution: "acorn@npm:8.15.0" +"acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.5.0, acorn@npm:^8.9.0": + version: 8.17.0 + resolution: "acorn@npm:8.17.0" bin: acorn: bin/acorn - checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec + checksum: 10c0/5dcefea5f8f023b6cc24cbe71fb5a8112b601d36c4fa07d14e4e6ffc2ee47383332c46b36c766d9437725aa6660156eae50efa0c838719823b50d7c327c4ed42 languageName: node linkType: hard @@ -6785,13 +4153,6 @@ __metadata: languageName: node linkType: hard -"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": - version: 7.1.4 - resolution: "agent-base@npm:7.1.4" - checksum: 10c0/c2c9ab7599692d594b6a161559ada307b7a624fa4c7b03e3afdb5a5e31cd0e53269115b620fcab024c5ac6a6f37fa5eb2e004f076ad30f5f7e6b8b671f7b35fe - languageName: node - linkType: hard - "agent-base@npm:~4.2.1": version: 4.2.1 resolution: "agent-base@npm:4.2.1" @@ -6829,23 +4190,21 @@ __metadata: languageName: node linkType: hard -"ajv-formats@npm:3.0.1": - version: 3.0.1 - resolution: "ajv-formats@npm:3.0.1" - dependencies: - ajv: "npm:^8.0.0" +"ajv-draft-04@npm:^1.0.0": + version: 1.0.0 + resolution: "ajv-draft-04@npm:1.0.0" peerDependencies: - ajv: ^8.0.0 + ajv: ^8.5.0 peerDependenciesMeta: ajv: optional: true - checksum: 10c0/168d6bca1ea9f163b41c8147bae537e67bd963357a5488a1eaf3abe8baa8eec806d4e45f15b10767e6020679315c7e1e5e6803088dfb84efa2b4e9353b83dd0a + checksum: 10c0/6044310bd38c17d77549fd326bd40ce1506fa10b0794540aa130180808bf94117fac8c9b448c621512bea60e4a947278f6a978e87f10d342950c15b33ddd9271 languageName: node linkType: hard -"ajv-formats@npm:^2.1.1": - version: 2.1.1 - resolution: "ajv-formats@npm:2.1.1" +"ajv-formats@npm:3.0.1": + version: 3.0.1 + resolution: "ajv-formats@npm:3.0.1" dependencies: ajv: "npm:^8.0.0" peerDependencies: @@ -6853,63 +4212,43 @@ __metadata: peerDependenciesMeta: ajv: optional: true - checksum: 10c0/e43ba22e91b6a48d96224b83d260d3a3a561b42d391f8d3c6d2c1559f9aa5b253bfb306bc94bbeca1d967c014e15a6efe9a207309e95b3eaae07fcbcdc2af662 - languageName: node - linkType: hard - -"ajv-keywords@npm:^3.5.2": - version: 3.5.2 - resolution: "ajv-keywords@npm:3.5.2" - peerDependencies: - ajv: ^6.9.1 - checksum: 10c0/0c57a47cbd656e8cdfd99d7c2264de5868918ffa207c8d7a72a7f63379d4333254b2ba03d69e3c035e996a3fd3eb6d5725d7a1597cca10694296e32510546360 - languageName: node - linkType: hard - -"ajv-keywords@npm:^5.1.0": - version: 5.1.0 - resolution: "ajv-keywords@npm:5.1.0" - dependencies: - fast-deep-equal: "npm:^3.1.3" - peerDependencies: - ajv: ^8.8.2 - checksum: 10c0/18bec51f0171b83123ba1d8883c126e60c6f420cef885250898bf77a8d3e65e3bfb9e8564f497e30bdbe762a83e0d144a36931328616a973ee669dc74d4a9590 + checksum: 10c0/168d6bca1ea9f163b41c8147bae537e67bd963357a5488a1eaf3abe8baa8eec806d4e45f15b10767e6020679315c7e1e5e6803088dfb84efa2b4e9353b83dd0a languageName: node linkType: hard -"ajv@npm:8.17.1, ajv@npm:^8.0.0, ajv@npm:^8.1.0, ajv@npm:^8.11.0, ajv@npm:^8.9.0": - version: 8.17.1 - resolution: "ajv@npm:8.17.1" +"ajv@npm:8.20.0, ajv@npm:^8.0.0, ajv@npm:^8.11.0, ajv@npm:^8.17.1": + version: 8.20.0 + resolution: "ajv@npm:8.20.0" dependencies: fast-deep-equal: "npm:^3.1.3" fast-uri: "npm:^3.0.1" json-schema-traverse: "npm:^1.0.0" require-from-string: "npm:^2.0.2" - checksum: 10c0/ec3ba10a573c6b60f94639ffc53526275917a2df6810e4ab5a6b959d87459f9ef3f00d5e7865b82677cb7d21590355b34da14d1d0b9c32d75f95a187e76fff35 + checksum: 10c0/5df9a1c8f83863cde1bd3a9ddb426f599718f88e3dc9153616c79fb28e0be455335830d7f21d745576519f057b371352daa31047b6a33d7036fe08777d60cf2a languageName: node linkType: hard -"ajv@npm:^6.11.0, ajv@npm:^6.12.3, ajv@npm:^6.12.4, ajv@npm:^6.12.5, ajv@npm:^6.12.6": - version: 6.12.6 - resolution: "ajv@npm:6.12.6" +"ajv@npm:^6.12.3, ajv@npm:^6.14.0": + version: 6.15.0 + resolution: "ajv@npm:6.15.0" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 + checksum: 10c0/67966499dd272ecde1c2e467084411132891523d057487587879d39ac04207f4351b7b2324c83198013967fbfa632c1612adc960114a30770fbe07a0773b32c2 languageName: node linkType: hard -"ajv@npm:~8.12.0": - version: 8.12.0 - resolution: "ajv@npm:8.12.0" +"ajv@npm:~8.18.0": + version: 8.18.0 + resolution: "ajv@npm:8.18.0" dependencies: - fast-deep-equal: "npm:^3.1.1" + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" json-schema-traverse: "npm:^1.0.0" require-from-string: "npm:^2.0.2" - uri-js: "npm:^4.2.2" - checksum: 10c0/ac4f72adf727ee425e049bc9d8b31d4a57e1c90da8d28bcd23d60781b12fcd6fc3d68db5df16994c57b78b94eed7988f5a6b482fd376dc5b084125e20a0a622e + checksum: 10c0/e7517c426173513a07391be951879932bdf3348feaebd2199f5b901c20f99d60db8cd1591502d4d551dc82f594e82a05c4fe1c70139b15b8937f7afeaed9532f languageName: node linkType: hard @@ -6923,7 +4262,7 @@ __metadata: languageName: node linkType: hard -"ansi-colors@npm:4.1.3, ansi-colors@npm:^4.1.1": +"ansi-colors@npm:^4.1.1": version: 4.1.3 resolution: "ansi-colors@npm:4.1.3" checksum: 10c0/ec87a2f59902f74e61eada7f6e6fe20094a628dab765cfdbd03c3477599368768cffccdb5d3bb19a1b6c99126783a143b1fee31aab729b31ffe5836c7e5e28b9 @@ -6937,15 +4276,6 @@ __metadata: languageName: node linkType: hard -"ansi-escapes@npm:^4.3.2": - version: 4.3.2 - resolution: "ansi-escapes@npm:4.3.2" - dependencies: - type-fest: "npm:^0.21.3" - checksum: 10c0/da917be01871525a3dfcf925ae2977bc59e8c513d4423368645634bf5d4ceba5401574eb705c1e92b79f7292af5a656f78c5725a4b0e1cec97c4b413705c1d50 - languageName: node - linkType: hard - "ansi-regex@npm:^2.0.0": version: 2.1.1 resolution: "ansi-regex@npm:2.1.1" @@ -6974,7 +4304,7 @@ __metadata: languageName: node linkType: hard -"ansi-regex@npm:^6.0.1": +"ansi-regex@npm:^6.2.2": version: 6.2.2 resolution: "ansi-regex@npm:6.2.2" checksum: 10c0/05d4acb1d2f59ab2cf4b794339c7b168890d44dda4bf0ce01152a8da0213aca207802f930442ce8cd22d7a92f44907664aac6508904e75e038fa944d2601b30f @@ -7006,7 +4336,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": +"ansi-styles@npm:^5.0.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df @@ -7020,17 +4350,10 @@ __metadata: languageName: node linkType: hard -"ansis@npm:4.1.0": - version: 4.1.0 - resolution: "ansis@npm:4.1.0" - checksum: 10c0/df62d017a7791babdaf45b93f930d2cfd6d1dab5568b610735c11434c9a5ef8f513740e7cfd80bcbc3530fc8bd892b88f8476f26621efc251230e53cbd1a2c24 - languageName: node - linkType: hard - -"ansis@npm:^3.17.0": - version: 3.17.0 - resolution: "ansis@npm:3.17.0" - checksum: 10c0/d8fa94ca7bb91e7e5f8a7d323756aa075facce07c5d02ca883673e128b2873d16f93e0dec782f98f1eeb1f2b3b4b7b60dcf0ad98fb442e75054fe857988cc5cb +"ansis@npm:4.3.1, ansis@npm:^4.2.0": + version: 4.3.1 + resolution: "ansis@npm:4.3.1" + checksum: 10c0/d1a48090f9c33b18f254a3496e5336a20391a51140b552a1c0bc38710ae7c8bc36a62658f28759797d7bce15e89b893e9ef1962ea1ea42a291d112263f6e593c languageName: node linkType: hard @@ -7041,7 +4364,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": +"anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -7079,13 +4402,6 @@ __metadata: languageName: node linkType: hard -"archy@npm:^1.0.0": - version: 1.0.0 - resolution: "archy@npm:1.0.0" - checksum: 10c0/200c849dd1c304ea9914827b0555e7e1e90982302d574153e28637db1a663c53de62bad96df42d50e8ce7fc18d05e3437d9aa8c4b383803763755f0956c7d308 - languageName: node - linkType: hard - "are-docs-informative@npm:^0.0.2": version: 0.0.2 resolution: "are-docs-informative@npm:0.0.2" @@ -7123,13 +4439,6 @@ __metadata: languageName: node linkType: hard -"arg@npm:^4.1.0": - version: 4.1.3 - resolution: "arg@npm:4.1.3" - checksum: 10c0/070ff801a9d236a6caa647507bdcc7034530604844d64408149a26b9e87c2f97650055c0f049abd1efc024b334635c01f29e0b632b371ac3f26130f4cf65997a - languageName: node - linkType: hard - "argparse@npm:^1.0.7": version: 1.0.10 resolution: "argparse@npm:1.0.10" @@ -7191,6 +4500,13 @@ __metadata: languageName: node linkType: hard +"array-flatten@npm:1.1.1": + version: 1.1.1 + resolution: "array-flatten@npm:1.1.1" + checksum: 10c0/806966c8abb2f858b08f5324d9d18d7737480610f3bd5d3498aaae6eb5efdc501a884ba019c9b4a8f02ff67002058749d05548fd42fa8643f02c9c7f22198b91 + languageName: node + linkType: hard + "array-ify@npm:^1.0.0": version: 1.0.0 resolution: "array-ify@npm:1.0.0" @@ -7283,7 +4599,7 @@ __metadata: languageName: node linkType: hard -"array.prototype.reduce@npm:^1.0.6": +"array.prototype.reduce@npm:^1.0.8": version: 1.0.8 resolution: "array.prototype.reduce@npm:1.0.8" dependencies: @@ -7351,6 +4667,13 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + "assign-symbols@npm:^1.0.0": version: 1.0.0 resolution: "assign-symbols@npm:1.0.0" @@ -7358,6 +4681,17 @@ __metadata: languageName: node linkType: hard +"ast-v8-to-istanbul@npm:^1.0.0": + version: 1.0.4 + resolution: "ast-v8-to-istanbul@npm:1.0.4" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.31" + estree-walker: "npm:^3.0.3" + js-tokens: "npm:^10.0.0" + checksum: 10c0/48305cc748fcd0c8a84cf5750cca9e220e1cdb977286917e79a3182cd1eda9a6c73db7afb6526d86f3336684d4662b684db7c8a925448122603df048097b1d00 + languageName: node + linkType: hard + "async-function@npm:^1.0.0": version: 1.0.0 resolution: "async-function@npm:1.0.0" @@ -7365,6 +4699,13 @@ __metadata: languageName: node linkType: hard +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186 + languageName: node + linkType: hard + "async@npm:^3.2.6": version: 3.2.6 resolution: "async@npm:3.2.6" @@ -7395,13 +4736,6 @@ __metadata: languageName: node linkType: hard -"atomic-sleep@npm:^1.0.0": - version: 1.0.0 - resolution: "atomic-sleep@npm:1.0.0" - checksum: 10c0/e329a6665512736a9bbb073e1761b4ec102f7926cce35037753146a9db9c8104f5044c1662e4a863576ce544fb8be27cd2be6bc8c1a40147d03f31eb1cfb6e8a - languageName: node - linkType: hard - "available-typed-arrays@npm:^1.0.7": version: 1.0.7 resolution: "available-typed-arrays@npm:1.0.7" @@ -7411,18 +4745,6 @@ __metadata: languageName: node linkType: hard -"avvio@npm:^7.1.2": - version: 7.2.5 - resolution: "avvio@npm:7.2.5" - dependencies: - archy: "npm:^1.0.0" - debug: "npm:^4.0.0" - fastq: "npm:^1.6.1" - queue-microtask: "npm:^1.1.2" - checksum: 10c0/20ca0bf216647ff09ebaa9f206cd55dbe768a72b915ad41e517223ed4595ab7ec72eeff99a5da88d47a7ecc53002424d2b4c68b09559e3ab918c296ced5100d8 - languageName: node - linkType: hard - "aws-sign2@npm:~0.7.0": version: 0.7.0 resolution: "aws-sign2@npm:0.7.0" @@ -7433,94 +4755,7 @@ __metadata: "aws4@npm:^1.8.0": version: 1.13.2 resolution: "aws4@npm:1.13.2" - checksum: 10c0/c993d0d186d699f685d73113733695d648ec7d4b301aba2e2a559d0cd9c1c902308cc52f4095e1396b23fddbc35113644e7f0a6a32753636306e41e3ed6f1e79 - languageName: node - linkType: hard - -"axios@npm:^1.6.3": - version: 1.13.2 - resolution: "axios@npm:1.13.2" - dependencies: - follow-redirects: "npm:^1.15.6" - form-data: "npm:^4.0.4" - proxy-from-env: "npm:^1.1.0" - checksum: 10c0/e8a42e37e5568ae9c7a28c348db0e8cf3e43d06fcbef73f0048669edfe4f71219664da7b6cc991b0c0f01c28a48f037c515263cb79be1f1ae8ff034cd813867b - languageName: node - linkType: hard - -"babel-jest@npm:30.2.0": - version: 30.2.0 - resolution: "babel-jest@npm:30.2.0" - dependencies: - "@jest/transform": "npm:30.2.0" - "@types/babel__core": "npm:^7.20.5" - babel-plugin-istanbul: "npm:^7.0.1" - babel-preset-jest: "npm:30.2.0" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - slash: "npm:^3.0.0" - peerDependencies: - "@babel/core": ^7.11.0 || ^8.0.0-0 - checksum: 10c0/673b8c87e5aec97c4f7372319c005d1e2b018e2f2e973378c7fb0a4f1e111f89872e6f1e49dd50aff6290cd881c865117ade67f2c78a356a8275ab21af47340d - languageName: node - linkType: hard - -"babel-plugin-istanbul@npm:^7.0.1": - version: 7.0.1 - resolution: "babel-plugin-istanbul@npm:7.0.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.0.0" - "@istanbuljs/load-nyc-config": "npm:^1.0.0" - "@istanbuljs/schema": "npm:^0.1.3" - istanbul-lib-instrument: "npm:^6.0.2" - test-exclude: "npm:^6.0.0" - checksum: 10c0/92975e3df12503b168695463b451468da0c20e117807221652eb8e33a26c160f3b9d4c5c4e65495657420e871c6a54e5e31f539e2e1da37ef2261d7ddd4b1dfd - languageName: node - linkType: hard - -"babel-plugin-jest-hoist@npm:30.2.0": - version: 30.2.0 - resolution: "babel-plugin-jest-hoist@npm:30.2.0" - dependencies: - "@types/babel__core": "npm:^7.20.5" - checksum: 10c0/a2bd862aaa4875127c02e6020d3da67556a8f25981060252668dda65cf9a146202937ae80d2e8612c3c47afe19ac85577647b8cc216faa98567c685525a3f203 - languageName: node - linkType: hard - -"babel-preset-current-node-syntax@npm:^1.2.0": - version: 1.2.0 - resolution: "babel-preset-current-node-syntax@npm:1.2.0" - dependencies: - "@babel/plugin-syntax-async-generators": "npm:^7.8.4" - "@babel/plugin-syntax-bigint": "npm:^7.8.3" - "@babel/plugin-syntax-class-properties": "npm:^7.12.13" - "@babel/plugin-syntax-class-static-block": "npm:^7.14.5" - "@babel/plugin-syntax-import-attributes": "npm:^7.24.7" - "@babel/plugin-syntax-import-meta": "npm:^7.10.4" - "@babel/plugin-syntax-json-strings": "npm:^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators": "npm:^7.10.4" - "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" - "@babel/plugin-syntax-numeric-separator": "npm:^7.10.4" - "@babel/plugin-syntax-object-rest-spread": "npm:^7.8.3" - "@babel/plugin-syntax-optional-catch-binding": "npm:^7.8.3" - "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" - "@babel/plugin-syntax-private-property-in-object": "npm:^7.14.5" - "@babel/plugin-syntax-top-level-await": "npm:^7.14.5" - peerDependencies: - "@babel/core": ^7.0.0 || ^8.0.0-0 - checksum: 10c0/94a4f81cddf9b051045d08489e4fff7336292016301664c138cfa3d9ffe3fe2ba10a24ad6ae589fd95af1ac72ba0216e1653555c187e694d7b17be0c002bea10 - languageName: node - linkType: hard - -"babel-preset-jest@npm:30.2.0": - version: 30.2.0 - resolution: "babel-preset-jest@npm:30.2.0" - dependencies: - babel-plugin-jest-hoist: "npm:30.2.0" - babel-preset-current-node-syntax: "npm:^1.2.0" - peerDependencies: - "@babel/core": ^7.11.0 || ^8.0.0-beta.1 - checksum: 10c0/fb2727bad450256146d63b5231b83a7638e73b96c9612296a20afd65fb8c76678ef9bc6fa56e81d1303109258aeb4fccea5b96568744059e47d3c6e3ebc98bd9 + checksum: 10c0/c993d0d186d699f685d73113733695d648ec7d4b301aba2e2a559d0cd9c1c902308cc52f4095e1396b23fddbc35113644e7f0a6a32753636306e41e3ed6f1e79 languageName: node linkType: hard @@ -7540,6 +4775,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b + languageName: node + linkType: hard + "base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -7547,13 +4789,6 @@ __metadata: languageName: node linkType: hard -"base64url@npm:3.x.x": - version: 3.0.1 - resolution: "base64url@npm:3.0.1" - checksum: 10c0/5ca9d6064e9440a2a45749558dddd2549ca439a305793d4f14a900b7256b5f4438ef1b7a494e1addc66ced5d20f5c010716d353ed267e4b769e6c78074991241 - languageName: node - linkType: hard - "base@npm:^0.11.1": version: 0.11.2 resolution: "base@npm:0.11.2" @@ -7569,15 +4804,6 @@ __metadata: languageName: node linkType: hard -"baseline-browser-mapping@npm:^2.8.25": - version: 2.8.28 - resolution: "baseline-browser-mapping@npm:2.8.28" - bin: - baseline-browser-mapping: dist/cli.js - checksum: 10c0/d157d73de33bff69cf3413983dc1b2421063cd1c895e9edabc22dcb6667f7e17762b46ebeee5eee7496271351754c12750867c6ea5cb432f1bbe33dc5c62d1e6 - languageName: node - linkType: hard - "bcrypt-pbkdf@npm:^1.0.0": version: 1.0.2 resolution: "bcrypt-pbkdf@npm:1.0.2" @@ -7638,20 +4864,40 @@ __metadata: languageName: node linkType: hard -"body-parser@npm:^2.2.0": - version: 2.2.0 - resolution: "body-parser@npm:2.2.0" +"body-parser@npm:^2.2.1": + version: 2.3.0 + resolution: "body-parser@npm:2.3.0" dependencies: bytes: "npm:^3.1.2" - content-type: "npm:^1.0.5" - debug: "npm:^4.4.0" - http-errors: "npm:^2.0.0" - iconv-lite: "npm:^0.6.3" + content-type: "npm:^2.0.0" + debug: "npm:^4.4.3" + http-errors: "npm:^2.0.1" + iconv-lite: "npm:^0.7.2" on-finished: "npm:^2.4.1" - qs: "npm:^6.14.0" - raw-body: "npm:^3.0.0" - type-is: "npm:^2.0.0" - checksum: 10c0/a9ded39e71ac9668e2211afa72e82ff86cc5ef94de1250b7d1ba9cc299e4150408aaa5f1e8b03dd4578472a3ce6d1caa2a23b27a6c18e526e48b4595174c116c + qs: "npm:^6.15.2" + raw-body: "npm:^3.0.2" + type-is: "npm:^2.1.0" + checksum: 10c0/2a8fbbdc471b588338555a3e1a597d1eb0ad0c21cf20fdc3bac5d3f8d9c3a4b19b4163575ab852a43a5dfc0df7770ced5284f979e561f1a24c2f851ce89e695a + languageName: node + linkType: hard + +"body-parser@npm:~1.20.5": + version: 1.20.5 + resolution: "body-parser@npm:1.20.5" + dependencies: + bytes: "npm:~3.1.2" + content-type: "npm:~1.0.5" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:~1.2.0" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.4.24" + on-finished: "npm:~2.4.1" + qs: "npm:~6.15.1" + raw-body: "npm:~2.5.3" + type-is: "npm:~1.6.18" + unpipe: "npm:~1.0.0" + checksum: 10c0/ad777ca5e4711eae253c93f50fdc4608c60b76a9710d79e5e5b84581c76691e6ad21ecc9158986d9ea2b365df73e403ca33c27a8bccc1a7cfc2ccc248548118d languageName: node linkType: hard @@ -7662,29 +4908,40 @@ __metadata: languageName: node linkType: hard -"bowser@npm:^2.11.0": - version: 2.12.1 - resolution: "bowser@npm:2.12.1" - checksum: 10c0/017e8cc63ce2dec75037340626e1408f68334dac95f953ba7db33a266c019f1d262346d2be3994f9a12b7e9c02f57c562078719b8c5e8e8febe01053c613ffbc - languageName: node - linkType: hard - "brace-expansion@npm:^1.1.7": - version: 1.1.12 - resolution: "brace-expansion@npm:1.1.12" + version: 1.1.15 + resolution: "brace-expansion@npm:1.1.15" dependencies: balanced-match: "npm:^1.0.0" concat-map: "npm:0.0.1" - checksum: 10c0/975fecac2bb7758c062c20d0b3b6288c7cc895219ee25f0a64a9de662dbac981ff0b6e89909c3897c1f84fa353113a721923afdec5f8b2350255b097f12b1f73 + checksum: 10c0/648e273f57cfa9ed67d8a77bdb15b408205465d33da9331808ee3c188d8b55674c9cdbf1f320b65bc562e485e1263360ae62ad355e128e0435891f6430e795d7 languageName: node linkType: hard -"brace-expansion@npm:^2.0.1": - version: 2.0.2 - resolution: "brace-expansion@npm:2.0.2" +"brace-expansion@npm:^2.0.1, brace-expansion@npm:^2.0.2": + version: 2.1.1 + resolution: "brace-expansion@npm:2.1.1" dependencies: balanced-match: "npm:^1.0.0" - checksum: 10c0/6d117a4c793488af86b83172deb6af143e94c17bc53b0b3cec259733923b4ca84679d506ac261f4ba3c7ed37c46018e2ff442f9ce453af8643ecd64f4a54e6cf + checksum: 10c0/63b5ddce608b70b50a76817c0526faf8ea67a9180073d88bb402f6bbc22a22da6b1dfac4f65efc53e5faa80222fb7d44bbf2fc638c3f55365975573f671d0ccb + languageName: node + linkType: hard + +"brace-expansion@npm:^5.0.5": + version: 5.0.6 + resolution: "brace-expansion@npm:5.0.6" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10c0/8c919869b90f61d533b341d3340be5ee4413232ea89b8246cbc2f38eb014f1d8182785c98a006eaf6111d02dc9eeffefdc240d5ac158625b2ed084dccd4bbf9b + languageName: node + linkType: hard + +"brace-expansion@npm:^5.0.8": + version: 5.0.9 + resolution: "brace-expansion@npm:5.0.9" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10c0/3dea38884a1c3c8b1c9c44a7402a0c76fca460f70cffb3127242b0b4cbf4472019e022ade021eec44838ff19f1dac2625dfd11dd459d7e1e055b0698a8d52fec languageName: node linkType: hard @@ -7706,7 +4963,7 @@ __metadata: languageName: node linkType: hard -"braces@npm:^3.0.3, braces@npm:~3.0.2": +"braces@npm:~3.0.2": version: 3.0.3 resolution: "braces@npm:3.0.3" dependencies: @@ -7715,39 +4972,6 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.24.0": - version: 4.28.0 - resolution: "browserslist@npm:4.28.0" - dependencies: - baseline-browser-mapping: "npm:^2.8.25" - caniuse-lite: "npm:^1.0.30001754" - electron-to-chromium: "npm:^1.5.249" - node-releases: "npm:^2.0.27" - update-browserslist-db: "npm:^1.1.4" - bin: - browserslist: cli.js - checksum: 10c0/4284fd568f7d40a496963083860d488cb2a89fb055b6affd316bebc59441fec938e090b3e62c0ee065eb0bc88cd1bc145f4300a16c75f3f565621c5823715ae1 - languageName: node - linkType: hard - -"bs-logger@npm:^0.2.6": - version: 0.2.6 - resolution: "bs-logger@npm:0.2.6" - dependencies: - fast-json-stable-stringify: "npm:2.x" - checksum: 10c0/80e89aaaed4b68e3374ce936f2eb097456a0dddbf11f75238dbd53140b1e39259f0d248a5089ed456f1158984f22191c3658d54a713982f676709fbe1a6fa5a0 - languageName: node - linkType: hard - -"bser@npm:2.1.1": - version: 2.1.1 - resolution: "bser@npm:2.1.1" - dependencies: - node-int64: "npm:^0.4.0" - checksum: 10c0/24d8dfb7b6d457d73f32744e678a60cc553e4ec0e9e1a01cf614b44d85c3c87e188d3cc78ef0442ce5032ee6818de20a0162ba1074725c0d08908f62ea979227 - languageName: node - linkType: hard - "btoa-lite@npm:^1.0.0": version: 1.0.0 resolution: "btoa-lite@npm:1.0.0" @@ -7819,7 +5043,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2, bytes@npm:^3.1.2": +"bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: 10c0/76d1c43cbd602794ad8ad2ae94095cddeb1de78c5dddaa7005c51af10b0176c69971a6d88e805a90c2b6550d76636e43c40d8427a808b8645ede885de4a0358e @@ -7875,25 +5099,6 @@ __metadata: languageName: node linkType: hard -"cacache@npm:^20.0.1": - version: 20.0.1 - resolution: "cacache@npm:20.0.1" - dependencies: - "@npmcli/fs": "npm:^4.0.0" - fs-minipass: "npm:^3.0.0" - glob: "npm:^11.0.3" - lru-cache: "npm:^11.1.0" - minipass: "npm:^7.0.3" - minipass-collect: "npm:^2.0.1" - minipass-flush: "npm:^1.0.5" - minipass-pipeline: "npm:^1.2.4" - p-map: "npm:^7.0.2" - ssri: "npm:^12.0.0" - unique-filename: "npm:^4.0.0" - checksum: 10c0/e3efcf3af1c984e6e59e03372d9289861736a572e6e05b620606b87a67e71d04cff6dbc99607801cb21bcaae1fb4fb84d4cc8e3fda725e95881329ef03dac602 - languageName: node - linkType: hard - "cache-base@npm:^1.0.1": version: 1.0.1 resolution: "cache-base@npm:1.0.1" @@ -7911,7 +5116,7 @@ __metadata: languageName: node linkType: hard -"call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": version: 1.0.2 resolution: "call-bind-apply-helpers@npm:1.0.2" dependencies: @@ -7921,15 +5126,15 @@ __metadata: languageName: node linkType: hard -"call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": - version: 1.0.8 - resolution: "call-bind@npm:1.0.8" +"call-bind@npm:^1.0.7, call-bind@npm:^1.0.8, call-bind@npm:^1.0.9": + version: 1.0.9 + resolution: "call-bind@npm:1.0.9" dependencies: - call-bind-apply-helpers: "npm:^1.0.0" - es-define-property: "npm:^1.0.0" - get-intrinsic: "npm:^1.2.4" + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + get-intrinsic: "npm:^1.3.0" set-function-length: "npm:^1.2.2" - checksum: 10c0/a13819be0681d915144467741b69875ae5f4eba8961eb0bf322aab63ec87f8250eb6d6b0dcbb2e1349876412a56129ca338592b3829ef4343527f5f18a0752d4 + checksum: 10c0/a6621f6da1444481919ce3b4983dff725691e0754d3507ae483ce56e54985f2da7d6f1df512c56dbf28660745cf1ca52553f1fc9aef5557f3ce353ef14fab714 languageName: node linkType: hard @@ -7943,7 +5148,7 @@ __metadata: languageName: node linkType: hard -"call-me-maybe@npm:^1.0.1": +"call-me-maybe@npm:^1.0.1, call-me-maybe@npm:^1.0.2": version: 1.0.2 resolution: "call-me-maybe@npm:1.0.2" checksum: 10c0/8eff5dbb61141ebb236ed71b4e9549e488bcb5451c48c11e5667d5c75b0532303788a1101e6978cafa2d0c8c1a727805599c2741e3e0982855c9f1d78cd06c9f @@ -7975,7 +5180,7 @@ __metadata: languageName: node linkType: hard -"callsites@npm:^3.0.0, callsites@npm:^3.1.0": +"callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" checksum: 10c0/fff92277400eb06c3079f9e74f3af120db9f8ea03bad0e84d9aede54bbe2d44a56cccb5f6cf12211f93f52306df87077ecec5b712794c5a9b5dac6d615a3f301 @@ -8045,20 +5250,6 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^6.3.0": - version: 6.3.0 - resolution: "camelcase@npm:6.3.0" - checksum: 10c0/0d701658219bd3116d12da3eab31acddb3f9440790c0792e0d398f0a520a6a4058018e546862b6fba89d7ae990efaeb97da71e1913e9ebf5a8b5621a3d55c710 - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001754": - version: 1.0.30001754 - resolution: "caniuse-lite@npm:1.0.30001754" - checksum: 10c0/d38709ab11abc36eea28068d241434eba925c4d3462916ccaa17a34a6227dfdeb58ab0e1eb614bab12fb393c7d527db392a0f477b48c33d70d8e466954f381ba - languageName: node - linkType: hard - "caseless@npm:~0.12.0": version: 0.12.0 resolution: "caseless@npm:0.12.0" @@ -8066,6 +5257,13 @@ __metadata: languageName: node linkType: hard +"chai@npm:^6.2.2": + version: 6.2.2 + resolution: "chai@npm:6.2.2" + checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 + languageName: node + linkType: hard + "chalk@npm:^2.3.1, chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" @@ -8087,7 +5285,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4, chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": +"chalk@npm:^4, chalk@npm:^4.0.0, chalk@npm:^4.1.0": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -8097,7 +5295,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.3.0": +"chalk@npm:^5.3.0, chalk@npm:^5.6.2": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 @@ -8128,9 +5326,9 @@ __metadata: linkType: hard "chardet@npm:^2.1.1": - version: 2.1.1 - resolution: "chardet@npm:2.1.1" - checksum: 10c0/d8391dd412338442b3de0d3a488aa9327f8bcf74b62b8723d6bd0b85c4084d50b731320e0a7c710edb1d44de75969995d2784b80e4c13b004a6c7a0db4c6e793 + version: 2.2.0 + resolution: "chardet@npm:2.2.0" + checksum: 10c0/8d43a1dd3ce535aa070d04139fae62f879b4388394eb1d857364ce416675a1f0f63974fba8208e9cd11b49e1a6da3e65ea6393d0fc654a8c4217c0d74c16b1b4 languageName: node linkType: hard @@ -8163,12 +5361,12 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:4.0.3, chokidar@npm:^4.0.1": - version: 4.0.3 - resolution: "chokidar@npm:4.0.3" +"chokidar@npm:5.0.0": + version: 5.0.0 + resolution: "chokidar@npm:5.0.0" dependencies: - readdirp: "npm:^4.0.1" - checksum: 10c0/a58b9df05bb452f7d105d9e7229ac82fa873741c0c40ddcc7bb82f8a909fbe3f7814c9ebe9bc9a2bef9b737c0ec6e2d699d179048ef06ad3ec46315df0ebe6ad + readdirp: "npm:^5.0.0" + checksum: 10c0/42fc907cb2a7ff5c9e220f84dae75380a77997f851c2a5e7865a2cf9ae45dd407a23557208cdcdbf3ac8c93341135a1748e4c48c31855f3bfa095e5159b6bdec languageName: node linkType: hard @@ -8212,13 +5410,6 @@ __metadata: languageName: node linkType: hard -"chrome-trace-event@npm:^1.0.2": - version: 1.0.4 - resolution: "chrome-trace-event@npm:1.0.4" - checksum: 10c0/3058da7a5f4934b87cf6a90ef5fb68ebc5f7d06f143ed5a4650208e5d7acae47bc03ec844b29fbf5ba7e46e8daa6acecc878f7983a4f4bb7271593da91e61ff5 - languageName: node - linkType: hard - "ci-info@npm:^2.0.0": version: 2.0.0 resolution: "ci-info@npm:2.0.0" @@ -8233,34 +5424,6 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^4.2.0": - version: 4.3.1 - resolution: "ci-info@npm:4.3.1" - checksum: 10c0/7dd82000f514d76ddfe7775e4cb0d66e5c638f5fa0e2a3be29557e898da0d32ac04f231217d414d07fb968b1fbc6d980ee17ddde0d2c516f23da9cfff608f6c1 - languageName: node - linkType: hard - -"cjs-module-lexer@npm:^1.2.2": - version: 1.4.3 - resolution: "cjs-module-lexer@npm:1.4.3" - checksum: 10c0/076b3af85adc4d65dbdab1b5b240fe5b45d44fcf0ef9d429044dd94d19be5589376805c44fb2d4b3e684e5fe6a9b7cf3e426476a6507c45283c5fc6ff95240be - languageName: node - linkType: hard - -"cjs-module-lexer@npm:^2.1.0": - version: 2.1.1 - resolution: "cjs-module-lexer@npm:2.1.1" - checksum: 10c0/813697c0ed1533f4a88bd8051d8ae1cb1b21d3ff1c6a5720353817d50c3f3f83bb2af6bd83922aae94b3ef90d64d01a6eb123fa8249f4dc7215e3afd89364f86 - languageName: node - linkType: hard - -"class-transformer@npm:^0.5.1": - version: 0.5.1 - resolution: "class-transformer@npm:0.5.1" - checksum: 10c0/19809914e51c6db42c036166839906420bb60367df14e15f49c45c8c1231bf25ae661ebe94736ee29cc688b77101ef851a8acca299375cc52fc141b64acde18a - languageName: node - linkType: hard - "class-utils@npm:^0.3.5": version: 0.3.6 resolution: "class-utils@npm:0.3.6" @@ -8273,17 +5436,6 @@ __metadata: languageName: node linkType: hard -"class-validator@npm:*, class-validator@npm:^0.14.1": - version: 0.14.2 - resolution: "class-validator@npm:0.14.2" - dependencies: - "@types/validator": "npm:^13.11.8" - libphonenumber-js: "npm:^1.11.1" - validator: "npm:^13.9.0" - checksum: 10c0/5bb67389d38fa23d342dffdd8e2dcee8235e1906e59799df5b2050278a6d89292fcaa88167f0215e3ddd684f47dcd51b004efa7be32d8aded91ee06cb317b3b8 - languageName: node - linkType: hard - "clean-css@npm:^4.2.1": version: 4.2.4 resolution: "clean-css@npm:4.2.4" @@ -8318,6 +5470,15 @@ __metadata: languageName: node linkType: hard +"cli-cursor@npm:^5.0.0": + version: 5.0.0 + resolution: "cli-cursor@npm:5.0.0" + dependencies: + restore-cursor: "npm:^5.0.0" + checksum: 10c0/7ec62f69b79f6734ab209a3e4dbdc8af7422d44d360a7cb1efa8a0887bbe466a6e625650c466fe4359aee44dbe2dc0b6994b583d40a05d0808a5cb193641d220 + languageName: node + linkType: hard + "cli-spinners@npm:^2.5.0": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" @@ -8325,6 +5486,13 @@ __metadata: languageName: node linkType: hard +"cli-spinners@npm:^3.2.0": + version: 3.4.0 + resolution: "cli-spinners@npm:3.4.0" + checksum: 10c0/91296c32e147d5b973c9d439d1512306499215437b92f0c0d8be44ec850b555acb8795c19c606b2f6747f31d50c4e41fdde7dcef653f18f0ae7cdd58e99a4764 + languageName: node + linkType: hard + "cli-table3@npm:0.6.5": version: 0.6.5 resolution: "cli-table3@npm:0.6.5" @@ -8403,13 +5571,6 @@ __metadata: languageName: node linkType: hard -"co@npm:^4.6.0": - version: 4.6.0 - resolution: "co@npm:4.6.0" - checksum: 10c0/c0e85ea0ca8bf0a50cbdca82efc5af0301240ca88ebe3644a6ffb8ffe911f34d40f8fbcf8f1d52c5ddd66706abd4d3bfcd64259f1e8e2371d4f47573b0dc8c28 - languageName: node - linkType: hard - "code-point-at@npm:^1.0.0": version: 1.1.0 resolution: "code-point-at@npm:1.1.0" @@ -8417,13 +5578,6 @@ __metadata: languageName: node linkType: hard -"collect-v8-coverage@npm:^1.0.2": - version: 1.0.3 - resolution: "collect-v8-coverage@npm:1.0.3" - checksum: 10c0/bc62ba251bcce5e3354a8f88fa6442bee56e3e612fec08d4dfcf66179b41ea0bf544b0f78c4ebc0f8050871220af95bb5c5578a6aef346feea155640582f09dc - languageName: node - linkType: hard - "collection-visit@npm:^1.0.0": version: 1.0.0 resolution: "collection-visit@npm:1.0.0" @@ -8494,10 +5648,10 @@ __metadata: languageName: node linkType: hard -"commander@npm:4.1.1": - version: 4.1.1 - resolution: "commander@npm:4.1.1" - checksum: 10c0/84a76c08fe6cc08c9c93f62ac573d2907d8e79138999312c92d4155bc2325d487d64d13f669b2000c9f8caf70493c1be2dac74fec3c51d5a04f8bc3ae1830bab +"commander@npm:15.0.0": + version: 15.0.0 + resolution: "commander@npm:15.0.0" + checksum: 10c0/539229c171914ea1ccd45ee5f10d924289a12a684ea3a7a44147abe54003c35ed6de9ae4ad198d88c29fdf403dca0428451a388234e6dc01b6faa978fb207206 languageName: node linkType: hard @@ -8508,7 +5662,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^2.19.0, commander@npm:^2.20.0": +"commander@npm:^2.19.0": version: 2.20.3 resolution: "commander@npm:2.20.3" checksum: 10c0/74c781a5248c2402a0a3e966a0a2bba3c054aad144f5c023364be83265e796b20565aa9feff624132ff629aa64e16999fa40a743c10c12f7c61e96a794b99288 @@ -8529,14 +5683,13 @@ __metadata: languageName: node linkType: hard -"comment-json@npm:4.4.1": - version: 4.4.1 - resolution: "comment-json@npm:4.4.1" +"comment-json@npm:5.0.0": + version: 5.0.0 + resolution: "comment-json@npm:5.0.0" dependencies: array-timsort: "npm:^1.0.3" - core-util-is: "npm:^1.0.3" esprima: "npm:^4.0.1" - checksum: 10c0/be6a197132543a3c286c725af412d582882c1eaf450cb124e4148e7542449f216aa717e7be81989f8b8cfe3e38a6f9bc06d209351b8ea82514cafc8feec11a2d + checksum: 10c0/86172bcdfc33e3f3da23819151a11a0a30e0d632c255c66541e511b6d41e549a10f1011c1467ded5ea5fe08096769cacfeb84107bbab2347f506aab926ad9082 languageName: node linkType: hard @@ -8605,13 +5758,6 @@ __metadata: languageName: node linkType: hard -"consola@npm:^3.2.3": - version: 3.4.2 - resolution: "consola@npm:3.4.2" - checksum: 10c0/7cebe57ecf646ba74b300bcce23bff43034ed6fbec9f7e39c27cee1dc00df8a21cd336b466ad32e304ea70fba04ec9e890c200270de9a526ce021ba8a7e4c11a - languageName: node - linkType: hard - "console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0, console-control-strings@npm:~1.1.0": version: 1.1.0 resolution: "console-control-strings@npm:1.1.0" @@ -8630,21 +5776,35 @@ __metadata: linkType: hard "content-disposition@npm:^1.0.0": - version: 1.0.0 - resolution: "content-disposition@npm:1.0.0" + version: 1.1.0 + resolution: "content-disposition@npm:1.1.0" + checksum: 10c0/94e0aef65873e69330f5f187fbc44ebce593bdcb8013dd8a68b7d0f159ca089bd30db3f8095d829f81c341695b60a6085ee6e15e6d775c4a325b586cc8d91974 + languageName: node + linkType: hard + +"content-disposition@npm:~0.5.4": + version: 0.5.4 + resolution: "content-disposition@npm:0.5.4" dependencies: safe-buffer: "npm:5.2.1" - checksum: 10c0/c7b1ba0cea2829da0352ebc1b7f14787c73884bc707c8bc2271d9e3bf447b372270d09f5d3980dc5037c749ceef56b9a13fccd0b0001c87c3f12579967e4dd27 + checksum: 10c0/bac0316ebfeacb8f381b38285dc691c9939bf0a78b0b7c2d5758acadad242d04783cee5337ba7d12a565a19075af1b3c11c728e1e4946de73c6ff7ce45f3f1bb languageName: node linkType: hard -"content-type@npm:^1.0.5": +"content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" checksum: 10c0/b76ebed15c000aee4678c3707e0860cb6abd4e680a598c0a26e17f0bfae723ec9cc2802f0ff1bc6e4d80603719010431d2231018373d4dde10f9ccff9dadf5af languageName: node linkType: hard +"content-type@npm:^2.0.0": + version: 2.0.0 + resolution: "content-type@npm:2.0.0" + checksum: 10c0/491539fff707d7594b0ca4fabcc084bef2a31ffa754ff0a4f80c4377e3963cff0394317f9271c24087596c97fa675bc123d61fa34ffe65b4904e7d3d3098de72 + languageName: node + linkType: hard + "conventional-changelog-angular@npm:^5.0.12, conventional-changelog-angular@npm:^5.0.3": version: 5.0.13 resolution: "conventional-changelog-angular@npm:5.0.13" @@ -8953,14 +6113,14 @@ __metadata: languageName: node linkType: hard -"cookie@npm:^0.5.0": - version: 0.5.0 - resolution: "cookie@npm:0.5.0" - checksum: 10c0/c01ca3ef8d7b8187bae434434582288681273b5a9ed27521d4d7f9f7928fe0c920df0decd9f9d3bbd2d14ac432b8c8cf42b98b3bdd5bfe0e6edddeebebe8b61d +"cookie-signature@npm:~1.0.6": + version: 1.0.7 + resolution: "cookie-signature@npm:1.0.7" + checksum: 10c0/e7731ad2995ae2efeed6435ec1e22cdd21afef29d300c27281438b1eab2bae04ef0d1a203928c0afec2cee72aa36540b8747406ebe308ad23c8e8cc3c26c9c51 languageName: node linkType: hard -"cookie@npm:^0.7.1": +"cookie@npm:^0.7.1, cookie@npm:~0.7.1": version: 0.7.2 resolution: "cookie@npm:0.7.2" checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 @@ -8995,19 +6155,6 @@ __metadata: languageName: node linkType: hard -"coralogix-logger@npm:^1.1.30": - version: 1.2.1 - resolution: "coralogix-logger@npm:1.2.1" - dependencies: - axios: "npm:^1.6.3" - json-stringify-safe: "npm:5.0.1" - object-sizeof: "npm:1.1.1" - proxy-from-env: "npm:^1.1.0" - rxjs: "npm:^7.8.1" - checksum: 10c0/e13c5fedf321337cd15ea390405d383aee67bda36bd26fe16c2bd806901b8add80a345aa43721bf3c8a94c16e6fa76330b36c097bf3fa5345de1989ed647dfc0 - languageName: node - linkType: hard - "core-util-is@npm:1.0.2": version: 1.0.2 resolution: "core-util-is@npm:1.0.2" @@ -9015,33 +6162,33 @@ __metadata: languageName: node linkType: hard -"core-util-is@npm:^1.0.3, core-util-is@npm:~1.0.0": +"core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" checksum: 10c0/90a0e40abbddfd7618f8ccd63a74d88deea94e77d0e8dbbea059fa7ebebb8fbb4e2909667fe26f3a467073de1a542ebe6ae4c73a73745ac5833786759cd906c9 languageName: node linkType: hard -"cors@npm:2.8.5": - version: 2.8.5 - resolution: "cors@npm:2.8.5" +"cors@npm:2.8.6": + version: 2.8.6 + resolution: "cors@npm:2.8.6" dependencies: object-assign: "npm:^4" vary: "npm:^1" - checksum: 10c0/373702b7999409922da80de4a61938aabba6929aea5b6fd9096fefb9e8342f626c0ebd7507b0e8b0b311380744cc985f27edebc0a26e0ddb784b54e1085de761 + checksum: 10c0/ab2bc57b8af8ef8476682a59647f7c55c1a7d406b559ac06119aa1c5f70b96d35036864d197b24cf86e228e4547231088f1f94ca05061dbb14d89cc0bc9d4cab languageName: node linkType: hard "cosmiconfig-typescript-loader@npm:^6.1.0": - version: 6.2.0 - resolution: "cosmiconfig-typescript-loader@npm:6.2.0" + version: 6.3.0 + resolution: "cosmiconfig-typescript-loader@npm:6.3.0" dependencies: - jiti: "npm:^2.6.1" + jiti: "npm:2.6.1" peerDependencies: "@types/node": "*" cosmiconfig: ">=9" typescript: ">=5" - checksum: 10c0/0fd8fd9b9b6a04eec75617b965ce0a1f63310fe29a361c1f95cb971e05dbbb935291899c2b15abfd69e09db58dbe97077f24a7c61414bbc6c3e78349b4314ad7 + checksum: 10c0/dd53519bf59b31c32a831f1140eaa44f4f0bf49229b7e3ba27bb6f26097c3ecff955a490e70b31349049463066b5047bd129ab82ed078be9b1f1f22ccb776c6a languageName: node linkType: hard @@ -9057,26 +6204,9 @@ __metadata: languageName: node linkType: hard -"cosmiconfig@npm:^8.2.0": - version: 8.3.6 - resolution: "cosmiconfig@npm:8.3.6" - dependencies: - import-fresh: "npm:^3.3.0" - js-yaml: "npm:^4.1.0" - parse-json: "npm:^5.2.0" - path-type: "npm:^4.0.0" - peerDependencies: - typescript: ">=4.9.5" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/0382a9ed13208f8bfc22ca2f62b364855207dffdb73dc26e150ade78c3093f1cf56172df2dd460c8caf2afa91c0ed4ec8a88c62f8f9cd1cf423d26506aa8797a - languageName: node - linkType: hard - "cosmiconfig@npm:^9.0.0": - version: 9.0.0 - resolution: "cosmiconfig@npm:9.0.0" + version: 9.0.2 + resolution: "cosmiconfig@npm:9.0.2" dependencies: env-paths: "npm:^2.2.1" import-fresh: "npm:^3.3.0" @@ -9087,14 +6217,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/1c1703be4f02a250b1d6ca3267e408ce16abfe8364193891afc94c2d5c060b69611fdc8d97af74b7e6d5d1aac0ab2fb94d6b079573146bc2d756c2484ce5f0ee - languageName: node - linkType: hard - -"create-require@npm:^1.1.0": - version: 1.1.1 - resolution: "create-require@npm:1.1.1" - checksum: 10c0/157cbc59b2430ae9a90034a5f3a1b398b6738bf510f713edc4d4e45e169bc514d3d99dd34d8d01ca7ae7830b5b8b537e46ae8f3c8f932371b0875c0151d7ec91 + checksum: 10c0/132d32863c0b3d9ace7010a10011e09089532b36e3b11e02004d671dcbd8b8f2f16293b82d510d9c15890f30358baf8722127c7b1c011449c90b6a178f9c6594 languageName: node linkType: hard @@ -9111,7 +6234,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -9230,10 +6353,19 @@ __metadata: languageName: node linkType: hard -"dayjs@npm:^1.11.13": - version: 1.11.19 - resolution: "dayjs@npm:1.11.19" - checksum: 10c0/7d8a6074a343f821f81ea284d700bd34ea6c7abbe8d93bce7aba818948957c1b7f56131702e5e890a5622cdfc05dcebe8aed0b8313bdc6838a594d7846b0b000 +"dayjs@npm:^1.11.20": + version: 1.11.21 + resolution: "dayjs@npm:1.11.21" + checksum: 10c0/bd97dfdc4bfea3c66268635690313828b386faa040fbc1f829ff42a2bd748b72c9d9b3c8f9616ce9e61fcb78923f1461a462c969c54b1084458ae1b715898fb0 + languageName: node + linkType: hard + +"debug@npm:2.6.9, debug@npm:^2.2.0, debug@npm:^2.3.3": + version: 2.6.9 + resolution: "debug@npm:2.6.9" + dependencies: + ms: "npm:2.0.0" + checksum: 10c0/121908fb839f7801180b69a7e218a40b5a0b718813b886b7d6bdb82001b931c938e2941d1e4450f33a1b1df1da653f5f7a0440c197f29fbf8a6e9d45ff6ef589 languageName: node linkType: hard @@ -9246,7 +6378,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": +"debug@npm:4, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.4.0, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -9258,15 +6390,6 @@ __metadata: languageName: node linkType: hard -"debug@npm:^2.2.0, debug@npm:^2.3.3": - version: 2.6.9 - resolution: "debug@npm:2.6.9" - dependencies: - ms: "npm:2.0.0" - checksum: 10c0/121908fb839f7801180b69a7e218a40b5a0b718813b886b7d6bdb82001b931c938e2941d1e4450f33a1b1df1da653f5f7a0440c197f29fbf8a6e9d45ff6ef589 - languageName: node - linkType: hard - "debug@npm:^3.1.0, debug@npm:^3.2.7": version: 3.2.7 resolution: "debug@npm:3.2.7" @@ -9323,15 +6446,15 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.6.0": - version: 1.7.0 - resolution: "dedent@npm:1.7.0" +"dedent@npm:^1.7.2": + version: 1.7.2 + resolution: "dedent@npm:1.7.2" peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: babel-plugin-macros: optional: true - checksum: 10c0/c5e8a8beb5072bd5e520cb64b27a82d7ec3c2a63ee5ce47dbc2a05d5b7700cefd77a992a752cd0a8b1d979c1db06b14fb9486e805f3ad6088eda6e07cd9bf2d5 + checksum: 10c0/acaff07cac355b93f17b1b17ebbb84d3cc55af6ab4b7814c3f505e061903e168bc6bf9ddce331552d64dee1525f0b4c549c9ade46aebfac6f69caaed74e90751 languageName: node linkType: hard @@ -9349,6 +6472,13 @@ __metadata: languageName: node linkType: hard +"deepmerge-ts@npm:^7.1.5": + version: 7.1.5 + resolution: "deepmerge-ts@npm:7.1.5" + checksum: 10c0/3a265a2086f334e3ecf43a7d4138c950cb99e0b39e816fa7fd7f5326161364e51b13010906908212667619066f5b48de738ed42543212323fbbb5d4ed7ebdc84 + languageName: node + linkType: hard + "deepmerge@npm:^3.2.0": version: 3.3.0 resolution: "deepmerge@npm:3.3.0" @@ -9356,13 +6486,6 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.1": - version: 4.3.1 - resolution: "deepmerge@npm:4.3.1" - checksum: 10c0/e53481aaf1aa2c4082b5342be6b6d8ad9dfe387bc92ce197a66dea08bd4265904a087e75e464f14d1347cf2ac8afe1e4c16b266e0561cc5df29382d3c5f80044 - languageName: node - linkType: hard - "defaults@npm:^1.0.3": version: 1.0.4 resolution: "defaults@npm:1.0.4" @@ -9436,7 +6559,7 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0, depd@npm:^2.0.0": +"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: 10c0/58bd06ec20e19529b06f7ad07ddab60e504d9e0faca4bd23079fac2d279c3594334d736508dc350e06e510aba5e22e4594483b3a6562ce7c17dd797f4cc4ad2c @@ -9450,6 +6573,13 @@ __metadata: languageName: node linkType: hard +"destroy@npm:1.2.0, destroy@npm:~1.2.0": + version: 1.2.0 + resolution: "destroy@npm:1.2.0" + checksum: 10c0/bd7633942f57418f5a3b80d5cb53898127bcf53e24cdf5d5f4396be471417671f0fee48a4ebe9a1e9defbde2a31280011af58a57e090ff822f589b443ed4e643 + languageName: node + linkType: hard + "detect-indent@npm:^5.0.0": version: 5.0.0 resolution: "detect-indent@npm:5.0.0" @@ -9464,7 +6594,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.0": +"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.3": version: 2.1.2 resolution: "detect-libc@npm:2.1.2" checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 @@ -9502,13 +6632,6 @@ __metadata: languageName: node linkType: hard -"diff@npm:^4.0.1": - version: 4.0.2 - resolution: "diff@npm:4.0.2" - checksum: 10c0/81b91f9d39c4eaca068eb0c1eb0e4afbdc5bb2941d197f513dd596b820b956fef43485876226d65d497bebc15666aa2aa82c679e84f65d5f2bfbf14ee46e32c1 - languageName: node - linkType: hard - "dir-glob@npm:^2.2.2": version: 2.2.2 resolution: "dir-glob@npm:2.2.2" @@ -9566,7 +6689,7 @@ __metadata: languageName: node linkType: hard -"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0, domelementtype@npm:^2.3.0": +"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0, domelementtype@npm:^2.3.0, domelementtype@npm:~2.3.0": version: 2.3.0 resolution: "domelementtype@npm:2.3.0" checksum: 10c0/686f5a9ef0fff078c1412c05db73a0dce096190036f33e400a07e2a4518e9f56b1e324f5c576a0a747ef0e75b5d985c040b0d51945ce780c0dd3c625a18cd8c9 @@ -9591,7 +6714,7 @@ __metadata: languageName: node linkType: hard -"domhandler@npm:^5.0.2, domhandler@npm:^5.0.3": +"domhandler@npm:^5.0.2, domhandler@npm:^5.0.3, domhandler@npm:~5.0.3": version: 5.0.3 resolution: "domhandler@npm:5.0.3" dependencies: @@ -9611,7 +6734,7 @@ __metadata: languageName: node linkType: hard -"domutils@npm:^3.0.1, domutils@npm:^3.1.0": +"domutils@npm:^3.0.1, domutils@npm:^3.1.0, domutils@npm:^3.2.2": version: 3.2.2 resolution: "domutils@npm:3.2.2" dependencies: @@ -9640,23 +6763,23 @@ __metadata: languageName: node linkType: hard -"dotenv-expand@npm:12.0.1": - version: 12.0.1 - resolution: "dotenv-expand@npm:12.0.1" +"dotenv-expand@npm:13.0.0": + version: 13.0.0 + resolution: "dotenv-expand@npm:13.0.0" dependencies: - dotenv: "npm:^16.4.5" - checksum: 10c0/51996bfa670073d7a441b8fbed26ac991026fba2c05e9a937a898ce7d2a2e7166f7b6ec4eb8879e576defb5d1ad399ed1300db8f803d6977577fea55b4d82dac + dotenv: "npm:^17.4.2" + checksum: 10c0/debebb7807fedebb21078dc661d28d69d373f3a673165afea7f7a8c3b82f0da675e417b2d42c40b1f3d357741c8b24a77d6f4376886092a82456225b3d241b88 languageName: node linkType: hard -"dotenv@npm:16.4.7": - version: 16.4.7 - resolution: "dotenv@npm:16.4.7" - checksum: 10c0/be9f597e36a8daf834452daa1f4cc30e5375a5968f98f46d89b16b983c567398a330580c88395069a77473943c06b877d1ca25b4afafcdd6d4adb549e8293462 +"dotenv@npm:17.4.2, dotenv@npm:^17.4.2": + version: 17.4.2 + resolution: "dotenv@npm:17.4.2" + checksum: 10c0/164f8e77a646c8446867d5b588d26ea6005c8ea7c5eb41cf926f6113d23f2191355f6e0cfd95ea9bab98394a5b0a3f1e51a8399711b666fe55cc7b0bd745f942 languageName: node linkType: hard -"dotenv@npm:^16.4.5, dotenv@npm:^16.4.7": +"dotenv@npm:^16.6.1": version: 16.6.1 resolution: "dotenv@npm:16.6.1" checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc @@ -9730,16 +6853,16 @@ __metadata: linkType: hard "editorconfig@npm:^1.0.4": - version: 1.0.4 - resolution: "editorconfig@npm:1.0.4" + version: 1.0.7 + resolution: "editorconfig@npm:1.0.7" dependencies: "@one-ini/wasm": "npm:0.1.1" commander: "npm:^10.0.0" - minimatch: "npm:9.0.1" + minimatch: "npm:^9.0.1" semver: "npm:^7.5.3" bin: editorconfig: bin/editorconfig - checksum: 10c0/ed6985959d7b34a56e1c09bef118758c81c969489b768d152c93689fce8403b0452462e934f665febaba3478eebc0fd41c0a36100783eaadf6d926c4abc87a3d + checksum: 10c0/5b0f524ae0a406c56e2d3c690656c016e326ae5f01813dfa476ef1cea50a24e6473f1ba6f655e81ea5fde73e9db6782d83e696ec9946c8db083f5e4b6147795a languageName: node linkType: hard @@ -9761,20 +6884,6 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.249": - version: 1.5.252 - resolution: "electron-to-chromium@npm:1.5.252" - checksum: 10c0/a42b164689d4230f1ab8a5e87183b58f5a96e123d2b7b680ca3a94734a04cf6cc4198e813473674ed626623a0cb7cb3ece42373c712a1afc340117ff85845b0f - languageName: node - linkType: hard - -"emittery@npm:^0.13.1": - version: 0.13.1 - resolution: "emittery@npm:0.13.1" - checksum: 10c0/1573d0ae29ab34661b6c63251ff8f5facd24ccf6a823f19417ae8ba8c88ea450325788c67f16c99edec8de4b52ce93a10fe441ece389fd156e88ee7dab9bfa35 - languageName: node - linkType: hard - "emoji-regex@npm:^7.0.1": version: 7.0.3 resolution: "emoji-regex@npm:7.0.3" @@ -9796,7 +6905,14 @@ __metadata: languageName: node linkType: hard -"encodeurl@npm:^2.0.0": +"emojilib@npm:^2.4.0": + version: 2.4.0 + resolution: "emojilib@npm:2.4.0" + checksum: 10c0/6e66ba8921175842193f974e18af448bb6adb0cf7aeea75e08b9d4ea8e9baba0e4a5347b46ed901491dcaba277485891c33a8d70b0560ca5cc9672a94c21ab8f + languageName: node + linkType: hard + +"encodeurl@npm:^2.0.0, encodeurl@npm:~2.0.0": version: 2.0.0 resolution: "encodeurl@npm:2.0.0" checksum: 10c0/5d317306acb13e6590e28e27924c754163946a2480de11865c991a3a7eed4315cd3fba378b543ca145829569eefe9b899f3d84bb09870f675ae60bc924b01ceb @@ -9810,7 +6926,7 @@ __metadata: languageName: node linkType: hard -"encoding@npm:^0.1.11, encoding@npm:^0.1.12, encoding@npm:^0.1.13": +"encoding@npm:^0.1.11, encoding@npm:^0.1.12": version: 0.1.13 resolution: "encoding@npm:0.1.13" dependencies: @@ -9828,16 +6944,6 @@ __metadata: languageName: node linkType: hard -"enhanced-resolve@npm:^5.0.0, enhanced-resolve@npm:^5.17.2, enhanced-resolve@npm:^5.7.0": - version: 5.18.3 - resolution: "enhanced-resolve@npm:5.18.3" - dependencies: - graceful-fs: "npm:^4.2.4" - tapable: "npm:^2.2.0" - checksum: 10c0/d413c23c2d494e4c1c9c9ac7d60b812083dc6d446699ed495e69c920988af0a3c66bf3f8d0e7a45cb1686c2d4c1df9f4e7352d973f5b56fe63d8d711dd0ccc54 - languageName: node - linkType: hard - "entities@npm:^2.0.0": version: 2.2.0 resolution: "entities@npm:2.2.0" @@ -9859,6 +6965,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^7.0.1": + version: 7.0.1 + resolution: "entities@npm:7.0.1" + checksum: 10c0/b4fb9937bb47ecb00aaaceb9db9cdd1cc0b0fb649c0e843d05cf5dbbd2e9d2df8f98721d8b1b286445689c72af7b54a7242fc2d63ef7c9739037a8c73363e7ca + languageName: node + linkType: hard + "env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -9867,11 +6980,11 @@ __metadata: linkType: hard "envinfo@npm:^7.3.1": - version: 7.20.0 - resolution: "envinfo@npm:7.20.0" + version: 7.21.0 + resolution: "envinfo@npm:7.21.0" bin: envinfo: dist/cli.js - checksum: 10c0/2afa8085f9952d3afe6893098ef9cadc991aa38ed5ed5a0fd953ddb72a7543f425fbf46e8c02c4fa0ecad3c03a93381b0a212f799c2a8db8dc8886d8d7d5dc05 + checksum: 10c0/4170127ca72dbf85be2c114f85558bd08178e8a43b394951ba9fd72d067c6fea3374df45a7b040e39e4e7b30bdd268e5bdf8661d99ae28302c2a88dedb41b5e6 languageName: node linkType: hard @@ -9898,9 +7011,21 @@ __metadata: languageName: node linkType: hard -"es-abstract@npm:^1.23.2, es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.9, es-abstract@npm:^1.24.0": - version: 1.24.0 - resolution: "es-abstract@npm:1.24.0" +"es-abstract-get@npm:^1.0.0": + version: 1.0.0 + resolution: "es-abstract-get@npm:1.0.0" + dependencies: + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.2" + is-callable: "npm:^1.2.7" + object-inspect: "npm:^1.13.4" + checksum: 10c0/f9b4838ae719752207383a6d95a74590f891122bf26b92f5e72eeedbe53771029e4561f1cf75ea19330b71bcf3d4f536fb0c8f7e2b601fe24d284f46e488c7e3 + languageName: node + linkType: hard + +"es-abstract@npm:^1.23.2, es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.9, es-abstract@npm:^1.24.0, es-abstract@npm:^1.24.2": + version: 1.24.2 + resolution: "es-abstract@npm:1.24.2" dependencies: array-buffer-byte-length: "npm:^1.0.2" arraybuffer.prototype.slice: "npm:^1.0.4" @@ -9956,7 +7081,7 @@ __metadata: typed-array-length: "npm:^1.0.7" unbox-primitive: "npm:^1.1.0" which-typed-array: "npm:^1.1.19" - checksum: 10c0/b256e897be32df5d382786ce8cce29a1dd8c97efbab77a26609bd70f2ed29fbcfc7a31758cb07488d532e7ccccdfca76c1118f2afe5a424cdc05ca007867c318 + checksum: 10c0/67a5bf21ef5c7d775e6f6131a836323900b4d87194cf544394ac68fe31c57fa53828b978af4a4f551ef307f83a2f910a16b6b982760ad3ddc3dc471f98d5fd1b languageName: node linkType: hard @@ -9981,19 +7106,19 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^1.2.1": - version: 1.7.0 - resolution: "es-module-lexer@npm:1.7.0" - checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b +"es-module-lexer@npm:^2.0.0": + version: 2.2.0 + resolution: "es-module-lexer@npm:2.2.0" + checksum: 10c0/a20547903d389031f383afe2c4770e5402f70afb8e5c69562427cb4c384bfb806aa24e8b719b75f432bae327c9db2abb1f227c907bb3bc3d9d9350d42d6f91a3 languageName: node linkType: hard -"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": - version: 1.1.1 - resolution: "es-object-atoms@npm:1.1.1" +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1, es-object-atoms@npm:^1.1.2": + version: 1.1.2 + resolution: "es-object-atoms@npm:1.1.2" dependencies: es-errors: "npm:^1.3.0" - checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c + checksum: 10c0/1772861f094f739d6f41b579cfb9a18579daffeb434552a370a5fbef50a32d22227e27b63fdbb757b7ddd429d1b42fe52ccae7966d9302a2ec221b6f1b41bbc4 languageName: node linkType: hard @@ -10019,13 +7144,29 @@ __metadata: linkType: hard "es-to-primitive@npm:^1.3.0": - version: 1.3.0 - resolution: "es-to-primitive@npm:1.3.0" + version: 1.3.1 + resolution: "es-to-primitive@npm:1.3.1" dependencies: + es-abstract-get: "npm:^1.0.0" + es-errors: "npm:^1.3.0" is-callable: "npm:^1.2.7" - is-date-object: "npm:^1.0.5" - is-symbol: "npm:^1.0.4" - checksum: 10c0/c7e87467abb0b438639baa8139f701a06537d2b9bc758f23e8622c3b42fd0fdb5bde0f535686119e446dd9d5e4c0f238af4e14960f4771877cf818d023f6730b + is-date-object: "npm:^1.1.0" + is-symbol: "npm:^1.1.1" + checksum: 10c0/288ec25e5d08c2718bab9faa87924e11f42f2b0b59854fc241f1fbbedcadf2682ab3a9a9fb4676fa40c483ce563ea63cfd3e33777cbb53833e94d5f60a851cde + languageName: node + linkType: hard + +"es-toolkit@npm:1.51.0": + version: 1.51.0 + resolution: "es-toolkit@npm:1.51.0" + dependenciesMeta: + "@trivago/prettier-plugin-sort-imports@4.3.0": + unplugged: true + prettier-plugin-sort-re-exports@0.0.1: + unplugged: true + vitepress-plugin-sandpack@1.1.4: + unplugged: true + checksum: 10c0/ad519228a317a27b11144b582c1168c552143dd58ce4e62be50c185de48c69dea996f6ad6ebc518129ebd58c34da150c1a2134d4a3d7cdc75120c626e57ce7c4 languageName: node linkType: hard @@ -10045,7 +7186,7 @@ __metadata: languageName: node linkType: hard -"escalade@npm:^3.1.1, escalade@npm:^3.2.0": +"escalade@npm:^3.1.1": version: 3.2.0 resolution: "escalade@npm:3.2.0" checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 @@ -10059,7 +7200,7 @@ __metadata: languageName: node linkType: hard -"escape-html@npm:^1.0.3": +"escape-html@npm:^1.0.3, escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" checksum: 10c0/524c739d776b36c3d29fa08a22e03e8824e3b2fd57500e5e44ecf3cc4707c34c60f9ca0781c0e33d191f2991161504c295e98f68c78fe7baa6e57081ec6ac0a3 @@ -10080,13 +7221,6 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:^2.0.0": - version: 2.0.0 - resolution: "escape-string-regexp@npm:2.0.0" - checksum: 10c0/2530479fe8db57eace5e8646c9c2a9c80fa279614986d16dcc6bcaceb63ae77f05a851ba6c43756d816c61d7f4534baf56e3c705e3e0d884818a46808811c507 - languageName: node - linkType: hard - "escape-string-regexp@npm:^4.0.0": version: 4.0.0 resolution: "escape-string-regexp@npm:4.0.0" @@ -10117,17 +7251,17 @@ __metadata: linkType: hard "eslint-import-resolver-node@npm:^0.3.9": - version: 0.3.9 - resolution: "eslint-import-resolver-node@npm:0.3.9" + version: 0.3.10 + resolution: "eslint-import-resolver-node@npm:0.3.10" dependencies: debug: "npm:^3.2.7" - is-core-module: "npm:^2.13.0" - resolve: "npm:^1.22.4" - checksum: 10c0/0ea8a24a72328a51fd95aa8f660dcca74c1429806737cf10261ab90cfcaaf62fd1eff664b76a44270868e0a932711a81b250053942595bcd00a93b1c1575dd61 + is-core-module: "npm:^2.16.1" + resolve: "npm:^2.0.0-next.6" + checksum: 10c0/2e05bdb148fe10a25b9a6fec3c4986a2e09e98bb99208491df82a9df7725f7bb312482d585404c440d42e58ab60debe7a48d9c992191851385b18d33a146e3c3 languageName: node linkType: hard -"eslint-module-utils@npm:2.12.1, eslint-module-utils@npm:^2.12.1": +"eslint-module-utils@npm:2.12.1": version: 2.12.1 resolution: "eslint-module-utils@npm:2.12.1" dependencies: @@ -10139,6 +7273,18 @@ __metadata: languageName: node linkType: hard +"eslint-module-utils@npm:^2.12.1": + version: 2.13.0 + resolution: "eslint-module-utils@npm:2.13.0" + dependencies: + debug: "npm:^3.2.7" + peerDependenciesMeta: + eslint: + optional: true + checksum: 10c0/9d3c9df4b515f57dec1e4176bfee7c25ab560d28128ab7169894629d21f962f17b811d830c11b81f6687834327a899494ddab62f32cac409d365a37e99808552 + languageName: node + linkType: hard + "eslint-plugin-import@npm:^2.32.0": version: 2.32.0 resolution: "eslint-plugin-import@npm:2.32.0" @@ -10169,17 +7315,17 @@ __metadata: linkType: hard "eslint-plugin-jsdoc@npm:^61.2.1": - version: 61.2.1 - resolution: "eslint-plugin-jsdoc@npm:61.2.1" + version: 61.7.1 + resolution: "eslint-plugin-jsdoc@npm:61.7.1" dependencies: - "@es-joy/jsdoccomment": "npm:~0.76.0" + "@es-joy/jsdoccomment": "npm:~0.78.0" "@es-joy/resolve.exports": "npm:1.2.0" are-docs-informative: "npm:^0.0.2" comment-parser: "npm:1.4.1" debug: "npm:^4.4.3" escape-string-regexp: "npm:^4.0.0" - espree: "npm:^10.4.0" - esquery: "npm:^1.6.0" + espree: "npm:^11.0.0" + esquery: "npm:^1.7.0" html-entities: "npm:^2.6.0" object-deep-merge: "npm:^2.0.0" parse-imports-exports: "npm:^0.2.4" @@ -10188,16 +7334,16 @@ __metadata: to-valid-identifier: "npm:^1.0.0" peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 - checksum: 10c0/c2288ab5cffcb8a21adc4b7baa26d253c616463367d52157bdcce322135fd9d141fa5f0d27bd1a8c9d93db16dc1727d9a5c58bf6d2008ed0849e6c4b2f811384 + checksum: 10c0/d0904b923f68a4e9e6da156316a4e2a972445bf79118bde9618ad80b4ef5927fc2c9dd597b22b776742ef548d65914e75fca190ab3be942385f268a3b83c1087 languageName: node linkType: hard "eslint-plugin-prettier@npm:^5.2.1, eslint-plugin-prettier@npm:^5.5.4": - version: 5.5.4 - resolution: "eslint-plugin-prettier@npm:5.5.4" + version: 5.5.6 + resolution: "eslint-plugin-prettier@npm:5.5.6" dependencies: - prettier-linter-helpers: "npm:^1.0.0" - synckit: "npm:^0.11.7" + prettier-linter-helpers: "npm:^1.0.1" + synckit: "npm:^0.11.13" peerDependencies: "@types/eslint": ">=8.0.0" eslint: ">=8.0.0" @@ -10208,28 +7354,18 @@ __metadata: optional: true eslint-config-prettier: optional: true - checksum: 10c0/5cc780e0ab002f838ad8057409e86de4ff8281aa2704a50fa8511abff87028060c2e45741bc9cbcbd498712e8d189de8026e70aed9e20e50fe5ba534ee5a8442 + checksum: 10c0/af37126c947ff3e87ff1ea76408db8cb1c7da966ebdaba68c6dd5924da7eb1b43b1abc4796d753a97b1bc26ea94c5497093e6ab9f8588fffe558d5a554e19bbf languageName: node linkType: hard "eslint-plugin-tsdoc@npm:^0.5.0": - version: 0.5.0 - resolution: "eslint-plugin-tsdoc@npm:0.5.0" + version: 0.5.2 + resolution: "eslint-plugin-tsdoc@npm:0.5.2" dependencies: "@microsoft/tsdoc": "npm:0.16.0" - "@microsoft/tsdoc-config": "npm:0.18.0" - "@typescript-eslint/utils": "npm:~8.46.0" - checksum: 10c0/f810ed29740da2f0ea32c07de386b56a8e26533d768a056defed2a9de26246b98272991ad55e3169cffa5f391562ad4849ecda0245e3029821ff3289ddda8169 - languageName: node - linkType: hard - -"eslint-scope@npm:5.1.1": - version: 5.1.1 - resolution: "eslint-scope@npm:5.1.1" - dependencies: - esrecurse: "npm:^4.3.0" - estraverse: "npm:^4.1.1" - checksum: 10c0/d30ef9dc1c1cbdece34db1539a4933fe3f9b14e1ffb27ecc85987902ee663ad7c9473bbd49a9a03195a373741e62e2f807c4938992e019b511993d163450e70a + "@microsoft/tsdoc-config": "npm:0.18.1" + "@typescript-eslint/utils": "npm:~8.56.0" + checksum: 10c0/ce6c9f7776c06dbf21273096d4a827ad99378966ed834026b0248390b4a2ea5ce0731623a48835989d13e0aea48a4ffde613449a4c1393ff3ab4bd5ad7fdf0f6 languageName: node linkType: hard @@ -10257,23 +7393,30 @@ __metadata: languageName: node linkType: hard +"eslint-visitor-keys@npm:^5.0.0, eslint-visitor-keys@npm:^5.0.1": + version: 5.0.1 + resolution: "eslint-visitor-keys@npm:5.0.1" + checksum: 10c0/16190bdf2cbae40a1109384c94450c526a79b0b9c3cb21e544256ed85ac48a4b84db66b74a6561d20fe6ab77447f150d711c2ad5ad74df4fcc133736bce99678 + languageName: node + linkType: hard + "eslint@npm:^9.39.1": - version: 9.39.1 - resolution: "eslint@npm:9.39.1" + version: 9.39.4 + resolution: "eslint@npm:9.39.4" dependencies: "@eslint-community/eslint-utils": "npm:^4.8.0" "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.21.1" + "@eslint/config-array": "npm:^0.21.2" "@eslint/config-helpers": "npm:^0.4.2" "@eslint/core": "npm:^0.17.0" - "@eslint/eslintrc": "npm:^3.3.1" - "@eslint/js": "npm:9.39.1" + "@eslint/eslintrc": "npm:^3.3.5" + "@eslint/js": "npm:9.39.4" "@eslint/plugin-kit": "npm:^0.4.1" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" "@humanwhocodes/retry": "npm:^0.4.2" "@types/estree": "npm:^1.0.6" - ajv: "npm:^6.12.4" + ajv: "npm:^6.14.0" chalk: "npm:^4.0.0" cross-spawn: "npm:^7.0.6" debug: "npm:^4.3.2" @@ -10292,7 +7435,7 @@ __metadata: is-glob: "npm:^4.0.0" json-stable-stringify-without-jsonify: "npm:^1.0.1" lodash.merge: "npm:^4.6.2" - minimatch: "npm:^3.1.2" + minimatch: "npm:^3.1.5" natural-compare: "npm:^1.4.0" optionator: "npm:^0.9.3" peerDependencies: @@ -10302,7 +7445,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10c0/59b2480639404ba24578ca480f973683b87b7aac8aa7e349240474a39067804fd13cd8b9cb22fee074170b8c7c563b57bab703ec0f0d3f81ea017e5d2cad299d + checksum: 10c0/1955067c2d991f0c84f4c4abfafe31bb47fa3b717a7fd3e43fe1e511c6f859d7700cbca969f85661dc4c130f7aeced5e5444884314198a54428f5e5141db9337 languageName: node linkType: hard @@ -10317,6 +7460,17 @@ __metadata: languageName: node linkType: hard +"espree@npm:^11.0.0": + version: 11.2.0 + resolution: "espree@npm:11.2.0" + dependencies: + acorn: "npm:^8.16.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^5.0.1" + checksum: 10c0/cf87e18ffd9dc113eb8d16588e7757701bc10c9934a71cce8b89c2611d51672681a918307bd6b19ac3ccd0e7ba1cbccc2f815b36b52fa7e73097b251014c3d81 + languageName: node + linkType: hard + "espree@npm:^9.0.0": version: 9.6.1 resolution: "espree@npm:9.6.1" @@ -10348,12 +7502,12 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.5.0, esquery@npm:^1.6.0": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" +"esquery@npm:^1.5.0, esquery@npm:^1.6.0, esquery@npm:^1.7.0": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" dependencies: estraverse: "npm:^5.1.0" - checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 languageName: node linkType: hard @@ -10373,13 +7527,6 @@ __metadata: languageName: node linkType: hard -"estraverse@npm:^4.1.1": - version: 4.3.0 - resolution: "estraverse@npm:4.3.0" - checksum: 10c0/9cb46463ef8a8a4905d3708a652d60122a0c20bb58dec7e0e12ab0e7235123d74214fc0141d743c381813e1b992767e2708194f6f6e0f9fd00c1b4e0887b8b6d - languageName: node - linkType: hard - "estraverse@npm:^5.1.0, estraverse@npm:^5.2.0": version: 5.3.0 resolution: "estraverse@npm:5.3.0" @@ -10387,6 +7534,15 @@ __metadata: languageName: node linkType: hard +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + "esutils@npm:^2.0.2": version: 2.0.3 resolution: "esutils@npm:2.0.3" @@ -10394,20 +7550,13 @@ __metadata: languageName: node linkType: hard -"etag@npm:^1.8.1": +"etag@npm:^1.8.1, etag@npm:~1.8.1": version: 1.8.1 resolution: "etag@npm:1.8.1" checksum: 10c0/12be11ef62fb9817314d790089a0a49fae4e1b50594135dcb8076312b7d7e470884b5100d249b28c18581b7fd52f8b485689ffae22a11ed9ec17377a33a08f84 languageName: node linkType: hard -"eventemitter2@npm:^6.4.9": - version: 6.4.9 - resolution: "eventemitter2@npm:6.4.9" - checksum: 10c0/b2adf7d9f1544aa2d95ee271b0621acaf1e309d85ebcef1244fb0ebc7ab0afa6ffd5e371535d0981bc46195ad67fd6ff57a8d1db030584dee69aa5e371a27ea7 - languageName: node - linkType: hard - "eventemitter3@npm:^3.1.0": version: 3.1.2 resolution: "eventemitter3@npm:3.1.2" @@ -10415,13 +7564,6 @@ __metadata: languageName: node linkType: hard -"events@npm:^3.2.0": - version: 3.3.0 - resolution: "events@npm:3.3.0" - checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 - languageName: node - linkType: hard - "execa@npm:^0.10.0": version: 0.10.0 resolution: "execa@npm:0.10.0" @@ -10452,30 +7594,6 @@ __metadata: languageName: node linkType: hard -"execa@npm:^5.1.1": - version: 5.1.1 - resolution: "execa@npm:5.1.1" - dependencies: - cross-spawn: "npm:^7.0.3" - get-stream: "npm:^6.0.0" - human-signals: "npm:^2.1.0" - is-stream: "npm:^2.0.0" - merge-stream: "npm:^2.0.0" - npm-run-path: "npm:^4.0.1" - onetime: "npm:^5.1.2" - signal-exit: "npm:^3.0.3" - strip-final-newline: "npm:^2.0.0" - checksum: 10c0/c8e615235e8de4c5addf2fa4c3da3e3aa59ce975a3e83533b4f6a71750fb816a2e79610dc5f1799b6e28976c9ae86747a36a606655bf8cb414a74d8d507b304f - languageName: node - linkType: hard - -"exit-x@npm:^0.2.2": - version: 0.2.2 - resolution: "exit-x@npm:0.2.2" - checksum: 10c0/212a7a095ca5540e9581f1ef2d1d6a40df7a6027c8cc96e78ce1d16b86d1a88326d4a0eff8dff2b5ec1e68bb0c1edd5d0dfdde87df1869bf7514d4bc6a5cbd72 - languageName: node - linkType: hard - "expand-brackets@npm:^2.1.4": version: 2.1.4 resolution: "expand-brackets@npm:2.1.4" @@ -10498,17 +7616,10 @@ __metadata: languageName: node linkType: hard -"expect@npm:30.2.0": - version: 30.2.0 - resolution: "expect@npm:30.2.0" - dependencies: - "@jest/expect-utils": "npm:30.2.0" - "@jest/get-type": "npm:30.1.0" - jest-matcher-utils: "npm:30.2.0" - jest-message-util: "npm:30.2.0" - jest-mock: "npm:30.2.0" - jest-util: "npm:30.2.0" - checksum: 10c0/fe440b3a036e2de1a3ede84bc6a699925328056e74324fbd2fdd9ce7b7358d03e515ac8db559c33828bcb0b7887b493dbaaece565e67d88748685850da5d9209 +"expect-type@npm:^1.3.0": + version: 1.4.0 + resolution: "expect-type@npm:1.4.0" + checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7 languageName: node linkType: hard @@ -10519,24 +7630,18 @@ __metadata: languageName: node linkType: hard -"express-serve-static-core@npm:^0.1.1": - version: 0.1.1 - resolution: "express-serve-static-core@npm:0.1.1" - checksum: 10c0/ca423f71ee2dfd0f39bf5b0d18c71d4c06e3d823c884b215ba8ddd9ba9cc80812159d9be01a9a45a1bbc77f06c96bde8e6220983aa6561de591d806c9426362d - languageName: node - linkType: hard - -"express@npm:5.1.0": - version: 5.1.0 - resolution: "express@npm:5.1.0" +"express@npm:5.2.1": + version: 5.2.1 + resolution: "express@npm:5.2.1" dependencies: accepts: "npm:^2.0.0" - body-parser: "npm:^2.2.0" + body-parser: "npm:^2.2.1" content-disposition: "npm:^1.0.0" content-type: "npm:^1.0.5" cookie: "npm:^0.7.1" cookie-signature: "npm:^1.2.1" debug: "npm:^4.4.0" + depd: "npm:^2.0.0" encodeurl: "npm:^2.0.0" escape-html: "npm:^1.0.3" etag: "npm:^1.8.1" @@ -10557,7 +7662,46 @@ __metadata: statuses: "npm:^2.0.1" type-is: "npm:^2.0.1" vary: "npm:^1.1.2" - checksum: 10c0/80ce7c53c5f56887d759b94c3f2283e2e51066c98d4b72a4cc1338e832b77f1e54f30d0239cc10815a0f849bdb753e6a284d2fa48d4ab56faf9c501f55d751d6 + checksum: 10c0/45e8c841ad188a41402ddcd1294901e861ee0819f632fb494f2ed344ef9c43315d294d443fb48d594e6586a3b779785120f43321417adaef8567316a55072949 + languageName: node + linkType: hard + +"express@npm:^4.21.0": + version: 4.22.2 + resolution: "express@npm:4.22.2" + dependencies: + accepts: "npm:~1.3.8" + array-flatten: "npm:1.1.1" + body-parser: "npm:~1.20.5" + content-disposition: "npm:~0.5.4" + content-type: "npm:~1.0.4" + cookie: "npm:~0.7.1" + cookie-signature: "npm:~1.0.6" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + finalhandler: "npm:~1.3.1" + fresh: "npm:~0.5.2" + http-errors: "npm:~2.0.0" + merge-descriptors: "npm:1.0.3" + methods: "npm:~1.1.2" + on-finished: "npm:~2.4.1" + parseurl: "npm:~1.3.3" + path-to-regexp: "npm:~0.1.12" + proxy-addr: "npm:~2.0.7" + qs: "npm:~6.15.1" + range-parser: "npm:~1.2.1" + safe-buffer: "npm:5.2.1" + send: "npm:~0.19.0" + serve-static: "npm:~1.16.2" + setprototypeof: "npm:1.2.0" + statuses: "npm:~2.0.1" + type-is: "npm:~1.6.18" + utils-merge: "npm:1.0.1" + vary: "npm:~1.1.2" + checksum: 10c0/d06dd4379fd217440b30f8abbe45f0e74931114c1395034f03e7d635196ecdab530d4835a1962a6aa34838d61967dc6f1f77846999bba3032373e9e714222c44 languageName: node linkType: hard @@ -10635,20 +7779,6 @@ __metadata: languageName: node linkType: hard -"fast-content-type-parse@npm:^1.0.0": - version: 1.1.0 - resolution: "fast-content-type-parse@npm:1.1.0" - checksum: 10c0/882bf990fa5d64be1825ce183818db43900ece0d7ef184cb9409bae8ed1001acbe536a657b1496382cb3e308e71ab39cc399bbdae70cba1745eecaeca4e55384 - languageName: node - linkType: hard - -"fast-decode-uri-component@npm:^1.0.1": - version: 1.0.1 - resolution: "fast-decode-uri-component@npm:1.0.1" - checksum: 10c0/039d50c2e99d64f999c3f2126c23fbf75a04a4117e218a149ca0b1d2aeb8c834b7b19d643b9d35d4eabce357189a6a94085f78cf48869e6e26cc59b036284bc3 - languageName: node - linkType: hard - "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -10677,38 +7807,13 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.3.2": - version: 3.3.3 - resolution: "fast-glob@npm:3.3.3" - dependencies: - "@nodelib/fs.stat": "npm:^2.0.2" - "@nodelib/fs.walk": "npm:^1.2.3" - glob-parent: "npm:^5.1.2" - merge2: "npm:^1.3.0" - micromatch: "npm:^4.0.8" - checksum: 10c0/f6aaa141d0d3384cf73cbcdfc52f475ed293f6d5b65bfc5def368b09163a9f7e5ec2b3014d80f733c405f58e470ee0cc451c2937685045cddcdeaa24199c43fe - languageName: node - linkType: hard - -"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": +"fast-json-stable-stringify@npm:^2.0.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" checksum: 10c0/7f081eb0b8a64e0057b3bb03f974b3ef00135fbf36c1c710895cd9300f13c94ba809bb3a81cf4e1b03f6e5285610a61abbd7602d0652de423144dfee5a389c9b languageName: node linkType: hard -"fast-json-stringify@npm:^2.5.2": - version: 2.7.13 - resolution: "fast-json-stringify@npm:2.7.13" - dependencies: - ajv: "npm:^6.11.0" - deepmerge: "npm:^4.2.2" - rfdc: "npm:^1.2.0" - string-similarity: "npm:^4.0.1" - checksum: 10c0/9c63e9e575bd75153afe456bd5c1d8afbfade79c79578f89fc0b8d599cdd65f3e731c826241caf472e11a915e6351a4c4c3f8295096be3fb01a79d2dbbbb22ed - languageName: node - linkType: hard - "fast-levenshtein@npm:^2.0.6": version: 2.0.6 resolution: "fast-levenshtein@npm:2.0.6" @@ -10716,77 +7821,42 @@ __metadata: languageName: node linkType: hard -"fast-redact@npm:^3.0.0": - version: 3.5.0 - resolution: "fast-redact@npm:3.5.0" - checksum: 10c0/7e2ce4aad6e7535e0775bf12bd3e4f2e53d8051d8b630e0fa9e67f68cb0b0e6070d2f7a94b1d0522ef07e32f7c7cda5755e2b677a6538f1e9070ca053c42343a - languageName: node - linkType: hard - -"fast-safe-stringify@npm:2.1.1, fast-safe-stringify@npm:^2.0.8, fast-safe-stringify@npm:^2.1.1": +"fast-safe-stringify@npm:2.1.1, fast-safe-stringify@npm:^2.1.1": version: 2.1.1 resolution: "fast-safe-stringify@npm:2.1.1" checksum: 10c0/d90ec1c963394919828872f21edaa3ad6f1dddd288d2bd4e977027afff09f5db40f94e39536d4646f7e01761d704d72d51dce5af1b93717f3489ef808f5f4e4d languageName: node linkType: hard -"fast-uri@npm:^3.0.1": - version: 3.1.0 - resolution: "fast-uri@npm:3.1.0" - checksum: 10c0/44364adca566f70f40d1e9b772c923138d47efeac2ae9732a872baafd77061f26b097ba2f68f0892885ad177becd065520412b8ffeec34b16c99433c5b9e2de7 +"fast-string-truncated-width@npm:^3.0.2": + version: 3.0.3 + resolution: "fast-string-truncated-width@npm:3.0.3" + checksum: 10c0/043b8663397d14a3880ce4f3407bcda60b40db9bbeafe62863a35d1f9c69ea17c8da3fcd72de235553e6c9cd053128cde9e24ca0d4a7463208f48db3cd23d981 languageName: node linkType: hard -"fast-xml-parser@npm:5.2.5": - version: 5.2.5 - resolution: "fast-xml-parser@npm:5.2.5" +"fast-string-width@npm:^3.0.2": + version: 3.0.2 + resolution: "fast-string-width@npm:3.0.2" dependencies: - strnum: "npm:^2.1.0" - bin: - fxparser: src/cli/cli.js - checksum: 10c0/d1057d2e790c327ccfc42b872b91786a4912a152d44f9507bf053f800102dfb07ece3da0a86b33ff6a0caa5a5cad86da3326744f6ae5efb0c6c571d754fe48cd - languageName: node - linkType: hard - -"fastify@npm:^3.29.5": - version: 3.29.5 - resolution: "fastify@npm:3.29.5" - dependencies: - "@fastify/ajv-compiler": "npm:^1.0.0" - "@fastify/error": "npm:^2.0.0" - abstract-logging: "npm:^2.0.0" - avvio: "npm:^7.1.2" - fast-content-type-parse: "npm:^1.0.0" - fast-json-stringify: "npm:^2.5.2" - find-my-way: "npm:^4.5.0" - flatstr: "npm:^1.0.12" - light-my-request: "npm:^4.2.0" - pino: "npm:^6.13.0" - process-warning: "npm:^1.0.0" - proxy-addr: "npm:^2.0.7" - rfdc: "npm:^1.1.4" - secure-json-parse: "npm:^2.0.0" - semver: "npm:^7.3.2" - tiny-lru: "npm:^8.0.1" - checksum: 10c0/83a4850c38b1ac8934eff07030f2d0b5ed37b190cbaf05d22b3ca93f00e4735dfe62e48f183bcc5af76b6697512a32e692e6e1765cf51c69819216629f125b3b + fast-string-truncated-width: "npm:^3.0.2" + checksum: 10c0/c8822d175315bb353ebe782b65214ac53b13e3bf704e03b132ea7bdfa8de6a636375b3ab7a4097545393d109381c37c4f387c72a462c90b61412dbc4632f39a7 languageName: node linkType: hard -"fastq@npm:^1.6.0, fastq@npm:^1.6.1": - version: 1.19.1 - resolution: "fastq@npm:1.19.1" - dependencies: - reusify: "npm:^1.0.4" - checksum: 10c0/ebc6e50ac7048daaeb8e64522a1ea7a26e92b3cee5cd1c7f2316cdca81ba543aa40a136b53891446ea5c3a67ec215fbaca87ad405f102dd97012f62916905630 +"fast-uri@npm:^3.0.1": + version: 3.1.2 + resolution: "fast-uri@npm:3.1.2" + checksum: 10c0/5b35641895959f3f7ab7a7b1b5542bded159346f25ec9f256817b206d50b64eda5828e90d605a2e2fc645c90519a7259c2bab2c942ee728c88b88e5be21b090d languageName: node linkType: hard -"fb-watchman@npm:^2.0.2": - version: 2.0.2 - resolution: "fb-watchman@npm:2.0.2" +"fast-wrap-ansi@npm:^0.2.0": + version: 0.2.2 + resolution: "fast-wrap-ansi@npm:0.2.2" dependencies: - bser: "npm:2.1.1" - checksum: 10c0/feae89ac148adb8f6ae8ccd87632e62b13563e6fb114cacb5265c51f585b17e2e268084519fb2edd133872f1d47a18e6bfd7e5e08625c0d41b93149694187581 + fast-string-width: "npm:^3.0.2" + checksum: 10c0/1aa7be4f7cb86f4bdb14691cb6bcc0b8df8b3b89df142ade3ae1602332dcf6f990cd750a923cd581ca0847808cb4ec1aa5afaafa7a72f849e87a2a62c98fa370 languageName: node linkType: hard @@ -10802,13 +7872,6 @@ __metadata: languageName: node linkType: hard -"fflate@npm:^0.8.2": - version: 0.8.2 - resolution: "fflate@npm:0.8.2" - checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293 - languageName: node - linkType: hard - "figgy-pudding@npm:^3.4.1, figgy-pudding@npm:^3.5.1": version: 3.5.2 resolution: "figgy-pudding@npm:3.5.2" @@ -10843,15 +7906,15 @@ __metadata: languageName: node linkType: hard -"file-type@npm:21.1.0": - version: 21.1.0 - resolution: "file-type@npm:21.1.0" +"file-type@npm:22.0.2": + version: 22.0.2 + resolution: "file-type@npm:22.0.2" dependencies: - "@tokenizer/inflate": "npm:^0.3.1" - strtok3: "npm:^10.3.1" - token-types: "npm:^6.0.0" - uint8array-extras: "npm:^1.4.0" - checksum: 10c0/e48676b147ada39b57827f933ac6ba85eb56257a8368c7efb5ae31f68496cf61a9bb42c79b84e6c0ac593ced1fc38b78312932457d148b61db6df7738493e0e7 + "@tokenizer/inflate": "npm:^0.4.1" + strtok3: "npm:^10.3.5" + token-types: "npm:^6.1.2" + uint8array-extras: "npm:^1.5.0" + checksum: 10c0/1ad23ecb6afd60ea59046b213ac834c51ab0706e185c9509c5a2adce290db05cc4672b197390268a145ea489d489768e10cfd7df802da2b492a45a0aa0231be8 languageName: node linkType: hard @@ -10863,11 +7926,11 @@ __metadata: linkType: hard "filelist@npm:^1.0.4": - version: 1.0.4 - resolution: "filelist@npm:1.0.4" + version: 1.0.6 + resolution: "filelist@npm:1.0.6" dependencies: minimatch: "npm:^5.0.1" - checksum: 10c0/426b1de3944a3d153b053f1c0ebfd02dccd0308a4f9e832ad220707a6d1f1b3c9784d6cadf6b2f68f09a57565f63ebc7bcdc913ccf8012d834f472c46e596f41 + checksum: 10c0/6ee725bec3e1936d680a45f14439b224d9f7c71658c145addcf551dd82f03d608522eb6b191aa086b392bc3e52ed4ce0ed8d78e24b203e6c5e867560a05d1121 languageName: node linkType: hard @@ -10900,8 +7963,8 @@ __metadata: linkType: hard "finalhandler@npm:^2.1.0": - version: 2.1.0 - resolution: "finalhandler@npm:2.1.0" + version: 2.1.1 + resolution: "finalhandler@npm:2.1.1" dependencies: debug: "npm:^4.4.0" encodeurl: "npm:^2.0.0" @@ -10909,19 +7972,22 @@ __metadata: on-finished: "npm:^2.4.1" parseurl: "npm:^1.3.3" statuses: "npm:^2.0.1" - checksum: 10c0/da0bbca6d03873472ee890564eb2183f4ed377f25f3628a0fc9d16dac40bed7b150a0d82ebb77356e4c6d97d2796ad2dba22948b951dddee2c8768b0d1b9fb1f + checksum: 10c0/6bd664e21b7b2e79efcaace7d1a427169f61cce048fae68eb56290e6934e676b78e55d89f5998c5508871345bc59a61f47002dc505dc7288be68cceac1b701e2 languageName: node linkType: hard -"find-my-way@npm:^4.5.0": - version: 4.5.1 - resolution: "find-my-way@npm:4.5.1" +"finalhandler@npm:~1.3.1": + version: 1.3.2 + resolution: "finalhandler@npm:1.3.2" dependencies: - fast-decode-uri-component: "npm:^1.0.1" - fast-deep-equal: "npm:^3.1.3" - safe-regex2: "npm:^2.0.0" - semver-store: "npm:^0.3.0" - checksum: 10c0/3e56f50befde96d4b0a0a8118bd7fe283184e54ed61ac359ce62e7229229ab8b4a5aebe02edfcc126f0b6eef38660b59347293d61752bc845aae823cace8042a + debug: "npm:2.6.9" + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + on-finished: "npm:~2.4.1" + parseurl: "npm:~1.3.3" + statuses: "npm:~2.0.2" + unpipe: "npm:~1.0.0" + checksum: 10c0/435a4fd65e4e4e4c71bb5474980090b73c353a123dd415583f67836bdd6516e528cf07298e219a82b94631dee7830eae5eece38d3c178073cf7df4e8c182f413 languageName: node linkType: hard @@ -10953,7 +8019,7 @@ __metadata: languageName: node linkType: hard -"find-up@npm:^4.0.0, find-up@npm:^4.1.0": +"find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" dependencies: @@ -11010,17 +8076,10 @@ __metadata: languageName: node linkType: hard -"flatstr@npm:^1.0.12": - version: 1.0.12 - resolution: "flatstr@npm:1.0.12" - checksum: 10c0/f99cf801fd3606e8b4aa96b93ec09caab42bc304526ff55a80db03db0ef73c9a014e983a6d72009c4f1bc50e2483d137041fae18a325dc0d851d045c4d6929a9 - languageName: node - linkType: hard - "flatted@npm:^3.2.9": - version: 3.3.3 - resolution: "flatted@npm:3.3.3" - checksum: 10c0/e957a1c6b0254aa15b8cce8533e24165abd98fadc98575db082b786b5da1b7d72062b81bfdcd1da2f4d46b6ed93bec2434e62333e9b4261d79ef2e75a10dd538 + version: 3.4.2 + resolution: "flatted@npm:3.4.2" + checksum: 10c0/a65b67aae7172d6cdf63691be7de6c5cd5adbdfdfe2e9da1a09b617c9512ed794037741ee53d93114276bff3f93cd3b0d97d54f9b316e1e4885dde6e9ffdf7ed languageName: node linkType: hard @@ -11034,16 +8093,6 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.6": - version: 1.15.11 - resolution: "follow-redirects@npm:1.15.11" - peerDependenciesMeta: - debug: - optional: true - checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343 - languageName: node - linkType: hard - "for-each@npm:^0.3.3, for-each@npm:^0.3.5": version: 0.3.5 resolution: "for-each@npm:0.3.5" @@ -11077,39 +8126,16 @@ __metadata: languageName: node linkType: hard -"fork-ts-checker-webpack-plugin@npm:9.1.0": - version: 9.1.0 - resolution: "fork-ts-checker-webpack-plugin@npm:9.1.0" - dependencies: - "@babel/code-frame": "npm:^7.16.7" - chalk: "npm:^4.1.2" - chokidar: "npm:^4.0.1" - cosmiconfig: "npm:^8.2.0" - deepmerge: "npm:^4.2.2" - fs-extra: "npm:^10.0.0" - memfs: "npm:^3.4.1" - minimatch: "npm:^3.0.4" - node-abort-controller: "npm:^3.0.1" - schema-utils: "npm:^3.1.1" - semver: "npm:^7.3.5" - tapable: "npm:^2.2.1" - peerDependencies: - typescript: ">3.6.0" - webpack: ^5.11.0 - checksum: 10c0/b4acdf400862af5f57d3e159b3a444e7f9f73e9f4609d54604c3810f75f8adcea0165a8b17ee856ed3c65591d058ffd73cd08d273e289d4952844e75f6efa85d - languageName: node - linkType: hard - -"form-data@npm:^4.0.0, form-data@npm:^4.0.4": - version: 4.0.4 - resolution: "form-data@npm:4.0.4" +"form-data@npm:^4.0.0": + version: 4.0.6 + resolution: "form-data@npm:4.0.6" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.12" - checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695 + hasown: "npm:^2.0.4" + mime-types: "npm:^2.1.35" + checksum: 10c0/43947a77bf0ff45c6ceed789778982d47a3f3e720a74b71721174ebf3310a5f1a8be1d6b38a3ee3688e8a18a2c4273073ec0844cd37efda3eaf46d41c9c318ff languageName: node linkType: hard @@ -11136,13 +8162,6 @@ __metadata: languageName: node linkType: hard -"forwarded-parse@npm:2.1.2": - version: 2.1.2 - resolution: "forwarded-parse@npm:2.1.2" - checksum: 10c0/0c6b4c631775f272b4475e935108635495e8a5b261d1b4a5caef31c47c5a0b04134adc564e655aadfef366a02647fa3ae90a1d3ac19929f3ade47f9bed53036a - languageName: node - linkType: hard - "forwarded@npm:0.2.0": version: 0.2.0 resolution: "forwarded@npm:0.2.0" @@ -11166,6 +8185,13 @@ __metadata: languageName: node linkType: hard +"fresh@npm:~0.5.2": + version: 0.5.2 + resolution: "fresh@npm:0.5.2" + checksum: 10c0/c6d27f3ed86cc5b601404822f31c900dd165ba63fff8152a3ef714e2012e7535027063bc67ded4cb5b3a49fa596495d46cacd9f47d6328459cf570f08b7d9e5a + languageName: node + linkType: hard + "from2@npm:^2.1.0": version: 2.3.0 resolution: "from2@npm:2.3.0" @@ -11183,17 +8209,6 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^10.0.0": - version: 10.1.0 - resolution: "fs-extra@npm:10.1.0" - dependencies: - graceful-fs: "npm:^4.2.0" - jsonfile: "npm:^6.0.1" - universalify: "npm:^2.0.0" - checksum: 10c0/5f579466e7109719d162a9249abbeffe7f426eb133ea486e020b89bc6d67a741134076bf439983f2eb79276ceaf6bd7b7c1e43c3fd67fe889863e69072fb0a5e - languageName: node - linkType: hard - "fs-extra@npm:^8.1.0": version: 8.1.0 resolution: "fs-extra@npm:8.1.0" @@ -11223,22 +8238,6 @@ __metadata: languageName: node linkType: hard -"fs-minipass@npm:^3.0.0": - version: 3.0.3 - resolution: "fs-minipass@npm:3.0.3" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10c0/63e80da2ff9b621e2cb1596abcb9207f1cf82b968b116ccd7b959e3323144cce7fb141462200971c38bbf2ecca51695069db45265705bed09a7cd93ae5b89f94 - languageName: node - linkType: hard - -"fs-monkey@npm:^1.0.4": - version: 1.1.0 - resolution: "fs-monkey@npm:1.1.0" - checksum: 10c0/45596fe14753ae8f3fa180724106383de68c8de2836eb24d1647cacf18a6d05335402f3611d32e00234072a60d2f3371024c00cd295593bfbce35b84ff9f6a34 - languageName: node - linkType: hard - "fs-write-stream-atomic@npm:^1.0.8": version: 1.0.10 resolution: "fs-write-stream-atomic@npm:1.0.10" @@ -11258,7 +8257,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.3, fsevents@npm:~2.3.2": +"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -11268,7 +8267,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": +"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -11285,16 +8284,19 @@ __metadata: linkType: hard "function.prototype.name@npm:^1.1.6, function.prototype.name@npm:^1.1.8": - version: 1.1.8 - resolution: "function.prototype.name@npm:1.1.8" + version: 1.2.0 + resolution: "function.prototype.name@npm:1.2.0" dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.3" - define-properties: "npm:^1.2.1" + call-bind: "npm:^1.0.9" + call-bound: "npm:^1.0.4" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" functions-have-names: "npm:^1.2.3" - hasown: "npm:^2.0.2" + has-property-descriptors: "npm:^1.0.2" + hasown: "npm:^2.0.4" is-callable: "npm:^1.2.7" - checksum: 10c0/e920a2ab52663005f3cbe7ee3373e3c71c1fb5558b0b0548648cdf3e51961085032458e26c71ff1a8c8c20e7ee7caeb03d43a5d1fa8610c459333323a2e71253 + is-document.all: "npm:^1.0.0" + checksum: 10c0/b20e6370ef4f7d56d0bedf5719f6684a517a8dd3334209b4d9f51e8834859302a584187156bf024cda9f50ba2479e4d6764ac34af9532ea47d2f4d9fa6bcf90d languageName: node linkType: hard @@ -11368,13 +8370,6 @@ __metadata: languageName: node linkType: hard -"gensync@npm:^1.0.0-beta.2": - version: 1.0.0-beta.2 - resolution: "gensync@npm:1.0.0-beta.2" - checksum: 10c0/782aba6cba65b1bb5af3b095d96249d20edbe8df32dbf4696fd49be2583faf676173bf4809386588828e4dd76a3354fcbeb577bab1c833ccd9fc4577f26103f8 - languageName: node - linkType: hard - "get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" @@ -11382,28 +8377,31 @@ __metadata: languageName: node linkType: hard +"get-east-asian-width@npm:^1.5.0": + version: 1.6.0 + resolution: "get-east-asian-width@npm:1.6.0" + checksum: 10c0/7e72e9550fd49ca5b246f9af6bb2afc129c96412845ff6556b3274fd44817a381702ca17028efe9866b261a3d44254cbf21e6c90cf05b4b61675630af776d431 + languageName: node + linkType: hard + "get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": - version: 1.3.0 - resolution: "get-intrinsic@npm:1.3.0" + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" call-bind-apply-helpers: "npm:^1.0.2" es-define-property: "npm:^1.0.1" es-errors: "npm:^1.3.0" es-object-atoms: "npm:^1.1.1" function-bind: "npm:^1.1.2" + generator-function: "npm:^2.0.0" get-proto: "npm:^1.0.1" gopd: "npm:^1.2.0" has-symbols: "npm:^1.1.0" hasown: "npm:^2.0.2" math-intrinsics: "npm:^1.1.0" - checksum: 10c0/52c81808af9a8130f581e6a6a83e1ba4a9f703359e7a438d1369a5267a25412322f03dcbd7c549edaef0b6214a0630a28511d7df0130c93cfd380f4fa0b5b66a - languageName: node - linkType: hard - -"get-package-type@npm:^0.1.0": - version: 0.1.0 - resolution: "get-package-type@npm:0.1.0" - checksum: 10c0/e34cdf447fdf1902a1f6d5af737eaadf606d2ee3518287abde8910e04159368c268568174b2e71102b87b26c2020486f126bfca9c4fb1ceb986ff99b52ecd1be + checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d languageName: node linkType: hard @@ -11490,13 +8488,6 @@ __metadata: languageName: node linkType: hard -"get-stream@npm:^6.0.0": - version: 6.0.1 - resolution: "get-stream@npm:6.0.1" - checksum: 10c0/49825d57d3fd6964228e6200a58169464b8e8970489b3acdc24906c782fb7f01f9f56f8e6653c4a50713771d6658f7cfe051e5eb8c12e334138c9c918b296341 - languageName: node - linkType: hard - "get-symbol-description@npm:^1.1.0": version: 1.1.0 resolution: "get-symbol-description@npm:1.1.0" @@ -11646,7 +8637,7 @@ __metadata: languageName: node linkType: hard -"glob-parent@npm:^5.0.0, glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": +"glob-parent@npm:^5.0.0, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" dependencies: @@ -11671,13 +8662,6 @@ __metadata: languageName: node linkType: hard -"glob-to-regexp@npm:^0.4.1": - version: 0.4.1 - resolution: "glob-to-regexp@npm:0.4.1" - checksum: 10c0/0486925072d7a916f052842772b61c3e86247f0a80cc0deb9b5a3e8a1a9faad5b04fb6f58986a09f34d3e96cd2a22a24b7e9882fb1cf904c31e9a310de96c429 - languageName: node - linkType: hard - "glob@npm:10.3.10": version: 10.3.10 resolution: "glob@npm:10.3.10" @@ -11693,25 +8677,25 @@ __metadata: languageName: node linkType: hard -"glob@npm:11.0.3, glob@npm:^11.0.3": - version: 11.0.3 - resolution: "glob@npm:11.0.3" +"glob@npm:11.1.0": + version: 11.1.0 + resolution: "glob@npm:11.1.0" dependencies: foreground-child: "npm:^3.3.1" jackspeak: "npm:^4.1.1" - minimatch: "npm:^10.0.3" + minimatch: "npm:^10.1.1" minipass: "npm:^7.1.2" package-json-from-dist: "npm:^1.0.0" path-scurry: "npm:^2.0.0" bin: glob: dist/esm/bin.mjs - checksum: 10c0/7d24457549ec2903920dfa3d8e76850e7c02aa709122f0164b240c712f5455c0b457e6f2a1eee39344c6148e39895be8094ae8cfef7ccc3296ed30bce250c661 + checksum: 10c0/1ceae07f23e316a6fa74581d9a74be6e8c2e590d2f7205034dd5c0435c53f5f7b712c2be00c3b65bf0a49294a1c6f4b98cd84c7637e29453b5aa13b79f1763a2 languageName: node linkType: hard -"glob@npm:^10.3.10, glob@npm:^10.4.2, glob@npm:^10.4.5, glob@npm:~10.4.1": - version: 10.4.5 - resolution: "glob@npm:10.4.5" +"glob@npm:^10.3.10, glob@npm:^10.4.2, glob@npm:^10.5.0": + version: 10.5.0 + resolution: "glob@npm:10.5.0" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^3.1.2" @@ -11721,7 +8705,7 @@ __metadata: path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e + checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 languageName: node linkType: hard @@ -11739,6 +8723,22 @@ __metadata: languageName: node linkType: hard +"glob@npm:~10.4.1": + version: 10.4.5 + resolution: "glob@npm:10.4.5" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^1.11.1" + bin: + glob: dist/esm/bin.mjs + checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e + languageName: node + linkType: hard + "global-directory@npm:^4.0.1": version: 4.0.1 resolution: "global-directory@npm:4.0.1" @@ -11802,23 +8802,16 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.2, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.2, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 languageName: node linkType: hard -"graphemer@npm:^1.4.0": - version: 1.4.0 - resolution: "graphemer@npm:1.4.0" - checksum: 10c0/e951259d8cd2e0d196c72ec711add7115d42eb9a8146c8eeda5b8d3ac91e5dd816b9cd68920726d9fd4490368e7ed86e9c423f40db87e2d8dfafa00fa17c3a31 - languageName: node - linkType: hard - "handlebars@npm:^4.7.6, handlebars@npm:^4.7.7, handlebars@npm:^4.7.8": - version: 4.7.8 - resolution: "handlebars@npm:4.7.8" + version: 4.7.9 + resolution: "handlebars@npm:4.7.9" dependencies: minimist: "npm:^1.2.5" neo-async: "npm:^2.6.2" @@ -11830,7 +8823,7 @@ __metadata: optional: true bin: handlebars: bin/handlebars - checksum: 10c0/7aff423ea38a14bb379316f3857fe0df3c5d66119270944247f155ba1f08e07a92b340c58edaa00cfe985c21508870ee5183e0634dcb53dd405f35c93ef7f10d + checksum: 10c0/22f8105a7e68e81aff2662bb434edf05f757d21d850731d71cec886d69c10cd33d3c43e34b2892968ec62de8241611851d3d0674c8ef324ea3e01dc66262faa9 languageName: node linkType: hard @@ -11959,12 +8952,12 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.0, hasown@npm:^2.0.2": - version: 2.0.2 - resolution: "hasown@npm:2.0.2" +"hasown@npm:^2.0.0, hasown@npm:^2.0.2, hasown@npm:^2.0.3, hasown@npm:^2.0.4": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" dependencies: function-bind: "npm:^1.1.2" - checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9 + checksum: 10c0/2d8de939e270b70618f8cebb69746620db10617dbb495bc66ddad326955ea24d3ca4af133aff3eb7c1853e0218f867bc2b050ec26fe02e3aea58f880ffc5e506 languageName: node linkType: hard @@ -12024,16 +9017,28 @@ __metadata: languageName: node linkType: hard -"html-to-text@npm:9.0.5": - version: 9.0.5 - resolution: "html-to-text@npm:9.0.5" +"html-to-text@npm:10.0.0": + version: 10.0.0 + resolution: "html-to-text@npm:10.0.0" dependencies: - "@selderee/plugin-htmlparser2": "npm:^0.11.0" - deepmerge: "npm:^4.3.1" + "@selderee/plugin-htmlparser2": "npm:~0.12.0" + deepmerge-ts: "npm:^7.1.5" dom-serializer: "npm:^2.0.0" - htmlparser2: "npm:^8.0.2" - selderee: "npm:^0.11.0" - checksum: 10c0/5d2c77b798cf88a81b1da2fc1ea1a3b3e2ff49fe5a3d812392f802fff18ec315cf0969bd7846ef2eb7df8c37f463bc63e8cbdcf84e42696c6f3e15dfa61cdf4f + htmlparser2: "npm:^10.1.0" + selderee: "npm:~0.12.0" + checksum: 10c0/fde3b3695ff1bf8ba71b5fb388734de941cf8ccd4c0e816add07104496e21b8f9f937058b35c69554e510af72a5fb582e8966daa165a350464faf97d0aa9e066 + languageName: node + linkType: hard + +"htmlparser2@npm:^10.1.0": + version: 10.1.0 + resolution: "htmlparser2@npm:10.1.0" + dependencies: + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + domutils: "npm:^3.2.2" + entities: "npm:^7.0.1" + checksum: 10c0/36394e29b80cfcc5e78e0fa4d3aa21fdaac3e6778d23e5c933e625c290987cd9a724a2eb0753ab60ed0c69dfaba0ab115f0ee50fb112fd8f0c4d522e7e0089a2 languageName: node linkType: hard @@ -12049,7 +9054,7 @@ __metadata: languageName: node linkType: hard -"htmlparser2@npm:^8.0.1, htmlparser2@npm:^8.0.2": +"htmlparser2@npm:^8.0.1": version: 8.0.2 resolution: "htmlparser2@npm:8.0.2" dependencies: @@ -12080,23 +9085,23 @@ __metadata: languageName: node linkType: hard -"http-cache-semantics@npm:^4.1.0, http-cache-semantics@npm:^4.1.1": +"http-cache-semantics@npm:^4.1.0": version: 4.2.0 resolution: "http-cache-semantics@npm:4.2.0" checksum: 10c0/45b66a945cf13ec2d1f29432277201313babf4a01d9e52f44b31ca923434083afeca03f18417f599c9ab3d0e7b618ceb21257542338b57c54b710463b4a53e37 languageName: node linkType: hard -"http-errors@npm:2.0.0, http-errors@npm:^2.0.0": - version: 2.0.0 - resolution: "http-errors@npm:2.0.0" +"http-errors@npm:^2.0.0, http-errors@npm:^2.0.1, http-errors@npm:~2.0.0, http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" dependencies: - depd: "npm:2.0.0" - inherits: "npm:2.0.4" - setprototypeof: "npm:1.2.0" - statuses: "npm:2.0.1" - toidentifier: "npm:1.0.1" - checksum: 10c0/fc6f2715fe188d091274b5ffc8b3657bd85c63e969daa68ccb77afb05b071a4b62841acb7a21e417b5539014dff2ebf9550f0b14a9ff126f2734a7c1387f8e19 + depd: "npm:~2.0.0" + inherits: "npm:~2.0.4" + setprototypeof: "npm:~1.2.0" + statuses: "npm:~2.0.2" + toidentifier: "npm:~1.0.1" + checksum: 10c0/fb38906cef4f5c83952d97661fe14dc156cb59fe54812a42cd448fa57b5c5dfcb38a40a916957737bd6b87aab257c0648d63eb5b6a9ca9f548e105b6072712d4 languageName: node linkType: hard @@ -12121,16 +9126,6 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^7.0.0": - version: 7.0.2 - resolution: "http-proxy-agent@npm:7.0.2" - dependencies: - agent-base: "npm:^7.1.0" - debug: "npm:^4.3.4" - checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921 - languageName: node - linkType: hard - "http-signature@npm:~1.2.0": version: 1.2.0 resolution: "http-signature@npm:1.2.0" @@ -12162,23 +9157,6 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^7.0.1": - version: 7.0.6 - resolution: "https-proxy-agent@npm:7.0.6" - dependencies: - agent-base: "npm:^7.1.2" - debug: "npm:4" - checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac - languageName: node - linkType: hard - -"human-signals@npm:^2.1.0": - version: 2.1.0 - resolution: "human-signals@npm:2.1.0" - checksum: 10c0/695edb3edfcfe9c8b52a76926cd31b36978782062c0ed9b1192b36bebc75c4c87c82e178dfcb0ed0fc27ca59d434198aac0bd0be18f5781ded775604db22304a - languageName: node - linkType: hard - "humanize-ms@npm:^1.2.1": version: 1.2.1 resolution: "humanize-ms@npm:1.2.1" @@ -12197,25 +9175,16 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3": - version: 0.6.3 - resolution: "iconv-lite@npm:0.6.3" - dependencies: - safer-buffer: "npm:>= 2.1.2 < 3.0.0" - checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1 - languageName: node - linkType: hard - -"iconv-lite@npm:0.7.0, iconv-lite@npm:^0.7.0": - version: 0.7.0 - resolution: "iconv-lite@npm:0.7.0" +"iconv-lite@npm:0.7.2, iconv-lite@npm:^0.7.2, iconv-lite@npm:~0.7.0": + version: 0.7.2 + resolution: "iconv-lite@npm:0.7.2" dependencies: safer-buffer: "npm:>= 2.1.2 < 3.0.0" - checksum: 10c0/2382400469071c55b6746c531eed5fa4d033e5db6690b7331fb2a5f59a30d7a9782932e92253db26df33c1cf46fa200a3fbe524a2a7c62037c762283f188ec2f + checksum: 10c0/3c228920f3bd307f56bf8363706a776f4a060eb042f131cd23855ceca962951b264d0997ab38a1ad340e1c5df8499ed26e1f4f0db6b2a2ad9befaff22f14b722 languageName: node linkType: hard -"iconv-lite@npm:^0.4.24": +"iconv-lite@npm:^0.4.24, iconv-lite@npm:~0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" dependencies: @@ -12224,6 +9193,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:^0.6.2": + version: 0.6.3 + resolution: "iconv-lite@npm:0.6.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1 + languageName: node + linkType: hard + "ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" @@ -12261,7 +9239,7 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^7.0.0": +"ignore@npm:^7.0.5": version: 7.0.5 resolution: "ignore@npm:7.0.5" checksum: 10c0/ae00db89fe873064a093b8999fe4cc284b13ef2a178636211842cceb650b9c3e390d3339191acb145d81ed5379d2074840cf0c33a20bdbd6f32821f79eb4ad5d @@ -12280,23 +9258,11 @@ __metadata: "import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": version: 3.3.1 - resolution: "import-fresh@npm:3.3.1" - dependencies: - parent-module: "npm:^1.0.0" - resolve-from: "npm:^4.0.0" - checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec - languageName: node - linkType: hard - -"import-in-the-middle@npm:^1.11.2, import-in-the-middle@npm:^1.8.1": - version: 1.15.0 - resolution: "import-in-the-middle@npm:1.15.0" + resolution: "import-fresh@npm:3.3.1" dependencies: - acorn: "npm:^8.14.0" - acorn-import-attributes: "npm:^1.9.5" - cjs-module-lexer: "npm:^1.2.2" - module-details-from-path: "npm:^1.0.3" - checksum: 10c0/43d4efbe75a89c04343fd052ca5d2193adc0e2df93325e50d8b32c31403b2f089a5e2b6e47f4e5413bc4058b9781aaaf61bfe3f0e5e6d7f9487eb112fd095e0d + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec languageName: node linkType: hard @@ -12312,18 +9278,6 @@ __metadata: languageName: node linkType: hard -"import-local@npm:^3.2.0": - version: 3.2.0 - resolution: "import-local@npm:3.2.0" - dependencies: - pkg-dir: "npm:^4.2.0" - resolve-cwd: "npm:^3.0.0" - bin: - import-local-fixture: fixtures/cli.js - checksum: 10c0/94cd6367a672b7e0cb026970c85b76902d2710a64896fa6de93bd5c571dd03b228c5759308959de205083e3b1c61e799f019c9e36ee8e9c523b993e1057f0433 - languageName: node - linkType: hard - "import-meta-resolve@npm:^4.0.0": version: 4.2.0 resolution: "import-meta-resolve@npm:4.2.0" @@ -12378,7 +9332,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -12454,10 +9408,10 @@ __metadata: languageName: node linkType: hard -"ip-address@npm:^10.0.1": - version: 10.1.0 - resolution: "ip-address@npm:10.1.0" - checksum: 10c0/0103516cfa93f6433b3bd7333fa876eb21263912329bfa47010af5e16934eeeff86f3d2ae700a3744a137839ddfad62b900c7a445607884a49b5d1e32a3d7566 +"ip-address@npm:^10.1.1": + version: 10.2.0 + resolution: "ip-address@npm:10.2.0" + checksum: 10c0/5a00aada6e922c9c69dfc800ed5d0fa3348675ebdeed0e1575f503f27ca385b5f534363c9af7ad1daf64c1f1409388cdd3cc2e9b9b0fe1c924a431378d55075a languageName: node linkType: hard @@ -12475,12 +9429,12 @@ __metadata: languageName: node linkType: hard -"is-accessor-descriptor@npm:^1.0.1": - version: 1.0.1 - resolution: "is-accessor-descriptor@npm:1.0.1" +"is-accessor-descriptor@npm:^1.0.1, is-accessor-descriptor@npm:^1.0.2": + version: 1.0.2 + resolution: "is-accessor-descriptor@npm:1.0.2" dependencies: - hasown: "npm:^2.0.0" - checksum: 10c0/d034034074c5ffeb6c868e091083182279db1a956f49f8d1494cecaa0f8b99d706556ded2a9b20d9aa290549106eef8204d67d8572902e06dcb1add6db6b524d + hasown: "npm:^2.0.3" + checksum: 10c0/6c02210d1ef82df78e718e031525f6941e2ac6f2503b9b018833b4437cb634245b4c34377a23dc1e110ae772e1a265a0040288daf8fc3f9f893f0a6e9f63fa53 languageName: node linkType: hard @@ -12568,12 +9522,12 @@ __metadata: languageName: node linkType: hard -"is-core-module@npm:^2.13.0, is-core-module@npm:^2.16.1, is-core-module@npm:^2.5.0": - version: 2.16.1 - resolution: "is-core-module@npm:2.16.1" +"is-core-module@npm:^2.16.1, is-core-module@npm:^2.16.2, is-core-module@npm:^2.5.0": + version: 2.16.2 + resolution: "is-core-module@npm:2.16.2" dependencies: - hasown: "npm:^2.0.2" - checksum: 10c0/898443c14780a577e807618aaae2b6f745c8538eca5c7bc11388a3f2dc6de82b9902bcc7eb74f07be672b11bbe82dd6a6edded44a00cb3d8f933d0459905eedd + hasown: "npm:^2.0.3" + checksum: 10c0/14b4258390283709c15476d023ec173e27458d5d014ccdb8ed39d576e551c3fa45498b7c9fe178f1529c4cb2648ddd58852a6a62107a019f6e349529f277518a languageName: node linkType: hard @@ -12597,7 +9551,7 @@ __metadata: languageName: node linkType: hard -"is-date-object@npm:^1.0.5, is-date-object@npm:^1.1.0": +"is-date-object@npm:^1.1.0": version: 1.1.0 resolution: "is-date-object@npm:1.1.0" dependencies: @@ -12608,22 +9562,22 @@ __metadata: linkType: hard "is-descriptor@npm:^0.1.0": - version: 0.1.7 - resolution: "is-descriptor@npm:0.1.7" + version: 0.1.8 + resolution: "is-descriptor@npm:0.1.8" dependencies: is-accessor-descriptor: "npm:^1.0.1" is-data-descriptor: "npm:^1.0.1" - checksum: 10c0/f5960b9783f508aec570465288cb673d4b3cc4aae4e6de970c3afd9a8fc1351edcb85d78b2cce2ec5251893a423f73263cab3bb94cf365a8d71b5d510a116392 + checksum: 10c0/923abafe0922ce5b54d082484638575f407cb046c4ea69e630ce6f7d0ea210a14b725803b5fe3bcacf1d9c42ae6a53060dd796156afd753306d828a699a3b4b3 languageName: node linkType: hard "is-descriptor@npm:^1.0.0, is-descriptor@npm:^1.0.2": - version: 1.0.3 - resolution: "is-descriptor@npm:1.0.3" + version: 1.0.4 + resolution: "is-descriptor@npm:1.0.4" dependencies: - is-accessor-descriptor: "npm:^1.0.1" + is-accessor-descriptor: "npm:^1.0.2" is-data-descriptor: "npm:^1.0.1" - checksum: 10c0/b4ee667ea787d3a0be4e58536087fd0587de2b0b6672fbfe288f5b8d831ac4b79fd987f31d6c2d4e5543a42c97a87428bc5215ce292a1a47070147793878226f + checksum: 10c0/ead851fe2aedb78c2d07b7a2a37f14c17f0bfb91013c829ccd6044b335684e9f4482422db5bb9470ea6d8d224bff57286e46f2fc34a8d8b5b19d678008e2607f languageName: node linkType: hard @@ -12643,6 +9597,15 @@ __metadata: languageName: node linkType: hard +"is-document.all@npm:^1.0.0": + version: 1.0.0 + resolution: "is-document.all@npm:1.0.0" + dependencies: + call-bound: "npm:^1.0.4" + checksum: 10c0/955c20ed5bf01d49da8243b4c714947a6ff64b6d9ba0e12bdbfa654a3e7c47f72cc01c6cd2905e85512d02bc3a1290edd73857bca8842566ff9dcfb7c3f92dae + languageName: node + linkType: hard + "is-expression@npm:^4.0.0": version: 4.0.0 resolution: "is-expression@npm:4.0.0" @@ -12715,13 +9678,6 @@ __metadata: languageName: node linkType: hard -"is-generator-fn@npm:^2.1.0": - version: 2.1.0 - resolution: "is-generator-fn@npm:2.1.0" - checksum: 10c0/2957cab387997a466cd0bf5c1b6047bd21ecb32bdcfd8996b15747aa01002c1c88731802f1b3d34ac99f4f6874b626418bd118658cf39380fe5fff32a3af9c4d - languageName: node - linkType: hard - "is-generator-function@npm:^1.0.10": version: 1.1.2 resolution: "is-generator-function@npm:1.1.2" @@ -12760,6 +9716,13 @@ __metadata: languageName: node linkType: hard +"is-interactive@npm:^2.0.0": + version: 2.0.0 + resolution: "is-interactive@npm:2.0.0" + checksum: 10c0/801c8f6064f85199dc6bf99b5dd98db3282e930c3bc197b32f2c5b89313bb578a07d1b8a01365c4348c2927229234f3681eb861b9c2c92bee72ff397390fa600 + languageName: node + linkType: hard + "is-lambda@npm:^1.0.1": version: 1.0.1 resolution: "is-lambda@npm:1.0.1" @@ -12902,13 +9865,6 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^2.0.0": - version: 2.0.1 - resolution: "is-stream@npm:2.0.1" - checksum: 10c0/7c284241313fc6efc329b8d7f08e16c0efeb6baab1b4cd0ba579eb78e5af1aa5da11e68559896a2067cd6c526bd29241dda4eb1225e627d5aa1a89a76d4635a5 - languageName: node - linkType: hard - "is-string@npm:^1.1.1": version: 1.1.1 resolution: "is-string@npm:1.1.1" @@ -12919,7 +9875,7 @@ __metadata: languageName: node linkType: hard -"is-symbol@npm:^1.0.4, is-symbol@npm:^1.1.1": +"is-symbol@npm:^1.1.1": version: 1.1.1 resolution: "is-symbol@npm:1.1.1" dependencies: @@ -12971,6 +9927,13 @@ __metadata: languageName: node linkType: hard +"is-unicode-supported@npm:^2.0.0, is-unicode-supported@npm:^2.1.0": + version: 2.1.0 + resolution: "is-unicode-supported@npm:2.1.0" + checksum: 10c0/a0f53e9a7c1fdbcf2d2ef6e40d4736fdffff1c9f8944c75e15425118ff3610172c87bf7bc6c34d3903b04be59790bb2212ddbe21ee65b5a97030fc50370545a5 + languageName: node + linkType: hard + "is-utf8@npm:^0.2.0": version: 0.2.1 resolution: "is-utf8@npm:0.2.1" @@ -13041,10 +10004,10 @@ __metadata: languageName: node linkType: hard -"isexe@npm:^3.1.1": - version: 3.1.1 - resolution: "isexe@npm:3.1.1" - checksum: 10c0/9ec257654093443eb0a528a9c8cbba9c0ca7616ccb40abd6dde7202734d96bb86e4ac0d764f0f8cd965856aacbff2f4ce23e730dc19dfb41e3b0d865ca6fdcc7 +"isexe@npm:^4.0.0": + version: 4.0.0 + resolution: "isexe@npm:4.0.0" + checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce languageName: node linkType: hard @@ -13071,27 +10034,14 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b languageName: node linkType: hard -"istanbul-lib-instrument@npm:^6.0.0, istanbul-lib-instrument@npm:^6.0.2": - version: 6.0.3 - resolution: "istanbul-lib-instrument@npm:6.0.3" - dependencies: - "@babel/core": "npm:^7.23.9" - "@babel/parser": "npm:^7.23.9" - "@istanbuljs/schema": "npm:^0.1.3" - istanbul-lib-coverage: "npm:^3.2.0" - semver: "npm:^7.5.4" - checksum: 10c0/a1894e060dd2a3b9f046ffdc87b44c00a35516f5e6b7baf4910369acca79e506fc5323a816f811ae23d82334b38e3ddeb8b3b331bd2c860540793b59a8689128 - languageName: node - linkType: hard - -"istanbul-lib-report@npm:^3.0.0": +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": version: 3.0.1 resolution: "istanbul-lib-report@npm:3.0.1" dependencies: @@ -13102,18 +10052,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-source-maps@npm:^5.0.0": - version: 5.0.6 - resolution: "istanbul-lib-source-maps@npm:5.0.6" - dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.23" - debug: "npm:^4.1.1" - istanbul-lib-coverage: "npm:^3.0.0" - checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f - languageName: node - linkType: hard - -"istanbul-reports@npm:^3.1.3": +"istanbul-reports@npm:^3.2.0": version: 3.2.0 resolution: "istanbul-reports@npm:3.2.0" dependencies: @@ -13157,11 +10096,11 @@ __metadata: linkType: hard "jackspeak@npm:^4.1.1": - version: 4.1.1 - resolution: "jackspeak@npm:4.1.1" + version: 4.2.3 + resolution: "jackspeak@npm:4.2.3" dependencies: - "@isaacs/cliui": "npm:^8.0.2" - checksum: 10c0/84ec4f8e21d6514db24737d9caf65361511f75e5e424980eebca4199f400874f45e562ac20fa8aeb1dd20ca2f3f81f0788b6e9c3e64d216a5794fd6f30e0e042 + "@isaacs/cliui": "npm:^9.0.0" + checksum: 10c0/b5c0c414f1607c2aa0597f4bf2c03b8443897fccd5fd3c2b3e4f77d556b2bc7c3d3413828ba91e0789f6fb40ad90242f7f89fb20aee9e9d705bc1681f7564f67 languageName: node linkType: hard @@ -13178,125 +10117,6 @@ __metadata: languageName: node linkType: hard -"jest-changed-files@npm:30.2.0": - version: 30.2.0 - resolution: "jest-changed-files@npm:30.2.0" - dependencies: - execa: "npm:^5.1.1" - jest-util: "npm:30.2.0" - p-limit: "npm:^3.1.0" - checksum: 10c0/0ce838f8bffdadcdc19028f4b7a24c04d2f9885ee5c5c1bb4746c205cb96649934090ef6492c3dc45b1be097672b4f8043ad141278bc82f390579fa3ea4c11fe - languageName: node - linkType: hard - -"jest-circus@npm:30.2.0": - version: 30.2.0 - resolution: "jest-circus@npm:30.2.0" - dependencies: - "@jest/environment": "npm:30.2.0" - "@jest/expect": "npm:30.2.0" - "@jest/test-result": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - co: "npm:^4.6.0" - dedent: "npm:^1.6.0" - is-generator-fn: "npm:^2.1.0" - jest-each: "npm:30.2.0" - jest-matcher-utils: "npm:30.2.0" - jest-message-util: "npm:30.2.0" - jest-runtime: "npm:30.2.0" - jest-snapshot: "npm:30.2.0" - jest-util: "npm:30.2.0" - p-limit: "npm:^3.1.0" - pretty-format: "npm:30.2.0" - pure-rand: "npm:^7.0.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.6" - checksum: 10c0/32fc88e13d3e811a9af5ca02d31f7cc742e726a0128df0b023330d6dff6ac29bf981da09937162f7c0705cf327df8d24e46de84860f6817dbc134438315c2967 - languageName: node - linkType: hard - -"jest-cli@npm:30.2.0": - version: 30.2.0 - resolution: "jest-cli@npm:30.2.0" - dependencies: - "@jest/core": "npm:30.2.0" - "@jest/test-result": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - chalk: "npm:^4.1.2" - exit-x: "npm:^0.2.2" - import-local: "npm:^3.2.0" - jest-config: "npm:30.2.0" - jest-util: "npm:30.2.0" - jest-validate: "npm:30.2.0" - yargs: "npm:^17.7.2" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - bin: - jest: ./bin/jest.js - checksum: 10c0/b722a98cdf7b0ff1c273dd4efbaf331d683335f1f338a76a24492574e582a4e5a12a9df66e41bf4c92c7cffe0f51b759818ecd42044cd9bbef67d40359240989 - languageName: node - linkType: hard - -"jest-config@npm:30.2.0": - version: 30.2.0 - resolution: "jest-config@npm:30.2.0" - dependencies: - "@babel/core": "npm:^7.27.4" - "@jest/get-type": "npm:30.1.0" - "@jest/pattern": "npm:30.0.1" - "@jest/test-sequencer": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - babel-jest: "npm:30.2.0" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - deepmerge: "npm:^4.3.1" - glob: "npm:^10.3.10" - graceful-fs: "npm:^4.2.11" - jest-circus: "npm:30.2.0" - jest-docblock: "npm:30.2.0" - jest-environment-node: "npm:30.2.0" - jest-regex-util: "npm:30.0.1" - jest-resolve: "npm:30.2.0" - jest-runner: "npm:30.2.0" - jest-util: "npm:30.2.0" - jest-validate: "npm:30.2.0" - micromatch: "npm:^4.0.8" - parse-json: "npm:^5.2.0" - pretty-format: "npm:30.2.0" - slash: "npm:^3.0.0" - strip-json-comments: "npm:^3.1.1" - peerDependencies: - "@types/node": "*" - esbuild-register: ">=3.4.0" - ts-node: ">=9.0.0" - peerDependenciesMeta: - "@types/node": - optional: true - esbuild-register: - optional: true - ts-node: - optional: true - checksum: 10c0/f02bb747e3382cdbb5a00abd583e9118a0b4f1d9d4cad01b5cc06b7fab9b817419ec183856cd791b2e9167051cad52b3d22ea34319a28c8f3e70a5ce73d05faa - languageName: node - linkType: hard - -"jest-diff@npm:30.2.0, jest-diff@npm:^30.0.0": - version: 30.2.0 - resolution: "jest-diff@npm:30.2.0" - dependencies: - "@jest/diff-sequences": "npm:30.0.1" - "@jest/get-type": "npm:30.1.0" - chalk: "npm:^4.1.2" - pretty-format: "npm:30.2.0" - checksum: 10c0/5fac2cd89a10b282c5a68fc6206a95dfff9955ed0b758d24ffb0edcb20fb2f98e1fa5045c5c4205d952712ea864c6a086654f80cdd500cce054a2f5daf5b4419 - languageName: node - linkType: hard - "jest-diff@npm:^27.5.1": version: 27.5.1 resolution: "jest-diff@npm:27.5.1" @@ -13309,60 +10129,6 @@ __metadata: languageName: node linkType: hard -"jest-docblock@npm:30.2.0": - version: 30.2.0 - resolution: "jest-docblock@npm:30.2.0" - dependencies: - detect-newline: "npm:^3.1.0" - checksum: 10c0/2578366604eef1b36d59ffe1fc52a710995571535d437f83d94ff94756a83f78e699c1ba004c38a34c01859d669fd6c64e865c23c5a7d5bf4837cfca4bef3dda - languageName: node - linkType: hard - -"jest-each@npm:30.2.0": - version: 30.2.0 - resolution: "jest-each@npm:30.2.0" - dependencies: - "@jest/get-type": "npm:30.1.0" - "@jest/types": "npm:30.2.0" - chalk: "npm:^4.1.2" - jest-util: "npm:30.2.0" - pretty-format: "npm:30.2.0" - checksum: 10c0/4fa7e88a2741daaebd58cf49f9add8bd6c68657d2c106a170ebe4d7f86082c9eede2b13924304277a92e02b31b59a3c34949877da077bc27712b57913bb88321 - languageName: node - linkType: hard - -"jest-environment-node@npm:30.2.0": - version: 30.2.0 - resolution: "jest-environment-node@npm:30.2.0" - dependencies: - "@jest/environment": "npm:30.2.0" - "@jest/fake-timers": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - jest-mock: "npm:30.2.0" - jest-util: "npm:30.2.0" - jest-validate: "npm:30.2.0" - checksum: 10c0/866ba2c04ccf003845a8ca1f372081d76923849ae8e06e50cdfed792e41a976b5f953e15f3af17ff51b111b9540cf846f7f582530ca724c2a2abf15d15a99728 - languageName: node - linkType: hard - -"jest-extended@npm:^7.0.0": - version: 7.0.0 - resolution: "jest-extended@npm:7.0.0" - dependencies: - jest-diff: "npm:^30.0.0" - peerDependencies: - jest: ">=27.2.5" - typescript: ">=5.0.0" - peerDependenciesMeta: - jest: - optional: true - typescript: - optional: false - checksum: 10c0/a89b29cf80207c89ab3f1cbd264844049f3f66f566cdc2043a0cbdf8180f23c563cf1aecf22e20e98f38add44646a4675fee8728e00e77bcfb27cf9ce0e7b1d2 - languageName: node - linkType: hard - "jest-get-type@npm:^27.5.1": version: 27.5.1 resolution: "jest-get-type@npm:27.5.1" @@ -13370,62 +10136,6 @@ __metadata: languageName: node linkType: hard -"jest-haste-map@npm:30.2.0": - version: 30.2.0 - resolution: "jest-haste-map@npm:30.2.0" - dependencies: - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - anymatch: "npm:^3.1.3" - fb-watchman: "npm:^2.0.2" - fsevents: "npm:^2.3.3" - graceful-fs: "npm:^4.2.11" - jest-regex-util: "npm:30.0.1" - jest-util: "npm:30.2.0" - jest-worker: "npm:30.2.0" - micromatch: "npm:^4.0.8" - walker: "npm:^1.0.8" - dependenciesMeta: - fsevents: - optional: true - checksum: 10c0/61b4ad5a59b4dfadac2f903f3d723d9017aada268c49b9222ec1e15c4892fd4c36af59b65f37f026d747d829672ab9679509fea5d4248d07a93b892963e1bb4e - languageName: node - linkType: hard - -"jest-junit@npm:^13.2.0": - version: 13.2.0 - resolution: "jest-junit@npm:13.2.0" - dependencies: - mkdirp: "npm:^1.0.4" - strip-ansi: "npm:^6.0.1" - uuid: "npm:^8.3.2" - xml: "npm:^1.0.1" - checksum: 10c0/c77c8fb91d9250ed062cf2e36243b5876bed1bf47a168fa3c73acd9c90ad49929e08fe52fe5b1ef7d65ad29a5e00838a696894b28372f5d89e489934e85ea1b5 - languageName: node - linkType: hard - -"jest-leak-detector@npm:30.2.0": - version: 30.2.0 - resolution: "jest-leak-detector@npm:30.2.0" - dependencies: - "@jest/get-type": "npm:30.1.0" - pretty-format: "npm:30.2.0" - checksum: 10c0/68e2822aabe302983b65a08b19719a2444259af8a23ff20a6e2b6ce7759f55730f51c7cf16c65cb6be930c80a6cc70a4820239c84e8f333c9670a8e3a4a21801 - languageName: node - linkType: hard - -"jest-matcher-utils@npm:30.2.0": - version: 30.2.0 - resolution: "jest-matcher-utils@npm:30.2.0" - dependencies: - "@jest/get-type": "npm:30.1.0" - chalk: "npm:^4.1.2" - jest-diff: "npm:30.2.0" - pretty-format: "npm:30.2.0" - checksum: 10c0/f221c8afa04cee693a2be735482c5db4ec6f845f8ca3a04cb419be34c6257f4531dab89c836251f31d1859318c38997e8e9f34bf7b4cdcc8c7be8ae6e2ecb9f2 - languageName: node - linkType: hard - "jest-matcher-utils@npm:^27.0.0": version: 27.5.1 resolution: "jest-matcher-utils@npm:27.5.1" @@ -13438,269 +10148,7 @@ __metadata: languageName: node linkType: hard -"jest-message-util@npm:30.2.0": - version: 30.2.0 - resolution: "jest-message-util@npm:30.2.0" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@jest/types": "npm:30.2.0" - "@types/stack-utils": "npm:^2.0.3" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - micromatch: "npm:^4.0.8" - pretty-format: "npm:30.2.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.6" - checksum: 10c0/9c4aae95f9e73a754e5ecababa06e5c00cf549ff1651bbbf9aadc671ee57e688b01606ef0e9932d9dfe3d4b8f4511b6e8d01e131a49d2f82761c820ab93ae519 - languageName: node - linkType: hard - -"jest-mock-extended@npm:^4.0.0": - version: 4.0.0 - resolution: "jest-mock-extended@npm:4.0.0" - dependencies: - ts-essentials: "npm:^10.0.2" - peerDependencies: - "@jest/globals": ^28.0.0 || ^29.0.0 || ^30.0.0 - jest: ^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0 || ^30.0.0 - typescript: ^3.0.0 || ^4.0.0 || ^5.0.0 - checksum: 10c0/ba51e8e49dba995a54bd9c45aa4d27e6b64a6199289c92292a286117d3f8a733fe480fc56b6d356505696fc79af193625892f5022ea69243525e706515a93249 - languageName: node - linkType: hard - -"jest-mock@npm:30.2.0": - version: 30.2.0 - resolution: "jest-mock@npm:30.2.0" - dependencies: - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - jest-util: "npm:30.2.0" - checksum: 10c0/dfc8eb87f4075242f1b31d9dcac606f945c4f6a245d2bb67273738d266bea6345e10de3afa675076d545361bc96b754f764cffb0ccc2e99767484bece981b2f8 - languageName: node - linkType: hard - -"jest-pnp-resolver@npm:^1.2.3": - version: 1.2.3 - resolution: "jest-pnp-resolver@npm:1.2.3" - peerDependencies: - jest-resolve: "*" - peerDependenciesMeta: - jest-resolve: - optional: true - checksum: 10c0/86eec0c78449a2de733a6d3e316d49461af6a858070e113c97f75fb742a48c2396ea94150cbca44159ffd4a959f743a47a8b37a792ef6fdad2cf0a5cba973fac - languageName: node - linkType: hard - -"jest-regex-util@npm:30.0.1": - version: 30.0.1 - resolution: "jest-regex-util@npm:30.0.1" - checksum: 10c0/f30c70524ebde2d1012afe5ffa5691d5d00f7d5ba9e43d588f6460ac6fe96f9e620f2f9b36a02d0d3e7e77bc8efb8b3450ae3b80ac53c8be5099e01bf54f6728 - languageName: node - linkType: hard - -"jest-resolve-dependencies@npm:30.2.0": - version: 30.2.0 - resolution: "jest-resolve-dependencies@npm:30.2.0" - dependencies: - jest-regex-util: "npm:30.0.1" - jest-snapshot: "npm:30.2.0" - checksum: 10c0/f98f2187b490f402dd9ed6b15b5d324b1220d250a5768d46b1f1582cef05b830311351532a7d19f1868a2ce0049856ae6c26587f3869995cae7850739088b879 - languageName: node - linkType: hard - -"jest-resolve@npm:30.2.0": - version: 30.2.0 - resolution: "jest-resolve@npm:30.2.0" - dependencies: - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.2.0" - jest-pnp-resolver: "npm:^1.2.3" - jest-util: "npm:30.2.0" - jest-validate: "npm:30.2.0" - slash: "npm:^3.0.0" - unrs-resolver: "npm:^1.7.11" - checksum: 10c0/149576b81609a79889d08298a95d52920839f796d24f8701beacaf998a4916df205acf86b64d0bc294172a821b88d144facf44ae5a4cb3cfaa03fa06a3fc666d - languageName: node - linkType: hard - -"jest-runner@npm:30.2.0": - version: 30.2.0 - resolution: "jest-runner@npm:30.2.0" - dependencies: - "@jest/console": "npm:30.2.0" - "@jest/environment": "npm:30.2.0" - "@jest/test-result": "npm:30.2.0" - "@jest/transform": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - emittery: "npm:^0.13.1" - exit-x: "npm:^0.2.2" - graceful-fs: "npm:^4.2.11" - jest-docblock: "npm:30.2.0" - jest-environment-node: "npm:30.2.0" - jest-haste-map: "npm:30.2.0" - jest-leak-detector: "npm:30.2.0" - jest-message-util: "npm:30.2.0" - jest-resolve: "npm:30.2.0" - jest-runtime: "npm:30.2.0" - jest-util: "npm:30.2.0" - jest-watcher: "npm:30.2.0" - jest-worker: "npm:30.2.0" - p-limit: "npm:^3.1.0" - source-map-support: "npm:0.5.13" - checksum: 10c0/68cb5eb993b4a02143fc442c245b17567432709879ad5f859fec635ccdf4ad0ef128c9fc6765c1582b3f5136b36cad5c5dd173926081bfc527d490b27406383e - languageName: node - linkType: hard - -"jest-runtime@npm:30.2.0": - version: 30.2.0 - resolution: "jest-runtime@npm:30.2.0" - dependencies: - "@jest/environment": "npm:30.2.0" - "@jest/fake-timers": "npm:30.2.0" - "@jest/globals": "npm:30.2.0" - "@jest/source-map": "npm:30.0.1" - "@jest/test-result": "npm:30.2.0" - "@jest/transform": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - cjs-module-lexer: "npm:^2.1.0" - collect-v8-coverage: "npm:^1.0.2" - glob: "npm:^10.3.10" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.2.0" - jest-message-util: "npm:30.2.0" - jest-mock: "npm:30.2.0" - jest-regex-util: "npm:30.0.1" - jest-resolve: "npm:30.2.0" - jest-snapshot: "npm:30.2.0" - jest-util: "npm:30.2.0" - slash: "npm:^3.0.0" - strip-bom: "npm:^4.0.0" - checksum: 10c0/d77b7eb75485f2b4913f635aeffa8e3e1b9baafb7a7f901f3c212195beb31f519e4b03358b5e454caee5cc94a2b9952c962fa7e5b0ff2ed06009a661924fd23e - languageName: node - linkType: hard - -"jest-snapshot@npm:30.2.0": - version: 30.2.0 - resolution: "jest-snapshot@npm:30.2.0" - dependencies: - "@babel/core": "npm:^7.27.4" - "@babel/generator": "npm:^7.27.5" - "@babel/plugin-syntax-jsx": "npm:^7.27.1" - "@babel/plugin-syntax-typescript": "npm:^7.27.1" - "@babel/types": "npm:^7.27.3" - "@jest/expect-utils": "npm:30.2.0" - "@jest/get-type": "npm:30.1.0" - "@jest/snapshot-utils": "npm:30.2.0" - "@jest/transform": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - babel-preset-current-node-syntax: "npm:^1.2.0" - chalk: "npm:^4.1.2" - expect: "npm:30.2.0" - graceful-fs: "npm:^4.2.11" - jest-diff: "npm:30.2.0" - jest-matcher-utils: "npm:30.2.0" - jest-message-util: "npm:30.2.0" - jest-util: "npm:30.2.0" - pretty-format: "npm:30.2.0" - semver: "npm:^7.7.2" - synckit: "npm:^0.11.8" - checksum: 10c0/961b13a3c9dcf8c533fe2ab8375bcdf441bd8680a7a7878245d8d8a4697432d806f7817cfaa061904e0c6cc939a38f1fe9f5af868b86328e77833a58822b3b63 - languageName: node - linkType: hard - -"jest-util@npm:30.2.0": - version: 30.2.0 - resolution: "jest-util@npm:30.2.0" - dependencies: - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - graceful-fs: "npm:^4.2.11" - picomatch: "npm:^4.0.2" - checksum: 10c0/896d663554b35258a87ec1a0a0fdd8741fdf4f3239d09fc52fdd88fa5c411a5ece7903bbbbd7d5194743fcb69f62afc3287e90f57736a91e7df95ad421937936 - languageName: node - linkType: hard - -"jest-validate@npm:30.2.0": - version: 30.2.0 - resolution: "jest-validate@npm:30.2.0" - dependencies: - "@jest/get-type": "npm:30.1.0" - "@jest/types": "npm:30.2.0" - camelcase: "npm:^6.3.0" - chalk: "npm:^4.1.2" - leven: "npm:^3.1.0" - pretty-format: "npm:30.2.0" - checksum: 10c0/56566643d79ca07f021fa14cebb62c423ae405757cb8d742113ff0070f0761b80c77f665fac8d89622faaab71fc5452e1471939028187a88c8445303d7976255 - languageName: node - linkType: hard - -"jest-watcher@npm:30.2.0": - version: 30.2.0 - resolution: "jest-watcher@npm:30.2.0" - dependencies: - "@jest/test-result": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - ansi-escapes: "npm:^4.3.2" - chalk: "npm:^4.1.2" - emittery: "npm:^0.13.1" - jest-util: "npm:30.2.0" - string-length: "npm:^4.0.2" - checksum: 10c0/51587968fabb5b180383d638a04db253b82d9cc3f53fbba06ba7b0544146178d50becc090aca7931e2d4eb9aa1624bb3fbd1a2571484c9391554404e8b5d8fe7 - languageName: node - linkType: hard - -"jest-worker@npm:30.2.0": - version: 30.2.0 - resolution: "jest-worker@npm:30.2.0" - dependencies: - "@types/node": "npm:*" - "@ungap/structured-clone": "npm:^1.3.0" - jest-util: "npm:30.2.0" - merge-stream: "npm:^2.0.0" - supports-color: "npm:^8.1.1" - checksum: 10c0/1ea47f6c682ba6cdbd50630544236aabccacf1d88335607206c10871a9777a45b0fc6336c8eb6344e32e69dd7681de17b2199b4d4552b00d48aade303627125c - languageName: node - linkType: hard - -"jest-worker@npm:^27.4.5": - version: 27.5.1 - resolution: "jest-worker@npm:27.5.1" - dependencies: - "@types/node": "npm:*" - merge-stream: "npm:^2.0.0" - supports-color: "npm:^8.0.0" - checksum: 10c0/8c4737ffd03887b3c6768e4cc3ca0269c0336c1e4b1b120943958ddb035ed2a0fc6acab6dc99631720a3720af4e708ff84fb45382ad1e83c27946adf3623969b - languageName: node - linkType: hard - -"jest@npm:30.2.0": - version: 30.2.0 - resolution: "jest@npm:30.2.0" - dependencies: - "@jest/core": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - import-local: "npm:^3.2.0" - jest-cli: "npm:30.2.0" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - bin: - jest: ./bin/jest.js - checksum: 10c0/af580c6e265d21870c2c98e31f17f2f5cb5c9e6cf9be26b95eaf4fad4140a01579f3b5844d4264cd8357eb24908e95f983ea84d20b8afef46e62aed3dd9452eb - languageName: node - linkType: hard - -"jiti@npm:^2.6.1": +"jiti@npm:2.6.1": version: 2.6.1 resolution: "jiti@npm:2.6.1" bin: @@ -13716,13 +10164,6 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.15.4": - version: 4.15.9 - resolution: "jose@npm:4.15.9" - checksum: 10c0/4ed4ddf4a029db04bd167f2215f65d7245e4dc5f36d7ac3c0126aab38d66309a9e692f52df88975d99429e357e5fd8bab340ff20baab544d17684dd1d940a0f4 - languageName: node - linkType: hard - "js-beautify@npm:^1.6.14": version: 1.15.4 resolution: "js-beautify@npm:1.15.4" @@ -13741,9 +10182,9 @@ __metadata: linkType: hard "js-cookie@npm:^3.0.5": - version: 3.0.5 - resolution: "js-cookie@npm:3.0.5" - checksum: 10c0/04a0e560407b4489daac3a63e231d35f4e86f78bff9d792011391b49c59f721b513411cd75714c418049c8dc9750b20fcddad1ca5a2ca616c3aca4874cce5b3a + version: 3.0.8 + resolution: "js-cookie@npm:3.0.8" + checksum: 10c0/421912a4a55535bda32b3059835864e1182c3af5b4516df00a060edc1fa5a53d38bb8d5a91d5d305e396206f4fd11e829f17850aa5aa8164118c04d8ebf1ff5d languageName: node linkType: hard @@ -13754,6 +10195,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^10.0.0": + version: 10.0.0 + resolution: "js-tokens@npm:10.0.0" + checksum: 10c0/a93498747812ba3e0c8626f95f75ab29319f2a13613a0de9e610700405760931624433a0de59eb7c27ff8836e526768fb20783861b86ef89be96676f2c996b64 + languageName: node + linkType: hard + "js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -13761,26 +10209,37 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:4.1.1, js-yaml@npm:^4.1.0": - version: 4.1.1 - resolution: "js-yaml@npm:4.1.1" +"js-yaml@npm:5.4.1": + version: 5.4.1 + resolution: "js-yaml@npm:5.4.1" dependencies: argparse: "npm:^2.0.1" bin: - js-yaml: bin/js-yaml.js - checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 + js-yaml: bin/js-yaml.mjs + checksum: 10c0/efe9dfaf222809694d375ad5ecca825561d2432eb55bc0f628911c0dfa4dbce98a71dcbc3252f630208bbdf5f4e9ac363847e4a7f49845b577ae2548b8fcf147 languageName: node linkType: hard "js-yaml@npm:^3.13.1": - version: 3.14.1 - resolution: "js-yaml@npm:3.14.1" + version: 3.14.2 + resolution: "js-yaml@npm:3.14.2" dependencies: argparse: "npm:^1.0.7" esprima: "npm:^4.0.0" bin: js-yaml: bin/js-yaml.js - checksum: 10c0/6746baaaeac312c4db8e75fa22331d9a04cccb7792d126ed8ce6a0bbcfef0cedaddd0c5098fade53db067c09fe00aa1c957674b4765610a8b06a5a189e46433b + checksum: 10c0/3261f25912f5dd76605e5993d0a126c2b6c346311885d3c483706cd722efe34f697ea0331f654ce27c00a42b426e524518ec89d65ed02ea47df8ad26dcc8ce69 + languageName: node + linkType: hard + +"js-yaml@npm:^4.1.0, js-yaml@npm:^4.1.1": + version: 4.2.0 + resolution: "js-yaml@npm:4.2.0" + dependencies: + argparse: "npm:^2.0.1" + bin: + js-yaml: bin/js-yaml.js + checksum: 10c0/1916456c118746603b067d74bbcbb0445d9a1d5e474ad4ae775e7b20525bed902e01d9d97dd0c81fcd8d4f596162309d0eb057f4aa38f3e9647f14075e9dea45 languageName: node linkType: hard @@ -13791,19 +10250,10 @@ __metadata: languageName: node linkType: hard -"jsdoc-type-pratt-parser@npm:~6.10.0": - version: 6.10.0 - resolution: "jsdoc-type-pratt-parser@npm:6.10.0" - checksum: 10c0/8ea395df0cae0e41d4bdba5f8d81b8d3e467fe53d1e4182a5d4e653235a5f17d60ed137343d68dbc74fa10e767f1c58fb85b1f6d5489c2cf16fc7216cc6d3e1a - languageName: node - linkType: hard - -"jsesc@npm:^3.0.2": - version: 3.1.0 - resolution: "jsesc@npm:3.1.0" - bin: - jsesc: bin/jsesc - checksum: 10c0/531779df5ec94f47e462da26b4cbf05eb88a83d9f08aac2ba04206508fc598527a153d08bd462bae82fc78b3eaa1a908e1a4a79f886e9238641c4cdefaf118b1 +"jsdoc-type-pratt-parser@npm:~7.0.0": + version: 7.0.0 + resolution: "jsdoc-type-pratt-parser@npm:7.0.0" + checksum: 10c0/3ede53c80dddf940a51dcdc79e3923537650f6fb6e9001fc76023c2d5cb0195cc8b24b7eebf9b3f20a7bc00d5e6b7f70318f0b8cb5972f6aff884152e6698014 languageName: node linkType: hard @@ -13821,7 +10271,7 @@ __metadata: languageName: node linkType: hard -"json-parse-even-better-errors@npm:^2.3.0, json-parse-even-better-errors@npm:^2.3.1": +"json-parse-even-better-errors@npm:^2.3.0": version: 2.3.1 resolution: "json-parse-even-better-errors@npm:2.3.1" checksum: 10c0/140932564c8f0b88455432e0f33c4cb4086b8868e37524e07e723f4eaedb9425bdc2bafd71bd1d9765bd15fd1e2d126972bc83990f55c467168c228c24d665f3 @@ -13856,7 +10306,7 @@ __metadata: languageName: node linkType: hard -"json-stringify-safe@npm:5.0.1, json-stringify-safe@npm:^5.0.1, json-stringify-safe@npm:~5.0.1": +"json-stringify-safe@npm:^5.0.1, json-stringify-safe@npm:~5.0.1": version: 5.0.1 resolution: "json-stringify-safe@npm:5.0.1" checksum: 10c0/7dbf35cd0411d1d648dceb6d59ce5857ec939e52e4afc37601aa3da611f0987d5cee5b38d58329ceddf3ed48bd7215229c8d52059ab01f2444a338bf24ed0f37 @@ -13874,7 +10324,7 @@ __metadata: languageName: node linkType: hard -"json5@npm:^2.2.2, json5@npm:^2.2.3": +"json5@npm:^2.2.2": version: 2.2.3 resolution: "json5@npm:2.2.3" bin: @@ -13884,14 +10334,14 @@ __metadata: linkType: hard "jsonc-eslint-parser@npm:^2.4.1": - version: 2.4.1 - resolution: "jsonc-eslint-parser@npm:2.4.1" + version: 2.4.2 + resolution: "jsonc-eslint-parser@npm:2.4.2" dependencies: acorn: "npm:^8.5.0" eslint-visitor-keys: "npm:^3.0.0" espree: "npm:^9.0.0" semver: "npm:^7.3.5" - checksum: 10c0/735bd33435fee002bf7f07d23ba969b994971ab3b333a0e2641b79cd413819fe36540ba6ed29da9ebc69062625e8bfb167ff4415321f9640fdd9d0cf92dfa999 + checksum: 10c0/821af0231cb8eba2afde34535bd76d723ed5b4b3711fdccff3f300f32de7bbf463ceab3ce2de9e6fd78dc3b24a9c7248ca12eb43e280cfa2695f49bd16908f87 languageName: node linkType: hard @@ -13921,19 +10371,6 @@ __metadata: languageName: node linkType: hard -"jsonfile@npm:^6.0.1": - version: 6.2.0 - resolution: "jsonfile@npm:6.2.0" - dependencies: - graceful-fs: "npm:^4.1.6" - universalify: "npm:^2.0.0" - dependenciesMeta: - graceful-fs: - optional: true - checksum: 10c0/7f4f43b08d1869ded8a6822213d13ae3b99d651151d77efd1557ced0889c466296a7d9684e397bd126acf5eb2cfcb605808c3e681d0fdccd2fe5a04b47e76c0d - languageName: node - linkType: hard - "jsonparse@npm:^1.2.0": version: 1.3.1 resolution: "jsonparse@npm:1.3.1" @@ -13948,11 +10385,11 @@ __metadata: languageName: node linkType: hard -"jsonwebtoken@npm:9.0.2, jsonwebtoken@npm:^9.0.0, jsonwebtoken@npm:^9.0.2": - version: 9.0.2 - resolution: "jsonwebtoken@npm:9.0.2" +"jsonwebtoken@npm:9.0.3, jsonwebtoken@npm:^9.0.0": + version: 9.0.3 + resolution: "jsonwebtoken@npm:9.0.3" dependencies: - jws: "npm:^3.2.2" + jws: "npm:^4.0.1" lodash.includes: "npm:^4.3.0" lodash.isboolean: "npm:^3.0.3" lodash.isinteger: "npm:^4.0.4" @@ -13962,7 +10399,7 @@ __metadata: lodash.once: "npm:^4.0.0" ms: "npm:^2.1.1" semver: "npm:^7.5.4" - checksum: 10c0/d287a29814895e866db2e5a0209ce730cbc158441a0e5a70d5e940eb0d28ab7498c6bf45029cc8b479639bca94056e9a7f254e2cdb92a2f5750c7f358657a131 + checksum: 10c0/6ca7f1e54886ea3bde7146a5a22b53847c46e25453c7f7307a69818b9a6ad48c390b2e59d5690fcfd03c529b01960060cc4bb0c686991d6edae2285dfd30f4ba languageName: node linkType: hard @@ -14003,38 +10440,24 @@ __metadata: languageName: node linkType: hard -"jwa@npm:^1.4.1": - version: 1.4.2 - resolution: "jwa@npm:1.4.2" +"jwa@npm:^2.0.1": + version: 2.0.1 + resolution: "jwa@npm:2.0.1" dependencies: buffer-equal-constant-time: "npm:^1.0.1" ecdsa-sig-formatter: "npm:1.0.11" safe-buffer: "npm:^5.0.1" - checksum: 10c0/210a544a42ca22203e8fc538835205155ba3af6a027753109f9258bdead33086bac3c25295af48ac1981f87f9c5f941bc8f70303670f54ea7dcaafb53993d92c - languageName: node - linkType: hard - -"jwks-rsa@npm:^3.1.0": - version: 3.2.0 - resolution: "jwks-rsa@npm:3.2.0" - dependencies: - "@types/express": "npm:^4.17.20" - "@types/jsonwebtoken": "npm:^9.0.4" - debug: "npm:^4.3.4" - jose: "npm:^4.15.4" - limiter: "npm:^1.1.5" - lru-memoizer: "npm:^2.2.0" - checksum: 10c0/94896264473c8ec0ec21b8f29fd69b760ccb58ff63e6d5328d99694dc49a9be1d6f739fa536c71ca279966874e6c77b405181ed2c567318e0f545d3e941c318e + checksum: 10c0/ab3ebc6598e10dc11419d4ed675c9ca714a387481466b10e8a6f3f65d8d9c9237e2826f2505280a739cf4cbcf511cb288eeec22b5c9c63286fc5a2e4f97e78cf languageName: node linkType: hard -"jws@npm:^3.2.2": - version: 3.2.2 - resolution: "jws@npm:3.2.2" +"jws@npm:^4.0.1": + version: 4.0.1 + resolution: "jws@npm:4.0.1" dependencies: - jwa: "npm:^1.4.1" + jwa: "npm:^2.0.1" safe-buffer: "npm:^5.0.1" - checksum: 10c0/e770704533d92df358adad7d1261fdecad4d7b66fa153ba80d047e03ca0f1f73007ce5ed3fbc04d2eba09ba6e7e6e645f351e08e5ab51614df1b0aa4f384dfff + checksum: 10c0/6be1ed93023aef570ccc5ea8d162b065840f3ef12f0d1bb3114cade844de7a357d5dc558201d9a65101e70885a6fa56b17462f520e6b0d426195510618a154d0 languageName: node linkType: hard @@ -14072,10 +10495,10 @@ __metadata: languageName: node linkType: hard -"leac@npm:^0.6.0": - version: 0.6.0 - resolution: "leac@npm:0.6.0" - checksum: 10c0/5257781e10791ef8462eb1cbe5e48e3cda7692486f2a775265d6f5216cc088960c62f138163b8df0dcf2119d18673bfe7b050d6b41543d92a7b7ac90e4eb1e8b +"leac@npm:^0.7.0": + version: 0.7.0 + resolution: "leac@npm:0.7.0" + checksum: 10c0/befd28f1adcef2039e6f071c44f2565dab0ac7e03b54ea35447786ef9ff3d10879d02cd0a7ed3d872f7d038c22890b30fce4518106d95fb94e5ac2947b486dda languageName: node linkType: hard @@ -14107,13 +10530,6 @@ __metadata: languageName: node linkType: hard -"leven@npm:^3.1.0": - version: 3.1.0 - resolution: "leven@npm:3.1.0" - checksum: 10c0/cd778ba3fbab0f4d0500b7e87d1f6e1f041507c56fdcd47e8256a3012c98aaee371d4c15e0a76e0386107af2d42e2b7466160a2d80688aaa03e66e49949f42df - languageName: node - linkType: hard - "levn@npm:^0.4.1": version: 0.4.1 resolution: "levn@npm:0.4.1" @@ -14131,22 +10547,15 @@ __metadata: languageName: node linkType: hard -"libmime@npm:5.3.7": - version: 5.3.7 - resolution: "libmime@npm:5.3.7" +"libmime@npm:5.3.8": + version: 5.3.8 + resolution: "libmime@npm:5.3.8" dependencies: encoding-japanese: "npm:2.2.0" - iconv-lite: "npm:0.6.3" + iconv-lite: "npm:0.7.2" libbase64: "npm:1.3.0" libqp: "npm:2.1.1" - checksum: 10c0/2c8afbe287df533b983b8eb6db0e98d4eb79c833aca7aac531994168d30dd6194a90d78cefb320427183853422be1c86511ab975b1a3fc9f1fc69ce1b83bb7c0 - languageName: node - linkType: hard - -"libphonenumber-js@npm:^1.11.1": - version: 1.12.26 - resolution: "libphonenumber-js@npm:1.12.26" - checksum: 10c0/20d79a32f4ec8d4d67d32897085cfd74a2a4c950b1ff1b6bedc35da37f2f369a8b0a8798d0e8a7b4b91ad8633ef6476320f52051af489fbe70e35e0271e39da9 + checksum: 10c0/4fb65f7c07a9bdb0c0ee25bb80fac94a803807f9ac15a3d6cafc2c93303500d92c8ceb6dfe0ab938c871406fc01cb69a7a457f28b2037c47c283574e8900d8c4 languageName: node linkType: hard @@ -14157,22 +10566,123 @@ __metadata: languageName: node linkType: hard -"light-my-request@npm:^4.2.0": - version: 4.12.0 - resolution: "light-my-request@npm:4.12.0" - dependencies: - ajv: "npm:^8.1.0" - cookie: "npm:^0.5.0" - process-warning: "npm:^1.0.0" - set-cookie-parser: "npm:^2.4.1" - checksum: 10c0/d95b9e5bec2fe32ec02d77791c2e5786358f396f27c9c15c97f224e8bb94e357e143f89b67856f20ee2f980136f2c9ac3a934177f8d10b3c184a81c578b00e6f +"lightningcss-android-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-android-arm64@npm:1.32.0" + conditions: os=android & cpu=arm64 languageName: node linkType: hard -"limiter@npm:^1.1.5": - version: 1.1.5 - resolution: "limiter@npm:1.1.5" - checksum: 10c0/ebe2b20a820d1f67b8e1724051246434c419b2da041a7e9cd943f6daf113b8d17a52a1bd88fb79be5b624c10283ecb737f50edb5c1c88c71f4cd367108c97300 +"lightningcss-darwin-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-arm64@npm:1.32.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-x64@npm:1.32.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-freebsd-x64@npm:1.32.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-musl@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:^1.32.0": + version: 1.32.0 + resolution: "lightningcss@npm:1.32.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.32.0" + lightningcss-darwin-arm64: "npm:1.32.0" + lightningcss-darwin-x64: "npm:1.32.0" + lightningcss-freebsd-x64: "npm:1.32.0" + lightningcss-linux-arm-gnueabihf: "npm:1.32.0" + lightningcss-linux-arm64-gnu: "npm:1.32.0" + lightningcss-linux-arm64-musl: "npm:1.32.0" + lightningcss-linux-x64-gnu: "npm:1.32.0" + lightningcss-linux-x64-musl: "npm:1.32.0" + lightningcss-win32-arm64-msvc: "npm:1.32.0" + lightningcss-win32-x64-msvc: "npm:1.32.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 languageName: node linkType: hard @@ -14183,12 +10693,12 @@ __metadata: languageName: node linkType: hard -"linkify-it@npm:5.0.0, linkify-it@npm:^5.0.0": - version: 5.0.0 - resolution: "linkify-it@npm:5.0.0" +"linkify-it@npm:5.0.1, linkify-it@npm:^5.0.0": + version: 5.0.1 + resolution: "linkify-it@npm:5.0.1" dependencies: uc.micro: "npm:^2.0.0" - checksum: 10c0/ff4abbcdfa2003472fc3eb4b8e60905ec97718e11e33cca52059919a4c80cc0e0c2a14d23e23d8c00e5402bc5a885cdba8ca053a11483ab3cc8b3c7a52f88e2d + checksum: 10c0/d06d04f1ed03be131740fc900a5e74ea1f49886b052213599e306d469d5ffe2303db76dd8f771de9f28e2b0b38852de22ec46ae597d245f8b66439b0ceb19b10 languageName: node linkType: hard @@ -14237,13 +10747,6 @@ __metadata: languageName: node linkType: hard -"loader-runner@npm:^4.2.0": - version: 4.3.1 - resolution: "loader-runner@npm:4.3.1" - checksum: 10c0/a523b6329f114e0a98317158e30a7dfce044b731521be5399464010472a93a15ece44757d1eaed1d8845019869c5390218bc1c7c3110f4eeaef5157394486eac - languageName: node - linkType: hard - "locate-path@npm:^2.0.0": version: 2.0.0 resolution: "locate-path@npm:2.0.0" @@ -14375,13 +10878,6 @@ __metadata: languageName: node linkType: hard -"lodash.memoize@npm:^4.1.2": - version: 4.1.2 - resolution: "lodash.memoize@npm:4.1.2" - checksum: 10c0/c8713e51eccc650422716a14cece1809cfe34bc5ab5e242b7f8b4e2241c2483697b971a604252807689b9dd69bfe3a98852e19a5b89d506b000b4187a1285df8 - languageName: node - linkType: hard - "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -14432,12 +10928,12 @@ __metadata: linkType: hard "lodash.template@npm:^4.0.2, lodash.template@npm:^4.5.0": - version: 4.5.0 - resolution: "lodash.template@npm:4.5.0" + version: 4.18.1 + resolution: "lodash.template@npm:4.18.1" dependencies: lodash._reinterpolate: "npm:^3.0.0" lodash.templatesettings: "npm:^4.0.0" - checksum: 10c0/62a02b397f72542fa9a989d9fc1a94fc1cb94ced8009fa5c37956746c0cf460279e844126c2abfbf7e235fe27e8b7ee8e6efbf6eac247a06aa05b05457fda817 + checksum: 10c0/b63531cd665533f84960f3ef239b0aea96b4bdac6bc5e8c5d94bab4ba6ecbde5095cc8c8d65f765391273925321203047f2dc702471e8ccb40acf44ea10bda91 languageName: node linkType: hard @@ -14464,10 +10960,10 @@ __metadata: languageName: node linkType: hard -"lodash@npm:4.17.21, lodash@npm:^4.17.12, lodash@npm:^4.17.15, lodash@npm:^4.17.21, lodash@npm:^4.2.1": - version: 4.17.21 - resolution: "lodash@npm:4.17.21" - checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c +"lodash@npm:^4.17.12, lodash@npm:^4.17.15, lodash@npm:^4.17.21, lodash@npm:^4.2.1": + version: 4.18.1 + resolution: "lodash@npm:4.18.1" + checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27 languageName: node linkType: hard @@ -14481,6 +10977,16 @@ __metadata: languageName: node linkType: hard +"log-symbols@npm:^7.0.1": + version: 7.0.1 + resolution: "log-symbols@npm:7.0.1" + dependencies: + is-unicode-supported: "npm:^2.0.0" + yoctocolors: "npm:^2.1.1" + checksum: 10c0/71d30f9a44b8604b14df5e7c9b579d739997253db7385339d493ece41ee2cc74c1f96c5b4c0b2c1e0829b05348d4f287e68faab495b7a094a80f51351c816075 + languageName: node + linkType: hard + "loud-rejection@npm:^1.0.0": version: 1.6.0 resolution: "loud-rejection@npm:1.6.0" @@ -14498,15 +11004,6 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:6.0.0, lru-cache@npm:^6.0.0": - version: 6.0.0 - resolution: "lru-cache@npm:6.0.0" - dependencies: - yallist: "npm:^4.0.0" - checksum: 10c0/cb53e582785c48187d7a188d3379c181b5ca2a9c78d2bce3e7dee36f32761d1c42983da3fe12b55cb74e1779fa94cdc2e5367c028a9b35317184ede0c07a30a9 - languageName: node - linkType: hard - "lru-cache@npm:^10.2.0": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" @@ -14514,10 +11011,10 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1": - version: 11.2.2 - resolution: "lru-cache@npm:11.2.2" - checksum: 10c0/72d7831bbebc85e2bdefe01047ee5584db69d641c48d7a509e86f66f6ee111b30af7ec3bd68a967d47b69a4b1fa8bbf3872630bd06a63b6735e6f0a5f1c8e83d +"lru-cache@npm:^11.0.0": + version: 11.5.1 + resolution: "lru-cache@npm:11.5.1" + checksum: 10c0/7b341cea79a8efe9c6a6f20c8757a77eca5b25d7ff983ccf4e11e547b81f6787824baa1c84705251dff84ab4ffac85717ac354b9d02e465f86a9f8b166409979 languageName: node linkType: hard @@ -14530,13 +11027,12 @@ __metadata: languageName: node linkType: hard -"lru-memoizer@npm:^2.2.0": - version: 2.3.0 - resolution: "lru-memoizer@npm:2.3.0" +"lru-cache@npm:^6.0.0": + version: 6.0.0 + resolution: "lru-cache@npm:6.0.0" dependencies: - lodash.clonedeep: "npm:^4.5.0" - lru-cache: "npm:6.0.0" - checksum: 10c0/13cf6bc9ff74cdb167078dbb66d4cf43adc802495da8f56097e6f388b4d7ccb91668beb809bdbc55b62d016c138d7c19a18c5883a2fdbcc7f508ad8a23ec7c65 + yallist: "npm:^4.0.0" + checksum: 10c0/cb53e582785c48187d7a188d3379c181b5ca2a9c78d2bce3e7dee36f32761d1c42983da3fe12b55cb74e1779fa94cdc2e5367c028a9b35317184ede0c07a30a9 languageName: node linkType: hard @@ -14554,30 +11050,50 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:0.30.17": - version: 0.30.17 - resolution: "magic-string@npm:0.30.17" +"magic-string@npm:1.0.0": + version: 1.0.0 + resolution: "magic-string@npm:1.0.0" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/89a21967ce44f0a81136eb57d42449731262e300bab5442ccf9c61e054e90dbd1a1895ad5b9cf8a82d8945463b53cdbafc43db6bca0551b635a4942e0866e60f + languageName: node + linkType: hard + +"magic-string@npm:^0.30.21": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + +"magicast@npm:^0.5.2": + version: 0.5.3 + resolution: "magicast@npm:0.5.3" dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.5.0" - checksum: 10c0/16826e415d04b88378f200fe022b53e638e3838b9e496edda6c0e086d7753a44a6ed187adc72d19f3623810589bf139af1a315541cd6a26ae0771a0193eaf7b8 + "@babel/parser": "npm:^7.29.3" + "@babel/types": "npm:^7.29.0" + source-map-js: "npm:^1.2.1" + checksum: 10c0/e288c027ae5f2a794a59148cb114f4b60f1d5c03090de6c60b4d187f12d1de9158779cd7c39cea391609f4f10cd7ea737929f25f7ce44f7a96ba96ec1a477e39 languageName: node linkType: hard "mailparser@npm:^3.6.4": - version: 3.9.0 - resolution: "mailparser@npm:3.9.0" + version: 3.9.11 + resolution: "mailparser@npm:3.9.11" dependencies: - "@zone-eu/mailsplit": "npm:5.4.7" + "@zone-eu/mailsplit": "npm:5.4.12" encoding-japanese: "npm:2.2.0" he: "npm:1.2.0" - html-to-text: "npm:9.0.5" - iconv-lite: "npm:0.7.0" - libmime: "npm:5.3.7" - linkify-it: "npm:5.0.0" - nodemailer: "npm:7.0.10" + html-to-text: "npm:10.0.0" + iconv-lite: "npm:0.7.2" + libmime: "npm:5.3.8" + linkify-it: "npm:5.0.1" + nodemailer: "npm:9.0.1" punycode.js: "npm:2.3.1" tlds: "npm:1.261.0" - checksum: 10c0/5b3c320e2669aaecc5683efd105743fd469a216eccd5723eb3d014cf9b92f70258ed8e46cb6ade0b94891eba244ed589ae5faad82b7c1ffd86c3b3543b50df0f + checksum: 10c0/58c438ca70af3616425bb3a6b2cb1de58ec061fcb40bf58b557d8e0d1b12d498ce2c1aac502a8ba638107e31e47cc736271ec0f0a7e29eb5891d7168b1ac65ea languageName: node linkType: hard @@ -14618,32 +11134,6 @@ __metadata: languageName: node linkType: hard -"make-error@npm:^1.1.1, make-error@npm:^1.3.6": - version: 1.3.6 - resolution: "make-error@npm:1.3.6" - checksum: 10c0/171e458d86854c6b3fc46610cfacf0b45149ba043782558c6875d9f42f222124384ad0b468c92e996d815a8a2003817a710c0a160e49c1c394626f76fa45396f - languageName: node - linkType: hard - -"make-fetch-happen@npm:^15.0.0": - version: 15.0.3 - resolution: "make-fetch-happen@npm:15.0.3" - dependencies: - "@npmcli/agent": "npm:^4.0.0" - cacache: "npm:^20.0.1" - http-cache-semantics: "npm:^4.1.1" - minipass: "npm:^7.0.2" - minipass-fetch: "npm:^5.0.0" - minipass-flush: "npm:^1.0.5" - minipass-pipeline: "npm:^1.2.4" - negotiator: "npm:^1.0.0" - proc-log: "npm:^6.0.0" - promise-retry: "npm:^2.0.1" - ssri: "npm:^13.0.0" - checksum: 10c0/525f74915660be60b616bcbd267c4a5b59481b073ba125e45c9c3a041bb1a47a2bd0ae79d028eb6f5f95bf9851a4158423f5068539c3093621abb64027e8e461 - languageName: node - linkType: hard - "make-fetch-happen@npm:^5.0.0": version: 5.0.2 resolution: "make-fetch-happen@npm:5.0.2" @@ -14687,15 +11177,6 @@ __metadata: languageName: node linkType: hard -"makeerror@npm:1.0.12": - version: 1.0.12 - resolution: "makeerror@npm:1.0.12" - dependencies: - tmpl: "npm:1.0.5" - checksum: 10c0/b0e6e599780ce6bab49cc413eba822f7d1f0dfebd1c103eaa3785c59e43e22c59018323cf9e1708f0ef5329e94a745d163fcbb6bff8e4c6742f9be9e86f3500c - languageName: node - linkType: hard - "map-cache@npm:^0.2.2": version: 0.2.2 resolution: "map-cache@npm:0.2.2" @@ -14824,15 +11305,6 @@ __metadata: languageName: node linkType: hard -"memfs@npm:^3.4.1": - version: 3.6.0 - resolution: "memfs@npm:3.6.0" - dependencies: - fs-monkey: "npm:^1.0.4" - checksum: 10c0/af567f9038bbb5bbacf100b35d5839e90a89f882d191d8a1c7002faeb224c6cfcebd0e97c0150e9af8be95ec7b5b75a52af56fcd109d0bc18807c1f4e004f053 - languageName: node - linkType: hard - "mensch@npm:^0.3.4": version: 0.3.4 resolution: "mensch@npm:0.3.4" @@ -14901,28 +11373,28 @@ __metadata: languageName: node linkType: hard -"merge-descriptors@npm:^2.0.0": - version: 2.0.0 - resolution: "merge-descriptors@npm:2.0.0" - checksum: 10c0/95389b7ced3f9b36fbdcf32eb946dc3dd1774c2fdf164609e55b18d03aa499b12bd3aae3a76c1c7185b96279e9803525550d3eb292b5224866060a288f335cb3 +"merge-descriptors@npm:1.0.3": + version: 1.0.3 + resolution: "merge-descriptors@npm:1.0.3" + checksum: 10c0/866b7094afd9293b5ea5dcd82d71f80e51514bed33b4c4e9f516795dc366612a4cbb4dc94356e943a8a6914889a914530badff27f397191b9b75cda20b6bae93 languageName: node linkType: hard -"merge-stream@npm:^2.0.0": +"merge-descriptors@npm:^2.0.0": version: 2.0.0 - resolution: "merge-stream@npm:2.0.0" - checksum: 10c0/867fdbb30a6d58b011449b8885601ec1690c3e41c759ecd5a9d609094f7aed0096c37823ff4a7190ef0b8f22cc86beb7049196ff68c016e3b3c671d0dac91ce5 + resolution: "merge-descriptors@npm:2.0.0" + checksum: 10c0/95389b7ced3f9b36fbdcf32eb946dc3dd1774c2fdf164609e55b18d03aa499b12bd3aae3a76c1c7185b96279e9803525550d3eb292b5224866060a288f335cb3 languageName: node linkType: hard -"merge2@npm:^1.2.3, merge2@npm:^1.3.0": +"merge2@npm:^1.2.3": version: 1.4.1 resolution: "merge2@npm:1.4.1" checksum: 10c0/254a8a4605b58f450308fc474c82ac9a094848081bf4c06778200207820e5193726dc563a0d2c16468810516a5c97d9d3ea0ca6585d23c58ccfff2403e8dbbeb languageName: node linkType: hard -"methods@npm:^1.1.2": +"methods@npm:^1.1.2, methods@npm:~1.1.2": version: 1.1.2 resolution: "methods@npm:1.1.2" checksum: 10c0/bdf7cc72ff0a33e3eede03708c08983c4d7a173f91348b4b1e4f47d4cdbf734433ad971e7d1e8c77247d9e5cd8adb81ea4c67b0a2db526b758b2233d7814b8b2 @@ -14950,16 +11422,6 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.0, micromatch@npm:^4.0.8": - version: 4.0.8 - resolution: "micromatch@npm:4.0.8" - dependencies: - braces: "npm:^3.0.3" - picomatch: "npm:^2.3.1" - checksum: 10c0/166fa6eb926b9553f32ef81f5f531d27b4ce7da60e5baf8c021d043b27a388fb95e46a8038d5045877881e673f8134122b59624d5cecbd16eb50a42e7a6b5ca8 - languageName: node - linkType: hard - "mime-db@npm:1.52.0": version: 1.52.0 resolution: "mime-db@npm:1.52.0" @@ -14974,7 +11436,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:~2.1.19, mime-types@npm:~2.1.24": +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.19, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -14983,12 +11445,21 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^3.0.0, mime-types@npm:^3.0.1": - version: 3.0.1 - resolution: "mime-types@npm:3.0.1" +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.2": + version: 3.0.2 + resolution: "mime-types@npm:3.0.2" dependencies: mime-db: "npm:^1.54.0" - checksum: 10c0/bd8c20d3694548089cf229016124f8f40e6a60bbb600161ae13e45f793a2d5bb40f96bbc61f275836696179c77c1d6bf4967b2a75e0a8ad40fe31f4ed5be4da5 + checksum: 10c0/35a0dd1035d14d185664f346efcdb72e93ef7a9b6e9ae808bd1f6358227010267fab52657b37562c80fc888ff76becb2b2938deb5e730818b7983bf8bd359767 + languageName: node + linkType: hard + +"mime@npm:1.6.0": + version: 1.6.0 + resolution: "mime@npm:1.6.0" + bin: + mime: cli.js + checksum: 10c0/b92cd0adc44888c7135a185bfd0dddc42c32606401c72896a842ae15da71eb88858f17669af41e498b463cd7eb998f7b48939a25b08374c7924a9c8a6f8a81b0 languageName: node linkType: hard @@ -15015,6 +11486,13 @@ __metadata: languageName: node linkType: hard +"mimic-function@npm:^5.0.0": + version: 5.0.1 + resolution: "mimic-function@npm:5.0.1" + checksum: 10c0/f3d9464dd1816ecf6bdf2aec6ba32c0728022039d992f178237d8e289b48764fee4131319e72eedd4f7f094e22ded0af836c3187a7edc4595d28dd74368fd81d + languageName: node + linkType: hard + "mimic-response@npm:^3.1.0": version: 3.1.0 resolution: "mimic-response@npm:3.1.0" @@ -15029,48 +11507,48 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:9.0.1": - version: 9.0.1 - resolution: "minimatch@npm:9.0.1" +"minimatch@npm:10.2.6": + version: 10.2.6 + resolution: "minimatch@npm:10.2.6" dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10c0/aa043eb8822210b39888a5d0d28df0017b365af5add9bd522f180d2a6962de1cbbf1bdeacdb1b17f410dc3336bc8d76fb1d3e814cdc65d00c2f68e01f0010096 + brace-expansion: "npm:^5.0.8" + checksum: 10c0/4559a836243b98bd4d17ea9f7edae698717c76399eea7be374f3737f33164e4907f19e9726891ddeb122f750a5a7fa80d2ac43e851d6e5984dc4ff42ec127d3a languageName: node linkType: hard -"minimatch@npm:^10.0.3": - version: 10.1.1 - resolution: "minimatch@npm:10.1.1" +"minimatch@npm:^10.1.1, minimatch@npm:^10.2.2": + version: 10.2.5 + resolution: "minimatch@npm:10.2.5" dependencies: - "@isaacs/brace-expansion": "npm:^5.0.0" - checksum: 10c0/c85d44821c71973d636091fddbfbffe62370f5ee3caf0241c5b60c18cd289e916200acb2361b7e987558cd06896d153e25d505db9fc1e43e6b4b6752e2702902 + brace-expansion: "npm:^5.0.5" + checksum: 10c0/6bb058bd6324104b9ec2f763476a35386d05079c1f5fe4fbf1f324a25237cd4534d6813ecd71f48208f4e635c1221899bef94c3c89f7df55698fe373aaae20fd languageName: node linkType: hard -"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": - version: 3.1.2 - resolution: "minimatch@npm:3.1.2" +"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2, minimatch@npm:^3.1.5": + version: 3.1.5 + resolution: "minimatch@npm:3.1.5" dependencies: brace-expansion: "npm:^1.1.7" - checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 languageName: node linkType: hard "minimatch@npm:^5.0.1": - version: 5.1.6 - resolution: "minimatch@npm:5.1.6" + version: 5.1.9 + resolution: "minimatch@npm:5.1.9" dependencies: brace-expansion: "npm:^2.0.1" - checksum: 10c0/3defdfd230914f22a8da203747c42ee3c405c39d4d37ffda284dac5e45b7e1f6c49aa8be606509002898e73091ff2a3bbfc59c2c6c71d4660609f63aa92f98e3 + checksum: 10c0/4202718683815a7288b13e470160a4f9560cf392adef4f453927505817e01ef6b3476ecde13cfcaed17e7326dd3b69ad44eb2daeb19a217c5500f9277893f1d6 languageName: node linkType: hard "minimatch@npm:^9.0.1, minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:~9.0.4": - version: 9.0.5 - resolution: "minimatch@npm:9.0.5" + version: 9.0.9 + resolution: "minimatch@npm:9.0.9" dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10c0/de96cf5e35bdf0eab3e2c853522f98ffbe9a36c37797778d2665231ec1f20a9447a7e567cb640901f89e4daaa95ae5d70c65a9e8aa2bb0019b6facbc3c0575ed + brace-expansion: "npm:^2.0.2" + checksum: 10c0/0b6a58530dbb00361745aa6c8cffaba4c90f551afe7c734830bd95fd88ebf469dd7355a027824ea1d09e37181cfeb0a797fb17df60c15ac174303ac110eb7e86 languageName: node linkType: hard @@ -15111,15 +11589,6 @@ __metadata: languageName: node linkType: hard -"minipass-collect@npm:^2.0.1": - version: 2.0.1 - resolution: "minipass-collect@npm:2.0.1" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10c0/5167e73f62bb74cc5019594709c77e6a742051a647fe9499abf03c71dca75515b7959d67a764bdc4f8b361cf897fbf25e2d9869ee039203ed45240f48b9aa06e - languageName: node - linkType: hard - "minipass-fetch@npm:^1.3.2": version: 1.4.1 resolution: "minipass-fetch@npm:1.4.1" @@ -15135,27 +11604,12 @@ __metadata: languageName: node linkType: hard -"minipass-fetch@npm:^5.0.0": - version: 5.0.0 - resolution: "minipass-fetch@npm:5.0.0" - dependencies: - encoding: "npm:^0.1.13" - minipass: "npm:^7.0.3" - minipass-sized: "npm:^1.0.3" - minizlib: "npm:^3.0.1" - dependenciesMeta: - encoding: - optional: true - checksum: 10c0/9443aab5feab190972f84b64116e54e58dd87a58e62399cae0a4a7461b80568281039b7c3a38ba96453431ebc799d1e26999e548540156216729a4967cd5ef06 - languageName: node - linkType: hard - "minipass-flush@npm:^1.0.5": - version: 1.0.5 - resolution: "minipass-flush@npm:1.0.5" + version: 1.0.7 + resolution: "minipass-flush@npm:1.0.7" dependencies: minipass: "npm:^3.0.0" - checksum: 10c0/2a51b63feb799d2bb34669205eee7c0eaf9dce01883261a5b77410c9408aa447e478efd191b4de6fc1101e796ff5892f8443ef20d9544385819093dbb32d36bd + checksum: 10c0/960915c02aa0991662c37c404517dd93708d17f96533b2ca8c1e776d158715d8107c5ced425ffc61674c167d93607f07f48a83c139ce1057f8781e5dfb4b90c2 languageName: node linkType: hard @@ -15203,10 +11657,10 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": - version: 7.1.2 - resolution: "minipass@npm:7.1.2" - checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557 +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb languageName: node linkType: hard @@ -15229,7 +11683,7 @@ __metadata: languageName: node linkType: hard -"minizlib@npm:^3.0.1, minizlib@npm:^3.1.0": +"minizlib@npm:^3.1.0": version: 3.1.0 resolution: "minizlib@npm:3.1.0" dependencies: @@ -15694,7 +12148,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.5, mkdirp@npm:^0.5.6": +"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.5": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" dependencies: @@ -15721,13 +12175,6 @@ __metadata: languageName: node linkType: hard -"module-details-from-path@npm:^1.0.3": - version: 1.0.4 - resolution: "module-details-from-path@npm:1.0.4" - checksum: 10c0/10863413e96dab07dee917eae07afe46f7bf853065cc75a7d2a718adf67574857fb64f8a2c0c9af12ac733a9a8cf652db7ed39b95f7a355d08106cb9cc50c83b - languageName: node - linkType: hard - "move-concurrently@npm:^1.0.1": version: 1.0.1 resolution: "move-concurrently@npm:1.0.1" @@ -15749,25 +12196,22 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": +"ms@npm:2.1.3, ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 languageName: node linkType: hard -"multer@npm:2.0.2": - version: 2.0.2 - resolution: "multer@npm:2.0.2" +"multer@npm:2.2.0": + version: 2.2.0 + resolution: "multer@npm:2.2.0" dependencies: append-field: "npm:^1.0.0" busboy: "npm:^1.6.0" concat-stream: "npm:^2.0.0" - mkdirp: "npm:^0.5.6" - object-assign: "npm:^4.1.1" type-is: "npm:^1.6.18" - xtend: "npm:^4.0.2" - checksum: 10c0/d3b99dd0512169bbabf15440e1bbb3ecdc000b761e5a3e4aaca40b5e5e213c6cdcc9b7dffebaa601b7691a84f6876aa87e0173ffcc47139253793cf5657819eb + checksum: 10c0/7aa366d89042427347b6ab8e4a203405d7fe942e4a3095648a14526fcf3691d98ffc2da62abf85d8aca0a341f828168eb197b33cfdcfcce29d6ef593a5625591 languageName: node linkType: hard @@ -15790,10 +12234,10 @@ __metadata: languageName: node linkType: hard -"mute-stream@npm:^2.0.0": - version: 2.0.0 - resolution: "mute-stream@npm:2.0.0" - checksum: 10c0/2cf48a2087175c60c8dcdbc619908b49c07f7adcfc37d29236b0c5c612d6204f789104c98cc44d38acab7b3c96f4a3ec2cfdc4934d0738d876dbefa2a12c69f4 +"mute-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "mute-stream@npm:3.0.0" + checksum: 10c0/12cdb36a101694c7a6b296632e6d93a30b74401873cf7507c88861441a090c71c77a58f213acadad03bc0c8fa186639dec99d68a14497773a8744320c136e701 languageName: node linkType: hard @@ -15815,6 +12259,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.12": + version: 3.3.15 + resolution: "nanoid@npm:3.3.15" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/e0b12e3a1d361f74150fa4b25631d0ae29f7162dab01a12f0f1be1f53b7a2a219f9b729504e474d4821207d0fe349bd3c97569ab5cf7ec2fff6aa94711956c93 + languageName: node + linkType: hard + "nanomatch@npm:^1.2.9": version: 1.2.13 resolution: "nanomatch@npm:1.2.13" @@ -15841,15 +12294,6 @@ __metadata: languageName: node linkType: hard -"napi-postinstall@npm:^0.3.0": - version: 0.3.4 - resolution: "napi-postinstall@npm:0.3.4" - bin: - napi-postinstall: lib/cli.js - checksum: 10c0/b33d64150828bdade3a5d07368a8b30da22ee393f8dd8432f1b9e5486867be21c84ec443dd875dd3ef3c7401a079a7ab7e2aa9d3538a889abbcd96495d5104fe - languageName: node - linkType: hard - "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -15857,6 +12301,13 @@ __metadata: languageName: node linkType: hard +"negotiator@npm:0.6.3": + version: 0.6.3 + resolution: "negotiator@npm:0.6.3" + checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2 + languageName: node + linkType: hard + "negotiator@npm:^0.6.2": version: 0.6.4 resolution: "negotiator@npm:0.6.4" @@ -15895,18 +12346,11 @@ __metadata: linkType: hard "node-abi@npm:^3.3.0": - version: 3.85.0 - resolution: "node-abi@npm:3.85.0" + version: 3.92.0 + resolution: "node-abi@npm:3.92.0" dependencies: semver: "npm:^7.3.5" - checksum: 10c0/d51b5718b6ebfcb23858e5429b74798c05fe3ab436d8afd8480b4809706bc53d6af3a60714ecc85e8c943f4e06e6378ca1935725c7611f3d1febdd3fc3bb5fe3 - languageName: node - linkType: hard - -"node-abort-controller@npm:^3.0.1": - version: 3.1.1 - resolution: "node-abort-controller@npm:3.1.1" - checksum: 10c0/f7ad0e7a8e33809d4f3a0d1d65036a711c39e9d23e0319d80ebe076b9a3b4432b4d6b86a7fab65521de3f6872ffed36fc35d1327487c48eb88c517803403eda3 + checksum: 10c0/d5fe063701542e1beef9017251b64dd648db200ae8d76745ddd07d475504734066ee69966609322ed4842a3b2f20cd3fbb0e1d2e675e3c25ff925d1e20fd06be languageName: node linkType: hard @@ -15928,12 +12372,27 @@ __metadata: languageName: node linkType: hard -"node-emoji@npm:1.11.0": - version: 1.11.0 - resolution: "node-emoji@npm:1.11.0" +"node-emoji@npm:2.2.0": + version: 2.2.0 + resolution: "node-emoji@npm:2.2.0" dependencies: - lodash: "npm:^4.17.21" - checksum: 10c0/5dac6502dbef087092d041fcc2686d8be61168593b3a9baf964d62652f55a3a9c2277f171b81cccb851ccef33f2d070f45e633fab1fda3264f8e1ae9041c673f + "@sindresorhus/is": "npm:^4.6.0" + char-regex: "npm:^1.0.2" + emojilib: "npm:^2.4.0" + skin-tone: "npm:^2.0.0" + checksum: 10c0/9525defbd90a82a2131758c2470203fa2a2faa8edd177147a8654a26307fe03594e52847ecbe2746d06cfc5c50acd12bd500f035350a7609e8217c9894c19aad + languageName: node + linkType: hard + +"node-exports-info@npm:^1.6.0": + version: 1.6.0 + resolution: "node-exports-info@npm:1.6.0" + dependencies: + array.prototype.flatmap: "npm:^1.3.3" + es-errors: "npm:^1.3.0" + object.entries: "npm:^1.1.9" + semver: "npm:^6.3.1" + checksum: 10c0/3613f21c60b047e66f168d3499a6be0060d89fb01ddceaa7032c2fb318aff12e4b9b111449c1a9aeb3b848bfdc1d4b6bc8fab327af692319597d21a1e7063692 languageName: node linkType: hard @@ -16004,43 +12463,29 @@ __metadata: linkType: hard "node-gyp@npm:latest": - version: 12.1.0 - resolution: "node-gyp@npm:12.1.0" + version: 13.0.0 + resolution: "node-gyp@npm:13.0.0" dependencies: env-paths: "npm:^2.2.0" exponential-backoff: "npm:^3.1.1" graceful-fs: "npm:^4.2.6" - make-fetch-happen: "npm:^15.0.0" - nopt: "npm:^9.0.0" - proc-log: "npm:^6.0.0" + nopt: "npm:^10.0.0" + proc-log: "npm:^7.0.0" semver: "npm:^7.3.5" - tar: "npm:^7.5.2" + tar: "npm:^7.5.4" tinyglobby: "npm:^0.2.12" - which: "npm:^6.0.0" + undici: "npm:^6.25.0" + which: "npm:^7.0.0" bin: node-gyp: bin/node-gyp.js - checksum: 10c0/f43efea8aaf0beb6b2f6184e533edad779b2ae38062953e21951f46221dd104006cc574154f2ad4a135467a5aae92c49e84ef289311a82e08481c5df0e8dc495 - languageName: node - linkType: hard - -"node-int64@npm:^0.4.0": - version: 0.4.0 - resolution: "node-int64@npm:0.4.0" - checksum: 10c0/a6a4d8369e2f2720e9c645255ffde909c0fbd41c92ea92a5607fc17055955daac99c1ff589d421eee12a0d24e99f7bfc2aabfeb1a4c14742f6c099a51863f31a - languageName: node - linkType: hard - -"node-releases@npm:^2.0.27": - version: 2.0.27 - resolution: "node-releases@npm:2.0.27" - checksum: 10c0/f1e6583b7833ea81880627748d28a3a7ff5703d5409328c216ae57befbced10ce2c991bea86434e8ec39003bd017f70481e2e5f8c1f7e0a7663241f81d6e00e2 + checksum: 10c0/e7525c427db2d16aa368b8947187de83083d2a8dda23e3e096a71c22ae637ac5bb8ed7cf6c871f1b9118cd2729dbfee4ff3a4245e2b79226900227b15831b492 languageName: node linkType: hard -"nodemailer@npm:7.0.10": - version: 7.0.10 - resolution: "nodemailer@npm:7.0.10" - checksum: 10c0/9bb39bde904397879a6394e5202146167cabc3bd4089c1b0255ce16875e721d1cf132afde25a570fc4cf38f159ba6b6b5411d3b9371775543d38343fbd505101 +"nodemailer@npm:9.0.1": + version: 9.0.1 + resolution: "nodemailer@npm:9.0.1" + checksum: 10c0/4213f01aa211127c1ce33243c5e45e7f831601a933d03fa864cd827b1bd5ea2782cb4b43722bee7028cbc193733d9802dad1120fe67181448eb0b7de52218a37 languageName: node linkType: hard @@ -16051,6 +12496,17 @@ __metadata: languageName: node linkType: hard +"nopt@npm:^10.0.0": + version: 10.0.1 + resolution: "nopt@npm:10.0.1" + dependencies: + abbrev: "npm:^5.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/980d89257f9587f3e1f77877ddbf905d6aa3b738ec33e49a4fa1a059a0dd82eb28063982b150654a7ae9de386f2ead60e56172db7d37cf56de545f7392a2a26a + languageName: node + linkType: hard + "nopt@npm:^4.0.1": version: 4.0.3 resolution: "nopt@npm:4.0.3" @@ -16085,17 +12541,6 @@ __metadata: languageName: node linkType: hard -"nopt@npm:^9.0.0": - version: 9.0.0 - resolution: "nopt@npm:9.0.0" - dependencies: - abbrev: "npm:^4.0.0" - bin: - nopt: bin/nopt.js - checksum: 10c0/1822eb6f9b020ef6f7a7516d7b64a8036e09666ea55ac40416c36e4b2b343122c3cff0e2f085675f53de1d2db99a2a89a60ccea1d120bcd6a5347bf6ceb4a7fd - languageName: node - linkType: hard - "normalize-package-data@npm:^2.0.0, normalize-package-data@npm:^2.3.0, normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.3.4, normalize-package-data@npm:^2.3.5, normalize-package-data@npm:^2.4.0, normalize-package-data@npm:^2.5.0": version: 2.5.0 resolution: "normalize-package-data@npm:2.5.0" @@ -16216,15 +12661,6 @@ __metadata: languageName: node linkType: hard -"npm-run-path@npm:^4.0.1": - version: 4.0.1 - resolution: "npm-run-path@npm:4.0.1" - dependencies: - path-key: "npm:^3.0.0" - checksum: 10c0/6f9353a95288f8455cf64cbeb707b28826a7f29690244c1e4bb61ec573256e021b6ad6651b394eb1ccfd00d6ec50147253aba2c5fe58a57ceb111fad62c519ac - languageName: node - linkType: hard - "npmlog@npm:^4.1.2": version: 4.1.2 resolution: "npmlog@npm:4.1.2" @@ -16284,13 +12720,6 @@ __metadata: languageName: node linkType: hard -"oauth@npm:0.10.x": - version: 0.10.2 - resolution: "oauth@npm:0.10.2" - checksum: 10c0/5660d652b31eb2a90509989955a75b02311591d2e3cfb04a3fb91deae0d22c259aa5c31a9e8845f760d749af901032e3cfd06d151bbda9a5ebb9b76268d3aef9 - languageName: node - linkType: hard - "object-assign@npm:^4, object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -16310,9 +12739,9 @@ __metadata: linkType: hard "object-deep-merge@npm:^2.0.0": - version: 2.0.0 - resolution: "object-deep-merge@npm:2.0.0" - checksum: 10c0/69e8741131ad49fa8720fb96007a3c82dca1119b5d874151d2ecbcc3b44ccd46e8553c7a30b0abcba752c099ba361bbba97f33a68c9ae54c57eed7be116ffc97 + version: 2.0.1 + resolution: "object-deep-merge@npm:2.0.1" + checksum: 10c0/c20580c462a3579ccc49a51c04a131d956d1d22197a92ddb2ad13c6201f54351708df47225a639660c46495f5c913c52609a6bac68feb74ddc27cddfd9a0f287 languageName: node linkType: hard @@ -16330,13 +12759,6 @@ __metadata: languageName: node linkType: hard -"object-sizeof@npm:1.1.1": - version: 1.1.1 - resolution: "object-sizeof@npm:1.1.1" - checksum: 10c0/e30723cc28707afce0f948704e5c568a762a43b125fa65d4601e86bb41854b758176c037c624351fe034caf66fe63680f51b9c72318ab5a69e7df295d262a96d - languageName: node - linkType: hard - "object-visit@npm:^1.0.0": version: 1.0.1 resolution: "object-visit@npm:1.0.1" @@ -16360,6 +12782,18 @@ __metadata: languageName: node linkType: hard +"object.entries@npm:^1.1.9": + version: 1.1.9 + resolution: "object.entries@npm:1.1.9" + dependencies: + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.4" + define-properties: "npm:^1.2.1" + es-object-atoms: "npm:^1.1.1" + checksum: 10c0/d4b8c1e586650407da03370845f029aa14076caca4e4d4afadbc69cfb5b78035fd3ee7be417141abdb0258fa142e59b11923b4c44d8b1255b28f5ffcc50da7db + languageName: node + linkType: hard + "object.fromentries@npm:^2.0.8": version: 2.0.8 resolution: "object.fromentries@npm:2.0.8" @@ -16373,17 +12807,17 @@ __metadata: linkType: hard "object.getownpropertydescriptors@npm:^2.0.3": - version: 2.1.8 - resolution: "object.getownpropertydescriptors@npm:2.1.8" + version: 2.1.9 + resolution: "object.getownpropertydescriptors@npm:2.1.9" dependencies: - array.prototype.reduce: "npm:^1.0.6" - call-bind: "npm:^1.0.7" + array.prototype.reduce: "npm:^1.0.8" + call-bind: "npm:^1.0.8" define-properties: "npm:^1.2.1" - es-abstract: "npm:^1.23.2" - es-object-atoms: "npm:^1.0.0" - gopd: "npm:^1.0.1" - safe-array-concat: "npm:^1.1.2" - checksum: 10c0/553e9562fd86637c9c169df23a56f1d810d8c9b580a6d4be11552c009f32469310c9347f3d10325abf0cd9cfe4afc521a1e903fbd24148ae7ec860e1e7c75cf3 + es-abstract: "npm:^1.24.0" + es-object-atoms: "npm:^1.1.1" + gopd: "npm:^1.2.0" + safe-array-concat: "npm:^1.1.3" + checksum: 10c0/8ccc9a4f28afb39cf7ab4d8acaf2ee817e47d59863d54a29b0e140648d841d2af3fc1564501a9b400862095258e3b28ee2c0506e1f5c04705ff781a8770f5eca languageName: node linkType: hard @@ -16419,6 +12853,13 @@ __metadata: languageName: node linkType: hard +"obug@npm:^2.1.1": + version: 2.1.3 + resolution: "obug@npm:2.1.3" + checksum: 10c0/cb8187fed0a5fc8445507c950e89f3c1bd43895658c398b5803f6b7804dfa0c562975ecce1e67f3d9247d521452a5bfade9e0e951cc0326b7444272f7c24d25f + languageName: node + linkType: hard + "octokit-pagination-methods@npm:^1.1.0": version: 1.1.0 resolution: "octokit-pagination-methods@npm:1.1.0" @@ -16426,7 +12867,7 @@ __metadata: languageName: node linkType: hard -"on-finished@npm:^2.4.1": +"on-finished@npm:^2.4.1, on-finished@npm:~2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" dependencies: @@ -16453,7 +12894,7 @@ __metadata: languageName: node linkType: hard -"onetime@npm:^5.1.0, onetime@npm:^5.1.2": +"onetime@npm:^5.1.0": version: 5.1.2 resolution: "onetime@npm:5.1.2" dependencies: @@ -16462,6 +12903,15 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^7.0.0": + version: 7.0.0 + resolution: "onetime@npm:7.0.0" + dependencies: + mimic-function: "npm:^5.0.0" + checksum: 10c0/5cb9179d74b63f52a196a2e7037ba2b9a893245a5532d3f44360012005c9cadb60851d56716ebff18a6f47129dab7168022445df47c2aff3b276d92585ed1221 + languageName: node + linkType: hard + "open@npm:7": version: 7.4.2 resolution: "open@npm:7.4.2" @@ -16486,7 +12936,23 @@ __metadata: languageName: node linkType: hard -"ora@npm:5.4.1, ora@npm:^5": +"ora@npm:9.4.1": + version: 9.4.1 + resolution: "ora@npm:9.4.1" + dependencies: + chalk: "npm:^5.6.2" + cli-cursor: "npm:^5.0.0" + cli-spinners: "npm:^3.2.0" + is-interactive: "npm:^2.0.0" + is-unicode-supported: "npm:^2.1.0" + log-symbols: "npm:^7.0.1" + stdin-discarder: "npm:^0.3.2" + string-width: "npm:^8.1.0" + checksum: 10c0/d58003408f3ddee46cd0a8d05e28c8a62e56cddd7ca4bc39adc4455c653d9d4c3d85b6f6b6c9ca78085f7ccbbb202ce8781505cdbebd357537a2b3f3eaa11ec2 + languageName: node + linkType: hard + +"ora@npm:^5": version: 5.4.1 resolution: "ora@npm:5.4.1" dependencies: @@ -16582,7 +13048,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": +"p-limit@npm:^3.0.2": version: 3.1.0 resolution: "p-limit@npm:3.1.0" dependencies: @@ -16670,13 +13136,6 @@ __metadata: languageName: node linkType: hard -"p-map@npm:^7.0.2": - version: 7.0.4 - resolution: "p-map@npm:7.0.4" - checksum: 10c0/a5030935d3cb2919d7e89454d1ce82141e6f9955413658b8c9403cfe379283770ed3048146b44cde168aa9e8c716505f196d5689db0ae3ce9a71521a2fef3abd - languageName: node - linkType: hard - "p-pipe@npm:^1.2.0": version: 1.2.0 resolution: "p-pipe@npm:1.2.0" @@ -16874,17 +13333,17 @@ __metadata: languageName: node linkType: hard -"parseley@npm:^0.12.0": - version: 0.12.1 - resolution: "parseley@npm:0.12.1" +"parseley@npm:~0.13.1": + version: 0.13.1 + resolution: "parseley@npm:0.13.1" dependencies: - leac: "npm:^0.6.0" - peberminta: "npm:^0.9.0" - checksum: 10c0/df3de74172b72305b867298a71e5882c413df75d30f2bafb5fb70779dfd349c5e4db03441fbf8ca83da8e4aa72bd0ef2b5c73086c4825d27d1c649d61bc0bcc0 + leac: "npm:^0.7.0" + peberminta: "npm:^0.10.0" + checksum: 10c0/cf92da29c1c6b280852327cb2f7d6af44306c75a653e6fa2b27df920903c498dd83383aa9e5174dd95cb49379234d4d4236671d39d795520480873794e732bae languageName: node linkType: hard -"parseurl@npm:^1.3.3": +"parseurl@npm:^1.3.3, parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" checksum: 10c0/90dd4760d6f6174adb9f20cf0965ae12e23879b5f5464f38e92fce8073354341e4b3b76fa3d878351efe7d01e617121955284cfd002ab087fba1a0726ec0b4f5 @@ -16898,34 +13357,6 @@ __metadata: languageName: node linkType: hard -"passport-apple@npm:^2.0.2": - version: 2.0.2 - resolution: "passport-apple@npm:2.0.2" - dependencies: - jsonwebtoken: "npm:^9.0.0" - passport-oauth2: "npm:^1.6.1" - checksum: 10c0/823ebfa190f9b6fb0cbcc7bbf3f8055c4f32ae333bb7e82c0414badf53692bc0b7f7950b496545829f2b7cbe18b4b185c1e90a85d3b3e9c0c421359c4d340bfe - languageName: node - linkType: hard - -"passport-github@npm:^1.1.0": - version: 1.1.0 - resolution: "passport-github@npm:1.1.0" - dependencies: - passport-oauth2: "npm:1.x.x" - checksum: 10c0/2d63e28151f6d00a3ca3a2207ef013e407a111326369b1ca89389f6b5ca22db6483bde15d4989734dd9808b1f603e0dd47f3c9bfff854ded308cfb764e89ab01 - languageName: node - linkType: hard - -"passport-google-oauth20@npm:^2.0.0": - version: 2.0.0 - resolution: "passport-google-oauth20@npm:2.0.0" - dependencies: - passport-oauth2: "npm:1.x.x" - checksum: 10c0/158930bb97a48431aa0dcff453c3b698742ed51e2d590c362cb5d4ae7715cfb4fb1feae31b007aef0bc8435edc8ff678853c044b139da827756f3b5f3b597c7f - languageName: node - linkType: hard - "passport-jwt@npm:^4.0.1": version: 4.0.1 resolution: "passport-jwt@npm:4.0.1" @@ -16945,19 +13376,6 @@ __metadata: languageName: node linkType: hard -"passport-oauth2@npm:1.x.x, passport-oauth2@npm:^1.6.1": - version: 1.8.0 - resolution: "passport-oauth2@npm:1.8.0" - dependencies: - base64url: "npm:3.x.x" - oauth: "npm:0.10.x" - passport-strategy: "npm:1.x.x" - uid2: "npm:0.0.x" - utils-merge: "npm:1.x.x" - checksum: 10c0/16b431bd856b84dfe0c9c913dcbea6ff54875befac1035171b0dce1c77f79072dc5e26d785b13c2e62c034c8174a1a47571751d1066bdbcdb9108de217c0b19b - languageName: node - linkType: hard - "passport-strategy@npm:1.x.x, passport-strategy@npm:^1.0.0": version: 1.0.0 resolution: "passport-strategy@npm:1.0.0" @@ -17027,7 +13445,7 @@ __metadata: languageName: node linkType: hard -"path-key@npm:^3.0.0, path-key@npm:^3.1.0": +"path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" checksum: 10c0/748c43efd5a569c039d7a00a03b58eecd1d75f3999f5a28303d75f521288df4823bc057d8784eb72358b2895a05f29a070bc9f1f17d28226cc4e62494cc58c4c @@ -17052,19 +13470,26 @@ __metadata: linkType: hard "path-scurry@npm:^2.0.0": - version: 2.0.1 - resolution: "path-scurry@npm:2.0.1" + version: 2.0.2 + resolution: "path-scurry@npm:2.0.2" dependencies: lru-cache: "npm:^11.0.0" minipass: "npm:^7.1.2" - checksum: 10c0/2a16ed0e81fbc43513e245aa5763354e25e787dab0d539581a6c3f0f967461a159ed6236b2559de23aa5b88e7dc32b469b6c47568833dd142a4b24b4f5cd2620 + checksum: 10c0/b35ad37cf6557a87fd057121ce2be7695380c9138d93e87ae928609da259ea0a170fac6f3ef1eb3ece8a068e8b7f2f3adf5bb2374cf4d4a57fe484954fcc9482 languageName: node linkType: hard -"path-to-regexp@npm:8.3.0, path-to-regexp@npm:^8.0.0": - version: 8.3.0 - resolution: "path-to-regexp@npm:8.3.0" - checksum: 10c0/ee1544a73a3f294a97a4c663b0ce71bbf1621d732d80c9c9ed201b3e911a86cb628ebad691b9d40f40a3742fe22011e5a059d8eed2cf63ec2cb94f6fb4efe67c +"path-to-regexp@npm:8.4.2, path-to-regexp@npm:^8.0.0": + version: 8.4.2 + resolution: "path-to-regexp@npm:8.4.2" + checksum: 10c0/05b115c49b47ad252ce05faa32930f643f23769c68b8bcfe78ad833545140c48bbffb3266986d6c8d5db13a64cf12e07e0d72d9882cab830efeefa553533ebaf + languageName: node + linkType: hard + +"path-to-regexp@npm:~0.1.12": + version: 0.1.13 + resolution: "path-to-regexp@npm:0.1.13" + checksum: 10c0/1cae3921739c154a8926e136185a10c916f79a249b9072a5001b266d96e193860ca03867e8e8cc808b786862d750f427ed93686bc259355442c3407a62deab1a languageName: node linkType: hard @@ -17088,10 +13513,10 @@ __metadata: languageName: node linkType: hard -"path-type@npm:^4.0.0": - version: 4.0.0 - resolution: "path-type@npm:4.0.0" - checksum: 10c0/666f6973f332f27581371efaf303fd6c272cc43c2057b37aa99e3643158c7e4b2626549555d88626e99ea9e046f82f32e41bbde5f1508547e9a11b149b52387c +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 languageName: node linkType: hard @@ -17102,10 +13527,10 @@ __metadata: languageName: node linkType: hard -"peberminta@npm:^0.9.0": - version: 0.9.0 - resolution: "peberminta@npm:0.9.0" - checksum: 10c0/59c2c39269d9f7f559cf44582f1c0503524c6a9bc3478e0309adba2b41c71ab98745a239a4e6f98f46105291256e6d8f12ae9860d9f016b1c9a6f52c0b63bfe7 +"peberminta@npm:^0.10.0": + version: 0.10.0 + resolution: "peberminta@npm:0.10.0" + checksum: 10c0/348ddd5779b1406399c0cb59e8b004720f5a7c48dd1a66c0df105607f7480e16e6d999084dfb5990a4c89424fbd49b40e000ff2ecf391f2f21330b2285b606f0 languageName: node linkType: hard @@ -17116,33 +13541,6 @@ __metadata: languageName: node linkType: hard -"pg-int8@npm:1.0.1": - version: 1.0.1 - resolution: "pg-int8@npm:1.0.1" - checksum: 10c0/be6a02d851fc2a4ae3e9de81710d861de3ba35ac927268973eb3cb618873a05b9424656df464dd43bd7dc3fc5295c3f5b3c8349494f87c7af50ec59ef14e0b98 - languageName: node - linkType: hard - -"pg-protocol@npm:*": - version: 1.10.3 - resolution: "pg-protocol@npm:1.10.3" - checksum: 10c0/f7ef54708c93ee6d271e37678296fc5097e4337fca91a88a3d99359b78633dbdbf6e983f0adb34b7cdd261b7ec7266deb20c3233bf3dfdb498b3e1098e8750b9 - languageName: node - linkType: hard - -"pg-types@npm:^2.2.0": - version: 2.2.0 - resolution: "pg-types@npm:2.2.0" - dependencies: - pg-int8: "npm:1.0.1" - postgres-array: "npm:~2.0.0" - postgres-bytea: "npm:~1.0.0" - postgres-date: "npm:~1.0.4" - postgres-interval: "npm:^1.1.0" - checksum: 10c0/ab3f8069a323f601cd2d2279ca8c425447dab3f9b61d933b0601d7ffc00d6200df25e26a4290b2b0783b59278198f7dd2ed03e94c4875797919605116a577c65 - languageName: node - linkType: hard - "picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" @@ -17150,24 +13548,24 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:4.0.2": - version: 4.0.2 - resolution: "picomatch@npm:4.0.2" - checksum: 10c0/7c51f3ad2bb42c776f49ebf964c644958158be30d0a510efd5a395e8d49cb5acfed5b82c0c5b365523ce18e6ab85013c9ebe574f60305892ec3fa8eee8304ccc +"picomatch@npm:4.0.5": + version: 4.0.5 + resolution: "picomatch@npm:4.0.5" + checksum: 10c0/947bc6b6e1ff1e6c5aaf95b107a0839d12802f4f7b867663f67d47accba939ca1cb582cf99dfc30438efa1c4648ac5990967e783e8929c36b03e8440704ef1bd languageName: node linkType: hard -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": - version: 2.3.1 - resolution: "picomatch@npm:2.3.1" - checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1": + version: 2.3.2 + resolution: "picomatch@npm:2.3.2" + checksum: 10c0/a554d1709e59be97d1acb9eaedbbc700a5c03dbd4579807baed95100b00420bc729335440ef15004ae2378984e2487a7c1cebd743cfdb72b6fa9ab69223c0d61 languageName: node linkType: hard -"picomatch@npm:^4.0.2, picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 +"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 languageName: node linkType: hard @@ -17208,37 +13606,6 @@ __metadata: languageName: node linkType: hard -"pino-std-serializers@npm:^3.1.0": - version: 3.2.0 - resolution: "pino-std-serializers@npm:3.2.0" - checksum: 10c0/ae08159372b5bbe69f13770a7f20ba7ded0bb97b2c6f42f780995582135ca907e66504f06371c12f991dbfcd489280f942786c02a9e8e952974d455cb0a477c9 - languageName: node - linkType: hard - -"pino@npm:^6.13.0": - version: 6.14.0 - resolution: "pino@npm:6.14.0" - dependencies: - fast-redact: "npm:^3.0.0" - fast-safe-stringify: "npm:^2.0.8" - flatstr: "npm:^1.0.12" - pino-std-serializers: "npm:^3.1.0" - process-warning: "npm:^1.0.0" - quick-format-unescaped: "npm:^4.0.3" - sonic-boom: "npm:^1.0.2" - bin: - pino: bin.js - checksum: 10c0/5d3cb22c804e2bf2439ace64a46a7901d0a138cb75715ad8a8bbcf3ddb09dc5e33a9fc8a49527c3345d317619748c6de94d28481911ae931c21b953e24048425 - languageName: node - linkType: hard - -"pirates@npm:^4.0.7": - version: 4.0.7 - resolution: "pirates@npm:4.0.7" - checksum: 10c0/a51f108dd811beb779d58a76864bbd49e239fa40c7984cd11596c75a121a8cc789f1c8971d8bb15f0dbf9d48b76c05bb62fcbce840f89b688c0fa64b37e8478a - languageName: node - linkType: hard - "pkg-dir@npm:^3.0.0": version: 3.0.0 resolution: "pkg-dir@npm:3.0.0" @@ -17248,15 +13615,6 @@ __metadata: languageName: node linkType: hard -"pkg-dir@npm:^4.2.0": - version: 4.2.0 - resolution: "pkg-dir@npm:4.2.0" - dependencies: - find-up: "npm:^4.0.0" - checksum: 10c0/c56bda7769e04907a88423feb320babaed0711af8c436ce3e56763ab1021ba107c7b0cafb11cde7529f669cfc22bffcaebffb573645cbd63842ea9fb17cd7728 - languageName: node - linkType: hard - "pluralize@npm:8.0.0": version: 8.0.0 resolution: "pluralize@npm:8.0.0" @@ -17271,40 +13629,21 @@ __metadata: languageName: node linkType: hard -"possible-typed-array-names@npm:^1.0.0": +"possible-typed-array-names@npm:^1.0.0, possible-typed-array-names@npm:^1.1.0": version: 1.1.0 resolution: "possible-typed-array-names@npm:1.1.0" checksum: 10c0/c810983414142071da1d644662ce4caebce890203eb2bc7bf119f37f3fe5796226e117e6cca146b521921fa6531072674174a3325066ac66fce089a53e1e5196 languageName: node linkType: hard -"postgres-array@npm:~2.0.0": - version: 2.0.0 - resolution: "postgres-array@npm:2.0.0" - checksum: 10c0/cbd56207e4141d7fbf08c86f2aebf21fa7064943d3f808ec85f442ff94b48d891e7a144cc02665fb2de5dbcb9b8e3183a2ac749959e794b4a4cfd379d7a21d08 - languageName: node - linkType: hard - -"postgres-bytea@npm:~1.0.0": - version: 1.0.0 - resolution: "postgres-bytea@npm:1.0.0" - checksum: 10c0/febf2364b8a8953695cac159eeb94542ead5886792a9627b97e33f6b5bb6e263bc0706ab47ec221516e79fbd6b2452d668841830fb3b49ec6c0fc29be61892ce - languageName: node - linkType: hard - -"postgres-date@npm:~1.0.4": - version: 1.0.7 - resolution: "postgres-date@npm:1.0.7" - checksum: 10c0/0ff91fccc64003e10b767fcfeefb5eaffbc522c93aa65d5051c49b3c4ce6cb93ab091a7d22877a90ad60b8874202c6f1d0f935f38a7235ed3b258efd54b97ca9 - languageName: node - linkType: hard - -"postgres-interval@npm:^1.1.0": - version: 1.2.0 - resolution: "postgres-interval@npm:1.2.0" +"postcss@npm:^8.5.16": + version: 8.5.16 + resolution: "postcss@npm:8.5.16" dependencies: - xtend: "npm:^4.0.0" - checksum: 10c0/c1734c3cb79e7f22579af0b268a463b1fa1d084e742a02a7a290c4f041e349456f3bee3b4ee0bb3f226828597f7b76deb615c1b857db9a742c45520100456272 + nanoid: "npm:^3.3.12" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/625de7a02f662f3a340964d14b487bd5097adf16f5f171e257d19005ba37aea8768ee446557500e88e91ca46b4d14d6cb4a0bf033c6ec0c8c0b660d85719f1ef languageName: node linkType: hard @@ -17337,32 +13676,21 @@ __metadata: languageName: node linkType: hard -"prettier-linter-helpers@npm:^1.0.0": - version: 1.0.0 - resolution: "prettier-linter-helpers@npm:1.0.0" +"prettier-linter-helpers@npm:^1.0.1": + version: 1.0.1 + resolution: "prettier-linter-helpers@npm:1.0.1" dependencies: fast-diff: "npm:^1.1.2" - checksum: 10c0/81e0027d731b7b3697ccd2129470ed9913ecb111e4ec175a12f0fcfab0096516373bf0af2fef132af50cafb0a905b74ff57996d615f59512bb9ac7378fcc64ab + checksum: 10c0/91cea965681bc5f62c9d26bd3ca6358b81557261d4802e96ec1cf0acbd99d4b61632d53320cd2c3ec7d7f7805a81345644108a41ef46ddc9688e783a9ac792d1 languageName: node linkType: hard "prettier@npm:^3.6.2": - version: 3.6.2 - resolution: "prettier@npm:3.6.2" + version: 3.8.4 + resolution: "prettier@npm:3.8.4" bin: prettier: bin/prettier.cjs - checksum: 10c0/488cb2f2b99ec13da1e50074912870217c11edaddedeadc649b1244c749d15ba94e846423d062e2c4c9ae683e2d65f754de28889ba06e697ac4f988d44f45812 - languageName: node - linkType: hard - -"pretty-format@npm:30.2.0": - version: 30.2.0 - resolution: "pretty-format@npm:30.2.0" - dependencies: - "@jest/schemas": "npm:30.0.5" - ansi-styles: "npm:^5.2.0" - react-is: "npm:^18.3.1" - checksum: 10c0/8fdacfd281aa98124e5df80b2c17223fdcb84433876422b54863a6849381b3059eb42b9806d92d2853826bcb966bcb98d499bea5b1e912d869a3c3107fd38d35 + checksum: 10c0/b90a0cbe75b88ac0af9c13fe0f359bd19926fabccd88483227b21f71f0c1cc42da056fc1ac3a361e665577c568371d5ccfb2c62c31c8a1186f8d1bd531a063e9 languageName: node linkType: hard @@ -17396,10 +13724,10 @@ __metadata: languageName: node linkType: hard -"proc-log@npm:^6.0.0": - version: 6.0.0 - resolution: "proc-log@npm:6.0.0" - checksum: 10c0/40c5e2b4c55e395a3bd72e38cba9c26e58598a1f4844fa6a115716d5231a0919f46aa8e351147035d91583ad39a794593615078c948bc001fe3beb99276be776 +"proc-log@npm:^7.0.0": + version: 7.0.0 + resolution: "proc-log@npm:7.0.0" + checksum: 10c0/b89c2d862604f35fec795477b0c7e376feab3ba0d4f4d291c4e959567442697cf451ac557d0623c1cc38af45a78128b983410f397a10c5d3a67f76c33de4754b languageName: node linkType: hard @@ -17410,13 +13738,6 @@ __metadata: languageName: node linkType: hard -"process-warning@npm:^1.0.0": - version: 1.0.0 - resolution: "process-warning@npm:1.0.0" - checksum: 10c0/43ec4229d64eb5c58340c8aacade49eb5f6fd513eae54140abf365929ca20987f0a35c5868125e2b583cad4de8cd257beb5667d9cc539d9190a7a4c3014adf22 - languageName: node - linkType: hard - "promise-inflight@npm:^1.0.1": version: 1.0.1 resolution: "promise-inflight@npm:1.0.1" @@ -17492,7 +13813,7 @@ __metadata: languageName: node linkType: hard -"proxy-addr@npm:^2.0.7": +"proxy-addr@npm:^2.0.7, proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" dependencies: @@ -17502,13 +13823,6 @@ __metadata: languageName: node linkType: hard -"proxy-from-env@npm:^1.1.0": - version: 1.1.0 - resolution: "proxy-from-env@npm:1.1.0" - checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b - languageName: node - linkType: hard - "psl@npm:^1.1.28": version: 1.15.0 resolution: "psl@npm:1.15.0" @@ -17529,9 +13843,9 @@ __metadata: languageName: node linkType: hard -"pug-code-gen@npm:^3.0.3": - version: 3.0.3 - resolution: "pug-code-gen@npm:3.0.3" +"pug-code-gen@npm:^3.0.4": + version: 3.0.4 + resolution: "pug-code-gen@npm:3.0.4" dependencies: constantinople: "npm:^4.0.1" doctypes: "npm:^1.1.0" @@ -17541,7 +13855,7 @@ __metadata: pug-runtime: "npm:^3.0.1" void-elements: "npm:^3.1.0" with: "npm:^7.0.0" - checksum: 10c0/517a93930dbc80bc7fa5f60ff324229a07cc5ab70ed9d344ce105e2fe24de68db5121c8457a9ba99cdc8d48dd18779dd34956ebfcab009b3c1c6843a3cade109 + checksum: 10c0/701c12bf8c0fed6f67110903357ff8dc3958138e8fc9d315e69fd560deb0b092784072d4c0d70140679e5c413c0f077bc807f5aca25106f9c956f626c476274b languageName: node linkType: hard @@ -17630,10 +13944,10 @@ __metadata: linkType: hard "pug@npm:^3.0.2": - version: 3.0.3 - resolution: "pug@npm:3.0.3" + version: 3.0.4 + resolution: "pug@npm:3.0.4" dependencies: - pug-code-gen: "npm:^3.0.3" + pug-code-gen: "npm:^3.0.4" pug-filters: "npm:^4.0.0" pug-lexer: "npm:^5.0.1" pug-linker: "npm:^4.0.0" @@ -17641,7 +13955,7 @@ __metadata: pug-parser: "npm:^6.0.0" pug-runtime: "npm:^3.0.1" pug-strip-comments: "npm:^2.0.0" - checksum: 10c0/bda53d3a6deea1d348cd5ab17427c77f3d74165510ad16f4fd182cc63618ad09388ecda317d17122ee890c8a68f9a54b96221fce7f44a332e463fdbb10a9d1e2 + checksum: 10c0/898258a95960a1819db70ea044002db9790e5cc93d169ac9798da522c884599c0c1b67da1bda34f82d11fe2df26df1588b406fdc7e0c9f08e0fc174303bb81ee languageName: node linkType: hard @@ -17656,12 +13970,12 @@ __metadata: linkType: hard "pump@npm:^3.0.0": - version: 3.0.3 - resolution: "pump@npm:3.0.3" + version: 3.0.4 + resolution: "pump@npm:3.0.4" dependencies: end-of-stream: "npm:^1.1.0" once: "npm:^1.3.1" - checksum: 10c0/ada5cdf1d813065bbc99aa2c393b8f6beee73b5de2890a8754c9f488d7323ffd2ca5f5a0943b48934e3fcbd97637d0337369c3c631aeb9614915db629f1c75c9 + checksum: 10c0/2780e66b5471c19e3e3e1063b84f3f6a3a08367f24c5ed552f98cd5901e6ada27c7ad6495d4244f553fd03b01884a4561933064f053f47c8994d84fd352768ea languageName: node linkType: hard @@ -17690,13 +14004,6 @@ __metadata: languageName: node linkType: hard -"pure-rand@npm:^7.0.0": - version: 7.0.1 - resolution: "pure-rand@npm:7.0.1" - checksum: 10c0/9cade41030f5ec95f5d55a11a71404cd6f46b69becaad892097cd7f58e2c6248cd0a933349ca7d21336ab629f1da42ffe899699b671bc4651600eaf6e57f837e - languageName: node - linkType: hard - "q@npm:^1.5.1": version: 1.5.1 resolution: "q@npm:1.5.1" @@ -17704,19 +14011,29 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.11.0, qs@npm:^6.14.0, qs@npm:^6.9.4": - version: 6.14.0 - resolution: "qs@npm:6.14.0" +"qs@npm:^6.11.0, qs@npm:^6.14.0, qs@npm:^6.9.4, qs@npm:~6.15.1": + version: 6.15.2 + resolution: "qs@npm:6.15.2" dependencies: side-channel: "npm:^1.1.0" - checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c + checksum: 10c0/e6fd5f6f0aab06d480fe9ab15cebfc4ce4235303e2f91dc69a8f7f4df1e668a61c11d1cfbabacf4295cbbeb7b670ed23db45307480726259761f98e5695e93a7 + languageName: node + linkType: hard + +"qs@npm:^6.15.2": + version: 6.15.3 + resolution: "qs@npm:6.15.3" + dependencies: + es-define-property: "npm:^1.0.1" + side-channel: "npm:^1.1.1" + checksum: 10c0/8f3f6e45ece255347d57696628401cde29e9ec649fff698b53bd3150dea7cefdf33036e1bc1826b9f110bfa7cb0ec4ab9f5297eca628ce216c55af82c304e08e languageName: node linkType: hard "qs@npm:~6.5.2": - version: 6.5.3 - resolution: "qs@npm:6.5.3" - checksum: 10c0/6631d4f2fa9d315e480662646745a4aa3a708817fbffe2cbdacec8ab9be130f92740c66191770fe9b704bc5fa9c1cc1f6596f55ad132fef7bd3ad1582f199eb0 + version: 6.5.5 + resolution: "qs@npm:6.5.5" + checksum: 10c0/6a5728b92378776d194c19d2bcf8e8847fa96ecfa6eb64f64e7ac73a394043cacaf257be014fa1a86201077a1e0c5ef5760ee0e0d6b6a4fe9f5ae8afcf5b9254 languageName: node linkType: hard @@ -17732,20 +14049,6 @@ __metadata: languageName: node linkType: hard -"queue-microtask@npm:^1.1.2, queue-microtask@npm:^1.2.2": - version: 1.2.3 - resolution: "queue-microtask@npm:1.2.3" - checksum: 10c0/900a93d3cdae3acd7d16f642c29a642aea32c2026446151f0778c62ac089d4b8e6c986811076e1ae180a694cedf077d453a11b58ff0a865629a4f82ab558e102 - languageName: node - linkType: hard - -"quick-format-unescaped@npm:^4.0.3": - version: 4.0.4 - resolution: "quick-format-unescaped@npm:4.0.4" - checksum: 10c0/fe5acc6f775b172ca5b4373df26f7e4fd347975578199e7d74b2ae4077f0af05baa27d231de1e80e8f72d88275ccc6028568a7a8c9ee5e7368ace0e18eff93a4 - languageName: node - linkType: hard - "quick-lru@npm:^1.0.0": version: 1.1.0 resolution: "quick-lru@npm:1.1.0" @@ -17760,31 +14063,41 @@ __metadata: languageName: node linkType: hard -"randombytes@npm:^2.1.0": - version: 2.1.0 - resolution: "randombytes@npm:2.1.0" - dependencies: - safe-buffer: "npm:^5.1.0" - checksum: 10c0/50395efda7a8c94f5dffab564f9ff89736064d32addf0cc7e8bf5e4166f09f8ded7a0849ca6c2d2a59478f7d90f78f20d8048bca3cdf8be09d8e8a10790388f3 +"range-parser@npm:^1.2.1": + version: 1.3.0 + resolution: "range-parser@npm:1.3.0" + checksum: 10c0/295494bb6685f9ab50f41a5f4fa5ce445aeeb9673b491bf178460fcc3a812dcf0d164cf1c83074f13a71a87d91ead5e097afd53e0630bc49a5101ed60f2a7529 languageName: node linkType: hard -"range-parser@npm:^1.2.1": +"range-parser@npm:~1.2.1": version: 1.2.1 resolution: "range-parser@npm:1.2.1" checksum: 10c0/96c032ac2475c8027b7a4e9fe22dc0dfe0f6d90b85e496e0f016fbdb99d6d066de0112e680805075bd989905e2123b3b3d002765149294dce0c1f7f01fcc2ea0 languageName: node linkType: hard -"raw-body@npm:^3.0.0": - version: 3.0.1 - resolution: "raw-body@npm:3.0.1" +"raw-body@npm:^3.0.2": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" + dependencies: + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.7.0" + unpipe: "npm:~1.0.0" + checksum: 10c0/d266678d08e1e7abea62c0ce5864344e980fa81c64f6b481e9842c5beaed2cdcf975f658a3ccd67ad35fc919c1f6664ccc106067801850286a6cbe101de89f29 + languageName: node + linkType: hard + +"raw-body@npm:~2.5.3": + version: 2.5.3 + resolution: "raw-body@npm:2.5.3" dependencies: - bytes: "npm:3.1.2" - http-errors: "npm:2.0.0" - iconv-lite: "npm:0.7.0" - unpipe: "npm:1.0.0" - checksum: 10c0/892f4fbd21ecab7e2fed0f045f7af9e16df7e8050879639d4e482784a2f4640aaaa33d916a0e98013f23acb82e09c2e3c57f84ab97104449f728d22f65a7d79a + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.4.24" + unpipe: "npm:~1.0.0" + checksum: 10c0/449844344fc90547fb994383a494b83300e4f22199f146a79f68d78a199a8f2a923ea9fd29c3be979bfd50291a3884733619ffc15ba02a32e703b612f8d3f74a languageName: node linkType: hard @@ -17809,13 +14122,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.3.1": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 - languageName: node - linkType: hard - "read-cmd-shim@npm:^1.0.1": version: 1.0.5 resolution: "read-cmd-shim@npm:1.0.5" @@ -17960,10 +14266,10 @@ __metadata: languageName: node linkType: hard -"readdirp@npm:^4.0.1": - version: 4.1.2 - resolution: "readdirp@npm:4.1.2" - checksum: 10c0/60a14f7619dec48c9c850255cd523e2717001b0e179dc7037cfa0895da7b9e9ab07532d324bfb118d73a710887d1e35f79c495fa91582784493e085d18c72c62 +"readdirp@npm:^5.0.0": + version: 5.0.0 + resolution: "readdirp@npm:5.0.0" + checksum: 10c0/faf1ec57cff2020f473128da3f8d2a57813cc3a08a36c38cae1c9af32c1579906cc50ba75578043b35bade77e945c098233665797cf9730ba3613a62d6e79219 languageName: node linkType: hard @@ -18006,7 +14312,7 @@ __metadata: languageName: node linkType: hard -"reflect-metadata@npm:0.2.2": +"reflect-metadata@npm:0.2.2, reflect-metadata@npm:^0.2.2": version: 0.2.2 resolution: "reflect-metadata@npm:0.2.2" checksum: 10c0/1cd93a15ea291e420204955544637c264c216e7aac527470e393d54b4bb075f10a17e60d8168ec96600c7e0b9fcc0cb0bb6e91c3fbf5b0d8c9056f04e6ac1ec2 @@ -18020,7 +14326,7 @@ __metadata: languageName: node linkType: hard -"reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": +"reflect.getprototypeof@npm:^1.0.10, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" dependencies: @@ -18132,17 +14438,6 @@ __metadata: languageName: node linkType: hard -"require-in-the-middle@npm:^7.1.1": - version: 7.5.2 - resolution: "require-in-the-middle@npm:7.5.2" - dependencies: - debug: "npm:^4.3.5" - module-details-from-path: "npm:^1.0.3" - resolve: "npm:^1.22.8" - checksum: 10c0/43a2dac5520e39d13c413650895715e102d6802e6cc6ff322017bd948f12a9657fe28435f7cbbcba437b167f02e192ac7af29fa35cabd5d0c375d071c0605e01 - languageName: node - linkType: hard - "require-main-filename@npm:^2.0.0": version: 2.0.0 resolution: "require-main-filename@npm:2.0.0" @@ -18166,15 +14461,6 @@ __metadata: languageName: node linkType: hard -"resolve-cwd@npm:^3.0.0": - version: 3.0.0 - resolution: "resolve-cwd@npm:3.0.0" - dependencies: - resolve-from: "npm:^5.0.0" - checksum: 10c0/e608a3ebd15356264653c32d7ecbc8fd702f94c6703ea4ac2fb81d9c359180cba0ae2e6b71faa446631ed6145454d5a56b227efc33a2d40638ac13f8beb20ee4 - languageName: node - linkType: hard - "resolve-from@npm:^3.0.0": version: 3.0.0 resolution: "resolve-from@npm:3.0.0" @@ -18203,29 +14489,63 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.10.0, resolve@npm:^1.15.1, resolve@npm:^1.22.4, resolve@npm:^1.22.8, resolve@npm:~1.22.2": - version: 1.22.11 - resolution: "resolve@npm:1.22.11" +"resolve@npm:^1.10.0, resolve@npm:^1.15.1, resolve@npm:~1.22.2": + version: 1.22.12 + resolution: "resolve@npm:1.22.12" dependencies: + es-errors: "npm:^1.3.0" is-core-module: "npm:^2.16.1" path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: resolve: bin/resolve - checksum: 10c0/f657191507530f2cbecb5815b1ee99b20741ea6ee02a59c57028e9ec4c2c8d7681afcc35febbd554ac0ded459db6f2d8153382c53a2f266cee2575e512674409 + checksum: 10c0/b16dc9b537c02e8c3388f7d3dcff9741d3071625f9a97ac1c885f2b0ca51e78df22328fb6d6ef214dd9101fb7cfc19aa2836fe3410402a94f3f7b8639c7149bf + languageName: node + linkType: hard + +"resolve@npm:^2.0.0-next.6": + version: 2.0.0-next.7 + resolution: "resolve@npm:2.0.0-next.7" + dependencies: + es-errors: "npm:^1.3.0" + is-core-module: "npm:^2.16.2" + node-exports-info: "npm:^1.6.0" + object-keys: "npm:^1.1.1" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/8c6fa17ccdb826a3a52387ed8c42bf705dbc19d5494da52a86661b124b5b88fad5d8edbbb4434a1b563ecf7768368b7da9fb9489e20f36e02f7551a4ea87d707 languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.15.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin, resolve@patch:resolve@npm%3A~1.22.2#optional!builtin": - version: 1.22.11 - resolution: "resolve@patch:resolve@npm%3A1.22.11#optional!builtin::version=1.22.11&hash=c3c19d" +"resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.15.1#optional!builtin, resolve@patch:resolve@npm%3A~1.22.2#optional!builtin": + version: 1.22.12 + resolution: "resolve@patch:resolve@npm%3A1.22.12#optional!builtin::version=1.22.12&hash=c3c19d" dependencies: + es-errors: "npm:^1.3.0" is-core-module: "npm:^2.16.1" path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: resolve: bin/resolve - checksum: 10c0/ee5b182f2e37cb1165465e58c6abc797fec0a80b5ba3231607beb4677db0c9291ac010c47cf092b6daa2b7f518d69a0e21888e7e2b633f68d501a874212a8c63 + checksum: 10c0/fc6519984ae1f894d877c0060ba8b1f5ba3bc0e85a02f74e141929c118c23d74d9735619a9cc2965397387e514884245c65d72a40731dcb6cfc84c7bcdc8321e + languageName: node + linkType: hard + +"resolve@patch:resolve@npm%3A^2.0.0-next.6#optional!builtin": + version: 2.0.0-next.7 + resolution: "resolve@patch:resolve@npm%3A2.0.0-next.7#optional!builtin::version=2.0.0-next.7&hash=c3c19d" + dependencies: + es-errors: "npm:^1.3.0" + is-core-module: "npm:^2.16.2" + node-exports-info: "npm:^1.6.0" + object-keys: "npm:^1.1.1" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/6bb6f1d8a1789f7a5b4e35d950e76bc0ba632ff7d51fe86b6f34fb5f41e1ce0f7b884ec166828cfc0728ddffeaee49527711d752d12398297f90c51acd22195e languageName: node linkType: hard @@ -18249,6 +14569,16 @@ __metadata: languageName: node linkType: hard +"restore-cursor@npm:^5.0.0": + version: 5.1.0 + resolution: "restore-cursor@npm:5.1.0" + dependencies: + onetime: "npm:^7.0.0" + signal-exit: "npm:^4.1.0" + checksum: 10c0/c2ba89131eea791d1b25205bdfdc86699767e2b88dee2a590b1a6caa51737deac8bad0260a5ded2f7c074b7db2f3a626bcf1fcf3cdf35974cbeea5e2e6764f60 + languageName: node + linkType: hard + "ret@npm:~0.1.10": version: 0.1.15 resolution: "ret@npm:0.1.15" @@ -18256,13 +14586,6 @@ __metadata: languageName: node linkType: hard -"ret@npm:~0.2.0": - version: 0.2.2 - resolution: "ret@npm:0.2.2" - checksum: 10c0/1a41e543913cda851abb1dae4852efa97bb693ce58fde3b51cc1cae94e2599dd70b91ad6268a4a07fc238305be06fed91723ef6d08863c48a0d02e0a74b943cd - languageName: node - linkType: hard - "retry@npm:^0.10.0": version: 0.10.1 resolution: "retry@npm:0.10.1" @@ -18277,20 +14600,6 @@ __metadata: languageName: node linkType: hard -"reusify@npm:^1.0.4": - version: 1.1.0 - resolution: "reusify@npm:1.1.0" - checksum: 10c0/4eff0d4a5f9383566c7d7ec437b671cc51b25963bd61bf127c3f3d3f68e44a026d99b8d2f1ad344afff8d278a8fe70a8ea092650a716d22287e8bef7126bb2fa - languageName: node - linkType: hard - -"rfdc@npm:^1.1.4, rfdc@npm:^1.2.0": - version: 1.4.1 - resolution: "rfdc@npm:1.4.1" - checksum: 10c0/4614e4292356cafade0b6031527eea9bc90f2372a22c012313be1dcc69a3b90c7338158b414539be863fa95bfcb2ddcd0587be696841af4e6679d85e62c060c7 - languageName: node - linkType: hard - "rimraf@npm:^2.5.4, rimraf@npm:^2.6.2, rimraf@npm:^2.6.3": version: 2.7.1 resolution: "rimraf@npm:2.7.1" @@ -18313,6 +14622,64 @@ __metadata: languageName: node linkType: hard +"rolldown@npm:~1.1.3": + version: 1.1.3 + resolution: "rolldown@npm:1.1.3" + dependencies: + "@oxc-project/types": "npm:=0.137.0" + "@rolldown/binding-android-arm64": "npm:1.1.3" + "@rolldown/binding-darwin-arm64": "npm:1.1.3" + "@rolldown/binding-darwin-x64": "npm:1.1.3" + "@rolldown/binding-freebsd-x64": "npm:1.1.3" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.1.3" + "@rolldown/binding-linux-arm64-gnu": "npm:1.1.3" + "@rolldown/binding-linux-arm64-musl": "npm:1.1.3" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.1.3" + "@rolldown/binding-linux-s390x-gnu": "npm:1.1.3" + "@rolldown/binding-linux-x64-gnu": "npm:1.1.3" + "@rolldown/binding-linux-x64-musl": "npm:1.1.3" + "@rolldown/binding-openharmony-arm64": "npm:1.1.3" + "@rolldown/binding-wasm32-wasi": "npm:1.1.3" + "@rolldown/binding-win32-arm64-msvc": "npm:1.1.3" + "@rolldown/binding-win32-x64-msvc": "npm:1.1.3" + "@rolldown/pluginutils": "npm:^1.0.0" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-wasm32-wasi": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: ./bin/cli.mjs + checksum: 10c0/6dae11bee45c56d000d5d2608ac78b2c7125b7f10337e0b0bbdee7290c352104f1f76072f8c0e6ccad331f51f1a131fc37faa179d9c4a10cc16abc87f85f6e86 + languageName: node + linkType: hard + "root@workspace:.": version: 0.0.0-use.local resolution: "root@workspace:." @@ -18323,16 +14690,16 @@ __metadata: "@concepta/prettier-config": "npm:2.0.0-alpha.4" "@darraghor/eslint-plugin-nestjs-typed": "npm:^6.9.3" "@eslint/js": "npm:^9.39.1" - "@nestjs/cli": "npm:^11.0.10" - "@nestjs/schematics": "npm:^11.0.9" - "@nestjs/testing": "npm:^11.1.9" + "@nestjs/cli": "npm:^12.0.0" + "@nestjs/platform-express": "npm:^12.0.1" + "@nestjs/schematics": "npm:^12.0.0" + "@nestjs/testing": "npm:^12.0.1" "@types/express": "npm:^4.17.21" "@types/jest": "npm:^27.5.2" "@types/node": "npm:^20.19.25" "@types/nodemailer": "npm:^6.4.15" "@types/supertest": "npm:^6.0.3" - class-transformer: "npm:^0.5.1" - class-validator: "npm:^0.14.1" + "@vitest/coverage-v8": "npm:^4.1.9" eslint: "npm:^9.39.1" eslint-config-prettier: "npm:^10.1.8" eslint-plugin-import: "npm:^2.32.0" @@ -18341,9 +14708,6 @@ __metadata: eslint-plugin-tsdoc: "npm:^0.5.0" globals: "npm:^16.5.0" husky: "npm:^7.0.4" - jest: "npm:30.2.0" - jest-junit: "npm:^13.2.0" - jest-mock-extended: "npm:^4.0.0" jsonc-eslint-parser: "npm:^2.4.1" lerna: "npm:^3.22.1" markdownlint-cli: "npm:^0.41.0" @@ -18353,15 +14717,13 @@ __metadata: rxjs: "npm:^7.8.1" standard-version: "npm:^9.5.0" supertest: "npm:^6.3.4" - ts-jest: "npm:^29.4.5" - ts-loader: "npm:^9.5.4" - ts-node: "npm:^10.9.2" - tsconfig-paths: "npm:^3.15.0" typedoc: "npm:^0.25.13" typedoc-plugin-coverage: "npm:^3.3.0" - typeorm: "npm:^0.3.27" - typescript: "npm:^4.9.5" + typeorm: "npm:^0.3.28" + typescript: "npm:^5.8.0" typescript-eslint: "npm:^8.46.4" + vitest: "npm:^4.1.9" + vitest-mock-extended: "npm:^4.0.0" languageName: unknown linkType: soft @@ -18408,15 +14770,6 @@ __metadata: languageName: node linkType: hard -"run-parallel@npm:^1.1.9": - version: 1.2.0 - resolution: "run-parallel@npm:1.2.0" - dependencies: - queue-microtask: "npm:^1.2.2" - checksum: 10c0/200b5ab25b5b8b7113f9901bfe3afc347e19bb7475b267d55ad0eb86a62a46d77510cb0f232507c9e5d497ebda569a08a9867d0d14f57a82ad5564d991588b39 - languageName: node - linkType: hard - "run-queue@npm:^1.0.0, run-queue@npm:^1.0.3": version: 1.0.3 resolution: "run-queue@npm:1.0.3" @@ -18426,12 +14779,12 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:7.8.1": - version: 7.8.1 - resolution: "rxjs@npm:7.8.1" +"rxjs@npm:7.8.2, rxjs@npm:^7.8.1": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" dependencies: tslib: "npm:^2.1.0" - checksum: 10c0/3c49c1ecd66170b175c9cacf5cef67f8914dcbc7cd0162855538d365c83fea631167cacb644b3ce533b2ea0e9a4d0b12175186985f89d75abe73dbd8f7f06f68 + checksum: 10c0/1fcd33d2066ada98ba8f21fcbbcaee9f0b271de1d38dc7f4e256bfbc6ffcdde68c8bfb69093de7eeb46f24b1fb820620bf0223706cff26b4ab99a7ff7b2e2c45 languageName: node linkType: hard @@ -18444,29 +14797,20 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.8.1": - version: 7.8.2 - resolution: "rxjs@npm:7.8.2" - dependencies: - tslib: "npm:^2.1.0" - checksum: 10c0/1fcd33d2066ada98ba8f21fcbbcaee9f0b271de1d38dc7f4e256bfbc6ffcdde68c8bfb69093de7eeb46f24b1fb820620bf0223706cff26b4ab99a7ff7b2e2c45 - languageName: node - linkType: hard - -"safe-array-concat@npm:^1.1.2, safe-array-concat@npm:^1.1.3": - version: 1.1.3 - resolution: "safe-array-concat@npm:1.1.3" +"safe-array-concat@npm:^1.1.3": + version: 1.1.4 + resolution: "safe-array-concat@npm:1.1.4" dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.2" - get-intrinsic: "npm:^1.2.6" + call-bind: "npm:^1.0.9" + call-bound: "npm:^1.0.4" + get-intrinsic: "npm:^1.3.0" has-symbols: "npm:^1.1.0" isarray: "npm:^2.0.5" - checksum: 10c0/43c86ffdddc461fb17ff8a17c5324f392f4868f3c7dd2c6a5d9f5971713bc5fd755667212c80eab9567595f9a7509cc2f83e590ddaebd1bd19b780f9c79f9a8d + checksum: 10c0/95fb4904ab1d9360a666fe5ba6d88f1c4a3a39682739e4512cff809fc6b5722a94bd95189211015bfb45859a7ffbc3340ea303ae22721c91c59e8946d310975a languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -18501,15 +14845,6 @@ __metadata: languageName: node linkType: hard -"safe-regex2@npm:^2.0.0": - version: 2.0.0 - resolution: "safe-regex2@npm:2.0.0" - dependencies: - ret: "npm:~0.2.0" - checksum: 10c0/f499e4fc69caafd7dd8023759e69a32991baa66e90bec5e2a7777b907943b27068dbff4e7a32cc8231f1354fcb779142f419e85498ae1e37384dc60619509c27 - languageName: node - linkType: hard - "safe-regex@npm:^1.1.0": version: 1.1.0 resolution: "safe-regex@npm:1.1.0" @@ -18526,49 +14861,12 @@ __metadata: languageName: node linkType: hard -"schema-utils@npm:^3.1.1": - version: 3.3.0 - resolution: "schema-utils@npm:3.3.0" - dependencies: - "@types/json-schema": "npm:^7.0.8" - ajv: "npm:^6.12.5" - ajv-keywords: "npm:^3.5.2" - checksum: 10c0/fafdbde91ad8aa1316bc543d4b61e65ea86970aebbfb750bfb6d8a6c287a23e415e0e926c2498696b242f63af1aab8e585252637fabe811fd37b604351da6500 - languageName: node - linkType: hard - -"schema-utils@npm:^4.3.0, schema-utils@npm:^4.3.2": - version: 4.3.3 - resolution: "schema-utils@npm:4.3.3" - dependencies: - "@types/json-schema": "npm:^7.0.9" - ajv: "npm:^8.9.0" - ajv-formats: "npm:^2.1.1" - ajv-keywords: "npm:^5.1.0" - checksum: 10c0/1c8d2c480a026d7c02ab2ecbe5919133a096d6a721a3f201fa50663e4f30f6d6ba020dfddd93cb828b66b922e76b342e103edd19a62c95c8f60e9079cc403202 - languageName: node - linkType: hard - -"secure-json-parse@npm:^2.0.0": - version: 2.7.0 - resolution: "secure-json-parse@npm:2.7.0" - checksum: 10c0/f57eb6a44a38a3eeaf3548228585d769d788f59007454214fab9ed7f01fbf2e0f1929111da6db28cf0bcc1a2e89db5219a59e83eeaec3a54e413a0197ce879e4 - languageName: node - linkType: hard - -"selderee@npm:^0.11.0": - version: 0.11.0 - resolution: "selderee@npm:0.11.0" +"selderee@npm:~0.12.0": + version: 0.12.0 + resolution: "selderee@npm:0.12.0" dependencies: - parseley: "npm:^0.12.0" - checksum: 10c0/c2ad8313a0dbf3c0b74752a8d03cfbc0931ae77a36679cdb64733eb732c1762f95a5174249bf7e8b8103874cb0e013a030f9c8b72f5d41e62f1d847d4a845d39 - languageName: node - linkType: hard - -"semver-store@npm:^0.3.0": - version: 0.3.0 - resolution: "semver-store@npm:0.3.0" - checksum: 10c0/4197aecef21dce734e8053e990c27f179136b106dbba69a8f52c1fb82779c670e456b21bbd0193bf10abe129ee44aa817eda75a3470f50f7b9284920d8af4fba + parseley: "npm:~0.13.1" + checksum: 10c0/dc0aea68d50dbbe0748fccf3332284546851bed698b1765c604b4a95868ac1090b7feea499cff7c48c98fc215a279ed9f8cbd468ebf0fb19e73967f671b6d39b languageName: node linkType: hard @@ -18590,52 +14888,76 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.7.2, semver@npm:^7.7.3": - version: 7.7.3 - resolution: "semver@npm:7.7.3" +"semver@npm:^7.1.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.7.3": + version: 7.8.5 + resolution: "semver@npm:7.8.5" bin: semver: bin/semver.js - checksum: 10c0/4afe5c986567db82f44c8c6faef8fe9df2a9b1d98098fc1721f57c696c4c21cebd572f297fc21002f81889492345b8470473bc6f4aff5fb032a6ea59ea2bc45e + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c languageName: node linkType: hard "send@npm:^1.1.0, send@npm:^1.2.0": - version: 1.2.0 - resolution: "send@npm:1.2.0" + version: 1.2.1 + resolution: "send@npm:1.2.1" dependencies: - debug: "npm:^4.3.5" + debug: "npm:^4.4.3" encodeurl: "npm:^2.0.0" escape-html: "npm:^1.0.3" etag: "npm:^1.8.1" fresh: "npm:^2.0.0" - http-errors: "npm:^2.0.0" - mime-types: "npm:^3.0.1" + http-errors: "npm:^2.0.1" + mime-types: "npm:^3.0.2" ms: "npm:^2.1.3" on-finished: "npm:^2.4.1" range-parser: "npm:^1.2.1" - statuses: "npm:^2.0.1" - checksum: 10c0/531bcfb5616948d3468d95a1fd0adaeb0c20818ba4a500f439b800ca2117971489e02074ce32796fd64a6772ea3e7235fe0583d8241dbd37a053dc3378eff9a5 + statuses: "npm:^2.0.2" + checksum: 10c0/fbbbbdc902a913d65605274be23f3d604065cfc3ee3d78bf9fc8af1dc9fc82667c50d3d657f5e601ac657bac9b396b50ee97bd29cd55436320cf1cddebdcec72 languageName: node linkType: hard -"serialize-javascript@npm:^6.0.2": - version: 6.0.2 - resolution: "serialize-javascript@npm:6.0.2" +"send@npm:~0.19.0, send@npm:~0.19.1": + version: 0.19.2 + resolution: "send@npm:0.19.2" dependencies: - randombytes: "npm:^2.1.0" - checksum: 10c0/2dd09ef4b65a1289ba24a788b1423a035581bef60817bea1f01eda8e3bda623f86357665fe7ac1b50f6d4f583f97db9615b3f07b2a2e8cbcb75033965f771dd2 + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:1.2.0" + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + fresh: "npm:~0.5.2" + http-errors: "npm:~2.0.1" + mime: "npm:1.6.0" + ms: "npm:2.1.3" + on-finished: "npm:~2.4.1" + range-parser: "npm:~1.2.1" + statuses: "npm:~2.0.2" + checksum: 10c0/20c2389fe0fdf3fc499938cac598bc32272287e993c4960717381a10de8550028feadfb9076f959a3a3ebdea42e1f690e116f0d16468fa56b9fd41866d3dc267 languageName: node linkType: hard "serve-static@npm:^2.2.0": - version: 2.2.0 - resolution: "serve-static@npm:2.2.0" + version: 2.2.1 + resolution: "serve-static@npm:2.2.1" dependencies: encodeurl: "npm:^2.0.0" escape-html: "npm:^1.0.3" parseurl: "npm:^1.3.3" send: "npm:^1.2.0" - checksum: 10c0/30e2ed1dbff1984836cfd0c65abf5d3f3f83bcd696c99d2d3c97edbd4e2a3ff4d3f87108a7d713640d290a7b6fe6c15ddcbc61165ab2eaad48ea8d3b52c7f913 + checksum: 10c0/37986096e8572e2dfaad35a3925fa8da0c0969f8814fd7788e84d4d388bc068cf0c06d1658509788e55bed942a6b6d040a8a267fa92bb9ffb1179f8bacde5fd7 + languageName: node + linkType: hard + +"serve-static@npm:~1.16.2": + version: 1.16.3 + resolution: "serve-static@npm:1.16.3" + dependencies: + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + parseurl: "npm:~1.3.3" + send: "npm:~0.19.1" + checksum: 10c0/36320397a073c71bedf58af48a4a100fe6d93f07459af4d6f08b9a7217c04ce2a4939e0effd842dc7bece93ffcd59eb52f58c4fff2a8e002dc29ae6b219cd42b languageName: node linkType: hard @@ -18646,13 +14968,6 @@ __metadata: languageName: node linkType: hard -"set-cookie-parser@npm:^2.4.1": - version: 2.7.2 - resolution: "set-cookie-parser@npm:2.7.2" - checksum: 10c0/4381a9eb7ee951dfe393fe7aacf76b9a3b4e93a684d2162ab35594fa4053cc82a4d7d7582bf397718012c9adcf839b8cd8f57c6c42901ea9effe33c752da4a45 - languageName: node - linkType: hard - "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -18702,7 +15017,7 @@ __metadata: languageName: node linkType: hard -"setprototypeof@npm:1.2.0": +"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" checksum: 10c0/68733173026766fa0d9ecaeb07f0483f4c2dc70ca376b3b7c40b7cda909f94b0918f6c5ad5ce27a9160bdfb475efaa9d5e705a11d8eaae18f9835d20976028bc @@ -18775,20 +15090,13 @@ __metadata: languageName: node linkType: hard -"shimmer@npm:^1.2.1": - version: 1.2.1 - resolution: "shimmer@npm:1.2.1" - checksum: 10c0/ae8b27c389db2a00acfc8da90240f11577685a8f3e40008f826a3bea8b4f3b3ecd305c26be024b4a0fd3b123d132c1569d6e238097960a9a543b6c60760fb46a - languageName: node - linkType: hard - -"side-channel-list@npm:^1.0.0": - version: 1.0.0 - resolution: "side-channel-list@npm:1.0.0" +"side-channel-list@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-list@npm:1.0.1" dependencies: es-errors: "npm:^1.3.0" - object-inspect: "npm:^1.13.3" - checksum: 10c0/644f4ac893456c9490ff388bf78aea9d333d5e5bfc64cfb84be8f04bf31ddc111a8d4b83b85d7e7e8a7b845bc185a9ad02c052d20e086983cf59f0be517d9b3d + object-inspect: "npm:^1.13.4" + checksum: 10c0/d346c787fd2f9f1c2fdea14f00e8250118db0e7596d85a6cb9faa75f105d31a73a8f7a341c93d7df2a2429098c3d37a77bd3be9e88c37094b8c01807bc77c7a2 languageName: node linkType: hard @@ -18817,20 +15125,27 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.1.0": - version: 1.1.0 - resolution: "side-channel@npm:1.1.0" +"side-channel@npm:^1.1.0, side-channel@npm:^1.1.1": + version: 1.1.1 + resolution: "side-channel@npm:1.1.1" dependencies: es-errors: "npm:^1.3.0" - object-inspect: "npm:^1.13.3" - side-channel-list: "npm:^1.0.0" + object-inspect: "npm:^1.13.4" + side-channel-list: "npm:^1.0.1" side-channel-map: "npm:^1.0.1" side-channel-weakmap: "npm:^1.0.2" - checksum: 10c0/cb20dad41eb032e6c24c0982e1e5a24963a28aa6122b4f05b3f3d6bf8ae7fd5474ef382c8f54a6a3ab86e0cac4d41a23bd64ede3970e5bfb50326ba02a7996e6 + checksum: 10c0/dc0ab81d67f61bda9247d053ce93f41c3fd8ad2bdcb9cf9d8d2f8540d488f26d87a5e99ebfc07eea49ec025867b2452b705442d974b1478f0395e69f6bfb3270 + languageName: node + linkType: hard + +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 languageName: node linkType: hard -"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": +"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 @@ -18862,17 +15177,19 @@ __metadata: languageName: node linkType: hard -"slash@npm:^2.0.0": +"skin-tone@npm:^2.0.0": version: 2.0.0 - resolution: "slash@npm:2.0.0" - checksum: 10c0/f83dbd3cb62c41bb8fcbbc6bf5473f3234b97fa1d008f571710a9d3757a28c7169e1811cad1554ccb1cc531460b3d221c9a7b37f549398d9a30707f0a5af9193 + resolution: "skin-tone@npm:2.0.0" + dependencies: + unicode-emoji-modifier-base: "npm:^1.0.0" + checksum: 10c0/82d4c2527864f9cbd6cb7f3c4abb31e2224752234d5013b881d3e34e9ab543545b05206df5a17d14b515459fcb265ce409f9cfe443903176b0360cd20e4e4ba5 languageName: node linkType: hard -"slash@npm:^3.0.0": - version: 3.0.0 - resolution: "slash@npm:3.0.0" - checksum: 10c0/e18488c6a42bdfd4ac5be85b2ced3ccd0224773baae6ad42cfbb9ec74fc07f9fa8396bd35ee638084ead7a2a0818eb5e7151111544d4731ce843019dab4be47b +"slash@npm:^2.0.0": + version: 2.0.0 + resolution: "slash@npm:2.0.0" + checksum: 10c0/f83dbd3cb62c41bb8fcbbc6bf5473f3234b97fa1d008f571710a9d3757a28c7169e1811cad1554ccb1cc531460b3d221c9a7b37f549398d9a30707f0a5af9193 languageName: node linkType: hard @@ -18961,24 +15278,13 @@ __metadata: languageName: node linkType: hard -"socks-proxy-agent@npm:^8.0.3": - version: 8.0.5 - resolution: "socks-proxy-agent@npm:8.0.5" - dependencies: - agent-base: "npm:^7.1.2" - debug: "npm:^4.3.4" - socks: "npm:^2.8.3" - checksum: 10c0/5d2c6cecba6821389aabf18728325730504bf9bb1d9e342e7987a5d13badd7a98838cc9a55b8ed3cb866ad37cc23e1086f09c4d72d93105ce9dfe76330e9d2a6 - languageName: node - linkType: hard - -"socks@npm:^2.6.2, socks@npm:^2.8.3": - version: 2.8.7 - resolution: "socks@npm:2.8.7" +"socks@npm:^2.6.2": + version: 2.8.9 + resolution: "socks@npm:2.8.9" dependencies: - ip-address: "npm:^10.0.1" + ip-address: "npm:^10.1.1" smart-buffer: "npm:^4.2.0" - checksum: 10c0/2805a43a1c4bcf9ebf6e018268d87b32b32b06fbbc1f9282573583acc155860dc361500f89c73bfbb157caa1b4ac78059eac0ef15d1811eb0ca75e0bdadbc9d2 + checksum: 10c0/2d4350c31142b0931eb1758825b426bcbf4bfb5eed682ca48bc46dc9e7d1930ec366ea574ad49fc6c1fd9e9e17ce243be0ef13e31fc4b0319d9093f1fb19743c languageName: node linkType: hard @@ -18992,16 +15298,6 @@ __metadata: languageName: node linkType: hard -"sonic-boom@npm:^1.0.2": - version: 1.4.1 - resolution: "sonic-boom@npm:1.4.1" - dependencies: - atomic-sleep: "npm:^1.0.0" - flatstr: "npm:^1.0.12" - checksum: 10c0/3498b835071365cc94aac0eae50c5ee3c2552a4e48cf6dce59ae2d995af6c62a8f529377852b39b073b8190b772a9fb2cdb48f515c0fec4948646dea862fb120 - languageName: node - linkType: hard - "sort-keys@npm:^2.0.0": version: 2.0.0 resolution: "sort-keys@npm:2.0.0" @@ -19011,6 +15307,13 @@ __metadata: languageName: node linkType: hard +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + "source-map-resolve@npm:^0.5.0": version: 0.5.3 resolution: "source-map-resolve@npm:0.5.3" @@ -19024,26 +15327,6 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:0.5.13": - version: 0.5.13 - resolution: "source-map-support@npm:0.5.13" - dependencies: - buffer-from: "npm:^1.0.0" - source-map: "npm:^0.6.0" - checksum: 10c0/137539f8c453fa0f496ea42049ab5da4569f96781f6ac8e5bfda26937be9494f4e8891f523c5f98f0e85f71b35d74127a00c46f83f6a4f54672b58d53202565e - languageName: node - linkType: hard - -"source-map-support@npm:~0.5.20": - version: 0.5.21 - resolution: "source-map-support@npm:0.5.21" - dependencies: - buffer-from: "npm:^1.0.0" - source-map: "npm:^0.6.0" - checksum: 10c0/9ee09942f415e0f721d6daad3917ec1516af746a8120bba7bb56278707a37f1eb8642bde456e98454b8a885023af81a16e646869975f06afc1a711fb90484e7d - languageName: node - linkType: hard - "source-map-url@npm:^0.4.0": version: 0.4.1 resolution: "source-map-url@npm:0.4.1" @@ -19051,10 +15334,10 @@ __metadata: languageName: node linkType: hard -"source-map@npm:0.7.4": - version: 0.7.4 - resolution: "source-map@npm:0.7.4" - checksum: 10c0/dc0cf3768fe23c345ea8760487f8c97ef6fca8a73c83cd7c9bf2fde8bc2c34adb9c0824d6feb14bc4f9e37fb522e18af621543f1289038a66ac7586da29aa7dc +"source-map@npm:0.7.6": + version: 0.7.6 + resolution: "source-map@npm:0.7.6" + checksum: 10c0/59f6f05538539b274ba771d2e9e32f6c65451982510564438e048bc1352f019c6efcdc6dd07909b1968144941c14015c2c7d4369fb7c4d7d53ae769716dcc16c languageName: node linkType: hard @@ -19065,20 +15348,13 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.0": +"source-map@npm:^0.6.1, source-map@npm:~0.6.0": version: 0.6.1 resolution: "source-map@npm:0.6.1" checksum: 10c0/ab55398007c5e5532957cb0beee2368529618ac0ab372d789806f5718123cc4367d57de3904b4e6a4170eb5a0b0f41373066d02ca0735a0c4d75c7d328d3e011 languageName: node linkType: hard -"source-map@npm:^0.7.4": - version: 0.7.6 - resolution: "source-map@npm:0.7.6" - checksum: 10c0/59f6f05538539b274ba771d2e9e32f6c65451982510564438e048bc1352f019c6efcdc6dd07909b1968144941c14015c2c7d4369fb7c4d7d53ae769716dcc16c - languageName: node - linkType: hard - "spdx-correct@npm:^3.0.0": version: 3.2.0 resolution: "spdx-correct@npm:3.2.0" @@ -19117,9 +15393,9 @@ __metadata: linkType: hard "spdx-license-ids@npm:^3.0.0": - version: 3.0.22 - resolution: "spdx-license-ids@npm:3.0.22" - checksum: 10c0/4a85e44c2ccfc06eebe63239193f526508ebec1abc7cf7bca8ee43923755636234395447c2c87f40fb672cf580a9c8e684513a676bfb2da3d38a4983684bbb38 + version: 3.0.23 + resolution: "spdx-license-ids@npm:3.0.23" + checksum: 10c0/8495620f6f2a237749cce922ea2d593a66f7885c301b1a0f5542183e7041182f27f616a8f13345cefdea0c9b3e0899328e0aa8cec100cf4f3fac4bb3bd975515 languageName: node linkType: hard @@ -19180,7 +15456,7 @@ __metadata: languageName: node linkType: hard -"sql-highlight@npm:^6.0.0": +"sql-highlight@npm:^6.1.0": version: 6.1.0 resolution: "sql-highlight@npm:6.1.0" checksum: 10c0/9614f4608bfde8ea7bf9b2fe9233dcc99a619c91cbc3f5cd85a6fb5ad4b2177f4ac8ca4a0191f4243ff8aea3b6f2a1229efc88635298269e0049b2ac08bde263 @@ -19229,24 +15505,6 @@ __metadata: languageName: node linkType: hard -"ssri@npm:^12.0.0": - version: 12.0.0 - resolution: "ssri@npm:12.0.0" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10c0/caddd5f544b2006e88fa6b0124d8d7b28208b83c72d7672d5ade44d794525d23b540f3396108c4eb9280dcb7c01f0bef50682f5b4b2c34291f7c5e211fd1417d - languageName: node - linkType: hard - -"ssri@npm:^13.0.0": - version: 13.0.0 - resolution: "ssri@npm:13.0.0" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10c0/405f3a531cd98b013cecb355d63555dca42fd12c7bc6671738aaa9a82882ff41cdf0ef9a2b734ca4f9a760338f114c29d01d9238a65db3ccac27929bd6e6d4b2 - languageName: node - linkType: hard - "ssri@npm:^6.0.0, ssri@npm:^6.0.1": version: 6.0.2 resolution: "ssri@npm:6.0.2" @@ -19265,12 +15523,10 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.6": - version: 2.0.6 - resolution: "stack-utils@npm:2.0.6" - dependencies: - escape-string-regexp: "npm:^2.0.0" - checksum: 10c0/651c9f87667e077584bbe848acaecc6049bc71979f1e9a46c7b920cad4431c388df0f51b8ad7cfd6eed3db97a2878d0fc8b3122979439ea8bac29c61c95eec8a +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 languageName: node linkType: hard @@ -19308,20 +15564,27 @@ __metadata: languageName: node linkType: hard -"statuses@npm:2.0.1": - version: 2.0.1 - resolution: "statuses@npm:2.0.1" - checksum: 10c0/34378b207a1620a24804ce8b5d230fea0c279f00b18a7209646d5d47e419d1cc23e7cbf33a25a1e51ac38973dc2ac2e1e9c647a8e481ef365f77668d72becfd0 - languageName: node - linkType: hard - -"statuses@npm:^2.0.1": +"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.1, statuses@npm:~2.0.2": version: 2.0.2 resolution: "statuses@npm:2.0.2" checksum: 10c0/a9947d98ad60d01f6b26727570f3bcceb6c8fa789da64fe6889908fe2e294d57503b14bf2b5af7605c2d36647259e856635cd4c49eab41667658ec9d0080ec3f languageName: node linkType: hard +"std-env@npm:^4.0.0-rc.1": + version: 4.1.0 + resolution: "std-env@npm:4.1.0" + checksum: 10c0/2e14b6b490db34cb969a48d9cf7c35bca4a47653914aac2814221baae7b867a5b15940d133625c391621971f98cd2266a5dc7036669960e883f1081db2a56558 + languageName: node + linkType: hard + +"stdin-discarder@npm:^0.3.2": + version: 0.3.2 + resolution: "stdin-discarder@npm:0.3.2" + checksum: 10c0/5dbaba9efbcb447a4450d5ae19794641ea9166abe96dc4b5547a109db1bb6e8bdb17bbe1029e02ca8d9d8ee996b7c7cbcce12b12c18c121871cd4f574292381a + languageName: node + linkType: hard + "stop-iteration-iterator@npm:^1.1.0": version: 1.1.0 resolution: "stop-iteration-iterator@npm:1.1.0" @@ -19363,23 +15626,6 @@ __metadata: languageName: node linkType: hard -"string-length@npm:^4.0.2": - version: 4.0.2 - resolution: "string-length@npm:4.0.2" - dependencies: - char-regex: "npm:^1.0.2" - strip-ansi: "npm:^6.0.0" - checksum: 10c0/1cd77409c3d7db7bc59406f6bcc9ef0783671dcbabb23597a1177c166906ef2ee7c8290f78cae73a8aec858768f189d2cb417797df5e15ec4eb5e16b3346340c - languageName: node - linkType: hard - -"string-similarity@npm:^4.0.1": - version: 4.0.4 - resolution: "string-similarity@npm:4.0.4" - checksum: 10c0/fce331b818efafa701f692ddc2e170bd3ceaf6e7ca56a445b36b139981effe0884d8edc794a65005e54304da55ba054edfcff16a339bd301c9b94983fbc62047 - languageName: node - linkType: hard - "string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" @@ -19434,30 +15680,41 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^8.1.0": + version: 8.2.1 + resolution: "string-width@npm:8.2.1" + dependencies: + get-east-asian-width: "npm:^1.5.0" + strip-ansi: "npm:^7.1.2" + checksum: 10c0/d467b4eaf4c40a01bb438a2620e77badd2456ffd5131c9973abe4f3acf7c802d5b21f3b6a00a5e33a7fc28ca8f9c103226e01bac61e9f259659c6f46d78e353a + languageName: node + linkType: hard + "string.prototype.trim@npm:^1.2.10": - version: 1.2.10 - resolution: "string.prototype.trim@npm:1.2.10" + version: 1.2.11 + resolution: "string.prototype.trim@npm:1.2.11" dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.2" + call-bind: "npm:^1.0.9" + call-bound: "npm:^1.0.4" define-data-property: "npm:^1.1.4" define-properties: "npm:^1.2.1" - es-abstract: "npm:^1.23.5" - es-object-atoms: "npm:^1.0.0" + es-abstract: "npm:^1.24.2" + es-object-atoms: "npm:^1.1.2" has-property-descriptors: "npm:^1.0.2" - checksum: 10c0/8a8854241c4b54a948e992eb7dd6b8b3a97185112deb0037a134f5ba57541d8248dd610c966311887b6c2fd1181a3877bffb14d873ce937a344535dabcc648f8 + safe-regex-test: "npm:^1.1.0" + checksum: 10c0/b153cf8ed06db82ff40e27829e88e5c13f45eff9799f1d5707626e25989b488b059d6f5d57011e07f77745e28451e16735f295bc59c8ae146a4fd73a442366b0 languageName: node linkType: hard "string.prototype.trimend@npm:^1.0.9": - version: 1.0.9 - resolution: "string.prototype.trimend@npm:1.0.9" + version: 1.0.10 + resolution: "string.prototype.trimend@npm:1.0.10" dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.2" + call-bind: "npm:^1.0.9" + call-bound: "npm:^1.0.4" define-properties: "npm:^1.2.1" - es-object-atoms: "npm:^1.0.0" - checksum: 10c0/59e1a70bf9414cb4c536a6e31bef5553c8ceb0cf44d8b4d0ed65c9653358d1c64dd0ec203b100df83d0413bbcde38b8c5d49e14bc4b86737d74adc593a0d35b6 + es-object-atoms: "npm:^1.1.2" + checksum: 10c0/cc09233181769047a5330becfd5740fec5f0c8137886e7b553626788b00f75df9f34db1159bc52dbb7fc389b8ebb6e1dab44c8c9e31eb600039729a542013286 languageName: node linkType: hard @@ -19533,12 +15790,12 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^7.0.1": - version: 7.1.2 - resolution: "strip-ansi@npm:7.1.2" +"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.2": + version: 7.2.0 + resolution: "strip-ansi@npm:7.2.0" dependencies: - ansi-regex: "npm:^6.0.1" - checksum: 10c0/0d6d7a023de33368fd042aab0bf48f4f4077abdfd60e5393e73c7c411e85e1b3a83507c11af2e656188511475776215df9ca589b4da2295c9455cc399ce1858b + ansi-regex: "npm:^6.2.2" + checksum: 10c0/544d13b7582f8254811ea97db202f519e189e59d35740c46095897e254e4f1aa9fe1524a83ad6bc5ad67d4dd6c0281d2e0219ed62b880a6238a16a17d375f221 languageName: node linkType: hard @@ -19558,13 +15815,6 @@ __metadata: languageName: node linkType: hard -"strip-bom@npm:^4.0.0": - version: 4.0.0 - resolution: "strip-bom@npm:4.0.0" - checksum: 10c0/26abad1172d6bc48985ab9a5f96c21e440f6e7e476686de49be813b5a59b3566dccb5c525b831ec54fe348283b47f3ffb8e080bc3f965fde12e84df23f6bb7ef - languageName: node - linkType: hard - "strip-eof@npm:^1.0.0": version: 1.0.0 resolution: "strip-eof@npm:1.0.0" @@ -19572,13 +15822,6 @@ __metadata: languageName: node linkType: hard -"strip-final-newline@npm:^2.0.0": - version: 2.0.0 - resolution: "strip-final-newline@npm:2.0.0" - checksum: 10c0/bddf8ccd47acd85c0e09ad7375409d81653f645fda13227a9d459642277c253d877b68f2e5e4d819fe75733b0e626bac7e954c04f3236f6d196f79c94fa4a96f - languageName: node - linkType: hard - "strip-indent@npm:^1.0.1": version: 1.0.1 resolution: "strip-indent@npm:1.0.1" @@ -19620,13 +15863,6 @@ __metadata: languageName: node linkType: hard -"strnum@npm:^2.1.0": - version: 2.1.1 - resolution: "strnum@npm:2.1.1" - checksum: 10c0/1f9bd1f9b4c68333f25c2b1f498ea529189f060cd50aa59f1876139c994d817056de3ce57c12c970f80568d75df2289725e218bd9e3cdf73cd1a876c9c102733 - languageName: node - linkType: hard - "strong-log-transformer@npm:^2.0.0": version: 2.1.0 resolution: "strong-log-transformer@npm:2.1.0" @@ -19640,12 +15876,12 @@ __metadata: languageName: node linkType: hard -"strtok3@npm:^10.3.1": - version: 10.3.4 - resolution: "strtok3@npm:10.3.4" +"strtok3@npm:^10.3.5": + version: 10.3.5 + resolution: "strtok3@npm:10.3.5" dependencies: "@tokenizer/token": "npm:^0.3.0" - checksum: 10c0/277ab69e417f4545e364ffaf9d560c991f531045dbace32d77b5c822cccd76a608b782785a2c60595274288d4d32dced184a5c21dc20348791da697127dc69a8 + checksum: 10c0/8d2477b239054c9f1f5b14a65d531147ca158ab9887fdc2d0938e77b7ec8891fb683b58254c7643afd5d98a421a59207534d491762b111f58c795071ecbe9fd1 languageName: node linkType: hard @@ -19695,15 +15931,6 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^8.0.0, supports-color@npm:^8.1.1": - version: 8.1.1 - resolution: "supports-color@npm:8.1.1" - dependencies: - has-flag: "npm:^4.0.0" - checksum: 10c0/ea1d3c275dd604c974670f63943ed9bd83623edc102430c05adb8efc56ba492746b6e95386e7831b872ec3807fd89dd8eb43f735195f37b5ec343e4234cc7e89 - languageName: node - linkType: hard - "supports-preserve-symlinks-flag@npm:^1.0.0": version: 1.0.0 resolution: "supports-preserve-symlinks-flag@npm:1.0.0" @@ -19711,35 +15938,21 @@ __metadata: languageName: node linkType: hard -"swagger-ui-dist@npm:5.30.2": - version: 5.30.2 - resolution: "swagger-ui-dist@npm:5.30.2" +"swagger-ui-dist@npm:5.32.14": + version: 5.32.14 + resolution: "swagger-ui-dist@npm:5.32.14" dependencies: "@scarf/scarf": "npm:=1.4.0" - checksum: 10c0/2d5ff6b8d0c4fff22f64719b8a9301ddd885b5ef7c7f19e160543b094de2afe0830576214d190d0f7e7010c52812724552506da5a138542e5c43a4429446769a - languageName: node - linkType: hard - -"symbol-observable@npm:4.0.0": - version: 4.0.0 - resolution: "symbol-observable@npm:4.0.0" - checksum: 10c0/5e9a3ab08263a6be8cbee76587ad5880dcc62a47002787ed5ebea56b1eb30dc87da6f0183d67e88286806799fbe21c69077fbd677be4be2188e92318d6c6f31d + checksum: 10c0/1457c4ee9d4e18411c6561b1df96c547c5f71334afb0f8c32bc787af05746b0d1f301f95d7483f6092e71eb40f96ea96f14444ee7cc9dfebeab7835c44c4bd31 languageName: node linkType: hard -"synckit@npm:^0.11.7, synckit@npm:^0.11.8": - version: 0.11.11 - resolution: "synckit@npm:0.11.11" +"synckit@npm:^0.11.13": + version: 0.11.13 + resolution: "synckit@npm:0.11.13" dependencies: - "@pkgr/core": "npm:^0.2.9" - checksum: 10c0/f0761495953d12d94a86edf6326b3a565496c72f9b94c02549b6961fb4d999f4ca316ce6b3eb8ed2e4bfc5056a8de65cda0bd03a233333a35221cd2fdc0e196b - languageName: node - linkType: hard - -"tapable@npm:^2.1.1, tapable@npm:^2.2.0, tapable@npm:^2.2.1": - version: 2.3.0 - resolution: "tapable@npm:2.3.0" - checksum: 10c0/cb9d67cc2c6a74dedc812ef3085d9d681edd2c1fa18e4aef57a3c0605fdbe44e6b8ea00bd9ef21bc74dd45314e39d31227aa031ebf2f5e38164df514136f2681 + "@pkgr/core": "npm:^0.3.6" + checksum: 10c0/5a6c19f4f79045aaa7994106401bff6dbe7cca23a6d0a0723ff14eb8b1bebeb4a71729118f6914905598e304ea2fa13509885e11ba07d92e7cb68a06740cb328 languageName: node linkType: hard @@ -19797,16 +16010,16 @@ __metadata: languageName: node linkType: hard -"tar@npm:^7.5.2": - version: 7.5.2 - resolution: "tar@npm:7.5.2" +"tar@npm:^7.5.4": + version: 7.5.16 + resolution: "tar@npm:7.5.16" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10c0/a7d8b801139b52f93a7e34830db0de54c5aa45487c7cb551f6f3d44a112c67f1cb8ffdae856b05fd4f17b1749911f1c26f1e3a23bbe0279e17fd96077f13f467 + checksum: 10c0/4f37f3c4bd2ca2755fd736a5df1d573c1a868ec1b1e893346aeafa95ac510f9e2fd1469420bd866cc7904799e5bd4ac62b5d4f03fe27747d6e1e373b44505c5c languageName: node linkType: hard @@ -19831,53 +16044,6 @@ __metadata: languageName: node linkType: hard -"terser-webpack-plugin@npm:^5.3.11": - version: 5.3.14 - resolution: "terser-webpack-plugin@npm:5.3.14" - dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.25" - jest-worker: "npm:^27.4.5" - schema-utils: "npm:^4.3.0" - serialize-javascript: "npm:^6.0.2" - terser: "npm:^5.31.1" - peerDependencies: - webpack: ^5.1.0 - peerDependenciesMeta: - "@swc/core": - optional: true - esbuild: - optional: true - uglify-js: - optional: true - checksum: 10c0/9b060947241af43bd6fd728456f60e646186aef492163672a35ad49be6fbc7f63b54a7356c3f6ff40a8f83f00a977edc26f044b8e106cc611c053c8c0eaf8569 - languageName: node - linkType: hard - -"terser@npm:^5.31.1": - version: 5.44.1 - resolution: "terser@npm:5.44.1" - dependencies: - "@jridgewell/source-map": "npm:^0.3.3" - acorn: "npm:^8.15.0" - commander: "npm:^2.20.0" - source-map-support: "npm:~0.5.20" - bin: - terser: bin/terser - checksum: 10c0/ee7a76692cb39b1ed22c30ff366c33ff3c977d9bb769575338ff5664676168fcba59192fb5168ef80c7cd901ef5411a1b0351261f5eaa50decf0fc71f63bde75 - languageName: node - linkType: hard - -"test-exclude@npm:^6.0.0": - version: 6.0.0 - resolution: "test-exclude@npm:6.0.0" - dependencies: - "@istanbuljs/schema": "npm:^0.1.2" - glob: "npm:^7.1.4" - minimatch: "npm:^3.0.4" - checksum: 10c0/019d33d81adff3f9f1bfcff18125fb2d3c65564f437d9be539270ee74b994986abb8260c7c2ce90e8f30162178b09dbbce33c6389273afac4f36069c48521f57 - languageName: node - linkType: hard - "text-extensions@npm:^1.0.0": version: 1.9.0 resolution: "text-extensions@npm:1.9.0" @@ -19946,27 +16112,34 @@ __metadata: languageName: node linkType: hard -"tiny-lru@npm:^8.0.1": - version: 8.0.2 - resolution: "tiny-lru@npm:8.0.2" - checksum: 10c0/32dc73db748ae50bf43498f81150ed23922c924d7a3665ea240c7041abbbd5e8667c05328fd520ca923b4d10b89e69a4ba671365eaac221c6f7eb8b898530506 +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c languageName: node linkType: hard -"tinyexec@npm:^1.0.0": - version: 1.0.2 - resolution: "tinyexec@npm:1.0.2" - checksum: 10c0/1261a8e34c9b539a9aae3b7f0bb5372045ff28ee1eba035a2a059e532198fe1a182ec61ac60fa0b4a4129f0c4c4b1d2d57355b5cb9aa2d17ac9454ecace502ee +"tinyexec@npm:^1.0.0, tinyexec@npm:^1.0.2": + version: 1.2.4 + resolution: "tinyexec@npm:1.2.4" + checksum: 10c0/153b8db6b080194b558ff145b9cffc36b80a6e07babd644dcfbe49c807eee668c876049d28bdee90b96304476f883352f2dad91b3f86bc23832532f4363e66ff languageName: node linkType: hard -"tinyglobby@npm:^0.2.12": - version: 0.2.15 - resolution: "tinyglobby@npm:0.2.15" +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.17": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" dependencies: fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.3" - checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + +"tinyrainbow@npm:^3.1.0": + version: 3.1.0 + resolution: "tinyrainbow@npm:3.1.0" + checksum: 10c0/f11cf387a26c5c9255bec141a90ac511b26172981b10c3e50053bc6700ea7d2336edcc4a3a21dbb8412fe7c013477d2ba4d7e4877800f3f8107be5105aad6511 languageName: node linkType: hard @@ -19988,13 +16161,6 @@ __metadata: languageName: node linkType: hard -"tmpl@npm:1.0.5": - version: 1.0.5 - resolution: "tmpl@npm:1.0.5" - checksum: 10c0/f935537799c2d1922cb5d6d3805f594388f75338fe7a4a9dac41504dd539704ca4db45b883b52e7b0aa5b2fd5ddadb1452bf95cd23a69da2f793a843f9451cc9 - languageName: node - linkType: hard - "to-buffer@npm:^1.2.0": version: 1.2.2 resolution: "to-buffer@npm:1.2.2" @@ -20056,7 +16222,7 @@ __metadata: languageName: node linkType: hard -"toidentifier@npm:1.0.1": +"toidentifier@npm:~1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1" checksum: 10c0/93937279934bd66cc3270016dd8d0afec14fb7c94a05c72dc57321f8bd1fa97e5bea6d1f7c89e728d077ca31ea125b78320a616a6c6cd0e6b9cb94cb864381c1 @@ -20070,14 +16236,14 @@ __metadata: languageName: node linkType: hard -"token-types@npm:^6.0.0": - version: 6.1.1 - resolution: "token-types@npm:6.1.1" +"token-types@npm:^6.1.1, token-types@npm:^6.1.2": + version: 6.1.2 + resolution: "token-types@npm:6.1.2" dependencies: - "@borewit/text-codec": "npm:^0.1.0" + "@borewit/text-codec": "npm:^0.2.1" "@tokenizer/token": "npm:^0.3.0" ieee754: "npm:^1.2.1" - checksum: 10c0/e2405e7789d41693a09c478b53c47ffadd735a5f4c826d9885787d022ab10e26cc4a67b03593285748bf3b0c0237e0ea2ab268abcb953ea314727201d0f6504d + checksum: 10c0/8786e28e3cb65b9e890bc3c38def98e6dfe4565538237f8c0e47dbe549ed8f5f00de8dc464717868308abb4729f1958f78f69e1c4c3deebbb685729113a6fee8 languageName: node linkType: hard @@ -20107,15 +16273,6 @@ __metadata: languageName: node linkType: hard -"tree-kill@npm:1.2.2": - version: 1.2.2 - resolution: "tree-kill@npm:1.2.2" - bin: - tree-kill: cli.js - checksum: 10c0/7b1b7c7f17608a8f8d20a162e7957ac1ef6cd1636db1aba92f4e072dc31818c2ff0efac1e3d91064ede67ed5dc57c565420531a8134090a12ac10cf792ab14d2 - languageName: node - linkType: hard - "trim-newlines@npm:^1.0.0": version: 1.0.0 resolution: "trim-newlines@npm:1.0.0" @@ -20137,134 +16294,37 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:2.1.0, ts-api-utils@npm:^2.1.0": +"ts-api-utils@npm:2.1.0": version: 2.1.0 - resolution: "ts-api-utils@npm:2.1.0" - peerDependencies: - typescript: ">=4.8.4" - checksum: 10c0/9806a38adea2db0f6aa217ccc6bc9c391ddba338a9fe3080676d0d50ed806d305bb90e8cef0276e793d28c8a929f400abb184ddd7ff83a416959c0f4d2ce754f - languageName: node - linkType: hard - -"ts-essentials@npm:^10.0.2": - version: 10.1.1 - resolution: "ts-essentials@npm:10.1.1" - peerDependencies: - typescript: ">=4.5.0" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/8c59148a03eae086e7b1454fa6895e94e2f71385089ccda7e1f720a586749ede7e49ff7338e5f27e44a79f4bed740cc5dc3ad59313769bec028a85fa985685ff - languageName: node - linkType: hard - -"ts-jest@npm:^29.4.5": - version: 29.4.5 - resolution: "ts-jest@npm:29.4.5" - dependencies: - bs-logger: "npm:^0.2.6" - fast-json-stable-stringify: "npm:^2.1.0" - handlebars: "npm:^4.7.8" - json5: "npm:^2.2.3" - lodash.memoize: "npm:^4.1.2" - make-error: "npm:^1.3.6" - semver: "npm:^7.7.3" - type-fest: "npm:^4.41.0" - yargs-parser: "npm:^21.1.1" - peerDependencies: - "@babel/core": ">=7.0.0-beta.0 <8" - "@jest/transform": ^29.0.0 || ^30.0.0 - "@jest/types": ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: ">=4.3 <6" - peerDependenciesMeta: - "@babel/core": - optional: true - "@jest/transform": - optional: true - "@jest/types": - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true - bin: - ts-jest: cli.js - checksum: 10c0/789f00666ba785ac425606d42601cbdc03015e46f228a0b333f06c6658d80865819bae0ddd59c762285352d2b14d0aa50912574ec699ba6369ddb0d400a49ac0 + resolution: "ts-api-utils@npm:2.1.0" + peerDependencies: + typescript: ">=4.8.4" + checksum: 10c0/9806a38adea2db0f6aa217ccc6bc9c391ddba338a9fe3080676d0d50ed806d305bb90e8cef0276e793d28c8a929f400abb184ddd7ff83a416959c0f4d2ce754f languageName: node linkType: hard -"ts-loader@npm:^9.5.4": - version: 9.5.4 - resolution: "ts-loader@npm:9.5.4" - dependencies: - chalk: "npm:^4.1.0" - enhanced-resolve: "npm:^5.0.0" - micromatch: "npm:^4.0.0" - semver: "npm:^7.3.4" - source-map: "npm:^0.7.4" - peerDependencies: - typescript: "*" - webpack: ^5.0.0 - checksum: 10c0/f0982404b43628c335d3b3a60ac3f1738385da7b97c3f04cb5ad2ebad791597be39b25c8a4e158a66173f9bd9f5aa72e285b046b0573e4beed8ecd032d418e4d - languageName: node - linkType: hard - -"ts-node@npm:^10.9.2": - version: 10.9.2 - resolution: "ts-node@npm:10.9.2" - dependencies: - "@cspotcode/source-map-support": "npm:^0.8.0" - "@tsconfig/node10": "npm:^1.0.7" - "@tsconfig/node12": "npm:^1.0.7" - "@tsconfig/node14": "npm:^1.0.0" - "@tsconfig/node16": "npm:^1.0.2" - acorn: "npm:^8.4.1" - acorn-walk: "npm:^8.1.1" - arg: "npm:^4.1.0" - create-require: "npm:^1.1.0" - diff: "npm:^4.0.1" - make-error: "npm:^1.1.1" - v8-compile-cache-lib: "npm:^3.0.1" - yn: "npm:3.1.1" +"ts-api-utils@npm:^2.4.0, ts-api-utils@npm:^2.5.0": + version: 2.5.0 + resolution: "ts-api-utils@npm:2.5.0" peerDependencies: - "@swc/core": ">=1.2.50" - "@swc/wasm": ">=1.2.50" - "@types/node": "*" - typescript: ">=2.7" - peerDependenciesMeta: - "@swc/core": - optional: true - "@swc/wasm": - optional: true - bin: - ts-node: dist/bin.js - ts-node-cwd: dist/bin-cwd.js - ts-node-esm: dist/bin-esm.js - ts-node-script: dist/bin-script.js - ts-node-transpile-only: dist/bin-transpile.js - ts-script: dist/bin-script-deprecated.js - checksum: 10c0/5f29938489f96982a25ba650b64218e83a3357d76f7bede80195c65ab44ad279c8357264639b7abdd5d7e75fc269a83daa0e9c62fd8637a3def67254ecc9ddc2 + typescript: ">=4.8.4" + checksum: 10c0/767849383c114e7f1971fa976b20e73ac28fd0c70d8d65c0004790bf4d8f89888c7e4cf6d5949f9c1beae9bc3c64835bef77bbe27fddf45a3c7b60cebcf85c8c languageName: node linkType: hard -"tsconfig-paths-webpack-plugin@npm:4.2.0": - version: 4.2.0 - resolution: "tsconfig-paths-webpack-plugin@npm:4.2.0" - dependencies: - chalk: "npm:^4.1.0" - enhanced-resolve: "npm:^5.7.0" - tapable: "npm:^2.2.1" - tsconfig-paths: "npm:^4.1.2" - checksum: 10c0/495c5ab7c1cb079217d98fe25d61def01e4bab38047c7ab25ec11876cc8c697ff01f43ea6c9933181875e51e49835407fc71afd92ea6cca1ba1bebf513dfb510 +"ts-essentials@npm:>=10.0.0": + version: 10.2.1 + resolution: "ts-essentials@npm:10.2.1" + peerDependencies: + typescript: ">=4.5.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/2d4c1ca128ae4b112c20067806fd64b559a0c15d9f24d12122865d6212a1e108e20d903a8dbd9ff25c228b434c16db0b8b75dd385863e2c32c4a857a442dac67 languageName: node linkType: hard -"tsconfig-paths@npm:4.2.0, tsconfig-paths@npm:^4.1.2": +"tsconfig-paths@npm:4.2.0": version: 4.2.0 resolution: "tsconfig-paths@npm:4.2.0" dependencies: @@ -20287,7 +16347,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.8.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": +"tslib@npm:2.8.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -20326,13 +16386,6 @@ __metadata: languageName: node linkType: hard -"type-detect@npm:4.0.8": - version: 4.0.8 - resolution: "type-detect@npm:4.0.8" - checksum: 10c0/8fb9a51d3f365a7de84ab7f73b653534b61b622aa6800aecdb0f1095a4a646d3f5eb295322127b6573db7982afcd40ab492d038cf825a42093a58b1e1353e0bd - languageName: node - linkType: hard - "type-fest@npm:^0.18.0": version: 0.18.1 resolution: "type-fest@npm:0.18.1" @@ -20340,13 +16393,6 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^0.21.3": - version: 0.21.3 - resolution: "type-fest@npm:0.21.3" - checksum: 10c0/902bd57bfa30d51d4779b641c2bc403cdf1371fb9c91d3c058b0133694fcfdb817aef07a47f40faf79039eecbaa39ee9d3c532deff244f3a19ce68cea71a61e8 - languageName: node - linkType: hard - "type-fest@npm:^0.3.0": version: 0.3.1 resolution: "type-fest@npm:0.3.1" @@ -20368,14 +16414,7 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^4.41.0": - version: 4.41.0 - resolution: "type-fest@npm:4.41.0" - checksum: 10c0/f5ca697797ed5e88d33ac8f1fec21921839871f808dc59345c9cf67345bfb958ce41bd821165dbf3ae591cedec2bf6fe8882098dfdd8dc54320b859711a2c1e4 - languageName: node - linkType: hard - -"type-is@npm:^1.6.18": +"type-is@npm:^1.6.18, type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" dependencies: @@ -20385,14 +16424,14 @@ __metadata: languageName: node linkType: hard -"type-is@npm:^2.0.0, type-is@npm:^2.0.1": - version: 2.0.1 - resolution: "type-is@npm:2.0.1" +"type-is@npm:^2.0.1, type-is@npm:^2.1.0": + version: 2.1.0 + resolution: "type-is@npm:2.1.0" dependencies: - content-type: "npm:^1.0.5" + content-type: "npm:^2.0.0" media-typer: "npm:^1.1.0" mime-types: "npm:^3.0.0" - checksum: 10c0/7f7ec0a060b16880bdad36824ab37c26019454b67d73e8a465ed5a3587440fbe158bc765f0da68344498235c877e7dbbb1600beccc94628ed05599d667951b99 + checksum: 10c0/a6018f8f509de48f2c7429305e3a920e73b374fa93127dd0877ae1c2df65a5d33907caac8afb0c37a9b9fc7c49f29e3f55d668963dc845d966930b667c07f50e languageName: node linkType: hard @@ -20436,16 +16475,16 @@ __metadata: linkType: hard "typed-array-length@npm:^1.0.7": - version: 1.0.7 - resolution: "typed-array-length@npm:1.0.7" + version: 1.0.8 + resolution: "typed-array-length@npm:1.0.8" dependencies: - call-bind: "npm:^1.0.7" - for-each: "npm:^0.3.3" - gopd: "npm:^1.0.1" - is-typed-array: "npm:^1.1.13" - possible-typed-array-names: "npm:^1.0.0" - reflect.getprototypeof: "npm:^1.0.6" - checksum: 10c0/e38f2ae3779584c138a2d8adfa8ecf749f494af3cd3cdafe4e688ce51418c7d2c5c88df1bd6be2bbea099c3f7cea58c02ca02ed438119e91f162a9de23f61295 + call-bind: "npm:^1.0.9" + for-each: "npm:^0.3.5" + gopd: "npm:^1.2.0" + is-typed-array: "npm:^1.1.15" + possible-typed-array-names: "npm:^1.1.0" + reflect.getprototypeof: "npm:^1.0.10" + checksum: 10c0/5319f740fc426a3217182c2f7c87656acb0903e046de5a938e30167337d26abf1bb3ad4b32833a72521a4cc58223aec80627b38b357d0a3d5fd64881427e77ab languageName: node linkType: hard @@ -20481,38 +16520,38 @@ __metadata: languageName: node linkType: hard -"typeorm@npm:^0.3.27": - version: 0.3.27 - resolution: "typeorm@npm:0.3.27" +"typeorm@npm:^0.3.28": + version: 0.3.30 + resolution: "typeorm@npm:0.3.30" dependencies: "@sqltools/formatter": "npm:^1.2.5" - ansis: "npm:^3.17.0" + ansis: "npm:^4.2.0" app-root-path: "npm:^3.1.0" buffer: "npm:^6.0.3" - dayjs: "npm:^1.11.13" - debug: "npm:^4.4.0" - dedent: "npm:^1.6.0" - dotenv: "npm:^16.4.7" - glob: "npm:^10.4.5" + dayjs: "npm:^1.11.20" + debug: "npm:^4.4.3" + dedent: "npm:^1.7.2" + dotenv: "npm:^16.6.1" + glob: "npm:^10.5.0" + reflect-metadata: "npm:^0.2.2" sha.js: "npm:^2.4.12" - sql-highlight: "npm:^6.0.0" + sql-highlight: "npm:^6.1.0" tslib: "npm:^2.8.1" - uuid: "npm:^11.1.0" + uuid: "npm:^11.1.1" yargs: "npm:^17.7.2" peerDependencies: - "@google-cloud/spanner": ^5.18.0 || ^6.0.0 || ^7.0.0 + "@google-cloud/spanner": ^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 "@sap/hana-client": ^2.14.22 better-sqlite3: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 ioredis: ^5.0.4 mongodb: ^5.8.0 || ^6.0.0 - mssql: ^9.1.1 || ^10.0.1 || ^11.0.1 + mssql: ^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0 mysql2: ^2.2.5 || ^3.0.1 oracledb: ^6.3.0 pg: ^8.5.1 pg-native: ^3.0.0 pg-query-stream: ^4.0.0 redis: ^3.1.1 || ^4.0.0 || ^5.0.14 - reflect-metadata: ^0.1.14 || ^0.2.0 sql.js: ^1.4.0 sqlite3: ^5.0.3 ts-node: ^10.7.0 @@ -20554,62 +16593,62 @@ __metadata: typeorm: cli.js typeorm-ts-node-commonjs: cli-ts-node-commonjs.js typeorm-ts-node-esm: cli-ts-node-esm.js - checksum: 10c0/e0136e1d277496de1d1b327912d55af4855c83d9147896547d6da78ed485c6fc5a84a8469938afe006860c237415028391b47717743e6d4a7b60a52bc6d349aa + checksum: 10c0/7102d1e1d65ed69642414bfb4705ef658ecdd00eb73b557b79a2c7be5b2aadeb366d623762a4773d31088737d30f331e42378e4e66d77572dd94181b37b30c9e languageName: node linkType: hard "typescript-eslint@npm:^8.15.0, typescript-eslint@npm:^8.46.4": - version: 8.46.4 - resolution: "typescript-eslint@npm:8.46.4" + version: 8.61.1 + resolution: "typescript-eslint@npm:8.61.1" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.46.4" - "@typescript-eslint/parser": "npm:8.46.4" - "@typescript-eslint/typescript-estree": "npm:8.46.4" - "@typescript-eslint/utils": "npm:8.46.4" + "@typescript-eslint/eslint-plugin": "npm:8.61.1" + "@typescript-eslint/parser": "npm:8.61.1" + "@typescript-eslint/typescript-estree": "npm:8.61.1" + "@typescript-eslint/utils": "npm:8.61.1" peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/e08f1a9a55969df12590b1633f0f6c35d843b7846dc38b60ff900517f8f10dc51f37f1598db92436e858967690bbce1ae732feea2f196071f733d6d2195b0db7 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/cf27a5c25a6d492a77ee72d98ff0e708b25553c4450483c627018556bee5b7b17066c0696947aa968ed24fd07ebc095b449dea79f8a7a4c65bccdb6aaf8dc09d languageName: node linkType: hard -"typescript@npm:5.8.3": - version: 5.8.3 - resolution: "typescript@npm:5.8.3" +"typescript@npm:^5.8.0": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10c0/5f8bb01196e542e64d44db3d16ee0e4063ce4f3e3966df6005f2588e86d91c03e1fb131c2581baf0fb65ee79669eea6e161cd448178986587e9f6844446dbb48 + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 languageName: node linkType: hard -"typescript@npm:^4.9.5": - version: 4.9.5 - resolution: "typescript@npm:4.9.5" +"typescript@npm:~6.0.2": + version: 6.0.3 + resolution: "typescript@npm:6.0.3" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10c0/5f6cad2e728a8a063521328e612d7876e12f0d8a8390d3b3aaa452a6a65e24e9ac8ea22beb72a924fd96ea0a49ea63bb4e251fb922b12eedfb7f7a26475e5c56 + checksum: 10c0/4a25ff5045b984370f48f196b3a0120779b1b343d40b9a68d114ea5e5fff099809b2bb777576991a63a5cd59cf7bffd96ff6fe10afcefbcb8bd6fb96ad4b6606 languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.8.3#optional!builtin": - version: 5.8.3 - resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" +"typescript@patch:typescript@npm%3A^5.8.0#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10c0/39117e346ff8ebd87ae1510b3a77d5d92dae5a89bde588c747d25da5c146603a99c8ee588c7ef80faaf123d89ed46f6dbd918d534d641083177d5fac38b8a1cb + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^4.9.5#optional!builtin": - version: 4.9.5 - resolution: "typescript@patch:typescript@npm%3A4.9.5#optional!builtin::version=4.9.5&hash=289587" +"typescript@patch:typescript@npm%3A~6.0.2#optional!builtin": + version: 6.0.3 + resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10c0/e3333f887c6829dfe0ab6c1dbe0dd1e3e2aeb56c66460cb85c5440c566f900c833d370ca34eb47558c0c69e78ced4bfe09b8f4f98b6de7afed9b84b8d1dd06a1 + checksum: 10c0/2f25c74e65663c248fa1ade2b8459d9ce5372ff9dad07067310f132966ebec1d93f6c42f0baf77a6b6a7a91460463f708e6887013aaade22111037457c6b25df languageName: node linkType: hard @@ -20636,13 +16675,6 @@ __metadata: languageName: node linkType: hard -"uid2@npm:0.0.x": - version: 0.0.4 - resolution: "uid2@npm:0.0.4" - checksum: 10c0/c3ed69da75d117214891f4743a1d8521db823d7a2f57644c1a9ae8b3bf25f0ba666d893264bf7e22be3dbbaa292d35a23d71d06ce7283458a65e8dd137c5c362 - languageName: node - linkType: hard - "uid@npm:2.0.2": version: 2.0.2 resolution: "uid@npm:2.0.2" @@ -20652,7 +16684,7 @@ __metadata: languageName: node linkType: hard -"uint8array-extras@npm:^1.4.0": +"uint8array-extras@npm:^1.5.0": version: 1.5.0 resolution: "uint8array-extras@npm:1.5.0" checksum: 10c0/0e74641ac7dadb02eadefc1ccdadba6010e007757bda824960de3c72bbe2b04e6d3af75648441f412148c4103261d54fcb60be45a2863beb76643a55fddba3bd @@ -20685,10 +16717,24 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.16.0": - version: 7.16.0 - resolution: "undici-types@npm:7.16.0" - checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a +"undici-types@npm:~8.3.0": + version: 8.3.0 + resolution: "undici-types@npm:8.3.0" + checksum: 10c0/c8aa7e2fbebfce519654dafadc0ece59be888d2ccaf180fb4495da875e7b536d2456345c384069c7e6f3e9c9ab7435f074957da306f142343eee86ff8048855a + languageName: node + linkType: hard + +"undici@npm:^6.25.0": + version: 6.27.0 + resolution: "undici@npm:6.27.0" + checksum: 10c0/f88c3dae3957dbf9d93cb481440aced317bd3c4941b5914fea5efba516d51138988cdb5c76006f0bb1337e41d56c3443351055d492e73af2428521c37ba2a76f + languageName: node + linkType: hard + +"unicode-emoji-modifier-base@npm:^1.0.0": + version: 1.0.0 + resolution: "unicode-emoji-modifier-base@npm:1.0.0" + checksum: 10c0/b37623fcf0162186debd20f116483e035a2d5b905b932a2c472459d9143d446ebcbefb2a494e2fe4fa7434355396e2a95ec3fc1f0c29a3bc8f2c827220e79c66 languageName: node linkType: hard @@ -20720,15 +16766,6 @@ __metadata: languageName: node linkType: hard -"unique-filename@npm:^4.0.0": - version: 4.0.0 - resolution: "unique-filename@npm:4.0.0" - dependencies: - unique-slug: "npm:^5.0.0" - checksum: 10c0/38ae681cceb1408ea0587b6b01e29b00eee3c84baee1e41fd5c16b9ed443b80fba90c40e0ba69627e30855570a34ba8b06702d4a35035d4b5e198bf5a64c9ddc - languageName: node - linkType: hard - "unique-slug@npm:^2.0.0": version: 2.0.2 resolution: "unique-slug@npm:2.0.2" @@ -20738,15 +16775,6 @@ __metadata: languageName: node linkType: hard -"unique-slug@npm:^5.0.0": - version: 5.0.0 - resolution: "unique-slug@npm:5.0.0" - dependencies: - imurmurhash: "npm:^0.1.4" - checksum: 10c0/d324c5a44887bd7e105ce800fcf7533d43f29c48757ac410afd42975de82cc38ea2035c0483f4de82d186691bf3208ef35c644f73aa2b1b20b8e651be5afd293 - languageName: node - linkType: hard - "universal-user-agent@npm:^4.0.0": version: 4.0.1 resolution: "universal-user-agent@npm:4.0.1" @@ -20770,87 +16798,13 @@ __metadata: languageName: node linkType: hard -"universalify@npm:^2.0.0": - version: 2.0.1 - resolution: "universalify@npm:2.0.1" - checksum: 10c0/73e8ee3809041ca8b818efb141801a1004e3fc0002727f1531f4de613ea281b494a40909596dae4a042a4fb6cd385af5d4db2e137b1362e0e91384b828effd3a - languageName: node - linkType: hard - -"unpipe@npm:1.0.0": +"unpipe@npm:~1.0.0": version: 1.0.0 resolution: "unpipe@npm:1.0.0" checksum: 10c0/193400255bd48968e5c5383730344fbb4fa114cdedfab26e329e50dd2d81b134244bb8a72c6ac1b10ab0281a58b363d06405632c9d49ca9dfd5e90cbd7d0f32c languageName: node linkType: hard -"unrs-resolver@npm:^1.7.11": - version: 1.11.1 - resolution: "unrs-resolver@npm:1.11.1" - dependencies: - "@unrs/resolver-binding-android-arm-eabi": "npm:1.11.1" - "@unrs/resolver-binding-android-arm64": "npm:1.11.1" - "@unrs/resolver-binding-darwin-arm64": "npm:1.11.1" - "@unrs/resolver-binding-darwin-x64": "npm:1.11.1" - "@unrs/resolver-binding-freebsd-x64": "npm:1.11.1" - "@unrs/resolver-binding-linux-arm-gnueabihf": "npm:1.11.1" - "@unrs/resolver-binding-linux-arm-musleabihf": "npm:1.11.1" - "@unrs/resolver-binding-linux-arm64-gnu": "npm:1.11.1" - "@unrs/resolver-binding-linux-arm64-musl": "npm:1.11.1" - "@unrs/resolver-binding-linux-ppc64-gnu": "npm:1.11.1" - "@unrs/resolver-binding-linux-riscv64-gnu": "npm:1.11.1" - "@unrs/resolver-binding-linux-riscv64-musl": "npm:1.11.1" - "@unrs/resolver-binding-linux-s390x-gnu": "npm:1.11.1" - "@unrs/resolver-binding-linux-x64-gnu": "npm:1.11.1" - "@unrs/resolver-binding-linux-x64-musl": "npm:1.11.1" - "@unrs/resolver-binding-wasm32-wasi": "npm:1.11.1" - "@unrs/resolver-binding-win32-arm64-msvc": "npm:1.11.1" - "@unrs/resolver-binding-win32-ia32-msvc": "npm:1.11.1" - "@unrs/resolver-binding-win32-x64-msvc": "npm:1.11.1" - napi-postinstall: "npm:^0.3.0" - dependenciesMeta: - "@unrs/resolver-binding-android-arm-eabi": - optional: true - "@unrs/resolver-binding-android-arm64": - optional: true - "@unrs/resolver-binding-darwin-arm64": - optional: true - "@unrs/resolver-binding-darwin-x64": - optional: true - "@unrs/resolver-binding-freebsd-x64": - optional: true - "@unrs/resolver-binding-linux-arm-gnueabihf": - optional: true - "@unrs/resolver-binding-linux-arm-musleabihf": - optional: true - "@unrs/resolver-binding-linux-arm64-gnu": - optional: true - "@unrs/resolver-binding-linux-arm64-musl": - optional: true - "@unrs/resolver-binding-linux-ppc64-gnu": - optional: true - "@unrs/resolver-binding-linux-riscv64-gnu": - optional: true - "@unrs/resolver-binding-linux-riscv64-musl": - optional: true - "@unrs/resolver-binding-linux-s390x-gnu": - optional: true - "@unrs/resolver-binding-linux-x64-gnu": - optional: true - "@unrs/resolver-binding-linux-x64-musl": - optional: true - "@unrs/resolver-binding-wasm32-wasi": - optional: true - "@unrs/resolver-binding-win32-arm64-msvc": - optional: true - "@unrs/resolver-binding-win32-ia32-msvc": - optional: true - "@unrs/resolver-binding-win32-x64-msvc": - optional: true - checksum: 10c0/c91b112c71a33d6b24e5c708dab43ab80911f2df8ee65b87cd7a18fb5af446708e98c4b415ca262026ad8df326debcc7ca6a801b2935504d87fd6f0b9d70dce1 - languageName: node - linkType: hard - "unset-value@npm:^1.0.0": version: 1.0.0 resolution: "unset-value@npm:1.0.0" @@ -20868,20 +16822,6 @@ __metadata: languageName: node linkType: hard -"update-browserslist-db@npm:^1.1.4": - version: 1.1.4 - resolution: "update-browserslist-db@npm:1.1.4" - dependencies: - escalade: "npm:^3.2.0" - picocolors: "npm:^1.1.1" - peerDependencies: - browserslist: ">= 4.21.0" - bin: - update-browserslist-db: cli.js - checksum: 10c0/db0c9aaecf1258a6acda5e937fc27a7996ccca7a7580a1b4aa8bba6a9b0e283e5e65c49ebbd74ec29288ef083f1b88d4da13e3d4d326c1e5fc55bf72d7390702 - languageName: node - linkType: hard - "upper-case@npm:^1.1.1": version: 1.1.3 resolution: "upper-case@npm:1.1.3" @@ -20928,19 +16868,19 @@ __metadata: languageName: node linkType: hard -"utils-merge@npm:1.x.x, utils-merge@npm:^1.0.1": +"utils-merge@npm:1.0.1, utils-merge@npm:^1.0.1": version: 1.0.1 resolution: "utils-merge@npm:1.0.1" checksum: 10c0/02ba649de1b7ca8854bfe20a82f1dfbdda3fb57a22ab4a8972a63a34553cf7aa51bc9081cf7e001b035b88186d23689d69e71b510e610a09a4c66f68aa95b672 languageName: node linkType: hard -"uuid@npm:^11.1.0": - version: 11.1.0 - resolution: "uuid@npm:11.1.0" +"uuid@npm:^11.1.1": + version: 11.1.1 + resolution: "uuid@npm:11.1.1" bin: uuid: dist/esm/bin/uuid - checksum: 10c0/34aa51b9874ae398c2b799c88a127701408cd581ee89ec3baa53509dd8728cbb25826f2a038f9465f8b7be446f0fbf11558862965b18d21c993684297628d4d3 + checksum: 10c0/9e3af58eba872ece5a5e76f4773a94fc78a0ef2c2444c38dbe6b42f41dadf76c01850fd783604f27986f6195e6286aef064d45987d401b2a33127b98ddf7c0c5 languageName: node linkType: hard @@ -20953,15 +16893,6 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^8.3.2": - version: 8.3.2 - resolution: "uuid@npm:8.3.2" - bin: - uuid: dist/bin/uuid - checksum: 10c0/bcbb807a917d374a49f475fae2e87fdca7da5e5530820ef53f65ba1d12131bd81a92ecf259cc7ce317cbe0f289e7d79fdfebcef9bfa3087c8c8a2fa304c9be54 - languageName: node - linkType: hard - "uuid@npm:^9.0.0": version: 9.0.1 resolution: "uuid@npm:9.0.1" @@ -20971,24 +16902,6 @@ __metadata: languageName: node linkType: hard -"v8-compile-cache-lib@npm:^3.0.1": - version: 3.0.1 - resolution: "v8-compile-cache-lib@npm:3.0.1" - checksum: 10c0/bdc36fb8095d3b41df197f5fb6f11e3a26adf4059df3213e3baa93810d8f0cc76f9a74aaefc18b73e91fe7e19154ed6f134eda6fded2e0f1c8d2272ed2d2d391 - languageName: node - linkType: hard - -"v8-to-istanbul@npm:^9.0.1": - version: 9.3.0 - resolution: "v8-to-istanbul@npm:9.3.0" - dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.12" - "@types/istanbul-lib-coverage": "npm:^2.0.1" - convert-source-map: "npm:^2.0.0" - checksum: 10c0/968bcf1c7c88c04df1ffb463c179558a2ec17aa49e49376120504958239d9e9dad5281aa05f2a78542b8557f2be0b0b4c325710262f3b838b40d703d5ed30c23 - languageName: node - linkType: hard - "valid-data-url@npm:^3.0.0": version: 3.0.1 resolution: "valid-data-url@npm:3.0.1" @@ -21015,14 +16928,7 @@ __metadata: languageName: node linkType: hard -"validator@npm:^13.9.0": - version: 13.15.23 - resolution: "validator@npm:13.15.23" - checksum: 10c0/22a05ec6a98d48d2b6fb34d43ce854af61d15842362d142e64cfca0325d4d0c2d1051d9f9d3a0f741e58ea888f73a35baf7a2a810f5aed0f89183bd5040f0177 - languageName: node - linkType: hard - -"vary@npm:^1, vary@npm:^1.1.2": +"vary@npm:^1, vary@npm:^1.1.2, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" checksum: 10c0/f15d588d79f3675135ba783c91a4083dcd290a2a5be9fcb6514220a1634e23df116847b1cc51f66bfb0644cf9353b2abb7815ae499bab06e46dd33c1a6bf1f4f @@ -21040,6 +16946,143 @@ __metadata: languageName: node linkType: hard +"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": + version: 8.1.2 + resolution: "vite@npm:8.1.2" + dependencies: + fsevents: "npm:~2.3.3" + lightningcss: "npm:^1.32.0" + picomatch: "npm:^4.0.4" + postcss: "npm:^8.5.16" + rolldown: "npm:~1.1.3" + tinyglobby: "npm:^0.2.17" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/b39731dc31250b267ae5ddae81d737deafe2163bd0e1aa04849fb5f7c66b7ff7bcffa50883480b5f9fb47b1596b772021a8f9f671986e57b88d98300710ded77 + languageName: node + linkType: hard + +"vitest-mock-extended@npm:^4.0.0": + version: 4.0.0 + resolution: "vitest-mock-extended@npm:4.0.0" + dependencies: + ts-essentials: "npm:>=10.0.0" + peerDependencies: + typescript: 3.x || 4.x || 5.x || 6.x + vitest: ">=4.0.0" + checksum: 10c0/c0730f1996475a1e87ef23ea637844c48caee928be65c8f9a00b53dde19805eb8df56dfc8a88fe9896092096d88172c6a3aaaae27317c7aabdc3d9fa4d5e234b + languageName: node + linkType: hard + +"vitest@npm:^4.1.9": + version: 4.1.9 + resolution: "vitest@npm:4.1.9" + dependencies: + "@vitest/expect": "npm:4.1.9" + "@vitest/mocker": "npm:4.1.9" + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/runner": "npm:4.1.9" + "@vitest/snapshot": "npm:4.1.9" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^4.0.0-rc.1" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.9 + "@vitest/browser-preview": 4.1.9 + "@vitest/browser-webdriverio": 4.1.9 + "@vitest/coverage-istanbul": 4.1.9 + "@vitest/coverage-v8": 4.1.9 + "@vitest/ui": 4.1.9 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: + optional: false + bin: + vitest: ./vitest.mjs + checksum: 10c0/1ac80ef4991be82822a52aea48415f1bc64ddf8fd88ee24c172ec368f1d480fefacbde622c3c951982f7961a1d07313e18deaafc774d29e42ad6f6ffa63334a7 + languageName: node + linkType: hard + "void-elements@npm:^3.1.0": version: 3.1.0 resolution: "void-elements@npm:3.1.0" @@ -21061,25 +17104,6 @@ __metadata: languageName: node linkType: hard -"walker@npm:^1.0.8": - version: 1.0.8 - resolution: "walker@npm:1.0.8" - dependencies: - makeerror: "npm:1.0.12" - checksum: 10c0/a17e037bccd3ca8a25a80cb850903facdfed0de4864bd8728f1782370715d679fa72e0a0f5da7c1c1379365159901e5935f35be531229da53bbfc0efdabdb48e - languageName: node - linkType: hard - -"watchpack@npm:^2.4.1": - version: 2.4.4 - resolution: "watchpack@npm:2.4.4" - dependencies: - glob-to-regexp: "npm:^0.4.1" - graceful-fs: "npm:^4.1.2" - checksum: 10c0/6c0901f75ce245d33991225af915eea1c5ae4ba087f3aee2b70dd377d4cacb34bef02a48daf109da9d59b2d31ec6463d924a0d72f8618ae1643dd07b95de5275 - languageName: node - linkType: hard - "wcwidth@npm:^1.0.0, wcwidth@npm:^1.0.1": version: 1.0.1 resolution: "wcwidth@npm:1.0.1" @@ -21117,58 +17141,6 @@ __metadata: languageName: node linkType: hard -"webpack-node-externals@npm:3.0.0": - version: 3.0.0 - resolution: "webpack-node-externals@npm:3.0.0" - checksum: 10c0/9f645a4dc8e122dac43cdc8c1367d4b44af20c79632438b633acc1b4fe64ea7ba1ad6ab61bd0fc46e1b873158c48d8c7a25a489cdab1f31299f00eb3b81cfc61 - languageName: node - linkType: hard - -"webpack-sources@npm:^3.3.3": - version: 3.3.3 - resolution: "webpack-sources@npm:3.3.3" - checksum: 10c0/ab732f6933b513ba4d505130418995ddef6df988421fccf3289e53583c6a39e205c4a0739cee98950964552d3006604912679c736031337fb4a9d78d8576ed40 - languageName: node - linkType: hard - -"webpack@npm:5.100.2": - version: 5.100.2 - resolution: "webpack@npm:5.100.2" - dependencies: - "@types/eslint-scope": "npm:^3.7.7" - "@types/estree": "npm:^1.0.8" - "@types/json-schema": "npm:^7.0.15" - "@webassemblyjs/ast": "npm:^1.14.1" - "@webassemblyjs/wasm-edit": "npm:^1.14.1" - "@webassemblyjs/wasm-parser": "npm:^1.14.1" - acorn: "npm:^8.15.0" - acorn-import-phases: "npm:^1.0.3" - browserslist: "npm:^4.24.0" - chrome-trace-event: "npm:^1.0.2" - enhanced-resolve: "npm:^5.17.2" - es-module-lexer: "npm:^1.2.1" - eslint-scope: "npm:5.1.1" - events: "npm:^3.2.0" - glob-to-regexp: "npm:^0.4.1" - graceful-fs: "npm:^4.2.11" - json-parse-even-better-errors: "npm:^2.3.1" - loader-runner: "npm:^4.2.0" - mime-types: "npm:^2.1.27" - neo-async: "npm:^2.6.2" - schema-utils: "npm:^4.3.2" - tapable: "npm:^2.1.1" - terser-webpack-plugin: "npm:^5.3.11" - watchpack: "npm:^2.4.1" - webpack-sources: "npm:^3.3.3" - peerDependenciesMeta: - webpack-cli: - optional: true - bin: - webpack: bin/webpack.js - checksum: 10c0/0add75d44c482634c6879a3fc87fa2af6a6c7c8eacda5d5f60ed778a2ce13d33fd6178a2b4750368706a49e769af6d828934c28914b4faa2e21be790f92b4110 - languageName: node - linkType: hard - "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" @@ -21244,17 +17216,17 @@ __metadata: linkType: hard "which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19": - version: 1.1.19 - resolution: "which-typed-array@npm:1.1.19" + version: 1.1.22 + resolution: "which-typed-array@npm:1.1.22" dependencies: available-typed-arrays: "npm:^1.0.7" - call-bind: "npm:^1.0.8" + call-bind: "npm:^1.0.9" call-bound: "npm:^1.0.4" for-each: "npm:^0.3.5" get-proto: "npm:^1.0.1" gopd: "npm:^1.2.0" has-tostringtag: "npm:^1.0.2" - checksum: 10c0/702b5dc878addafe6c6300c3d0af5983b175c75fcb4f2a72dfc3dd38d93cf9e89581e4b29c854b16ea37e50a7d7fca5ae42ece5c273d8060dcd603b2404bbb3f + checksum: 10c0/e59db184a4e78b461fac3b05fafc1e7badbbedafbf04a967ee1de73717f1f9723a79699e7b5de71d449541cb5da8353efc01a4f8a72a152479850e54fa196c40 languageName: node linkType: hard @@ -21280,14 +17252,26 @@ __metadata: languageName: node linkType: hard -"which@npm:^6.0.0": - version: 6.0.0 - resolution: "which@npm:6.0.0" +"which@npm:^7.0.0": + version: 7.0.0 + resolution: "which@npm:7.0.0" dependencies: - isexe: "npm:^3.1.1" + isexe: "npm:^4.0.0" bin: node-which: bin/which.js - checksum: 10c0/fe9d6463fe44a76232bb6e3b3181922c87510a5b250a98f1e43a69c99c079b3f42ddeca7e03d3e5f2241bf2d334f5a7657cfa868b97c109f3870625842f4cc15 + checksum: 10c0/ca0b54f198f78bbc4b7c02e34bda8d335cb352e0adb4cbca1c37b1a957af3a879a82c4c27ca6525bc942f548d8b64f816ef6528360af9f3de55ffb9b979b620d + languageName: node + linkType: hard + +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 languageName: node linkType: hard @@ -21357,17 +17341,6 @@ __metadata: languageName: node linkType: hard -"wrap-ansi@npm:^6.2.0": - version: 6.2.0 - resolution: "wrap-ansi@npm:6.2.0" - dependencies: - ansi-styles: "npm:^4.0.0" - string-width: "npm:^4.1.0" - strip-ansi: "npm:^6.0.0" - checksum: 10c0/baad244e6e33335ea24e86e51868fe6823626e3a3c88d9a6674642afff1d34d9a154c917e74af8d845fd25d170c4ea9cf69a47133c3f3656e1252b3d462d9f6c - languageName: node - linkType: hard - "wrap-ansi@npm:^8.1.0": version: 8.1.0 resolution: "wrap-ansi@npm:8.1.0" @@ -21397,16 +17370,6 @@ __metadata: languageName: node linkType: hard -"write-file-atomic@npm:^5.0.1": - version: 5.0.1 - resolution: "write-file-atomic@npm:5.0.1" - dependencies: - imurmurhash: "npm:^0.1.4" - signal-exit: "npm:^4.0.1" - checksum: 10c0/e8c850a8e3e74eeadadb8ad23c9d9d63e4e792bd10f4836ed74189ef6e996763959f1249c5650e232f3c77c11169d239cbfc8342fc70f3fe401407d23810505d - languageName: node - linkType: hard - "write-json-file@npm:^2.2.0": version: 2.3.0 resolution: "write-json-file@npm:2.3.0" @@ -21445,14 +17408,7 @@ __metadata: languageName: node linkType: hard -"xml@npm:^1.0.1": - version: 1.0.1 - resolution: "xml@npm:1.0.1" - checksum: 10c0/04bcc9b8b5e7b49392072fbd9c6b0f0958bd8e8f8606fee460318e43991349a68cbc5384038d179ff15aef7d222285f69ca0f067f53d071084eb14c7fdb30411 - languageName: node - linkType: hard - -"xtend@npm:^4.0.0, xtend@npm:^4.0.2, xtend@npm:~4.0.1": +"xtend@npm:~4.0.1": version: 4.0.2 resolution: "xtend@npm:4.0.2" checksum: 10c0/366ae4783eec6100f8a02dff02ac907bf29f9a00b82ac0264b4d8b832ead18306797e283cf19de776538babfdcb2101375ec5646b59f08c52128ac4ab812ed0e @@ -21494,13 +17450,6 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:21.1.1, yargs-parser@npm:^21.1.1": - version: 21.1.1 - resolution: "yargs-parser@npm:21.1.1" - checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 - languageName: node - linkType: hard - "yargs-parser@npm:^15.0.1": version: 15.0.3 resolution: "yargs-parser@npm:15.0.3" @@ -21518,6 +17467,13 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 + languageName: node + linkType: hard + "yargs@npm:^14.2.2": version: 14.2.3 resolution: "yargs@npm:14.2.3" @@ -21538,8 +17494,8 @@ __metadata: linkType: hard "yargs@npm:^16.0.0, yargs@npm:^16.2.0": - version: 16.2.0 - resolution: "yargs@npm:16.2.0" + version: 16.2.2 + resolution: "yargs@npm:16.2.2" dependencies: cliui: "npm:^7.0.2" escalade: "npm:^3.1.1" @@ -21548,13 +17504,13 @@ __metadata: string-width: "npm:^4.2.0" y18n: "npm:^5.0.5" yargs-parser: "npm:^20.2.2" - checksum: 10c0/b1dbfefa679848442454b60053a6c95d62f2d2e21dd28def92b647587f415969173c6e99a0f3bab4f1b67ee8283bf735ebe3544013f09491186ba9e8a9a2b651 + checksum: 10c0/1ca2152581ee7c9c9fb4174767ff7294b1c272d2d0a1f6eb13c39ce177fded85eb9c96711e00cd7913c0a9b88b6e763713ee11cae81b9b62fd97bdd0b03d5549 languageName: node linkType: hard "yargs@npm:^17.0.0, yargs@npm:^17.3.0, yargs@npm:^17.7.2": - version: 17.7.2 - resolution: "yargs@npm:17.7.2" + version: 17.7.3 + resolution: "yargs@npm:17.7.3" dependencies: cliui: "npm:^8.0.1" escalade: "npm:^3.1.1" @@ -21563,14 +17519,7 @@ __metadata: string-width: "npm:^4.2.3" y18n: "npm:^5.0.5" yargs-parser: "npm:^21.1.1" - checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 - languageName: node - linkType: hard - -"yn@npm:3.1.1": - version: 3.1.1 - resolution: "yn@npm:3.1.1" - checksum: 10c0/0732468dd7622ed8a274f640f191f3eaf1f39d5349a1b72836df484998d7d9807fbea094e2f5486d6b0cd2414aad5775972df0e68f8604db89a239f0f4bf7443 + checksum: 10c0/7a28572f7e785a57886e34fdbddb9b28756dec552e1453d5f6e7cdd00ad8721a4e8c4321d33683f5e61cacb36ad43258adbb48396b71ec4ed14abee0fc0d0c1f languageName: node linkType: hard @@ -21588,10 +17537,17 @@ __metadata: languageName: node linkType: hard -"yoctocolors-cjs@npm:^2.1.3": - version: 2.1.3 - resolution: "yoctocolors-cjs@npm:2.1.3" - checksum: 10c0/584168ef98eb5d913473a4858dce128803c4a6cd87c0f09e954fa01126a59a33ab9e513b633ad9ab953786ed16efdd8c8700097a51635aafaeed3fef7712fa79 +"yoctocolors@npm:^2.1.1": + version: 2.1.2 + resolution: "yoctocolors@npm:2.1.2" + checksum: 10c0/b220f30f53ebc2167330c3adc86a3c7f158bcba0236f6c67e25644c3188e2571a6014ffc1321943bb619460259d3d27eb4c9cc58c2d884c1b195805883ec7066 + languageName: node + linkType: hard + +"zod@npm:^4.4.3": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3 languageName: node linkType: hard