From 7119f89c31598049a6b9cff1693873c3acc7e034 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 20 Feb 2026 12:19:35 +1100 Subject: [PATCH 01/41] Fix the response format for copilot requests to match the previous service and fix a bug in the copilots portal --- src/api/copilot/copilot-request.service.ts | 44 +++++++++++++--------- src/api/copilot/dto/copilot-request.dto.ts | 3 ++ 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/api/copilot/copilot-request.service.ts b/src/api/copilot/copilot-request.service.ts index fd67e25..8c343f5 100644 --- a/src/api/copilot/copilot-request.service.ts +++ b/src/api/copilot/copilot-request.service.ts @@ -47,10 +47,7 @@ const REQUEST_SORTS = [ type CopilotRequestWithRelations = CopilotRequest & { opportunities: CopilotOpportunity[]; - project?: { - id: bigint; - name: string; - } | null; + project?: ({ name?: string } & Record) | null; }; interface PaginatedRequestResponse { @@ -73,6 +70,7 @@ export class CopilotRequestService { user: JwtUser, ): Promise { this.ensurePermission(NamedPermission.MANAGE_COPILOT_REQUEST, user); + const includeProjectInResponse = isAdminOrManager(user); const parsedProjectId = projectId ? parseNumericId(projectId, 'Project') @@ -84,8 +82,7 @@ export class CopilotRequestService { 'createdAt desc', ); - const includeProject = - isAdminOrManager(user) || sortField.toLowerCase() === 'projectname'; + const includeProjectForSort = sortField.toLowerCase() === 'projectname'; const requests = await this.prisma.copilotRequest.findMany({ where: { @@ -101,14 +98,16 @@ export class CopilotRequestService { id: 'asc', }, }, - project: includeProject - ? { - select: { - id: true, - name: true, - }, - } - : false, + project: includeProjectInResponse + ? true + : includeProjectForSort + ? { + select: { + id: true, + name: true, + }, + } + : false, }, }); @@ -123,7 +122,9 @@ export class CopilotRequestService { return { data: sorted .slice(start, end) - .map((request) => this.formatRequest(request, isAdminOrManager(user))), + .map((request) => + this.formatRequest(request, includeProjectInResponse), + ), page, perPage, total, @@ -503,7 +504,7 @@ export class CopilotRequestService { private formatRequest( input: CopilotRequestWithRelations, - includeProjectId: boolean, + includeProjectInResponse: boolean, ): CopilotRequestResponseDto { const normalized = normalizeEntity(input) as Record; @@ -528,10 +529,14 @@ export class CopilotRequestService { copilotOpportunity: opportunities, }; - if (includeProjectId && normalized.projectId) { + if (includeProjectInResponse && normalized.projectId) { response.projectId = String(normalized.projectId); } + if (includeProjectInResponse && normalized.project) { + response.project = normalized.project as Record; + } + return response; } @@ -548,7 +553,10 @@ export class CopilotRequestService { if (field === 'projectName') { return ( - this.compareValues(left.project?.name, right.project?.name) * factor + this.compareValues( + left.project?.name as unknown, + right.project?.name as unknown, + ) * factor ); } diff --git a/src/api/copilot/dto/copilot-request.dto.ts b/src/api/copilot/dto/copilot-request.dto.ts index 5e202a0..3de411e 100644 --- a/src/api/copilot/dto/copilot-request.dto.ts +++ b/src/api/copilot/dto/copilot-request.dto.ts @@ -189,6 +189,9 @@ export class CopilotRequestResponseDto { @ApiPropertyOptional() projectId?: string; + @ApiPropertyOptional({ type: Object }) + project?: Record; + @ApiProperty({ enum: CopilotRequestStatus, enumName: 'CopilotRequestStatus' }) status: CopilotRequestStatus; From 87bca521270fe4415cb882b89e154f14d5230355 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 20 Feb 2026 12:56:49 +1100 Subject: [PATCH 02/41] Enable code scanning and reviewer, lots of documentation, fix a permissions issue when challenge-api-v6 calls into the project service when launching a challenge. --- .github/workflows/code_reviewer.yml | 22 ++ .github/workflows/trivy.yaml | 35 +++ package.json | 13 +- pnpm-lock.yaml | 74 +++--- src/api/api.module.ts | 22 ++ .../health-check/healthCheck.controller.ts | 32 +++ src/api/project/project.controller.ts | 7 +- src/app.module.ts | 16 ++ src/main.ts | 48 ++++ src/shared/config/app.config.ts | 42 ++++ src/shared/config/kafka.config.ts | 28 +++ src/shared/constants/event.constants.ts | 19 ++ src/shared/constants/permissions.constants.ts | 217 +++++++++++++++++- src/shared/constants/permissions.ts | 66 ++++++ .../decorators/currentUser.decorator.ts | 9 + .../decorators/projectMembers.decorator.ts | 8 + src/shared/decorators/public.decorator.ts | 11 + .../decorators/requirePermission.decorator.ts | 24 ++ src/shared/decorators/scopes.decorator.ts | 16 ++ src/shared/enums/projectMemberRole.enum.ts | 36 +++ src/shared/enums/scopes.enum.ts | 75 ++++++ src/shared/enums/userRole.enum.ts | 70 ++++++ src/shared/guards/adminOnly.guard.ts | 45 ++++ src/shared/guards/copilotAndAbove.guard.ts | 36 +++ src/shared/guards/permission.guard.ts | 52 +++++ src/shared/guards/projectMember.guard.ts | 58 +++++ src/shared/guards/tokenRoles.guard.ts | 54 +++++ .../projectContext.interceptor.ts | 35 +++ src/shared/interfaces/permission.interface.ts | 79 +++++++ src/shared/interfaces/request.interface.ts | 15 ++ src/shared/modules/global/eventBus.service.ts | 46 ++++ .../modules/global/globalProviders.module.ts | 19 ++ src/shared/modules/global/jwt.service.ts | 154 +++++++++++++ src/shared/modules/global/logger.service.ts | 86 +++++++ src/shared/modules/global/m2m.service.ts | 62 +++++ .../modules/global/prisma-error.service.ts | 29 +++ src/shared/modules/global/prisma.service.ts | 59 +++++ src/shared/services/billingAccount.service.ts | 140 ++++++++++- src/shared/services/email.service.ts | 36 +++ src/shared/services/file.service.ts | 45 ++++ src/shared/services/identity.service.ts | 22 ++ src/shared/services/member.service.ts | 42 ++++ .../services/permission.service.spec.ts | 18 ++ src/shared/services/permission.service.ts | 175 +++++++++++++- src/shared/utils/event.utils.ts | 98 ++++++++ src/shared/utils/member.utils.ts | 81 +++++++ src/shared/utils/pagination.utils.ts | 27 +++ src/shared/utils/permission.utils.ts | 33 +++ src/shared/utils/project.utils.ts | 89 +++++++ src/shared/utils/swagger.utils.ts | 39 ++++ 50 files changed, 2493 insertions(+), 71 deletions(-) create mode 100644 .github/workflows/code_reviewer.yml create mode 100644 .github/workflows/trivy.yaml diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml new file mode 100644 index 0000000..9b6a6ce --- /dev/null +++ b/.github/workflows/code_reviewer.yml @@ -0,0 +1,22 @@ +name: AI PR Reviewer + +on: + pull_request: + types: + - opened + - synchronize +permissions: + pull-requests: write +jobs: + tc-ai-pr-review: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: TC AI PR Reviewer + uses: topcoder-platform/tc-ai-pr-reviewer@master + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) + LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} + exclude: '**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp' # Optional: exclude patterns separated by commas \ No newline at end of file diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml new file mode 100644 index 0000000..a99d8b8 --- /dev/null +++ b/.github/workflows/trivy.yaml @@ -0,0 +1,35 @@ +name: Trivy Scanner + +permissions: + contents: read + security-events: write + +on: + push: + branches: + - dev + pull_request: + +jobs: + trivy-scan: + name: Use Trivy + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy scanner in repo mode + uses: aquasecurity/trivy-action@0.34.0 + with: + scan-type: fs + ignore-unfixed: true + format: sarif + output: trivy-results.sarif + severity: CRITICAL,HIGH,UNKNOWN + scanners: vuln,secret,misconfig,license + github-pat: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results.sarif diff --git a/package.json b/package.json index c7352ce..0ca0f1f 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,8 @@ "cors": "^2.8.5", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", - "lodash": "^4.17.21", - "qs": "^6.14.0", + "lodash": "^4.17.23", + "qs": "^6.14.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "tc-bus-api-wrapper": "github:topcoder-platform/tc-bus-api-wrapper.git", @@ -102,4 +102,13 @@ "testEnvironment": "node" }, "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" + , + "pnpm": { + "overrides": { + "fast-xml-parser": "5.3.6", + "hono": "4.11.7", + "lodash": "4.17.23", + "qs": "6.14.2" + } + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02c608b..73d9413 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,12 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + fast-xml-parser: 5.3.6 + hono: 4.11.7 + lodash: 4.17.23 + qs: 6.14.2 + importers: .: @@ -60,11 +66,11 @@ importers: specifier: ^3.2.0 version: 3.2.2 lodash: - specifier: ^4.17.21 + specifier: 4.17.23 version: 4.17.23 qs: - specifier: ^6.14.0 - version: 6.14.1 + specifier: 6.14.2 + version: 6.14.2 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -653,7 +659,7 @@ packages: resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.11.7 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -973,49 +979,42 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-arm64-musl@1.1.1': resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/nice-linux-ppc64-gnu@1.1.1': resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-riscv64-gnu@1.1.1': resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-s390x-gnu@1.1.1': resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-x64-gnu@1.1.1': resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-x64-musl@1.1.1': resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/nice-openharmony-arm64@1.1.1': resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} @@ -1508,28 +1507,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.11': resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.15.11': resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.11': resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.11': resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} @@ -2649,8 +2644,8 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-xml-parser@5.3.4: - resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} + fast-xml-parser@5.3.6: + resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} hasBin: true fastq@1.20.1: @@ -2833,11 +2828,11 @@ packages: glob@6.0.4: resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -2895,8 +2890,8 @@ packages: hdr-histogram-percentiles-obj@3.0.0: resolution: {integrity: sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==} - hono@4.11.4: - resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} + hono@4.11.7: + resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -3338,9 +3333,6 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -3826,8 +3818,8 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - qs@6.14.1: - resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -5107,7 +5099,7 @@ snapshots: '@aws-sdk/xml-builder@3.972.4': dependencies: '@smithy/types': 4.12.0 - fast-xml-parser: 5.3.4 + fast-xml-parser: 5.3.6 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.3': {} @@ -5307,12 +5299,12 @@ snapshots: dependencies: '@chevrotain/gast': 10.5.0 '@chevrotain/types': 10.5.0 - lodash: 4.17.21 + lodash: 4.17.23 '@chevrotain/gast@10.5.0': dependencies: '@chevrotain/types': 10.5.0 - lodash: 4.17.21 + lodash: 4.17.23 '@chevrotain/types@10.5.0': {} @@ -5405,9 +5397,9 @@ snapshots: dependencies: '@hapi/hoek': 11.0.7 - '@hono/node-server@1.19.9(hono@4.11.4)': + '@hono/node-server@1.19.9(hono@4.11.7)': dependencies: - hono: 4.11.4 + hono: 4.11.7 '@humanfs/core@0.19.1': {} @@ -6029,13 +6021,13 @@ snapshots: '@electric-sql/pglite': 0.3.15 '@electric-sql/pglite-socket': 0.0.20(@electric-sql/pglite@0.3.15) '@electric-sql/pglite-tools': 0.2.20(@electric-sql/pglite@0.3.15) - '@hono/node-server': 1.19.9(hono@4.11.4) + '@hono/node-server': 1.19.9(hono@4.11.7) '@mrleebo/prisma-ast': 0.13.1 '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 foreground-child: 3.3.1 get-port-please: 3.2.0 - hono: 4.11.4 + hono: 4.11.7 http-status-codes: 2.3.0 pathe: 2.0.3 proper-lockfile: 4.1.2 @@ -7187,7 +7179,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.14.1 + qs: 6.14.2 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -7310,7 +7302,7 @@ snapshots: '@chevrotain/gast': 10.5.0 '@chevrotain/types': 10.5.0 '@chevrotain/utils': 10.5.0 - lodash: 4.17.21 + lodash: 4.17.23 regexp-to-ast: 0.5.0 chokidar@4.0.3: @@ -7742,7 +7734,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.14.1 + qs: 6.14.2 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -7790,7 +7782,7 @@ snapshots: fast-uri@3.1.0: {} - fast-xml-parser@5.3.4: + fast-xml-parser@5.3.6: dependencies: strnum: 2.1.2 @@ -8067,7 +8059,7 @@ snapshots: hdr-histogram-percentiles-obj@3.0.0: {} - hono@4.11.4: {} + hono@4.11.7: {} html-escaper@2.0.2: {} @@ -8667,8 +8659,6 @@ snapshots: lodash.once@4.1.1: {} - lodash@4.17.21: {} - lodash@4.17.23: {} log-symbols@4.1.0: @@ -9101,7 +9091,7 @@ snapshots: pure-rand@6.1.0: {} - qs@6.14.1: + qs@6.14.2: dependencies: side-channel: 1.1.0 @@ -9441,7 +9431,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.14.1 + qs: 6.14.2 transitivePeerDependencies: - supports-color diff --git a/src/api/api.module.ts b/src/api/api.module.ts index b4f2e43..86c3780 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -1,5 +1,7 @@ +// TODO (quality): HttpModule is already provided by GlobalProvidersModule (which is @Global()). Verify whether any provider in ApiModule directly depends on it at this level; if not, remove this import. import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +// TODO (quality): GlobalProvidersModule is decorated @Global() and is already imported in AppModule. Importing it again here is redundant. Remove this import from ApiModule. import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; import { CopilotModule } from './copilot/copilot.module'; import { PhaseProductModule } from './phase-product/phase-product.module'; @@ -12,6 +14,25 @@ import { ProjectPhaseModule } from './project-phase/project-phase.module'; import { ProjectSettingModule } from './project-setting/project-setting.module'; import { ProjectModule } from './project/project.module'; +/** + * Feature aggregation module for all API routes in the Topcoder Project API v6. + * + * Imports and re-exports every domain feature module: + * - ProjectModule - CRUD for projects + * - ProjectMemberModule - project membership management + * - ProjectInviteModule - invitation workflow + * - ProjectAttachmentModule - file attachments + * - ProjectPhaseModule - project phases + * - PhaseProductModule - products within phases + * - ProjectSettingModule - per-project settings + * - CopilotModule - copilot request/opportunity/application flow + * - MetadataModule - reference metadata (categories, skills, etc.) + * + * Also registers HealthCheckController directly (not via a sub-module). + * + * Consumed by: AppModule (imported once at the root). + * Swagger: main.ts includes this module when building the OpenAPI document. + */ @Module({ imports: [ HttpModule, @@ -25,6 +46,7 @@ import { ProjectModule } from './project/project.module'; ProjectPhaseModule, PhaseProductModule, ProjectSettingModule, + // TODO (quality): WorkStreamModule is included in the Swagger document in main.ts but is not imported here. Add WorkStreamModule to this imports array so its routes are part of the same module graph, or remove it from the Swagger include list. ], controllers: [HealthCheckController], providers: [], diff --git a/src/api/health-check/healthCheck.controller.ts b/src/api/health-check/healthCheck.controller.ts index b5ab456..99f9b04 100644 --- a/src/api/health-check/healthCheck.controller.ts +++ b/src/api/health-check/healthCheck.controller.ts @@ -8,14 +8,37 @@ import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import { Public } from 'src/shared/decorators/public.decorator'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; +// TODO (quality): Move GetHealthCheckResponseDto to a dedicated src/api/health-check/dto/get-health-check-response.dto.ts file to follow the project's DTO file convention. +/** + * Response shape returned by the health-check endpoint. + * + * Used by: HealthCheckController.healthCheck() + */ export class GetHealthCheckResponseDto { @ApiProperty({ description: 'Health checks run number', example: 1, }) + /** Running count of health checks executed since the last server restart. */ checksRun: number; } +/** + * Controller that exposes a liveness/readiness health-check endpoint for the + * Topcoder Project API v6. + * + * Route: GET /v6/projects/health (prefix applied by ApiModule -> AppModule) + * Auth: @Public() - no JWT or M2M token required. + * + * The check performs a lightweight Prisma query (findFirst on the project + * table) to verify database connectivity. If the query exceeds + * HEALTH_CHECK_TIMEOUT ms (default 60 000 ms) it throws + * InternalServerErrorException; any Prisma/DB error throws + * ServiceUnavailableException. + * + * Used by: Kubernetes liveness probes, load-balancer health checks, and + * platform monitoring dashboards. + */ @ApiTags('Healthcheck') @Controller('/projects') export class HealthCheckController { @@ -27,6 +50,14 @@ export class HealthCheckController { @Public() @Get('/health') @ApiOperation({ summary: 'Execute a health check' }) + /** + * Executes a lightweight database connectivity check. + * + * @returns {Promise} Object containing the cumulative number of health checks run since the last server restart. + * + * @throws {InternalServerErrorException} (HTTP 500) when the database query completes but took longer than the configured HEALTH_CHECK_TIMEOUT. + * @throws {ServiceUnavailableException} (HTTP 503) when the database query itself throws (connection refused, query error, etc.). + */ async healthCheck(): Promise { const response = new GetHealthCheckResponseDto(); @@ -41,6 +72,7 @@ export class HealthCheckController { }); const elapsedMs = Date.now() - startedAt; + // TODO (quality): The timeout is checked after the Prisma query resolves, so it cannot interrupt a hung database connection. Use Promise.race() with a setTimeout-based rejection to enforce a hard deadline on the query itself. if (elapsedMs > this.timeout) { throw new InternalServerErrorException('Database operation is slow.'); } diff --git a/src/api/project/project.controller.ts b/src/api/project/project.controller.ts index 43f841f..168b41a 100644 --- a/src/api/project/project.controller.ts +++ b/src/api/project/project.controller.ts @@ -141,12 +141,7 @@ export class ProjectController { @Get(':projectId') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) - @Scopes( - Scope.PROJECTS_READ, - Scope.PROJECTS_WRITE, - Scope.PROJECTS_ALL, - Scope.CONNECT_PROJECT_ADMIN, - ) + @Scopes(Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL) @RequirePermission(Permission.VIEW_PROJECT) @ApiOperation({ summary: 'Get project by id', diff --git a/src/app.module.ts b/src/app.module.ts index 2f208b0..19997f3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,22 @@ import { TokenRolesGuard } from './shared/guards/tokenRoles.guard'; import { ProjectContextInterceptor } from './shared/interceptors/projectContext.interceptor'; import { GlobalProvidersModule } from './shared/modules/global/globalProviders.module'; +/** + * Root application module for the Topcoder Project API v6. + * + * Responsibilities: + * - Imports GlobalProvidersModule to register all shared, globally-scoped + * providers (Prisma, JWT, M2M, Logger, EventBus, shared services). + * - Imports ApiModule which aggregates every feature module (Project, + * ProjectMember, ProjectInvite, ProjectPhase, PhaseProduct, + * ProjectAttachment, ProjectSetting, Copilot, Metadata, HealthCheck). + * - Registers TokenRolesGuard as the application-wide AUTH guard via + * APP_GUARD, so every route is protected unless decorated with @Public(). + * - Registers ProjectContextInterceptor as the application-wide interceptor + * via APP_INTERCEPTOR to enrich request context with resolved project data. + * + * Usage: passed directly to NestFactory.create() in main.ts. + */ @Module({ imports: [GlobalProvidersModule, ApiModule], providers: [ diff --git a/src/main.ts b/src/main.ts index 4f924d6..f0aaee0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,23 @@ +/** + * NestJS application bootstrap entry point for the Topcoder Project API v6. + * + * Responsibilities: + * - Configures CORS. + * - Registers global middleware (BigInt serialiser and HTTP request/response logger). + * - Applies a global ValidationPipe. + * - Builds and serves Swagger documentation. + * - Registers process-level error handlers. + * + * Environment variables consumed: + * - API_PREFIX + * - PORT + * - CORS_ALLOWED_ORIGIN + * - HEALTH_CHECK_TIMEOUT + * + * Lifecycle: + * - Called once by Node.js at startup. + * - Loads all other modules through AppModule. + */ import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; @@ -14,6 +34,14 @@ import { AppModule } from './app.module'; import { enrichSwaggerAuthDocumentation } from './shared/utils/swagger.utils'; import { LoggerService } from './shared/modules/global/logger.service'; +// TODO (quality): Move serializeBigInt to src/shared/utils/serialization.utils.ts +/** + * Recursively serializes BigInt values so JSON responses can be emitted safely. + * + * @param value - Any value that may contain BigInt scalars at any depth. + * @returns The same structure with every BigInt replaced by its decimal string representation. + * @remarks Recursively handles plain objects and arrays. Needed because JSON.stringify throws on BigInt. + */ function serializeBigInt(value: unknown): unknown { if (typeof value === 'bigint') { return value.toString(); @@ -35,6 +63,12 @@ function serializeBigInt(value: unknown): unknown { return value; } +/** + * Bootstraps the Topcoder Project API v6 HTTP server. + * + * @returns Promise - resolves when the HTTP server is listening. + * @throws Will log and surface any NestJS factory or listen errors via the unhandledRejection handler. + */ async function bootstrap() { const app = await NestFactory.create(AppModule, { rawBody: true, @@ -47,6 +81,7 @@ async function bootstrap() { app.setGlobalPrefix(apiPrefix); + // CORS origin logic: static allow-list plus Topcoder domain patterns. const topcoderOriginPatterns = [ /^https?:\/\/([\w-]+\.)*topcoder\.com(?::\d+)?$/i, /^https?:\/\/([\w-]+\.)*topcoder-dev\.com(?::\d+)?$/i, @@ -59,6 +94,7 @@ async function bootstrap() { if (process.env.CORS_ALLOWED_ORIGIN) { try { + // TODO (security): Compiling an untrusted env-var string directly into a RegExp is a ReDoS risk. Validate or escape the value before use, or restrict it to a plain string comparison. allowList.push(new RegExp(process.env.CORS_ALLOWED_ORIGIN)); } catch (error) { const errorMessage = @@ -93,6 +129,7 @@ async function bootstrap() { if (!requestOrigin) { // Keep a permissive fallback for non-browser requests so cached variants // do not drop CORS headers for subsequent browser calls. + // TODO (security): Returning '*' for requests with no Origin header allows any cross-origin server-side client to receive CORS headers. Consider returning false or omitting the header for server-to-server calls. callback(null, '*'); return; } @@ -108,6 +145,7 @@ async function bootstrap() { app.use(cors(corsConfig)); + // BigInt serialiser middleware. app.use((_req: Request, res: Response, next: NextFunction) => { const originalJson = res.json.bind(res); res.json = ((body?: any): Response => { @@ -117,7 +155,9 @@ async function bootstrap() { next(); }); + // HTTP request/response logger middleware. app.use((req: Request, res: Response, next: NextFunction) => { + // TODO (quality): A new LoggerService instance is created on every HTTP request. Hoist the logger to module scope or inject it once at bootstrap time. const requestLogger = LoggerService.forRoot('HttpRequest'); const startedAt = Date.now(); @@ -157,9 +197,11 @@ async function bootstrap() { next(); }); + // Body-parser limits. app.useBodyParser('json', { limit: '15mb' }); app.useBodyParser('urlencoded', { limit: '15mb', extended: true }); + // Global ValidationPipe. app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -206,7 +248,9 @@ curl --request POST \\ }) .build(); + // Swagger setup. const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig, { + // TODO (quality): WorkStreamModule is included in the Swagger document but is not imported in ApiModule. Either add WorkStreamModule to ApiModule's imports array, or remove it from the Swagger include list to avoid documentation drift. include: [ApiModule, WorkStreamModule], deepScanRoutes: true, extraModels: [...EVENT_SWAGGER_MODELS], @@ -220,9 +264,13 @@ curl --request POST \\ enrichSwaggerAuthDocumentation(swaggerDocument); + // TODO (security): Swagger UI is publicly accessible with no authentication. In production, restrict access by IP, add HTTP Basic auth, or disable entirely via an env flag. SwaggerModule.setup(`/${apiPrefix}/projects/api-docs`, app, swaggerDocument); + // TODO (security): Swagger UI is publicly accessible with no authentication. In production, restrict access by IP, add HTTP Basic auth, or disable entirely via an env flag. + // TODO (quality): Duplicate Swagger mount. Consolidate to a single canonical path (e.g. /${apiPrefix}/projects/api-docs) and remove the second mount. SwaggerModule.setup(`/${apiPrefix}/projects-api-docs`, app, swaggerDocument); + // Process error handlers. process.on('unhandledRejection', (reason, promise) => { logger.error( { diff --git a/src/shared/config/app.config.ts b/src/shared/config/app.config.ts index a3744b3..3578c9f 100644 --- a/src/shared/config/app.config.ts +++ b/src/shared/config/app.config.ts @@ -1,3 +1,13 @@ +/** + * Application-level configuration loaded from environment variables at module + * initialization time. + */ +/** + * Parses a boolean-like environment value. + * + * Accepts `true`/`false` (case-insensitive) and falls back for undefined or + * unrecognized values. + */ function parseBooleanEnv( value: string | undefined, fallback: boolean, @@ -19,6 +29,11 @@ function parseBooleanEnv( return fallback; } +/** + * Parses a numeric environment value. + * + * Returns the fallback for undefined inputs or `NaN` parsing results. + */ function parseNumberEnv(value: string | undefined, fallback: number): number { if (typeof value === 'undefined') { return fallback; @@ -32,15 +47,42 @@ function parseNumberEnv(value: string | undefined, fallback: number): number { return parsed; } +/** + * Runtime app configuration. + */ export const APP_CONFIG = { + /** + * S3 bucket used for project attachment storage. + * Env: `ATTACHMENTS_S3_BUCKET`, default: `topcoder-prod-media`. + */ attachmentsS3Bucket: process.env.ATTACHMENTS_S3_BUCKET || 'topcoder-prod-media', + /** + * S3 key prefix used for project attachment objects. + * Env: `PROJECT_ATTACHMENT_PATH_PREFIX`, default: `projects`. + */ projectAttachmentPathPrefix: process.env.PROJECT_ATTACHMENT_PATH_PREFIX || 'projects', + /** + * Pre-signed URL expiration in seconds. + * Env: `PRESIGNED_URL_EXPIRATION`, default: `3600`. + */ presignedUrlExpiration: parseNumberEnv( process.env.PRESIGNED_URL_EXPIRATION, 3600, ), + /** + * Maximum number of phase products per phase. + * Env: `MAX_PHASE_PRODUCT_COUNT`, default: `20`. + */ maxPhaseProductCount: parseNumberEnv(process.env.MAX_PHASE_PRODUCT_COUNT, 20), + /** + * Feature flag controlling file upload endpoints. + * Env: `ENABLE_FILE_UPLOAD`, default: `true`. + * + * @security Required env vars are not centrally validated on startup. + * Add startup validation (for example in `main.ts`) to fail fast when + * `ATTACHMENTS_S3_BUCKET` is missing in production. + */ enableFileUpload: parseBooleanEnv(process.env.ENABLE_FILE_UPLOAD, true), } as const; diff --git a/src/shared/config/kafka.config.ts b/src/shared/config/kafka.config.ts index 2302ecb..c5a45db 100644 --- a/src/shared/config/kafka.config.ts +++ b/src/shared/config/kafka.config.ts @@ -1,11 +1,39 @@ +/** + * Kafka topic registry. + * + * Topic names can be overridden through environment variables. + */ export const KAFKA_TOPIC = { + /** + * Project created topic. + * Env: `KAFKA_PROJECT_CREATED_TOPIC`, default: `project.created`. + */ PROJECT_CREATED: process.env.KAFKA_PROJECT_CREATED_TOPIC || 'project.created', + /** + * Project updated topic. + * Env: `KAFKA_PROJECT_UPDATED_TOPIC`, default: `project.updated`. + */ PROJECT_UPDATED: process.env.KAFKA_PROJECT_UPDATED_TOPIC || 'project.updated', + /** + * Project deleted topic. + * Env: `KAFKA_PROJECT_DELETED_TOPIC`, default: `project.deleted`. + */ PROJECT_DELETED: process.env.KAFKA_PROJECT_DELETED_TOPIC || 'project.deleted', + /** + * Project member added topic. + * Env: `KAFKA_PROJECT_MEMBER_ADDED_TOPIC`, default: `project.member.added`. + */ PROJECT_MEMBER_ADDED: process.env.KAFKA_PROJECT_MEMBER_ADDED_TOPIC || 'project.member.added', + /** + * Project member removed topic. + * Env: `KAFKA_PROJECT_MEMBER_REMOVED_TOPIC`, default: `project.member.removed`. + */ PROJECT_MEMBER_REMOVED: process.env.KAFKA_PROJECT_MEMBER_REMOVED_TOPIC || 'project.member.removed', } as const; +/** + * Union type of all topic names in `KAFKA_TOPIC`. + */ export type KafkaTopic = (typeof KAFKA_TOPIC)[keyof typeof KAFKA_TOPIC]; diff --git a/src/shared/constants/event.constants.ts b/src/shared/constants/event.constants.ts index 961ecdc..6db93ec 100644 --- a/src/shared/constants/event.constants.ts +++ b/src/shared/constants/event.constants.ts @@ -1,18 +1,37 @@ +/** + * Kafka event resource-type registry used in event payload envelopes. + */ export const PROJECT_METADATA_RESOURCE = { + /** Project template metadata updates from project-template module. */ PROJECT_TEMPLATE: 'project.template', + /** Product template metadata updates from product-template module. */ PRODUCT_TEMPLATE: 'product.template', + /** Project type metadata updates from project-type module. */ PROJECT_TYPE: 'project.type', + /** Product category metadata updates from product-category module. */ PRODUCT_CATEGORY: 'product.category', + /** Organization config metadata updates from org-config module. */ ORG_CONFIG: 'project.orgConfig', + /** Form-version metadata updates from project form module. */ FORM_VERSION: 'project.form.version', + /** Form-revision metadata updates from project form module. */ FORM_REVISION: 'project.form.revision', + /** Plan-config version metadata updates from planning config module. */ PLAN_CONFIG_VERSION: 'project.planConfig.version', + /** Plan-config revision metadata updates from planning config module. */ PLAN_CONFIG_REVISION: 'project.planConfig.revision', + /** Price-config version metadata updates from pricing config module. */ PRICE_CONFIG_VERSION: 'project.priceConfig.version', + /** Price-config revision metadata updates from pricing config module. */ PRICE_CONFIG_REVISION: 'project.priceConfig.revision', + /** Milestone template metadata updates from milestone-template module. */ MILESTONE_TEMPLATE: 'milestone.template', + /** Work-management permission metadata updates from work settings module. */ WORK_MANAGEMENT_PERMISSION: 'project.workManagementPermission', } as const; +/** + * Union type of all `PROJECT_METADATA_RESOURCE` values. + */ export type ProjectMetadataResource = (typeof PROJECT_METADATA_RESOURCE)[keyof typeof PROJECT_METADATA_RESOURCE]; diff --git a/src/shared/constants/permissions.constants.ts b/src/shared/constants/permissions.constants.ts index 5edfba8..23bd04d 100644 --- a/src/shared/constants/permissions.constants.ts +++ b/src/shared/constants/permissions.constants.ts @@ -2,6 +2,12 @@ * User permission policies. * Can be used with `hasPermission` method. * + * This registry defines named authorization policies using allow/deny rule + * semantics consumed by `PermissionService`. + * + * `ALL = true` is a sentinel that means any authenticated user (for + * `topcoderRoles`) or any project member (for `projectRoles`) is allowed. + * * PERMISSION GUIDELINES * * All the permission name and meaning should define **WHAT** can be done having such permission @@ -132,23 +138,35 @@ const SCOPES_PROJECT_MEMBERS_WRITE = [ M2M_SCOPES.PROJECT_MEMBERS.WRITE, ]; +/** + * M2M scopes that permit reading project invites. + */ const SCOPES_PROJECT_INVITES_READ = [ M2M_SCOPES.CONNECT_PROJECT_ADMIN, M2M_SCOPES.PROJECT_INVITES.ALL, M2M_SCOPES.PROJECT_INVITES.READ, ]; +/** + * M2M scopes that permit creating/updating/deleting project invites. + */ const SCOPES_PROJECT_INVITES_WRITE = [ M2M_SCOPES.CONNECT_PROJECT_ADMIN, M2M_SCOPES.PROJECT_INVITES.ALL, M2M_SCOPES.PROJECT_INVITES.WRITE, ]; +/** + * M2M scopes that permit creating or updating customer payment records. + */ const SCOPES_CUSTOMER_PAYMENT_WRITE = [ M2M_SCOPES.CUSTOMER_PAYMENT.ALL, M2M_SCOPES.CUSTOMER_PAYMENT.WRITE, ]; +/** + * M2M scopes that permit reading customer payment records. + */ const SCOPES_CUSTOMER_PAYMENT_READ = [ M2M_SCOPES.CUSTOMER_PAYMENT.ALL, M2M_SCOPES.CUSTOMER_PAYMENT.READ, @@ -158,9 +176,13 @@ const SCOPES_CUSTOMER_PAYMENT_READ = [ * The full list of possible permission rules in Project Service */ export const PERMISSION = { + // TODO: duplicates role-policy intent from PROJECT_TO_TOPCODER_ROLES_MATRIX in src/shared/utils/member.utils.ts; consolidate into a single source of truth. /* * Project */ + /** + * @description Permission policy: CREATE_PROJECT. + */ CREATE_PROJECT: { meta: { title: 'Create Project', @@ -170,6 +192,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: CREATE_PROJECT_AS_MANAGER. + */ CREATE_PROJECT_AS_MANAGER: { meta: { title: 'Create Project as a "manager"', @@ -182,6 +207,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: READ_PROJECT. + */ READ_PROJECT: { meta: { title: 'Read Project', @@ -201,6 +229,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_READ, }, + /** + * @description Permission policy: READ_PROJECT_ANY. + */ READ_PROJECT_ANY: { meta: { title: 'Read Any Project', @@ -219,6 +250,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_READ, }, + /** + * @description Permission policy: UPDATE_PROJECT. + */ UPDATE_PROJECT: { meta: { title: 'Update Project', @@ -235,6 +269,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: UPDATE_PROJECT_STATUS. + */ UPDATE_PROJECT_STATUS: { meta: { title: 'Update Project Status', @@ -245,6 +282,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: MANAGE_PROJECT_DIRECT_PROJECT_ID. + */ MANAGE_PROJECT_DIRECT_PROJECT_ID: { meta: { title: 'Manage Project property "directProjectId"', @@ -255,6 +295,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: MANAGE_COPILOT_REQUEST. + */ MANAGE_COPILOT_REQUEST: { meta: { title: 'Manage Copilot Request', @@ -265,6 +308,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: APPLY_COPILOT_OPPORTUNITY. + */ APPLY_COPILOT_OPPORTUNITY: { meta: { title: 'Apply copilot opportunity', @@ -274,6 +320,9 @@ export const PERMISSION = { topcoderRoles: [USER_ROLE.TC_COPILOT], scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: ASSIGN_COPILOT_OPPORTUNITY. + */ ASSIGN_COPILOT_OPPORTUNITY: { meta: { title: 'Assign copilot to opportunity', @@ -284,6 +333,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: CANCEL_COPILOT_OPPORTUNITY. + */ CANCEL_COPILOT_OPPORTUNITY: { meta: { title: 'Cancel copilot opportunity', @@ -294,6 +346,12 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: LIST_COPILOT_OPPORTUNITY. + * @todo `projectRoles` currently uses `USER_ROLE.PROJECT_MANAGER`, which is a + * Topcoder role value and will never match a project member role. Replace + * with `PROJECT_MEMBER_ROLE.PROJECT_MANAGER` or move it to `topcoderRoles`. + */ LIST_COPILOT_OPPORTUNITY: { meta: { title: 'Apply copilot opportunity', @@ -305,6 +363,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: MANAGE_PROJECT_BILLING_ACCOUNT_ID. + */ MANAGE_PROJECT_BILLING_ACCOUNT_ID: { meta: { title: 'Manage Project property "billingAccountId"', @@ -315,6 +376,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE_PROJECTS_BILLING_ACCOUNTS, }, + /** + * @description Permission policy: DELETE_PROJECT. + */ DELETE_PROJECT: { meta: { title: 'Delete Project', @@ -336,6 +400,9 @@ export const PERMISSION = { /* * Project Invite */ + /** + * @description Permission policy: READ_AVL_PROJECT_BILLING_ACCOUNTS. + */ READ_AVL_PROJECT_BILLING_ACCOUNTS: { meta: { title: 'Read Available Project Billing Accounts', @@ -358,6 +425,9 @@ export const PERMISSION = { /* * Project Invite */ + /** + * @description Permission policy: READ_PROJECT_BILLING_ACCOUNT_DETAILS. + */ READ_PROJECT_BILLING_ACCOUNT_DETAILS: { meta: { title: 'Read details of billing accounts - only allowed to m2m calls', @@ -380,6 +450,9 @@ export const PERMISSION = { /* * Project Member */ + /** + * @description Permission policy: READ_PROJECT_MEMBER. + */ READ_PROJECT_MEMBER: { meta: { title: 'Read Project Member', @@ -390,6 +463,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_MEMBERS_READ, }, + /** + * @description Permission policy: READ_PROJECT_MEMBER_DETAILS. + */ READ_PROJECT_MEMBER_DETAILS: { meta: { title: 'Read Project Member Details', @@ -401,6 +477,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_MEMBERS_READ, }, + /** + * @description Permission policy: CREATE_PROJECT_MEMBER_OWN. + */ CREATE_PROJECT_MEMBER_OWN: { meta: { title: 'Create Project Member (own)', @@ -414,6 +493,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_MEMBERS_WRITE, }, + /** + * @description Permission policy: CREATE_PROJECT_MEMBER_NOT_OWN. + */ CREATE_PROJECT_MEMBER_NOT_OWN: { meta: { title: 'Create Project Member (not own)', @@ -424,6 +506,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_MEMBERS_WRITE, }, + /** + * @description Permission policy: UPDATE_PROJECT_MEMBER_CUSTOMER. + */ UPDATE_PROJECT_MEMBER_CUSTOMER: { meta: { title: 'Update Project Member (customer)', @@ -435,6 +520,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_MEMBERS_WRITE, }, + /** + * @description Permission policy: UPDATE_PROJECT_MEMBER_NON_CUSTOMER. + */ UPDATE_PROJECT_MEMBER_NON_CUSTOMER: { meta: { title: 'Update Project Member (non-customer)', @@ -446,6 +534,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_MEMBERS_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_MEMBER_CUSTOMER. + */ DELETE_PROJECT_MEMBER_CUSTOMER: { meta: { title: 'Delete Project Member (customer)', @@ -457,6 +548,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_MEMBERS_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_MEMBER_TOPCODER. + */ DELETE_PROJECT_MEMBER_TOPCODER: { meta: { title: 'Delete Project Member (topcoder)', @@ -469,6 +563,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_MEMBERS_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_MEMBER_COPILOT. + */ DELETE_PROJECT_MEMBER_COPILOT: { meta: { title: 'Delete Project Member (copilot)', @@ -487,6 +584,9 @@ export const PERMISSION = { /* * Project Invite */ + /** + * @description Permission policy: READ_PROJECT_INVITE_OWN. + */ READ_PROJECT_INVITE_OWN: { meta: { title: 'Read Project Invite (own)', @@ -497,6 +597,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_READ, }, + /** + * @description Permission policy: READ_PROJECT_INVITE_NOT_OWN. + */ READ_PROJECT_INVITE_NOT_OWN: { meta: { title: 'Read Project Invite (not own)', @@ -508,6 +611,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_READ, }, + /** + * @description Permission policy: CREATE_PROJECT_INVITE_CUSTOMER. + */ CREATE_PROJECT_INVITE_CUSTOMER: { meta: { title: 'Create Project Invite (customer)', @@ -519,6 +625,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_WRITE, }, + /** + * @description Permission policy: CREATE_PROJECT_INVITE_TOPCODER. + */ CREATE_PROJECT_INVITE_TOPCODER: { meta: { title: 'Create Project Invite (topcoder)', @@ -531,6 +640,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_WRITE, }, + /** + * @description Permission policy: CREATE_PROJECT_INVITE_COPILOT. + */ CREATE_PROJECT_INVITE_COPILOT: { meta: { title: 'Create Project Invite (copilot)', @@ -542,6 +654,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_WRITE, }, + /** + * @description Permission policy: UPDATE_PROJECT_INVITE_OWN. + */ UPDATE_PROJECT_INVITE_OWN: { meta: { title: 'Update Project Invite (own)', @@ -552,6 +667,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_WRITE, }, + /** + * @description Permission policy: UPDATE_PROJECT_INVITE_NOT_OWN. + */ UPDATE_PROJECT_INVITE_NOT_OWN: { meta: { title: 'Update Project Invite (not own)', @@ -562,6 +680,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_WRITE, }, + /** + * @description Permission policy: UPDATE_PROJECT_INVITE_REQUESTED. + */ UPDATE_PROJECT_INVITE_REQUESTED: { meta: { title: 'Update Project Invite (requested)', @@ -572,6 +693,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_INVITE_OWN. + */ DELETE_PROJECT_INVITE_OWN: { meta: { title: 'Delete Project Member (own)', @@ -582,6 +706,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER. + */ DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER: { meta: { title: 'Delete Project Invite (not own, customer)', @@ -594,6 +721,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_INVITE_NOT_OWN_TOPCODER. + */ DELETE_PROJECT_INVITE_NOT_OWN_TOPCODER: { meta: { title: 'Delete Project Invite (not own, topcoder)', @@ -606,6 +736,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_INVITE_NOT_OWN_COPILOT. + */ DELETE_PROJECT_INVITE_NOT_OWN_COPILOT: { meta: { title: 'Delete Project Invite (not own, copilot)', @@ -618,6 +751,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECT_INVITES_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_INVITE_REQUESTED. + */ DELETE_PROJECT_INVITE_REQUESTED: { meta: { title: 'Delete Project Invite (requested)', @@ -635,6 +771,9 @@ export const PERMISSION = { /* * Project Attachments */ + /** + * @description Permission policy: CREATE_PROJECT_ATTACHMENT. + */ CREATE_PROJECT_ATTACHMENT: { meta: { title: 'Create Project Attachment', @@ -649,6 +788,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: VIEW_PROJECT_ATTACHMENT. + */ VIEW_PROJECT_ATTACHMENT: { meta: { title: 'View Project Attachment', @@ -659,6 +801,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_READ, }, + /** + * @description Permission policy: READ_PROJECT_ATTACHMENT_OWN_OR_ALLOWED. + */ READ_PROJECT_ATTACHMENT_OWN_OR_ALLOWED: { meta: { title: 'Read Project Attachment (own or allowed)', @@ -671,6 +816,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_READ, }, + /** + * @description Permission policy: READ_PROJECT_ATTACHMENT_NOT_OWN_AND_NOT_ALLOWED. + */ READ_PROJECT_ATTACHMENT_NOT_OWN_AND_NOT_ALLOWED: { meta: { title: 'Read Project Attachment (not own and not allowed)', @@ -682,6 +830,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_READ, }, + /** + * @description Permission policy: UPDATE_PROJECT_ATTACHMENT_OWN. + */ UPDATE_PROJECT_ATTACHMENT_OWN: { meta: { title: 'Update Project Attachment (own)', @@ -697,6 +848,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: UPDATE_PROJECT_ATTACHMENT_NOT_OWN. + */ UPDATE_PROJECT_ATTACHMENT_NOT_OWN: { meta: { title: 'Update Project Attachment (not own)', @@ -707,6 +861,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: EDIT_PROJECT_ATTACHMENT. + */ EDIT_PROJECT_ATTACHMENT: { meta: { title: 'Edit Project Attachment', @@ -722,6 +879,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_ATTACHMENT_OWN. + */ DELETE_PROJECT_ATTACHMENT_OWN: { meta: { title: 'Delete Project Attachment (own)', @@ -737,6 +897,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_ATTACHMENT_NOT_OWN. + */ DELETE_PROJECT_ATTACHMENT_NOT_OWN: { meta: { title: 'Delete Project Attachment (not own)', @@ -747,6 +910,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_ATTACHMENT. + */ DELETE_PROJECT_ATTACHMENT: { meta: { title: 'Delete Project Attachment', @@ -762,6 +928,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: ADD_PROJECT_PHASE. + */ ADD_PROJECT_PHASE: { meta: { title: 'Add Project Phase', @@ -772,6 +941,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: UPDATE_PROJECT_PHASE. + */ UPDATE_PROJECT_PHASE: { meta: { title: 'Update Project Phase', @@ -782,6 +954,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_PHASE. + */ DELETE_PROJECT_PHASE: { meta: { title: 'Delete Project Phase', @@ -792,6 +967,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: ADD_PHASE_PRODUCT. + */ ADD_PHASE_PRODUCT: { meta: { title: 'Add Phase Product', @@ -802,6 +980,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: UPDATE_PHASE_PRODUCT. + */ UPDATE_PHASE_PRODUCT: { meta: { title: 'Update Phase Product', @@ -812,6 +993,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: DELETE_PHASE_PRODUCT. + */ DELETE_PHASE_PRODUCT: { meta: { title: 'Delete Phase Product', @@ -825,6 +1009,9 @@ export const PERMISSION = { /* * Project Phase Approval */ + /** + * @description Permission policy: CREATE_PROJECT_PHASE_APPROVE. + */ CREATE_PROJECT_PHASE_APPROVE: { meta: { title: 'Create Project Phase Approval', @@ -839,6 +1026,13 @@ export const PERMISSION = { * * Permissions defined by logic: **WHO** can do actions with such a permission. */ + /** + * @description Permission policy: ROLES_COPILOT_AND_ABOVE. + * @deprecated Deprecated role-bucket policy. Migrate + * `CopilotAndAboveGuard` callers to explicit named permissions via + * `@RequirePermission()`. + * @todo Remove this rule after all callers migrate to explicit permissions. + */ ROLES_COPILOT_AND_ABOVE: { meta: { group: 'Deprecated', @@ -847,6 +1041,9 @@ export const PERMISSION = { projectRoles: [...PROJECT_ROLES_MANAGEMENT, PROJECT_MEMBER_ROLE.COPILOT], }, + /** + * @description Permission policy: CREATE_CUSTOMER_PAYMENT. + */ CREATE_CUSTOMER_PAYMENT: { meta: { title: 'Create Customer Payment', @@ -857,6 +1054,9 @@ export const PERMISSION = { scopes: SCOPES_CUSTOMER_PAYMENT_WRITE, }, + /** + * @description Permission policy: VIEW_CUSTOMER_PAYMENT. + */ VIEW_CUSTOMER_PAYMENT: { meta: { title: 'View Customer Payments', @@ -867,6 +1067,9 @@ export const PERMISSION = { scopes: SCOPES_CUSTOMER_PAYMENT_READ, }, + /** + * @description Permission policy: UPDATE_CUSTOMER_PAYMENT. + */ UPDATE_CUSTOMER_PAYMENT: { meta: { title: 'Update Customer Payment', @@ -879,8 +1082,10 @@ export const PERMISSION = { }; /** - * Matrix which define Project Roles and corresponding Topcoder Roles of users - * who may join with such Project Roles. + * Matrix mapping project roles to Topcoder roles that are allowed to hold each + * project role. + * + * Used for membership role validation. */ export const PROJECT_TO_TOPCODER_ROLES_MATRIX = { [PROJECT_MEMBER_ROLE.CUSTOMER]: _.values(USER_ROLE), @@ -902,13 +1107,9 @@ export const PROJECT_TO_TOPCODER_ROLES_MATRIX = { }; /** - * This list determines default Project Role by Topcoder Role. + * Ordered list for deriving a user's default project role from Topcoder roles. * - * - The order of items in this list is IMPORTANT. - * - To determine default Project Role we have to go from TOP to END - * and find the first record which has the Topcoder Role of the user. - * - Always define default Project Role which is allowed for such Topcoder Role - * as per `PROJECT_TO_TOPCODER_ROLES_MATRIX` + * The order is significant: first match wins. */ export const DEFAULT_PROJECT_ROLE = [ { diff --git a/src/shared/constants/permissions.ts b/src/shared/constants/permissions.ts index c13941b..f623c8c 100644 --- a/src/shared/constants/permissions.ts +++ b/src/shared/constants/permissions.ts @@ -1,61 +1,127 @@ +/** + * Named permission keys used with `@RequirePermission(Permission.X)`. + * + * These enum values are lookup keys for policies defined in + * `permissions.constants.ts`. They are an alternative to inline permission + * objects passed directly to the decorator. + */ export enum Permission { + /** Read any project even when the user is not a member. */ READ_PROJECT_ANY = 'READ_PROJECT_ANY', + /** View project details. */ VIEW_PROJECT = 'VIEW_PROJECT', + /** Create a project. */ CREATE_PROJECT = 'CREATE_PROJECT', + /** Edit project details. */ EDIT_PROJECT = 'EDIT_PROJECT', + /** Delete a project. */ DELETE_PROJECT = 'DELETE_PROJECT', + /** Read project members. */ READ_PROJECT_MEMBER = 'READ_PROJECT_MEMBER', + /** Add the current user as a project member. */ CREATE_PROJECT_MEMBER_OWN = 'CREATE_PROJECT_MEMBER_OWN', + /** Add another user as a project member. */ CREATE_PROJECT_MEMBER_NOT_OWN = 'CREATE_PROJECT_MEMBER_NOT_OWN', + /** Update non-customer project members. */ UPDATE_PROJECT_MEMBER_NON_CUSTOMER = 'UPDATE_PROJECT_MEMBER_NON_CUSTOMER', + /** Remove topcoder-role project members. */ DELETE_PROJECT_MEMBER_TOPCODER = 'DELETE_PROJECT_MEMBER_TOPCODER', + /** Remove customer project members. */ DELETE_PROJECT_MEMBER_CUSTOMER = 'DELETE_PROJECT_MEMBER_CUSTOMER', + /** Remove copilot project members. */ DELETE_PROJECT_MEMBER_COPILOT = 'DELETE_PROJECT_MEMBER_COPILOT', + /** Read own project invites. */ READ_PROJECT_INVITE_OWN = 'READ_PROJECT_INVITE_OWN', + /** Read project invites that belong to other users. */ READ_PROJECT_INVITE_NOT_OWN = 'READ_PROJECT_INVITE_NOT_OWN', + /** Create customer invites. */ CREATE_PROJECT_INVITE_CUSTOMER = 'CREATE_PROJECT_INVITE_CUSTOMER', + /** Create topcoder-member invites. */ CREATE_PROJECT_INVITE_TOPCODER = 'CREATE_PROJECT_INVITE_TOPCODER', + /** Create copilot invites. */ CREATE_PROJECT_INVITE_COPILOT = 'CREATE_PROJECT_INVITE_COPILOT', + /** Update own invites. */ UPDATE_PROJECT_INVITE_OWN = 'UPDATE_PROJECT_INVITE_OWN', + /** Update requested invites. */ UPDATE_PROJECT_INVITE_REQUESTED = 'UPDATE_PROJECT_INVITE_REQUESTED', + /** Update invites for other users. */ UPDATE_PROJECT_INVITE_NOT_OWN = 'UPDATE_PROJECT_INVITE_NOT_OWN', + /** Delete own invites. */ DELETE_PROJECT_INVITE_OWN = 'DELETE_PROJECT_INVITE_OWN', + /** Delete requested invites. */ DELETE_PROJECT_INVITE_REQUESTED = 'DELETE_PROJECT_INVITE_REQUESTED', + /** Delete non-own topcoder invites. */ DELETE_PROJECT_INVITE_NOT_OWN_TOPCODER = 'DELETE_PROJECT_INVITE_NOT_OWN_TOPCODER', + /** Delete non-own customer invites. */ DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER = 'DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER', + /** Delete non-own copilot invites. */ DELETE_PROJECT_INVITE_NOT_OWN_COPILOT = 'DELETE_PROJECT_INVITE_NOT_OWN_COPILOT', + /** Manage project billingAccountId assignment. */ MANAGE_PROJECT_BILLING_ACCOUNT_ID = 'MANAGE_PROJECT_BILLING_ACCOUNT_ID', + /** Manage project directProjectId value. */ MANAGE_PROJECT_DIRECT_PROJECT_ID = 'MANAGE_PROJECT_DIRECT_PROJECT_ID', + /** Read billing accounts available for a project. */ READ_AVL_PROJECT_BILLING_ACCOUNTS = 'READ_AVL_PROJECT_BILLING_ACCOUNTS', + /** Read details for the project's billing account. */ READ_PROJECT_BILLING_ACCOUNT_DETAILS = 'READ_PROJECT_BILLING_ACCOUNT_DETAILS', + /** Manage copilot request lifecycle. */ MANAGE_COPILOT_REQUEST = 'MANAGE_COPILOT_REQUEST', + /** Apply for copilot opportunity. */ APPLY_COPILOT_OPPORTUNITY = 'APPLY_COPILOT_OPPORTUNITY', + /** Assign copilot opportunity. */ ASSIGN_COPILOT_OPPORTUNITY = 'ASSIGN_COPILOT_OPPORTUNITY', + /** Cancel copilot opportunity. */ CANCEL_COPILOT_OPPORTUNITY = 'CANCEL_COPILOT_OPPORTUNITY', + /** Create a project as manager role. */ CREATE_PROJECT_AS_MANAGER = 'CREATE_PROJECT_AS_MANAGER', + /** View project attachments. */ VIEW_PROJECT_ATTACHMENT = 'VIEW_PROJECT_ATTACHMENT', + /** Create project attachments. */ CREATE_PROJECT_ATTACHMENT = 'CREATE_PROJECT_ATTACHMENT', + /** Edit project attachments. */ EDIT_PROJECT_ATTACHMENT = 'EDIT_PROJECT_ATTACHMENT', + /** Update attachments created by other users. */ UPDATE_PROJECT_ATTACHMENT_NOT_OWN = 'UPDATE_PROJECT_ATTACHMENT_NOT_OWN', + /** Delete project attachments. */ DELETE_PROJECT_ATTACHMENT = 'DELETE_PROJECT_ATTACHMENT', + /** Add project phases. */ ADD_PROJECT_PHASE = 'ADD_PROJECT_PHASE', + /** Update project phases. */ UPDATE_PROJECT_PHASE = 'UPDATE_PROJECT_PHASE', + /** Delete project phases. */ DELETE_PROJECT_PHASE = 'DELETE_PROJECT_PHASE', + /** Add phase products. */ ADD_PHASE_PRODUCT = 'ADD_PHASE_PRODUCT', + /** Update phase products. */ UPDATE_PHASE_PRODUCT = 'UPDATE_PHASE_PRODUCT', + /** Delete phase products. */ DELETE_PHASE_PRODUCT = 'DELETE_PHASE_PRODUCT', + /** Workstream create permission. */ WORKSTREAM_CREATE = 'workStream.create', + /** Workstream view permission. */ WORKSTREAM_VIEW = 'workStream.view', + /** Workstream edit permission. */ WORKSTREAM_EDIT = 'workStream.edit', + /** Workstream delete permission. */ WORKSTREAM_DELETE = 'workStream.delete', + /** Work create permission. */ WORK_CREATE = 'work.create', + /** Work view permission. */ WORK_VIEW = 'work.view', + /** Work edit permission. */ WORK_EDIT = 'work.edit', + /** Work delete permission. */ WORK_DELETE = 'work.delete', + /** Work item create permission. */ WORKITEM_CREATE = 'workItem.create', + /** Work item view permission. */ WORKITEM_VIEW = 'workItem.view', + /** Work item edit permission. */ WORKITEM_EDIT = 'workItem.edit', + /** Work item delete permission. */ WORKITEM_DELETE = 'workItem.delete', + /** View work-management permission settings. */ WORK_MANAGEMENT_PERMISSION_VIEW = 'workManagementPermission.view', + /** Edit work-management permission settings. */ WORK_MANAGEMENT_PERMISSION_EDIT = 'workManagementPermission.edit', } diff --git a/src/shared/decorators/currentUser.decorator.ts b/src/shared/decorators/currentUser.decorator.ts index 60601b1..bc74b79 100644 --- a/src/shared/decorators/currentUser.decorator.ts +++ b/src/shared/decorators/currentUser.decorator.ts @@ -1,7 +1,16 @@ +/** + * Parameter decorator that injects the validated JWT user from the request. + */ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { JwtUser } from '../modules/global/jwt.service'; import { AuthenticatedRequest } from '../interfaces/request.interface'; +/** + * Injects `request.user` into a controller handler parameter. + * + * Returns `undefined` when auth guards did not populate the request (for + * example on `@Public()` routes). + */ export const CurrentUser = createParamDecorator( (_data: unknown, ctx: ExecutionContext): JwtUser | undefined => { const request = ctx.switchToHttp().getRequest(); diff --git a/src/shared/decorators/projectMembers.decorator.ts b/src/shared/decorators/projectMembers.decorator.ts index b095660..3c595aa 100644 --- a/src/shared/decorators/projectMembers.decorator.ts +++ b/src/shared/decorators/projectMembers.decorator.ts @@ -1,7 +1,15 @@ +/** + * Parameter decorator that injects project members from request context. + */ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { ProjectMember } from '../interfaces/permission.interface'; import { AuthenticatedRequest } from '../interfaces/request.interface'; +/** + * Injects `request.projectContext.projectMembers` into handler parameters. + * + * Returns an empty list when project context is unavailable. + */ export const ProjectMembers = createParamDecorator( (_data: unknown, ctx: ExecutionContext): ProjectMember[] => { const request = ctx.switchToHttp().getRequest(); diff --git a/src/shared/decorators/public.decorator.ts b/src/shared/decorators/public.decorator.ts index 767ac49..796c296 100644 --- a/src/shared/decorators/public.decorator.ts +++ b/src/shared/decorators/public.decorator.ts @@ -1,5 +1,16 @@ +/** + * Public-route decorator for bypassing `TokenRolesGuard`. + */ import { SetMetadata } from '@nestjs/common'; +/** + * Metadata key used to mark handlers/controllers as public. + */ export const IS_PUBLIC_KEY = 'isPublic'; +/** + * Marks a route or controller as public. + * + * `TokenRolesGuard` reads this metadata and immediately allows the request. + */ export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/shared/decorators/requirePermission.decorator.ts b/src/shared/decorators/requirePermission.decorator.ts index f35ab21..f91339b 100644 --- a/src/shared/decorators/requirePermission.decorator.ts +++ b/src/shared/decorators/requirePermission.decorator.ts @@ -1,13 +1,37 @@ +/** + * Fine-grained permission decorator used by `PermissionGuard`. + */ import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiExtension } from '@nestjs/swagger'; import { Permission as NamedPermission } from '../constants/permissions'; import { Permission } from '../interfaces/permission.interface'; +/** + * Metadata key used to store required permissions. + */ export const PERMISSION_KEY = 'required_permissions'; +/** + * Swagger extension key used to expose permission requirements. + */ export const SWAGGER_REQUIRED_PERMISSIONS_KEY = 'x-required-permissions'; +/** + * Accepted permission declaration type. + * + * Supports either: + * - a named permission enum key, or + * - an inline permission object. + */ export type RequiredPermission = Permission | NamedPermission; +/** + * Declares required permissions for a route. + * + * Nested arrays are flattened before metadata is written, then mirrored into a + * Swagger extension for OpenAPI enrichment. + * + * @param permissions Named/inline permissions or nested arrays of them. + */ export const RequirePermission = ( ...permissions: (RequiredPermission | RequiredPermission[])[] ) => { diff --git a/src/shared/decorators/scopes.decorator.ts b/src/shared/decorators/scopes.decorator.ts index 3186107..024fa0e 100644 --- a/src/shared/decorators/scopes.decorator.ts +++ b/src/shared/decorators/scopes.decorator.ts @@ -1,9 +1,25 @@ +/** + * Scope-based authorization decorator for M2M access rules. + */ import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiExtension } from '@nestjs/swagger'; +/** + * Metadata key for required token scopes. + */ export const SCOPES_KEY = 'scopes'; +/** + * Swagger extension key listing required scopes per operation. + */ export const SWAGGER_REQUIRED_SCOPES_KEY = 'x-required-scopes'; +/** + * Declares required OAuth scopes for a route. + * + * The decorator writes both runtime metadata and Swagger metadata. + * + * @param scopes Allowed scopes for this endpoint. + */ export const Scopes = (...scopes: string[]) => applyDecorators( SetMetadata(SCOPES_KEY, scopes), diff --git a/src/shared/enums/projectMemberRole.enum.ts b/src/shared/enums/projectMemberRole.enum.ts index 5b446e9..b91deaa 100644 --- a/src/shared/enums/projectMemberRole.enum.ts +++ b/src/shared/enums/projectMemberRole.enum.ts @@ -1,15 +1,48 @@ +/** + * Canonical project-member role registry. + */ export enum ProjectMemberRole { + /** + * Delivery manager role for project operations and governance. + */ MANAGER = 'manager', + /** + * Customer stakeholder role. + */ CUSTOMER = 'customer', + /** + * Copilot delivery specialist role. + */ COPILOT = 'copilot', + /** + * Read-only observer role. + */ OBSERVER = 'observer', + /** + * Account manager role. + */ ACCOUNT_MANAGER = 'account_manager', + /** + * Project manager role. + */ PROJECT_MANAGER = 'project_manager', + /** + * Program manager role. + */ PROGRAM_MANAGER = 'program_manager', + /** + * Solution architect role. + */ SOLUTION_ARCHITECT = 'solution_architect', + /** + * Account executive role. + */ ACCOUNT_EXECUTIVE = 'account_executive', } +/** + * Management-tier project member roles used by query filters and permissions. + */ export const PROJECT_MEMBER_MANAGER_ROLES: ProjectMemberRole[] = [ ProjectMemberRole.MANAGER, ProjectMemberRole.ACCOUNT_MANAGER, @@ -19,6 +52,9 @@ export const PROJECT_MEMBER_MANAGER_ROLES: ProjectMemberRole[] = [ ProjectMemberRole.SOLUTION_ARCHITECT, ]; +/** + * Project roles considered non-customer in permission checks. + */ export const PROJECT_MEMBER_NON_CUSTOMER_ROLES: ProjectMemberRole[] = [ ProjectMemberRole.MANAGER, ProjectMemberRole.COPILOT, diff --git a/src/shared/enums/scopes.enum.ts b/src/shared/enums/scopes.enum.ts index 3885880..679c006 100644 --- a/src/shared/enums/scopes.enum.ts +++ b/src/shared/enums/scopes.enum.ts @@ -1,27 +1,91 @@ +/** + * Canonical OAuth scope registry for machine-to-machine access. + * + * Hierarchy model: + * - `all:x` implies the matching `read:x` and `write:x` scopes. + * - Aliases are normalized through `SCOPE_SYNONYMS`. + */ export enum Scope { + /** + * Full Connect Project admin scope. + */ CONNECT_PROJECT_ADMIN = 'all:connect_project', + /** + * Legacy alias for full Connect Project admin scope. + * + * @security Compatibility shim that grants full admin access. Do not issue + * this alias to new M2M clients. + */ CONNECT_PROJECT_ADMIN_ALIAS = 'all:project', + /** + * Full project read/write scope. + */ PROJECTS_ALL = 'all:projects', + /** + * Read projects. + */ PROJECTS_READ = 'read:projects', + /** + * Create/update/delete projects. + */ PROJECTS_WRITE = 'write:projects', + /** + * Read billing accounts available to a user for project assignment. + */ PROJECTS_READ_USER_BILLING_ACCOUNTS = 'read:user-billing-accounts', + /** + * Write project billing account associations. + */ PROJECTS_WRITE_PROJECTS_BILLING_ACCOUNTS = 'write:projects-billing-accounts', + /** + * Read details of billing accounts already attached to a project. + */ PROJECTS_READ_PROJECT_BILLING_ACCOUNT_DETAILS = 'read:project-billing-account-details', + /** + * Full project-member read/write scope. + */ PROJECT_MEMBERS_ALL = 'all:project-members', + /** + * Read project members. + */ PROJECT_MEMBERS_READ = 'read:project-members', + /** + * Create/update/delete project members. + */ PROJECT_MEMBERS_WRITE = 'write:project-members', + /** + * Full project-invite read/write scope. + */ PROJECT_INVITES_ALL = 'all:project-invites', + /** + * Read project invites. + */ PROJECT_INVITES_READ = 'read:project-invites', + /** + * Create/update/delete project invites. + */ PROJECT_INVITES_WRITE = 'write:project-invites', + /** + * Full customer-payment read/write scope. + */ CUSTOMER_PAYMENT_ALL = 'all:customer-payments', + /** + * Read customer payments. + */ CUSTOMER_PAYMENT_READ = 'read:customer-payments', + /** + * Create/update customer payments. + */ CUSTOMER_PAYMENT_WRITE = 'write:customer-payments', } +/** + * Type-safe aliases used by services and policy constants. + */ export const M2M_SCOPES = { CONNECT_PROJECT_ADMIN: Scope.CONNECT_PROJECT_ADMIN, PROJECTS: { @@ -51,6 +115,9 @@ export const M2M_SCOPES = { }, } as const; +/** + * Scope set implied by `CONNECT_PROJECT_ADMIN`. + */ export const ALL_PROJECT_RELATED_SCOPES: Scope[] = [ Scope.PROJECTS_ALL, Scope.PROJECTS_READ, @@ -69,6 +136,11 @@ export const ALL_PROJECT_RELATED_SCOPES: Scope[] = [ Scope.CUSTOMER_PAYMENT_WRITE, ]; +/** + * Explicit implication graph for `all:x` scope expansion. + * + * Used by `M2MService.hasRequiredScopes`. + */ export const SCOPE_HIERARCHY: Record = { [Scope.PROJECTS_ALL]: [Scope.PROJECTS_READ, Scope.PROJECTS_WRITE], [Scope.PROJECT_MEMBERS_ALL]: [ @@ -86,6 +158,9 @@ export const SCOPE_HIERARCHY: Record = { [Scope.CONNECT_PROJECT_ADMIN]: ALL_PROJECT_RELATED_SCOPES, }; +/** + * Legacy alias mappings expanded to canonical scope values. + */ export const SCOPE_SYNONYMS: Record = { [Scope.CONNECT_PROJECT_ADMIN_ALIAS]: [Scope.CONNECT_PROJECT_ADMIN], }; diff --git a/src/shared/enums/userRole.enum.ts b/src/shared/enums/userRole.enum.ts index aab12f2..1d9b011 100644 --- a/src/shared/enums/userRole.enum.ts +++ b/src/shared/enums/userRole.enum.ts @@ -1,31 +1,101 @@ +/** + * Canonical Topcoder platform role values from JWT `roles` claim. + */ export enum UserRole { + /** + * Topcoder platform administrator. + */ TOPCODER_ADMIN = 'administrator', + /** + * Connect manager role. + */ MANAGER = 'Connect Manager', + /** + * Connect account manager role. + */ TOPCODER_ACCOUNT_MANAGER = 'Connect Account Manager', + /** + * Connect copilot role. + */ COPILOT = 'Connect Copilot', + /** + * Connect admin role. + */ CONNECT_ADMIN = 'Connect Admin', + /** + * Connect copilot manager role. + */ COPILOT_MANAGER = 'Connect Copilot Manager', + /** + * Business development representative role. + */ BUSINESS_DEVELOPMENT_REPRESENTATIVE = 'Business Development Representative', + /** + * Presales role. + */ PRESALES = 'Presales', + /** + * Account executive role. + */ ACCOUNT_EXECUTIVE = 'Account Executive', + /** + * Program manager role. + */ PROGRAM_MANAGER = 'Program Manager', + /** + * Solution architect role. + */ SOLUTION_ARCHITECT = 'Solution Architect', + /** + * Project manager role. + */ PROJECT_MANAGER = 'Project Manager', + /** + * Task manager role. + */ TASK_MANAGER = 'Task Manager', + /** + * Topcoder task manager role. + */ TOPCODER_TASK_MANAGER = 'Topcoder Task Manager', + /** + * Talent manager role. + */ TALENT_MANAGER = 'Talent Manager', + /** + * Topcoder talent manager role. + */ TOPCODER_TALENT_MANAGER = 'Topcoder Talent Manager', + /** + * Generic Topcoder authenticated user role. + */ TOPCODER_USER = 'Topcoder User', + /** + * Legacy tgadmin role. + */ TG_ADMIN = 'tgadmin', + /** + * Legacy lowercase copilot role. + */ TC_COPILOT = 'copilot', } +/** + * Roles treated as platform admins. + */ export const ADMIN_ROLES: UserRole[] = [ UserRole.CONNECT_ADMIN, UserRole.TOPCODER_ADMIN, UserRole.TG_ADMIN, ]; +/** + * Roles treated as manager-tier access (admins included). + * + * @todo `MANAGER_ROLES` overlaps with `MANAGER_TOPCODER_ROLES` in + * `src/shared/utils/member.utils.ts`. Remove the local copy and import this + * list directly. + */ export const MANAGER_ROLES: UserRole[] = [ ...ADMIN_ROLES, UserRole.MANAGER, diff --git a/src/shared/guards/adminOnly.guard.ts b/src/shared/guards/adminOnly.guard.ts index d41bb2f..728ad21 100644 --- a/src/shared/guards/adminOnly.guard.ts +++ b/src/shared/guards/adminOnly.guard.ts @@ -1,3 +1,10 @@ +/** + * Secondary guard that enforces admin-level access. + * + * Route-level entry points are: + * - `@AdminOnly()` for strict admin checks. + * - `@ManagerOnly()` as a role-based shorthand without this guard. + */ import { applyDecorators, CanActivate, @@ -15,17 +22,46 @@ import { M2MService } from '../modules/global/m2m.service'; import { PermissionService } from '../services/permission.service'; import { Roles } from './tokenRoles.guard'; +/** + * Swagger extension key indicating an admin-only operation. + */ export const SWAGGER_ADMIN_ONLY_KEY = 'x-admin-only'; +/** + * Swagger extension key listing admin roles accepted by the guard. + */ export const SWAGGER_ADMIN_ALLOWED_ROLES_KEY = 'x-admin-only-roles'; +/** + * Swagger extension key listing M2M scopes accepted by the guard. + */ export const SWAGGER_ADMIN_ALLOWED_SCOPES_KEY = 'x-admin-only-scopes'; +/** + * Guard that permits only admin roles or admin-equivalent M2M scope. + */ @Injectable() export class AdminOnlyGuard implements CanActivate { + /** + * @param permissionService Permission helper for role intersections. + * @param m2mService M2M helper for scope hierarchy checks. + */ constructor( private readonly permissionService: PermissionService, private readonly m2mService: M2MService, ) {} + /** + * Enforces admin-only access after `TokenRolesGuard` has populated user data. + * + * Behavior: + * - Throws `UnauthorizedException` if `request.user` is missing. + * - Grants access for any role in `ADMIN_ROLES`. + * - Grants access for scope `CONNECT_PROJECT_ADMIN`. + * - Throws `ForbiddenException('Admin access is required.')` otherwise. + * + * @security M2M callers that already passed `TokenRolesGuard` with non-admin + * scopes can reach this guard; `CONNECT_PROJECT_ADMIN` is the only M2M + * admission criterion here. + */ canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const user = request.user; @@ -55,6 +91,9 @@ export class AdminOnlyGuard implements CanActivate { } } +/** + * Composite decorator that applies `AdminOnlyGuard` and Swagger auth metadata. + */ export const AdminOnly = () => applyDecorators( UseGuards(AdminOnlyGuard), @@ -65,4 +104,10 @@ export const AdminOnly = () => ]), ); +/** + * Role-only shorthand for manager-tier routes. + * + * This decorator only applies `Roles(...MANAGER_ROLES)` and does not attach + * `AdminOnlyGuard`. + */ export const ManagerOnly = () => applyDecorators(Roles(...MANAGER_ROLES)); diff --git a/src/shared/guards/copilotAndAbove.guard.ts b/src/shared/guards/copilotAndAbove.guard.ts index 547c040..f3824df 100644 --- a/src/shared/guards/copilotAndAbove.guard.ts +++ b/src/shared/guards/copilotAndAbove.guard.ts @@ -1,3 +1,9 @@ +/** + * Convenience guard for "copilot and above" authorization. + * + * This guard currently uses the deprecated + * `PERMISSION.ROLES_COPILOT_AND_ABOVE` policy. + */ import { applyDecorators, CanActivate, @@ -13,13 +19,33 @@ import { AuthenticatedRequest } from '../interfaces/request.interface'; import { PrismaService } from '../modules/global/prisma.service'; import { PermissionService } from '../services/permission.service'; +/** + * Guard enforcing the legacy copilot-and-above permission tier. + */ @Injectable() export class CopilotAndAboveGuard implements CanActivate { + /** + * @param permissionService Permission evaluator. + * @param prisma Prisma client used for member resolution. + */ constructor( private readonly permissionService: PermissionService, private readonly prisma: PrismaService, ) {} + /** + * Enforces `PERMISSION.ROLES_COPILOT_AND_ABOVE` for the request user. + * + * Behavior: + * - Throws `UnauthorizedException` when `request.user` is missing. + * - Loads project members via `resolveProjectMembers`. + * - Calls `permissionService.hasPermission(...)`. + * - Throws `ForbiddenException` when permission check fails. + * + * @deprecated `PERMISSION.ROLES_COPILOT_AND_ABOVE` is deprecated in + * `permissions.constants.ts`. Migrate callers to explicit + * `@RequirePermission()` usage with a clear permission key. + */ async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const user = request.user; @@ -45,6 +71,13 @@ export class CopilotAndAboveGuard implements CanActivate { return true; } + /** + * Resolves project members from request cache or database. + * + * @todo The Prisma query + role mapping pattern is duplicated across this + * guard, `ProjectMemberGuard`, `PermissionGuard`, and + * `ProjectContextInterceptor`. Extract a shared resolver service. + */ private async resolveProjectMembers( request: AuthenticatedRequest, ): Promise { @@ -92,5 +125,8 @@ export class CopilotAndAboveGuard implements CanActivate { } } +/** + * Composite decorator that applies `CopilotAndAboveGuard`. + */ export const CopilotAndAbove = () => applyDecorators(UseGuards(CopilotAndAboveGuard)); diff --git a/src/shared/guards/permission.guard.ts b/src/shared/guards/permission.guard.ts index 6cb213e..c4d22a5 100644 --- a/src/shared/guards/permission.guard.ts +++ b/src/shared/guards/permission.guard.ts @@ -1,3 +1,8 @@ +/** + * Fine-grained authorization guard driven by `@RequirePermission()` metadata. + * + * This guard evaluates named or inline permissions using `PermissionService`. + */ import { CanActivate, ExecutionContext, @@ -18,14 +23,34 @@ import { AuthenticatedRequest } from '../interfaces/request.interface'; import { PrismaService } from '../modules/global/prisma.service'; import { PermissionService } from '../services/permission.service'; +/** + * Policy guard that evaluates route-level permission requirements. + */ @Injectable() export class PermissionGuard implements CanActivate { + /** + * @param reflector Metadata reader for `@RequirePermission()`. + * @param permissionService Permission evaluator. + * @param prisma Prisma client used for lazy project context loading. + */ constructor( private readonly reflector: Reflector, private readonly permissionService: PermissionService, private readonly prisma: PrismaService, ) {} + /** + * Resolves and evaluates route permissions. + * + * Behavior: + * - Returns `true` when no `@RequirePermission()` metadata is declared. + * - Throws `UnauthorizedException` if `request.user` is missing. + * - Lazily loads project context via `resolveProjectContextIfRequired`. + * - Evaluates each permission and allows if any match. + * - Throws `ForbiddenException('Insufficient permissions')` otherwise. + * + * @security Routes without `@RequirePermission()` bypass this guard's checks. + */ async canActivate(context: ExecutionContext): Promise { const permissions = this.reflector.getAllAndOverride( PERMISSION_KEY, @@ -70,6 +95,22 @@ export class PermissionGuard implements CanActivate { return true; } + /** + * Loads project members/invites only when the requested permissions require + * project-scoped context. + * + * Behavior: + * - Skips DB access if no project id or no project-scoped permission exists. + * - Resets cached context when project id changes. + * - Loads `projectMember` rows when required and cache is empty. + * - Loads `projectMemberInvite` rows when required and invites are not loaded. + * + * @todo `projectMembers.length === 0` causes re-fetch for projects that + * genuinely have zero members. Add a sentinel flag such as + * `projectMembersLoaded: boolean`. + * @todo Member/invite query and mapping logic is duplicated in multiple guards + * and `ProjectContextInterceptor`; extract a shared `ProjectContextService`. + */ private async resolveProjectContextIfRequired( request: AuthenticatedRequest, permissions: RequiredPermission[], @@ -169,6 +210,11 @@ export class PermissionGuard implements CanActivate { }; } + /** + * Checks if a permission depends on project members. + * + * Delegates named and inline permissions to `PermissionService`. + */ private requireProjectMembers(permission: RequiredPermission): boolean { if (typeof permission === 'string') { return this.permissionService.isNamedPermissionRequireProjectMembers( @@ -179,6 +225,12 @@ export class PermissionGuard implements CanActivate { return this.permissionService.isPermissionRequireProjectMembers(permission); } + /** + * Checks if a permission depends on project invites. + * + * Only named permission keys are supported; inline `Permission` objects return + * `false`. + */ private requireProjectInvites(permission: RequiredPermission): boolean { if (typeof permission !== 'string') { return false; diff --git a/src/shared/guards/projectMember.guard.ts b/src/shared/guards/projectMember.guard.ts index a43c065..7a1a47d 100644 --- a/src/shared/guards/projectMember.guard.ts +++ b/src/shared/guards/projectMember.guard.ts @@ -1,3 +1,9 @@ +/** + * Guard for project-scoped membership checks. + * + * `@ProjectMemberRole()` uses this guard to ensure the current user belongs to + * the target project, with optional role constraints. + */ import { applyDecorators, CanActivate, @@ -15,19 +21,47 @@ import { AuthenticatedRequest } from '../interfaces/request.interface'; import { PrismaService } from '../modules/global/prisma.service'; import { PermissionService } from '../services/permission.service'; +/** + * Metadata key for required project member roles. + */ export const PROJECT_MEMBER_ROLES_KEY = 'project_member_roles'; +/** + * Writes required project member roles to route metadata. + * + * @param roles Allowed project member roles. + */ export const RequireProjectMemberRoles = (...roles: ProjectMemberRoleEnum[]) => SetMetadata(PROJECT_MEMBER_ROLES_KEY, roles); +/** + * Enforces that the caller is a member of the project from route params. + */ @Injectable() export class ProjectMemberGuard implements CanActivate { + /** + * @param reflector Metadata reader for `RequireProjectMemberRoles`. + * @param prisma Prisma client used to load project members. + * @param permissionService Permission helper for role intersections. + */ constructor( private readonly reflector: Reflector, private readonly prisma: PrismaService, private readonly permissionService: PermissionService, ) {} + /** + * Enforces project membership and optional required project roles. + * + * Behavior: + * - Throws `UnauthorizedException` if `user.userId` is missing. + * - Throws `ForbiddenException` if `projectId` route param is missing. + * - Resolves members from request cache or database. + * - Throws `ForbiddenException('User is not a project member.')` when no + * matching member exists. + * - Throws `ForbiddenException('User does not have required project role.')` + * when required roles are declared and no role matches. + */ async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const user = request.user; @@ -67,6 +101,11 @@ export class ProjectMemberGuard implements CanActivate { return true; } + /** + * Extracts the route `projectId` value. + * + * @returns Trimmed project id or `undefined` when blank/missing. + */ private extractProjectId(request: AuthenticatedRequest): string | undefined { const projectId = request.params?.projectId; @@ -77,6 +116,20 @@ export class ProjectMemberGuard implements CanActivate { return projectId.trim(); } + /** + * Resolves project members from request cache or database and updates cache. + * + * Behavior: + * - Returns cached members from `request.projectContext` if the project id + * matches. + * - Queries `prisma.projectMember.findMany` with soft-delete filtering. + * - Converts Prisma enum role values to plain strings. + * - Stores the result in `request.projectContext`. + * + * @todo The Prisma query and role-mapping logic is duplicated in + * `ProjectContextInterceptor`, `PermissionGuard`, and `CopilotAndAboveGuard`. + * Extract a shared `ProjectContextService.resolveMembers(projectId)` helper. + */ private async resolveProjectMembers( request: AuthenticatedRequest, projectId: string, @@ -117,6 +170,11 @@ export class ProjectMemberGuard implements CanActivate { } } +/** + * Composite decorator for project membership enforcement with optional roles. + * + * @param roles Accepted project member roles. + */ export const ProjectMemberRole = (...roles: ProjectMemberRoleEnum[]) => applyDecorators( UseGuards(ProjectMemberGuard), diff --git a/src/shared/guards/tokenRoles.guard.ts b/src/shared/guards/tokenRoles.guard.ts index 90a8920..c3e56b5 100644 --- a/src/shared/guards/tokenRoles.guard.ts +++ b/src/shared/guards/tokenRoles.guard.ts @@ -1,3 +1,12 @@ +/** + * Primary authentication and coarse-grained authorization guard applied across + * the API surface. + * + * The guard supports: + * - `@Public()` escape hatch for unauthenticated routes. + * - Bearer token extraction and JWT validation. + * - Dual authorization flow for human tokens and M2M tokens. + */ import { applyDecorators, CanActivate, @@ -15,22 +24,61 @@ import { AuthenticatedRequest } from '../interfaces/request.interface'; import { JwtService } from '../modules/global/jwt.service'; import { M2MService } from '../modules/global/m2m.service'; +/** + * Metadata key for required Topcoder roles declared with `@Roles()`. + */ export const ROLES_KEY = 'roles'; +/** + * Swagger extension key used to expose required roles in OpenAPI operations. + */ export const SWAGGER_REQUIRED_ROLES_KEY = 'x-required-roles'; +/** + * Declares allowed Topcoder roles for a route. + * + * The decorator writes both runtime metadata and Swagger metadata. + * + * @param roles Roles accepted by this route. Matching is case-insensitive. + */ export const Roles = (...roles: string[]) => applyDecorators( SetMetadata(ROLES_KEY, roles), ApiExtension(SWAGGER_REQUIRED_ROLES_KEY, roles), ); +/** + * Global auth guard that validates JWT tokens and applies role/scope checks. + */ @Injectable() export class TokenRolesGuard implements CanActivate { + /** + * @param reflector Nest metadata reader for auth decorators. + * @param jwtService Service that validates and parses JWT payloads. + * @param m2mService Service that classifies machine tokens and scope checks. + */ constructor( private readonly reflector: Reflector, private readonly jwtService: JwtService, private readonly m2mService: M2MService, ) {} + /** + * Authenticates and authorizes the incoming request. + * + * Behavior: + * - Returns `true` for `@Public()` routes. + * - Throws `UnauthorizedException` when Bearer token is absent or malformed. + * - Calls `JwtService.validateToken` and stores the validated user on request. + * - Reads both `@Roles()` and `@Scopes()` metadata. + * - Returns `true` for any authenticated user if both metadata lists are empty. + * - For M2M tokens: requires declared scopes and checks scope intersection. + * - For human tokens: allows if any required role or scope matches. + * - Throws `ForbiddenException('Insufficient permissions')` otherwise. + * + * @security Endpoints without `@Roles()` and `@Scopes()` are reachable by any + * valid token. + * @todo Move toward a default-deny posture by requiring at least one auth + * decorator, or add an explicit `@AnyAuthenticated()` marker. + */ async canActivate(context: ExecutionContext): Promise { const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ context.getHandler(), @@ -124,6 +172,12 @@ export class TokenRolesGuard implements CanActivate { throw new ForbiddenException('Insufficient permissions'); } + /** + * Normalizes role/scope strings for case-insensitive comparison. + * + * @param values Role/scope values to normalize. + * @returns Trimmed, lowercase, non-empty values. + */ private normalizeValues(values: string[]): string[] { return values .map((value) => String(value).trim().toLowerCase()) diff --git a/src/shared/interceptors/projectContext.interceptor.ts b/src/shared/interceptors/projectContext.interceptor.ts index fe404e2..5a1b186 100644 --- a/src/shared/interceptors/projectContext.interceptor.ts +++ b/src/shared/interceptors/projectContext.interceptor.ts @@ -1,3 +1,9 @@ +/** + * Request-scoped project-context cache primer. + * + * This interceptor preloads project members into `request.projectContext` + * before route handlers execute to reduce repeated database lookups. + */ import { CallHandler, ExecutionContext, @@ -9,12 +15,36 @@ import { AuthenticatedRequest } from '../interfaces/request.interface'; import { LoggerService } from '../modules/global/logger.service'; import { PrismaService } from '../modules/global/prisma.service'; +/** + * Interceptor that preloads and caches project membership context per request. + */ @Injectable() export class ProjectContextInterceptor implements NestInterceptor { + /** + * Static logger instance used for non-blocking preload failures. + */ private readonly logger = LoggerService.forRoot('ProjectContextInterceptor'); + /** + * @param prisma Prisma client used for project member lookups. + */ constructor(private readonly prisma: PrismaService) {} + /** + * Initializes `request.projectContext` and preloads project members when a + * `projectId` route param is available. + * + * Behavior: + * - Initializes `request.projectContext` if absent. + * - Short-circuits when no project id is present. + * - Short-circuits on cache hits where project id already matches. + * - Queries active project members and maps `role` to plain strings. + * - On query error, logs a warning and stores `projectMembers = []`. + * - Never throws; request processing continues with `next.handle()`. + * + * @todo Member query + mapping logic is duplicated in multiple guards. + * Introduce a shared `ProjectContextService` to centralize loading behavior. + */ async intercept( context: ExecutionContext, next: CallHandler, @@ -72,6 +102,11 @@ export class ProjectContextInterceptor implements NestInterceptor { return next.handle(); } + /** + * Extracts a normalized `projectId` route param. + * + * @returns Trimmed id or `undefined` when missing/blank/non-string. + */ private extractProjectId(request: AuthenticatedRequest): string | undefined { const rawProjectId = request.params?.projectId; diff --git a/src/shared/interfaces/permission.interface.ts b/src/shared/interfaces/permission.interface.ts index e549778..f695434 100644 --- a/src/shared/interfaces/permission.interface.ts +++ b/src/shared/interfaces/permission.interface.ts @@ -1,20 +1,72 @@ +/** + * Shared type contracts for the permission system. + * + * These interfaces are consumed by permission decorators, guards, and services + * to evaluate authorization decisions consistently. + */ +/** + * A project-role rule used inside permission definitions. + */ export interface ProjectRoleRule { + /** + * Project member role value. + */ role: string; + /** + * Whether the rule applies only to the primary member of the role. + */ isPrimary?: boolean; } +/** + * A single allow/deny rule for role and scope checks. + */ export interface PermissionRule { + /** + * Topcoder roles: + * - `string[]`: specific allowed roles. + * - `true`: any authenticated user. + */ topcoderRoles?: string[] | boolean; + /** + * Project roles: + * - `(string | ProjectRoleRule)[]`: specific project role checks. + * - `true`: any project member. + */ projectRoles?: (string | ProjectRoleRule)[] | boolean; + /** + * Required M2M token scopes. + */ scopes?: string[]; } +/** + * Full permission policy object. + */ export interface Permission { + /** + * Allow rule evaluated by permission service. + */ allowRule?: PermissionRule; + /** + * Deny rule evaluated before or alongside allow checks. + */ denyRule?: PermissionRule; + /** + * Shorthand equivalent to `allowRule.topcoderRoles`. + */ topcoderRoles?: string[] | boolean; + /** + * Shorthand equivalent to `allowRule.projectRoles`. + */ projectRoles?: (string | ProjectRoleRule)[] | boolean; + /** + * Shorthand equivalent to `allowRule.scopes`. + */ scopes?: string[]; + /** + * Documentation metadata for grouping and display. + */ meta?: { title?: string; group?: string; @@ -22,15 +74,30 @@ export interface Permission { }; } +/** + * Project member shape used by permission checks. + */ export interface ProjectMember { id?: bigint | number | string; projectId?: bigint | number | string; + /** + * Member user identifier. + */ userId: bigint | number | string; + /** + * Project member role value. + */ role: string; + /** + * Whether the member is primary for the assigned role. + */ isPrimary?: boolean; deletedAt?: Date | null; } +/** + * Project invite shape used by permission checks. + */ export interface ProjectInvite { id?: bigint | number | string; projectId?: bigint | number | string; @@ -40,8 +107,20 @@ export interface ProjectInvite { deletedAt?: Date | null; } +/** + * Per-request project context cache attached to `AuthenticatedRequest`. + */ export interface ProjectContext { + /** + * Currently cached project id. + */ projectId?: string; + /** + * Cached project members for the current project id. + */ projectMembers: ProjectMember[]; + /** + * Cached project invites for the current project id. + */ projectInvites?: ProjectInvite[]; } diff --git a/src/shared/interfaces/request.interface.ts b/src/shared/interfaces/request.interface.ts index 107cc67..efa8bcc 100644 --- a/src/shared/interfaces/request.interface.ts +++ b/src/shared/interfaces/request.interface.ts @@ -1,8 +1,23 @@ +/** + * Augmented Express request type used by authenticated API handlers. + * + * `TokenRolesGuard` populates `user`, while `ProjectContextInterceptor` and + * project-aware guards populate `projectContext`. + */ import { Request } from 'express'; import { JwtUser } from '../modules/global/jwt.service'; import { ProjectContext } from './permission.interface'; +/** + * Request shape available to guards, interceptors, and controllers. + */ export type AuthenticatedRequest = Request & { + /** + * Validated JWT user context set by auth guards. + */ user?: JwtUser; + /** + * Per-request project context cache set by interceptor/guards. + */ projectContext?: ProjectContext; }; diff --git a/src/shared/modules/global/eventBus.service.ts b/src/shared/modules/global/eventBus.service.ts index a279ef0..d51d82b 100644 --- a/src/shared/modules/global/eventBus.service.ts +++ b/src/shared/modules/global/eventBus.service.ts @@ -7,7 +7,27 @@ import { LoggerService } from './logger.service'; import * as busApi from 'tc-bus-api-wrapper'; +/** + * Event bus integration for publishing project events. + * + * Wraps the tc bus API client and degrades gracefully when required runtime + * configuration is unavailable. + */ +/** + * Event bus client contract used by this service. + */ type EventBusClient = { + /** + * Publishes an event envelope to the bus. + * + * @param event Event envelope. + * @param event.topic Kafka topic name. + * @param event.originator Service identifier that emits the event. + * @param event.timestamp ISO-8601 event timestamp. + * @param event['mime-type'] MIME type of the payload body. + * @param event.payload Serializable event payload. + * @returns {Promise} Result returned by the bus client. + */ postEvent: (event: { topic: string; originator: string; @@ -18,15 +38,31 @@ type EventBusClient = { }; @Injectable() +/** + * Service responsible for publishing events to Kafka through tc-bus-api-wrapper. + */ export class EventBusService { private readonly logger = LoggerService.forRoot('EventBusService'); private readonly client: EventBusClient | null; + /** + * Creates the event-bus client if the runtime supports it. + */ constructor() { this.client = this.createClient(); } + /** + * Publishes a project event to the configured event bus topic. + * + * @param {string} topic Kafka topic string. + * @param {unknown} payload Serializable event payload. + * @returns {Promise} + * @throws {ServiceUnavailableException} When event bus client is not configured. + * @throws {InternalServerErrorException} When event publishing fails. + */ async publishProjectEvent(topic: string, payload: unknown): Promise { + // TODO (security): The 'topic' parameter is not validated. A caller passing an untrusted or user-supplied topic string could publish to unintended Kafka topics. Validate against an allowlist of known topics. if (!this.client) { throw new ServiceUnavailableException( 'Event bus client is not configured.', @@ -52,6 +88,14 @@ export class EventBusService { } } + /** + * Creates a bus API client from environment configuration. + * + * Returns null when configuration is insufficient or client initialization + * fails, so callers can degrade gracefully. + * + * @returns {EventBusClient | null} Initialized client or null when unavailable. + */ private createClient(): EventBusClient | null { const busApiFactory = busApi as unknown as ( config: Record, @@ -72,6 +116,8 @@ export class EventBusService { } try { + // TODO (quality): TOKEN_CACHE_TIME is hardcoded to 900 seconds. Expose as an environment variable (e.g., AUTH0_TOKEN_CACHE_TIME) for operational flexibility. + // TODO (security): KAFKA_CLIENT_CERT and KAFKA_CLIENT_CERT_KEY are passed directly from environment variables without validation. Ensure these are properly formatted PEM strings before passing to the client. return busApiFactory({ BUSAPI_URL: process.env.BUSAPI_URL, KAFKA_URL: process.env.KAFKA_URL, diff --git a/src/shared/modules/global/globalProviders.module.ts b/src/shared/modules/global/globalProviders.module.ts index 013ed45..ae158e4 100644 --- a/src/shared/modules/global/globalProviders.module.ts +++ b/src/shared/modules/global/globalProviders.module.ts @@ -13,14 +13,30 @@ import { M2MService } from './m2m.service'; import { PrismaErrorService } from './prisma-error.service'; import { PrismaService } from './prisma.service'; +/** + * Global providers module. + * + * Acts as the single registration point for cross-cutting infrastructure and + * shared integration services: + * - PrismaService: database client and lifecycle management + * - PrismaErrorService: Prisma-to-HTTP exception translation + * - JwtService: token validation and user extraction + * - LoggerService: application logging abstraction + * - M2MService: machine-token acquisition and scope utilities + * - EventBusService: event publishing to bus/Kafka + * - PermissionService: authorization checks + * - BillingAccountService/MemberService/IdentityService/EmailService/FileService: external integration services + */ @Global() @Module({ + // TODO (quality): HttpModule is imported without configuration (timeout, baseURL). Consider providing a global HttpModule with sensible defaults (e.g., a request timeout) to prevent hanging HTTP calls in shared services. imports: [HttpModule], providers: [ PrismaService, JwtService, { provide: LoggerService, + // TODO (quality): LoggerService is provided via factory with a fixed 'Global' context. However, all individual services call LoggerService.forRoot() directly, bypassing DI. Either standardise on DI injection with setContext(), or remove the factory provider and document that LoggerService is not injected. useFactory: () => { return new LoggerService('Global'); }, @@ -50,4 +66,7 @@ import { PrismaService } from './prisma.service'; FileService, ], }) +/** + * Exports globally shared infrastructure providers for the application. + */ export class GlobalProvidersModule {} diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index fa3e7de..250f5c0 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -7,22 +7,56 @@ import * as jwt from 'jsonwebtoken'; import * as jwksClient from 'jwks-rsa'; import { LoggerService } from './logger.service'; +/** + * JWT validation service. + * + * Implements a dual-path token validation strategy: + * 1) legacy tc-core middleware validation, then + * 2) JWKS-backed Auth0/JWT validation fallback. + * + * Runtime behavior is environment-dependent and includes non-production + * shortcuts that should not be used in internet-accessible environments. + */ // tc-core-library-js is CommonJS-only. // eslint-disable-next-line @typescript-eslint/no-require-imports const tcCore = require('tc-core-library-js'); type JwtPayloadRecord = Record; +/** + * Authenticated user model derived from JWT payload data. + */ export interface JwtUser { + /** + * Primary user identifier when present (for example `userId` or `sub`). + */ userId?: string; + /** + * Topcoder handle extracted from token claims. + */ handle?: string; + /** + * Role names extracted from token claims. + */ roles?: string[]; + /** + * Token scopes in normalized list form. + */ scopes?: string[]; + /** + * Indicates whether the token appears to be a machine-to-machine token. + */ isMachine: boolean; + /** + * Raw token payload used for downstream claim access. + */ tokenPayload?: JwtPayloadRecord; } @Injectable() +/** + * Service that validates and parses JWT tokens into a normalized `JwtUser`. + */ export class JwtService implements OnModuleInit { private readonly logger = LoggerService.forRoot('JwtService'); private readonly jwksClients = new Map(); @@ -30,9 +64,16 @@ export class JwtService implements OnModuleInit { private readonly audience = process.env.AUTH0_AUDIENCE; private jwtAuthenticator: any; + /** + * Initializes the optional tc-core JWT authenticator. + * + * If `AUTH_SECRET` is absent, tc-core validation is skipped and only the JWKS + * fallback path can validate tokens. + */ onModuleInit(): void { if (tcCore?.middleware?.jwtAuthenticator) { if (!process.env.AUTH_SECRET) { + // TODO (security): Absence of AUTH_SECRET only logs a warning and disables tc-core validation. The service continues to start. Ensure the JWKS fallback path is always configured in production when AUTH_SECRET is omitted. this.logger.warn( 'AUTH_SECRET is not configured. tc-core JWT validator disabled.', ); @@ -52,6 +93,15 @@ export class JwtService implements OnModuleInit { } } + /** + * Validates an incoming JWT and builds a normalized user model. + * + * Accepts either a raw JWT string or a `Bearer ` prefixed token. + * + * @param {string} rawToken Raw token or Bearer token value. + * @returns {Promise} Parsed authenticated user data. + * @throws {UnauthorizedException} When token validation fails. + */ async validateToken(rawToken: string): Promise { const token = this.normalizeToken(rawToken); @@ -63,6 +113,15 @@ export class JwtService implements OnModuleInit { return this.buildJwtUser(payload); } + /** + * Normalizes a raw Authorization header/token value. + * + * Removes a leading `Bearer ` prefix and trims whitespace. + * + * @param {string} token Raw token value. + * @returns {string} Normalized token string. + * @throws {UnauthorizedException} When token is empty after normalization. + */ private normalizeToken(token: string): string { const normalized = token.startsWith('Bearer ') ? token.slice('Bearer '.length) @@ -75,6 +134,13 @@ export class JwtService implements OnModuleInit { return normalized.trim(); } + /** + * Validates a token using tc-core's Express-style middleware wrapper. + * + * @param {string} token Normalized token. + * @returns {Promise} Decoded payload or null when tc-core validation is unavailable. + * @throws {UnauthorizedException} In production when tc-core validation fails. + */ private async validateWithTcCore( token: string, ): Promise { @@ -125,6 +191,7 @@ export class JwtService implements OnModuleInit { throw new UnauthorizedException('Invalid token'); } + // TODO (security): In non-production, a tc-core validation failure is swallowed and returns null, causing fallthrough to validateWithJwt which skips signature verification. This means any structurally valid JWT is accepted in non-production. Ensure non-production environments are never internet-accessible. this.logger.warn( `tc-core token validation fallback used: ${error instanceof Error ? error.message : String(error)}`, ); @@ -133,6 +200,13 @@ export class JwtService implements OnModuleInit { } } + /** + * Validates a token using JWT parsing and, in production, JWKS signature checks. + * + * @param {string} token Normalized token. + * @returns {Promise} Verified JWT payload. + * @throws {UnauthorizedException} When token structure is invalid, issuer/keyId is missing, signing key cannot be fetched, or signature verification fails. + */ private async validateWithJwt(token: string): Promise { const decoded = jwt.decode(token, { complete: true }) as | (jwt.Jwt & { payload?: jwt.JwtPayload | string }) @@ -149,6 +223,7 @@ export class JwtService implements OnModuleInit { const payload = decoded.payload as JwtPayloadRecord; if (process.env.NODE_ENV !== 'production') { + // TODO (security): CRITICAL - JWT signature verification is skipped entirely in non-production (NODE_ENV !== 'production'). Any token with a valid structure will be accepted. This must never reach a publicly accessible environment. return payload; } @@ -176,6 +251,16 @@ export class JwtService implements OnModuleInit { return verifiedPayload as JwtPayloadRecord; } + /** + * Resolves the JWT signing key from issuer JWKS data. + * + * Lazily initializes and caches one JWKS client per issuer. + * + * @param {string} issuer Token issuer URL. + * @param {string} keyId JWT key identifier (`kid`). + * @returns {Promise} Public key used for signature verification. + * @throws {UnauthorizedException} When the JWKS client cannot be initialized or key lookup fails. + */ private getSigningKey(issuer: string, keyId: string): Promise { const normalizedIssuer = issuer.replace(/\/$/, ''); @@ -215,6 +300,16 @@ export class JwtService implements OnModuleInit { }); } + /** + * Builds a normalized `JwtUser` model from token payload claims. + * + * Uses a two-pass extraction strategy: + * 1) direct known-claim extraction for standard fields + * 2) suffix-based fallback scanning for alternate claim names + * + * @param {JwtPayloadRecord} payload Decoded JWT payload. + * @returns {JwtUser} Parsed user and machine-token metadata. + */ private buildJwtUser(payload: JwtPayloadRecord): JwtUser { const user: JwtUser = { isMachine: false, @@ -242,6 +337,7 @@ export class JwtService implements OnModuleInit { user.roles = roles; } + // TODO (quality): The second Object.keys(payload) loop (line 245) partially re-extracts userId, handle, and roles that were already extracted in the first pass. Consolidate into a single extraction pass to eliminate the DRY violation. for (const key of Object.keys(payload)) { const lowerKey = key.toLowerCase(); @@ -280,7 +376,14 @@ export class JwtService implements OnModuleInit { return user; } + /** + * Extracts token scopes from standard `scope`/`scopes` claims. + * + * @param {JwtPayloadRecord} payload Token payload. + * @returns {string[]} Normalized list of scopes. + */ private extractScopes(payload: JwtPayloadRecord): string[] { + // TODO (quality): This method is nearly identical to M2MService.extractScopes(). Extract to a shared utility function (e.g., src/shared/utils/scope.utils.ts) to eliminate duplication. const rawScope = payload.scope || payload.scopes; if (typeof rawScope === 'string') { @@ -299,6 +402,12 @@ export class JwtService implements OnModuleInit { return []; } + /** + * Extracts role values from token claims. + * + * @param {JwtPayloadRecord} payload Token payload. + * @returns {string[]} Normalized role names. + */ private extractRoles(payload: JwtPayloadRecord): string[] { const rawRoles = payload.roles; @@ -311,6 +420,13 @@ export class JwtService implements OnModuleInit { .filter((role) => role.length > 0); } + /** + * Extracts the first non-empty string value for a set of claim keys. + * + * @param {JwtPayloadRecord} payload Token payload. + * @param {string[]} keys Candidate claim keys. + * @returns {string | undefined} Claim value when found. + */ private extractString( payload: JwtPayloadRecord, keys: string[], @@ -325,6 +441,12 @@ export class JwtService implements OnModuleInit { return undefined; } + /** + * Extracts a user identifier from common claims. + * + * @param {JwtPayloadRecord} payload Token payload. + * @returns {string | undefined} User identifier from `userId` or `sub`. + */ private extractUserId(payload: JwtPayloadRecord): string | undefined { const userId = this.extractIdentifier(payload.userId); if (userId) { @@ -334,6 +456,12 @@ export class JwtService implements OnModuleInit { return this.extractIdentifier(payload.sub); } + /** + * Converts supported identifier types into string form. + * + * @param {unknown} value Candidate identifier value. + * @returns {string | undefined} Normalized identifier string. + */ private extractIdentifier(value: unknown): string | undefined { if (typeof value === 'string' && value.trim().length > 0) { return value.trim(); @@ -350,10 +478,23 @@ export class JwtService implements OnModuleInit { return undefined; } + /** + * Checks whether an identifier string is purely numeric. + * + * @param {string} value Identifier value. + * @returns {boolean} True when the value contains only digits. + */ private isNumericIdentifier(value: string): boolean { return /^\d+$/.test(value.trim()); } + /** + * Reads valid JWT issuers from `VALID_ISSUERS`. + * + * Supports JSON array or comma-separated string formats. + * + * @returns {string[]} Configured issuer values. + */ private getValidIssuers(): string[] { const validIssuers = process.env.VALID_ISSUERS; @@ -378,6 +519,12 @@ export class JwtService implements OnModuleInit { return []; } + /** + * Resolves issuer value from token payload or configuration fallback. + * + * @param {JwtPayloadRecord} payload Token payload. + * @returns {string | undefined} Resolved issuer. + */ private resolveIssuer(payload: JwtPayloadRecord): string | undefined { const issuer = payload.iss; @@ -385,6 +532,7 @@ export class JwtService implements OnModuleInit { return issuer; } + // TODO (security): When the token has no 'iss' claim, this method falls back to validIssuers[0]. A token without an issuer claim should be rejected outright rather than assumed to belong to the first configured issuer. if (this.validIssuers.length > 0) { return this.validIssuers[0]; } @@ -392,6 +540,12 @@ export class JwtService implements OnModuleInit { return undefined; } + /** + * Extracts an error message from tc-core middleware response payloads. + * + * @param {unknown} payload Error payload. + * @returns {string | undefined} Extracted message text. + */ private extractErrorMessage(payload: unknown): string | undefined { if (typeof payload === 'string' && payload.trim().length > 0) { return payload; diff --git a/src/shared/modules/global/logger.service.ts b/src/shared/modules/global/logger.service.ts index 2d8a807..fddff34 100644 --- a/src/shared/modules/global/logger.service.ts +++ b/src/shared/modules/global/logger.service.ts @@ -5,11 +5,30 @@ import { } from '@nestjs/common'; import { createLogger, format, Logger, transports } from 'winston'; +/** + * Winston-backed NestJS logger wrapper. + * + * Provides a `forRoot` factory pattern for contextual loggers and formats all + * messages with timestamp, level, and optional context metadata. + */ @Injectable() +/** + * Global logger service implementing NestJS LoggerService contract. + */ export class LoggerService implements NestLoggerService { private context?: string; private readonly logger: Logger; + // TODO (quality): Each LoggerService instance creates its own Winston logger instance. For high-throughput services, consider sharing a single Winston logger instance and passing context as metadata to reduce overhead. + // TODO (security): Log messages are not sanitized. Sensitive values (tokens, passwords, PII) passed as message arguments will appear in logs. Consider adding a sanitization step in serializeMessage(). + // TODO (quality): LOG_LEVEL from env is not validated against Winston's accepted levels. An invalid value will silently default to Winston's behaviour. Add validation on startup. + /** + * Creates a context-aware logger instance. + * + * Initializes the Winston logger transport and output format pipeline. + * + * @param {string} [context] Optional default context label. + */ constructor(context?: string) { this.context = context; this.logger = createLogger({ @@ -36,18 +55,46 @@ export class LoggerService implements NestLoggerService { }); } + // TODO (quality): forRoot() creates a new instance (and a new Winston logger) on every call. Services that call LoggerService.forRoot() in their class body bypass the DI-provided singleton. Consider injecting LoggerService and using setContext() instead. + /** + * Factory method used by consumers to create a contextual logger. + * + * @param {string} context Context label for log messages. + * @returns {LoggerService} New logger instance bound to the provided context. + */ static forRoot(context: string): LoggerService { return new LoggerService(context); } + /** + * Sets or overrides the logger context. + * + * @param {string} context Context label to include in log entries. + * @returns {void} + */ setContext(context: string): void { this.context = context; } + /** + * Logs an informational message. + * + * @param {any} message Message payload. + * @param {string} [context] Optional context override. + * @returns {void} + */ log(message: any, context?: string): void { this.printMessage('log', message, context || this.context); } + /** + * Logs an error message. + * + * @param {any} message Message payload. + * @param {string} [trace] Optional stack trace. + * @param {string} [context] Optional context override. + * @returns {void} + */ error(message: any, trace?: string, context?: string): void { if (trace) { this.printMessage( @@ -60,18 +107,49 @@ export class LoggerService implements NestLoggerService { this.printMessage('error', message, context || this.context); } + /** + * Logs a warning message. + * + * @param {any} message Message payload. + * @param {string} [context] Optional context override. + * @returns {void} + */ warn(message: any, context?: string): void { this.printMessage('warn', message, context || this.context); } + /** + * Logs a debug message. + * + * @param {any} message Message payload. + * @param {string} [context] Optional context override. + * @returns {void} + */ debug(message: any, context?: string): void { this.printMessage('debug', message, context || this.context); } + /** + * Logs a verbose message. + * + * @param {any} message Message payload. + * @param {string} [context] Optional context override. + * @returns {void} + */ verbose(message: any, context?: string): void { this.printMessage('verbose', message, context || this.context); } + /** + * Normalizes NestJS log levels and forwards entries to Winston. + * + * Maps NestJS `log` level to Winston `info`. + * + * @param {LogLevel} level NestJS log level. + * @param {any} message Message payload. + * @param {string} [context] Optional context override. + * @returns {void} + */ private printMessage(level: LogLevel, message: any, context?: string): void { const normalizedMessage = this.serializeMessage(message); @@ -83,6 +161,14 @@ export class LoggerService implements NestLoggerService { this.logger.log(level, normalizedMessage, { context }); } + /** + * Converts non-string message payloads to string form. + * + * Uses JSON serialization when possible with `String(...)` fallback. + * + * @param {any} message Message payload. + * @returns {string} Serialized message. + */ private serializeMessage(message: any): string { if (typeof message === 'string') { return message; diff --git a/src/shared/modules/global/m2m.service.ts b/src/shared/modules/global/m2m.service.ts index 3b5e523..82d6a7f 100644 --- a/src/shared/modules/global/m2m.service.ts +++ b/src/shared/modules/global/m2m.service.ts @@ -6,6 +6,12 @@ import { } from 'src/shared/enums/scopes.enum'; import { LoggerService } from './logger.service'; +/** + * Machine-to-machine authentication helpers. + * + * Provides M2M token acquisition through tc-core/Auth0 and scope utilities + * used to classify machine tokens and evaluate scope-based access. + */ // tc-core-library-js is CommonJS-only. // eslint-disable-next-line @typescript-eslint/no-require-imports const tcCore = require('tc-core-library-js'); @@ -13,10 +19,21 @@ const tcCore = require('tc-core-library-js'); type TokenPayload = Record; @Injectable() +/** + * Service for M2M token management and scope expansion. + * + * Scope expansion uses both `SCOPE_HIERARCHY` and `SCOPE_SYNONYMS` to compute + * effective permissions. + */ export class M2MService { private readonly logger = LoggerService.forRoot('M2MService'); private readonly m2mClient: any; + // TODO (security): AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET are read at call-time in getM2MToken() rather than validated at startup. A missing secret will only surface at runtime when a token is first requested. + // TODO (quality): m2mClient is typed as 'any'. Define an interface for the tc-core M2M client to enable type safety. + /** + * Creates the tc-core M2M client when tc-core auth bindings are available. + */ constructor() { if (tcCore?.auth?.m2m) { this.m2mClient = tcCore.auth.m2m({ @@ -27,6 +44,12 @@ export class M2MService { } } + /** + * Fetches an M2M token from Auth0 through tc-core. + * + * @returns {Promise} Machine token string. + * @throws {InternalServerErrorException} When the client is not initialized, credentials are missing, or token retrieval fails. + */ async getM2MToken(): Promise { if (!this.m2mClient) { throw new InternalServerErrorException('M2M client is not initialized.'); @@ -56,6 +79,12 @@ export class M2MService { } } + /** + * Determines whether a token payload represents a machine token. + * + * @param {TokenPayload} [payload] Optional decoded token payload. + * @returns {{ isMachine: boolean; scopes: string[] }} Machine-token classification and extracted scopes. + */ validateMachineToken(payload?: TokenPayload): { isMachine: boolean; scopes: string[]; @@ -81,7 +110,14 @@ export class M2MService { }; } + /** + * Extracts scope claims from token payload. + * + * @param {TokenPayload} payload Decoded token payload. + * @returns {string[]} Normalized scopes. + */ extractScopes(payload: TokenPayload): string[] { + // TODO (quality): Duplicates JwtService.extractScopes(). Move to a shared utility to satisfy DRY. const rawScopes = payload.scope || payload.scopes; if (typeof rawScopes === 'string') { @@ -100,6 +136,15 @@ export class M2MService { return []; } + /** + * Expands scopes transitively using hierarchy and synonym relationships. + * + * Uses breadth-first traversal over scope graph edges derived from + * `SCOPE_HIERARCHY` and canonical scope mappings in `SCOPE_SYNONYMS`. + * + * @param {string[]} scopes Input scopes. + * @returns {string[]} All normalized and transitively implied scopes. + */ expandScopes(scopes: string[]): string[] { const expandedScopes = new Set(); const queue = scopes.map((scope) => this.normalizeScope(scope)); @@ -140,6 +185,16 @@ export class M2MService { return Array.from(expandedScopes); } + /** + * Checks whether token scopes satisfy any required scope after expansion. + * + * Uses OR semantics: returns true when any expanded required scope is present + * in expanded token scopes. + * + * @param {string[]} tokenScopes Scopes from the token. + * @param {string[]} requiredScopes Scopes required by an operation. + * @returns {boolean} True when authorization requirements are satisfied. + */ hasRequiredScopes(tokenScopes: string[], requiredScopes: string[]): boolean { if (requiredScopes.length === 0) { return true; @@ -153,9 +208,16 @@ export class M2MService { ); } + /** + * Normalizes a scope string and applies legacy compatibility aliases. + * + * @param {string} scope Scope value to normalize. + * @returns {string} Normalized scope. + */ private normalizeScope(scope: string): string { const normalizedScope = String(scope).trim().toLowerCase(); + // TODO (security): The hardcoded mapping of 'all:project' -> Scope.CONNECT_PROJECT_ADMIN is a legacy compatibility shim. Document the origin of this alias and audit whether it grants broader access than intended. if (normalizedScope === 'all:project') { return Scope.CONNECT_PROJECT_ADMIN; } diff --git a/src/shared/modules/global/prisma-error.service.ts b/src/shared/modules/global/prisma-error.service.ts index bae6fd0..847899c 100644 --- a/src/shared/modules/global/prisma-error.service.ts +++ b/src/shared/modules/global/prisma-error.service.ts @@ -9,10 +9,38 @@ import { import { Prisma } from '@prisma/client'; import { LoggerService } from './logger.service'; +/** + * Prisma exception normalization utilities. + * + * Centralizes translation of Prisma client/runtime errors into HTTP exceptions + * that can be returned consistently by API handlers. + */ @Injectable() +/** + * Maps Prisma error variants and known Prisma error codes to NestJS HTTP + * exceptions. + * + * Known request error codes currently handled: + * - P2002: unique constraint violation + * - P2003: foreign key constraint violation + * - P2025: record not found + */ export class PrismaErrorService { private readonly logger = LoggerService.forRoot('PrismaErrorService'); + // TODO (quality): Parameter 'error' is typed as 'any'. Change to 'unknown' and use type narrowing already present in the method body for stricter type safety. + /** + * Handles a Prisma error by logging context and throwing an HTTP exception. + * + * @param {any} error Raw error thrown by Prisma. + * @param {string} operation Human-readable database operation context for logs. + * @returns {never} This method never returns and always throws. + * @throws {ConflictException} Prisma known error `P2002` (unique constraint violation). + * @throws {BadRequestException} Prisma known error `P2003`, Prisma validation errors, and other unknown known Prisma codes. + * @throws {NotFoundException} Prisma known error `P2025` (record not found). + * @throws {ServiceUnavailableException} Prisma client initialization failures. + * @throws {InternalServerErrorException} Prisma Rust panic errors or unknown unexpected errors. + */ handleError(error: any, operation: string): never { this.logger.error( `Prisma error during ${operation}: ${error instanceof Error ? error.message : String(error)}`, @@ -25,6 +53,7 @@ export class PrismaErrorService { throw new ConflictException('Unique constraint failed.'); case 'P2003': throw new BadRequestException('Foreign key constraint failed.'); + // TODO (quality): Prisma error code P2025 ("record not found") is currently mapped to NotFoundException, which is correct, but the comment above the case says BadRequestException - verify the mapping is intentional. case 'P2025': throw new NotFoundException('Requested record was not found.'); default: diff --git a/src/shared/modules/global/prisma.service.ts b/src/shared/modules/global/prisma.service.ts index e7deec7..b12eb0d 100644 --- a/src/shared/modules/global/prisma.service.ts +++ b/src/shared/modules/global/prisma.service.ts @@ -4,10 +4,33 @@ import { Prisma, PrismaClient } from '@prisma/client'; import { LoggerService } from './logger.service'; import { PrismaErrorService } from './prisma-error.service'; +/** + * Global Prisma service for project-service-v6. + * + * This module provides the singleton database client used across the app and + * wires database lifecycle hooks into NestJS startup/shutdown events. It also + * centralizes datasource configuration, connection pooling parameters, and + * slow-query monitoring. + */ +// TODO (quality): These three helpers are module-level functions; consider converting them to private static methods on PrismaService for better encapsulation and testability. +/** + * Resolves the interactive transaction timeout in milliseconds. + * + * @returns {number} Timeout value in milliseconds from PROJECT_SERVICE_PRISMA_TIMEOUT. + */ function getTransactionTimeout(): number { return Number(process.env.PROJECT_SERVICE_PRISMA_TIMEOUT || 10000); } +// TODO (quality): These three helpers are module-level functions; consider converting them to private static methods on PrismaService for better encapsulation and testability. +/** + * Resolves the datasource URL for Prisma. + * + * In production, default connection-pool query parameters are appended when + * absent. + * + * @returns {string | undefined} The resolved datasource URL or undefined when not configured. + */ function getDatasourceUrl(): string | undefined { const databaseUrl = process.env.DATABASE_URL; @@ -36,6 +59,13 @@ function getDatasourceUrl(): string | undefined { } } +// TODO (quality): These three helpers are module-level functions; consider converting them to private static methods on PrismaService for better encapsulation and testability. +/** + * Extracts the Prisma schema name from a datasource URL. + * + * @param {string | undefined} datasourceUrl Datasource URL to inspect. + * @returns {string | undefined} Schema name from the `schema` query param, if present. + */ function getSchemaFromDatasourceUrl( datasourceUrl: string | undefined, ): string | undefined { @@ -51,12 +81,26 @@ function getSchemaFromDatasourceUrl( } @Injectable() +/** + * Singleton Prisma client with NestJS lifecycle integration. + * + * Extends PrismaClient to configure the PostgreSQL adapter, connection and + * transaction options, and Prisma log listeners used for query observability. + */ export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { private readonly logger = LoggerService.forRoot('PrismaService'); + /** + * Creates a configured Prisma client instance. + * + * Sets up the Prisma PostgreSQL adapter, applies transaction timeout options, + * and registers Prisma event listeners for query/info/warn/error logs. + * + * @param {PrismaErrorService} prismaErrorService Service that normalizes Prisma exceptions. + */ constructor(private readonly prismaErrorService: PrismaErrorService) { const datasourceUrl = getDatasourceUrl(); const schema = getSchemaFromDatasourceUrl(datasourceUrl); @@ -82,6 +126,7 @@ export class PrismaService const queryDurationMs = event.duration; if (process.env.NODE_ENV !== 'production') { + // TODO (security): In non-production environments, all query parameters are logged (line 87). This may expose sensitive data such as PII or credentials. Consider redacting params in all environments. this.logger.debug( `Query: ${event.query} | Params: ${event.params} | Duration: ${queryDurationMs}ms`, ); @@ -108,6 +153,14 @@ export class PrismaService }); } + /** + * Connects Prisma when the module is initialized. + * + * In production, also applies session-level database settings. + * + * @returns {Promise} + * @throws Delegates connection errors to PrismaErrorService.handleError. + */ async onModuleInit(): Promise { this.logger.log('Initializing Prisma connection'); @@ -117,6 +170,7 @@ export class PrismaService if (process.env.NODE_ENV === 'production') { try { + // TODO (security): $executeRawUnsafe is used on line 120 to set statement_timeout. Although the string is hardcoded and not injectable, prefer $executeRaw with a tagged template or a Prisma-native option if one becomes available. await this.$executeRawUnsafe('SET statement_timeout = 30000'); this.logger.log('Production connection settings configured'); } catch (error) { @@ -130,6 +184,11 @@ export class PrismaService } } + /** + * Disconnects Prisma during module teardown. + * + * @returns {Promise} + */ async onModuleDestroy(): Promise { this.logger.log('Disconnecting Prisma'); await this.$disconnect(); diff --git a/src/shared/services/billingAccount.service.ts b/src/shared/services/billingAccount.service.ts index aaf6738..553aaa5 100644 --- a/src/shared/services/billingAccount.service.ts +++ b/src/shared/services/billingAccount.service.ts @@ -15,6 +15,17 @@ export interface BillingAccount { [key: string]: unknown; } +/** + * Salesforce billing-account integration service. + * + * Uses JWT Bearer OAuth against Salesforce and runs SOQL queries to retrieve + * billing-account data used by Projects API. + * + * Injected into the billing-account controller for account listing/detail + * endpoints. Requires `SALESFORCE_CLIENT_ID`, `SALESFORCE_CLIENT_AUDIENCE` + * (or `SALESFORCE_AUDIENCE`), `SALESFORCE_SUBJECT`, and + * `SALESFORCE_CLIENT_KEY`. + */ @Injectable() export class BillingAccountService { private readonly logger = LoggerService.forRoot('BillingAccountService'); @@ -22,6 +33,7 @@ export class BillingAccountService { process.env.SALESFORCE_CLIENT_AUDIENCE || process.env.SALESFORCE_AUDIENCE || ''; + // TODO: document why salesforceAudience is a valid fallback for the login URL, or remove this fallback. private readonly salesforceLoginBaseUrl = process.env.SALESFORCE_LOGIN_BASE_URL || this.salesforceAudience || @@ -37,6 +49,15 @@ export class BillingAccountService { constructor(private readonly httpService: HttpService) {} + /** + * Returns billing accounts available to the current project user. + * + * Queries `Topcoder_Billing_Account_Resource__c` by `UserID__c`. + * + * @param projectId project id used for logging context + * @param userId Topcoder user id + * @returns billing-account list for the user + */ async getBillingAccountsForProject( projectId: string, userId: string, @@ -47,10 +68,19 @@ export class BillingAccountService { } try { + const normalizedUserId = this.parseIntStrictly(userId); + if (!normalizedUserId) { + this.logger.warn( + `Invalid userId while listing billing accounts for projectId=${projectId}.`, + ); + return []; + } + + // TODO (security + performance): cache the Salesforce access token until its expiry to reduce token surface area and latency. const { accessToken, instanceUrl } = await this.authenticate(); - const escapedUserId = this.escapeSoqlLiteral(userId); // Keep tc-project-service behavior: list by current user assignment and active BA. - const sql = `SELECT Topcoder_Billing_Account__r.Id, Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c, Topcoder_Billing_Account__r.Billing_Account_Name__c, Topcoder_Billing_Account__r.Start_Date__c, Topcoder_Billing_Account__r.End_Date__c from Topcoder_Billing_Account_Resource__c tbar where Topcoder_Billing_Account__r.Active__c=true AND UserID__c='${escapedUserId}'`; + // SECURITY: SOQL injection mitigated by parseIntStrictly integer validation. If this validation is ever relaxed, parameterized queries must be used. + const sql = `SELECT Topcoder_Billing_Account__r.Id, Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c, Topcoder_Billing_Account__r.Billing_Account_Name__c, Topcoder_Billing_Account__r.Start_Date__c, Topcoder_Billing_Account__r.End_Date__c from Topcoder_Billing_Account_Resource__c tbar where Topcoder_Billing_Account__r.Active__c=true AND UserID__c='${normalizedUserId}'`; this.logger.debug(sql); const records = await this.queryBillingAccountRecords( @@ -90,6 +120,15 @@ export class BillingAccountService { } } + /** + * Returns the default billing-account record by Topcoder billing-account id. + * + * Queries `Topcoder_Billing_Account__c` by + * `TopCoder_Billing_Account_Id__c`. + * + * @param billingAccountId Topcoder billing-account id + * @returns billing-account details, or `null` when not found/invalid + */ async getDefaultBillingAccount( billingAccountId: string, ): Promise { @@ -99,9 +138,18 @@ export class BillingAccountService { } try { + const normalizedBillingAccountId = + this.parseIntStrictly(billingAccountId); + if (!normalizedBillingAccountId) { + this.logger.warn( + `Invalid billingAccountId received while fetching default billing account: ${billingAccountId}`, + ); + return null; + } + const { accessToken, instanceUrl } = await this.authenticate(); - const escapedBillingAccountId = this.escapeSoqlLiteral(billingAccountId); - const sql = `SELECT TopCoder_Billing_Account_Id__c, Mark_Up__c, Active__c, Start_Date__c, End_Date__c from Topcoder_Billing_Account__c tba where TopCoder_Billing_Account_Id__c='${escapedBillingAccountId}'`; + // SECURITY: SOQL injection mitigated by parseIntStrictly integer validation. If this validation is ever relaxed, parameterized queries must be used. + const sql = `SELECT TopCoder_Billing_Account_Id__c, Mark_Up__c, Active__c, Start_Date__c, End_Date__c from Topcoder_Billing_Account__c tba where TopCoder_Billing_Account_Id__c='${normalizedBillingAccountId}'`; this.logger.debug(sql); const records = await this.queryBillingAccountRecords( @@ -138,6 +186,14 @@ export class BillingAccountService { } } + /** + * Returns a map of billing-account details keyed by Topcoder account id. + * + * Executes a single SOQL query with an `IN` clause over normalized ids. + * + * @param billingAccountIds billing-account ids to fetch + * @returns record keyed by normalized billing-account id + */ async getBillingAccountsByIds( billingAccountIds: string[], ): Promise> { @@ -163,10 +219,9 @@ export class BillingAccountService { try { const { accessToken, instanceUrl } = await this.authenticate(); const inClause = normalizedBillingAccountIds - .map( - (billingAccountId) => `'${this.escapeSoqlLiteral(billingAccountId)}'`, - ) + .map((billingAccountId) => `'${billingAccountId}'`) .join(','); + // SECURITY: SOQL injection mitigated by parseIntStrictly integer validation. If this validation is ever relaxed, parameterized queries must be used. const sql = `SELECT TopCoder_Billing_Account_Id__c, ${this.sfdcBillingAccountNameField}, Start_Date__c, End_Date__c, ${this.sfdcBillingAccountActiveField} from Topcoder_Billing_Account__c tba where TopCoder_Billing_Account_Id__c IN (${inClause})`; this.logger.debug(sql); @@ -207,6 +262,11 @@ export class BillingAccountService { } } + /** + * Checks whether required Salesforce auth configuration exists. + * + * @returns `true` when required env vars are present + */ private isSalesforceConfigured(): boolean { return Boolean( process.env.SALESFORCE_CLIENT_ID && @@ -216,6 +276,15 @@ export class BillingAccountService { ); } + /** + * Authenticates to Salesforce using JWT Bearer OAuth. + * + * Signs an RS256 JWT assertion and exchanges it for an access token + + * instance URL. + * + * @returns Salesforce auth session + * @throws Error when Salesforce returns an invalid auth response + */ private async authenticate(): Promise<{ accessToken: string; instanceUrl: string; @@ -226,6 +295,7 @@ export class BillingAccountService { const privateKey = this.normalizePrivateKey( process.env.SALESFORCE_CLIENT_KEY || '', ); + // TODO: parse and cache the private KeyObject at construction time. const privateKeyObject = this.toPrivateKeyObject(privateKey); const assertion = jwt.sign({}, privateKeyObject, { @@ -264,6 +334,14 @@ export class BillingAccountService { }; } + /** + * Executes a SOQL query and returns Salesforce `records`. + * + * @param sql SOQL query string + * @param accessToken Salesforce OAuth access token + * @param instanceUrl Salesforce instance base URL + * @returns Salesforce records array (empty when payload shape is unexpected) + */ private async queryBillingAccountRecords( sql: string, accessToken: string, @@ -291,7 +369,14 @@ export class BillingAccountService { return records as Record[]; } + /** + * Validates that a value is a strict integer string and normalizes it. + * + * @param rawValue candidate value + * @returns normalized integer string or `undefined` when invalid + */ private parseIntStrictly(rawValue: string | undefined): string | undefined { + // TODO: rename to toIntegerString or validateIntegerString for clarity. if (!rawValue) { return undefined; } @@ -308,7 +393,14 @@ export class BillingAccountService { return String(parsed); } + /** + * Coerces a Salesforce field value to a non-empty trimmed string. + * + * @param value raw field value + * @returns trimmed string or `undefined` + */ private readAsString(value: unknown): string | undefined { + // TODO: extract to a shared SalesforceUtils module if other Salesforce integrations are added. if (typeof value !== 'string') { return undefined; } @@ -317,7 +409,14 @@ export class BillingAccountService { return trimmed.length > 0 ? trimmed : undefined; } + /** + * Coerces a Salesforce field value to a finite number. + * + * @param value raw field value + * @returns parsed number or `undefined` + */ private readAsNumber(value: unknown): number | undefined { + // TODO: extract to a shared SalesforceUtils module if other Salesforce integrations are added. if (typeof value === 'number' && Number.isFinite(value)) { return value; } @@ -330,7 +429,14 @@ export class BillingAccountService { return undefined; } + /** + * Coerces a Salesforce field value to boolean. + * + * @param value raw field value + * @returns boolean value or `undefined` + */ private readAsBoolean(value: unknown): boolean | undefined { + // TODO: extract to a shared SalesforceUtils module if other Salesforce integrations are added. if (typeof value === 'boolean') { return value; } @@ -349,10 +455,15 @@ export class BillingAccountService { return undefined; } - private escapeSoqlLiteral(value: string): string { - return String(value).replace(/'/g, "\\'"); - } - + /** + * Normalizes private-key input from multiple secret-storage formats. + * + * Supports quoted PEM strings, escaped newlines, one-line space-separated + * PEM bodies, and base64-encoded PEM text. + * + * @param rawKey raw private-key value + * @returns normalized PEM string + */ private normalizePrivateKey(rawKey: string): string { let normalized = String(rawKey || '').trim(); @@ -404,6 +515,13 @@ export class BillingAccountService { return normalized.trim(); } + /** + * Converts PEM text into a Node.js `KeyObject`. + * + * @param privateKey PEM private key + * @returns parsed key object + * @throws Error with descriptive message when key format is invalid + */ private toPrivateKeyObject(privateKey: string): KeyObject { try { return createPrivateKey({ diff --git a/src/shared/services/email.service.ts b/src/shared/services/email.service.ts index 27cb5c9..4c408d3 100644 --- a/src/shared/services/email.service.ts +++ b/src/shared/services/email.service.ts @@ -22,12 +22,34 @@ const EXTERNAL_ACTION_EMAIL_TOPIC = 'external.action.email'; const DEFAULT_INVITE_EMAIL_SUBJECT = 'You are invited to Topcoder'; const DEFAULT_INVITE_EMAIL_SECTION_TITLE = 'Project Invitation'; +/** + * Project-invite email publisher. + * + * Publishes invite email events to the Topcoder event bus, which forwards + * them to `tc-email-service` for SendGrid delivery. + * + * Used by the project-invite service after an invite is created. + */ @Injectable() export class EmailService { private readonly logger = LoggerService.forRoot('EmailService'); constructor(private readonly eventBusService: EventBusService) {} + /** + * Publishes a project invite email event. + * + * No-ops when `invite.email` is empty or when + * `SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED` is unset. + * + * Publishes payload to `external.action.email`. + * + * @param projectId project id for context and payload + * @param invite invite payload containing recipient info + * @param initiator invite initiator details + * @param projectName optional project display name + * @returns resolved promise when publish succeeds or is skipped + */ async sendInviteEmail( projectId: string, invite: InviteEmailPayload, @@ -35,11 +57,14 @@ export class EmailService { projectName?: string, ): Promise { const recipient = invite.email?.trim().toLowerCase(); + // TODO: add basic email format validation before publishing the event. if (!recipient) { return; } + // TODO: validate this env var at startup and throw if missing, rather than silently skipping. + // TODO: cache these values as private readonly fields in the constructor, consistent with other services. const templateId = process.env.SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED; if (!templateId) { this.logger.warn( @@ -51,9 +76,12 @@ export class EmailService { const normalizedProjectName = projectName?.trim() || `Project ${projectId}`; const payload = { data: { + // TODO: cache these values as private readonly fields in the constructor, consistent with other services. workManagerUrl: process.env.WORK_MANAGER_URL || '', + // TODO: cache these values as private readonly fields in the constructor, consistent with other services. accountsAppURL: process.env.ACCOUNTS_APP_URL || '', subject: + // TODO: cache these values as private readonly fields in the constructor, consistent with other services. process.env.INVITE_EMAIL_SUBJECT || DEFAULT_INVITE_EMAIL_SUBJECT, projects: [ { @@ -63,11 +91,13 @@ export class EmailService { { EMAIL_INVITES: true, title: + // TODO: cache these values as private readonly fields in the constructor, consistent with other services. process.env.INVITE_EMAIL_SECTION_TITLE || DEFAULT_INVITE_EMAIL_SECTION_TITLE, projectName: normalizedProjectName, projectId, initiator: this.normalizeInitiator(initiator), + // TODO: determine if SSO status should be dynamic based on the invitee's identity provider. isSSO: false, }, ], @@ -92,6 +122,12 @@ export class EmailService { } } + /** + * Normalizes initiator details and applies display-name defaults. + * + * @param initiator initiator details from API/service context + * @returns normalized initiator with `'Connect'/'User'` defaults + */ private normalizeInitiator( initiator: InviteEmailInitiator, ): InviteEmailInitiator { diff --git a/src/shared/services/file.service.ts b/src/shared/services/file.service.ts index 954f798..80f6ca2 100644 --- a/src/shared/services/file.service.ts +++ b/src/shared/services/file.service.ts @@ -9,16 +9,38 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { APP_CONFIG } from 'src/shared/config/app.config'; import { LoggerService } from 'src/shared/modules/global/logger.service'; +/** + * S3 file-operation wrapper for project attachments. + * + * Provides presigned download URLs, server-side copy, and delete operations. + * + * Used by project-attachment flows and relies on ambient AWS credential chain + * resolution (environment variables, IAM role, shared credentials). + */ @Injectable() export class FileService { private readonly logger = LoggerService.forRoot('FileService'); private readonly client: S3Client; constructor() { + // TODO: consider explicitly setting the AWS region via APP_CONFIG or env var to avoid region resolution failures. this.client = new S3Client({}); } + /** + * Generates a presigned S3 download URL. + * + * URL expiry is `APP_CONFIG.presignedUrlExpiration` seconds (defaults to + * 3600 seconds). + * + * @param bucket S3 bucket name + * @param key S3 object key + * @returns presigned GET URL + * @throws S3ServiceException when AWS SDK request signing fails + */ async getPresignedDownloadUrl(bucket: string, key: string): Promise { + // TODO: validate that bucket and key values are within expected prefixes before issuing S3 commands. + // TODO: review whether 3600s expiry is appropriate for the attachment use case; consider a shorter window. const command = new GetObjectCommand({ Bucket: bucket, Key: key, @@ -29,12 +51,24 @@ export class FileService { }); } + /** + * Copies an S3 object server-side from source to destination. + * + * @param sourceBucket source bucket name + * @param sourceKey source object key + * @param destBucket destination bucket name + * @param destKey destination object key + * @returns resolved promise on successful copy + * @throws S3ServiceException when the source object is missing or copy fails + */ async transferFile( sourceBucket: string, sourceKey: string, destBucket: string, destKey: string, ): Promise { + // TODO: validate that bucket and key values are within expected prefixes before issuing S3 commands. + // TODO: consider wrapping S3 errors in a domain-specific exception for consistent error handling across the service. const command = new CopyObjectCommand({ Bucket: destBucket, Key: destKey, @@ -47,7 +81,18 @@ export class FileService { ); } + /** + * Deletes an S3 object. + * + * S3 delete is idempotent and succeeds even when the key does not exist. + * + * @param bucket S3 bucket name + * @param key S3 object key + * @returns resolved promise on successful delete request + */ async deleteFile(bucket: string, key: string): Promise { + // TODO: validate that bucket and key values are within expected prefixes before issuing S3 commands. + // TODO: consider wrapping S3 errors in a domain-specific exception for consistent error handling across the service. const command = new DeleteObjectCommand({ Bucket: bucket, Key: key, diff --git a/src/shared/services/identity.service.ts b/src/shared/services/identity.service.ts index 42251c8..46d7a86 100644 --- a/src/shared/services/identity.service.ts +++ b/src/shared/services/identity.service.ts @@ -10,9 +10,16 @@ export interface IdentityUser { email: string; } +/** + * Identity lookup service for resolving users by email. + * + * Used by project invite flows to map invite email addresses into + * `IdentityUser` records (`id`, `handle`, `email`) from the Identity API. + */ @Injectable() export class IdentityService { private readonly logger = LoggerService.forRoot('IdentityService'); + // TODO: DRY violation - consider a shared config constant or ConfigService. private readonly identityApiUrl = process.env.IDENTITY_API_URL || ''; constructor( @@ -20,6 +27,18 @@ export class IdentityService { private readonly m2mService: M2MService, ) {} + /** + * Looks up identity users for multiple emails. + * + * Executes one request per email in parallel via `Promise.all`, swallows + * per-email request failures with `.catch(() => null)`, and de-duplicates + * successful responses by `id::email`. + * + * Returns an empty array when `IDENTITY_API_URL` is not configured. + * + * @param emails email addresses to resolve + * @returns matched identity users + */ async lookupMultipleUserEmails( emails: string[] = [], ): Promise { @@ -44,6 +63,7 @@ export class IdentityService { const responses = await Promise.all( normalizedEmails.map((email) => + // TODO: URL-encode the email value in the filter param to prevent query string injection. firstValueFrom( this.httpService.get( `${this.identityApiUrl.replace(/\/$/, '')}/users`, @@ -62,6 +82,7 @@ export class IdentityService { ).catch(() => null), ), ); + // TODO: consider batching or throttling parallel email lookups. const users = responses.flatMap((response) => { if (!response || !Array.isArray(response.data)) { @@ -76,6 +97,7 @@ export class IdentityService { const key = `${String(user.id || '').trim()}::${String(user.email || '') .trim() .toLowerCase()}`; + // TODO: strengthen deduplication guard - skip entries where id is empty. if (!key.trim()) { continue; } diff --git a/src/shared/services/member.service.ts b/src/shared/services/member.service.ts index b367718..b146783 100644 --- a/src/shared/services/member.service.ts +++ b/src/shared/services/member.service.ts @@ -9,10 +9,23 @@ export interface MemberRoleRecord { roleName: string; } +/** + * Member and identity enrichment service. + * + * Fetches profile details from the Topcoder Member API and user roles from the + * Identity API using M2M tokens. + * + * Used by project invite/member flows to enrich responses with `handle`, + * `email`, `firstName`, `lastName`, and to validate Topcoder roles while + * managing project membership. + */ @Injectable() export class MemberService { private readonly logger = LoggerService.forRoot('MemberService'); + // TODO: validate required env vars at startup (e.g., in onModuleInit). private readonly memberApiUrl = process.env.MEMBER_API_URL || ''; + // TODO: DRY violation - consider a shared config constant or ConfigService. + // TODO: validate required env vars at startup (e.g., in onModuleInit). private readonly identityApiUrl = process.env.IDENTITY_API_URL || ''; constructor( @@ -20,6 +33,15 @@ export class MemberService { private readonly m2mService: M2MService, ) {} + /** + * Looks up member details by handles through the Member API. + * + * Returns an empty array when the API is not configured, input is empty, or + * network/API errors occur. + * + * @param handles handles to resolve + * @returns member detail records matched by handle + */ async getMemberDetailsByHandles( handles: string[] = [], ): Promise { @@ -43,6 +65,7 @@ export class MemberService { const token = await this.m2mService.getM2MToken(); const quotedHandles = normalizedHandles.map((handle) => `"${handle}"`); + // TODO: verify Member API treats this as a safe list parameter, not a raw query string. const response = await firstValueFrom( this.httpService.get(`${this.memberApiUrl}`, { headers: { @@ -71,9 +94,19 @@ export class MemberService { } } + /** + * Looks up member details by numeric user ids through the Member API. + * + * Returns an empty array when the API is not configured, input is empty, or + * network/API errors occur. + * + * @param userIds member user ids to resolve + * @returns member detail records returned by the Member API + */ async getMemberDetailsByUserIds( userIds: Array, ): Promise { + // TODO: extract shared HTTP fetch logic into a private fetchFromMemberApi(params) helper. if (!this.memberApiUrl || userIds.length === 0) { return []; } @@ -113,7 +146,16 @@ export class MemberService { } } + /** + * Looks up Identity API role names for a user id. + * + * Queries `IDENTITY_API_URL/roles` with `filter=subjectID=`. + * + * @param userId Topcoder user id + * @returns role name list or empty array on errors/misconfiguration + */ async getUserRoles(userId: string | number | bigint): Promise { + // TODO: consider moving getUserRoles into IdentityService to consolidate identity API calls. if (!this.identityApiUrl) { this.logger.warn('IDENTITY_API_URL is not configured.'); return []; diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index bcdf3d7..6a7e98b 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -204,6 +204,24 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it('allows viewing project for machine token with project read scope', () => { + const allowed = service.hasNamedPermission(Permission.VIEW_PROJECT, { + scopes: [Scope.PROJECTS_READ], + isMachine: true, + }); + + expect(allowed).toBe(true); + }); + + it('allows editing project for machine token with project write scope', () => { + const allowed = service.hasNamedPermission(Permission.EDIT_PROJECT, { + scopes: [Scope.PROJECTS_WRITE], + isMachine: true, + }); + + expect(allowed).toBe(true); + }); + it('marks billing account permissions as requiring project member context', () => { expect( service.isNamedPermissionRequireProjectMembers( diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index 072b7d0..1b2f931 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -17,10 +17,31 @@ import { import { JwtUser } from '../modules/global/jwt.service'; import { M2MService } from '../modules/global/m2m.service'; +/** + * Central authorization engine for Projects API permissions. + * + * Evaluates both rule-based permission objects and named permissions against a + * {@link JwtUser}, optional project members, and optional project invites. + * + * This service is injected broadly across controllers and services. The + * `ProjectContextInterceptor` preloads project members/invites so those arrays + * are available when permission checks run. + */ @Injectable() export class PermissionService { constructor(private readonly m2mService: M2MService) {} + /** + * Matches a single permission rule against user/project context. + * + * The rule is considered matched when any one dimension matches: + * project role OR Topcoder role OR M2M scope. + * + * @param permissionRule rule to evaluate + * @param user authenticated JWT user context + * @param projectMembers optional members for project-role checks + * @returns `true` when at least one rule dimension matches + */ matchPermissionRule( permissionRule: PermissionRule | null | undefined, user: JwtUser, @@ -74,6 +95,16 @@ export class PermissionService { return hasProjectRole || hasTopcoderRole || hasScope; } + /** + * Evaluates a permission object using allow/deny semantics. + * + * Effective result is `allowRule` minus `denyRule`. + * + * @param permission permission object or shorthand rule + * @param user authenticated JWT user context + * @param projectMembers optional members for project-role checks + * @returns `true` when allow matches and deny does not match + */ hasPermission( permission: Permission | null | undefined, user: JwtUser, @@ -92,6 +123,22 @@ export class PermissionService { return allow && !deny; } + /** + * Evaluates one of the named permissions used by guards/controllers. + * + * Contains an explicit switch with all named permission intents for project, + * member, invite, billing, copilot, attachment, and work-management actions. + * + * @param permission named permission enum value + * @param user authenticated JWT user context + * @param projectMembers project member list required by many permission paths + * @param projectInvites invite list required by invite-aware permissions + * @returns `true` when the user satisfies the named permission rule + * @security `CREATE_PROJECT` currently trusts a permissive `isAuthenticated` + * check: any non-empty `userId`, any role, any scope, or `isMachine`. + * @security Admin detection currently includes the raw role string + * `'topcoder_manager'`, which can drift from enum-backed role names. + */ hasNamedPermission( permission: NamedPermission, user: JwtUser, @@ -107,7 +154,9 @@ export class PermissionService { (Array.isArray(user.roles) && user.roles.length > 0) || (Array.isArray(user.scopes) && user.scopes.length > 0) || user.isMachine; + // TODO: intentionally permissive authentication gate for CREATE_PROJECT; reassess whether any role/scope/machine token should qualify. + // TODO: replace 'topcoder_manager' string literal with UserRole enum value. const isAdmin = this.hasIntersection(user.roles || [], [ ...ADMIN_ROLES, UserRole.MANAGER, @@ -118,6 +167,19 @@ export class PermissionService { this.m2mService.hasRequiredScopes(user.scopes || [], [ Scope.CONNECT_PROJECT_ADMIN, ]); + const hasProjectReadScope = this.m2mService.hasRequiredScopes( + user.scopes || [], + [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECTS_ALL, + Scope.PROJECTS_READ, + Scope.PROJECTS_WRITE, + ], + ); + const hasProjectWriteScope = this.m2mService.hasRequiredScopes( + user.scopes || [], + [Scope.CONNECT_PROJECT_ADMIN, Scope.PROJECTS_ALL, Scope.PROJECTS_WRITE], + ); const member = this.getProjectMember(user.userId, projectMembers); const hasProjectMembership = Boolean(member); @@ -144,18 +206,30 @@ export class PermissionService { ); }); + // TODO: extract to private isAdminManagerOrCopilot() helper to reduce duplication. switch (permission) { + // Project read/write lifecycle permissions. case NamedPermission.READ_PROJECT_ANY: return isAdmin; case NamedPermission.VIEW_PROJECT: - return isAdmin || hasProjectMembership || hasPendingInvite; + return ( + isAdmin || + hasProjectMembership || + hasPendingInvite || + hasProjectReadScope + ); case NamedPermission.CREATE_PROJECT: return isAuthenticated; case NamedPermission.EDIT_PROJECT: - return isAdmin || isManagementMember || this.isCopilot(member?.role); + return ( + isAdmin || + isManagementMember || + this.isCopilot(member?.role) || + hasProjectWriteScope + ); case NamedPermission.DELETE_PROJECT: return ( @@ -167,6 +241,7 @@ export class PermissionService { ) ); + // Project member management permissions. case NamedPermission.READ_PROJECT_MEMBER: return isAdmin || hasProjectMembership; @@ -189,6 +264,7 @@ export class PermissionService { this.hasCopilotManagerRole(user) ); + // Project invite read/write permissions. case NamedPermission.READ_PROJECT_INVITE_OWN: return isAuthenticated; @@ -229,6 +305,7 @@ export class PermissionService { this.hasCopilotManagerRole(user) ); + // Billing-account related permissions. case NamedPermission.MANAGE_PROJECT_BILLING_ACCOUNT_ID: case NamedPermission.MANAGE_PROJECT_DIRECT_PROJECT_ID: return isAdmin; @@ -254,6 +331,7 @@ export class PermissionService { ]) ); + // Copilot opportunity permissions. case NamedPermission.MANAGE_COPILOT_REQUEST: case NamedPermission.ASSIGN_COPILOT_OPPORTUNITY: case NamedPermission.CANCEL_COPILOT_OPPORTUNITY: @@ -271,6 +349,7 @@ export class PermissionService { UserRole.CONNECT_ADMIN, ]); + // Project attachment permissions. case NamedPermission.VIEW_PROJECT_ATTACHMENT: return isAdmin || hasProjectMembership; @@ -287,6 +366,7 @@ export class PermissionService { case NamedPermission.UPDATE_PROJECT_ATTACHMENT_NOT_OWN: return isAdmin; + // Phase/work/workstream permissions. case NamedPermission.ADD_PROJECT_PHASE: case NamedPermission.UPDATE_PROJECT_PHASE: case NamedPermission.DELETE_PROJECT_PHASE: @@ -311,6 +391,7 @@ export class PermissionService { case NamedPermission.WORKITEM_DELETE: return isAdmin || isManagementMember; + // Work manager permission matrix endpoints. case NamedPermission.WORK_MANAGEMENT_PERMISSION_VIEW: return isAuthenticated; @@ -322,6 +403,12 @@ export class PermissionService { } } + /** + * Checks whether a rule-based permission needs project members in context. + * + * @param permission permission object or shorthand rule + * @returns `true` when allow or deny rule references project roles + */ isPermissionRequireProjectMembers( permission: Permission | null | undefined, ): boolean { @@ -338,7 +425,17 @@ export class PermissionService { ); } + /** + * Checks whether a named permission needs project members in context. + * + * This list is a static allowlist and must stay aligned with + * {@link hasNamedPermission}. + * + * @param permission named permission enum value + * @returns `true` when project member context is required + */ isNamedPermissionRequireProjectMembers(permission: NamedPermission): boolean { + // TODO: derive this list programmatically or add a unit test to enforce sync. return [ NamedPermission.VIEW_PROJECT, NamedPermission.EDIT_PROJECT, @@ -385,11 +482,24 @@ export class PermissionService { ].includes(permission); } + /** + * Checks whether a named permission needs project invites in context. + * + * @param permission named permission enum value + * @returns `true` for invite-aware permissions (currently `VIEW_PROJECT`) + */ isNamedPermissionRequireProjectInvites(permission: NamedPermission): boolean { return permission === NamedPermission.VIEW_PROJECT; } + /** + * Resolves the default project role from Topcoder roles by precedence. + * + * @param user authenticated JWT user context + * @returns first matched default {@link ProjectMemberRole}, or `undefined` + */ getDefaultProjectRole(user: JwtUser): ProjectMemberRole | undefined { + // TODO: duplicated with src/shared/utils/member.utils.ts#getDefaultProjectRole; consolidate shared role-resolution logic. for (const rule of DEFAULT_PROJECT_ROLE) { if (this.hasPermission({ topcoderRoles: [rule.topcoderRole] }, user)) { return rule.projectRole; @@ -399,11 +509,27 @@ export class PermissionService { return undefined; } + /** + * Normalizes a role value into lowercase-trimmed form. + * + * @param role role string to normalize + * @returns normalized lowercase role + */ normalizeRole(role: string): string { + // TODO: duplicated with src/shared/utils/member.utils.ts#normalizeRole; extract shared normalization utility. + // TODO: consider making these protected/private. return String(role).trim().toLowerCase(); } + /** + * Checks whether two role arrays intersect (case-insensitive). + * + * @param array1 source roles + * @param array2 roles to match against + * @returns `true` when at least one normalized role intersects + */ hasIntersection(array1: string[], array2: string[]): boolean { + // TODO: consider making these protected/private. const normalizedArray1 = new Set( array1.map((role) => this.normalizeRole(role)), ); @@ -412,6 +538,13 @@ export class PermissionService { ); } + /** + * Finds a project member record for the given user id. + * + * @param userId user id from JWT or invite context + * @param projectMembers project members loaded for the current project + * @returns member record when found + */ private getProjectMember( userId: string | number | undefined, projectMembers: ProjectMember[], @@ -427,12 +560,26 @@ export class PermissionService { ); } + /** + * Normalizes a user id into a trimmed string. + * + * @param userId user id from JWT/member/invite payloads + * @returns trimmed string user id + */ private normalizeUserId( userId: string | number | bigint | undefined, ): string { + // TODO: duplicated with src/shared/utils/member.utils.ts#normalizeUserId; extract shared normalization utility. return String(userId || '').trim(); } + /** + * Evaluates a project-role rule against a specific member. + * + * @param member project member under evaluation + * @param rule role rule including optional `isPrimary` requirement + * @returns `true` when role (and optional primary flag) match + */ private matchProjectRoleRule( member: ProjectMember, rule: ProjectRoleRule, @@ -451,6 +598,12 @@ export class PermissionService { return true; } + /** + * Checks whether a project role is copilot. + * + * @param role project role to test + * @returns `true` when role resolves to `COPILOT` + */ private isCopilot(role?: string): boolean { if (!role) { return false; @@ -461,6 +614,12 @@ export class PermissionService { ); } + /** + * Checks whether a project role is customer. + * + * @param role project role to test + * @returns `true` when role resolves to `CUSTOMER` + */ private isCustomer(role?: string): boolean { if (!role) { return false; @@ -472,10 +631,22 @@ export class PermissionService { ); } + /** + * Checks whether user has the copilot-manager Topcoder role. + * + * @param user authenticated JWT user context + * @returns `true` when user has `COPILOT_MANAGER` + */ private hasCopilotManagerRole(user: JwtUser): boolean { return this.hasIntersection(user.roles || [], [UserRole.COPILOT_MANAGER]); } + /** + * Checks Topcoder roles allowed to view billing-account data. + * + * @param user authenticated JWT user context + * @returns `true` when user has one of billing-related roles + */ private hasProjectBillingTopcoderRole(user: JwtUser): boolean { return this.hasIntersection(user.roles || [], [ ...ADMIN_ROLES, diff --git a/src/shared/utils/event.utils.ts b/src/shared/utils/event.utils.ts index 474b2c4..af89687 100644 --- a/src/shared/utils/event.utils.ts +++ b/src/shared/utils/event.utils.ts @@ -1,6 +1,14 @@ +/** + * Kafka event publishing utility with retry and circuit-breaker behavior. + * + * Used by domain services to publish lifecycle events to Topcoder Bus API. + */ import { LoggerService } from 'src/shared/modules/global/logger.service'; import * as busApi from 'tc-bus-api-wrapper'; +/** + * Event envelope shape accepted by `tc-bus-api-wrapper`. + */ type BusApiEvent = { topic: string; originator: string; @@ -9,24 +17,67 @@ type BusApiEvent = { payload: unknown; }; +/** + * Minimal client contract required from bus API wrapper. + */ type BusApiClient = { postEvent: (event: BusApiEvent) => Promise; }; +/** + * Optional callback invoked after successful publish. + */ type PublishCallback = (event: BusApiEvent) => void; +/** + * Originator value embedded into outbound events. + */ const EVENT_ORIGINATOR = 'project-service-v6'; +/** + * MIME type used for event payloads. + */ const EVENT_MIME_TYPE = 'application/json'; +/** + * Maximum retry attempts for client initialization and event publish. + */ const MAX_RETRY_ATTEMPTS = 3; +/** + * Initial retry delay used before exponential backoff. + */ const INITIAL_RETRY_DELAY_MS = 100; +/** + * Number of consecutive failures needed to open circuit. + */ const CIRCUIT_BREAKER_FAILURE_THRESHOLD = 5; +/** + * Duration (ms) the circuit remains open before retry attempts resume. + */ const CIRCUIT_BREAKER_OPEN_MS = 30000; +/** + * Lazily initialized BUS API client singleton for this process. + * + * @todo Module-level mutable state (`busApiClient`, failure counters, circuit + * timers) is process-local and does not synchronize across worker threads or + * horizontally scaled instances. + */ let busApiClient: BusApiClient | null; +/** + * Consecutive publish failure counter used by circuit-breaker logic. + */ let consecutiveFailures = 0; +/** + * Epoch timestamp until which the circuit remains open. + */ let circuitOpenUntil = 0; const logger = LoggerService.forRoot('EventUtils'); +/** + * Builds `tc-bus-api-wrapper` configuration from environment variables. + * + * @security `AUTH0_CLIENT_SECRET` is injected from environment and must never + * be logged. + */ function buildBusApiConfig(): Record { return { BUSAPI_URL: process.env.BUSAPI_URL, @@ -42,14 +93,23 @@ function buildBusApiConfig(): Record { }; } +/** + * Converts unknown errors into safe log messages. + */ function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** + * Extracts error stack when available. + */ function toErrorStack(error: unknown): string | undefined { return error instanceof Error ? error.stack : undefined; } +/** + * Classifies transient network/socket failures for retry logic. + */ function isTransientError(error: unknown): boolean { const message = toErrorMessage(error); const normalizedMessage = message.toLowerCase(); @@ -79,6 +139,11 @@ function isTransientError(error: unknown): boolean { ); } +/** + * Calculates serialized payload size in bytes for logging. + * + * Returns `-1` if serialization fails. + */ function calculatePayloadSize(payload: unknown): number { try { return Buffer.byteLength(JSON.stringify(payload), 'utf8'); @@ -87,15 +152,24 @@ function calculatePayloadSize(payload: unknown): number { } } +/** + * Returns `true` when circuit breaker is currently open. + */ function isCircuitOpen(): boolean { return Date.now() < circuitOpenUntil; } +/** + * Clears circuit-breaker failure state. + */ function resetCircuitBreaker(): void { consecutiveFailures = 0; circuitOpenUntil = 0; } +/** + * Registers a failed publish attempt and opens circuit if threshold is met. + */ function registerFailure(): void { consecutiveFailures += 1; @@ -109,10 +183,21 @@ function registerFailure(): void { ); } +/** + * Promise-based sleep helper used for retry backoff. + */ async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Returns a lazily-initialized BUS API client. + * + * Initialization retries transient failures with exponential backoff up to + * `MAX_RETRY_ATTEMPTS`. + * + * @throws Error when all initialization attempts fail. + */ export async function getBusApiClient(): Promise { if (busApiClient) { return busApiClient; @@ -159,6 +244,9 @@ export async function getBusApiClient(): Promise { ); } +/** + * Creates an outbound BUS API event envelope. + */ function createBusApiEvent(topic: string, payload: unknown): BusApiEvent { return { topic, @@ -169,6 +257,16 @@ function createBusApiEvent(topic: string, payload: unknown): BusApiEvent { }; } +/** + * Publishes an event with retry + circuit-breaker protection. + * + * Behavior: + * - Skips publish when circuit is open. + * - Retries transient failures with exponential backoff. + * - Executes optional callback on successful publish. + * - Registers circuit-breaker failure when all retries are exhausted. + * - Logs failures and returns; this helper does not rethrow. + */ async function postEventWithRetry( event: BusApiEvent, operation: string, diff --git a/src/shared/utils/member.utils.ts b/src/shared/utils/member.utils.ts index 82aed46..bf28107 100644 --- a/src/shared/utils/member.utils.ts +++ b/src/shared/utils/member.utils.ts @@ -1,8 +1,16 @@ +/** + * Member/invite enrichment and role-validation helpers. + * + * Used by project member and project invite services. + */ import { InviteStatus, ProjectMemberRole } from '@prisma/client'; import { DEFAULT_PROJECT_ROLE } from 'src/shared/constants/permissions.constants'; import { UserRole } from 'src/shared/enums/userRole.enum'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; +/** + * Minimal user profile payload used for member/invite enrichment. + */ export type MemberDetail = { userId?: string | number | bigint | null; handle?: string | null; @@ -11,6 +19,9 @@ export type MemberDetail = { lastName?: string | null; }; +/** + * Project member shape accepted by enrichment helpers. + */ export type ProjectMemberLike = { id?: string | number | bigint; userId?: string | number | bigint; @@ -20,6 +31,9 @@ export type ProjectMemberLike = { updatedAt?: Date; }; +/** + * Project invite shape accepted by enrichment helpers. + */ export type ProjectInviteLike = { id?: string | number | bigint; userId?: string | number | bigint | null; @@ -30,6 +44,12 @@ export type ProjectInviteLike = { updatedAt?: Date; }; +/** + * Manager-tier Topcoder roles allowed to hold management project roles. + * + * @todo Duplicates `MANAGER_ROLES` from `userRole.enum.ts`. Import the shared + * constant instead of maintaining a local copy. + */ const MANAGER_TOPCODER_ROLES: string[] = [ UserRole.TOPCODER_ADMIN, UserRole.CONNECT_ADMIN, @@ -44,6 +64,12 @@ const MANAGER_TOPCODER_ROLES: string[] = [ UserRole.COPILOT_MANAGER, ]; +/** + * Mapping between project roles and allowed Topcoder roles. + * + * @todo Duplicates matrix data from `permissions.constants.ts`. Consolidate to + * a single source of truth. + */ const PROJECT_TO_TOPCODER_ROLES_MATRIX: Record = { [ProjectMemberRole.customer]: null, [ProjectMemberRole.observer]: null, @@ -56,10 +82,20 @@ const PROJECT_TO_TOPCODER_ROLES_MATRIX: Record = { [ProjectMemberRole.copilot]: [UserRole.COPILOT, UserRole.TC_COPILOT], }; +/** + * Normalizes role strings for case-insensitive comparisons. + * + * @todo Duplicated in `permission.service.ts`; extract shared utility. + */ function normalizeRole(value: string): string { return String(value).trim().toLowerCase(); } +/** + * Normalizes user ids to trimmed strings. + * + * @todo Duplicated in `permission.service.ts`; extract shared utility. + */ function normalizeUserId( value: string | number | bigint | null | undefined, ): string { @@ -70,12 +106,18 @@ function normalizeUserId( return String(value).trim(); } +/** + * Normalizes emails for case-insensitive comparison. + */ function normalizeEmail(value: string | null | undefined): string { return String(value || '') .trim() .toLowerCase(); } +/** + * Parses requested fields from CSV string or string array input. + */ function parseFields(fields?: string[] | string): string[] { if (!fields) { return []; @@ -93,6 +135,9 @@ function parseFields(fields?: string[] | string): string[] { .filter((entry) => entry.length > 0); } +/** + * Builds `Map` for O(1) enrichment lookup. + */ function buildDetailsMap(details: MemberDetail[]): Map { const map = new Map(); @@ -108,6 +153,14 @@ function buildDetailsMap(details: MemberDetail[]): Map { return map; } +/** + * Derives the default project role for a user. + * + * Evaluates `DEFAULT_PROJECT_ROLE` in order; first matching Topcoder role wins. + * + * @todo Duplicated in `permission.service.ts`; consolidate shared role + * resolution logic. + */ export function getDefaultProjectRole( user: JwtUser, ): ProjectMemberRole | undefined { @@ -125,6 +178,11 @@ export function getDefaultProjectRole( return undefined; } +/** + * Validates that user Topcoder roles permit the requested project role. + * + * `customer` and `observer` are unrestricted by design. + */ export function validateUserHasProjectRole( role: ProjectMemberRole, topcoderRoles: string[] = [], @@ -142,6 +200,14 @@ export function validateUserHasProjectRole( ); } +/** + * Enriches project members with user profile fields. + * + * Supported fields: `handle`, `email`, `firstName`, `lastName`. + * + * @todo Shares near-identical logic with `enrichInvitesWithUserDetails`. + * Extract `enrichWithUserDetails(items, getKey, fields, userDetails)`. + */ export function enrichMembersWithUserDetails( members: T[], fields?: string[] | string, @@ -184,6 +250,15 @@ export function enrichMembersWithUserDetails( }) as Array>; } +/** + * Enriches project invites with user profile fields. + * + * Supported fields: `handle`, `email`, `firstName`, `lastName`. + * For `email`, falls back to `invite.email` when user detail is absent. + * + * @todo Shares near-identical logic with `enrichMembersWithUserDetails`. + * Extract `enrichWithUserDetails(items, getKey, fields, userDetails)`. + */ export function enrichInvitesWithUserDetails( invites: T[], fields?: string[] | string, @@ -225,6 +300,12 @@ export function enrichInvitesWithUserDetails( }) as Array>; } +/** + * Compares two emails case-insensitively. + * + * When `UNIQUE_GMAIL_VALIDATION` is enabled, Gmail addresses are normalized for + * dot-insensitivity and `@googlemail.com` alias compatibility. + */ export function compareEmail( email1: string | null | undefined, email2: string | null | undefined, diff --git a/src/shared/utils/pagination.utils.ts b/src/shared/utils/pagination.utils.ts index 68e6c9e..5235e17 100644 --- a/src/shared/utils/pagination.utils.ts +++ b/src/shared/utils/pagination.utils.ts @@ -1,6 +1,15 @@ +/** + * Pagination header utilities used by project listing endpoints. + * + * Sets the standard `X-Page`, `X-Per-Page`, `X-Total`, `X-Total-Pages`, + * `X-Prev-Page`, `X-Next-Page`, and `Link` headers used by platform UI clients. + */ import { Request, Response } from 'express'; import * as qs from 'qs'; +/** + * Builds an absolute page URL by merging current query params with `page`. + */ function getProjectPageLink(req: Request, page: number): string { const query = { ...req.query, @@ -10,6 +19,18 @@ function getProjectPageLink(req: Request, page: number): string { return `${req.protocol}://${req.get('Host')}${req.baseUrl}${req.path}?${qs.stringify(query)}`; } +/** + * Sets project pagination headers on the response. + * + * @param req Current Express request used for URL generation. + * @param res Express response that receives pagination headers. + * @param page Current page number. + * @param perPage Number of records per page. + * @param total Total record count. + * + * `Access-Control-Expose-Headers` is appended (not replaced) to preserve + * existing CORS exposure headers. + */ export function setProjectPaginationHeaders( req: Request, res: Response, @@ -56,4 +77,10 @@ export function setProjectPaginationHeaders( res.header('Access-Control-Expose-Headers', exposeHeaders); } +/** + * Deprecated alias for `setProjectPaginationHeaders`. + * + * @deprecated Use `setProjectPaginationHeaders` directly. + * @todo Remove this alias after all call sites are migrated. + */ export const setResHeader = setProjectPaginationHeaders; diff --git a/src/shared/utils/permission.utils.ts b/src/shared/utils/permission.utils.ts index ba7eae9..f6208d9 100644 --- a/src/shared/utils/permission.utils.ts +++ b/src/shared/utils/permission.utils.ts @@ -1,12 +1,29 @@ +/** + * Stateless helper functions for common permission checks. + * + * Used across guards and services for role/scope/member assertions. + */ import { ProjectMember } from '../interfaces/permission.interface'; import { JwtUser } from '../modules/global/jwt.service'; import { Scope } from '../enums/scopes.enum'; import { ADMIN_ROLES, UserRole } from '../enums/userRole.enum'; +/** + * Normalizes role/scope values for case-insensitive comparisons. + * + * @todo Duplicate normalization helper also exists in `member.utils.ts`, + * `project.utils.ts`, and `permission.service.ts`. Extract a shared + * `src/shared/utils/string.utils.ts` helper. + */ function normalize(value: string): string { return value.trim().toLowerCase(); } +/** + * Returns `true` when any requested role exists in `user.roles`. + * + * Comparison is case-insensitive. + */ export function hasRoles(user: JwtUser, roles: string[]): boolean { const userRoles = (user.roles || []).map(normalize); const normalizedRoles = roles.map(normalize); @@ -14,6 +31,13 @@ export function hasRoles(user: JwtUser, roles: string[]): boolean { return normalizedRoles.some((role) => userRoles.includes(role)); } +/** + * Returns `true` if the user has an admin role or admin-equivalent scope. + * + * Accepted scope overrides: + * - `all:connect_project` + * - `all:project` (legacy alias) + */ export function hasAdminRole(user: JwtUser): boolean { const normalizedScopes = (user.scopes || []).map(normalize); @@ -24,10 +48,16 @@ export function hasAdminRole(user: JwtUser): boolean { ); } +/** + * Returns `true` when user has `UserRole.PROJECT_MANAGER`. + */ export function hasProjectManagerRole(user: JwtUser): boolean { return hasRoles(user, [UserRole.PROJECT_MANAGER]); } +/** + * Returns `true` when `userId` is present in project members. + */ export function isProjectMember( userId: string, projectMembers: ProjectMember[], @@ -38,6 +68,9 @@ export function isProjectMember( ); } +/** + * Returns the matching project member for `userId`, if found. + */ export function getProjectMember( userId: string, projectMembers: ProjectMember[], diff --git a/src/shared/utils/project.utils.ts b/src/shared/utils/project.utils.ts index be09c7f..a89599f 100644 --- a/src/shared/utils/project.utils.ts +++ b/src/shared/utils/project.utils.ts @@ -1,8 +1,17 @@ +/** + * Project query-building and response-filtering helpers. + * + * Used by project services to construct Prisma `where`/`include` clauses and to + * filter nested resources based on caller permissions. + */ import { Prisma, ProjectStatus } from '@prisma/client'; import { ProjectListQueryDto } from 'src/api/project/dto/project-list-query.dto'; import { PROJECT_MEMBER_MANAGER_ROLES } from 'src/shared/enums/projectMemberRole.enum'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; +/** + * Flags representing requested project response sub-resources. + */ export interface ParsedProjectFields { projects: boolean; project_members: boolean; @@ -11,6 +20,9 @@ export interface ParsedProjectFields { raw: string[]; } +/** + * Default field selection used when no `fields` query param is provided. + */ const DEFAULT_FIELDS: ParsedProjectFields = { projects: true, project_members: true, @@ -19,10 +31,22 @@ const DEFAULT_FIELDS: ParsedProjectFields = { raw: [], }; +/** + * Normalizes string comparisons. + * + * @todo Duplicate helper exists in other shared modules. Consolidate in a + * shared string utility. + */ function normalize(value: string): string { return value.trim().toLowerCase(); } +/** + * Normalizes user ids to trimmed string form. + * + * @todo Duplicate helper exists in other shared modules. Consolidate in a + * shared string utility. + */ function normalizeUserId( value: string | number | bigint | null | undefined, ): string { @@ -33,6 +57,9 @@ function normalizeUserId( return String(value).trim(); } +/** + * Parses comma-separated values into a trimmed non-empty string list. + */ function parseCsv(value: string): string[] { return value .split(',') @@ -40,6 +67,11 @@ function parseCsv(value: string): string[] { .filter((item) => item.length > 0); } +/** + * Normalizes query filter values from scalar/array/object representations. + * + * Supports plain strings, arrays, and object forms with `$in` or `in`. + */ function parseFilterValue(value: unknown): string[] { if (typeof value === 'string') { return parseCsv(value); @@ -78,6 +110,11 @@ function parseFilterValue(value: unknown): string[] { return []; } +/** + * Safely converts a string to bigint. + * + * Returns `null` for invalid bigint input. + */ function toBigInt(value: string): bigint | null { try { return BigInt(value); @@ -86,6 +123,9 @@ function toBigInt(value: string): bigint | null { } } +/** + * Appends a new condition into a Prisma `AND` clause. + */ function appendAndCondition( where: Prisma.ProjectWhereInput, condition: Prisma.ProjectWhereInput, @@ -103,12 +143,18 @@ function appendAndCondition( where.AND = [where.AND, condition]; } +/** + * Converts list of string ids into valid bigint values. + */ function toBigIntList(values: string[]): bigint[] { return values .map((value) => toBigInt(value)) .filter((value): value is bigint => value !== null); } +/** + * Parses and validates project status values against Prisma enum values. + */ function parseProjectStatus(values: string[]): ProjectStatus[] { const allowed = new Set(Object.values(ProjectStatus)); @@ -119,6 +165,11 @@ function parseProjectStatus(values: string[]): ProjectStatus[] { ); } +/** + * Parses the `fields` query parameter into include flags. + * + * `projects` is always true. `all` enables all optional sub-resources. + */ export function parseFieldsParameter(fields?: string): ParsedProjectFields { if (!fields || fields.trim().length === 0) { return { @@ -146,6 +197,19 @@ export function parseFieldsParameter(fields?: string): ParsedProjectFields { }; } +/** + * Builds Prisma `where` clause for project list filtering. + * + * Filters supported: + * - `id`, `status`, `billingAccountId`, `type`: exact or multi-value `in`. + * - `name`: case-insensitive contains match. + * - `directProjectId`: exact bigint match. + * - `keyword`: full-text search (`search`) plus case-insensitive contains on + * name and description. + * - `code`: case-insensitive contains on name. + * - `customer` / `manager`: member-subquery constraints. + * - non-admin or `memberOnly=true`: restrict to membership/invite ownership. + */ export function buildProjectWhereClause( criteria: ProjectListQueryDto, user: JwtUser, @@ -329,6 +393,11 @@ export function buildProjectWhereClause( return where; } +/** + * Builds Prisma `include` clause from parsed field flags. + * + * Included sub-resources are soft-delete aware and ordered by `id asc`. + */ export function buildProjectIncludeClause( fields: ParsedProjectFields, ): Prisma.ProjectInclude { @@ -374,6 +443,14 @@ type InviteLike = { userId?: string | number | bigint | null; }; +/** + * Filters project invites according to permission flags. + * + * Returns: + * - all invites when `hasReadAll` is true, + * - only own invites when `hasReadOwn` is true, + * - empty list otherwise. + */ export function filterInvitesByPermission( invites: T[] | undefined, user: JwtUser, @@ -413,6 +490,18 @@ type ProjectMemberLike = { role?: string | null; }; +/** + * Filters attachments by caller visibility rules. + * + * Visibility: + * - admins and management-role members can view all attachments, + * - others can view own attachments, + * - others can view attachments with empty `allowedUsers`, + * - others can view attachments where their numeric user id is in + * `allowedUsers`. + * + * @security Empty `allowedUsers` means "public within the project". + */ export function filterAttachmentsByPermission( attachments: T[] | undefined, user: JwtUser, diff --git a/src/shared/utils/swagger.utils.ts b/src/shared/utils/swagger.utils.ts index d57b9c4..469e5db 100644 --- a/src/shared/utils/swagger.utils.ts +++ b/src/shared/utils/swagger.utils.ts @@ -1,3 +1,10 @@ +/** + * Swagger/OpenAPI auth documentation enrichers. + * + * `enrichSwaggerAuthDocumentation` is applied in `main.ts` to translate custom + * auth extension metadata into human-readable authorization summaries while + * ensuring `401` and `403` responses are declared. + */ import { OpenAPIObject } from '@nestjs/swagger'; import { SWAGGER_REQUIRED_PERMISSIONS_KEY, @@ -28,6 +35,9 @@ const HTTP_METHODS = [ 'trace', ] as const; +/** + * Safely coerces unknown values to a trimmed `string[]`. + */ function parseStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -38,6 +48,11 @@ function parseStringArray(value: unknown): string[] { .filter((entry) => entry.length > 0); } +/** + * Parses required-permission extension values to display-friendly strings. + * + * Inline permission objects are JSON-stringified. + */ function parsePermissionArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -48,6 +63,9 @@ function parsePermissionArray(value: unknown): string[] { .filter((entry) => entry.length > 0); } +/** + * Stringifies a permission key or inline permission object. + */ function stringifyPermission(permission: RequiredPermission): string { if (typeof permission === 'string') { return permission; @@ -56,6 +74,12 @@ function stringifyPermission(permission: RequiredPermission): string { return JSON.stringify(permission); } +/** + * Appends an `Authorization:` section to an operation description. + * + * Idempotent: if an authorization section already exists, description is left + * unchanged. + */ function addAuthSection( description: string | undefined, authorizationLines: string[], @@ -80,6 +104,9 @@ function addAuthSection( return `${description}\n\n${authSection}`; } +/** + * Ensures standard auth error response stubs exist on an operation. + */ function ensureErrorResponses(operation: SwaggerOperation): void { operation.responses = operation.responses || {}; @@ -92,6 +119,9 @@ function ensureErrorResponses(operation: SwaggerOperation): void { } } +/** + * Builds human-readable authorization lines from custom Swagger extensions. + */ function getAuthorizationLines(operation: SwaggerOperation): string[] { const roles = parseStringArray(operation[SWAGGER_REQUIRED_ROLES_KEY]); const scopes = parseStringArray(operation[SWAGGER_REQUIRED_SCOPES_KEY]); @@ -140,6 +170,15 @@ function getAuthorizationLines(operation: SwaggerOperation): string[] { return authorizationLines; } +/** + * Enriches OpenAPI operation descriptions with authorization summaries. + * + * Iterates each path/method, inspects custom auth metadata extensions, appends + * authorization details to operation descriptions, and ensures `401`/`403` + * response declarations. + * + * @returns The same mutated OpenAPI document instance. + */ export function enrichSwaggerAuthDocumentation( document: OpenAPIObject, ): OpenAPIObject { From a7d9727e1f0fcb018688b6b620a325a365ae3d60 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 20 Feb 2026 13:45:04 +1100 Subject: [PATCH 03/41] More documentation and fix a path issue that was affecting platform-ui / copilots --- .../copilot/copilot-opportunity.controller.ts | 1 + .../dto/create-phase-product.dto.ts | 27 +- .../dto/phase-product-response.dto.ts | 8 +- .../dto/update-phase-product.dto.ts | 4 + .../phase-product/phase-product.controller.ts | 67 ++++ src/api/phase-product/phase-product.module.ts | 5 + .../phase-product/phase-product.service.ts | 138 ++++++++ src/api/phase-product/workitem.controller.ts | 67 ++++ .../dto/create-attachment.dto.ts | 10 +- .../project-attachment.controller.ts | 64 ++++ .../project-attachment.module.ts | 4 + .../project-attachment.service.ts | 159 +++++++++ .../project-invite/dto/create-invite.dto.ts | 6 + .../dto/invite-list-query.dto.ts | 6 + .../project-invite/dto/invite-response.dto.ts | 12 + .../project-invite/dto/update-invite.dto.ts | 8 + .../project-invite.controller.ts | 82 +++++ .../project-invite/project-invite.service.ts | 274 ++++++++++++++++ .../project-member/dto/create-member.dto.ts | 13 + .../dto/member-list-query.dto.ts | 12 + .../project-member/dto/member-response.dto.ts | 6 + .../project-member/dto/update-member.dto.ts | 8 + .../project-member.controller.ts | 83 +++++ .../project-member/project-member.service.ts | 169 ++++++++++ src/api/project-phase/dto/create-phase.dto.ts | 46 ++- .../project-phase/dto/phase-list-query.dto.ts | 13 +- .../project-phase/dto/phase-response.dto.ts | 29 +- src/api/project-phase/dto/update-phase.dto.ts | 10 + .../project-phase/project-phase.controller.ts | 64 ++++ src/api/project-phase/project-phase.module.ts | 7 + .../project-phase/project-phase.service.ts | 229 +++++++++++++ src/api/project-phase/work.controller.ts | 64 ++++ .../dto/create-project-setting.dto.ts | 6 + .../dto/project-setting-response.dto.ts | 6 + .../dto/update-project-setting.dto.ts | 6 + .../project-setting.controller.ts | 89 ++++++ .../project-setting.service.ts | 121 +++++++ src/api/project/dto/create-project.dto.ts | 49 +++ src/api/project/dto/pagination.dto.ts | 12 + src/api/project/dto/project-list-query.dto.ts | 27 ++ src/api/project/dto/project-response.dto.ts | 18 ++ src/api/project/dto/update-project.dto.ts | 5 + src/api/project/dto/upgrade-project.dto.ts | 9 + src/api/project/project.controller.ts | 121 +++++++ src/api/project/project.module.ts | 7 + src/api/project/project.service.ts | 302 ++++++++++++++++++ src/api/workstream/workstream.controller.ts | 58 ++++ src/api/workstream/workstream.module.ts | 5 + src/api/workstream/workstream.service.ts | 137 ++++++++ src/main.ts | 4 + src/shared/constants/permissions.constants.ts | 5 +- src/shared/constants/permissions.ts | 30 ++ src/shared/guards/permission.guard.spec.ts | 30 ++ src/shared/guards/permission.guard.ts | 18 +- src/shared/guards/tokenRoles.guard.spec.ts | 83 ++++- src/shared/guards/tokenRoles.guard.ts | 41 ++- src/shared/interfaces/permission.interface.ts | 4 + src/shared/utils/event.utils.ts | 90 ++++++ src/shared/utils/project.utils.ts | 10 +- src/shared/utils/swagger.utils.ts | 10 +- 60 files changed, 2948 insertions(+), 50 deletions(-) diff --git a/src/api/copilot/copilot-opportunity.controller.ts b/src/api/copilot/copilot-opportunity.controller.ts index 6b04417..a642dd5 100644 --- a/src/api/copilot/copilot-opportunity.controller.ts +++ b/src/api/copilot/copilot-opportunity.controller.ts @@ -83,6 +83,7 @@ export class CopilotOpportunityController { return result.data; } + @Get('copilot/opportunity/:id') @Get('copilots/opportunity/:id') @Roles(...Object.values(UserRole)) @Scopes( diff --git a/src/api/phase-product/dto/create-phase-product.dto.ts b/src/api/phase-product/dto/create-phase-product.dto.ts index 214f235..63fbb06 100644 --- a/src/api/phase-product/dto/create-phase-product.dto.ts +++ b/src/api/phase-product/dto/create-phase-product.dto.ts @@ -21,6 +21,7 @@ function parseOptionalNumber(value: unknown): number | undefined { return parsed; } +// TODO [DRY]: Duplicated in `create-phase.dto.ts` and `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. function parseOptionalInteger(value: unknown): number | undefined { const parsed = parseOptionalNumber(value); @@ -31,47 +32,59 @@ function parseOptionalInteger(value: unknown): number | undefined { return Math.trunc(parsed); } +// TODO [DRY]: Duplicated in `create-phase.dto.ts` and `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +/** + * Create payload for phase product/work-item endpoints: + * `POST /projects/:projectId/phases/:phaseId/products` and + * `POST /projects/:projectId/workstreams/:workStreamId/works/:workId/workitems`. + */ export class CreatePhaseProductDto { - @ApiProperty() + @ApiProperty({ description: 'Product/work-item name.' }) @IsString() @IsNotEmpty() name: string; - @ApiProperty() + @ApiProperty({ description: 'Product/work-item type key.' }) @IsString() @IsNotEmpty() type: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Optional product template id.' }) @IsOptional() @Transform(({ value }) => parseOptionalInteger(value)) @IsNumber() @Min(1) templateId?: number; - @ApiPropertyOptional() + @ApiPropertyOptional({ + description: + 'Optional direct project id. Defaults to parent project directProjectId when omitted.', + }) @IsOptional() @Transform(({ value }) => parseOptionalInteger(value)) @IsNumber() @Min(1) directProjectId?: number; - @ApiPropertyOptional() + @ApiPropertyOptional({ + description: + 'Optional billing account id. Defaults to parent project billingAccountId when omitted.', + }) @IsOptional() @Transform(({ value }) => parseOptionalInteger(value)) @IsNumber() @Min(1) billingAccountId?: number; - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Estimated product price.' }) @IsOptional() @Transform(({ value }) => parseOptionalNumber(value)) @IsNumber() @Min(1) estimatedPrice?: number; - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Actual product price.' }) @IsOptional() @Transform(({ value }) => parseOptionalNumber(value)) @IsNumber() diff --git a/src/api/phase-product/dto/phase-product-response.dto.ts b/src/api/phase-product/dto/phase-product-response.dto.ts index 1b870b2..bcab8b1 100644 --- a/src/api/phase-product/dto/phase-product-response.dto.ts +++ b/src/api/phase-product/dto/phase-product-response.dto.ts @@ -1,5 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** + * Response payload for phase product/work-item endpoints. + */ export class PhaseProductResponseDto { @ApiProperty() id: string; @@ -16,7 +19,10 @@ export class PhaseProductResponseDto { @ApiPropertyOptional() billingAccountId?: string | null; - @ApiProperty() + @ApiProperty({ + description: + 'Template id as string. Returns `"0"` when no template was applied.', + }) templateId: string; @ApiPropertyOptional() diff --git a/src/api/phase-product/dto/update-phase-product.dto.ts b/src/api/phase-product/dto/update-phase-product.dto.ts index c094a5b..6bb4db0 100644 --- a/src/api/phase-product/dto/update-phase-product.dto.ts +++ b/src/api/phase-product/dto/update-phase-product.dto.ts @@ -1,4 +1,8 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreatePhaseProductDto } from './create-phase-product.dto'; +/** + * Update payload for phase products/work items. This is a full `PartialType` + * of `CreatePhaseProductDto`, so every create field is optional. + */ export class UpdatePhaseProductDto extends PartialType(CreatePhaseProductDto) {} diff --git a/src/api/phase-product/phase-product.controller.ts b/src/api/phase-product/phase-product.controller.ts index e6bf027..9450bb9 100644 --- a/src/api/phase-product/phase-product.controller.ts +++ b/src/api/phase-product/phase-product.controller.ts @@ -34,9 +34,27 @@ import { PhaseProductService } from './phase-product.service'; @ApiTags('Phase Products') @ApiBearerAuth() @Controller('/projects/:projectId/phases/:phaseId/products') +/** + * REST controller for phase products under + * `/projects/:projectId/phases/:phaseId/products`. All endpoints require + * `PermissionGuard`. Read endpoints require `VIEW_PROJECT`; write endpoints + * require `ADD/UPDATE/DELETE_PHASE_PRODUCT`. Used by platform-ui Work app (via + * `WorkItemController` alias) and the legacy Connect app. + */ export class PhaseProductController { constructor(private readonly service: PhaseProductService) {} + /** + * Lists products belonging to a phase. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param user - Authenticated user. + * @returns Phase product DTO list. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks view permission. + * @throws {NotFoundException} When project or phase is not found. + */ @Get() @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -59,6 +77,18 @@ export class PhaseProductController { return this.service.listPhaseProducts(projectId, phaseId, user); } + /** + * Fetches a single phase product. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param productId - Product id from the route. + * @param user - Authenticated user. + * @returns One phase product DTO. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks view permission. + * @throws {NotFoundException} When project, phase, or product is not found. + */ @Get(':productId') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -83,6 +113,18 @@ export class PhaseProductController { return this.service.getPhaseProduct(projectId, phaseId, productId, user); } + /** + * Creates a product under a phase. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param dto - Create payload. + * @param user - Authenticated user. + * @returns Created phase product DTO. + * @throws {BadRequestException} When route ids or payload values are invalid. + * @throws {ForbiddenException} When the caller lacks create permission. + * @throws {NotFoundException} When project or phase is not found. + */ @Post() @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -102,6 +144,19 @@ export class PhaseProductController { return this.service.createPhaseProduct(projectId, phaseId, dto, user); } + /** + * Updates an existing phase product. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param productId - Product id from the route. + * @param dto - Update payload. + * @param user - Authenticated user. + * @returns Updated phase product DTO. + * @throws {BadRequestException} When ids or payload fields are invalid. + * @throws {ForbiddenException} When the caller lacks update permission. + * @throws {NotFoundException} When project, phase, or product is not found. + */ @Patch(':productId') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -129,6 +184,18 @@ export class PhaseProductController { ); } + /** + * Soft deletes a phase product. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param productId - Product id from the route. + * @param user - Authenticated user. + * @returns Nothing. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks delete permission. + * @throws {NotFoundException} When project, phase, or product is not found. + */ @Delete(':productId') @HttpCode(204) @UseGuards(PermissionGuard) diff --git a/src/api/phase-product/phase-product.module.ts b/src/api/phase-product/phase-product.module.ts index 1383c06..ffc71f7 100644 --- a/src/api/phase-product/phase-product.module.ts +++ b/src/api/phase-product/phase-product.module.ts @@ -12,4 +12,9 @@ import { WorkItemController } from './workitem.controller'; providers: [PhaseProductService], exports: [PhaseProductService], }) +/** + * NestJS feature module for phase products (work items). Registers + * `PhaseProductController` and `WorkItemController`. Exports + * `PhaseProductService`. + */ export class PhaseProductModule {} diff --git a/src/api/phase-product/phase-product.service.ts b/src/api/phase-product/phase-product.service.ts index 394badf..0b83cb9 100644 --- a/src/api/phase-product/phase-product.service.ts +++ b/src/api/phase-product/phase-product.service.ts @@ -14,6 +14,7 @@ import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { PermissionService } from 'src/shared/services/permission.service'; import { PhaseProductResponseDto } from './dto/phase-product-response.dto'; +// TODO [DRY]: Move to `src/shared/interfaces/project-permission-context.interface.ts`. interface ProjectPermissionContext { id: bigint; directProjectId: bigint | null; @@ -26,12 +27,31 @@ interface ProjectPermissionContext { } @Injectable() +/** + * Business logic for phase products. Enforces a per-phase product count limit + * (`APP_CONFIG.maxPhaseProductCount`, default 20). Inherits + * `directProjectId` and `billingAccountId` from the parent project when not + * explicitly provided, preserving v5 behavior. Used by + * `PhaseProductController` and `WorkItemController`. + */ export class PhaseProductService { constructor( private readonly prisma: PrismaService, private readonly permissionService: PermissionService, ) {} + /** + * Validates project and phase existence, then lists non-deleted products + * ordered by ascending id. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param user - Authenticated user. + * @returns Product DTO list. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks view permission. + * @throws {NotFoundException} When project or phase is not found. + */ async listPhaseProducts( projectId: string, phaseId: string, @@ -63,6 +83,18 @@ export class PhaseProductService { return products.map((product) => this.toDto(product)); } + /** + * Validates project and phase, then fetches one non-deleted product. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param productId - Product id from the route. + * @param user - Authenticated user. + * @returns Product DTO. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks view permission. + * @throws {NotFoundException} When phase or product is missing. + */ async getPhaseProduct( projectId: string, phaseId: string, @@ -100,6 +132,19 @@ export class PhaseProductService { return this.toDto(product); } + /** + * Creates a phase product with product-count guardrails and defaulting for + * `directProjectId`/`billingAccountId` from the parent project. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param dto - Create payload. + * @param user - Authenticated user. + * @returns Created product DTO. + * @throws {BadRequestException} When input is invalid or per-phase limit is exceeded. + * @throws {ForbiddenException} When the caller lacks create permission. + * @throws {NotFoundException} When project or phase is missing. + */ async createPhaseProduct( projectId: string, phaseId: string, @@ -171,6 +216,20 @@ export class PhaseProductService { return response; } + /** + * Partially updates a phase product. `templateId`, `directProjectId`, and + * `billingAccountId` are updated only when provided as numbers. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param productId - Product id from the route. + * @param dto - Update payload. + * @param user - Authenticated user. + * @returns Updated product DTO. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks update permission. + * @throws {NotFoundException} When phase or product is missing. + */ async updatePhaseProduct( projectId: string, phaseId: string, @@ -241,6 +300,7 @@ export class PhaseProductService { }); const response = this.toDto(updatedProduct); + // TODO [QUALITY]: Remove unused `void` suppressions; these variables are already used earlier in the method or should be removed from scope. void projectId; void phaseId; void user; @@ -249,6 +309,18 @@ export class PhaseProductService { return response; } + /** + * Soft deletes a phase product. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param productId - Product id from the route. + * @param user - Authenticated user. + * @returns Nothing. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks delete permission. + * @throws {NotFoundException} When phase or product is missing. + */ async deletePhaseProduct( projectId: string, phaseId: string, @@ -299,12 +371,23 @@ export class PhaseProductService { }, }); + // TODO [QUALITY]: Remove unused `void` suppressions; these variables are already used earlier in the method or should be removed from scope. void projectId; void phaseId; void user; void deletedProduct; } + /** + * Ensures a non-deleted phase exists for the given project. + * + * @param projectId - Parsed project id. + * @param phaseId - Parsed phase id. + * @param projectIdInput - Raw project id for error messages. + * @param phaseIdInput - Raw phase id for error messages. + * @returns Nothing. + * @throws {NotFoundException} When the phase is not found. + */ private async ensurePhaseExists( projectId: bigint, phaseId: bigint, @@ -329,6 +412,14 @@ export class PhaseProductService { } } + /** + * Loads project permission context used by permission checks and defaulting. + * + * @param projectId - Parsed project id. + * @returns Project permission context. + * @throws {NotFoundException} When the project does not exist. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private async getProjectPermissionContext( projectId: bigint, ): Promise { @@ -363,6 +454,16 @@ export class PhaseProductService { return project; } + /** + * Enforces a named permission against project members. + * + * @param permission - Permission to verify. + * @param user - Authenticated user. + * @param projectMembers - Active project members. + * @returns Nothing. + * @throws {ForbiddenException} When permission is missing. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private ensureNamedPermission( permission: Permission, user: JwtUser, @@ -383,6 +484,12 @@ export class PhaseProductService { } } + /** + * Maps a phase product entity into response DTO form. + * + * @param product - Phase product entity. + * @returns Serialized response DTO. + */ private toDto(product: PhaseProduct): PhaseProductResponseDto { return { id: product.id.toString(), @@ -407,6 +514,13 @@ export class PhaseProductService { }; } + /** + * Safely converts unknown JSON-like details to plain object form. + * + * @param value - Candidate JSON value. + * @returns Object details payload. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private toDetailsObject(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; @@ -415,6 +529,13 @@ export class PhaseProductService { return value as Record; } + /** + * Converts arbitrary values to Prisma JSON input semantics. + * + * @param value - Candidate JSON value. + * @returns Prisma JSON value, JsonNull, or undefined. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private toJsonInput( value: unknown, ): Prisma.InputJsonValue | Prisma.JsonNullValueInput | undefined { @@ -429,6 +550,15 @@ export class PhaseProductService { return value as Prisma.InputJsonValue; } + /** + * Parses route ids as bigint values. + * + * @param value - Raw id value. + * @param entityName - Entity name for error context. + * @returns Parsed id. + * @throws {BadRequestException} When parsing fails. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private parseId(value: string, entityName: string): bigint { try { return BigInt(value); @@ -437,6 +567,14 @@ export class PhaseProductService { } } + /** + * Parses authenticated user id for audit fields. + * + * @param user - Authenticated user. + * @returns Numeric audit user id. + */ + // TODO [SECURITY]: Returning `-1` silently when `user.userId` is invalid can corrupt audit trails; throw `UnauthorizedException` instead. + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private getAuditUserId(user: JwtUser): number { const userId = Number.parseInt(String(user.userId || ''), 10); diff --git a/src/api/phase-product/workitem.controller.ts b/src/api/phase-product/workitem.controller.ts index 984c543..26dc603 100644 --- a/src/api/phase-product/workitem.controller.ts +++ b/src/api/phase-product/workitem.controller.ts @@ -41,18 +41,36 @@ const WORKITEM_ALLOWED_ROLES = [ UserRole.TC_COPILOT, UserRole.COPILOT_MANAGER, ]; +// TODO [DRY]: Extract a single `WORK_LAYER_ALLOWED_ROLES` constant to `src/shared/constants/roles.ts`. @ApiTags('WorkItem') @ApiBearerAuth() @Controller( '/projects/:projectId/workstreams/:workStreamId/works/:workId/workitems', ) +/** + * Alias REST controller exposing phase products as "work items" under + * `/projects/:projectId/workstreams/:workStreamId/works/:workId/workitems`. + * Used by the platform-ui Work app. Validates work-stream linkage via + * `WorkStreamService` before delegating to `PhaseProductService`. + */ export class WorkItemController { constructor( private readonly phaseProductService: PhaseProductService, private readonly workStreamService: WorkStreamService, ) {} + /** + * Validates work-stream/work linkage, then delegates list retrieval to + * `PhaseProductService`. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param workId - Work id (phase id). + * @param user - Authenticated user. + * @returns Work item DTO list. + * @throws {NotFoundException} When the work stream or linkage is missing. + */ @Get() @UseGuards(PermissionGuard) @Roles(...WORKITEM_ALLOWED_ROLES) @@ -96,6 +114,18 @@ export class WorkItemController { return this.phaseProductService.listPhaseProducts(projectId, workId, user); } + /** + * Validates work-stream/work linkage, then delegates single item retrieval to + * `PhaseProductService`. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param workId - Work id (phase id). + * @param id - Work item id (phase product id). + * @param user - Authenticated user. + * @returns Work item DTO. + * @throws {NotFoundException} When the work stream or linkage is missing. + */ @Get(':id') @UseGuards(PermissionGuard) @Roles(...WORKITEM_ALLOWED_ROLES) @@ -146,6 +176,18 @@ export class WorkItemController { ); } + /** + * Validates work-stream/work linkage, then delegates create to + * `PhaseProductService`. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param workId - Work id (phase id). + * @param dto - Work item create payload. + * @param user - Authenticated user. + * @returns Created work item DTO. + * @throws {NotFoundException} When the work stream or linkage is missing. + */ @Post() @UseGuards(PermissionGuard) @Roles(...WORKITEM_ALLOWED_ROLES) @@ -191,6 +233,19 @@ export class WorkItemController { ); } + /** + * Validates work-stream/work linkage, then delegates update to + * `PhaseProductService`. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param workId - Work id (phase id). + * @param id - Work item id (phase product id). + * @param dto - Work item update payload. + * @param user - Authenticated user. + * @returns Updated work item DTO. + * @throws {NotFoundException} When the work stream or linkage is missing. + */ @Patch(':id') @UseGuards(PermissionGuard) @Roles(...WORKITEM_ALLOWED_ROLES) @@ -239,6 +294,18 @@ export class WorkItemController { ); } + /** + * Validates work-stream/work linkage, then delegates soft delete to + * `PhaseProductService`. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param workId - Work id (phase id). + * @param id - Work item id (phase product id). + * @param user - Authenticated user. + * @returns Nothing. + * @throws {NotFoundException} When the work stream or linkage is missing. + */ @Delete(':id') @HttpCode(204) @UseGuards(PermissionGuard) diff --git a/src/api/project-attachment/dto/create-attachment.dto.ts b/src/api/project-attachment/dto/create-attachment.dto.ts index 75968e1..58eb31b 100644 --- a/src/api/project-attachment/dto/create-attachment.dto.ts +++ b/src/api/project-attachment/dto/create-attachment.dto.ts @@ -24,6 +24,7 @@ function parseOptionalInteger(value: unknown): number | undefined { return Math.trunc(parsed); } +// TODO [DRY]: Duplicated in `update-attachment.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. function parseAllowedUsers(value: unknown): number[] | undefined { if (!Array.isArray(value)) { @@ -34,7 +35,12 @@ function parseAllowedUsers(value: unknown): number[] | undefined { .map((entry) => parseOptionalInteger(entry)) .filter((entry): entry is number => typeof entry === 'number'); } +// TODO [DRY]: Duplicated in `update-attachment.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +/** + * Create payload for project attachment endpoints: + * `POST /projects/:projectId/attachments`. + */ export class CreateAttachmentDto { @ApiProperty() @IsString() @@ -76,13 +82,15 @@ export class CreateAttachmentDto { tags?: string[]; @ApiPropertyOptional({ - description: 'Required for file attachments', + description: + 'Required for file attachments. Source S3 bucket for transfer; not stored in attachment row.', }) @ValidateIf( (value: CreateAttachmentDto) => value.type === AttachmentType.file, ) @IsString() @IsNotEmpty() + // TODO [SECURITY]: Validate `s3Bucket` against an allowlist before using it as transfer source. s3Bucket?: string; @ApiPropertyOptional({ diff --git a/src/api/project-attachment/project-attachment.controller.ts b/src/api/project-attachment/project-attachment.controller.ts index 2c3a554..c17bb67 100644 --- a/src/api/project-attachment/project-attachment.controller.ts +++ b/src/api/project-attachment/project-attachment.controller.ts @@ -36,9 +36,27 @@ import { ProjectAttachmentService } from './project-attachment.service'; @ApiTags('Project Attachments') @ApiBearerAuth() @Controller('/projects/:projectId/attachments') +/** + * REST controller for project attachments under `/projects/:projectId/attachments`. + * Supports two attachment types: `file` (S3-backed) and `link` (URL reference). + * Read endpoints require `VIEW_PROJECT_ATTACHMENT`; write endpoints require + * `CREATE/EDIT/DELETE_PROJECT_ATTACHMENT`. Used by platform-ui Work, + * Engagements, and Copilots apps. + */ export class ProjectAttachmentController { constructor(private readonly service: ProjectAttachmentService) {} + /** + * Lists attachments visible to the caller. + * + * @param projectId - Project id from the route. + * @param _query - List query payload (currently unused). + * @param user - Authenticated user. + * @returns Attachment DTO list. + * @throws {BadRequestException} When project id is invalid. + * @throws {ForbiddenException} When the caller lacks read permission. + * @throws {NotFoundException} When the project is missing. + */ @Get() @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -57,9 +75,21 @@ export class ProjectAttachmentController { @Query() _query: AttachmentListQueryDto, @CurrentUser() user: JwtUser, ): Promise { + // TODO [QUALITY]: `AttachmentListQueryDto` is empty and `_query` is unused. Either add filtering fields (e.g., `type`, `category`) or remove the parameter. return this.service.listAttachments(projectId, user); } + /** + * Returns a single attachment by id. + * + * @param projectId - Project id from the route. + * @param attachmentId - Attachment id from the route. + * @param user - Authenticated user. + * @returns Attachment DTO. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks read permission. + * @throws {NotFoundException} When the project or attachment is not found. + */ @Get(':id') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -82,6 +112,17 @@ export class ProjectAttachmentController { return this.service.getAttachment(projectId, attachmentId, user); } + /** + * Creates a new project attachment. + * + * @param projectId - Project id from the route. + * @param dto - Attachment create payload. + * @param user - Authenticated user. + * @returns Created attachment DTO. + * @throws {BadRequestException} When ids or payload are invalid. + * @throws {ForbiddenException} When the caller lacks create permission. + * @throws {NotFoundException} When the project is missing. + */ @Post() @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -99,6 +140,18 @@ export class ProjectAttachmentController { return this.service.createAttachment(projectId, dto, user); } + /** + * Updates an existing attachment. + * + * @param projectId - Project id from the route. + * @param attachmentId - Attachment id from the route. + * @param dto - Attachment update payload. + * @param user - Authenticated user. + * @returns Updated attachment DTO. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks edit permission. + * @throws {NotFoundException} When the project or attachment is not found. + */ @Patch(':id') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -118,6 +171,17 @@ export class ProjectAttachmentController { return this.service.updateAttachment(projectId, attachmentId, dto, user); } + /** + * Soft deletes an attachment. + * + * @param projectId - Project id from the route. + * @param attachmentId - Attachment id from the route. + * @param user - Authenticated user. + * @returns Nothing. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks delete permission. + * @throws {NotFoundException} When the project or attachment is not found. + */ @Delete(':id') @HttpCode(204) @UseGuards(PermissionGuard) diff --git a/src/api/project-attachment/project-attachment.module.ts b/src/api/project-attachment/project-attachment.module.ts index 393aa8f..50f9ece 100644 --- a/src/api/project-attachment/project-attachment.module.ts +++ b/src/api/project-attachment/project-attachment.module.ts @@ -10,4 +10,8 @@ import { ProjectAttachmentService } from './project-attachment.service'; providers: [ProjectAttachmentService], exports: [ProjectAttachmentService], }) +/** + * NestJS feature module for project attachments. Manages file and link + * attachments, including async S3 transfer/delete via `FileService`. + */ export class ProjectAttachmentModule {} diff --git a/src/api/project-attachment/project-attachment.service.ts b/src/api/project-attachment/project-attachment.service.ts index 7b60dca..b4be629 100644 --- a/src/api/project-attachment/project-attachment.service.ts +++ b/src/api/project-attachment/project-attachment.service.ts @@ -19,6 +19,7 @@ import { PermissionService } from 'src/shared/services/permission.service'; import { hasAdminRole } from 'src/shared/utils/permission.utils'; import { AttachmentResponseDto } from './dto/attachment-response.dto'; +// TODO [DRY]: Move to `src/shared/interfaces/project-permission-context.interface.ts`. interface ProjectPermissionContext { id: bigint; members: Array<{ @@ -29,6 +30,12 @@ interface ProjectPermissionContext { } @Injectable() +/** + * Business logic for project attachments. Handles two attachment types: `link` + * (stored as-is) and `file` (transferred asynchronously from a caller-supplied + * S3 bucket to `ATTACHMENTS_S3_BUCKET`). Enforces per-attachment read access + * via `allowedUsers` and resolves creator handles via `MemberService`. + */ export class ProjectAttachmentService { private readonly logger = LoggerService.forRoot('ProjectAttachmentService'); @@ -39,6 +46,17 @@ export class ProjectAttachmentService { private readonly memberService: MemberService, ) {} + /** + * Fetches all non-deleted attachments for a project, filters by + * `allowedUsers`, and enriches creator handles. + * + * @param projectId - Project id from the route. + * @param user - Authenticated user. + * @returns Visible attachment DTO list. + * @throws {BadRequestException} When project id is invalid. + * @throws {ForbiddenException} When the caller lacks view permission. + * @throws {NotFoundException} When the project is not found. + */ async listAttachments( projectId: string, user: JwtUser, @@ -77,6 +95,18 @@ export class ProjectAttachmentService { ); } + /** + * Fetches one non-deleted attachment. For `file` attachments, adds a + * presigned download URL to the response. + * + * @param projectId - Project id from the route. + * @param attachmentId - Attachment id from the route. + * @param user - Authenticated user. + * @returns Attachment DTO. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When caller lacks view permission. + * @throws {NotFoundException} When project/attachment is missing or hidden. + */ async getAttachment( projectId: string, attachmentId: string, @@ -125,6 +155,19 @@ export class ProjectAttachmentService { return response; } + /** + * Creates link or file attachments. Link attachments are inserted directly. + * File attachments validate source metadata, create a destination path, + * persist the row, and trigger asynchronous S3 transfer. + * + * @param projectId - Project id from the route. + * @param dto - Attachment create payload. + * @param user - Authenticated user. + * @returns Created attachment DTO. + * @throws {BadRequestException} When route ids, file metadata, or path are invalid. + * @throws {ForbiddenException} When caller lacks create permission. + * @throws {NotFoundException} When project is missing. + */ async createAttachment( projectId: string, dto: CreateAttachmentDto, @@ -173,6 +216,7 @@ export class ProjectAttachmentService { 's3Bucket and contentType are required for file attachments.', ); } + // TODO [SECURITY]: Validate that `s3Bucket` is an allowed/trusted bucket (e.g., a whitelist in `APP_CONFIG`) to prevent reading from arbitrary S3 buckets. const fileName = basename(dto.path); if (!fileName) { @@ -217,6 +261,7 @@ export class ProjectAttachmentService { process.env.NODE_ENV !== 'development' || APP_CONFIG.enableFileUpload; if (shouldTransfer) { + // TODO [SECURITY/RELIABILITY]: Consider a two-phase approach: transfer first, then insert the DB record. Alternatively, add a `transferStatus` column and a background reconciliation job. void this.fileService .transferFile( dto.s3Bucket, @@ -235,6 +280,19 @@ export class ProjectAttachmentService { return response; } + /** + * Updates an attachment with creator-only enforcement unless the caller has + * `UPDATE_PROJECT_ATTACHMENT_NOT_OWN`. + * + * @param projectId - Project id from the route. + * @param attachmentId - Attachment id from the route. + * @param dto - Attachment update payload. + * @param user - Authenticated user. + * @returns Updated attachment DTO. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When caller lacks edit permissions. + * @throws {NotFoundException} When project/attachment is missing. + */ async updateAttachment( projectId: string, attachmentId: string, @@ -309,6 +367,18 @@ export class ProjectAttachmentService { return response; } + /** + * Soft deletes an attachment and, for file attachments, triggers asynchronous + * S3 deletion. + * + * @param projectId - Project id from the route. + * @param attachmentId - Attachment id from the route. + * @param user - Authenticated user. + * @returns Nothing. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When caller lacks delete permission. + * @throws {NotFoundException} When project/attachment is missing. + */ async deleteAttachment( projectId: string, attachmentId: string, @@ -355,6 +425,7 @@ export class ProjectAttachmentService { (process.env.NODE_ENV !== 'development' || APP_CONFIG.enableFileUpload); if (shouldDeleteFile) { + // TODO [SECURITY/RELIABILITY]: S3 object may not be deleted if the async call fails. Consider a deletion queue or synchronous delete. void this.fileService .deleteFile(APP_CONFIG.attachmentsS3Bucket, attachment.path) .catch((error) => { @@ -366,6 +437,14 @@ export class ProjectAttachmentService { } } + /** + * Loads project members required for permission checks. + * + * @param projectId - Parsed project id. + * @returns Permission context. + * @throws {NotFoundException} When project does not exist. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private async getProjectPermissionContext( projectId: bigint, ): Promise { @@ -398,11 +477,27 @@ export class ProjectAttachmentService { return project; } + /** + * Builds destination path for canonical attachment storage. + * + * @param projectId - Parsed project id. + * @param fileName - File basename. + * @returns Destination object key path. + */ + // TODO [BUG]: The prefix appears twice in the path. Verify the intended path structure and fix to `${prefix}/${projectId}/${fileName}`. private buildDestinationPath(projectId: bigint, fileName: string): string { const prefix = APP_CONFIG.projectAttachmentPathPrefix; return `${prefix}/${projectId.toString()}/${prefix}/${fileName}`; } + /** + * Evaluates attachment read visibility for the current user. + * + * @param attachment - Attachment row. + * @param user - Authenticated user. + * @param isAdmin - Whether caller has admin visibility. + * @returns `true` when attachment is readable. + */ private hasReadAccessToAttachment( attachment: ProjectAttachment, user: JwtUser, @@ -428,6 +523,16 @@ export class ProjectAttachmentService { return allowedUsers.includes(userId); } + /** + * Enforces a named permission using project members context. + * + * @param permission - Permission to verify. + * @param user - Authenticated user. + * @param projectMembers - Active project members. + * @returns Nothing. + * @throws {ForbiddenException} When permission is missing. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private ensureNamedPermission( permission: Permission, user: JwtUser, @@ -448,6 +553,14 @@ export class ProjectAttachmentService { } } + /** + * Determines whether caller should bypass attachment-level user filtering. + * + * @param user - Authenticated user. + * @param projectMembers - Active project members. + * @returns `true` when caller has admin-level access. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private isAdminUser( user: JwtUser, projectMembers: Array<{ @@ -466,6 +579,14 @@ export class ProjectAttachmentService { ); } + /** + * Resolves attachment download URL. In development file-upload bypass mode, + * returns raw path. + * + * @param path - Attachment storage path. + * @returns Download URL or raw path. + */ + // TODO [SECURITY]: Ensure `NODE_ENV` is not accidentally set to `development` in production deployments, as this bypasses presigned URL generation. private async resolveDownloadUrl(path: string): Promise { const shouldUseRealUploadFlow = process.env.NODE_ENV !== 'development' || APP_CONFIG.enableFileUpload; @@ -480,6 +601,13 @@ export class ProjectAttachmentService { ); } + /** + * Maps attachment row plus optional computed values into response DTO. + * + * @param attachment - Attachment row. + * @param extra - Optional URL/handle enrichment values. + * @returns Attachment response DTO. + */ private toDto( attachment: ProjectAttachment, extra: { @@ -524,6 +652,12 @@ export class ProjectAttachmentService { return response; } + /** + * Resolves creator handle map for attachment rows. + * + * @param attachments - Attachment rows. + * @returns Map of numeric user id to handle. + */ private async getCreatorHandleMap( attachments: ProjectAttachment[], ): Promise> { @@ -553,6 +687,14 @@ export class ProjectAttachmentService { return map; } + /** + * Resolves createdBy handle from enriched map with current-user fallback. + * + * @param attachment - Attachment row. + * @param creatorHandleMap - Map of creator ids to handles. + * @param user - Optional authenticated user for fallback. + * @returns Resolved handle string or empty string. + */ private resolveCreatedByHandle( attachment: ProjectAttachment, creatorHandleMap: Map, @@ -577,6 +719,15 @@ export class ProjectAttachmentService { return ''; } + /** + * Parses route ids into bigint values. + * + * @param value - Raw route id. + * @param entityName - Entity label for exception messages. + * @returns Parsed bigint id. + * @throws {BadRequestException} When parsing fails. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private parseId(value: string, entityName: string): bigint { try { return BigInt(value); @@ -585,6 +736,14 @@ export class ProjectAttachmentService { } } + /** + * Parses authenticated user id for audit columns. + * + * @param user - Authenticated user. + * @returns Numeric user id. + */ + // TODO [SECURITY]: Returning `-1` silently when `user.userId` is invalid can corrupt audit trails; throw `UnauthorizedException` instead. + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private getAuditUserId(user: JwtUser): number { const userId = Number.parseInt(String(user.userId || ''), 10); diff --git a/src/api/project-invite/dto/create-invite.dto.ts b/src/api/project-invite/dto/create-invite.dto.ts index 45a2c05..975345d 100644 --- a/src/api/project-invite/dto/create-invite.dto.ts +++ b/src/api/project-invite/dto/create-invite.dto.ts @@ -8,6 +8,12 @@ import { IsString, } from 'class-validator'; +/** + * DTO for bulk project invite creation. + * + * `emails` are supported only for `customer` role targets. + * `handles` resolve to existing Topcoder users. + */ export class CreateInviteDto { @ApiPropertyOptional({ type: [String] }) @IsOptional() diff --git a/src/api/project-invite/dto/invite-list-query.dto.ts b/src/api/project-invite/dto/invite-list-query.dto.ts index c15ace6..5465292 100644 --- a/src/api/project-invite/dto/invite-list-query.dto.ts +++ b/src/api/project-invite/dto/invite-list-query.dto.ts @@ -1,6 +1,9 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, IsString } from 'class-validator'; +/** + * Query DTO for listing project invites. + */ export class InviteListQueryDto { @ApiPropertyOptional({ description: 'CSV of additional user fields. Example: handle,email', @@ -10,6 +13,9 @@ export class InviteListQueryDto { fields?: string; } +/** + * Query DTO for getting a single project invite. + */ export class GetInviteQueryDto { @ApiPropertyOptional({ description: 'CSV of additional user fields. Example: handle,email', diff --git a/src/api/project-invite/dto/invite-response.dto.ts b/src/api/project-invite/dto/invite-response.dto.ts index 9eb76ef..beaff62 100644 --- a/src/api/project-invite/dto/invite-response.dto.ts +++ b/src/api/project-invite/dto/invite-response.dto.ts @@ -1,6 +1,9 @@ import { InviteStatus, ProjectMemberRole } from '@prisma/client'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** + * Serialized project invite response DTO. + */ export class InviteDto { @ApiProperty() id: string; @@ -30,6 +33,9 @@ export class InviteDto { updatedAt: Date; } +/** + * Represents a failed invite target in bulk invite operations. + */ export class InviteFailureDto { @ApiPropertyOptional() handle?: string; @@ -47,6 +53,12 @@ export class InviteFailureDto { role?: string; } +/** + * Bulk invite response DTO with partial-failure support. + * + * `success` always contains created invites. + * `failed` is present when some targets were rejected. + */ export class InviteBulkResponseDto { @ApiProperty({ type: () => [InviteDto] }) success: InviteDto[]; diff --git a/src/api/project-invite/dto/update-invite.dto.ts b/src/api/project-invite/dto/update-invite.dto.ts index 4505c2d..168902e 100644 --- a/src/api/project-invite/dto/update-invite.dto.ts +++ b/src/api/project-invite/dto/update-invite.dto.ts @@ -2,12 +2,20 @@ import { InviteStatus } from '@prisma/client'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsOptional, IsString } from 'class-validator'; +/** + * DTO for invite status updates. + * + * `source` currently supports `work_manager` and `copilot_portal`, which + * influence copilot application workflow transitions. + */ export class UpdateInviteDto { @ApiProperty({ enum: InviteStatus, enumName: 'InviteStatus' }) @IsEnum(InviteStatus) status: InviteStatus; @ApiPropertyOptional() + // TODO: SECURITY: `source` is free-form but drives workflow state changes. + // Validate with enum or `@IsIn(['work_manager', 'copilot_portal'])`. @IsOptional() @IsString() source?: string; diff --git a/src/api/project-invite/project-invite.controller.ts b/src/api/project-invite/project-invite.controller.ts index cf3ce29..c6aef99 100644 --- a/src/api/project-invite/project-invite.controller.ts +++ b/src/api/project-invite/project-invite.controller.ts @@ -42,11 +42,32 @@ import { ProjectInviteService } from './project-invite.service'; @ApiTags('Project Invites') @ApiBearerAuth() @Controller('/projects/:projectId/invites') +/** + * REST controller for `/projects/:projectId/invites`. + * + * `createInvites` can return HTTP 403 for partial failures while still + * returning successful records in `InviteBulkResponseDto`. + * + * `deleteInvite` performs a soft cancel by setting `status = canceled`. + */ export class ProjectInviteController { constructor(private readonly service: ProjectInviteService) {} + /** + * Lists invites for a project. + * + * @param projectId Project identifier from the route. + * @param query Optional list query fields. + * @param user Authenticated caller. + * @returns A list of invite response payloads. + * @throws {NotFoundException} If the project does not exist. + * @throws {ForbiddenException} If invite read permissions are missing. + * @throws {BadRequestException} If ids are invalid. + */ @Get() @UseGuards(PermissionGuard) + // TODO: QUALITY: `@Roles(...Object.values(UserRole))` repeats on every route; + // extract to a controller-level decorator or shared constant. @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECT_INVITES_READ, @@ -69,8 +90,26 @@ export class ProjectInviteController { return this.service.listInvites(projectId, query, user); } + /** + * Creates invites by handles/emails with partial-failure semantics. + * + * Full success returns HTTP 201. Partial success returns HTTP 403 with both + * `success` and `failed` payload sections. + * + * @param projectId Project identifier from the route. + * @param dto Invite creation payload. + * @param fields Optional CSV list of additional user fields in the response. + * @param user Authenticated caller. + * @param res Express response used to set status code. + * @returns Bulk invite response with success and optional failures. + * @throws {NotFoundException} If the project does not exist. + * @throws {ForbiddenException} If create permissions are missing. + * @throws {BadRequestException} If request payload is invalid. + */ @Post() @UseGuards(PermissionGuard) + // TODO: QUALITY: `@Roles(...Object.values(UserRole))` repeats on every route; + // extract to a controller-level decorator or shared constant. @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECT_INVITES_WRITE, @@ -111,8 +150,24 @@ export class ProjectInviteController { return response; } + /** + * Updates an invite status. + * + * @param projectId Project identifier from the route. + * @param inviteId Invite identifier from the route. + * @param dto Invite update payload. + * @param fields Optional CSV list of additional user fields in the response. + * @param user Authenticated caller. + * @returns Updated invite payload. + * @throws {NotFoundException} If the project or invite does not exist. + * @throws {ForbiddenException} If update permissions are missing. + * @throws {BadRequestException} If ids/status payload is invalid. + * @throws {ConflictException} If linked copilot opportunity is inactive. + */ @Patch(':inviteId') @UseGuards(PermissionGuard) + // TODO: QUALITY: `@Roles(...Object.values(UserRole))` repeats on every route; + // extract to a controller-level decorator or shared constant. @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECT_INVITES_WRITE, @@ -139,9 +194,22 @@ export class ProjectInviteController { return this.service.updateInvite(projectId, inviteId, dto, user, fields); } + /** + * Cancels an invite by setting its status to `canceled`. + * + * @param projectId Project identifier from the route. + * @param inviteId Invite identifier from the route. + * @param user Authenticated caller. + * @returns Resolves when cancellation is complete. + * @throws {NotFoundException} If the project or invite does not exist. + * @throws {ForbiddenException} If delete permissions are missing. + * @throws {BadRequestException} If ids are invalid. + */ @Delete(':inviteId') @HttpCode(204) @UseGuards(PermissionGuard) + // TODO: QUALITY: `@Roles(...Object.values(UserRole))` repeats on every route; + // extract to a controller-level decorator or shared constant. @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECT_INVITES_WRITE, @@ -167,8 +235,22 @@ export class ProjectInviteController { await this.service.deleteInvite(projectId, inviteId, user); } + /** + * Gets a single invite by id. + * + * @param projectId Project identifier from the route. + * @param inviteId Invite identifier from the route. + * @param query Optional response field selection. + * @param user Authenticated caller. + * @returns Invite response payload. + * @throws {NotFoundException} If the project or invite does not exist. + * @throws {ForbiddenException} If read permissions are missing. + * @throws {BadRequestException} If ids are invalid. + */ @Get(':inviteId') @UseGuards(PermissionGuard) + // TODO: QUALITY: `@Roles(...Object.values(UserRole))` repeats on every route; + // extract to a controller-level decorator or shared constant. @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECT_INVITES_READ, diff --git a/src/api/project-invite/project-invite.service.ts b/src/api/project-invite/project-invite.service.ts index 4ce4063..acbe7b9 100644 --- a/src/api/project-invite/project-invite.service.ts +++ b/src/api/project-invite/project-invite.service.ts @@ -51,6 +51,21 @@ interface InviteTargetByEmail { email: string; } +/** + * Manages project invite lifecycle across creation, updates, reads, and cancel. + * + * The service supports bulk invite creation from handles/emails, invite state + * transitions, and copilot workflow side effects. + * + * Accepting or request-approving an invite auto-creates a `ProjectMember` + * record when needed and updates linked copilot application state. + * + * Refusing or canceling an invite can move linked applications back to + * `pending` when no open invites remain. + * + * `PROJECT_MEMBER_ADDED` events are published when invite acceptance yields a + * project member. + */ @Injectable() export class ProjectInviteService { private readonly logger = LoggerService.forRoot('ProjectInviteService'); @@ -63,6 +78,18 @@ export class ProjectInviteService { private readonly emailService: EmailService, ) {} + /** + * Creates invites for project users identified by handles or emails. + * + * @param projectId Project identifier. + * @param dto Invite creation payload. + * @param user Authenticated caller. + * @param fields Optional CSV list of additional response fields. + * @returns Bulk invite response with `success` and optional `failed` targets. + * @throws {NotFoundException} If the project is not found. + * @throws {ForbiddenException} If role-specific invite permission is missing. + * @throws {BadRequestException} If handles/emails are both missing. + */ async createInvites( projectId: string, dto: CreateInviteDto, @@ -241,6 +268,20 @@ export class ProjectInviteService { }; } + /** + * Updates invite status and applies member/copilot side effects. + * + * @param projectId Project identifier. + * @param inviteId Invite identifier. + * @param dto Invite update payload. + * @param user Authenticated caller. + * @param fields Optional CSV list of additional response fields. + * @returns The updated invite response payload. + * @throws {NotFoundException} If the project or invite is not found. + * @throws {ForbiddenException} If update permission is missing. + * @throws {BadRequestException} If status/id/user resolution is invalid. + * @throws {ConflictException} If linked opportunity is not active. + */ async updateInvite( projectId: string, inviteId: string, @@ -253,6 +294,9 @@ export class ProjectInviteService { 'Cannot change invite status to "canceled". Delete the invite instead.', ); } + // TODO: SECURITY: No explicit state-machine transition guard exists here. + // Add allowed transitions (for example: + // pending -> accepted|refused and requested -> request_approved|refused). const parsedProjectId = this.parseId(projectId, 'Project'); const parsedInviteId = this.parseId(inviteId, 'Invite'); @@ -328,6 +372,8 @@ export class ProjectInviteService { await this.ensureActiveOpportunity(invite.applicationId); } + // TODO: QUALITY: `work_manager` is a magic string. Extract a named + // constant and reuse it where source comparisons occur. const source = dto.source || 'work_manager'; const { updatedInvite, projectMember } = await this.prisma.$transaction( @@ -385,6 +431,9 @@ export class ProjectInviteService { source, ); } else { + // TODO: SECURITY: This currently cancels all project copilot + // requests when `applicationId` is null. Scope cancellation to only + // superseded requests. await this.cancelProjectCopilotWorkflow(tx, parsedProjectId); } } else if ( @@ -414,6 +463,17 @@ export class ProjectInviteService { return this.hydrateInviteResponse(updatedInvite, fields); } + /** + * Soft-cancels an invite by setting status to `canceled`. + * + * @param projectId Project identifier. + * @param inviteId Invite identifier. + * @param user Authenticated caller. + * @returns Resolves when cancel operation and side effects complete. + * @throws {NotFoundException} If the project or invite is not found. + * @throws {ForbiddenException} If delete permission is missing. + * @throws {BadRequestException} If ids are invalid. + */ async deleteInvite( projectId: string, inviteId: string, @@ -501,6 +561,16 @@ export class ProjectInviteService { }); } + /** + * Lists project invites visible to the current user. + * + * @param projectId Project identifier. + * @param query Invite list query. + * @param user Authenticated caller. + * @returns Visible invite responses. + * @throws {NotFoundException} If the project is not found. + * @throws {ForbiddenException} If no read permission is granted. + */ async listInvites( projectId: string, query: InviteListQueryDto, @@ -564,6 +634,17 @@ export class ProjectInviteService { return this.hydrateInviteListResponse(visibleInvites, query.fields); } + /** + * Gets a single invite if the current caller can read it. + * + * @param projectId Project identifier. + * @param inviteId Invite identifier. + * @param query Invite query. + * @param user Authenticated caller. + * @returns Invite response payload. + * @throws {NotFoundException} If the project or invite is not found. + * @throws {ForbiddenException} If read permission is missing. + */ async getInvite( projectId: string, inviteId: string, @@ -633,6 +714,16 @@ export class ProjectInviteService { return this.hydrateInviteResponse(invite, query.fields); } + /** + * Resolves invite targets from provided handles. + * + * @param dto Invite creation payload. + * @param failed Mutable array collecting failed target reasons. + * @param memberUserIds Existing member user ids. + * @param existingInvites Existing open invites. + * @returns User-backed invite targets. + * @throws {BadRequestException} When handle payload normalization fails. + */ private async resolveHandleTargets( dto: CreateInviteDto, failed: InviteFailureDto[], @@ -648,6 +739,8 @@ export class ProjectInviteService { ); const lowerCaseHandles = dto.handles.map((handle) => handle.toLowerCase()); + // TODO: DRY: `(foundUser as any).handleLower` and `(user as any).userId` + // rely on unsafe casts. Type `MemberDetail` to include these fields. const filteredUsers = foundUsers.filter((foundUser) => lowerCaseHandles.includes( String( @@ -713,6 +806,16 @@ export class ProjectInviteService { return targets; } + /** + * Resolves invite targets from provided emails. + * + * @param dto Invite creation payload. + * @param failed Mutable array collecting failed target reasons. + * @param memberUserIds Existing member user ids. + * @param existingInvites Existing open invites. + * @returns User-backed targets and email-only targets. + * @throws {BadRequestException} If email validation or parsing fails. + */ private async resolveEmailTargets( dto: CreateInviteDto, failed: InviteFailureDto[], @@ -813,6 +916,17 @@ export class ProjectInviteService { }; } + /** + * Validates that user targets can be invited for the requested project role. + * + * @param role Target invite role. + * @param targets Candidate user targets. + * @param failed Mutable array collecting failed target reasons. + * @returns Only targets that satisfy role constraints. + * @throws {ForbiddenException} Role validation may fail at downstream calls. + */ + // TODO: QUALITY: This calls `memberService.getUserRoles` sequentially in a + // loop. Batch requests or use `Promise.all` to reduce latency. private async validateUserTargetsByRole( role: ProjectMemberRole, targets: InviteTargetByUser[], @@ -843,6 +957,13 @@ export class ProjectInviteService { return validTargets; } + /** + * Ensures the application points to an active copilot opportunity. + * + * @param applicationId Copilot application identifier. + * @returns Resolves when the opportunity is active. + * @throws {ConflictException} If application/opportunity is missing/inactive. + */ private async ensureActiveOpportunity(applicationId: bigint): Promise { const application = await this.prisma.copilotApplication.findFirst({ where: { @@ -872,6 +993,15 @@ export class ProjectInviteService { } } + /** + * Updates copilot application/opportunity/request states by invite source. + * + * @param tx Active transaction client. + * @param applicationId Application identifier. + * @param source Invite update source value. + * @returns Resolves after related entities are updated. + * @throws {Prisma.PrismaClientKnownRequestError} If transaction updates fail. + */ private async updateCopilotApplicationStateBySource( tx: Prisma.TransactionClient, applicationId: bigint, @@ -943,6 +1073,18 @@ export class ProjectInviteService { } } + /** + * Cancels project-level copilot workflow records. + * + * @param tx Active transaction client. + * @param projectId Project identifier. + * @returns Resolves when request/opportunity/application/invite status + * updates complete. + * @throws {Prisma.PrismaClientKnownRequestError} If transaction updates fail. + */ + // TODO: SECURITY: This can cancel all copilot requests in the project when + // invoked from invite acceptance without `applicationId`. Narrow the scope to + // superseded workflow records only. private async cancelProjectCopilotWorkflow( tx: Prisma.TransactionClient, projectId: bigint, @@ -1043,6 +1185,14 @@ export class ProjectInviteService { } } + /** + * Resets an application to pending when no open invites reference it. + * + * @param tx Active transaction client. + * @param applicationId Application identifier. + * @returns Resolves when the application status is reconciled. + * @throws {Prisma.PrismaClientKnownRequestError} If transaction updates fail. + */ private async updateApplicationToPendingIfNoOpenInvites( tx: Prisma.TransactionClient, applicationId: bigint, @@ -1069,6 +1219,17 @@ export class ProjectInviteService { } } + /** + * Enforces role-specific permissions when deleting another user's invite. + * + * @param role Invite target role. + * @param user Authenticated caller. + * @param projectMembers Active project members for permission evaluation. + * @returns Nothing. + * @throws {ForbiddenException} If required delete permission is missing. + */ + // TODO: SECURITY: `projectMembers` is typed as `any[]`, which bypasses type + // safety and can hide invalid data shape issues. Use `ProjectMember[]`. private ensureDeleteInvitePermission( role: ProjectMemberRole, user: JwtUser, @@ -1115,6 +1276,14 @@ export class ProjectInviteService { } } + /** + * Resolves user id for invite acceptance from invite or current user context. + * + * @param invite Invite entity. + * @param user Authenticated caller. + * @returns Resolved user id or `null`. + * @throws {BadRequestException} When caller id cannot be parsed. + */ private resolveInviteUserId( invite: ProjectMemberInvite, user: JwtUser, @@ -1131,6 +1300,14 @@ export class ProjectInviteService { return null; } + /** + * Determines whether the invite belongs to the current user. + * + * @param invite Invite entity. + * @param user Authenticated caller. + * @returns `true` when owned by user id or matched email. + * @throws {BadRequestException} When caller identity cannot be parsed. + */ private isOwnInvite(invite: ProjectMemberInvite, user: JwtUser): boolean { const currentUserId = this.parseOptionalId(user.userId); const currentUserEmail = this.getUserEmail(user); @@ -1148,6 +1325,14 @@ export class ProjectInviteService { return false; } + /** + * Hydrates invite list responses with requested user profile fields. + * + * @param invites Invite entities. + * @param fields Optional CSV response fields. + * @returns Hydrated invite response list. + * @throws {BadRequestException} When field parsing fails. + */ private async hydrateInviteListResponse( invites: ProjectMemberInvite[], fields?: string, @@ -1173,6 +1358,14 @@ export class ProjectInviteService { ); } + /** + * Hydrates a single invite response payload. + * + * @param invite Invite entity. + * @param fields Optional CSV response fields. + * @returns Hydrated invite response. + * @throws {BadRequestException} When field parsing fails. + */ private async hydrateInviteResponse( invite: ProjectMemberInvite, fields?: string, @@ -1181,6 +1374,16 @@ export class ProjectInviteService { return response; } + /** + * Parses and validates required numeric id values. + * + * @param value Raw id value. + * @param label Friendly name used in error message text. + * @returns Parsed bigint id. + * @throws {BadRequestException} If value is not numeric. + */ + // TODO: DRY: `parseId` is duplicated across project member, invite, and + // setting services; consolidate into shared helper/base service. private parseId(value: string, label: string): bigint { const normalized = String(value || '').trim(); if (!/^\d+$/.test(normalized)) { @@ -1190,6 +1393,15 @@ export class ProjectInviteService { return BigInt(normalized); } + /** + * Parses optional id values into bigint. + * + * @param value Raw id-like value. + * @returns Parsed bigint or `null` when missing/invalid. + * @throws {BadRequestException} If parsing fails unexpectedly. + */ + // TODO: DRY: `parseOptionalId` follows patterns duplicated in other services; + // consolidate into shared helper/base service. private parseOptionalId( value: string | number | bigint | null | undefined, ): bigint | null { @@ -1205,6 +1417,15 @@ export class ProjectInviteService { return BigInt(normalized); } + /** + * Resolves authenticated caller id as a normalized string. + * + * @param user Authenticated caller. + * @returns Trimmed caller id. + * @throws {ForbiddenException} If caller id is missing. + */ + // TODO: DRY: `getActorUserId` is duplicated across project member, invite, + // and setting services; centralize. private getActorUserId(user: JwtUser): string { if (!user?.userId || String(user.userId).trim().length === 0) { throw new ForbiddenException('Authenticated user id is missing.'); @@ -1213,6 +1434,15 @@ export class ProjectInviteService { return String(user.userId).trim(); } + /** + * Resolves authenticated caller id as numeric audit id. + * + * @param user Authenticated caller. + * @returns Numeric audit user id. + * @throws {ForbiddenException} If caller id is missing or non-numeric. + */ + // TODO: DRY: `getAuditUserId` is duplicated across project member, invite, + // and setting services; centralize. private getAuditUserId(user: JwtUser): number { const parsedUserId = Number.parseInt(this.getActorUserId(user), 10); @@ -1223,6 +1453,14 @@ export class ProjectInviteService { return parsedUserId; } + /** + * Parses CSV field selection for invite response hydration. + * + * @param fields Raw CSV field list. + * @returns Normalized field names. + * @throws {BadRequestException} If field parsing fails. + */ + // TODO: DRY: `parseFields` is duplicated across services; centralize. private parseFields(fields?: string): string[] { if (!fields || fields.trim().length === 0) { return []; @@ -1234,6 +1472,15 @@ export class ProjectInviteService { .filter((field) => field.length > 0); } + /** + * Extracts user email claim from token payload. + * + * @param user Authenticated caller. + * @returns Lower-cased email if present. + * @throws {BadRequestException} If payload claims are malformed. + */ + // TODO: SECURITY: Iterating all payload keys ending with `email` is fragile + // and may match unintended claims. Use explicit claim key(s). private getUserEmail(user: JwtUser): string | undefined { const payload = user.tokenPayload || {}; @@ -1249,6 +1496,14 @@ export class ProjectInviteService { return undefined; } + /** + * Resolves UNIQUE_GMAIL_VALIDATION runtime toggle. + * + * @returns `true` when unique Gmail validation is enabled. + * @throws {Error} Never intentionally thrown. + */ + // TODO: SECURITY: Reading `process.env` at call-time should be replaced with + // injected `ConfigService` configuration for testability and consistency. private isUniqueGmailValidationEnabled(): boolean { return ( String(process.env.UNIQUE_GMAIL_VALIDATION || 'false').toLowerCase() === @@ -1256,6 +1511,15 @@ export class ProjectInviteService { ); } + /** + * Normalizes Prisma entities for API/event payload serialization. + * + * @param payload Input payload. + * @returns Payload with bigint and decimal values normalized. + * @throws {TypeError} If recursive traversal fails. + */ + // TODO: DRY: `normalizeEntity` is identical to member service implementation; + // extract to shared utility (`src/shared/utils/entity.utils.ts`). private normalizeEntity(payload: T): T { const walk = (input: unknown): unknown => { if (typeof input === 'bigint') { @@ -1289,6 +1553,16 @@ export class ProjectInviteService { return walk(payload) as T; } + /** + * Publishes member event payloads and logs failures. + * + * @param topic Kafka topic. + * @param payload Event payload. + * @returns Nothing. + * @throws {Error} Publisher errors are caught and logged. + */ + // TODO: DRY: `publishMember` is near-identical to member service + // `publishEvent`; consolidate into shared helper. private publishMember(topic: string, payload: unknown): void { void publishMemberEvent(topic, payload).catch((error) => { this.logger.error( diff --git a/src/api/project-member/dto/create-member.dto.ts b/src/api/project-member/dto/create-member.dto.ts index dba613f..4e8b1cf 100644 --- a/src/api/project-member/dto/create-member.dto.ts +++ b/src/api/project-member/dto/create-member.dto.ts @@ -3,6 +3,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEnum, IsNumber, IsOptional } from 'class-validator'; +/** + * Parses optional integer-like input values from query/body payloads. + * + * @param value Raw unknown value from the incoming payload. + * @returns A truncated number when parseable, otherwise `undefined`. + * @throws {TypeError} Propagates only if value conversion throws unexpectedly. + */ function parseOptionalInteger(value: unknown): number | undefined { if (typeof value === 'undefined' || value === null || value === '') { return undefined; @@ -16,6 +23,12 @@ function parseOptionalInteger(value: unknown): number | undefined { return Math.trunc(parsed); } +/** + * DTO for creating a project member. + * + * Validation requires `role`, while the service still defensively falls back + * to `getDefaultProjectRole` when role is missing in runtime payloads. + */ export class CreateMemberDto { @ApiPropertyOptional({ description: 'User id. Defaults to current user.' }) @IsOptional() diff --git a/src/api/project-member/dto/member-list-query.dto.ts b/src/api/project-member/dto/member-list-query.dto.ts index f4a6b6d..a635bb0 100644 --- a/src/api/project-member/dto/member-list-query.dto.ts +++ b/src/api/project-member/dto/member-list-query.dto.ts @@ -2,6 +2,12 @@ import { ProjectMemberRole } from '@prisma/client'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsOptional, IsString } from 'class-validator'; +/** + * Query DTO for listing project members. + * + * `fields` accepts a CSV list such as + * `handle,email,firstName,lastName`. + */ export class MemberListQueryDto { @ApiPropertyOptional({ enum: ProjectMemberRole, @@ -19,6 +25,12 @@ export class MemberListQueryDto { fields?: string; } +/** + * Query DTO for fetching a single project member. + * + * `fields` accepts a CSV list such as + * `handle,email,firstName,lastName`. + */ export class GetMemberQueryDto { @ApiPropertyOptional({ description: 'CSV of additional user fields. Example: handle,email', diff --git a/src/api/project-member/dto/member-response.dto.ts b/src/api/project-member/dto/member-response.dto.ts index a4f3c84..8aa5930 100644 --- a/src/api/project-member/dto/member-response.dto.ts +++ b/src/api/project-member/dto/member-response.dto.ts @@ -1,6 +1,12 @@ import { ProjectMemberRole } from '@prisma/client'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** + * Serialized project member response DTO. + * + * `handle` and `email` are only populated when the caller requests them via + * `?fields=handle,email`. + */ export class MemberResponseDto { @ApiProperty() id: string; diff --git a/src/api/project-member/dto/update-member.dto.ts b/src/api/project-member/dto/update-member.dto.ts index 12f5b37..57ea9d8 100644 --- a/src/api/project-member/dto/update-member.dto.ts +++ b/src/api/project-member/dto/update-member.dto.ts @@ -2,6 +2,12 @@ import { ProjectMemberRole } from '@prisma/client'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; +/** + * DTO for updating an existing project member. + * + * `role` is required (no `@IsOptional()`), so callers must always provide it + * even when they only want to update `isPrimary`. + */ export class UpdateMemberDto { @ApiProperty({ enum: ProjectMemberRole, enumName: 'ProjectMemberRole' }) @IsEnum(ProjectMemberRole) @@ -15,6 +21,8 @@ export class UpdateMemberDto { @ApiPropertyOptional({ description: 'Supported value: complete-copilot-requests', }) + // TODO: QUALITY: `action` is free-form; only + // `complete-copilot-requests` is documented. Use enum or `@IsIn([...])`. @IsOptional() @IsString() action?: string; diff --git a/src/api/project-member/project-member.controller.ts b/src/api/project-member/project-member.controller.ts index 072f8f4..0df314b 100644 --- a/src/api/project-member/project-member.controller.ts +++ b/src/api/project-member/project-member.controller.ts @@ -40,11 +40,35 @@ import { ProjectMemberService } from './project-member.service'; @ApiTags('Project Members') @ApiBearerAuth() @Controller('/projects/:projectId/members') +/** + * REST controller for `/projects/:projectId/members`. + * + * Every route enforces `PermissionGuard` and delegates business logic to + * `ProjectMemberService`. + * + * Write routes use the scopes `PROJECT_MEMBERS_WRITE`, + * `PROJECT_MEMBERS_ALL`, and `CONNECT_PROJECT_ADMIN`. + */ export class ProjectMemberController { constructor(private readonly service: ProjectMemberService) {} + /** + * Adds a member to the target project. + * + * @param projectId Project identifier from the route. + * @param dto Member creation payload. + * @param fields Optional CSV list of extra member profile fields to include. + * @param user Authenticated caller. + * @returns The created member response payload. + * @throws {NotFoundException} If the project does not exist. + * @throws {ForbiddenException} If the caller lacks add-member permissions. + * @throws {BadRequestException} If ids/role are invalid. + * @throws {ConflictException} If the target user is already a project member. + */ @Post() @UseGuards(PermissionGuard) + // TODO: QUALITY: `@Roles(...Object.values(UserRole))` repeats on every route; + // extract to a controller-level decorator or shared constant. @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECT_MEMBERS_WRITE, @@ -69,8 +93,24 @@ export class ProjectMemberController { return this.service.addMember(projectId, dto, user, fields); } + /** + * Updates an existing project member. + * + * @param projectId Project identifier from the route. + * @param id Project member identifier from the route. + * @param dto Member update payload. + * @param fields Optional CSV list of extra member profile fields to include. + * @param user Authenticated caller. + * @returns The updated member response payload. + * @throws {NotFoundException} If the project or member does not exist. + * @throws {ForbiddenException} If the caller lacks update permissions. + * @throws {BadRequestException} If ids are invalid. + * @throws {ConflictException} If an update conflicts with existing state. + */ @Patch(':id') @UseGuards(PermissionGuard) + // TODO: QUALITY: `@Roles(...Object.values(UserRole))` repeats on every route; + // extract to a controller-level decorator or shared constant. @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECT_MEMBERS_WRITE, @@ -97,9 +137,23 @@ export class ProjectMemberController { return this.service.updateMember(projectId, id, dto, user, fields); } + /** + * Soft-deletes a project member. + * + * @param projectId Project identifier from the route. + * @param id Project member identifier from the route. + * @param user Authenticated caller. + * @returns Resolves when deletion completes. + * @throws {NotFoundException} If the project or member does not exist. + * @throws {ForbiddenException} If the caller lacks delete permissions. + * @throws {BadRequestException} If ids are invalid. + * @throws {ConflictException} If deletion conflicts with current state. + */ @Delete(':id') @HttpCode(204) @UseGuards(PermissionGuard) + // TODO: QUALITY: `@Roles(...Object.values(UserRole))` repeats on every route; + // extract to a controller-level decorator or shared constant. @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECT_MEMBERS_WRITE, @@ -123,8 +177,22 @@ export class ProjectMemberController { await this.service.deleteMember(projectId, id, user); } + /** + * Lists members for a project. + * + * @param projectId Project identifier from the route. + * @param query Optional list filters and response field selection. + * @param user Authenticated caller. + * @returns A list of serialized member response payloads. + * @throws {NotFoundException} If the project does not exist. + * @throws {ForbiddenException} If the caller lacks read permissions. + * @throws {BadRequestException} If ids are invalid. + * @throws {ConflictException} If query execution conflicts with current state. + */ @Get() @UseGuards(PermissionGuard) + // TODO: QUALITY: `@Roles(...Object.values(UserRole))` repeats on every route; + // extract to a controller-level decorator or shared constant. @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECT_MEMBERS_READ, @@ -143,8 +211,23 @@ export class ProjectMemberController { return this.service.listMembers(projectId, query, user); } + /** + * Gets a single member by id. + * + * @param projectId Project identifier from the route. + * @param id Project member identifier from the route. + * @param query Optional response field selection. + * @param user Authenticated caller. + * @returns The serialized member response payload. + * @throws {NotFoundException} If the project or member does not exist. + * @throws {ForbiddenException} If the caller lacks read permissions. + * @throws {BadRequestException} If ids are invalid. + * @throws {ConflictException} If retrieval conflicts with current state. + */ @Get(':id') @UseGuards(PermissionGuard) + // TODO: QUALITY: `@Roles(...Object.values(UserRole))` repeats on every route; + // extract to a controller-level decorator or shared constant. @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECT_MEMBERS_READ, diff --git a/src/api/project-member/project-member.service.ts b/src/api/project-member/project-member.service.ts index 8e071e5..f3da901 100644 --- a/src/api/project-member/project-member.service.ts +++ b/src/api/project-member/project-member.service.ts @@ -34,6 +34,18 @@ import { } from 'src/shared/utils/member.utils'; import { publishMemberEvent } from 'src/shared/utils/event.utils'; +/** + * Manages the project member lifecycle: add, update, delete, list, and get. + * + * The service enforces permissions through `PermissionService`, validates + * Topcoder roles through `MemberService`, and publishes member events through + * `publishMemberEvent`. + * + * Member creation also cancels open invites for the same user in a + * transaction. A manager-to-copilot promotion with + * `action: complete-copilot-requests` completes open copilot workflow records + * atomically. + */ @Injectable() export class ProjectMemberService { private readonly logger = LoggerService.forRoot('ProjectMemberService'); @@ -44,6 +56,19 @@ export class ProjectMemberService { private readonly memberService: MemberService, ) {} + /** + * Adds a member to a project. + * + * @param projectId Project identifier. + * @param dto Member creation payload. + * @param user Authenticated caller. + * @param fields Optional CSV list of additional member profile fields. + * @returns The created member response enriched with requested fields. + * @throws {NotFoundException} If the project cannot be found. + * @throws {ForbiddenException} If permissions or role constraints fail. + * @throws {BadRequestException} If ids are invalid or role cannot be resolved. + * @throws {ConflictException} If the target user is already a member. + */ async addMember( projectId: string, dto: CreateMemberDto, @@ -171,6 +196,19 @@ export class ProjectMemberService { return this.hydrateMemberResponse(createdMember, fields); } + /** + * Updates a project member. + * + * @param projectId Project identifier. + * @param memberId Member identifier. + * @param dto Member update payload. + * @param user Authenticated caller. + * @param fields Optional CSV list of additional member profile fields. + * @returns The updated member response enriched with requested fields. + * @throws {NotFoundException} If the project or member cannot be found. + * @throws {ForbiddenException} If permissions or role constraints fail. + * @throws {BadRequestException} If ids are invalid. + */ async updateMember( projectId: string, memberId: string, @@ -288,6 +326,17 @@ export class ProjectMemberService { return this.hydrateMemberResponse(updatedMember, fields); } + /** + * Deletes a project member by soft-deleting the member record. + * + * @param projectId Project identifier. + * @param memberId Member identifier. + * @param user Authenticated caller. + * @returns Resolves when deletion logic and follow-up updates complete. + * @throws {NotFoundException} If the project or member cannot be found. + * @throws {ForbiddenException} If role-specific delete permission is missing. + * @throws {BadRequestException} If ids are invalid. + */ async deleteMember( projectId: string, memberId: string, @@ -387,6 +436,17 @@ export class ProjectMemberService { ); } + /** + * Lists project members optionally filtered by role and enriched fields. + * + * @param projectId Project identifier. + * @param query Member list query parameters. + * @param user Authenticated caller. + * @returns A list of serialized member records. + * @throws {NotFoundException} If the project cannot be found. + * @throws {ForbiddenException} If read permission is missing. + * @throws {BadRequestException} If ids are invalid. + */ async listMembers( projectId: string, query: MemberListQueryDto, @@ -442,6 +502,18 @@ export class ProjectMemberService { return this.hydrateMemberListResponse(members, query.fields); } + /** + * Gets a single member by project and member ids. + * + * @param projectId Project identifier. + * @param memberId Member identifier. + * @param query Member query parameters. + * @param user Authenticated caller. + * @returns A serialized member record. + * @throws {NotFoundException} If the project or member cannot be found. + * @throws {ForbiddenException} If read permission is missing. + * @throws {BadRequestException} If ids are invalid. + */ async getMember( projectId: string, memberId: string, @@ -498,6 +570,18 @@ export class ProjectMemberService { return this.hydrateMemberResponse(member, query.fields); } + /** + * Enforces role-specific permissions when deleting another member. + * + * @param role Role of the member being deleted. + * @param user Authenticated caller. + * @param projectMembers Current active project members for permission checks. + * @returns Nothing. + * @throws {ForbiddenException} If role-specific delete permission is missing. + */ + // TODO: DRY: This role-branch permission check mirrors + // `ensureDeleteInvitePermission`; extract shared + // `assertRoleDeletePermission(role, user, members, permMap)` helper. private ensureDeletePermission( role: ProjectMemberRole, user: JwtUser, @@ -544,6 +628,19 @@ export class ProjectMemberService { } } + /** + * Completes open copilot workflow records for a promoted copilot member. + * + * @param tx Active transaction client. + * @param projectId Project identifier. + * @param memberUserId User id of the promoted member. + * @returns Resolves when workflow updates are applied. + * @throws {Prisma.PrismaClientKnownRequestError} If transaction operations + * fail due to database constraints. + */ + // TODO: QUALITY: This intentionally performs sequential queries in one + // transaction for atomicity, but can create N+1-like load with large + // `requestIds`; consider batching opportunity/application fetches. private async completeCopilotRequests( tx: Prisma.TransactionClient, projectId: bigint, @@ -666,6 +763,14 @@ export class ProjectMemberService { } } + /** + * Enriches a list of members with optional profile fields. + * + * @param members Member entities. + * @param fields Optional CSV list of additional profile fields. + * @returns Enriched member response objects. + * @throws {BadRequestException} If field parsing fails validation. + */ private async hydrateMemberListResponse( members: ProjectMember[], fields?: string, @@ -686,6 +791,14 @@ export class ProjectMemberService { ); } + /** + * Enriches a single member with optional profile fields. + * + * @param member Member entity. + * @param fields Optional CSV list of additional profile fields. + * @returns Enriched member response object. + * @throws {BadRequestException} If field parsing fails validation. + */ private async hydrateMemberResponse( member: ProjectMember, fields?: string, @@ -706,6 +819,16 @@ export class ProjectMemberService { return response; } + /** + * Parses and validates a numeric id value. + * + * @param value Raw id string. + * @param label Friendly entity label for error messages. + * @returns Parsed bigint id. + * @throws {BadRequestException} If the id is not numeric. + */ + // TODO: DRY: `parseId` is duplicated across project member, invite, and + // setting flows; extract a shared service helper. private parseId(value: string, label: string): bigint { const normalized = String(value || '').trim(); if (!/^\d+$/.test(normalized)) { @@ -715,6 +838,15 @@ export class ProjectMemberService { return BigInt(normalized); } + /** + * Resolves the authenticated actor id as a trimmed string. + * + * @param user Authenticated caller. + * @returns Normalized user id string. + * @throws {ForbiddenException} If the user id is missing. + */ + // TODO: DRY: `getActorUserId` is duplicated across project member, invite, + // and setting flows; extract a shared service helper. private getActorUserId(user: JwtUser): string { if (!user?.userId || String(user.userId).trim().length === 0) { throw new ForbiddenException('Authenticated user id is missing.'); @@ -723,6 +855,15 @@ export class ProjectMemberService { return String(user.userId).trim(); } + /** + * Resolves the authenticated actor id as a numeric audit id. + * + * @param user Authenticated caller. + * @returns Numeric user id used for audit columns. + * @throws {ForbiddenException} If the user id is missing or non-numeric. + */ + // TODO: DRY: `getAuditUserId` is duplicated across project member, invite, + // and setting flows; extract a shared service helper. private getAuditUserId(user: JwtUser): number { const parsedUserId = Number.parseInt(this.getActorUserId(user), 10); @@ -733,6 +874,15 @@ export class ProjectMemberService { return parsedUserId; } + /** + * Parses a CSV list of additional member fields. + * + * @param fields Raw CSV string. + * @returns Normalized list of field names. + * @throws {BadRequestException} When field input fails validation. + */ + // TODO: DRY: `parseFields` is duplicated across project member, invite, and + // setting flows; extract a shared service helper. private parseFields(fields?: string): string[] { if (!fields || fields.trim().length === 0) { return []; @@ -744,6 +894,15 @@ export class ProjectMemberService { .filter((field) => field.length > 0); } + /** + * Converts entity payloads into API-safe primitives. + * + * @param payload Payload containing Prisma entities. + * @returns Payload with bigint/decimal values normalized. + * @throws {TypeError} If recursive traversal encounters unsupported values. + */ + // TODO: DRY: `normalizeEntity` is identical to the implementation in + // `project-invite.service.ts`; extract to `src/shared/utils/entity.utils.ts`. private normalizeEntity(payload: T): T { const walk = (input: unknown): unknown => { if (typeof input === 'bigint') { @@ -776,6 +935,16 @@ export class ProjectMemberService { return walk(payload) as T; } + /** + * Publishes a member-related Kafka event with defensive logging. + * + * @param topic Kafka topic name. + * @param payload Event payload. + * @returns Nothing. + * @throws {Error} The underlying publisher may reject and is handled here. + */ + // TODO: DRY: `publishEvent` is near-identical to `publishMember` in + // `project-invite.service.ts`; consolidate into shared event helper. private publishEvent(topic: string, payload: unknown): void { void publishMemberEvent(topic, payload).catch((error) => { this.logger.error( diff --git a/src/api/project-phase/dto/create-phase.dto.ts b/src/api/project-phase/dto/create-phase.dto.ts index 96e483b..b89ec4f 100644 --- a/src/api/project-phase/dto/create-phase.dto.ts +++ b/src/api/project-phase/dto/create-phase.dto.ts @@ -26,6 +26,7 @@ function parseOptionalNumber(value: unknown): number | undefined { return parsed; } +// TODO [DRY]: Duplicated in `create-phase-product.dto.ts` and `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. function parseOptionalInteger(value: unknown): number | undefined { const parsed = parseOptionalNumber(value); @@ -36,6 +37,7 @@ function parseOptionalInteger(value: unknown): number | undefined { return Math.trunc(parsed); } +// TODO [DRY]: Duplicated in `create-phase-product.dto.ts` and `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. function parseOptionalIntegerArray(value: unknown): number[] | undefined { if (!Array.isArray(value)) { @@ -46,19 +48,25 @@ function parseOptionalIntegerArray(value: unknown): number[] | undefined { .map((entry) => parseOptionalInteger(entry)) .filter((entry): entry is number => typeof entry === 'number'); } +// TODO [DRY]: Duplicated in `create-phase-product.dto.ts` and `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +/** + * Create payload for project phase creation endpoints: + * `POST /projects/:projectId/phases` and + * `POST /projects/:projectId/workstreams/:workStreamId/works`. + */ export class CreatePhaseDto { - @ApiProperty() + @ApiProperty({ description: 'Human-readable phase/work name.' }) @IsString() @IsNotEmpty() name: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Optional phase description.' }) @IsOptional() @IsString() description?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Optional requirements narrative.' }) @IsOptional() @IsString() requirements?: string; @@ -70,40 +78,46 @@ export class CreatePhaseDto { @IsEnum(ProjectStatus) status: ProjectStatus; - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Planned phase start date.' }) @IsOptional() @Type(() => Date) @IsDate() startDate?: Date; - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Planned phase end date.' }) @IsOptional() @Type(() => Date) @IsDate() endDate?: Date; - @ApiPropertyOptional({ minimum: 0 }) + @ApiPropertyOptional({ minimum: 0, description: 'Planned duration in days.' }) @IsOptional() @Transform(({ value }) => parseOptionalInteger(value)) @IsInt() @Min(0) duration?: number; - @ApiPropertyOptional({ minimum: 0 }) + @ApiPropertyOptional({ minimum: 0, description: 'Planned budget amount.' }) @IsOptional() @Transform(({ value }) => parseOptionalNumber(value)) @IsNumber() @Min(0) budget?: number; - @ApiPropertyOptional({ minimum: 0 }) + @ApiPropertyOptional({ + minimum: 0, + description: 'Actual spent budget amount.', + }) @IsOptional() @Transform(({ value }) => parseOptionalNumber(value)) @IsNumber() @Min(0) spentBudget?: number; - @ApiPropertyOptional({ minimum: 0 }) + @ApiPropertyOptional({ + minimum: 0, + description: 'Progress percentage value.', + }) @IsOptional() @Transform(({ value }) => parseOptionalNumber(value)) @IsNumber() @@ -118,20 +132,28 @@ export class CreatePhaseDto { @IsObject() details?: Record; - @ApiPropertyOptional() + @ApiPropertyOptional({ + description: 'Optional explicit order position within project phases.', + }) @IsOptional() @Transform(({ value }) => parseOptionalInteger(value)) @IsInt() order?: number; - @ApiPropertyOptional() + @ApiPropertyOptional({ + description: + 'Optional product template id to seed one PhaseProduct during creation.', + }) @IsOptional() @Transform(({ value }) => parseOptionalInteger(value)) @IsInt() @Min(1) productTemplateId?: number; - @ApiPropertyOptional({ type: [Number] }) + @ApiPropertyOptional({ + type: [Number], + description: 'Optional user ids for bulk phase-member assignment.', + }) @IsOptional() @IsArray() @Transform(({ value }) => parseOptionalIntegerArray(value)) diff --git a/src/api/project-phase/dto/phase-list-query.dto.ts b/src/api/project-phase/dto/phase-list-query.dto.ts index 594c338..bd96a44 100644 --- a/src/api/project-phase/dto/phase-list-query.dto.ts +++ b/src/api/project-phase/dto/phase-list-query.dto.ts @@ -21,11 +21,17 @@ function parseOptionalBoolean(value: unknown): boolean | undefined { return undefined; } +// TODO [DRY]: Duplicated in `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +/** + * Query params for phase/work listing endpoints: + * `GET /projects/:projectId/phases` and + * `GET /projects/:projectId/workstreams/:workStreamId/works`. + */ export class PhaseListQueryDto { @ApiPropertyOptional({ description: - 'CSV fields to include. Supports phase fields plus products,members,approvals.', + 'CSV field projection (for example: `id,name,products`). Supports phase fields plus `products`, `members`, and `approvals`.', }) @IsOptional() @IsString() @@ -33,7 +39,7 @@ export class PhaseListQueryDto { @ApiPropertyOptional({ description: - 'Sort expression. Supported fields: startDate, endDate, status, order.', + 'Sort expression. Allowed fields: `startDate`, `endDate`, `status`, `order`.', example: 'startDate asc', }) @IsOptional() @@ -41,7 +47,8 @@ export class PhaseListQueryDto { sort?: string; @ApiPropertyOptional({ - description: 'If true, filter to phases where current user is a member.', + description: + 'If true, non-admin users only see phases where they are active members.', }) @IsOptional() @Transform(({ value }) => parseOptionalBoolean(value)) diff --git a/src/api/project-phase/dto/phase-response.dto.ts b/src/api/project-phase/dto/phase-response.dto.ts index cb13592..8d154ef 100644 --- a/src/api/project-phase/dto/phase-response.dto.ts +++ b/src/api/project-phase/dto/phase-response.dto.ts @@ -2,6 +2,9 @@ import { ProjectStatus } from '@prisma/client'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { PhaseProductResponseDto } from 'src/api/phase-product/dto/phase-product-response.dto'; +/** + * Response model for phase member rows embedded in phase responses. + */ export class ProjectPhaseMemberDto { @ApiProperty() id: string; @@ -25,6 +28,9 @@ export class ProjectPhaseMemberDto { updatedBy: number; } +/** + * Response model for phase approval rows embedded in phase responses. + */ export class ProjectPhaseApprovalDto { @ApiProperty() id: string; @@ -60,6 +66,11 @@ export class ProjectPhaseApprovalDto { updatedBy: number; } +/** + * Response payload for phase/work endpoints. Relation arrays are included only + * when requested via the `fields` query parameter (`products`, `members`, + * `approvals`, or `all`). + */ export class PhaseResponseDto { @ApiProperty() id: string; @@ -121,12 +132,24 @@ export class PhaseResponseDto { @ApiProperty() updatedBy: number; - @ApiPropertyOptional({ type: () => [PhaseProductResponseDto] }) + @ApiPropertyOptional({ + type: () => [PhaseProductResponseDto], + description: + 'Conditionally populated when `fields` includes `products` (or `all`).', + }) products?: PhaseProductResponseDto[]; - @ApiPropertyOptional({ type: () => [ProjectPhaseMemberDto] }) + @ApiPropertyOptional({ + type: () => [ProjectPhaseMemberDto], + description: + 'Conditionally populated when `fields` includes `members` (or `all`).', + }) members?: ProjectPhaseMemberDto[]; - @ApiPropertyOptional({ type: () => [ProjectPhaseApprovalDto] }) + @ApiPropertyOptional({ + type: () => [ProjectPhaseApprovalDto], + description: + 'Conditionally populated when `fields` includes `approvals` (or `all`).', + }) approvals?: ProjectPhaseApprovalDto[]; } diff --git a/src/api/project-phase/dto/update-phase.dto.ts b/src/api/project-phase/dto/update-phase.dto.ts index 3e4cfb5..6653710 100644 --- a/src/api/project-phase/dto/update-phase.dto.ts +++ b/src/api/project-phase/dto/update-phase.dto.ts @@ -1,9 +1,19 @@ import { OmitType, PartialType } from '@nestjs/mapped-types'; import { CreatePhaseDto } from './create-phase.dto'; +/** + * Subset of `CreatePhaseDto` fields that are mutable through phase update + * endpoints. `productTemplateId` and `members` are intentionally omitted; use + * dedicated flows for template seeding and phase-member management. + */ class UpdatablePhaseFieldsDto extends OmitType(CreatePhaseDto, [ 'productTemplateId', 'members', ] as const) {} +/** + * Partial update payload for: + * `PATCH /projects/:projectId/phases/:phaseId` and + * `PATCH /projects/:projectId/workstreams/:workStreamId/works/:id`. + */ export class UpdatePhaseDto extends PartialType(UpdatablePhaseFieldsDto) {} diff --git a/src/api/project-phase/project-phase.controller.ts b/src/api/project-phase/project-phase.controller.ts index ad17344..cae775d 100644 --- a/src/api/project-phase/project-phase.controller.ts +++ b/src/api/project-phase/project-phase.controller.ts @@ -37,9 +37,28 @@ import { ProjectPhaseService } from './project-phase.service'; @ApiTags('Project Phases') @ApiBearerAuth() @Controller('/projects/:projectId/phases') +/** + * REST controller for project phases under `/projects/:projectId/phases`. + * All endpoints require a valid JWT and are gated by `PermissionGuard`. + * Read endpoints require `VIEW_PROJECT`; write endpoints require the + * corresponding `ADD/UPDATE/DELETE_PROJECT_PHASE` permission. + * Used by platform-ui Work app and the legacy Connect app. + */ export class ProjectPhaseController { constructor(private readonly service: ProjectPhaseService) {} + /** + * Lists phases for a project with optional field projection, sorting, and + * member-only filtering. + * + * @param projectId - Project identifier from the route. + * @param query - Query parameters (`fields`, `sort`, `memberOnly`). + * @param user - Authenticated JWT user. + * @returns Array of project phase DTOs. + * @throws {BadRequestException} When a route id or sort expression is invalid. + * @throws {ForbiddenException} When the caller lacks project view permission. + * @throws {NotFoundException} When the project does not exist. + */ @Get() @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -64,6 +83,17 @@ export class ProjectPhaseController { return this.service.listPhases(projectId, query, user); } + /** + * Fetches one phase in a project with its relations. + * + * @param projectId - Project identifier from the route. + * @param phaseId - Phase identifier from the route. + * @param user - Authenticated JWT user. + * @returns A single project phase DTO. + * @throws {BadRequestException} When a route id is invalid. + * @throws {ForbiddenException} When the caller lacks project view permission. + * @throws {NotFoundException} When the project or phase is not found. + */ @Get(':phaseId') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -86,6 +116,17 @@ export class ProjectPhaseController { return this.service.getPhase(projectId, phaseId, user); } + /** + * Creates a new phase in a project. + * + * @param projectId - Project identifier from the route. + * @param dto - Create payload for the phase. + * @param user - Authenticated JWT user. + * @returns The created project phase DTO. + * @throws {BadRequestException} When payload ids, dates, or template data are invalid. + * @throws {ForbiddenException} When the caller lacks create permission. + * @throws {NotFoundException} When the project is not found. + */ @Post() @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -103,6 +144,18 @@ export class ProjectPhaseController { return this.service.createPhase(projectId, dto, user); } + /** + * Updates an existing project phase. + * + * @param projectId - Project identifier from the route. + * @param phaseId - Phase identifier from the route. + * @param dto - Partial update payload. + * @param user - Authenticated JWT user. + * @returns The updated project phase DTO. + * @throws {BadRequestException} When ids are invalid or a terminal status transition is requested. + * @throws {ForbiddenException} When the caller lacks update permission. + * @throws {NotFoundException} When the project or phase is not found. + */ @Patch(':phaseId') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -122,6 +175,17 @@ export class ProjectPhaseController { return this.service.updatePhase(projectId, phaseId, dto, user); } + /** + * Soft deletes a project phase. + * + * @param projectId - Project identifier from the route. + * @param phaseId - Phase identifier from the route. + * @param user - Authenticated JWT user. + * @returns No content. + * @throws {BadRequestException} When a route id is invalid. + * @throws {ForbiddenException} When the caller lacks delete permission. + * @throws {NotFoundException} When the project or phase is not found. + */ @Delete(':phaseId') @HttpCode(204) @UseGuards(PermissionGuard) diff --git a/src/api/project-phase/project-phase.module.ts b/src/api/project-phase/project-phase.module.ts index c0bcdfd..71ca22b 100644 --- a/src/api/project-phase/project-phase.module.ts +++ b/src/api/project-phase/project-phase.module.ts @@ -12,4 +12,11 @@ import { WorkController } from './work.controller'; providers: [ProjectPhaseService], exports: [ProjectPhaseService], }) +/** + * NestJS feature module for project phases (works). Registers + * `ProjectPhaseController` (classic `/phases` routes) and `WorkController` + * (workstream-scoped `/works` routes). Exports `ProjectPhaseService` for use + * by `WorkController` and other consumers. Includes `WorkStreamModule` to + * support work-stream linkage flows. + */ export class ProjectPhaseModule {} diff --git a/src/api/project-phase/project-phase.service.ts b/src/api/project-phase/project-phase.service.ts index be47e9f..03de716 100644 --- a/src/api/project-phase/project-phase.service.ts +++ b/src/api/project-phase/project-phase.service.ts @@ -27,6 +27,7 @@ import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { PermissionService } from 'src/shared/services/permission.service'; import { hasAdminRole } from 'src/shared/utils/permission.utils'; +// TODO [DRY]: Move to `src/shared/interfaces/project-permission-context.interface.ts`. interface ProjectPermissionContext { id: bigint; directProjectId: bigint | null; @@ -78,12 +79,31 @@ const TERMINAL_PHASE_STATUSES = new Set([ ]); @Injectable() +/** + * Business logic for project phases. Handles CRUD, ordered insertion/reordering + * of phases within a project, optional product-template seeding on creation, + * and member assignment. Used by `ProjectPhaseController` (direct phase routes) + * and `WorkController` (workstream-scoped work routes). + */ export class ProjectPhaseService { constructor( private readonly prisma: PrismaService, private readonly permissionService: PermissionService, ) {} + /** + * Lists phases for a project with optional field selection, sort, member-only + * filtering, and optional phase id whitelist support for alias controllers. + * + * @param projectId - Project id from the route. + * @param query - Query DTO with `fields`, `sort`, and `memberOnly`. + * @param user - Authenticated user. + * @param options - Optional constraints (for example `phaseIds` whitelist). + * @returns Phase response DTO array. + * @throws {BadRequestException} When route ids or sort criteria are invalid. + * @throws {ForbiddenException} When the caller lacks view permission. + * @throws {NotFoundException} When the project does not exist. + */ async listPhases( projectId: string, query: PhaseListQueryDto, @@ -177,6 +197,17 @@ export class ProjectPhaseService { .map((phase) => this.filterFields(phase, query.fields)); } + /** + * Fetches one project phase with products, members, and approvals. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param user - Authenticated user. + * @returns Phase response DTO. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks view permission. + * @throws {NotFoundException} When the phase is not found. + */ async getPhase( projectId: string, phaseId: string, @@ -231,6 +262,18 @@ export class ProjectPhaseService { return this.toDto(phase); } + /** + * Creates a phase inside a transaction, resolves insertion order, optionally + * seeds a phase product from a product template, and bulk-creates members. + * + * @param projectId - Project id from the route. + * @param dto - Create payload. + * @param user - Authenticated user. + * @returns Created phase response DTO. + * @throws {BadRequestException} When ids, date range, or template id are invalid. + * @throws {ForbiddenException} When the caller lacks create permission. + * @throws {NotFoundException} When the project or created phase is not found. + */ async createPhase( projectId: string, dto: CreatePhaseDto, @@ -388,6 +431,19 @@ export class ProjectPhaseService { return response; } + /** + * Updates mutable phase fields, validates date and status transitions, and + * reorders sibling phases when order is changed. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param dto - Update payload. + * @param user - Authenticated user. + * @returns Updated phase response DTO. + * @throws {BadRequestException} When ids, dates, or status transition are invalid. + * @throws {ForbiddenException} When the caller lacks update permission. + * @throws {NotFoundException} When the phase is not found. + */ async updatePhase( projectId: string, phaseId: string, @@ -520,6 +576,17 @@ export class ProjectPhaseService { return response; } + /** + * Soft deletes a phase and reindexes remaining phase order values. + * + * @param projectId - Project id from the route. + * @param phaseId - Phase id from the route. + * @param user - Authenticated user. + * @returns Nothing. + * @throws {BadRequestException} When route ids are invalid. + * @throws {ForbiddenException} When the caller lacks delete permission. + * @throws {NotFoundException} When the phase is not found. + */ async deletePhase( projectId: string, phaseId: string, @@ -586,6 +653,12 @@ export class ProjectPhaseService { void deletedPhase; } + /** + * Parses `fields` CSV and maps to relation include flags. + * + * @param fields - CSV list of requested fields. + * @returns Include flags for products, members, and approvals. + */ private parseFieldSelection(fields?: string): { includeProducts: boolean; includeMembers: boolean; @@ -608,6 +681,14 @@ export class ProjectPhaseService { }; } + /** + * Validates and maps a sort expression to Prisma `orderBy`. + * + * @param sort - Sort expression (`field direction`). + * @returns Prisma-compatible orderBy object. + * @throws {BadRequestException} When sort field or direction is invalid. + */ + // TODO [DRY]: Extract a shared `parseSortParam(sort, allowedFields)` helper to `src/shared/utils/query.utils.ts`. private parseSortCriteria(sort?: string): { [key: string]: 'asc' | 'desc'; } { @@ -644,6 +725,13 @@ export class ProjectPhaseService { }; } + /** + * Applies top-level response projection for legacy `/v5` `fields` behavior. + * + * @param phase - Full phase DTO. + * @param fields - Requested fields CSV. + * @returns Filtered phase DTO. + */ private filterFields( phase: PhaseResponseDto, fields?: string, @@ -669,6 +757,12 @@ export class ProjectPhaseService { return filtered as unknown as PhaseResponseDto; } + /** + * Maps a phase entity with optional relations to response DTO shape. + * + * @param phase - Phase entity from Prisma. + * @returns Serialized phase DTO. + */ private toDto(phase: PhaseWithRelations): PhaseResponseDto { const response: PhaseResponseDto = { id: phase.id.toString(), @@ -730,6 +824,12 @@ export class ProjectPhaseService { return response; } + /** + * Maps a phase member entity to response DTO and serializes `bigint` ids. + * + * @param member - Phase member entity. + * @returns Phase member DTO. + */ private toPhaseMemberDto(member: ProjectPhaseMember): ProjectPhaseMemberDto { return { id: member.id.toString(), @@ -742,6 +842,12 @@ export class ProjectPhaseService { }; } + /** + * Maps a phase approval entity to response DTO and serializes `bigint` ids. + * + * @param approval - Phase approval entity. + * @returns Phase approval DTO. + */ private toPhaseApprovalDto( approval: ProjectPhaseApproval, ): ProjectPhaseApprovalDto { @@ -760,6 +866,14 @@ export class ProjectPhaseService { }; } + /** + * Validates that start date is not after end date. + * + * @param startDate - Candidate start date. + * @param endDate - Candidate end date. + * @returns Nothing. + * @throws {BadRequestException} When `startDate` is after `endDate`. + */ private validateDateRange( startDate?: Date | null, endDate?: Date | null, @@ -773,6 +887,14 @@ export class ProjectPhaseService { } } + /** + * Validates phase status transitions from non-terminal states only. + * + * @param currentStatus - Current persisted status. + * @param requestedStatus - Requested status update. + * @returns Nothing. + * @throws {BadRequestException} When transitioning out of terminal status. + */ private validateStatusTransition( currentStatus?: ProjectStatus | null, requestedStatus?: ProjectStatus | null, @@ -792,6 +914,13 @@ export class ProjectPhaseService { } } + /** + * Returns active phases in a project with minimal fields used for ordering. + * + * @param tx - Transaction client. + * @param projectId - Project id. + * @returns Array of phase ids and order values. + */ private async getActiveProjectPhaseOrders( tx: Prisma.TransactionClient, projectId: bigint, @@ -808,6 +937,12 @@ export class ProjectPhaseService { }); } + /** + * Stably sorts phases by `order` (nulls last), then by id. + * + * @param phases - Phase rows. + * @returns Sorted phase rows. + */ private sortPhasesByOrder( phases: T[], ): T[] { @@ -829,6 +964,14 @@ export class ProjectPhaseService { }); } + /** + * Normalizes a requested order value into the inclusive `[1, maxOrder]` range. + * + * @param requestedOrder - Requested order from input. + * @param maxOrder - Max accepted order. + * @param fallbackOrder - Fallback order when omitted. + * @returns Normalized order. + */ private resolveRequestedOrder( requestedOrder: number | undefined, maxOrder: number, @@ -850,6 +993,17 @@ export class ProjectPhaseService { return normalizedOrder; } + /** + * Reindexes phase order values based on the provided sorted phase id list. + * + * @param tx - Transaction client. + * @param projectId - Project id. + * @param orderedPhaseIds - Phase ids in desired order. + * @param auditUserId - User id for audit fields. + * @param skipPhaseId - Optional phase id to skip. + * @returns Nothing. + */ + // TODO [PERF/QUALITY]: Replace per-row updates with a batched `UPDATE ... CASE WHEN` or `updateMany` strategy to avoid N round-trips in the transaction. private async reindexProjectPhases( tx: Prisma.TransactionClient, projectId: bigint, @@ -901,6 +1055,12 @@ export class ProjectPhaseService { } } + /** + * Builds phase product details payload from a product template. + * + * @param template - Product template row. + * @returns Details object for phase product creation. + */ private buildPhaseProductDetailsFromTemplate( template: ProductTemplate, ): Record { @@ -924,6 +1084,13 @@ export class ProjectPhaseService { }; } + /** + * Safely converts unknown JSON-like data into an object DTO payload. + * + * @param value - Candidate details value. + * @returns Plain object, or empty object when invalid. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private toDetailsObject(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; @@ -932,6 +1099,13 @@ export class ProjectPhaseService { return value as Record; } + /** + * Converts an arbitrary value into Prisma JSON input semantics. + * + * @param value - Candidate JSON value. + * @returns Prisma JSON input value, JsonNull, or undefined. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private toJsonInput( value: unknown, ): Prisma.InputJsonValue | Prisma.JsonNullValueInput | undefined { @@ -946,6 +1120,12 @@ export class ProjectPhaseService { return value as Prisma.InputJsonValue; } + /** + * Parses comma-separated values into a normalized lowercased token set. + * + * @param value - CSV string. + * @returns Set of normalized tokens. + */ private parseCsv(value: string): Set { return new Set( value @@ -955,6 +1135,12 @@ export class ProjectPhaseService { ); } + /** + * Parses an optional string as bigint. + * + * @param value - Optional raw value. + * @returns Parsed bigint or undefined. + */ private parseOptionalBigInt(value?: string): bigint | undefined { if (!value || value.trim().length === 0) { return undefined; @@ -967,6 +1153,14 @@ export class ProjectPhaseService { } } + /** + * Determines whether the user is effectively an admin for member filters. + * + * @param user - Authenticated user. + * @param projectMembers - Active project members. + * @returns `true` if caller has admin access. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private isAdminUser( user: JwtUser, projectMembers: Array<{ @@ -985,6 +1179,16 @@ export class ProjectPhaseService { ); } + /** + * Enforces named permission checks against project member context. + * + * @param permission - Permission to evaluate. + * @param user - Authenticated user. + * @param projectMembers - Active project members. + * @returns Nothing. + * @throws {ForbiddenException} When permission is missing. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private ensureNamedPermission( permission: Permission, user: JwtUser, @@ -1005,6 +1209,14 @@ export class ProjectPhaseService { } } + /** + * Loads project metadata and active members for permission evaluation. + * + * @param projectId - Parsed project id. + * @returns Permission context payload. + * @throws {NotFoundException} When the project does not exist. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private async getProjectPermissionContext( projectId: bigint, ): Promise { @@ -1039,6 +1251,15 @@ export class ProjectPhaseService { return project; } + /** + * Parses a route id into bigint and throws on invalid values. + * + * @param value - Raw route id value. + * @param entityName - Entity name for error messages. + * @returns Parsed bigint id. + * @throws {BadRequestException} When parsing fails. + */ + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private parseId(value: string, entityName: string): bigint { try { return BigInt(value); @@ -1047,6 +1268,14 @@ export class ProjectPhaseService { } } + /** + * Parses the authenticated user id for audit fields. + * + * @param user - Authenticated user. + * @returns Numeric user id. + */ + // TODO [SECURITY]: Returning `-1` silently when `user.userId` is invalid can corrupt audit trails; throw `UnauthorizedException` instead. + // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private getAuditUserId(user: JwtUser): number { const userId = Number.parseInt(String(user.userId || ''), 10); diff --git a/src/api/project-phase/work.controller.ts b/src/api/project-phase/work.controller.ts index 6dca986..ec8f19c 100644 --- a/src/api/project-phase/work.controller.ts +++ b/src/api/project-phase/work.controller.ts @@ -44,16 +44,35 @@ const WORK_ALLOWED_ROLES = [ UserRole.TC_COPILOT, UserRole.COPILOT_MANAGER, ]; +// TODO [DRY]: Extract a single `WORK_LAYER_ALLOWED_ROLES` constant to `src/shared/constants/roles.ts`. @ApiTags('Work') @ApiBearerAuth() @Controller('/projects/:projectId/workstreams/:workStreamId/works') +/** + * Alias REST controller exposing project phases as "works" under + * `/projects/:projectId/workstreams/:workStreamId/works`. Used by the + * platform-ui Work app. Delegates all business logic to `ProjectPhaseService`; + * uses `WorkStreamService` to validate work-stream membership before each + * operation. + */ export class WorkController { constructor( private readonly projectPhaseService: ProjectPhaseService, private readonly workStreamService: WorkStreamService, ) {} + /** + * Validates the work stream exists, resolves linked phase ids, then delegates + * phase listing to `ProjectPhaseService`. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param query - Work list query parameters. + * @param user - Authenticated user. + * @returns Array of work DTOs. + * @throws {NotFoundException} When work stream is missing. + */ @Get() @UseGuards(PermissionGuard) @Roles(...WORK_ALLOWED_ROLES) @@ -108,6 +127,17 @@ export class WorkController { }); } + /** + * Validates work-stream linkage for the phase id, then delegates retrieval to + * `ProjectPhaseService`. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param id - Work id (phase id). + * @param user - Authenticated user. + * @returns One work DTO. + * @throws {NotFoundException} When work stream is missing or work is not linked. + */ @Get(':id') @UseGuards(PermissionGuard) @Roles(...WORK_ALLOWED_ROLES) @@ -151,6 +181,17 @@ export class WorkController { return this.projectPhaseService.getPhase(projectId, id, user); } + /** + * Validates the work stream, creates a phase as a work, then creates a + * `phase_work_streams` link via `WorkStreamService`. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param dto - Work create payload. + * @param user - Authenticated user. + * @returns Created work DTO. + * @throws {NotFoundException} When the work stream does not exist. + */ @Post() @UseGuards(PermissionGuard) @Roles(...WORK_ALLOWED_ROLES) @@ -195,6 +236,18 @@ export class WorkController { return created; } + /** + * Validates that the work belongs to the work stream, then delegates update + * to `ProjectPhaseService`. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param id - Work id (phase id). + * @param dto - Work update payload. + * @param user - Authenticated user. + * @returns Updated work DTO. + * @throws {NotFoundException} When work stream is missing or work is not linked. + */ @Patch(':id') @UseGuards(PermissionGuard) @Roles(...WORK_ALLOWED_ROLES) @@ -235,6 +288,17 @@ export class WorkController { return this.projectPhaseService.updatePhase(projectId, id, dto, user); } + /** + * Validates work-stream linkage, then delegates soft deletion to + * `ProjectPhaseService`. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param id - Work id (phase id). + * @param user - Authenticated user. + * @returns Nothing. + * @throws {NotFoundException} When work stream is missing or work is not linked. + */ @Delete(':id') @HttpCode(204) @UseGuards(PermissionGuard) diff --git a/src/api/project-setting/dto/create-project-setting.dto.ts b/src/api/project-setting/dto/create-project-setting.dto.ts index d4baa65..56e8439 100644 --- a/src/api/project-setting/dto/create-project-setting.dto.ts +++ b/src/api/project-setting/dto/create-project-setting.dto.ts @@ -13,6 +13,12 @@ export const PROJECT_SETTING_VALUE_TYPES = Object.values(ValueType); export type ProjectSettingValueType = ValueType; +/** + * DTO for creating project settings. + * + * `readPermission` and `writePermission` accept JSON objects that are evaluated + * through `PermissionService.hasPermission` at runtime. + */ export class CreateProjectSettingDto { @ApiProperty({ maxLength: 255 }) @IsString() diff --git a/src/api/project-setting/dto/project-setting-response.dto.ts b/src/api/project-setting/dto/project-setting-response.dto.ts index 8aa4c69..8386404 100644 --- a/src/api/project-setting/dto/project-setting-response.dto.ts +++ b/src/api/project-setting/dto/project-setting-response.dto.ts @@ -4,6 +4,12 @@ import { ProjectSettingValueType, } from './create-project-setting.dto'; +/** + * Serialized project setting response DTO. + * + * `value` and `valueType` are optional in responses even though both are + * required on create. + */ export class ProjectSettingResponseDto { @ApiProperty() id: string; diff --git a/src/api/project-setting/dto/update-project-setting.dto.ts b/src/api/project-setting/dto/update-project-setting.dto.ts index 0c56105..540b0bf 100644 --- a/src/api/project-setting/dto/update-project-setting.dto.ts +++ b/src/api/project-setting/dto/update-project-setting.dto.ts @@ -1,6 +1,12 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateProjectSettingDto } from './create-project-setting.dto'; +/** + * DTO for updating project settings. + * + * Extends `PartialType(CreateProjectSettingDto)`, making all create fields + * optional for patch semantics. + */ export class UpdateProjectSettingDto extends PartialType( CreateProjectSettingDto, ) {} diff --git a/src/api/project-setting/project-setting.controller.ts b/src/api/project-setting/project-setting.controller.ts index df5de73..8faf23e 100644 --- a/src/api/project-setting/project-setting.controller.ts +++ b/src/api/project-setting/project-setting.controller.ts @@ -38,12 +38,35 @@ const PROJECT_SETTING_ROLES = Object.values(UserRole); @ApiTags('Project Settings') @ApiBearerAuth() @Controller('/projects/:projectId/settings') +/** + * REST controller for `/projects/:projectId/settings`. + * + * Settings are per-project key/value records with per-record + * `readPermission` and `writePermission` JSON objects. Visibility filtering is + * enforced by the service layer. + * + * Architectural note: this controller currently injects `PrismaService` and + * preloads project members for each route, which couples controller logic to + * persistence concerns. + */ export class ProjectSettingController { constructor( private readonly service: ProjectSettingService, + // TODO: QUALITY: Controller-level Prisma usage couples HTTP handling to data + // access. Move member lookup into `ProjectSettingService`. private readonly prisma: PrismaService, ) {} + /** + * Lists project settings visible to the current user. + * + * @param projectId Project identifier from route params. + * @param user Authenticated caller. + * @returns List of project setting response DTOs. + * @throws {BadRequestException} If project id is not numeric. + * @throws {ForbiddenException} If read permission check fails. + * @throws {NotFoundException} If project does not exist. + */ @Get() @UseGuards(PermissionGuard) @Roles(...PROJECT_SETTING_ROLES) @@ -66,10 +89,25 @@ export class ProjectSettingController { @Param('projectId') projectId: string, @CurrentUser() user: JwtUser, ): Promise { + // TODO: QUALITY: This performs a separate member query per route before + // service execution. Consolidate project/member lookup in the service to + // reduce round-trips. const projectMembers = await this.getProjectMembers(projectId); return this.service.findAll(projectId, user, projectMembers); } + /** + * Creates a project setting. + * + * @param projectId Project identifier from route params. + * @param dto Project setting payload. + * @param user Authenticated caller. + * @returns Created project setting response DTO. + * @throws {BadRequestException} If project id or payload is invalid. + * @throws {ForbiddenException} If write permission check fails. + * @throws {NotFoundException} If project does not exist. + * @throws {ConflictException} If the setting key already exists. + */ @Post() @UseGuards(PermissionGuard) @Roles(...PROJECT_SETTING_ROLES) @@ -89,10 +127,26 @@ export class ProjectSettingController { @Body() dto: CreateProjectSettingDto, @CurrentUser() user: JwtUser, ): Promise { + // TODO: QUALITY: This performs a separate member query per route before + // service execution. Consolidate project/member lookup in the service to + // reduce round-trips. const projectMembers = await this.getProjectMembers(projectId); return this.service.create(projectId, dto, user, projectMembers); } + /** + * Updates a project setting. + * + * @param projectId Project identifier from route params. + * @param id Project setting identifier from route params. + * @param dto Partial project setting payload. + * @param user Authenticated caller. + * @returns Updated project setting response DTO. + * @throws {BadRequestException} If ids or payload are invalid. + * @throws {ForbiddenException} If write permission check fails. + * @throws {NotFoundException} If setting/project does not exist. + * @throws {ConflictException} If updated key conflicts. + */ @Patch(':id') @UseGuards(PermissionGuard) @Roles(...PROJECT_SETTING_ROLES) @@ -114,10 +168,24 @@ export class ProjectSettingController { @Body() dto: UpdateProjectSettingDto, @CurrentUser() user: JwtUser, ): Promise { + // TODO: QUALITY: This performs a separate member query per route before + // service execution. Consolidate project/member lookup in the service to + // reduce round-trips. const projectMembers = await this.getProjectMembers(projectId); return this.service.update(projectId, id, dto, user, projectMembers); } + /** + * Soft-deletes a project setting. + * + * @param projectId Project identifier from route params. + * @param id Project setting identifier from route params. + * @param user Authenticated caller. + * @returns Resolves when deletion is complete. + * @throws {BadRequestException} If ids are invalid. + * @throws {ForbiddenException} If write permission check fails. + * @throws {NotFoundException} If setting/project does not exist. + */ @Delete(':id') @HttpCode(204) @UseGuards(PermissionGuard) @@ -138,10 +206,22 @@ export class ProjectSettingController { @Param('id') id: string, @CurrentUser() user: JwtUser, ): Promise { + // TODO: QUALITY: This performs a separate member query per route before + // service execution. Consolidate project/member lookup in the service to + // reduce round-trips. const projectMembers = await this.getProjectMembers(projectId); await this.service.delete(projectId, id, user, projectMembers); } + /** + * Fetches active project members for downstream permission checks. + * + * @param projectId Project identifier from route params. + * @returns Project member records with fields required by permission checks. + * @throws {BadRequestException} If project id is not numeric. + */ + // TODO: QUALITY: Move this method to `ProjectSettingService` so the + // controller no longer accesses Prisma directly. private async getProjectMembers(projectId: string) { const parsedProjectId = this.parseProjectId(projectId); @@ -161,6 +241,15 @@ export class ProjectSettingController { }); } + /** + * Parses and validates numeric project id params. + * + * @param projectId Raw project id route param. + * @returns Parsed bigint project id. + * @throws {BadRequestException} If project id is not numeric. + */ + // TODO: DRY: Duplicates `parseBigIntParam` in `ProjectSettingService`. + // Remove once member lookup is moved into the service layer. private parseProjectId(projectId: string): bigint { const normalizedProjectId = projectId.trim(); diff --git a/src/api/project-setting/project-setting.service.ts b/src/api/project-setting/project-setting.service.ts index 0fa2bc4..43410d0 100644 --- a/src/api/project-setting/project-setting.service.ts +++ b/src/api/project-setting/project-setting.service.ts @@ -20,6 +20,17 @@ import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { PermissionService } from 'src/shared/services/permission.service'; +/** + * Manages per-project key/value settings persisted in `ProjectSetting`. + * + * Each record stores dynamic JSON `readPermission`/`writePermission` objects + * that are evaluated via `PermissionService.hasPermission` at runtime. + * + * Permission schema is intentionally open (`Record`). + * + * This service currently does not publish Kafka events for create/update/delete + * setting mutations. + */ @Injectable() export class ProjectSettingService { constructor( @@ -27,6 +38,16 @@ export class ProjectSettingService { private readonly permissionService: PermissionService, ) {} + /** + * Lists settings visible to the current user. + * + * @param projectId Project identifier. + * @param user Authenticated caller. + * @param projectMembers Project members used for permission evaluation. + * @returns Visible project settings. + * @throws {BadRequestException} If project id is invalid. + * @throws {ForbiddenException} If permission checks fail. + */ async findAll( projectId: string, user: JwtUser, @@ -53,6 +74,22 @@ export class ProjectSettingService { .map((setting) => this.toDto(setting)); } + /** + * Creates a new project setting. + * + * @param projectId Project identifier. + * @param dto Create payload. + * @param user Authenticated caller. + * @param projectMembers Project members used for permission evaluation. + * @returns Created project setting response. + * @throws {BadRequestException} If project id/payload is invalid. + * @throws {ForbiddenException} If caller cannot satisfy write permission. + * @throws {NotFoundException} If project is not found. + * @throws {ConflictException} If setting key already exists. + */ + // TODO: SECURITY: No Kafka setting-change event is published. If downstream + // consumers depend on setting mutation events, add publishSettingEvent calls + // or document this omission as intentional. async create( projectId: string, dto: CreateProjectSettingDto, @@ -140,6 +177,23 @@ export class ProjectSettingService { } } + /** + * Updates an existing project setting. + * + * @param projectId Project identifier. + * @param settingId Setting identifier. + * @param dto Partial update payload. + * @param user Authenticated caller. + * @param projectMembers Project members used for permission evaluation. + * @returns Updated project setting response. + * @throws {BadRequestException} If ids/payload are invalid. + * @throws {ForbiddenException} If caller cannot satisfy write permission. + * @throws {NotFoundException} If setting is not found. + * @throws {ConflictException} If updated key conflicts. + */ + // TODO: SECURITY: No Kafka setting-change event is published. If downstream + // consumers depend on setting mutation events, add publishSettingEvent calls + // or document this omission as intentional. async update( projectId: string, settingId: string, @@ -228,6 +282,21 @@ export class ProjectSettingService { return response; } + /** + * Soft-deletes a project setting. + * + * @param projectId Project identifier. + * @param settingId Setting identifier. + * @param user Authenticated caller. + * @param projectMembers Project members used for permission evaluation. + * @returns Resolves when setting is soft-deleted. + * @throws {BadRequestException} If ids are invalid. + * @throws {ForbiddenException} If caller cannot satisfy write permission. + * @throws {NotFoundException} If setting is not found. + */ + // TODO: SECURITY: No Kafka setting-change event is published. If downstream + // consumers depend on setting mutation events, add publishSettingEvent calls + // or document this omission as intentional. async delete( projectId: string, settingId: string, @@ -280,9 +349,20 @@ export class ProjectSettingService { }, }); + // TODO: QUALITY: `void deleted` is a no-op expression; remove it. void deleted; } + /** + * Parses numeric route params into bigint values. + * + * @param value Raw parameter value. + * @param name Parameter name for error text. + * @returns Parsed bigint value. + * @throws {BadRequestException} If value is not numeric. + */ + // TODO: DRY: `parseBigIntParam` logic is duplicated in other services. + // Consolidate shared id parsing helpers. private parseBigIntParam(value: string, name: string): bigint { const normalized = value.trim(); @@ -293,6 +373,15 @@ export class ProjectSettingService { return BigInt(normalized); } + /** + * Resolves authenticated user id for audit columns. + * + * @param user Authenticated caller. + * @returns Numeric audit user id. + * @throws {ForbiddenException} If user id is missing or non-numeric. + */ + // TODO: DRY: `getAuditUserId` logic is duplicated in other services. + // Consolidate shared user-id helper logic. private getAuditUserId(user: JwtUser): number { const normalizedUserId = String(user.userId || '').trim(); const parsedUserId = Number.parseInt(normalizedUserId, 10); @@ -304,10 +393,26 @@ export class ProjectSettingService { return parsedUserId; } + /** + * Maps API value type to Prisma value type. + * + * @param valueType Requested setting value type. + * @returns Prisma value type. + * @throws {BadRequestException} If unsupported mapping is added later. + */ + // TODO: QUALITY: `mapValueType` is currently a no-op passthrough. Remove it + // or implement explicit validation/mapping logic. private mapValueType(valueType: ProjectSettingValueType): ValueType { return valueType; } + /** + * Normalizes unknown JSON-like permission payload to permission shape. + * + * @param value Raw permission-like value. + * @returns Permission object. + * @throws {BadRequestException} If conversion rules are tightened later. + */ private toPermission(value: unknown): Permission { if (!value || typeof value !== 'object') { return {}; @@ -316,10 +421,26 @@ export class ProjectSettingService { return value as Permission; } + /** + * Casts values into Prisma JSON input type. + * + * @param value Raw value to persist as JSON. + * @returns Prisma JSON input payload. + * @throws {TypeError} If a non-serializable value is passed at runtime. + */ + // TODO: QUALITY: This direct cast to `Prisma.InputJsonValue` is unsafe. + // Validate serializability or normalize with `JSON.parse(JSON.stringify())`. private toJsonInput(value: unknown): Prisma.InputJsonValue { return value as Prisma.InputJsonValue; } + /** + * Maps persistence entity to response DTO. + * + * @param setting Project setting entity. + * @returns API response DTO. + * @throws {TypeError} If entity fields are unexpectedly nullish. + */ private toDto(setting: ProjectSetting): ProjectSettingResponseDto { return { id: setting.id.toString(), diff --git a/src/api/project/dto/create-project.dto.ts b/src/api/project/dto/create-project.dto.ts index 031b3b1..4ac381a 100644 --- a/src/api/project/dto/create-project.dto.ts +++ b/src/api/project/dto/create-project.dto.ts @@ -21,6 +21,14 @@ import { ValidateNested, } from 'class-validator'; +/** + * Parses optional numeric input from query/body payloads. + * + * @param value Raw value. + * @returns Parsed number or `undefined`. + * @todo Duplicates `parseNumberInput` behavior from `pagination.dto.ts`; + * consolidate shared numeric parsing in DTO utilities. + */ function parseOptionalNumber(value: unknown): number | undefined { if (typeof value === 'undefined' || value === null || value === '') { return undefined; @@ -35,6 +43,14 @@ function parseOptionalNumber(value: unknown): number | undefined { return parsed; } +/** + * Parses optional integer input. + * + * @param value Raw value. + * @returns Truncated integer or `undefined`. + * @todo Duplicates `parseNumberInput` behavior from `pagination.dto.ts`; + * consolidate shared numeric parsing in DTO utilities. + */ function parseOptionalInteger(value: unknown): number | undefined { const parsed = parseOptionalNumber(value); @@ -45,6 +61,12 @@ function parseOptionalInteger(value: unknown): number | undefined { return Math.trunc(parsed); } +/** + * Parses `allowedUsers` arrays into validated integer ids. + * + * @param value Raw array candidate. + * @returns Integer user ids or `undefined` when input is not an array. + */ function parseAllowedUsers(value: unknown): number[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -55,6 +77,9 @@ function parseAllowedUsers(value: unknown): number[] | undefined { .filter((entry): entry is number => typeof entry === 'number'); } +/** + * DTO describing UTM metadata for project tracking. + */ export class ProjectUtmDto { @ApiPropertyOptional() @IsOptional() @@ -82,6 +107,9 @@ export class ProjectUtmDto { term?: string; } +/** + * DTO describing a bookmark entry associated with a project. + */ export class BookmarkDto { @ApiPropertyOptional() @IsOptional() @@ -95,6 +123,9 @@ export class BookmarkDto { url: string; } +/** + * DTO for external system metadata payload. + */ export class ProjectExternalDto { @ApiPropertyOptional({ description: 'External links or metadata for third-party systems.', @@ -106,6 +137,9 @@ export class ProjectExternalDto { data?: Record; } +/** + * DTO for challenge-eligibility metadata payload. + */ export class ChallengeEligibilityDto { @ApiPropertyOptional({ description: 'Challenge eligibility rules metadata', @@ -117,6 +151,9 @@ export class ChallengeEligibilityDto { data?: Record; } +/** + * DTO for a single estimation item. + */ export class EstimationItemDto { @ApiProperty({ enum: EstimationType, @@ -151,6 +188,9 @@ export class EstimationItemDto { metadata?: Record; } +/** + * DTO for project-level estimation records. + */ export class EstimationDto { @ApiProperty() @IsString() @@ -205,6 +245,9 @@ export class EstimationDto { items?: EstimationItemDto[]; } +/** + * DTO for project attachment input payload. + */ export class ProjectAttachmentInputDto { @ApiPropertyOptional() @IsOptional() @@ -252,6 +295,12 @@ export class ProjectAttachmentInputDto { allowedUsers?: number[]; } +/** + * Request DTO for `POST /projects`. + * + * Supports core project fields plus nested estimations, attachments, + * bookmarks, utm metadata, external metadata, and challenge-eligibility data. + */ export class CreateProjectDto { @ApiProperty({ description: 'Project name', diff --git a/src/api/project/dto/pagination.dto.ts b/src/api/project/dto/pagination.dto.ts index ae9371c..b305d62 100644 --- a/src/api/project/dto/pagination.dto.ts +++ b/src/api/project/dto/pagination.dto.ts @@ -2,6 +2,12 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsInt, IsOptional, Max, Min } from 'class-validator'; +/** + * Parses string/number pagination input into an integer. + * + * @param value Raw query value. + * @returns Parsed integer or `undefined` for invalid/empty input. + */ function parseNumberInput(value: unknown): number | undefined { if (typeof value === 'undefined' || value === null || value === '') { return undefined; @@ -24,6 +30,12 @@ function parseNumberInput(value: unknown): number | undefined { return parsed; } +/** + * Base pagination DTO for list-style query DTOs. + * + * Provides `page` (default 1, min 1) and `perPage` (default 20, min 1, + * max 200). + */ export class PaginationDto { @ApiPropertyOptional({ description: 'Page number', diff --git a/src/api/project/dto/project-list-query.dto.ts b/src/api/project/dto/project-list-query.dto.ts index f4f6010..1a346ed 100644 --- a/src/api/project/dto/project-list-query.dto.ts +++ b/src/api/project/dto/project-list-query.dto.ts @@ -3,6 +3,14 @@ import { Transform } from 'class-transformer'; import { IsBoolean, IsOptional, IsString } from 'class-validator'; import { PaginationDto } from './pagination.dto'; +/** + * Parses boolean-like query values. + * + * @param value Raw query value. + * @returns Parsed boolean or `undefined`. + * @todo Duplicated helper pattern exists across DTOs; extract shared parsing + * helpers into `src/shared/utils/dto.utils.ts`. + */ function parseBoolean(value: unknown): boolean | undefined { if (typeof value === 'boolean') { return value; @@ -23,6 +31,14 @@ function parseBoolean(value: unknown): boolean | undefined { return undefined; } +/** + * Normalizes filter input values from scalar/array/object forms. + * + * @param value Raw query value. + * @returns Normalized filter payload or `undefined`. + * @todo Duplicated helper pattern exists across DTOs; extract shared parsing + * helpers into `src/shared/utils/dto.utils.ts`. + */ function parseFilterInput( value: unknown, ): string | string[] | Record | undefined { @@ -49,6 +65,14 @@ function parseFilterInput( return undefined; } +/** + * Query DTO for `GET /projects`. + * + * Extends pagination fields and supports optional filters for id/status/ + * billingAccountId/type using scalar or `$in`-style representations. + * The `code` filter is intentionally mapped to a case-insensitive name + * contains query in the project query builder. + */ export class ProjectListQueryDto extends PaginationDto { @ApiPropertyOptional({ description: 'Sort expression. Example: "lastActivityAt desc"', @@ -140,6 +164,9 @@ export class ProjectListQueryDto extends PaginationDto { directProjectId?: string; } +/** + * Query DTO for `GET /projects/:projectId`. + */ export class GetProjectQueryDto { @ApiPropertyOptional({ description: 'CSV fields list. Supported: members, invites, attachments', diff --git a/src/api/project/dto/project-response.dto.ts b/src/api/project/dto/project-response.dto.ts index 24555ed..fe668c9 100644 --- a/src/api/project/dto/project-response.dto.ts +++ b/src/api/project/dto/project-response.dto.ts @@ -1,6 +1,9 @@ import { AttachmentType, InviteStatus, ProjectStatus } from '@prisma/client'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** + * DTO for serialized project member entries. + */ export class ProjectMemberDto { @ApiProperty() id: string; @@ -27,6 +30,9 @@ export class ProjectMemberDto { updatedAt: Date; } +/** + * DTO for serialized project invite entries. + */ export class ProjectInviteDto { @ApiProperty() id: string; @@ -59,6 +65,9 @@ export class ProjectInviteDto { updatedAt: Date; } +/** + * DTO for serialized project attachment entries. + */ export class ProjectAttachmentDto { @ApiProperty() id: string; @@ -97,6 +106,12 @@ export class ProjectAttachmentDto { updatedAt: Date; } +/** + * DTO for serialized project entities. + * + * Uses string ids for bigint-backed columns and number values for decimal + * price fields. + */ export class ProjectResponseDto { @ApiProperty() id: string; @@ -186,6 +201,9 @@ export class ProjectResponseDto { updatedBy: number; } +/** + * DTO for project responses with optional relation collections. + */ export class ProjectWithRelationsDto extends ProjectResponseDto { @ApiPropertyOptional({ type: () => [ProjectMemberDto] }) members?: ProjectMemberDto[]; diff --git a/src/api/project/dto/update-project.dto.ts b/src/api/project/dto/update-project.dto.ts index 4a5d865..18228d5 100644 --- a/src/api/project/dto/update-project.dto.ts +++ b/src/api/project/dto/update-project.dto.ts @@ -1,4 +1,9 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateProjectDto } from './create-project.dto'; +/** + * Request DTO for `PATCH /projects/:projectId`. + * + * Reuses `CreateProjectDto` and makes all fields optional via `PartialType`. + */ export class UpdateProjectDto extends PartialType(CreateProjectDto) {} diff --git a/src/api/project/dto/upgrade-project.dto.ts b/src/api/project/dto/upgrade-project.dto.ts index 5005460..2fc077e 100644 --- a/src/api/project/dto/upgrade-project.dto.ts +++ b/src/api/project/dto/upgrade-project.dto.ts @@ -2,6 +2,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, IsOptional, IsString, Min } from 'class-validator'; +/** + * Admin-only request DTO for `POST /projects/:projectId/upgrade`. + * + * Only `targetVersion: 'v3'` is currently supported by the service layer. + */ export class UpgradeProjectDto { @ApiProperty({ description: 'Target project version, for example v3.' }) @IsString() @@ -16,6 +21,10 @@ export class UpgradeProjectDto { @Min(1) defaultProductTemplateId?: number; + /** + * @todo `phaseName` is currently not consumed by `ProjectService.upgradeProject` + * and should be removed or implemented. + */ @ApiPropertyOptional({ description: 'Optional phase name for future use.' }) @IsOptional() @IsString() diff --git a/src/api/project/project.controller.ts b/src/api/project/project.controller.ts index 168b41a..c8604e7 100644 --- a/src/api/project/project.controller.ts +++ b/src/api/project/project.controller.ts @@ -47,9 +47,33 @@ import { ProjectService } from './project.service'; @ApiTags('Projects') @ApiBearerAuth() @Controller('/projects') +/** + * REST controller for the `/projects` resource. + * + * Handles CRUD operations, billing-account sub-resources, permissions lookup, + * and admin upgrade actions for projects. Access control relies on + * `PermissionGuard` together with `@Roles`, `@Scopes`, and + * `@RequirePermission` decorators applied on handlers. + * + * @todo `@Roles(...Object.values(UserRole))` is repeated on every handler and + * should be hoisted to class level. + */ export class ProjectController { constructor(private readonly service: ProjectService) {} + /** + * Lists projects using paging, filters, and optional relation fields. + * + * @param req Express request used for pagination-link generation. + * @param res Express response where paging headers are set. + * @param criteria Project list query criteria. + * @param user Authenticated caller context. + * @returns Project list payload; pagination metadata is returned in headers. + * @throws UnauthorizedException When the caller is unauthenticated. + * @throws ForbiddenException When the caller lacks required scope/permission. + * @security Pagination depends on DTO validation (`perPage` max 200); there + * is no additional DB-level hard cap in this handler/service path. + */ @Get() @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -138,6 +162,18 @@ export class ProjectController { return result.data; } + /** + * Gets a single project by id. + * + * @param projectId Project id path parameter. + * @param query Query object with optional `fields` CSV. + * @param user Authenticated caller context. + * @returns A project resource with optional relations. + * @throws BadRequestException When `projectId` is not numeric. + * @throws UnauthorizedException When the caller is unauthenticated. + * @throws ForbiddenException When the caller cannot view the project. + * @throws NotFoundException When the project is missing. + */ @Get(':projectId') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -176,6 +212,18 @@ export class ProjectController { return this.service.getProject(projectId, query.fields, user); } + /** + * Lists billing accounts available for a project in caller context. + * + * @param projectId Project id path parameter. + * @param user Authenticated caller context. + * @returns Billing accounts available to the caller. + * @throws BadRequestException When `projectId` is not numeric. + * @throws UnauthorizedException When the caller is unauthenticated. + * @throws ForbiddenException When the caller lacks required permissions. + * @security Requires `CONNECT_PROJECT_ADMIN` or + * `PROJECTS_READ_USER_BILLING_ACCOUNTS` scope. + */ @Get(':projectId/billingAccounts') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -215,6 +263,19 @@ export class ProjectController { return this.service.listProjectBillingAccounts(projectId, user); } + /** + * Gets the default billing account associated with a project. + * + * @param projectId Project id path parameter. + * @param user Authenticated caller context. + * @returns Default project billing-account details. + * @throws BadRequestException When `projectId` is not numeric. + * @throws UnauthorizedException When the caller is unauthenticated. + * @throws ForbiddenException When the caller lacks required permissions. + * @throws NotFoundException When project or billing account is missing. + * @security The service strips `markup` for non-machine callers before + * returning the response payload. + */ @Get(':projectId/billingAccount') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -249,6 +310,17 @@ export class ProjectController { return this.service.getProjectBillingAccount(projectId, user); } + /** + * Returns policy decisions for the caller on a project. + * + * @param projectId Project id path parameter. + * @param user Authenticated caller context. + * @returns Policy-name to boolean map; returns `{}` when no template exists. + * @throws BadRequestException When `projectId` is not numeric. + * @throws UnauthorizedException When the caller is unauthenticated. + * @throws ForbiddenException When caller cannot access the project. + * @throws NotFoundException When the project is missing. + */ @Get(':projectId/permissions') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -287,6 +359,18 @@ export class ProjectController { return this.service.getProjectPermissions(projectId, user); } + /** + * Creates a new project with optional nested records. + * + * @param dto Project creation payload. + * @param user Authenticated caller context. + * @returns Newly created project. + * @throws BadRequestException For invalid input values. + * @throws UnauthorizedException When the caller is unauthenticated. + * @throws ForbiddenException When the caller lacks create permissions. + * @throws NotFoundException When dependent referenced data is missing. + * Publishes a `project.created` lifecycle event on success. + */ @Post() @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -315,6 +399,18 @@ export class ProjectController { return this.service.createProject(dto, user); } + /** + * Upgrades a project to a supported target version. + * + * @param projectId Project id path parameter. + * @param dto Upgrade payload. + * @param user Authenticated caller context. + * @returns Success message; current implementation supports only `v3`. + * @throws BadRequestException For unsupported upgrade arguments. + * @throws UnauthorizedException When the caller is unauthenticated. + * @throws ForbiddenException When the caller is not an admin. + * @throws NotFoundException When the project is missing. + */ @Post(':projectId/upgrade') @AdminOnly() @ApiOperation({ summary: 'Upgrade project to new template version' }) @@ -349,6 +445,19 @@ export class ProjectController { return this.service.upgradeProject(projectId, dto, user); } + /** + * Partially updates a project. + * + * @param projectId Project id path parameter. + * @param dto Patch payload. + * @param user Authenticated caller context. + * @returns Updated project. + * @throws BadRequestException For invalid update fields. + * @throws UnauthorizedException When the caller is unauthenticated. + * @throws ForbiddenException When the caller lacks edit permissions. + * @throws NotFoundException When the project is missing. + * Publishes a `project.updated` lifecycle event on success. + */ @Patch(':projectId') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -382,6 +491,18 @@ export class ProjectController { return this.service.updateProject(projectId, dto, user); } + /** + * Soft-deletes a project. + * + * @param projectId Project id path parameter. + * @param user Authenticated caller context. + * @returns No content. + * @throws BadRequestException When `projectId` is not numeric. + * @throws UnauthorizedException When the caller is unauthenticated. + * @throws ForbiddenException When the caller lacks delete permissions. + * @throws NotFoundException When the project is missing. + * Publishes a `project.deleted` lifecycle event on success. + */ @Delete(':projectId') @HttpCode(204) @UseGuards(PermissionGuard) diff --git a/src/api/project/project.module.ts b/src/api/project/project.module.ts index 917093e..ecc7425 100644 --- a/src/api/project/project.module.ts +++ b/src/api/project/project.module.ts @@ -10,4 +10,11 @@ import { ProjectService } from './project.service'; providers: [ProjectService], exports: [ProjectService], }) +/** + * Project feature module. + * + * Registers `ProjectController` and `ProjectService`, imports `HttpModule` + * for billing-account HTTP integrations and `GlobalProvidersModule`, and + * exports `ProjectService` for reuse by other feature modules. + */ export class ProjectModule {} diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index 2476248..dfb9942 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -65,6 +65,15 @@ type ProjectWithRawRelations = Project & { }; @Injectable() +/** + * Core business-logic service for projects. + * + * Coordinates persistence via `PrismaService`, permission checks via + * `PermissionService`, billing-account lookups via `BillingAccountService`, + * and lifecycle event publication through `publishProjectEvent`. + * It also enriches members/invites with Topcoder handles by querying + * `members.member`. + */ export class ProjectService { private readonly logger = LoggerService.forRoot('ProjectService'); @@ -74,6 +83,20 @@ export class ProjectService { private readonly billingAccountService: BillingAccountService, ) {} + /** + * Returns a paginated project list for the caller. + * + * Builds query clauses from shared utilities, scopes non-admin callers to + * their memberships, enriches member/invite handles, and hydrates billing + * account names. + * + * @param criteria List filters, paging, sort, and field selection. + * @param user Authenticated caller context. + * @returns Paginated project response payload. + * @throws Database/transport errors are not handled here and propagate. + * @security Pagination hard limit is enforced via DTO validation (`perPage` + * max 200); there is no additional database-level cap in this method. + */ async listProjects( criteria: ProjectListQueryDto, user: JwtUser, @@ -140,6 +163,20 @@ export class ProjectService { }; } + /** + * Returns one project with optional relation fields. + * + * Members and invites are always loaded for permission evaluation regardless + * of requested `fields`, then relation visibility is filtered by caller + * permissions before response serialization. + * + * @param projectId Project id path parameter. + * @param fieldsParam Optional CSV list of relation fields. + * @param user Authenticated caller context. + * @returns Project response DTO. + * @throws NotFoundException When the project does not exist. + * @throws ForbiddenException When caller lacks `VIEW_PROJECT`. + */ async getProject( projectId: string, fieldsParam: string | undefined, @@ -216,6 +253,23 @@ export class ProjectService { }); } + /** + * Creates a project and all requested nested resources in one transaction. + * + * Writes project, members, attachments, estimations (+items), and optional + * template-derived phases/products, then records initial project history and + * publishes `project.created`. + * + * @param dto Project creation payload. + * @param user Authenticated caller context. + * @returns Created project payload. + * @throws BadRequestException For invalid type/template/building-block keys. + * @throws ConflictException When post-transaction re-fetch fails. + * @security Caller permissions determine the primary member role + * (`manager` vs `customer`) assigned on creation. + * @todo Sequential `for...of` loops for estimations and template phases in + * the transaction can be optimized with `Promise.all` for larger payloads. + */ async createProject( dto: CreateProjectDto, user: JwtUser, @@ -476,6 +530,20 @@ export class ProjectService { return response; } + /** + * Partially updates a project with permission-aware field guards. + * + * Performs additional checks for `billingAccountId` and `directProjectId`, + * appends project history when status changes, and publishes + * `project.updated`. + * + * @param projectId Project id path parameter. + * @param dto Patch payload. + * @param user Authenticated caller context. + * @returns Updated project payload. + * @throws NotFoundException When the project does not exist. + * @throws ForbiddenException When caller lacks edit-related permissions. + */ async updateProject( projectId: string, dto: UpdateProjectDto, @@ -670,6 +738,17 @@ export class ProjectService { return response; } + /** + * Soft-deletes a project by setting audit deletion columns. + * + * Publishes `project.deleted` after persistence. + * + * @param projectId Project id path parameter. + * @param user Authenticated caller context. + * @returns Promise resolved when deletion is complete. + * @throws NotFoundException When the project does not exist. + * @throws ForbiddenException When caller lacks delete permissions. + */ async deleteProject(projectId: string, user: JwtUser): Promise { const parsedProjectId = this.parseProjectId(projectId); const auditUserId = this.getAuditUserId(user); @@ -721,6 +800,16 @@ export class ProjectService { }); } + /** + * Computes project template policy decisions for the caller. + * + * Returns an empty map when the project does not have a template. + * + * @param projectId Project id path parameter. + * @param user Authenticated caller context. + * @returns Policy-name to boolean decision map. + * @throws NotFoundException When the project does not exist. + */ async getProjectPermissions( projectId: string, user: JwtUser, @@ -791,6 +880,16 @@ export class ProjectService { return policyMap; } + /** + * Lists billing accounts available for a project and caller. + * + * Delegates to `BillingAccountService.getBillingAccountsForProject`. + * Logs a warning and returns an empty list when `user.userId` is missing. + * + * @param projectId Project id path parameter. + * @param user Authenticated caller context. + * @returns Billing account list. + */ async listProjectBillingAccounts( projectId: string, user: JwtUser, @@ -811,6 +910,16 @@ export class ProjectService { ); } + /** + * Returns the default billing account configured on a project. + * + * @param projectId Project id path parameter. + * @param user Authenticated caller context. + * @returns Default billing account details. + * @throws NotFoundException When project or billing account is missing. + * @security Removes `markup` for non-machine callers to avoid exposing + * markup details to interactive users. + */ async getProjectBillingAccount( projectId: string, user: JwtUser, @@ -855,6 +964,21 @@ export class ProjectService { return sanitizedBillingAccount; } + /** + * Upgrades project version/template values for administrative callers. + * + * Only `v3` is currently accepted as `targetVersion`. + * + * @param projectId Project id path parameter. + * @param dto Upgrade payload. + * @param user Authenticated caller context. + * @returns Success message. + * @throws ForbiddenException When caller is not admin-equivalent. + * @throws NotFoundException When the project does not exist. + * @throws BadRequestException When `targetVersion` is unsupported. + * @security Performs redundant admin checks (role + scope) as + * defense-in-depth even when controller layer already applies `@AdminOnly()`. + */ async upgradeProject( projectId: string, dto: UpgradeProjectDto, @@ -922,6 +1046,16 @@ export class ProjectService { }; } + /** + * Enriches member and invite records with handles. + * + * Collects user ids across all projects, resolves handles once, and injects + * them into members/invites when a local handle is not already present. + * No-ops for empty project inputs. + * + * @param projects Projects to enrich. + * @returns Projects with best-effort handle enrichment. + */ private async enrichProjectsWithMemberHandles( projects: ProjectWithRawRelations[], ): Promise { @@ -962,6 +1096,12 @@ export class ProjectService { })); } + /** + * Extracts and deduplicates user ids from project members and invites. + * + * @param projects Projects to inspect. + * @returns Unique user ids as bigint values. + */ private collectProjectUserIds(projects: ProjectWithRawRelations[]): bigint[] { const userIds = new Set(); @@ -986,6 +1126,14 @@ export class ProjectService { return Array.from(userIds).map((userId) => BigInt(userId)); } + /** + * Loads member handles keyed by user id from the members schema. + * + * @param userIds User ids to resolve. + * @returns Map keyed by normalized user id string. + * @security Uses `Prisma.join` parameterization for safe binding and avoids + * SQL injection even though it queries across schema boundary `members.member`. + */ private async fetchMemberHandlesByUserId( userIds: bigint[], ): Promise> { @@ -1025,6 +1173,13 @@ export class ProjectService { } } + /** + * Looks up a pre-fetched handle by user id. + * + * @param userId Input user id in mixed primitive forms. + * @param handlesByUserId Handle lookup map. + * @returns Resolved handle or `null` when unavailable. + */ private getHandleByUserId( userId: bigint | number | string | null | undefined, handlesByUserId: Map, @@ -1038,6 +1193,12 @@ export class ProjectService { return handlesByUserId.get(parsedUserId.toString()) || null; } + /** + * Normalizes user id values into bigint when possible. + * + * @param userId Candidate user id. + * @returns Parsed bigint or `undefined` when invalid. + */ private parseUserIdValue( userId: bigint | number | string | null | undefined, ): bigint | undefined { @@ -1060,6 +1221,12 @@ export class ProjectService { return undefined; } + /** + * Normalizes a handle candidate to a trimmed non-empty string. + * + * @param value Unknown handle candidate. + * @returns Normalized handle or `undefined`. + */ private toOptionalHandle(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; @@ -1070,6 +1237,16 @@ export class ProjectService { return normalizedHandle || undefined; } + /** + * Parses list sort expression against an allowlist. + * + * Supports `field [asc|desc]` input and defaults to `createdAt asc`. + * + * @param sort Optional sort expression. + * @returns Prisma orderBy clause. + * @todo Replace the nested ternary field mapping with a + * `Map` for readability. + */ private resolveSort(sort?: string): Prisma.ProjectOrderByWithRelationInput { const defaultOrderBy: Prisma.ProjectOrderByWithRelationInput = { createdAt: 'asc', @@ -1113,6 +1290,15 @@ export class ProjectService { } as Prisma.ProjectOrderByWithRelationInput; } + /** + * Resolves list fields for the collection endpoint. + * + * Defaults `members`, `invites`, and `attachments` to `false` when no fields + * are requested to optimize list query payload size. + * + * @param fieldsParam Optional fields CSV. + * @returns Parsed field flags. + */ private resolveListFields(fieldsParam?: string): ParsedProjectFields { const parsedFields = parseFieldsParameter(fieldsParam); @@ -1128,6 +1314,14 @@ export class ProjectService { }; } + /** + * Ensures include fields required for permission-aware filtering. + * + * Forces `project_members` when invites or attachments are requested. + * + * @param requestedFields Requested field flags. + * @returns Include flags safe for relation filtering. + */ private resolveListIncludeFields( requestedFields: ParsedProjectFields, ): ParsedProjectFields { @@ -1145,6 +1339,17 @@ export class ProjectService { return requestedFields; } + /** + * Filters relation arrays according to caller permissions. + * + * Applies `READ_PROJECT_MEMBER`, invite visibility checks, and attachment + * filtering by ownership/admin status. + * + * @param project Project with optional relations. + * @param user Authenticated caller context. + * @param isAdmin Whether caller has admin-level project read privileges. + * @returns Filtered project relation view. + */ private filterProjectRelations( project: ProjectWithRawRelations, user: JwtUser, @@ -1195,6 +1400,13 @@ export class ProjectService { return clone; } + /** + * Removes relation keys that were not explicitly requested. + * + * @param project Project with optional relations. + * @param fields Parsed field flags. + * @returns Project with unrequested relations removed. + */ private filterProjectFields( project: ProjectWithRawRelations, fields: ParsedProjectFields, @@ -1218,6 +1430,13 @@ export class ProjectService { return clone; } + /** + * Validates and parses project id path input. + * + * @param projectId Raw project id value. + * @returns Parsed bigint project id. + * @throws BadRequestException When input is not a numeric string. + */ private parseProjectId(projectId: string): bigint { const normalizedProjectId = projectId.trim(); @@ -1228,6 +1447,13 @@ export class ProjectService { return BigInt(normalizedProjectId); } + /** + * Returns the authenticated actor user id as trimmed string. + * + * @param user Authenticated caller context. + * @returns Trimmed actor user id. + * @throws ForbiddenException When `user.userId` is absent. + */ private getActorUserId(user: JwtUser): string { if (!user.userId || String(user.userId).trim().length === 0) { throw new ForbiddenException('Authenticated user id is missing.'); @@ -1236,6 +1462,13 @@ export class ProjectService { return String(user.userId).trim(); } + /** + * Parses actor id into numeric audit column representation. + * + * @param user Authenticated caller context. + * @returns Numeric actor id. + * @throws ForbiddenException When actor id is missing or non-numeric. + */ private getAuditUserId(user: JwtUser): number { const actorId = this.getActorUserId(user); const parsedActorId = Number.parseInt(actorId, 10); @@ -1247,6 +1480,12 @@ export class ProjectService { return parsedActorId; } + /** + * Safely extracts template phase objects from JSON payload. + * + * @param templatePhases Raw template phases JSON value. + * @returns Typed phase object list. + */ private extractTemplatePhases( templatePhases: Prisma.JsonValue, ): Array> { @@ -1260,6 +1499,18 @@ export class ProjectService { ); } + /** + * Creates project estimation and estimation-item rows in a transaction. + * + * Validates `buildingBlockKey` before creating rows. + * + * @param prismaTx Active Prisma transaction client. + * @param estimation Estimation payload to persist. + * @param projectId Project id for association. + * @param auditUserId Numeric audit actor id. + * @returns Promise resolved when estimation rows are persisted. + * @throws BadRequestException When `buildingBlockKey` is unknown. + */ private async createEstimation( prismaTx: Prisma.TransactionClient, estimation: EstimationDto, @@ -1313,6 +1564,14 @@ export class ProjectService { } } + /** + * Fire-and-forget event publication wrapper. + * + * Logs publication failures and intentionally does not rethrow. + * + * @param topic Kafka topic name. + * @param payload Event payload. + */ private publishEvent(topic: string, payload: unknown): void { void publishProjectEvent(topic, payload).catch((error) => { this.logger.error( @@ -1322,12 +1581,26 @@ export class ProjectService { }); } + /** + * Converts raw project entity into API DTO shape. + * + * @param project Raw project entity. + * @returns Serialized project DTO. + */ private toDto(project: ProjectWithRawRelations): ProjectWithRelationsDto { return this.normalizeProjectEntity( project, ) as unknown as ProjectWithRelationsDto; } + /** + * Converts nullable values into Prisma JSON input type. + * + * @param value Value to convert. + * @returns Prisma nullable JSON input. + * @todo Consolidate with `toJsonInput`; both methods are nearly identical + * and differ primarily in return type annotations. + */ private toNullableJsonInput( value: unknown, ): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | undefined { @@ -1342,6 +1615,14 @@ export class ProjectService { return value as Prisma.InputJsonValue; } + /** + * Converts values into Prisma JSON input type. + * + * @param value Value to convert. + * @returns Prisma JSON input. + * @todo Consolidate with `toNullableJsonInput`; both methods are nearly + * identical and can be unified with a generic/union helper. + */ private toJsonInput( value: unknown, ): Prisma.InputJsonValue | Prisma.JsonNullValueInput | undefined { @@ -1356,6 +1637,15 @@ export class ProjectService { return value as Prisma.InputJsonValue; } + /** + * Recursively normalizes project payload values for JSON serialization. + * + * Converts `bigint` to string and `Prisma.Decimal` to number while + * preserving arrays, objects, and `Date` instances. + * + * @param payload Payload to normalize. + * @returns Normalized payload. + */ private normalizeProjectEntity(payload: T): T { const walk = (input: unknown): unknown => { if (typeof input === 'bigint') { @@ -1390,6 +1680,12 @@ export class ProjectService { return walk(payload) as T; } + /** + * Converts optional bigint to optional string. + * + * @param value Bigint value. + * @returns String representation or `undefined`. + */ private toOptionalBigintString( value: bigint | null | undefined, ): string | undefined { @@ -1400,6 +1696,12 @@ export class ProjectService { return value.toString(); } + /** + * Batch-loads billing account names for project list responses. + * + * @param projects Project rows that may contain billing-account ids. + * @returns Map of billing account id to billing account name. + */ private async getBillingAccountNamesById( projects: Project[], ): Promise> { diff --git a/src/api/workstream/workstream.controller.ts b/src/api/workstream/workstream.controller.ts index 5974275..662bc52 100644 --- a/src/api/workstream/workstream.controller.ts +++ b/src/api/workstream/workstream.controller.ts @@ -45,13 +45,29 @@ const WORKSTREAM_ALLOWED_ROLES = [ UserRole.TC_COPILOT, UserRole.COPILOT_MANAGER, ]; +// TODO [DRY]: Extract a single `WORK_LAYER_ALLOWED_ROLES` constant to `src/shared/constants/roles.ts`. @ApiTags('WorkStream') @ApiBearerAuth() @Controller('/projects/:projectId/workstreams') +/** + * REST controller for work streams under `/projects/:projectId/workstreams`. + * Work streams are containers for works (project phases) linked via the + * `phase_work_streams` join table. Access is restricted to + * admin/manager/copilot roles. Used by the platform-ui Work app. + */ export class WorkStreamController { constructor(private readonly service: WorkStreamService) {} + /** + * Lists project work streams. + * + * @param projectId - Project id from the route. + * @param criteria - Pagination/filter/sort criteria. + * @returns Work stream DTO list. + * @throws {BadRequestException} When route id or criteria are invalid. + * @throws {NotFoundException} When project is not found. + */ @Get() @UseGuards(PermissionGuard) @Roles(...WORKSTREAM_ALLOWED_ROLES) @@ -80,6 +96,16 @@ export class WorkStreamController { return this.service.findAll(projectId, criteria); } + /** + * Creates a work stream under a project. + * + * @param projectId - Project id from the route. + * @param dto - Create payload. + * @param user - Authenticated user. + * @returns Created work stream DTO. + * @throws {BadRequestException} When input is invalid. + * @throws {NotFoundException} When project is not found. + */ @Post() @UseGuards(PermissionGuard) @Roles(...WORKSTREAM_ALLOWED_ROLES) @@ -106,6 +132,17 @@ export class WorkStreamController { return this.service.create(projectId, dto, user.userId); } + /** + * Fetches one work stream, optionally including linked works when + * `includeWorks=true`. + * + * @param projectId - Project id from the route. + * @param id - Work stream id from the route. + * @param query - Get criteria including `includeWorks`. + * @returns Work stream DTO. + * @throws {BadRequestException} When route ids are invalid. + * @throws {NotFoundException} When project or work stream is not found. + */ @Get(':id') @UseGuards(PermissionGuard) @Roles(...WORKSTREAM_ALLOWED_ROLES) @@ -137,6 +174,17 @@ export class WorkStreamController { return this.service.findOne(projectId, id, query.includeWorks === true); } + /** + * Partially updates mutable work stream fields. + * + * @param projectId - Project id from the route. + * @param id - Work stream id from the route. + * @param dto - Update payload. + * @param user - Authenticated user. + * @returns Updated work stream DTO. + * @throws {BadRequestException} When route ids or payload are invalid. + * @throws {NotFoundException} When project or work stream is not found. + */ @Patch(':id') @UseGuards(PermissionGuard) @Roles(...WORKSTREAM_ALLOWED_ROLES) @@ -164,6 +212,16 @@ export class WorkStreamController { return this.service.update(projectId, id, dto, user.userId); } + /** + * Soft deletes a work stream. + * + * @param projectId - Project id from the route. + * @param id - Work stream id from the route. + * @param user - Authenticated user. + * @returns Nothing. + * @throws {BadRequestException} When route ids are invalid. + * @throws {NotFoundException} When project or work stream is not found. + */ @Delete(':id') @HttpCode(204) @UseGuards(PermissionGuard) diff --git a/src/api/workstream/workstream.module.ts b/src/api/workstream/workstream.module.ts index 5b7f26d..6199d82 100644 --- a/src/api/workstream/workstream.module.ts +++ b/src/api/workstream/workstream.module.ts @@ -10,4 +10,9 @@ import { WorkStreamService } from './workstream.service'; providers: [WorkStreamService], exports: [WorkStreamService], }) +/** + * NestJS feature module for work streams. Exported so `ProjectPhaseModule` and + * `PhaseProductModule` can inject `WorkStreamService` into their alias + * controllers. + */ export class WorkStreamModule {} diff --git a/src/api/workstream/workstream.service.ts b/src/api/workstream/workstream.service.ts index a88f9d5..ca18918 100644 --- a/src/api/workstream/workstream.service.ts +++ b/src/api/workstream/workstream.service.ts @@ -26,9 +26,27 @@ type WorkStreamWithRelations = WorkStream & { const WORK_STREAM_SORT_FIELDS = ['name', 'status', 'createdAt', 'updatedAt']; @Injectable() +/** + * Business logic for work streams. Manages CRUD and the `phase_work_streams` + * join table linking phases (works) to work streams. Exposes helper methods + * (`ensureWorkStreamExists`, `listLinkedPhaseIds`, + * `ensurePhaseLinkedToWorkStream`, `createLink`) used by alias work/work-item + * controllers. + */ export class WorkStreamService { + // TODO [DRY]: `WORKSTREAM_ALLOWED_ROLES` is duplicated with `WORK_ALLOWED_ROLES` and `WORKITEM_ALLOWED_ROLES`; extract `WORK_LAYER_ALLOWED_ROLES` to `src/shared/constants/roles.ts`. constructor(private readonly prisma: PrismaService) {} + /** + * Creates a work stream and stores audit fields from the caller user id. + * + * @param projectId - Project id from the route. + * @param dto - Create payload. + * @param userId - Caller user id for audit columns. + * @returns Created work stream DTO. + * @throws {BadRequestException} When route id is invalid. + * @throws {NotFoundException} When project does not exist. + */ async create( projectId: string, dto: CreateWorkStreamDto, @@ -50,12 +68,22 @@ export class WorkStreamService { }); const response = this.toDto(created); + // TODO [QUALITY]: Remove `void` suppressions; these variables are already consumed earlier in the method. void projectId; void userId; return response; } + /** + * Returns paginated work streams with optional status filtering and sort. + * + * @param projectId - Project id from the route. + * @param criteria - List criteria. + * @returns Work stream DTO list. + * @throws {BadRequestException} When route id or sort is invalid. + * @throws {NotFoundException} When project does not exist. + */ async findAll( projectId: string, criteria: WorkStreamListCriteria, @@ -80,6 +108,16 @@ export class WorkStreamService { return rows.map((row) => this.toDto(row)); } + /** + * Fetches one work stream, optionally including linked works (phases). + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param includeWorks - Whether linked works should be included. + * @returns Work stream DTO. + * @throws {BadRequestException} When route ids are invalid. + * @throws {NotFoundException} When project or work stream is missing. + */ async findOne( projectId: string, workStreamId: string, @@ -125,6 +163,17 @@ export class WorkStreamService { return this.toDto(row); } + /** + * Partially updates work stream fields (`name`, `type`, `status`). + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param dto - Update payload. + * @param userId - Caller user id for audit columns. + * @returns Updated work stream DTO. + * @throws {BadRequestException} When route ids are invalid. + * @throws {NotFoundException} When project or work stream is missing. + */ async update( projectId: string, workStreamId: string, @@ -169,6 +218,7 @@ export class WorkStreamService { }); const response = this.toDto(updated); + // TODO [QUALITY]: Remove `void` suppressions; these variables are already consumed earlier in the method. void projectId; void userId; void existing; @@ -176,6 +226,16 @@ export class WorkStreamService { return response; } + /** + * Soft deletes a work stream. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param userId - Caller user id for audit columns. + * @returns Nothing. + * @throws {BadRequestException} When route ids are invalid. + * @throws {NotFoundException} When project or work stream is missing. + */ async delete( projectId: string, workStreamId: string, @@ -218,6 +278,14 @@ export class WorkStreamService { void deleted; } + /** + * Guard helper that ensures a work stream exists in a project. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @returns Nothing. + * @throws {NotFoundException} When work stream is missing. + */ async ensureWorkStreamExists( projectId: string, workStreamId: string, @@ -225,6 +293,15 @@ export class WorkStreamService { await this.findOne(projectId, workStreamId); } + /** + * Lists linked phase ids for a work stream in ascending phase id order. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @returns Ordered phase id array. + * @throws {BadRequestException} When route ids are invalid. + * @throws {NotFoundException} When project is missing. + */ async listLinkedPhaseIds( projectId: string, workStreamId: string, @@ -256,6 +333,16 @@ export class WorkStreamService { return links.map((link) => link.phaseId); } + /** + * Guard helper that ensures a phase is linked to a work stream. + * + * @param projectId - Project id from the route. + * @param workStreamId - Work stream id from the route. + * @param phaseId - Phase id from the route. + * @returns Nothing. + * @throws {BadRequestException} When route ids are invalid. + * @throws {NotFoundException} When no link exists. + */ async ensurePhaseLinkedToWorkStream( projectId: string, workStreamId: string, @@ -291,6 +378,14 @@ export class WorkStreamService { } } + /** + * Creates a phase/work-stream link row. + * + * @param workStreamId - Work stream id. + * @param phaseId - Phase id. + * @returns Nothing. + * @throws {BadRequestException} When ids are invalid. + */ async createLink(workStreamId: string, phaseId: string): Promise { const parsedWorkStreamId = this.parseId(workStreamId, 'Work stream'); const parsedPhaseId = this.parseId(phaseId, 'Work'); @@ -303,6 +398,13 @@ export class WorkStreamService { }); } + /** + * Ensures a project exists and is not soft deleted. + * + * @param projectId - Parsed project id. + * @returns Nothing. + * @throws {NotFoundException} When project does not exist. + */ private async ensureProjectExists(projectId: bigint): Promise { const project = await this.prisma.project.findFirst({ where: { @@ -321,6 +423,14 @@ export class WorkStreamService { } } + /** + * Validates and parses work stream list sort expression. + * + * @param sort - Sort expression (`field direction`). + * @returns Prisma orderBy object. + * @throws {BadRequestException} When field/direction is invalid. + */ + // TODO [DRY]: Extract a shared `parseSortParam(sort, allowedFields)` helper to `src/shared/utils/query.utils.ts`. private parseSort(sort?: string): Prisma.WorkStreamOrderByWithRelationInput { if (!sort || sort.trim().length === 0) { return { @@ -352,6 +462,12 @@ export class WorkStreamService { }; } + /** + * Maps work stream entities (with optional linked phase relation) to DTO. + * + * @param row - Work stream row. + * @returns Response DTO. + */ private toDto(row: WorkStreamWithRelations): WorkStreamResponseDto { const response: WorkStreamResponseDto = { id: row.id.toString(), @@ -378,6 +494,14 @@ export class WorkStreamService { return response; } + /** + * Parses route ids as bigint values. + * + * @param value - Raw id value. + * @param entityName - Entity label for errors. + * @returns Parsed id. + * @throws {BadRequestException} When parsing fails. + */ private parseId(value: string, entityName: string): bigint { try { return BigInt(value); @@ -386,6 +510,12 @@ export class WorkStreamService { } } + /** + * Parses caller user id into bigint for audit columns. + * + * @param userId - Raw user id value. + * @returns Parsed audit user id, or `-1n` fallback. + */ private getAuditUserId(userId: string | number | undefined): bigint { if (typeof userId === 'number') { return BigInt(Math.trunc(userId)); @@ -402,6 +532,13 @@ export class WorkStreamService { return BigInt(-1); } + /** + * Parses caller user id into number for numeric audit columns. + * + * @param userId - Raw user id value. + * @returns Parsed numeric user id, or `-1` fallback. + */ + // TODO [QUALITY]: Consolidate into a single `getAuditUserId(userId): number` helper (consistent with other services) and cast to `BigInt` at the call site. private getAuditUserIdNumber(userId: string | number | undefined): number { if (typeof userId === 'number') { return Math.trunc(userId); diff --git a/src/main.ts b/src/main.ts index f0aaee0..8af8af2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -47,6 +47,10 @@ function serializeBigInt(value: unknown): unknown { return value.toString(); } + if (value instanceof Date) { + return value; + } + if (Array.isArray(value)) { return value.map((entry) => serializeBigInt(entry)); } diff --git a/src/shared/constants/permissions.constants.ts b/src/shared/constants/permissions.constants.ts index 23bd04d..f3ea70a 100644 --- a/src/shared/constants/permissions.constants.ts +++ b/src/shared/constants/permissions.constants.ts @@ -348,9 +348,6 @@ export const PERMISSION = { /** * @description Permission policy: LIST_COPILOT_OPPORTUNITY. - * @todo `projectRoles` currently uses `USER_ROLE.PROJECT_MANAGER`, which is a - * Topcoder role value and will never match a project member role. Replace - * with `PROJECT_MEMBER_ROLE.PROJECT_MANAGER` or move it to `topcoderRoles`. */ LIST_COPILOT_OPPORTUNITY: { meta: { @@ -359,7 +356,7 @@ export const PERMISSION = { description: 'Who can apply for copilot opportunity.', }, topcoderRoles: [USER_ROLE.TOPCODER_ADMIN], - projectRoles: [USER_ROLE.PROJECT_MANAGER], + projectRoles: [PROJECT_MEMBER_ROLE.PROJECT_MANAGER], scopes: SCOPES_PROJECTS_WRITE, }, diff --git a/src/shared/constants/permissions.ts b/src/shared/constants/permissions.ts index f623c8c..09de334 100644 --- a/src/shared/constants/permissions.ts +++ b/src/shared/constants/permissions.ts @@ -6,6 +6,9 @@ * objects passed directly to the decorator. */ export enum Permission { + /** + * Project permissions. + */ /** Read any project even when the user is not a member. */ READ_PROJECT_ANY = 'READ_PROJECT_ANY', /** View project details. */ @@ -16,6 +19,9 @@ export enum Permission { EDIT_PROJECT = 'EDIT_PROJECT', /** Delete a project. */ DELETE_PROJECT = 'DELETE_PROJECT', + /** + * Project member permissions. + */ /** Read project members. */ READ_PROJECT_MEMBER = 'READ_PROJECT_MEMBER', /** Add the current user as a project member. */ @@ -30,6 +36,9 @@ export enum Permission { DELETE_PROJECT_MEMBER_CUSTOMER = 'DELETE_PROJECT_MEMBER_CUSTOMER', /** Remove copilot project members. */ DELETE_PROJECT_MEMBER_COPILOT = 'DELETE_PROJECT_MEMBER_COPILOT', + /** + * Project invite permissions. + */ /** Read own project invites. */ READ_PROJECT_INVITE_OWN = 'READ_PROJECT_INVITE_OWN', /** Read project invites that belong to other users. */ @@ -74,6 +83,9 @@ export enum Permission { CANCEL_COPILOT_OPPORTUNITY = 'CANCEL_COPILOT_OPPORTUNITY', /** Create a project as manager role. */ CREATE_PROJECT_AS_MANAGER = 'CREATE_PROJECT_AS_MANAGER', + /** + * Attachment permissions. + */ /** View project attachments. */ VIEW_PROJECT_ATTACHMENT = 'VIEW_PROJECT_ATTACHMENT', /** Create project attachments. */ @@ -84,18 +96,27 @@ export enum Permission { UPDATE_PROJECT_ATTACHMENT_NOT_OWN = 'UPDATE_PROJECT_ATTACHMENT_NOT_OWN', /** Delete project attachments. */ DELETE_PROJECT_ATTACHMENT = 'DELETE_PROJECT_ATTACHMENT', + /** + * Phase permissions. + */ /** Add project phases. */ ADD_PROJECT_PHASE = 'ADD_PROJECT_PHASE', /** Update project phases. */ UPDATE_PROJECT_PHASE = 'UPDATE_PROJECT_PHASE', /** Delete project phases. */ DELETE_PROJECT_PHASE = 'DELETE_PROJECT_PHASE', + /** + * Phase product permissions. + */ /** Add phase products. */ ADD_PHASE_PRODUCT = 'ADD_PHASE_PRODUCT', /** Update phase products. */ UPDATE_PHASE_PRODUCT = 'UPDATE_PHASE_PRODUCT', /** Delete phase products. */ DELETE_PHASE_PRODUCT = 'DELETE_PHASE_PRODUCT', + /** + * Workstream permissions. + */ /** Workstream create permission. */ WORKSTREAM_CREATE = 'workStream.create', /** Workstream view permission. */ @@ -104,6 +125,9 @@ export enum Permission { WORKSTREAM_EDIT = 'workStream.edit', /** Workstream delete permission. */ WORKSTREAM_DELETE = 'workStream.delete', + /** + * Work permissions. + */ /** Work create permission. */ WORK_CREATE = 'work.create', /** Work view permission. */ @@ -112,6 +136,9 @@ export enum Permission { WORK_EDIT = 'work.edit', /** Work delete permission. */ WORK_DELETE = 'work.delete', + /** + * Work item permissions. + */ /** Work item create permission. */ WORKITEM_CREATE = 'workItem.create', /** Work item view permission. */ @@ -120,6 +147,9 @@ export enum Permission { WORKITEM_EDIT = 'workItem.edit', /** Work item delete permission. */ WORKITEM_DELETE = 'workItem.delete', + /** + * Work-management permission settings. + */ /** View work-management permission settings. */ WORK_MANAGEMENT_PERMISSION_VIEW = 'workManagementPermission.view', /** Edit work-management permission settings. */ diff --git a/src/shared/guards/permission.guard.spec.ts b/src/shared/guards/permission.guard.spec.ts index f811935..6562a8a 100644 --- a/src/shared/guards/permission.guard.spec.ts +++ b/src/shared/guards/permission.guard.spec.ts @@ -184,4 +184,34 @@ describe('PermissionGuard', () => { expect(result).toBe(true); expect(prismaServiceMock.projectMember.findMany).not.toHaveBeenCalled(); }); + + it('does not refetch project members when a zero-member project was already loaded', async () => { + const permission: Permission = { + projectRoles: true, + }; + + reflectorMock.getAllAndOverride.mockReturnValue([permission]); + permissionServiceMock.isPermissionRequireProjectMembers.mockReturnValue( + true, + ); + permissionServiceMock.hasPermission.mockReturnValue(true); + prismaServiceMock.projectMember.findMany.mockResolvedValue([]); + + const request = { + user: { + userId: '123', + isMachine: false, + }, + params: { + projectId: '1001', + }, + }; + + await guard.canActivate(createExecutionContext(request)); + await guard.canActivate(createExecutionContext(request)); + + expect(prismaServiceMock.projectMember.findMany).toHaveBeenCalledTimes(1); + expect(request.projectContext.projectMembersLoaded).toBe(true); + expect(request.projectContext.projectMembers).toEqual([]); + }); }); diff --git a/src/shared/guards/permission.guard.ts b/src/shared/guards/permission.guard.ts index c4d22a5..057b671 100644 --- a/src/shared/guards/permission.guard.ts +++ b/src/shared/guards/permission.guard.ts @@ -102,12 +102,8 @@ export class PermissionGuard implements CanActivate { * Behavior: * - Skips DB access if no project id or no project-scoped permission exists. * - Resets cached context when project id changes. - * - Loads `projectMember` rows when required and cache is empty. + * - Loads `projectMember` rows when required and members are not loaded yet. * - Loads `projectMemberInvite` rows when required and invites are not loaded. - * - * @todo `projectMembers.length === 0` causes re-fetch for projects that - * genuinely have zero members. Add a sentinel flag such as - * `projectMembersLoaded: boolean`. * @todo Member/invite query and mapping logic is duplicated in multiple guards * and `ProjectContextInterceptor`; extract a shared `ProjectContextService`. */ @@ -145,18 +141,27 @@ export class PermissionGuard implements CanActivate { if (!request.projectContext) { request.projectContext = { projectMembers: [], + projectMembersLoaded: false, }; + } else if ( + request.projectContext.projectMembersLoaded === undefined && + request.projectContext.projectId === normalizedProjectId && + Array.isArray(request.projectContext.projectMembers) + ) { + // Backward-compatible bridge for context objects that predate the flag. + request.projectContext.projectMembersLoaded = true; } if (request.projectContext.projectId !== normalizedProjectId) { request.projectContext.projectId = normalizedProjectId; request.projectContext.projectMembers = []; + request.projectContext.projectMembersLoaded = false; request.projectContext.projectInvites = []; } if ( requiresProjectMembers && - request.projectContext.projectMembers.length === 0 + request.projectContext.projectMembersLoaded !== true ) { const projectMembers = await this.prisma.projectMember.findMany({ where: { @@ -177,6 +182,7 @@ export class PermissionGuard implements CanActivate { ...member, role: String(member.role), })); + request.projectContext.projectMembersLoaded = true; } if ( diff --git a/src/shared/guards/tokenRoles.guard.spec.ts b/src/shared/guards/tokenRoles.guard.spec.ts index 6e5eb81..b2a7456 100644 --- a/src/shared/guards/tokenRoles.guard.spec.ts +++ b/src/shared/guards/tokenRoles.guard.spec.ts @@ -8,7 +8,11 @@ import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { SCOPES_KEY } from '../decorators/scopes.decorator'; import { JwtService } from '../modules/global/jwt.service'; import { M2MService } from '../modules/global/m2m.service'; -import { ROLES_KEY, TokenRolesGuard } from './tokenRoles.guard'; +import { + ANY_AUTHENTICATED_KEY, + ROLES_KEY, + TokenRolesGuard, +} from './tokenRoles.guard'; describe('TokenRolesGuard', () => { let guard: TokenRolesGuard; @@ -97,6 +101,83 @@ describe('TokenRolesGuard', () => { ).rejects.toBeInstanceOf(UnauthorizedException); }); + it('throws ForbiddenException when route has no auth metadata', async () => { + reflectorMock.getAllAndOverride.mockImplementation((key: string) => { + if (key === IS_PUBLIC_KEY) { + return false; + } + if (key === ROLES_KEY) { + return []; + } + if (key === SCOPES_KEY) { + return []; + } + if (key === ANY_AUTHENTICATED_KEY) { + return false; + } + return undefined; + }); + + jwtServiceMock.validateToken.mockResolvedValue({ + roles: [], + scopes: [], + isMachine: false, + tokenPayload: { + sub: '123', + }, + }); + + await expect( + guard.canActivate( + createExecutionContext({ + headers: { + authorization: 'Bearer human-token', + }, + }), + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('allows authenticated route when @AnyAuthenticated metadata is present', async () => { + const request: Record = { + headers: { + authorization: 'Bearer human-token', + }, + }; + const user = { + roles: [], + scopes: [], + isMachine: false, + tokenPayload: { + sub: '123', + }, + }; + + reflectorMock.getAllAndOverride.mockImplementation((key: string) => { + if (key === IS_PUBLIC_KEY) { + return false; + } + if (key === ROLES_KEY) { + return []; + } + if (key === SCOPES_KEY) { + return []; + } + if (key === ANY_AUTHENTICATED_KEY) { + return true; + } + return undefined; + }); + + jwtServiceMock.validateToken.mockResolvedValue(user); + + const result = await guard.canActivate(createExecutionContext(request)); + + expect(result).toBe(true); + expect(request.user).toEqual(user); + expect(m2mServiceMock.validateMachineToken).not.toHaveBeenCalled(); + }); + it('allows human token when required role is present', async () => { const request: Record = { headers: { diff --git a/src/shared/guards/tokenRoles.guard.ts b/src/shared/guards/tokenRoles.guard.ts index c3e56b5..4a31998 100644 --- a/src/shared/guards/tokenRoles.guard.ts +++ b/src/shared/guards/tokenRoles.guard.ts @@ -32,6 +32,14 @@ export const ROLES_KEY = 'roles'; * Swagger extension key used to expose required roles in OpenAPI operations. */ export const SWAGGER_REQUIRED_ROLES_KEY = 'x-required-roles'; +/** + * Metadata key for explicit "any authenticated token" access. + */ +export const ANY_AUTHENTICATED_KEY = 'any_authenticated'; +/** + * Swagger extension key used to expose explicit any-authenticated access. + */ +export const SWAGGER_ANY_AUTHENTICATED_KEY = 'x-any-authenticated'; /** * Declares allowed Topcoder roles for a route. * @@ -45,6 +53,18 @@ export const Roles = (...roles: string[]) => ApiExtension(SWAGGER_REQUIRED_ROLES_KEY, roles), ); +/** + * Marks a route as accessible by any validated token. + * + * Use this decorator explicitly when a route should not constrain roles/scopes + * beyond authentication itself. + */ +export const AnyAuthenticated = () => + applyDecorators( + SetMetadata(ANY_AUTHENTICATED_KEY, true), + ApiExtension(SWAGGER_ANY_AUTHENTICATED_KEY, true), + ); + /** * Global auth guard that validates JWT tokens and applies role/scope checks. */ @@ -69,15 +89,12 @@ export class TokenRolesGuard implements CanActivate { * - Throws `UnauthorizedException` when Bearer token is absent or malformed. * - Calls `JwtService.validateToken` and stores the validated user on request. * - Reads both `@Roles()` and `@Scopes()` metadata. - * - Returns `true` for any authenticated user if both metadata lists are empty. + * - Requires one of `@Roles()`, `@Scopes()`, or `@AnyAuthenticated()`. + * - Throws `ForbiddenException` when no auth metadata is declared. + * - Returns `true` for any authenticated user when `@AnyAuthenticated()` is present. * - For M2M tokens: requires declared scopes and checks scope intersection. * - For human tokens: allows if any required role or scope matches. * - Throws `ForbiddenException('Insufficient permissions')` otherwise. - * - * @security Endpoints without `@Roles()` and `@Scopes()` are reachable by any - * valid token. - * @todo Move toward a default-deny posture by requiring at least one auth - * decorator, or add an explicit `@AnyAuthenticated()` marker. */ async canActivate(context: ExecutionContext): Promise { const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ @@ -118,11 +135,21 @@ export class TokenRolesGuard implements CanActivate { const normalizedRequiredRoles = this.normalizeValues(requiredRoles); const normalizedRequiredScopes = this.normalizeValues(requiredScopes); + const isAnyAuthenticated = + this.reflector.getAllAndOverride(ANY_AUTHENTICATED_KEY, [ + context.getHandler(), + context.getClass(), + ]) || false; if ( normalizedRequiredRoles.length === 0 && - normalizedRequiredScopes.length === 0 + normalizedRequiredScopes.length === 0 && + !isAnyAuthenticated ) { + throw new ForbiddenException('Authorization metadata is required'); + } + + if (isAnyAuthenticated) { return true; } diff --git a/src/shared/interfaces/permission.interface.ts b/src/shared/interfaces/permission.interface.ts index f695434..402f13e 100644 --- a/src/shared/interfaces/permission.interface.ts +++ b/src/shared/interfaces/permission.interface.ts @@ -115,6 +115,10 @@ export interface ProjectContext { * Currently cached project id. */ projectId?: string; + /** + * Whether members were already loaded for the current `projectId`. + */ + projectMembersLoaded?: boolean; /** * Cached project members for the current project id. */ diff --git a/src/shared/utils/event.utils.ts b/src/shared/utils/event.utils.ts index af89687..30778ee 100644 --- a/src/shared/utils/event.utils.ts +++ b/src/shared/utils/event.utils.ts @@ -319,6 +319,12 @@ async function postEventWithRetry( ); } +/** + * Builds `{ resource: 'project', data }` payload. + * + * @todo These `buildXxxEventPayload` functions are structurally identical. + * Replace with `buildEventPayload(resource, data)` + resource constants map. + */ function buildProjectEventPayload(project: unknown): unknown { return { resource: 'project', @@ -326,6 +332,9 @@ function buildProjectEventPayload(project: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.member', data }` payload. + */ function buildMemberEventPayload(member: unknown): unknown { return { resource: 'project.member', @@ -333,6 +342,9 @@ function buildMemberEventPayload(member: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.member.invite', data }` payload. + */ function buildInviteEventPayload(invite: unknown): unknown { return { resource: 'project.member.invite', @@ -340,6 +352,9 @@ function buildInviteEventPayload(invite: unknown): unknown { }; } +/** + * Builds `{ resource: 'attachment', data }` payload. + */ function buildAttachmentEventPayload(attachment: unknown): unknown { return { resource: 'attachment', @@ -347,6 +362,9 @@ function buildAttachmentEventPayload(attachment: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.phase', data }` payload. + */ function buildPhaseEventPayload(phase: unknown): unknown { return { resource: 'project.phase', @@ -354,6 +372,9 @@ function buildPhaseEventPayload(phase: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.phase.product', data }` payload. + */ function buildPhaseProductEventPayload(phaseProduct: unknown): unknown { return { resource: 'project.phase.product', @@ -361,6 +382,9 @@ function buildPhaseProductEventPayload(phaseProduct: unknown): unknown { }; } +/** + * Builds `{ resource: 'timeline', data }` payload. + */ function buildTimelineEventPayload(timeline: unknown): unknown { return { resource: 'timeline', @@ -368,6 +392,9 @@ function buildTimelineEventPayload(timeline: unknown): unknown { }; } +/** + * Builds `{ resource: 'milestone', data }` payload. + */ function buildMilestoneEventPayload(milestone: unknown): unknown { return { resource: 'milestone', @@ -375,6 +402,9 @@ function buildMilestoneEventPayload(milestone: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.workstream', data }` payload. + */ function buildWorkstreamEventPayload(workstream: unknown): unknown { return { resource: 'project.workstream', @@ -382,6 +412,9 @@ function buildWorkstreamEventPayload(workstream: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.work', data }` payload. + */ function buildWorkEventPayload(work: unknown): unknown { return { resource: 'project.work', @@ -389,6 +422,9 @@ function buildWorkEventPayload(work: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.workitem', data }` payload. + */ function buildWorkItemEventPayload(workItem: unknown): unknown { return { resource: 'project.workitem', @@ -396,6 +432,9 @@ function buildWorkItemEventPayload(workItem: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.setting', data }` payload. + */ function buildSettingEventPayload(setting: unknown): unknown { return { resource: 'project.setting', @@ -403,6 +442,16 @@ function buildSettingEventPayload(setting: unknown): unknown { }; } +/** + * Publishes a project event envelope. + * + * @param topic Kafka topic name. + * @param project Domain payload wrapped as `resource: 'project'`. + * @param callback Optional success callback. + * + * @todo All `publishXxxEvent` helpers share identical try/catch + retry + * wrappers. Consolidate into `publishEvent(topic, resource, data, callback?)`. + */ export async function publishProjectEvent( topic: string, project: unknown, @@ -422,6 +471,9 @@ export async function publishProjectEvent( } } +/** + * Publishes a project-member event envelope. + */ export async function publishMemberEvent( topic: string, member: unknown, @@ -441,6 +493,9 @@ export async function publishMemberEvent( } } +/** + * Publishes a project-member-invite event envelope. + */ export async function publishInviteEvent( topic: string, invite: unknown, @@ -460,6 +515,9 @@ export async function publishInviteEvent( } } +/** + * Publishes an attachment event envelope. + */ export async function publishAttachmentEvent( topic: string, attachment: unknown, @@ -479,6 +537,9 @@ export async function publishAttachmentEvent( } } +/** + * Publishes a project-phase event envelope. + */ export async function publishPhaseEvent( topic: string, phase: unknown, @@ -498,6 +559,9 @@ export async function publishPhaseEvent( } } +/** + * Publishes a phase-product event envelope. + */ export async function publishPhaseProductEvent( topic: string, phaseProduct: unknown, @@ -517,6 +581,9 @@ export async function publishPhaseProductEvent( } } +/** + * Publishes a timeline event envelope. + */ export async function publishTimelineEvent( topic: string, timeline: unknown, @@ -536,6 +603,9 @@ export async function publishTimelineEvent( } } +/** + * Publishes a milestone event envelope. + */ export async function publishMilestoneEvent( topic: string, milestone: unknown, @@ -555,6 +625,9 @@ export async function publishMilestoneEvent( } } +/** + * Publishes a workstream event envelope. + */ export async function publishWorkstreamEvent( topic: string, workstream: unknown, @@ -574,6 +647,9 @@ export async function publishWorkstreamEvent( } } +/** + * Publishes a work event envelope. + */ export async function publishWorkEvent( topic: string, work: unknown, @@ -593,6 +669,9 @@ export async function publishWorkEvent( } } +/** + * Publishes a work-item event envelope. + */ export async function publishWorkItemEvent( topic: string, workItem: unknown, @@ -612,6 +691,9 @@ export async function publishWorkItemEvent( } } +/** + * Publishes a project-setting event envelope. + */ export async function publishSettingEvent( topic: string, setting: unknown, @@ -631,6 +713,9 @@ export async function publishSettingEvent( } } +/** + * Publishes a raw notification payload without resource wrapping. + */ export async function publishNotificationEvent( topic: string, payload: unknown, @@ -650,6 +735,11 @@ export async function publishNotificationEvent( } } +/** + * Backward-compatible alias for `publishNotificationEvent`. + * + * @deprecated Use `publishNotificationEvent` instead. + */ export async function publishRawEvent( topic: string, payload: unknown, diff --git a/src/shared/utils/project.utils.ts b/src/shared/utils/project.utils.ts index a89599f..d8ee3ce 100644 --- a/src/shared/utils/project.utils.ts +++ b/src/shared/utils/project.utils.ts @@ -34,8 +34,9 @@ const DEFAULT_FIELDS: ParsedProjectFields = { /** * Normalizes string comparisons. * - * @todo Duplicate helper exists in other shared modules. Consolidate in a - * shared string utility. + * @todo Duplicate helper exists in other shared modules (including + * `normalize` variants in permission/member utilities). Consolidate in a + * single shared string utility. */ function normalize(value: string): string { return value.trim().toLowerCase(); @@ -44,8 +45,9 @@ function normalize(value: string): string { /** * Normalizes user ids to trimmed string form. * - * @todo Duplicate helper exists in other shared modules. Consolidate in a - * shared string utility. + * @todo Duplicate helper exists in other shared modules (including + * `src/shared/utils/member.utils.ts#normalizeUserId`). Consolidate user-id + * normalization in one shared utility. */ function normalizeUserId( value: string | number | bigint | null | undefined, diff --git a/src/shared/utils/swagger.utils.ts b/src/shared/utils/swagger.utils.ts index 469e5db..e9ecdc7 100644 --- a/src/shared/utils/swagger.utils.ts +++ b/src/shared/utils/swagger.utils.ts @@ -16,7 +16,10 @@ import { SWAGGER_ADMIN_ALLOWED_SCOPES_KEY, SWAGGER_ADMIN_ONLY_KEY, } from '../guards/adminOnly.guard'; -import { SWAGGER_REQUIRED_ROLES_KEY } from '../guards/tokenRoles.guard'; +import { + SWAGGER_ANY_AUTHENTICATED_KEY, + SWAGGER_REQUIRED_ROLES_KEY, +} from '../guards/tokenRoles.guard'; type SwaggerOperation = { description?: string; @@ -125,6 +128,7 @@ function ensureErrorResponses(operation: SwaggerOperation): void { function getAuthorizationLines(operation: SwaggerOperation): string[] { const roles = parseStringArray(operation[SWAGGER_REQUIRED_ROLES_KEY]); const scopes = parseStringArray(operation[SWAGGER_REQUIRED_SCOPES_KEY]); + const isAnyAuthenticated = Boolean(operation[SWAGGER_ANY_AUTHENTICATED_KEY]); const permissions = parsePermissionArray( operation[SWAGGER_REQUIRED_PERMISSIONS_KEY], ); @@ -138,6 +142,10 @@ function getAuthorizationLines(operation: SwaggerOperation): string[] { const authorizationLines: string[] = []; + if (isAnyAuthenticated) { + authorizationLines.push('Any authenticated token is allowed.'); + } + if (roles.length > 0) { authorizationLines.push(`Allowed user roles (any): ${roles.join(', ')}`); } From 7e3e378a6d63ca6720a8a5891fec1b070f74b170 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 20 Feb 2026 15:30:30 +1100 Subject: [PATCH 04/41] Completed documentation, dependency updates for security fixes --- README.md | 479 ++++++++++++++++-- docs/DEPENDENCIES.md | 92 ++++ package.json | 23 +- pnpm-lock.yaml | 284 +++++------ .../copilot/copilot-application.controller.ts | 23 + .../copilot/copilot-application.service.ts | 53 ++ .../copilot/copilot-notification.service.ts | 83 +++ .../copilot/copilot-opportunity.controller.ts | 41 ++ .../copilot/copilot-opportunity.service.ts | 92 ++++ src/api/copilot/copilot-request.controller.ts | 70 +++ src/api/copilot/copilot-request.service.ts | 140 +++++ src/api/copilot/copilot.module.ts | 14 + src/api/copilot/copilot.utils.ts | 61 +++ .../copilot/dto/copilot-application.dto.ts | 21 + .../copilot/dto/copilot-opportunity.dto.ts | 14 + src/api/copilot/dto/copilot-request.dto.ts | 29 ++ .../metadata/dto/metadata-reference.dto.ts | 7 + .../form/dto/create-form-revision.dto.ts | 5 + .../form/dto/create-form-version.dto.ts | 5 + .../metadata/form/dto/form-response.dto.ts | 13 + .../form/dto/update-form-version.dto.ts | 5 + .../metadata/form/form-revision.controller.ts | 29 ++ .../metadata/form/form-version.controller.ts | 34 ++ src/api/metadata/form/form.controller.ts | 19 +- src/api/metadata/form/form.module.ts | 4 + src/api/metadata/form/form.service.ts | 117 +++++ .../metadata/metadata-list-response.dto.ts | 12 + src/api/metadata/metadata-list.controller.ts | 16 + src/api/metadata/metadata-list.service.ts | 66 +++ src/api/metadata/metadata.module.ts | 20 + .../dto/clone-milestone-template.dto.ts | 7 + .../dto/create-milestone-template.dto.ts | 17 + .../dto/milestone-template-response.dto.ts | 22 + .../dto/update-milestone-template.dto.ts | 17 + .../milestone-template.module.ts | 5 + .../milestone-template.service.ts | 39 ++ .../org-config/dto/create-org-config.dto.ts | 7 + .../org-config/dto/org-config-response.dto.ts | 12 + .../org-config/dto/update-org-config.dto.ts | 7 + .../org-config/org-config.controller.ts | 20 + .../metadata/org-config/org-config.module.ts | 4 + .../metadata/org-config/org-config.service.ts | 35 ++ .../dto/create-plan-config-revision.dto.ts | 5 + .../dto/create-plan-config-version.dto.ts | 5 + .../dto/plan-config-response.dto.ts | 13 + .../dto/update-plan-config-version.dto.ts | 5 + .../plan-config-revision.controller.ts | 19 + .../plan-config-version.controller.ts | 22 + .../plan-config/plan-config.controller.ts | 19 +- .../plan-config/plan-config.module.ts | 5 + .../plan-config/plan-config.service.ts | 114 +++++ .../dto/create-price-config-revision.dto.ts | 5 + .../dto/create-price-config-version.dto.ts | 5 + .../dto/price-config-response.dto.ts | 13 + .../dto/update-price-config-version.dto.ts | 5 + .../price-config-revision.controller.ts | 19 + .../price-config-version.controller.ts | 22 + .../price-config/price-config.controller.ts | 17 +- .../price-config/price-config.module.ts | 5 + .../price-config/price-config.service.ts | 115 +++++ .../dto/create-product-category.dto.ts | 12 + .../dto/product-category-response.dto.ts | 16 + .../dto/update-product-category.dto.ts | 11 + .../product-category.controller.ts | 20 + .../product-category.module.ts | 4 + .../product-category.service.ts | 27 + .../dto/create-product-template.dto.ts | 17 + .../dto/product-template-response.dto.ts | 22 + .../dto/update-product-template.dto.ts | 17 + .../dto/upgrade-product-template.dto.ts | 10 + .../product-template.controller.ts | 27 + .../product-template.module.ts | 4 + .../product-template.service.ts | 54 ++ .../dto/create-project-template.dto.ts | 20 + .../dto/project-template-response.dto.ts | 25 + .../dto/update-project-template.dto.ts | 20 + .../dto/upgrade-project-template.dto.ts | 13 + .../project-template.controller.ts | 27 + .../project-template.module.ts | 4 + .../project-template.service.ts | 58 +++ .../dto/create-project-type.dto.ts | 13 + .../dto/project-type-response.dto.ts | 17 + .../dto/update-project-type.dto.ts | 12 + .../project-type/project-type.controller.ts | 20 + .../project-type/project-type.module.ts | 4 + .../project-type/project-type.service.ts | 27 + .../metadata/utils/metadata-event.utils.ts | 19 + src/api/metadata/utils/metadata-utils.ts | 73 +++ .../utils/metadata-validation.utils.ts | 51 ++ .../create-work-management-permission.dto.ts | 7 + ...-work-management-permission-request.dto.ts | 8 + .../update-work-management-permission.dto.ts | 7 + ...work-management-permission-criteria.dto.ts | 5 + ...work-management-permission-id-query.dto.ts | 5 + .../work-management-permission-query.dto.ts | 6 + ...work-management-permission-response.dto.ts | 12 + .../work-management-permission.controller.ts | 29 ++ .../work-management-permission.module.ts | 4 + .../work-management-permission.service.ts | 42 ++ .../dto/attachment-list-query.dto.ts | 4 + .../dto/attachment-response.dto.ts | 8 +- .../dto/update-attachment.dto.ts | 7 + src/api/workstream/workstream.dto.ts | 45 +- 103 files changed, 3059 insertions(+), 250 deletions(-) create mode 100644 docs/DEPENDENCIES.md diff --git a/README.md b/README.md index c107638..ce7fccd 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,344 @@ -# Topcoder Project API v6 +# Projects API v6 + +NestJS drop-in replacement for `tc-project-service`, serving the Topcoder platform at `/v6/projects`. + +[![CircleCI](https://img.shields.io/badge/CircleCI-build%20status-informational?logo=circleci)](https://circleci.com/) +![Node](https://img.shields.io/badge/node-v22.13.1-339933?logo=node.js&logoColor=white) +![pnpm](https://img.shields.io/badge/pnpm-10.28.2-F69220?logo=pnpm&logoColor=white) +![Audit](https://img.shields.io/badge/audit-2%20moderate%20ajv%20findings-orange) + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [API Reference](#api-reference) +- [Platform Consumers](#platform-consumers) +- [Authorization Model](#authorization-model) +- [Event Publishing](#event-publishing) +- [Environment Variables](#environment-variables) +- [Setup and Development](#setup-and-development) +- [Testing](#testing) +- [Deployment](#deployment) +- [Security Notes](#security-notes) +- [Dependency Status](#dependency-status) +- [Code Quality Notes](#code-quality-notes) +- [Migration from v5](#migration-from-v5) +- [Related Documentation](#related-documentation) + +## Overview + +Projects API v6 manages the full project lifecycle: project CRUD, member management, invites, phases, phase products, attachments, workstreams, copilot request/opportunity/application workflows, and metadata. + +It is the platform replacement for `tc-project-service` (`/v5`) and is consumed by multiple frontend apps and backend services. + +Key design decisions: + +- Elasticsearch removed; Prisma + PostgreSQL handle all reads and writes. +- NestJS modular architecture with focused feature modules. +- Layered JWT + M2M authorization. +- Kafka event publishing for mutation flows. + +## Architecture + +```mermaid +sequenceDiagram + participant Client + participant TokenRolesGuard + participant ProjectContextInterceptor + participant PermissionGuard + participant Controller + participant Service + participant Prisma + participant EventBus + + Client->>TokenRolesGuard: HTTP request + Bearer JWT / M2M token + TokenRolesGuard->>TokenRolesGuard: Validate JWT, set request.user + TokenRolesGuard->>ProjectContextInterceptor: pass + ProjectContextInterceptor->>Prisma: Load project members (if :projectId route) + ProjectContextInterceptor->>Controller: request.projectContext populated + Controller->>PermissionGuard: @RequirePermission check + PermissionGuard->>Controller: authorized + Controller->>Service: business logic + Service->>Prisma: DB query + Service->>EventBus: publish Kafka event (on mutations) + Service-->>Controller: result + Controller-->>Client: HTTP response +``` -Topcoder Project API v6 is a modern NestJS-based project service that serves as a drop-in replacement for `tc-project-service`. +| NestJS Module | Responsibility | +| --- | --- | +| `GlobalProvidersModule` | Prisma, JWT, M2M, Logger, EventBus, shared services | +| `ApiModule` | Aggregates all feature modules | +| `ProjectModule` | Project CRUD, listing, billing account lookup | +| `ProjectMemberModule` | Member add/update/remove | +| `ProjectInviteModule` | Invite create/update/delete, email notifications | +| `ProjectPhaseModule` | Phase CRUD | +| `PhaseProductModule` | Phase product CRUD | +| `ProjectAttachmentModule` | File/link attachment CRUD, S3 presigned URLs | +| `ProjectSettingModule` | Project settings | +| `WorkstreamModule` | Workstream + work + workitem CRUD | +| `CopilotModule` | Copilot request/opportunity/application workflows | +| `MetadataModule` | Project types, templates, forms, plan configs, org configs, milestone templates, work-management permissions | +| `HealthCheckModule` | `GET /v6/projects/health` | + +## API Reference + +For the full v5 -> v6 mapping table, see `docs/api-usage-analysis.md`. + +### Projects + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET` | `/v6/projects` | JWT / M2M | List projects with filters (`keyword`, `status`, `memberOnly`, `billingAccountId`, `sort`, `page`, `perPage`) | +| `POST` | `/v6/projects` | JWT / M2M | Create project | +| `GET` | `/v6/projects/:projectId` | JWT / M2M | Get project by ID (includes `members`, `invites`) | +| `PATCH` | `/v6/projects/:projectId` | JWT / M2M | Update project | +| `DELETE` | `/v6/projects/:projectId` | Admin only | Soft-delete project | +| `GET` | `/v6/projects/:projectId/billingAccount` | JWT / M2M | Default billing account (Salesforce) | +| `GET` | `/v6/projects/:projectId/billingAccounts` | JWT / M2M | All billing accounts for project | +| `GET` | `/v6/projects/:projectId/permissions` | JWT | Work-management permission map | + +### Members + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET` | `/v6/projects/:projectId/members` | JWT / M2M | List members | +| `GET` | `/v6/projects/:projectId/members/:id` | JWT / M2M | Get member | +| `POST` | `/v6/projects/:projectId/members` | JWT / M2M | Add member | +| `PATCH` | `/v6/projects/:projectId/members/:id` | JWT / M2M | Update member role | +| `DELETE` | `/v6/projects/:projectId/members/:id` | JWT / M2M | Remove member | + +### Invites + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET` | `/v6/projects/:projectId/invites` | JWT / M2M | List invites | +| `GET` | `/v6/projects/:projectId/invites/:inviteId` | JWT / M2M | Get invite | +| `POST` | `/v6/projects/:projectId/invites` | JWT / M2M | Create invite(s) - partial-success response `{ success[], failed[] }` | +| `PATCH` | `/v6/projects/:projectId/invites/:inviteId` | JWT / M2M | Accept / decline invite | +| `DELETE` | `/v6/projects/:projectId/invites/:inviteId` | JWT / M2M | Delete invite | + +### Phases and Phase Products + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET` | `/v6/projects/:projectId/phases` | JWT / M2M | List phases | +| `GET` | `/v6/projects/:projectId/phases/:phaseId` | JWT / M2M | Get phase | +| `POST` | `/v6/projects/:projectId/phases` | JWT / M2M | Create phase | +| `PATCH` | `/v6/projects/:projectId/phases/:phaseId` | JWT / M2M | Update phase | +| `DELETE` | `/v6/projects/:projectId/phases/:phaseId` | JWT / M2M | Soft-delete phase | +| `GET` | `/v6/projects/:projectId/phases/:phaseId/products` | JWT / M2M | List phase products | +| `GET` | `/v6/projects/:projectId/phases/:phaseId/products/:productId` | JWT / M2M | Get phase product | +| `POST` | `/v6/projects/:projectId/phases/:phaseId/products` | JWT / M2M | Create phase product (challenge linkage via `details.challengeGuid`) | +| `PATCH` | `/v6/projects/:projectId/phases/:phaseId/products/:productId` | JWT / M2M | Update phase product | +| `DELETE` | `/v6/projects/:projectId/phases/:phaseId/products/:productId` | JWT / M2M | Soft-delete phase product | + +### Attachments + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET` | `/v6/projects/:projectId/attachments` | JWT / M2M | List attachments | +| `GET` | `/v6/projects/:projectId/attachments/:id` | JWT / M2M | Get attachment (file -> presigned S3 URL) | +| `POST` | `/v6/projects/:projectId/attachments` | JWT / M2M | Upload file or add link attachment | +| `PATCH` | `/v6/projects/:projectId/attachments/:id` | JWT / M2M | Update attachment metadata | +| `DELETE` | `/v6/projects/:projectId/attachments/:id` | JWT / M2M | Soft-delete + async S3 removal | + +### Workstreams, Works, and Work Items + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET/POST` | `/v6/projects/:projectId/workstreams` | JWT / M2M | List / create workstreams | +| `GET/PATCH/DELETE` | `/v6/projects/:projectId/workstreams/:id` | JWT / M2M | Get / update / delete workstream | +| `GET/POST` | `/v6/projects/:projectId/workstreams/:workStreamId/works` | JWT / M2M | List / create works (maps to `ProjectPhase`) | +| `GET/PATCH/DELETE` | `/v6/projects/:projectId/workstreams/:workStreamId/works/:id` | JWT / M2M | Get / update / delete work | +| `GET/POST` | `/v6/projects/:projectId/workstreams/:workStreamId/works/:workId/workitems` | JWT / M2M | List / create work items (maps to `PhaseProduct`) | +| `GET/PATCH/DELETE` | `/v6/projects/:projectId/workstreams/:workStreamId/works/:workId/workitems/:id` | JWT / M2M | Get / update / delete work item | + +### Copilot + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET` | `/v6/projects/copilots/requests` | JWT | List all copilot requests (admin/PM sees all; others see own) | +| `GET` | `/v6/projects/:projectId/copilots/requests` | JWT | Project-scoped copilot requests | +| `GET` | `/v6/projects/copilots/requests/:copilotRequestId` | JWT | Get single copilot request | +| `POST` | `/v6/projects/:projectId/copilots/requests` | JWT | Create copilot request | +| `PATCH` | `/v6/projects/copilots/requests/:copilotRequestId` | JWT | Update copilot request | +| `POST` | `/v6/projects/:projectId/copilots/requests/:copilotRequestId/approve` | JWT | Approve request -> creates opportunity | +| `GET` | `/v6/projects/copilots/opportunities` | **Public** | List copilot opportunities | +| `GET` | `/v6/projects/copilot/opportunity/:id` | **Public** | Get opportunity details | +| `POST` | `/v6/projects/copilots/opportunity/:id/apply` | JWT | Apply as copilot | +| `GET` | `/v6/projects/copilots/opportunity/:id/applications` | JWT | List applications | +| `POST` | `/v6/projects/copilots/opportunity/:id/assign` | JWT | Assign copilot (triggers member/state transitions) | +| `DELETE` | `/v6/projects/copilots/opportunity/:id/cancel` | JWT | Cancel opportunity (cascade) | + +### Metadata + +See `docs/api-usage-analysis.md` (P2 section) for the complete metadata list. + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET` | `/v6/projects/metadata` | JWT / M2M | Aggregate metadata object | +| `GET/POST/PATCH/DELETE` | `/v6/projects/metadata/projectTypes` | JWT / M2M | Project type CRUD | +| `GET/POST/PATCH/DELETE` | `/v6/projects/metadata/projectTemplates` | JWT / M2M | Project template CRUD | +| `GET` | `/v6/projects/metadata/productTemplates` | JWT / M2M | Product templates | +| `GET/POST/PATCH/DELETE` | `/v6/projects/metadata/workManagementPermission` | JWT / M2M | Work-management permission rows | +| `GET/POST/PATCH/DELETE` | `/v6/projects/metadata/orgConfig` | JWT / M2M | Org config | +| `GET/POST/PATCH/DELETE` | `/v6/projects/metadata/milestoneTemplates` | JWT / M2M | Milestone template CRUD | +| `GET/POST/PATCH/DELETE` | `/v6/projects/metadata/forms` | JWT / M2M | Form CRUD | +| `GET/POST/PATCH/DELETE` | `/v6/projects/metadata/planConfigs` | JWT / M2M | Plan config CRUD | + +### Health + +| Method | Path | Auth | Description | +| --- | --- | --- | --- | +| `GET` | `/v6/projects/health` | Public | Liveness check | + +Swagger UI: `http://localhost:3000/v6/projects/api-docs` + +## Platform Consumers + +### `platform-ui` - Work App (`platform-ui/src/apps/work/src/lib/services/projects.service.ts`) + +- `PROJECTS_API_URL` resolves to `${EnvironmentConfig.API.V6}/projects`. +- Calls `GET /v6/projects` (paginated listing with `sort`, `status`, `memberOnly`, `billingAccountId`, `keyword`), `GET /v6/projects/:id`, `POST /v6/projects`, `PATCH /v6/projects/:id`. +- Member operations: `POST /members`, `PATCH /members/:id`, `DELETE /members/:id`. +- Invite operations via `platform-ui/src/apps/work/src/lib/services/project-member-invites.service.ts`: `POST /invites?fields=...`, `PATCH /invites/:id`, `DELETE /invites/:id`. +- Phase/product linkage: `GET /phases?fields=id,name,products,status`, `POST /phases/:phaseId/products` (challenge linkage with `details.challengeGuid`), `DELETE /phases/:phaseId/products/:productId`. +- Attachment operations: `GET /attachments/:id`, `POST /attachments`, `PATCH /attachments/:id`, `DELETE /attachments/:id`. +- Billing: `GET /billingAccount`. +- Metadata: `GET /metadata/projectTypes` with fallback to `GET /metadata`. + +### `platform-ui` - Copilots App (`platform-ui/src/apps/copilots/src/services/projects.ts`) + +- `baseUrl` resolves to `${EnvironmentConfig.API.V6}/projects`. +- Uses SWR hook `useProjects` for reactive project listing with chunked ID filtering (20 IDs per request, 200 ms rate-limit delay between chunks). +- Calls `GET /v6/projects` (with `name` search and arbitrary filter params), `GET /v6/projects/:id`. + +### `work-manager` + +- Heaviest consumer of the P0 surface: project CRUD/listing, billing lookups, member/invite write flows, attachment file/link flows, phase-product linkage. +- Uses `action: "complete-copilot-requests"` on `PATCH /members/:id` to trigger copilot workflow side effects. +- Reads pagination headers: `X-Page`, `X-Per-Page`, `X-Total`, `X-Total-Pages`. + +### `engagements-api-v6` + +- Calls `GET /v6/projects/:projectId` to validate project existence and extract `members[]` + `invites[]` for engagement creation. + +### `challenge-api-v6` + +- Calls `GET /v6/projects/:projectId` and `GET /v6/projects/:projectId/billingAccount` (reads `markup` field for M2M payment flows). +- Calls `POST /v6/projects` and `PATCH /v6/projects/:projectId` for self-service challenge project lifecycle. + +### `community-app` -## Technology Stack +- Calls `GET /v6/projects/copilots/opportunities?noGrouping=true` (public endpoint) for the copilot marketplace listing. -- TypeScript -- NestJS -- Prisma -- PostgreSQL -- Swagger -- pnpm +## Authorization Model + +Layered auth details and guard usage are documented in `docs/PERMISSIONS.md`. + +```text +JWT Bearer token -> TokenRolesGuard -> request.user + ↓ + ProjectContextInterceptor (loads project members for :projectId routes) + ↓ + PermissionGuard / AdminOnlyGuard / ProjectMemberGuard / CopilotAndAboveGuard + ↓ + Controller (@CurrentUser, @ProjectMembers decorators) +``` + +- `TokenRolesGuard` validates JWT or M2M token; routes decorated with `@Public()` bypass it. +- `PermissionGuard` evaluates `@RequirePermission(PERMISSION.X)` using allow + deny rule semantics. +- `AdminOnlyGuard` restricts to Topcoder admin roles. +- `ProjectMemberGuard` requires caller to be a project member; optionally filtered by `@RequireProjectMemberRoles(...)`. +- `CopilotAndAboveGuard` requires copilot, manager, or admin role. + +### M2M scope hierarchy + +| Scope | Implies | +| --- | --- | +| `all:connect_project` / `all:project` | All project read/write scopes | +| `all:projects` | `read:projects` + `write:projects` | +| `all:project-members` | `read:project-members` + `write:project-members` | +| `all:project-invites` | `read:project-invites` + `write:project-invites` | +| `all:*` | All scopes | -## Key Differences from v5 +## Event Publishing -- No Elasticsearch dependency -- Only actively-used endpoints are ported -- API endpoints use `/v6` prefix instead of `/v5` +For full event envelope and payload schemas, see `docs/event-schemas.md`. -## Setup +| Kafka Topic | Trigger | +| --- | --- | +| `project.created` | `POST /v6/projects` | +| `project.updated` | `PATCH /v6/projects/:projectId` (status change or field update) | +| `project.deleted` | `DELETE /v6/projects/:projectId` | +| `project.member.added` | `POST /v6/projects/:projectId/members` | +| `project.member.removed` | `DELETE /v6/projects/:projectId/members/:id` | + +- Originator: `project-service-v6`. +- Status-change events are emitted only when status actually changes. +- Metadata event publishing is currently disabled. + +## Environment Variables + +Reference source: `.env.example`. + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `DATABASE_URL` | ✅ | - | PostgreSQL connection string for Prisma | +| `AUTH_SECRET` | ✅ | - | JWT signing secret (from `tc-core-library-js`) | +| `VALID_ISSUERS` | ✅ | - | JSON array of accepted JWT issuers | +| `AUTH0_URL` | ✅ | - | Auth0 token endpoint for M2M | +| `AUTH0_AUDIENCE` | ✅ | - | M2M audience | +| `AUTH0_PROXY_SERVER_URL` | - | - | Auth0 proxy (optional) | +| `AUTH0_CLIENT_ID` | ✅ | - | M2M client ID | +| `AUTH0_CLIENT_SECRET` | ✅ | - | M2M client secret | +| `KAFKA_URL` | ✅ | - | Kafka broker URL | +| `KAFKA_CLIENT_CERT` | - | - | Kafka TLS cert | +| `KAFKA_CLIENT_CERT_KEY` | - | - | Kafka TLS key | +| `BUSAPI_URL` | ✅ | - | Topcoder Bus API base URL | +| `KAFKA_PROJECT_CREATED_TOPIC` | ✅ | `project.created` | Kafka topic | +| `KAFKA_PROJECT_UPDATED_TOPIC` | ✅ | `project.updated` | Kafka topic | +| `KAFKA_PROJECT_DELETED_TOPIC` | ✅ | `project.deleted` | Kafka topic | +| `KAFKA_PROJECT_MEMBER_ADDED_TOPIC` | ✅ | `project.member.added` | Kafka topic | +| `KAFKA_PROJECT_MEMBER_REMOVED_TOPIC` | ✅ | `project.member.removed` | Kafka topic | +| `ATTACHMENTS_S3_BUCKET` | ✅ | - | S3 bucket for file attachments | +| `PROJECT_ATTACHMENT_PATH_PREFIX` | - | `projects` | S3 key prefix | +| `PRESIGNED_URL_EXPIRATION` | - | `3600` | Presigned URL TTL (seconds) | +| `MAX_PHASE_PRODUCT_COUNT` | - | `20` | Max phase products per phase | +| `ENABLE_FILE_UPLOAD` | - | `true` | Toggle S3 file upload | +| `MEMBER_API_URL` | ✅ | - | Member API base URL | +| `IDENTITY_API_URL` | ✅ | - | Identity API base URL | +| `SALESFORCE_CLIENT_ID` | ✅ | - | Salesforce JWT client ID | +| `SALESFORCE_CLIENT_AUDIENCE` | ✅ | `https://login.salesforce.com` | Salesforce audience | +| `SALESFORCE_SUBJECT` | ✅ | - | Salesforce JWT subject | +| `SALESFORCE_CLIENT_KEY` | ✅ | - | Salesforce private key | +| `SALESFORCE_LOGIN_BASE_URL` | - | `https://login.salesforce.com` | Salesforce login URL | +| `SALESFORCE_API_VERSION` | - | `v37.0` | Salesforce API version | +| `SFDC_BILLING_ACCOUNT_NAME_FIELD` | - | `Billing_Account_name__c` | SOQL field name | +| `SFDC_BILLING_ACCOUNT_MARKUP_FIELD` | - | `Mark_Up__c` | SOQL field name | +| `SFDC_BILLING_ACCOUNT_ACTIVE_FIELD` | - | `Active__c` | SOQL field name | +| `INVITE_EMAIL_SUBJECT` | - | - | Email subject for invites | +| `SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED` | - | - | SendGrid template ID | +| `SENDGRID_TEMPLATE_COPILOT_ALREADY_PART_OF_PROJECT` | - | - | SendGrid template ID | +| `SENDGRID_TEMPLATE_INFORM_PM_COPILOT_APPLICATION_ACCEPTED` | - | - | SendGrid template ID | +| `COPILOT_PORTAL_URL` | - | - | Copilot portal URL (used in invite emails) | +| `WORK_MANAGER_URL` | - | - | Work Manager URL (used in invite emails) | +| `ACCOUNTS_APP_URL` | - | - | Accounts app URL (used in invite emails) | +| `UNIQUE_GMAIL_VALIDATION` | - | `false` | Treat Gmail `+` aliases as same address | +| `PORT` | - | `3000` | HTTP listen port | +| `API_PREFIX` | - | `v6` | Global route prefix | +| `HEALTH_CHECK_TIMEOUT` | - | `60000` | Health check timeout (ms) | +| `PROJECT_SERVICE_PRISMA_TIMEOUT` | - | `10000` | Prisma query timeout (ms) | +| `CORS_ALLOWED_ORIGIN` | - | - | Additional CORS origin (regex string) | +| `NODE_ENV` | - | `development` | Node environment | + +## Setup and Development ### Prerequisites -- Node.js `v22.13.1` -- pnpm +- Node.js `v22.13.1` (`nvm use` in this project folder) +- pnpm `10.28.2` - PostgreSQL ### Installation @@ -31,63 +347,130 @@ Topcoder Project API v6 is a modern NestJS-based project service that serves as pnpm install ``` -### Database Setup +`postinstall` runs `prisma generate`. + +### Database -1. Configure `DATABASE_URL` in your environment (see `.env.example`). -2. Run migrations: +Configure `DATABASE_URL`, then run: ```bash npx prisma migrate dev +pnpm prisma db seed ``` -### Development +### Environment + +Copy `.env.example` to `.env` and fill in all required variables. + +### Development server ```bash pnpm run start:dev ``` -### Production +### Production build ```bash -pnpm run build && pnpm run start:prod +pnpm run build +pnpm run start:prod ``` -## Testing - -- Unit tests: `pnpm test` -- Coverage: `pnpm test:cov` -- E2E tests: `pnpm test:e2e` - -## Linting +### Linting ```bash pnpm lint ``` -## API Documentation - -Swagger docs are available at: +Must pass before every commit per `AGENTS.md`. -- `http://localhost:3000/v6/projects/api-docs` +### Build -## Health Check +```bash +pnpm build +``` -- `GET /v6/projects/health` +Must pass before every commit per `AGENTS.md`. -## Environment Variables +## Testing -Use `.env.example` as the reference source. +| Command | Purpose | +| --- | --- | +| `pnpm test` | Unit tests (Jest) | +| `pnpm test:cov` | Unit tests with coverage | +| `pnpm test:e2e` | Full e2e suite | +| `pnpm test:load` | Load / performance tests (autocannon) | +| `pnpm test:deployment` | Deployment smoke validation | ## Deployment -Deployments are automated via CircleCI to AWS ECS Fargate. - -## Downstream Usage - -This API is used by the following services: - -- `work-manager` -- `platform-ui` -- `engagements-api-v6` -- `challenge-api-v6` -- `community-app` +- CI/CD: CircleCI -> AWS ECS Fargate. +- Blue-green rollout strategy is documented in `docs/MIGRATION_RUNBOOK.md`. +- Recommended alerts: + - 5xx rate > 2% for 5 minutes + - p95 latency > 2.5s for 10 minutes + - event publish failures > 0.5% for 5 minutes + - DB pool saturation > 85% + +## Security Notes + +Open findings are tracked inline with `TODO (security)` comments in source. + +| Location | Finding | Severity | Status | +| --- | --- | --- | --- | +| `src/main.ts` | `CORS_ALLOWED_ORIGIN` env var compiled directly into `RegExp` - ReDoS risk | Medium | Open - validate/escape before use | +| `src/main.ts` | CORS returns `'*'` for requests with no `Origin` header | Low | Open - consider returning `false` for server-to-server calls | +| `src/main.ts` | Swagger UI publicly accessible with no auth in production | Medium | Open - restrict by IP or add HTTP Basic auth, or gate behind env flag | +| `src/main.ts` | Duplicate Swagger mount at `/v6/projects-api-docs` | Low (quality) | Open - consolidate to single path | +| `docs/DEPENDENCIES.md` | `tc-bus-api-wrapper` and `tc-core-library-js` sourced from GitHub (floating refs) | Medium | Open - publish to npm or pin to commit SHA | +| `docs/DEPENDENCIES.md` | 2 moderate `ajv` vulnerabilities (transitive via `@eslint/eslintrc` and `fork-ts-checker-webpack-plugin`) | Moderate | Open - requires upstream toolchain migration off Ajv v6 | + +## Dependency Status + +Summary from `docs/DEPENDENCIES.md`: + +- Audit: 2 moderate `ajv` vulnerabilities remain (transitive, upstream fix required). +- All HIGH/CRITICAL findings are resolved via `pnpm.overrides` (`axios`, `jws`, `minimatch`, `hono`, `lodash`, `qs`, `fast-xml-parser`). +- Patch updates available: `@nestjs/*` (11.1.13 -> 11.1.14), `@prisma/*` (7.4.0 -> 7.4.1), `@aws-sdk/*`. +- Major updates available: `eslint` (9 -> 10), `jest` (29 -> 30), `uuid` (11 -> 13), `@types/node` (22 -> 25) - evaluate compatibility before upgrading. +- Supply-chain risk: `tc-bus-api-wrapper` and `tc-core-library-js` are GitHub-sourced with floating refs. + +Full details: `docs/DEPENDENCIES.md`. + +## Code Quality Notes + +Open `TODO (quality)` findings from prior phases: + +| Location | Finding | +| --- | --- | +| `src/main.ts` | `serializeBigInt` should move to `src/shared/utils/serialization.utils.ts` | +| `src/main.ts` | `LoggerService` instantiated per HTTP request - hoist to module scope | +| `src/main.ts` | Duplicate Swagger mount - consolidate to one path | +| `src/main.ts` | `WorkStreamModule` included in Swagger but not in `ApiModule` imports - causes documentation drift | +| `src/shared/services/permission.service.ts` | Large switch/case permission map - refactor to a data-driven lookup table | +| `src/api/copilot/copilot.utils.ts` | Sort utility functions duplicated across copilot sub-services - centralize | + +## Migration from v5 + +- API prefix changed from `/v5` to `/v6`. +- Elasticsearch removed; all reads use PostgreSQL via Prisma. +- Timeline/milestone CRUD intentionally not migrated (see `docs/timeline-milestone-migration.md`). +- Deprecated endpoints not ported (scope change requests, reports, customer payments, phase members/approvals, estimation items). +- Invite creation uses partial-success semantics: `{ success: Invite[], failed: ErrorInfo[] }`. +- Event originator changed from `tc-project-service` to `project-service-v6`. + +Full details: `docs/DIFFERENCES_FROM_V5.md` and `docs/MIGRATION_FROM_TC_PROJECT_SERVICE.md`. + +Migration runbook (phased rollout, rollback, monitoring): `docs/MIGRATION_RUNBOOK.md`. + +## Related Documentation + +| Document | Purpose | +| --- | --- | +| `docs/PERMISSIONS.md` | Full permission system reference, guard usage, M2M scope hierarchy | +| `docs/api-usage-analysis.md` | v5 -> v6 endpoint mapping, P0/P1/P2 classification, consumer call patterns | +| `docs/event-schemas.md` | Kafka event envelope and payload schemas | +| `docs/DIFFERENCES_FROM_V5.md` | Intentional differences and improvements vs `tc-project-service` | +| `docs/MIGRATION_FROM_TC_PROJECT_SERVICE.md` | Auth migration guide, permission mapping, consumer notes | +| `docs/MIGRATION_RUNBOOK.md` | Phased rollout, rollback procedures, monitoring alerts | +| `docs/DEPENDENCIES.md` | Dependency security audit, outdated packages, overrides | +| `docs/timeline-milestone-migration.md` | Guidance for future timeline/milestone migration | diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md new file mode 100644 index 0000000..887af99 --- /dev/null +++ b/docs/DEPENDENCIES.md @@ -0,0 +1,92 @@ +# Dependency Security and Maintenance + +## Overview + +This document tracks dependency security posture and version drift for `projects-api-v6`. + +Toolchain used for this verification cycle: + +- Node: `v22.13.1` +- pnpm: `10.28.2` +- Verification date: `2026-02-20` + +To re-run checks: + +- `pnpm outdated` +- `pnpm audit` +- `pnpm lint` +- `pnpm build` + +## Security Vulnerabilities + +| CVE / Advisory | Severity | Package | Affected Versions | Fixed In | Status | Planned / Required Fix | +|---|---|---|---|---|---|---| +| GHSA-43fc-jf86-j433 | **HIGH** | `axios` | `<=0.30.2` and `>=1.0.0 <=1.13.4` | `>=0.30.3`, `>=1.13.5` | ✅ Cleared in production/transitive paths | Applied `pnpm.overrides.axios = 1.13.5`; verified `tc-core-library-js` now resolves `axios@1.13.5`. | +| GHSA-gq3j-xvxp-8hrf | **LOW** | `hono` | `<4.11.10` | `>=4.11.10` | ✅ Cleared | Updated `pnpm.overrides.hono` to `4.11.10`; Prisma transitive paths resolve `hono@4.11.10`. | +| GHSA-3ppc-4f35-3m26 | **HIGH** | `minimatch` | `<10.2.1` | `>=10.2.1` | ✅ Cleared | Added `pnpm.overrides.minimatch = 10.2.1`; audit no longer reports minimatch. | +| GHSA-2g4f-4pwh-qvx6 | **MODERATE** | `ajv` | `<8.18.0` | `>=8.18.0` | ⚠️ Still open (2 moderate findings in `pnpm audit`) | Direct `ajv` bumped to `^8.18.0`, but transitive `ajv@6.12.6` remains via `@eslint/eslintrc@3.3.3` and `@nestjs/cli -> fork-ts-checker-webpack-plugin -> schema-utils@3.3.0`. Required fix is upstream/toolchain migration off Ajv v6 paths. | +| CVE-2025-65945 | **HIGH** | `jws` (transitive via `jsonwebtoken`, `jwks-rsa`) | `<=3.2.2`, `4.0.0` | `3.2.3`, `4.0.1` | ✅ Cleared | Existing `jws` override retained (`>=3.2.3 <4.0.0 || >=4.0.1`). | + +Current `pnpm audit` summary: + +```text +2 vulnerabilities found +Severity: 2 moderate +``` + +## Outdated Dependencies + +Regenerated from `pnpm outdated` after dependency/security updates. + +| Package | Specifier | Resolved | Latest | Notes | +|---|---|---|---|---| +| `@nestjs/common` | `^11.0.1` | `11.1.13` | `11.1.14` | Patch update available | +| `@nestjs/core` | `^11.0.1` | `11.1.13` | `11.1.14` | Patch update available | +| `@nestjs/platform-express` | `^11.0.1` | `11.1.13` | `11.1.14` | Patch update available | +| `@nestjs/testing` (dev) | `^11.0.1` | `11.1.13` | `11.1.14` | Patch update available | +| `@prisma/adapter-pg` | `7.4.0` | `7.4.0` | `7.4.1` | Patch update available | +| `@prisma/client` | `7.4.0` | `7.4.0` | `7.4.1` | Patch update available | +| `prisma` (dev) | `7.4.0` | `7.4.0` | `7.4.1` | Patch update available | +| `@aws-sdk/client-s3` | `^3.926.0` | `3.985.0` | `3.994.0` | Minor update available | +| `@aws-sdk/s3-request-presigner` | `^3.926.0` | `3.985.0` | `3.994.0` | Minor update available | +| `qs` | `^6.14.2` | `6.14.2` | `6.15.0` | Minor update available (currently security-pinned by override) | +| `typescript-eslint` (dev) | `^8.20.0` | `8.54.0` | `8.56.0` | Minor update available | +| `@eslint/js` (dev) | `^9.18.0` | `9.39.2` | `10.0.1` | Major update available | +| `@types/jest` (dev) | `^29.5.14` | `29.5.14` | `30.0.0` | Major update available | +| `@types/node` (dev) | `^22.10.7` | `22.19.9` | `25.3.0` | Major update available | +| `eslint` (dev) | `^9.18.0` | `9.39.2` | `10.0.0` | Major update available | +| `globals` (dev) | `^15.14.0` | `15.15.0` | `17.3.0` | Major update available | +| `jest` (dev) | `^29.7.0` | `29.7.0` | `30.2.0` | Major update available | +| `uuid` | `^11.1.0` | `11.1.0` | `13.0.0` | Major update available | +| `@swc/cli` (dev) | `^0.6.0` | `0.6.0` | `0.8.0` | Minor update available | + +## GitHub-Sourced Packages (Supply-Chain Risk) + +| Package | Specifier | Risk | +|---|---|---| +| `tc-bus-api-wrapper` | `github:topcoder-platform/tc-bus-api-wrapper.git` | GitHub source dependency; no semver release stream in npm | +| `tc-core-library-js` | `topcoder-platform/tc-core-library-js.git#master` | Floating `master` reference; transitive changes can land without semver | + +## pnpm Overrides in Effect + +| Override | Pinned To | Reason | +|---|---|---| +| `axios` | `1.13.5` | Force patched axios across transitive paths (`tc-core-library-js` included) | +| `fast-xml-parser` | `5.3.6` | Security hardening | +| `hono` | `4.11.10` | Fix advisory GHSA-gq3j-xvxp-8hrf | +| `jws` | `>=3.2.3 <4.0.0 \|\| >=4.0.1` | Fix CVE-2025-65945 | +| `lodash` | `4.17.23` | Prototype pollution fix | +| `minimatch` | `10.2.1` | Fix advisory GHSA-3ppc-4f35-3m26 | +| `qs` | `6.14.2` | Prototype pollution / DoS fix | + +## Verification Log + +Commands run in `projects-api-v6/` (with `nvm use` before each): + +| Command | Result | +|---|---| +| `pnpm install` | ✅ Passed | +| `pnpm audit` | ⚠️ Fails with 2 moderate `ajv` vulnerabilities (upstream Ajv v6 transitive deps) | +| `pnpm outdated` | ✅ Completed (table above updated) | +| `pnpm lint` | ✅ Passed | +| `pnpm build` | ✅ Passed | diff --git a/package.json b/package.json index 0ca0f1f..5edf89c 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,9 @@ "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.0.3", - "@prisma/adapter-pg": "7.3.0", - "@prisma/client": "7.3.0", - "@types/jsonwebtoken": "^9.0.9", - "axios": "^1.9.0", + "@prisma/adapter-pg": "7.4.0", + "@prisma/client": "7.4.0", + "axios": "^1.13.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cors": "^2.8.5", @@ -46,7 +45,8 @@ "rxjs": "^7.8.1", "tc-bus-api-wrapper": "github:topcoder-platform/tc-bus-api-wrapper.git", "tc-core-library-js": "topcoder-platform/tc-core-library-js.git#master", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "winston": "^3.17.0" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -58,11 +58,12 @@ "@swc/core": "^1.10.7", "@types/autocannon": "^7.12.7", "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.9", "@types/jest": "^29.5.14", "@types/lodash": "^4.17.20", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", - "ajv": "^8.17.1", + "ajv": "^8.18.0", "autocannon": "^8.0.0", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -70,7 +71,7 @@ "globals": "^15.14.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "7.3.0", + "prisma": "7.4.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", @@ -78,8 +79,7 @@ "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0", - "winston": "^3.17.0" + "typescript-eslint": "^8.20.0" }, "prisma": { "seed": "ts-node prisma/seed.ts" @@ -105,9 +105,12 @@ , "pnpm": { "overrides": { + "axios": "1.13.5", "fast-xml-parser": "5.3.6", - "hono": "4.11.7", + "hono": "4.11.10", + "jws": ">=3.2.3 <4.0.0 || >=4.0.1", "lodash": "4.17.23", + "minimatch": "10.2.1", "qs": "6.14.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73d9413..82436f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,12 @@ settings: excludeLinksFromLockfile: false overrides: + axios: 1.13.5 fast-xml-parser: 5.3.6 - hono: 4.11.7 + hono: 4.11.10 + jws: '>=3.2.3 <4.0.0 || >=4.0.1' lodash: 4.17.23 + minimatch: 10.2.1 qs: 6.14.2 importers: @@ -22,7 +25,7 @@ importers: version: 3.985.0 '@nestjs/axios': specifier: ^4.0.0 - version: 4.0.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2) + version: 4.0.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.5)(rxjs@7.8.2) '@nestjs/common': specifier: ^11.0.1 version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -39,17 +42,14 @@ importers: specifier: ^11.0.3 version: 11.2.6(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) '@prisma/adapter-pg': - specifier: 7.3.0 - version: 7.3.0 + specifier: 7.4.0 + version: 7.4.0 '@prisma/client': - specifier: 7.3.0 - version: 7.3.0(prisma@7.3.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) - '@types/jsonwebtoken': - specifier: ^9.0.9 - version: 9.0.10 + specifier: 7.4.0 + version: 7.4.0(prisma@7.4.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) axios: - specifier: ^1.9.0 - version: 1.13.4 + specifier: 1.13.5 + version: 1.13.5 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -86,6 +86,9 @@ importers: uuid: specifier: ^11.1.0 version: 11.1.0 + winston: + specifier: ^3.17.0 + version: 3.19.0 devDependencies: '@eslint/eslintrc': specifier: ^3.2.0 @@ -117,6 +120,9 @@ importers: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/jsonwebtoken': + specifier: ^9.0.9 + version: 9.0.10 '@types/lodash': specifier: ^4.17.20 version: 4.17.23 @@ -127,8 +133,8 @@ importers: specifier: ^6.0.2 version: 6.0.3 ajv: - specifier: ^8.17.1 - version: 8.17.1 + specifier: ^8.18.0 + version: 8.18.0 autocannon: specifier: ^8.0.0 version: 8.0.0 @@ -151,8 +157,8 @@ importers: specifier: ^3.4.2 version: 3.8.1 prisma: - specifier: 7.3.0 - version: 7.3.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + specifier: 7.4.0 + version: 7.4.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -177,9 +183,6 @@ importers: typescript-eslint: specifier: ^8.20.0 version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - winston: - specifier: ^3.17.0 - version: 3.19.0 packages: @@ -659,7 +662,7 @@ packages: resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: 4.11.7 + hono: 4.11.10 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -820,14 +823,6 @@ packages: '@types/node': optional: true - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} - '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -1048,7 +1043,7 @@ packages: resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==} peerDependencies: '@nestjs/common': ^10.0.0 || ^11.0.0 - axios: ^1.3.1 + axios: 1.13.5 rxjs: ^7.0.0 '@nestjs/cli@11.0.16': @@ -1177,14 +1172,14 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@prisma/adapter-pg@7.3.0': - resolution: {integrity: sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg==} + '@prisma/adapter-pg@7.4.0': + resolution: {integrity: sha512-LWwTHaio0bMxvzahmpwpWqsZM0vTfMqwF8zo06YvILL/o47voaSfKzCVxZw/o9awf4fRgS5Vgthobikj9Dusaw==} - '@prisma/client-runtime-utils@7.3.0': - resolution: {integrity: sha512-dG/ceD9c+tnXATPk8G+USxxYM9E6UdMTnQeQ+1SZUDxTz7SgQcfxEqafqIQHcjdlcNK/pvmmLfSwAs3s2gYwUw==} + '@prisma/client-runtime-utils@7.4.0': + resolution: {integrity: sha512-jTmWAOBGBSCT8n7SMbpjCpHjELgcDW9GNP/CeK6CeqjUFlEL6dn8Cl81t/NBDjJdXDm85XDJmc+PEQqqQee3xw==} - '@prisma/client@7.3.0': - resolution: {integrity: sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ==} + '@prisma/client@7.4.0': + resolution: {integrity: sha512-Sc+ncr7+ph1hMf1LQfn6UyEXDEamCd5pXMsx8Q3SBH0NGX+zjqs3eaABt9hXwbcK9l7f8UyK8ldxOWA2LyPynQ==} engines: {node: ^20.19 || ^22.12 || >=24.0} peerDependencies: prisma: '*' @@ -1195,35 +1190,35 @@ packages: typescript: optional: true - '@prisma/config@7.3.0': - resolution: {integrity: sha512-QyMV67+eXF7uMtKxTEeQqNu/Be7iH+3iDZOQZW5ttfbSwBamCSdwPszA0dum+Wx27I7anYTPLmRmMORKViSW1A==} + '@prisma/config@7.4.0': + resolution: {integrity: sha512-EnNrZMwZ9+O6UlG+YO9SP3VhVw4zwMahDRzQm3r0DQn9KeU5NwzmaDAY+BzACrgmaU71Id1/0FtWIDdl7xQp9g==} '@prisma/debug@7.2.0': resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} - '@prisma/debug@7.3.0': - resolution: {integrity: sha512-yh/tHhraCzYkffsI1/3a7SHX8tpgbJu1NPnuxS4rEpJdWAUDHUH25F1EDo6PPzirpyLNkgPPZdhojQK804BGtg==} + '@prisma/debug@7.4.0': + resolution: {integrity: sha512-fZicwzgFHvvPMrRLCUinrsBTdadJsi/1oirzShjmFvNLwtu2DYlkxwRVy5zEGhp85mrEGnLeS/PdNRCdE027+Q==} '@prisma/dev@0.20.0': resolution: {integrity: sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==} - '@prisma/driver-adapter-utils@7.3.0': - resolution: {integrity: sha512-Wdlezh1ck0Rq2dDINkfSkwbR53q53//Eo1vVqVLwtiZ0I6fuWDGNPxwq+SNAIHnsU+FD/m3aIJKevH3vF13U3w==} + '@prisma/driver-adapter-utils@7.4.0': + resolution: {integrity: sha512-jEyE5LkqZ27Ba/DIOfCGOQl6nKMLxuwJNRceCfh7/LRs46UkIKn3bmkI97MEH2t7zkYV3PGBrUr+6sMJaHvc0A==} - '@prisma/engines-version@7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735': - resolution: {integrity: sha512-IH2va2ouUHihyiTTRW889LjKAl1CusZOvFfZxCDNpjSENt7g2ndFsK0vdIw/72v7+jCN6YgkHmdAP/BI7SDgyg==} + '@prisma/engines-version@7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57': + resolution: {integrity: sha512-5o3/bubIYdUeg38cyNf+VDq+LVtxvvi2393Fd1Uru52LPfkGJnmVbCaX1wBOAncgKR3BCloMJFD+Koog9LtYqQ==} - '@prisma/engines@7.3.0': - resolution: {integrity: sha512-cWRQoPDXPtR6stOWuWFZf9pHdQ/o8/QNWn0m0zByxf5Kd946Q875XdEJ52pEsX88vOiXUmjuPG3euw82mwQNMg==} + '@prisma/engines@7.4.0': + resolution: {integrity: sha512-H+dgpbbY3VN/j5hOSVP1LXsv/rU0w/4C2zh5PZUwo/Q3NqZjOvBlVvkhtziioRmeEZ3SBAqPCsf1sQ74sI3O/w==} - '@prisma/fetch-engine@7.3.0': - resolution: {integrity: sha512-Mm0F84JMqM9Vxk70pzfNpGJ1lE4hYjOeLMu7nOOD1i83nvp8MSAcFYBnHqLvEZiA6onUR+m8iYogtOY4oPO5lQ==} + '@prisma/fetch-engine@7.4.0': + resolution: {integrity: sha512-IXPOYskT89UTVsntuSnMTiKRWCuTg5JMWflgEDV1OSKFpuhwP5vqbfF01/iwo9y6rCjR0sDIO+jdV5kq38/hgA==} '@prisma/get-platform@7.2.0': resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} - '@prisma/get-platform@7.3.0': - resolution: {integrity: sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg==} + '@prisma/get-platform@7.4.0': + resolution: {integrity: sha512-fOUIoGzAPgtjHVs4DsVSnEDPBEauAmFeZr4Ej3tMwxywam7hHdRtCzgKagQBKcYIJuya8gzYrTqUoukzXtWJaA==} '@prisma/query-plan-executor@7.2.0': resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} @@ -1910,6 +1905,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1973,11 +1971,8 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} - axios@0.30.2: - resolution: {integrity: sha512-0pE4RQ4UQi1jKY6p7u6i1Tkzqmu+d+/tHS7Q7rKunWLB9WyilBTpHHpXzPNMDj5hTbK0B0PTLSz07yqMBiF6xg==} - - axios@1.13.4: - resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} + axios@1.13.5: + resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} b4a@1.7.3: resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} @@ -2016,8 +2011,9 @@ packages: resolution: {integrity: sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==} engines: {node: '>= 0.6'} - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.3: + resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} + engines: {node: 20 || >=22} bare-events@2.8.2: resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} @@ -2052,11 +2048,9 @@ packages: bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.2: + resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -2269,9 +2263,6 @@ packages: component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concat-stream@2.0.0: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} @@ -2890,8 +2881,8 @@ packages: hdr-histogram-percentiles-obj@3.0.0: resolution: {integrity: sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==} - hono@4.11.7: - resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==} + hono@4.11.10: + resolution: {integrity: sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -3456,17 +3447,10 @@ packages: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - minimatch@10.1.2: - resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} + minimatch@10.2.1: + resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==} engines: {node: 20 || >=22} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3780,8 +3764,8 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - prisma@7.3.0: - resolution: {integrity: sha512-ApYSOLHfMN8WftJA+vL6XwAPOh/aZ0BgUyyKPwUFgjARmG6EBI9LzDPf6SWULQMSAxydV9qn5gLj037nPNlg2w==} + prisma@7.4.0: + resolution: {integrity: sha512-n2xU9vSaH4uxZF/l2aKoGYtKtC7BL936jM9Q94Syk1zOD39t/5hjDUxMgaPkVRDX5wWEMsIqvzQxoebNIesOKw==} engines: {node: ^20.19 || ^22.12 || >=24.0} hasBin: true peerDependencies: @@ -5346,7 +5330,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 10.2.1 transitivePeerDependencies: - supports-color @@ -5367,7 +5351,7 @@ snapshots: ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 10.2.1 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -5397,9 +5381,9 @@ snapshots: dependencies: '@hapi/hoek': 11.0.7 - '@hono/node-server@1.19.9(hono@4.11.7)': + '@hono/node-server@1.19.9(hono@4.11.10)': dependencies: - hono: 4.11.7 + hono: 4.11.10 '@humanfs/core@0.19.1': {} @@ -5552,12 +5536,6 @@ snapshots: optionalDependencies: '@types/node': 22.19.9 - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.1': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -5844,10 +5822,10 @@ snapshots: '@napi-rs/nice-win32-x64-msvc': 1.1.1 optional: true - '@nestjs/axios@4.0.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2)': + '@nestjs/axios@4.0.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.5)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - axios: 1.13.4 + axios: 1.13.5 rxjs: 7.8.2 '@nestjs/cli@11.0.16(@swc/cli@0.6.0(@swc/core@1.15.11)(chokidar@4.0.3))(@swc/core@1.15.11)(@types/node@22.19.9)': @@ -5986,24 +5964,24 @@ snapshots: '@pkgr/core@0.2.9': {} - '@prisma/adapter-pg@7.3.0': + '@prisma/adapter-pg@7.4.0': dependencies: - '@prisma/driver-adapter-utils': 7.3.0 + '@prisma/driver-adapter-utils': 7.4.0 pg: 8.18.0 postgres-array: 3.0.4 transitivePeerDependencies: - pg-native - '@prisma/client-runtime-utils@7.3.0': {} + '@prisma/client-runtime-utils@7.4.0': {} - '@prisma/client@7.3.0(prisma@7.3.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@7.4.0(prisma@7.4.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@prisma/client-runtime-utils': 7.3.0 + '@prisma/client-runtime-utils': 7.4.0 optionalDependencies: - prisma: 7.3.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + prisma: 7.4.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) typescript: 5.9.3 - '@prisma/config@7.3.0': + '@prisma/config@7.4.0': dependencies: c12: 3.1.0 deepmerge-ts: 7.1.5 @@ -6014,20 +5992,20 @@ snapshots: '@prisma/debug@7.2.0': {} - '@prisma/debug@7.3.0': {} + '@prisma/debug@7.4.0': {} '@prisma/dev@0.20.0(typescript@5.9.3)': dependencies: '@electric-sql/pglite': 0.3.15 '@electric-sql/pglite-socket': 0.0.20(@electric-sql/pglite@0.3.15) '@electric-sql/pglite-tools': 0.2.20(@electric-sql/pglite@0.3.15) - '@hono/node-server': 1.19.9(hono@4.11.7) + '@hono/node-server': 1.19.9(hono@4.11.10) '@mrleebo/prisma-ast': 0.13.1 '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 foreground-child: 3.3.1 get-port-please: 3.2.0 - hono: 4.11.7 + hono: 4.11.10 http-status-codes: 2.3.0 pathe: 2.0.3 proper-lockfile: 4.1.2 @@ -6038,32 +6016,32 @@ snapshots: transitivePeerDependencies: - typescript - '@prisma/driver-adapter-utils@7.3.0': + '@prisma/driver-adapter-utils@7.4.0': dependencies: - '@prisma/debug': 7.3.0 + '@prisma/debug': 7.4.0 - '@prisma/engines-version@7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735': {} + '@prisma/engines-version@7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57': {} - '@prisma/engines@7.3.0': + '@prisma/engines@7.4.0': dependencies: - '@prisma/debug': 7.3.0 - '@prisma/engines-version': 7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735 - '@prisma/fetch-engine': 7.3.0 - '@prisma/get-platform': 7.3.0 + '@prisma/debug': 7.4.0 + '@prisma/engines-version': 7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57 + '@prisma/fetch-engine': 7.4.0 + '@prisma/get-platform': 7.4.0 - '@prisma/fetch-engine@7.3.0': + '@prisma/fetch-engine@7.4.0': dependencies: - '@prisma/debug': 7.3.0 - '@prisma/engines-version': 7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735 - '@prisma/get-platform': 7.3.0 + '@prisma/debug': 7.4.0 + '@prisma/engines-version': 7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57 + '@prisma/get-platform': 7.4.0 '@prisma/get-platform@7.2.0': dependencies: '@prisma/debug': 7.2.0 - '@prisma/get-platform@7.3.0': + '@prisma/get-platform@7.4.0': dependencies: - '@prisma/debug': 7.3.0 + '@prisma/debug': 7.4.0 '@prisma/query-plan-executor@7.2.0': {} @@ -6439,7 +6417,7 @@ snapshots: '@xhmikosr/bin-wrapper': 13.2.0 commander: 8.3.0 fast-glob: 3.3.3 - minimatch: 9.0.5 + minimatch: 10.2.1 piscina: 4.9.2 semver: 7.7.4 slash: 3.0.0 @@ -6751,7 +6729,7 @@ snapshots: '@typescript-eslint/types': 8.54.0 '@typescript-eslint/visitor-keys': 8.54.0 debug: 4.4.3 - minimatch: 9.0.5 + minimatch: 10.2.1 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -6969,9 +6947,9 @@ snapshots: acorn@8.15.0: {} - ajv-formats@2.1.1(ajv@8.17.1): + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.18.0 ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: @@ -6981,9 +6959,9 @@ snapshots: dependencies: ajv: 6.12.6 - ajv-keywords@5.1.0(ajv@8.17.1): + ajv-keywords@5.1.0(ajv@8.18.0): dependencies: - ajv: 8.17.1 + ajv: 8.18.0 fast-deep-equal: 3.1.3 ajv@6.12.6: @@ -7000,6 +6978,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -7069,15 +7054,7 @@ snapshots: aws-ssl-profiles@1.1.2: {} - axios@0.30.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - axios@1.13.4: + axios@1.13.5: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 @@ -7146,7 +7123,7 @@ snapshots: dependencies: precond: 0.2.3 - balanced-match@1.0.2: {} + balanced-match@4.0.3: {} bare-events@2.8.2: {} @@ -7187,14 +7164,9 @@ snapshots: bowser@2.13.1: {} - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: + brace-expansion@5.0.2: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.3 braces@3.0.3: dependencies: @@ -7402,8 +7374,6 @@ snapshots: component-emitter@1.3.1: {} - concat-map@0.0.1: {} - concat-stream@2.0.0: dependencies: buffer-from: 1.1.2 @@ -7650,7 +7620,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 10.2.1 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -7884,7 +7854,7 @@ snapshots: deepmerge: 4.3.1 fs-extra: 10.1.0 memfs: 3.5.3 - minimatch: 3.1.2 + minimatch: 10.2.1 node-abort-controller: 3.1.1 schema-utils: 3.3.0 semver: 7.7.4 @@ -7980,7 +7950,7 @@ snapshots: glob@13.0.0: dependencies: - minimatch: 10.1.2 + minimatch: 10.2.1 minipass: 7.1.2 path-scurry: 2.0.1 @@ -7988,7 +7958,7 @@ snapshots: dependencies: inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 10.2.1 once: 1.4.0 path-is-absolute: 1.0.1 optional: true @@ -7998,7 +7968,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 10.2.1 once: 1.4.0 path-is-absolute: 1.0.1 @@ -8059,7 +8029,7 @@ snapshots: hdr-histogram-percentiles-obj@3.0.0: {} - hono@4.11.7: {} + hono@4.11.10: {} html-escaper@2.0.2: {} @@ -8757,17 +8727,9 @@ snapshots: mimic-response@4.0.0: {} - minimatch@10.1.2: - dependencies: - '@isaacs/brace-expansion': 5.0.1 - - minimatch@3.1.2: + minimatch@10.2.1: dependencies: - brace-expansion: 1.1.12 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 + brace-expansion: 5.0.2 minimist@1.2.8: {} @@ -9051,11 +9013,11 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 - prisma@7.3.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + prisma@7.4.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: - '@prisma/config': 7.3.0 + '@prisma/config': 7.4.0 '@prisma/dev': 0.20.0(typescript@5.9.3) - '@prisma/engines': 7.3.0 + '@prisma/engines': 7.4.0 '@prisma/studio-core': 0.13.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) mysql2: 3.15.3 postgres: 3.4.7 @@ -9237,9 +9199,9 @@ snapshots: schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 - ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - ajv-keywords: 5.1.0(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 2.1.1(ajv@8.18.0) + ajv-keywords: 5.1.0(ajv@8.18.0) seek-bzip@2.0.0: dependencies: @@ -9476,7 +9438,7 @@ snapshots: tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/1075136355e1e1c4779f2138a30f3ffbd718bfa4: dependencies: - axios: 0.30.2 + axios: 1.13.5 bunyan: 1.8.15 jsonwebtoken: 9.0.3 jwks-rsa: 3.2.2 @@ -9509,7 +9471,7 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 7.2.3 - minimatch: 3.1.2 + minimatch: 10.2.1 text-decoder@1.2.3: dependencies: diff --git a/src/api/copilot/copilot-application.controller.ts b/src/api/copilot/copilot-application.controller.ts index b775a06..cee0c60 100644 --- a/src/api/copilot/copilot-application.controller.ts +++ b/src/api/copilot/copilot-application.controller.ts @@ -35,9 +35,22 @@ import { @ApiTags('Copilot Applications') @ApiBearerAuth() @Controller('/projects') +/** + * Exposes copilot application submission and listing endpoints. + * Submission is copilot-only; listing is role-filtered by response fields. + */ export class CopilotApplicationController { constructor(private readonly service: CopilotApplicationService) {} + /** + * POST /projects/copilots/opportunity/:id/apply + * Copilot-only idempotent application endpoint. + * + * @param id Opportunity id path value. + * @param dto Application payload. + * @param user Authenticated JWT user. + * @returns Created or existing application response. + */ @Post('copilots/opportunity/:id/apply') @UseGuards(PermissionGuard) @Roles(UserRole.TC_COPILOT) @@ -63,6 +76,15 @@ export class CopilotApplicationController { return this.service.applyToOpportunity(id, dto, user); } + /** + * GET /projects/copilots/opportunity/:id/applications + * Returns application list with role-dependent response shape. + * + * @param id Opportunity id path value. + * @param query Pagination/sort query. + * @param user Authenticated JWT user. + * @returns Full or limited application list depending on caller role. + */ @Get('copilots/opportunity/:id/applications') @Roles(...Object.values(UserRole)) @Scopes( @@ -84,6 +106,7 @@ export class CopilotApplicationController { @ApiResponse({ status: 400, description: 'Bad request' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 404, description: 'Not found' }) + // TODO [QUALITY]: Controller return type is unknown; prefer explicit discriminated union or a single response DTO shape with optional fields. async listApplications( @Param('id') id: string, @Query() query: CopilotApplicationListQueryDto, diff --git a/src/api/copilot/copilot-application.service.ts b/src/api/copilot/copilot-application.service.ts index c17bb79..313d5af 100644 --- a/src/api/copilot/copilot-application.service.ts +++ b/src/api/copilot/copilot-application.service.ts @@ -35,6 +35,10 @@ type ApplicationWithRelations = CopilotApplication & { }; @Injectable() +/** + * Handles copilot applications: submit, list with role-based field filtering, + * and numeric user-id validation via parseUserId. + */ export class CopilotApplicationService { constructor( private readonly prisma: PrismaService, @@ -42,6 +46,18 @@ export class CopilotApplicationService { private readonly notificationService: CopilotNotificationService, ) {} + /** + * Applies the current user to an active opportunity. + * Idempotent behavior: returns existing application if already present for this user/opportunity. + * + * @param opportunityId Opportunity id path value. + * @param dto Application payload. + * @param user Authenticated JWT user. + * @returns Created or existing copilot application response. + * @throws ForbiddenException If user lacks APPLY_COPILOT_OPPORTUNITY permission. + * @throws NotFoundException If opportunity is not found. + * @throws BadRequestException If ids are invalid or opportunity is not active. + */ async applyToOpportunity( opportunityId: string, dto: CreateCopilotApplicationDto, @@ -90,6 +106,7 @@ export class CopilotApplicationService { userId: parsedUserId, notes: dto.notes, status: CopilotApplicationStatus.pending, + // TODO [QUALITY]: createdBy/updatedBy bypass parseUserId and parse directly; use getAuditUserId(user) from copilot.utils.ts for consistency. createdBy: Number.parseInt(user.userId as string, 10), updatedBy: Number.parseInt(user.userId as string, 10), }, @@ -103,6 +120,19 @@ export class CopilotApplicationService { return this.formatApplication(created); } + /** + * Lists applications for one opportunity. + * Admin/PM users receive full CopilotApplicationResponseDto rows. + * Other users receive limited fields: userId, status, and createdAt. + * existingMembership is enriched from a separate projectMember query. + * + * @param opportunityId Opportunity id path value. + * @param query Pagination and sort parameters. + * @param user Authenticated JWT user. + * @returns Full or limited application list depending on caller role. + * @throws NotFoundException If opportunity is not found. + * @throws BadRequestException If opportunityId is non-numeric. + */ async listApplications( opportunityId: string, query: CopilotApplicationListQueryDto, @@ -113,6 +143,7 @@ export class CopilotApplicationService { Pick > > { + // TODO [QUALITY]: Controller return type is unknown and this service returns a union; consider discriminated union or single response shape with optional fields. const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity'); const [sortField, sortDirection] = parseSortExpression( query.sort, @@ -193,6 +224,13 @@ export class CopilotApplicationService { })); } + /** + * Formats an application entity into response DTO shape. + * Applies bigint-to-string normalization through normalizeEntity. + * + * @param input Copilot application entity. + * @returns Formatted copilot application response. + */ private formatApplication( input: CopilotApplication, ): CopilotApplicationResponseDto { @@ -209,6 +247,13 @@ export class CopilotApplicationService { }; } + /** + * Parses and validates authenticated user id as bigint. + * + * @param user Authenticated JWT user. + * @returns Parsed numeric user id as bigint. + * @throws BadRequestException If user id is non-numeric. + */ private parseUserId(user: JwtUser): bigint { const normalized = String(user.userId || '').trim(); @@ -219,7 +264,15 @@ export class CopilotApplicationService { return BigInt(normalized); } + /** + * Enforces a named permission for the current user. + * + * @param permission Permission constant. + * @param user Authenticated JWT user. + * @throws ForbiddenException If permission is missing. + */ private ensurePermission(permission: NamedPermission, user: JwtUser): void { + // TODO [DRY]: Identical ensurePermission method exists in CopilotRequestService and CopilotOpportunityService; extract shared utility/base class. if (!this.permissionService.hasNamedPermission(permission, user)) { throw new ForbiddenException('Insufficient permissions'); } diff --git a/src/api/copilot/copilot-notification.service.ts b/src/api/copilot/copilot-notification.service.ts index 5b20397..396b10b 100644 --- a/src/api/copilot/copilot-notification.service.ts +++ b/src/api/copilot/copilot-notification.service.ts @@ -11,6 +11,7 @@ import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { MemberService } from 'src/shared/services/member.service'; import { getCopilotRequestData, getCopilotTypeLabel } from './copilot.utils'; +// TODO [CONFIG]: TEMPLATE_IDS are hardcoded SendGrid template ids; move these values to environment-based configuration. const TEMPLATE_IDS = { APPLY_COPILOT: 'd-d7c1f48628654798a05c8e09e52db14f', COPILOT_APPLICATION_ACCEPTED: 'd-eef5e7568c644940b250e76d026ced5b', @@ -29,6 +30,11 @@ type ApplicationWithMembership = CopilotApplication & { }; }; +/** + * Email notification dispatcher for copilot lifecycle events. + * Email dispatch is currently disabled: publishEmail is a stub that logs and returns + * without sending to Kafka/SendGrid. + */ @Injectable() export class CopilotNotificationService { private readonly logger = LoggerService.forRoot('CopilotNotificationService'); @@ -38,6 +44,14 @@ export class CopilotNotificationService { private readonly memberService: MemberService, ) {} + /** + * Sends application notifications to project managers and request creator. + * Recipient resolution: manager/project_manager project members + request creator, deduplicated. + * + * @param opportunity Opportunity that must include copilotRequest relation. + * @param application Newly created application. + * @returns Resolves after notifications are queued. + */ async sendCopilotApplicationNotification( opportunity: OpportunityWithRequest, application: CopilotApplication, @@ -111,6 +125,16 @@ export class CopilotNotificationService { ); } + /** + * Sends assigned notification to the accepted applicant. + * Uses COPILOT_ALREADY_PART_OF_PROJECT when existing membership is copilot/manager, + * otherwise uses COPILOT_APPLICATION_ACCEPTED. + * + * @param opportunity Assigned opportunity with optional request relation. + * @param application Accepted application with optional existingMembership. + * @param copilotRequest Optional request override. + * @returns Resolves after notification dispatch. + */ async sendCopilotAssignedNotification( opportunity: OpportunityWithRequest, application: ApplicationWithMembership, @@ -156,6 +180,15 @@ export class CopilotNotificationService { ); } + /** + * Sends notifications to non-accepted applicants after assignment. + * Uses COPILOT_OPPORTUNITY_COMPLETED template for all provided applications. + * + * @param opportunity Completed opportunity. + * @param applications Non-accepted applications. + * @param copilotRequest Optional request override. + * @returns Resolves after notification dispatch. + */ async sendCopilotRejectedNotification( opportunity: OpportunityWithRequest, applications: CopilotApplication[], @@ -197,6 +230,13 @@ export class CopilotNotificationService { ); } + /** + * Sends canceled notifications to all applicants for an opportunity. + * + * @param opportunity Canceled opportunity. + * @param applications Opportunity applications. + * @returns Resolves after notification dispatch. + */ async sendOpportunityCanceledNotification( opportunity: OpportunityWithRequest, applications: CopilotApplication[], @@ -235,6 +275,14 @@ export class CopilotNotificationService { ); } + /** + * Publishes a template email notification. + * + * @param templateId Template identifier. + * @param recipients Recipient emails. + * @param data Template payload. + * @returns Resolved promise (dispatch currently disabled). + */ private publishEmail( templateId: string, recipients: string[], @@ -244,6 +292,7 @@ export class CopilotNotificationService { return Promise.resolve(); } + // TODO [SECURITY/FUNCTIONALITY]: Email dispatch is fully disabled; templateId and data are discarded and nothing is sent. Re-enable Kafka/SendGrid before production use. void templateId; void data; this.logger.warn( @@ -252,17 +301,37 @@ export class CopilotNotificationService { return Promise.resolve(); } + /** + * Returns the configured Work Manager base URL. + * + * @returns Work Manager URL string. + */ private getWorkManagerUrl(): string { + // TODO [QUALITY]: No startup validation for WORK_MANAGER_URL; empty values can create broken notification links. return process.env.WORK_MANAGER_URL || ''; } + /** + * Returns the configured Copilot portal URL with trailing slash removed. + * + * @returns Copilot portal URL string. + */ private getCopilotPortalUrl(): string { + // TODO [QUALITY]: No startup validation for COPILOT_PORTAL_URL/WORK_MANAGER_URL fallback; empty values can create broken notification links. const value = process.env.COPILOT_PORTAL_URL || process.env.WORK_MANAGER_URL; return String(value || '').replace(/\/$/, ''); } + /** + * Resolves opportunity type with fallback chain: + * requestData.projectType -> opportunity.type. + * + * @param opportunity Opportunity entity. + * @param requestData Parsed request data object. + * @returns Resolved opportunity type enum. + */ private resolveOpportunityType( opportunity: CopilotOpportunity, requestData: Record, @@ -282,6 +351,13 @@ export class CopilotNotificationService { return opportunity.type; } + /** + * Formats date-like values into UTC DD-MM-YYYY string. + * Returns empty string when value is missing or invalid. + * + * @param value Date-like value. + * @returns Formatted date string or empty string. + */ private formatDate(value: unknown): string { if (!value) { return ''; @@ -306,7 +382,14 @@ export class CopilotNotificationService { return `${day}-${month}-${year}`; } + /** + * Reads a string-like primitive value. + * + * @param value Input value. + * @returns String value or undefined. + */ private readString(value: unknown): string | undefined { + // TODO [DRY]: Identical readString exists in CopilotRequestService; extract to copilot.utils.ts. if (typeof value === 'string') { return value; } diff --git a/src/api/copilot/copilot-opportunity.controller.ts b/src/api/copilot/copilot-opportunity.controller.ts index a642dd5..f91de7e 100644 --- a/src/api/copilot/copilot-opportunity.controller.ts +++ b/src/api/copilot/copilot-opportunity.controller.ts @@ -41,9 +41,23 @@ import { @ApiTags('Copilot Opportunities') @ApiBearerAuth() @Controller('/projects') +/** + * Exposes opportunity browsing endpoints for authenticated users and + * privileged assignment/cancellation endpoints for admins/PMs. + */ export class CopilotOpportunityController { constructor(private readonly service: CopilotOpportunityService) {} + /** + * GET /projects/copilots/opportunities + * Lists opportunities for all authenticated roles and sets pagination headers. + * + * @param req Express request. + * @param res Express response. + * @param query Pagination/sort query. + * @param user Authenticated JWT user. + * @returns Opportunity page data. + */ @Get('copilots/opportunities') @Roles(...Object.values(UserRole)) @Scopes( @@ -83,8 +97,18 @@ export class CopilotOpportunityController { return result.data; } + /** + * GET /projects/copilot/opportunity/:id + * GET /projects/copilots/opportunity/:id + * Returns one opportunity response. + * + * @param id Opportunity id path value. + * @param user Authenticated JWT user. + * @returns One opportunity response. + */ @Get('copilot/opportunity/:id') @Get('copilots/opportunity/:id') + // TODO [QUALITY]: Two route decorators (singular/plural) map to the same handler for legacy compatibility; document which route is canonical. @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECTS_READ, @@ -109,6 +133,15 @@ export class CopilotOpportunityController { return this.service.getOpportunity(id, user); } + /** + * POST /projects/copilots/opportunity/:id/assign + * Assigns an application with full assignment transaction behavior. + * + * @param id Opportunity id path value. + * @param dto Assignment payload. + * @param user Authenticated JWT user. + * @returns Assigned application id payload. + */ @Post('copilots/opportunity/:id/assign') @UseGuards(PermissionGuard) @Roles(UserRole.PROJECT_MANAGER, UserRole.TOPCODER_ADMIN) @@ -135,6 +168,14 @@ export class CopilotOpportunityController { return this.service.assignCopilot(id, dto, user); } + /** + * DELETE /projects/copilots/opportunity/:id/cancel + * Cancels an opportunity and related applications. + * + * @param id Opportunity id path value. + * @param user Authenticated JWT user. + * @returns Canceled opportunity id payload. + */ @Delete('copilots/opportunity/:id/cancel') @UseGuards(PermissionGuard) @Roles(UserRole.PROJECT_MANAGER, UserRole.TOPCODER_ADMIN) diff --git a/src/api/copilot/copilot-opportunity.service.ts b/src/api/copilot/copilot-opportunity.service.ts index 74db7c0..f798538 100644 --- a/src/api/copilot/copilot-opportunity.service.ts +++ b/src/api/copilot/copilot-opportunity.service.ts @@ -66,6 +66,11 @@ interface PaginatedOpportunityResponse { } @Injectable() +/** + * Manages copilot opportunity visibility, assignment, and cancellation. + * listOpportunities and getOpportunity are intentionally open to authenticated users + * so copilots can browse opportunities and apply. + */ export class CopilotOpportunityService { constructor( private readonly prisma: PrismaService, @@ -73,10 +78,20 @@ export class CopilotOpportunityService { private readonly notificationService: CopilotNotificationService, ) {} + /** + * Lists opportunities with pagination and status-priority grouping. + * Uses a two-phase fetch: opportunities first, then membership lookup to compute canApplyAsCopilot. + * Default ordering groups by status priority active -> canceled -> completed unless noGrouping is true. + * + * @param query Pagination, sort, and noGrouping parameters. + * @param user Authenticated JWT user. + * @returns Paginated opportunity response payload. + */ async listOpportunities( query: ListOpportunitiesQueryDto, user: JwtUser, ): Promise { + // TODO [SECURITY]: No permission check is applied here; this is intentional for authenticated browsing and should remain explicitly documented. const [sortField, sortDirection] = parseSortExpression( query.sort, OPPORTUNITY_SORTS, @@ -85,6 +100,7 @@ export class CopilotOpportunityService { const includeProject = isAdminOrManager(user); + // TODO [PERF]: This fetches the full opportunity set and performs sorting/pagination in memory; move to DB-level orderBy/skip/take for scale. const opportunities = await this.prisma.copilotOpportunity.findMany({ where: { deletedAt: null, @@ -137,10 +153,21 @@ export class CopilotOpportunityService { }; } + /** + * Returns a single opportunity with eligibility context. + * canApplyAsCopilot is true when the user is not already a member of the project. + * + * @param opportunityId Opportunity id path value. + * @param user Authenticated JWT user. + * @returns One formatted opportunity response. + * @throws BadRequestException If id is non-numeric. + * @throws NotFoundException If opportunity does not exist. + */ async getOpportunity( opportunityId: string, user: JwtUser, ): Promise { + // TODO [SECURITY]: No permission check is applied; any authenticated user can access any opportunity by id. const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity'); const opportunity = await this.prisma.copilotOpportunity.findFirst({ @@ -190,6 +217,25 @@ export class CopilotOpportunityService { ); } + /** + * Assigns a selected copilot application to an active opportunity in one transaction: + * 1) validate active opportunity with project + * 2) validate application belongs to opportunity + * 3) guard against double-accept + * 4) upsert project member with copilot role + * 5) accept selected application + * 6) cancel other applications + * 7) mark opportunity completed + * 8) mark linked request fulfilled + * + * @param opportunityId Opportunity id path value. + * @param dto Assignment payload with applicationId. + * @param user Authenticated JWT user. + * @returns Assigned application id payload. + * @throws ForbiddenException If user lacks ASSIGN_COPILOT_OPPORTUNITY permission. + * @throws NotFoundException If opportunity is not found. + * @throws BadRequestException If ids are invalid, opportunity is inactive, or application is invalid/already accepted. + */ async assignCopilot( opportunityId: string, dto: AssignCopilotDto, @@ -263,6 +309,7 @@ export class CopilotOpportunityService { existingMember.role !== ProjectMemberRole.copilot && existingMember.role !== ProjectMemberRole.manager ) { + // TODO [SECURITY]: Existing non-copilot/non-manager roles (for example customer) are silently upgraded to copilot without explicit confirmation or dedicated audit event. await tx.projectMember.update({ where: { id: existingMember.id, @@ -376,6 +423,16 @@ export class CopilotOpportunityService { }; } + /** + * Cancels an opportunity and its applications in a transaction, then sends notifications. + * + * @param opportunityId Opportunity id path value. + * @param user Authenticated JWT user. + * @returns Canceled opportunity id payload. + * @throws ForbiddenException If user lacks CANCEL_COPILOT_OPPORTUNITY permission. + * @throws NotFoundException If opportunity is not found. + * @throws BadRequestException If id is non-numeric. + */ async cancelOpportunity( opportunityId: string, user: JwtUser, @@ -449,6 +506,16 @@ export class CopilotOpportunityService { }; } + /** + * Formats an opportunity response DTO. + * Request data fields are spread directly onto the response. + * + * @param input Opportunity row with relations. + * @param canApplyAsCopilot Whether caller can apply. + * @param members Optional member userId list. + * @param includeProjectId Whether to include projectId in response. + * @returns Formatted opportunity response. + */ private formatOpportunity( input: OpportunityWithRelations, canApplyAsCopilot: boolean, @@ -481,6 +548,15 @@ export class CopilotOpportunityService { return response; } + /** + * Sorts opportunities by status-priority grouping (unless noGrouping) and createdAt. + * + * @param rows Opportunity rows. + * @param sortField Sort field. + * @param sortDirection Sort direction. + * @param noGrouping When true, skip status-priority grouping. + * @returns Sorted opportunities. + */ private sortOpportunities( rows: OpportunityWithRelations[], sortField: string, @@ -507,6 +583,14 @@ export class CopilotOpportunityService { }); } + /** + * Resolves project ids where the current user is already a member. + * Returns early for missing/non-numeric user ids and performs one batch membership query. + * + * @param opportunities Opportunity rows used to collect project ids. + * @param user Authenticated JWT user. + * @returns Set of project ids where membership exists. + */ private async getMembershipProjectIds( opportunities: CopilotOpportunity[], user: JwtUser, @@ -541,7 +625,15 @@ export class CopilotOpportunityService { ); } + /** + * Enforces a named permission for the current user. + * + * @param permission Permission constant. + * @param user Authenticated JWT user. + * @throws ForbiddenException If permission is missing. + */ private ensurePermission(permission: NamedPermission, user: JwtUser): void { + // TODO [DRY]: Identical ensurePermission method exists in CopilotRequestService and CopilotApplicationService; extract shared utility/base class. if (!this.permissionService.hasNamedPermission(permission, user)) { throw new ForbiddenException('Insufficient permissions'); } diff --git a/src/api/copilot/copilot-request.controller.ts b/src/api/copilot/copilot-request.controller.ts index cbfb0e7..d5527b8 100644 --- a/src/api/copilot/copilot-request.controller.ts +++ b/src/api/copilot/copilot-request.controller.ts @@ -45,9 +45,24 @@ import { @Roles(UserRole.PROJECT_MANAGER, UserRole.TOPCODER_ADMIN) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.MANAGE_COPILOT_REQUEST) +/** + * Exposes copilot request CRUD and approval endpoints under /projects. + * All endpoints require MANAGE_COPILOT_REQUEST permission and are restricted + * to PROJECT_MANAGER and TOPCODER_ADMIN roles. + */ export class CopilotRequestController { constructor(private readonly service: CopilotRequestService) {} + /** + * GET /projects/copilots/requests + * Cross-project request listing endpoint that sets pagination headers. + * + * @param req Express request. + * @param res Express response. + * @param query Pagination/sort query. + * @param user Authenticated JWT user. + * @returns Copilot request page data. + */ @Get('copilots/requests') @ApiOperation({ summary: 'List all copilot requests', @@ -80,6 +95,14 @@ export class CopilotRequestController { return result.data; } + /** + * GET /projects/copilots/requests/:copilotRequestId + * Returns one copilot request. + * + * @param copilotRequestId Copilot request id path value. + * @param user Authenticated JWT user. + * @returns One copilot request response. + */ @Get('copilots/requests/:copilotRequestId') @ApiOperation({ summary: 'Get copilot request', @@ -98,6 +121,15 @@ export class CopilotRequestController { return this.service.getRequest(copilotRequestId, user); } + /** + * PATCH /projects/copilots/requests/:copilotRequestId + * Partial request update endpoint; terminal statuses are rejected. + * + * @param copilotRequestId Copilot request id path value. + * @param dto Patch payload. + * @param user Authenticated JWT user. + * @returns Updated request response. + */ @Patch('copilots/requests/:copilotRequestId') @ApiOperation({ summary: 'Update copilot request', @@ -119,6 +151,17 @@ export class CopilotRequestController { return this.service.updateRequest(copilotRequestId, dto, user); } + /** + * GET /projects/:projectId/copilots/requests + * Project-scoped request list endpoint that sets pagination headers. + * + * @param req Express request. + * @param res Express response. + * @param projectId Project id path value. + * @param query Pagination/sort query. + * @param user Authenticated JWT user. + * @returns Project request page data. + */ @Get(':projectId/copilots/requests') @ApiOperation({ summary: 'List copilot requests for project', @@ -153,6 +196,14 @@ export class CopilotRequestController { return result.data; } + /** + * POST /projects/copilots/requests + * Creates a request using data.projectId from the request body. + * + * @param dto Create request payload. + * @param user Authenticated JWT user. + * @returns Created request response. + */ @Post('copilots/requests') @ApiOperation({ summary: 'Create copilot request', @@ -172,6 +223,15 @@ export class CopilotRequestController { return this.service.createRequest(String(dto.data.projectId), dto, user); } + /** + * POST /projects/:projectId/copilots/requests + * Creates a request using projectId from the path. + * + * @param projectId Project id path value. + * @param dto Create request payload. + * @param user Authenticated JWT user. + * @returns Created request response. + */ @Post(':projectId/copilots/requests') @ApiOperation({ summary: 'Create copilot request', @@ -193,6 +253,16 @@ export class CopilotRequestController { return this.service.createRequest(projectId, dto, user); } + /** + * POST /projects/:projectId/copilots/requests/:copilotRequestId/approve + * Approves request and creates opportunity, with optional type override in body. + * + * @param projectId Project id path value. + * @param copilotRequestId Copilot request id path value. + * @param type Optional opportunity type override. + * @param user Authenticated JWT user. + * @returns Created opportunity payload. + */ @Post(':projectId/copilots/requests/:copilotRequestId/approve') @ApiOperation({ summary: 'Approve copilot request', diff --git a/src/api/copilot/copilot-request.service.ts b/src/api/copilot/copilot-request.service.ts index 8c343f5..8534631 100644 --- a/src/api/copilot/copilot-request.service.ts +++ b/src/api/copilot/copilot-request.service.ts @@ -58,12 +58,28 @@ interface PaginatedRequestResponse { } @Injectable() +/** + * Manages the full lifecycle of copilot requests: + * create (with auto-approval), list, update, and manual approval. + * Request creation atomically creates the request and invokes + * approveRequestInternal within the same transaction. + */ export class CopilotRequestService { constructor( private readonly prisma: PrismaService, private readonly permissionService: PermissionService, ) {} + /** + * Lists copilot requests with optional project scoping and pagination. + * + * @param projectId Optional project id path value used to scope requests. + * @param query Pagination and sort parameters. + * @param user Authenticated JWT user. + * @returns Paginated request response payload. + * @throws ForbiddenException If user lacks MANAGE_COPILOT_REQUEST permission. + * @throws BadRequestException If projectId is non-numeric. + */ async listRequests( projectId: string | undefined, query: CopilotRequestListQueryDto, @@ -84,6 +100,7 @@ export class CopilotRequestService { const includeProjectForSort = sortField.toLowerCase() === 'projectname'; + // TODO [PERF]: This fetches all rows and applies sort/pagination in memory; move to database-level orderBy/skip/take for large datasets. const requests = await this.prisma.copilotRequest.findMany({ where: { deletedAt: null, @@ -131,6 +148,16 @@ export class CopilotRequestService { }; } + /** + * Returns a single copilot request by id. + * + * @param copilotRequestId Copilot request id path value. + * @param user Authenticated JWT user. + * @returns One formatted copilot request response. + * @throws ForbiddenException If user lacks MANAGE_COPILOT_REQUEST permission. + * @throws BadRequestException If id is non-numeric. + * @throws NotFoundException If the request does not exist. + */ async getRequest( copilotRequestId: string, user: JwtUser, @@ -165,6 +192,19 @@ export class CopilotRequestService { return this.formatRequest(request, isAdminOrManager(user)); } + /** + * Creates a new request and auto-approves it in one transaction. + * Transaction flow: create request -> approveRequestInternal -> re-fetch with opportunities. + * + * @param projectId Project id path value. + * @param dto Create request payload. + * @param user Authenticated JWT user. + * @returns Newly created and formatted request response. + * @throws ForbiddenException If user lacks MANAGE_COPILOT_REQUEST permission. + * @throws BadRequestException If payload data.projectId mismatches path projectId. + * @throws ConflictException If an active request already exists for the same projectType. + * @throws NotFoundException If project is not found. + */ async createRequest( projectId: string, dto: CreateCopilotRequestDto, @@ -174,6 +214,7 @@ export class CopilotRequestService { const parsedProjectId = parseNumericId(projectId, 'Project'); const auditUserId = getAuditUserId(user); + // TODO [QUALITY]: projectId is parsed twice (parseNumericId to bigint and Number.parseInt to number); consolidate to a single parse source. const payloadData = { ...dto.data, projectId: Number.parseInt(projectId, 10), @@ -239,6 +280,20 @@ export class CopilotRequestService { return this.formatRequest(created, isAdminOrManager(user)); } + /** + * Updates an existing request. + * Canceled and fulfilled requests are immutable. + * If projectType changes, linked opportunities are updated via updateMany. + * + * @param copilotRequestId Copilot request id path value. + * @param dto Patch payload. + * @param user Authenticated JWT user. + * @returns Updated formatted request response. + * @throws ForbiddenException If user lacks MANAGE_COPILOT_REQUEST permission. + * @throws BadRequestException If id is non-numeric or request is in terminal status. + * @throws NotFoundException If request is not found. + * @throws ConflictException If updated projectType conflicts with an existing active request type. + */ async updateRequest( copilotRequestId: string, dto: UpdateCopilotRequestDto, @@ -339,6 +394,19 @@ export class CopilotRequestService { return this.formatRequest(updated, isAdminOrManager(user)); } + /** + * Public approval entry point that wraps approveRequestInternal in a transaction. + * + * @param projectId Project id path value. + * @param copilotRequestId Copilot request id path value. + * @param type Optional type override. + * @param user Authenticated JWT user. + * @returns Normalized CopilotOpportunity payload. + * @throws ForbiddenException If user lacks MANAGE_COPILOT_REQUEST permission. + * @throws BadRequestException If ids or type are invalid. + * @throws NotFoundException If project or request are not found. + * @throws ConflictException If an active opportunity of the same type already exists. + */ async approveRequest( projectId: string, copilotRequestId: string, @@ -364,6 +432,21 @@ export class CopilotRequestService { return normalizeEntity(opportunity); } + /** + * Core request approval workflow. + * Validates project and request, validates type enum, checks for active opportunity conflicts, + * marks request approved, and creates an active opportunity. + * + * @param tx Prisma transaction client. + * @param projectId Parsed project id. + * @param copilotRequestId Parsed request id. + * @param type Optional type override. + * @param auditUserId Numeric audit user id. + * @returns Created CopilotOpportunity row. + * @throws NotFoundException If project or request are missing. + * @throws BadRequestException If type is invalid. + * @throws ConflictException If active opportunity of same type already exists. + */ private async approveRequestInternal( tx: Prisma.TransactionClient, projectId: bigint, @@ -441,6 +524,14 @@ export class CopilotRequestService { }); } + /** + * Ensures a project exists by id. + * + * @param projectId Project id. + * @param tx Optional Prisma transaction client; falls back to this.prisma when omitted. + * @returns Resolves when project exists. + * @throws NotFoundException If project does not exist. + */ private async ensureProjectExists( projectId: bigint, tx?: Prisma.TransactionClient, @@ -464,6 +555,15 @@ export class CopilotRequestService { } } + /** + * Ensures there is no active request with the same projectType for a project. + * + * @param projectId Project id. + * @param projectType Requested project type. + * @param excludeRequestId Optional request id to exclude (used by update flow). + * @returns Resolves when no duplicate exists. + * @throws ConflictException If duplicate request type exists. + */ private async ensureNoDuplicateRequestType( projectId: bigint, projectType: string, @@ -502,6 +602,14 @@ export class CopilotRequestService { } } + /** + * Formats a request row into API response shape. + * projectId and project are included only for admin/manager users. + * + * @param input Request row with relations. + * @param includeProjectInResponse Whether project fields should be included. + * @returns Formatted request response DTO. + */ private formatRequest( input: CopilotRequestWithRelations, includeProjectInResponse: boolean, @@ -540,11 +648,20 @@ export class CopilotRequestService { return response; } + /** + * Sorts request rows in memory by requested field/direction. + * + * @param rows Request rows. + * @param field Sort field. + * @param direction Sort direction. + * @returns Sorted request rows. + */ private sortRequests( rows: CopilotRequestWithRelations[], field: string, direction: 'asc' | 'desc', ): CopilotRequestWithRelations[] { + // TODO [PERF]: In-memory sort should be replaced with DB orderBy when listRequests moves to DB pagination. const factor = direction === 'asc' ? 1 : -1; return [...rows].sort((left, right) => { @@ -584,6 +701,14 @@ export class CopilotRequestService { }); } + /** + * Compares two values for sorting. + * Uses a Date fast-path, then falls back to case-insensitive string comparison. + * + * @param left Left value. + * @param right Right value. + * @returns Comparison result (-1, 0, 1). + */ private compareValues(left: unknown, right: unknown): number { if (left instanceof Date && right instanceof Date) { return left.getTime() - right.getTime(); @@ -603,13 +728,28 @@ export class CopilotRequestService { return 0; } + /** + * Enforces a named permission for the current user. + * + * @param permission Permission constant. + * @param user Authenticated JWT user. + * @throws ForbiddenException If permission is missing. + */ private ensurePermission(permission: NamedPermission, user: JwtUser): void { + // TODO [DRY]: Identical ensurePermission method exists in CopilotOpportunityService and CopilotApplicationService; extract shared utility/base class. if (!this.permissionService.hasNamedPermission(permission, user)) { throw new ForbiddenException('Insufficient permissions'); } } + /** + * Reads a string-like primitive value. + * + * @param value Input value. + * @returns String value or undefined. + */ private readString(value: unknown): string | undefined { + // TODO [DRY]: Identical readString exists in CopilotNotificationService; extract to copilot.utils.ts. if (typeof value === 'string') { return value; } diff --git a/src/api/copilot/copilot.module.ts b/src/api/copilot/copilot.module.ts index 2ee5ccb..f877ae9 100644 --- a/src/api/copilot/copilot.module.ts +++ b/src/api/copilot/copilot.module.ts @@ -22,4 +22,18 @@ import { CopilotRequestService } from './copilot-request.service'; CopilotNotificationService, ], }) +/** + * NestJS feature module for the copilot subsystem. + * Controllers: + * - CopilotRequestController + * - CopilotOpportunityController + * - CopilotApplicationController + * Providers: + * - CopilotRequestService + * - CopilotOpportunityService + * - CopilotApplicationService + * - CopilotNotificationService + * Depends on GlobalProvidersModule for shared PrismaService, + * PermissionService, and MemberService wiring. + */ export class CopilotModule {} diff --git a/src/api/copilot/copilot.utils.ts b/src/api/copilot/copilot.utils.ts index 903aa08..92d1648 100644 --- a/src/api/copilot/copilot.utils.ts +++ b/src/api/copilot/copilot.utils.ts @@ -3,8 +3,21 @@ import { CopilotOpportunityType, Prisma } from '@prisma/client'; import { UserRole } from 'src/shared/enums/userRole.enum'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; +/** + * Shared pure-function toolkit for the copilot feature. + * Handles id parsing, request-data extraction, entity normalization, + * role checks, sort parsing, and audit user id parsing. + */ export type CopilotRequestDataRecord = Record; +/** + * Parses a numeric entity id from a raw string. + * + * @param value Raw id string value. + * @param label Entity label used in the error message. + * @returns Parsed bigint id. + * @throws BadRequestException If the value is not numeric. + */ export function parseNumericId(value: string, label: string): bigint { const normalized = String(value || '').trim(); @@ -15,6 +28,12 @@ export function parseNumericId(value: string, label: string): bigint { return BigInt(normalized); } +/** + * Reads and safely clones the copilot request data object from Prisma JSON. + * + * @param value Prisma JsonValue payload. + * @returns Safe plain-object copy of request data, or an empty object. + */ export function getCopilotRequestData( value: Prisma.JsonValue | null | undefined, ): CopilotRequestDataRecord { @@ -25,6 +44,12 @@ export function getCopilotRequestData( return {}; } +/** + * Recursively normalizes entity values for API responses. + * + * @param payload Prisma entity payload. + * @returns Same shape with bigint values converted to string and Decimal values converted to number. + */ export function normalizeEntity(payload: T): T { const walk = (input: unknown): unknown => { if (typeof input === 'bigint') { @@ -58,6 +83,12 @@ export function normalizeEntity(payload: T): T { return walk(payload) as T; } +/** + * Converts an opportunity type enum to a human-readable label. + * + * @param type Copilot opportunity type. + * @returns Human-readable type label. + */ export function getCopilotTypeLabel(type: CopilotOpportunityType): string { switch (type) { case CopilotOpportunityType.dev: @@ -75,6 +106,12 @@ export function getCopilotTypeLabel(type: CopilotOpportunityType): string { } } +/** + * Returns true if user is admin, project manager, or manager. + * + * @param user Authenticated JWT user. + * @returns Whether the user is admin-or-manager scoped. + */ export function isAdminOrManager(user: JwtUser): boolean { const userRoles = user.roles || []; @@ -86,6 +123,12 @@ export function isAdminOrManager(user: JwtUser): boolean { ].some((role) => userRoles.includes(role)); } +/** + * Returns true if user is admin or project manager. + * + * @param user Authenticated JWT user. + * @returns Whether the user is admin-or-pm scoped. + */ export function isAdminOrPm(user: JwtUser): boolean { const userRoles = user.roles || []; @@ -96,6 +139,15 @@ export function isAdminOrPm(user: JwtUser): boolean { ].some((role) => userRoles.includes(role)); } +/** + * Parses a query sort expression and validates it against an allow-list. + * + * @param sort Raw sort query value. + * @param allowedSorts List of allowed sort expressions. + * @param defaultValue Default sort expression when none is provided. + * @returns Tuple of [field, direction]. + * @throws BadRequestException If the sort expression is invalid. + */ export function parseSortExpression( sort: string | undefined, allowedSorts: string[], @@ -116,6 +168,13 @@ export function parseSortExpression( return [field, direction.toLowerCase() === 'asc' ? 'asc' : 'desc']; } +/** + * Parses the numeric audit user id from an authenticated JWT user. + * + * @param user Authenticated JWT user. + * @returns Numeric user id for audit fields. + * @throws BadRequestException If user.userId is not numeric. + */ export function getAuditUserId(user: JwtUser): number { const value = Number.parseInt(String(user.userId || '').trim(), 10); @@ -125,3 +184,5 @@ export function getAuditUserId(user: JwtUser): number { return value; } + +// TODO [DRY]: Extract and export readString here; it is duplicated in CopilotRequestService and CopilotNotificationService. diff --git a/src/api/copilot/dto/copilot-application.dto.ts b/src/api/copilot/dto/copilot-application.dto.ts index a9d2740..6c4d549 100644 --- a/src/api/copilot/dto/copilot-application.dto.ts +++ b/src/api/copilot/dto/copilot-application.dto.ts @@ -3,7 +3,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; +/** + * DTOs for copilot applications (apply, list, assign). + */ function parseOptionalInteger(value: unknown): number | undefined { + // TODO [DRY]: parseOptionalInteger is duplicated verbatim in copilot-request.dto.ts and copilot-opportunity.dto.ts; extract shared dto transform utilities. if (typeof value === 'undefined' || value === null || value === '') { return undefined; } @@ -20,6 +24,10 @@ function parseOptionalInteger(value: unknown): number | undefined { return undefined; } +/** + * Request body for applying to an opportunity. + * notes is required. + */ export class CreateCopilotApplicationDto { @ApiProperty() @IsString() @@ -27,11 +35,18 @@ export class CreateCopilotApplicationDto { notes: string; } +/** + * Embedded membership context when applicant is already in the project. + */ export class ExistingMembershipDto { @ApiProperty() role: string; } +/** + * Full copilot application response. + * existingMembership is only populated in admin/PM list views. + */ export class CopilotApplicationResponseDto { @ApiProperty() id: string; @@ -61,6 +76,9 @@ export class CopilotApplicationResponseDto { existingMembership?: ExistingMembershipDto; } +/** + * Request body for assigning a copilot application. + */ export class AssignCopilotDto { @ApiProperty() @IsString() @@ -68,6 +86,9 @@ export class AssignCopilotDto { applicationId: string; } +/** + * Pagination and sort query parameters for application listing endpoints. + */ export class CopilotApplicationListQueryDto { @ApiPropertyOptional({ minimum: 1, default: 1 }) @IsOptional() diff --git a/src/api/copilot/dto/copilot-opportunity.dto.ts b/src/api/copilot/dto/copilot-opportunity.dto.ts index e8ab164..151eb82 100644 --- a/src/api/copilot/dto/copilot-opportunity.dto.ts +++ b/src/api/copilot/dto/copilot-opportunity.dto.ts @@ -7,7 +7,11 @@ import { Transform } from 'class-transformer'; import { IsBoolean, IsInt, IsOptional, IsString, Min } from 'class-validator'; import { CopilotSkillDto } from './copilot-request.dto'; +/** + * DTOs for listing and responding with copilot opportunities. + */ function parseOptionalInteger(value: unknown): number | undefined { + // TODO [DRY]: parseOptionalInteger is duplicated across copilot-request.dto.ts and copilot-application.dto.ts; extract shared dto transform utilities. if (typeof value === 'undefined' || value === null || value === '') { return undefined; } @@ -25,6 +29,7 @@ function parseOptionalInteger(value: unknown): number | undefined { } function parseOptionalBoolean(value: unknown): boolean | undefined { + // TODO [DRY]: parseOptionalBoolean duplicates query transform logic; extract shared dto transform utilities. if (typeof value === 'boolean') { return value; } @@ -44,6 +49,10 @@ function parseOptionalBoolean(value: unknown): boolean | undefined { return undefined; } +/** + * Flattened response merging opportunity fields with request data. + * canApplyAsCopilot indicates whether the current user is eligible to apply. + */ export class CopilotOpportunityResponseDto { @ApiProperty() id: string; @@ -121,6 +130,11 @@ export class CopilotOpportunityResponseDto { members?: string[]; } +/** + * Pagination, sort, and noGrouping query parameters for opportunities. + * noGrouping=false (default) groups results by status priority: + * active -> canceled -> completed. + */ export class ListOpportunitiesQueryDto { @ApiPropertyOptional({ minimum: 1, default: 1 }) @IsOptional() diff --git a/src/api/copilot/dto/copilot-request.dto.ts b/src/api/copilot/dto/copilot-request.dto.ts index 3de411e..04efa83 100644 --- a/src/api/copilot/dto/copilot-request.dto.ts +++ b/src/api/copilot/dto/copilot-request.dto.ts @@ -20,7 +20,11 @@ import { ValidateNested, } from 'class-validator'; +/** + * Input/output DTOs for the copilot request lifecycle. + */ function parseOptionalInteger(value: unknown): number | undefined { + // TODO [DRY]: parseOptionalInteger is duplicated verbatim in copilot-opportunity.dto.ts and copilot-application.dto.ts; extract to src/shared/utils/dto-transforms.utils.ts (or similar). if (typeof value === 'undefined' || value === null || value === '') { return undefined; } @@ -53,6 +57,9 @@ export enum CopilotPaymentType { OTHER = 'other', } +/** + * Represents a skill tag attached to a copilot request. + */ export class CopilotSkillDto { @ApiProperty() @IsString() @@ -65,6 +72,10 @@ export class CopilotSkillDto { name: string; } +/** + * Validated payload for the data envelope of a new copilot request. + * All fields are required except copilotUsername and otherPaymentType. + */ export class CreateCopilotRequestDataDto { @ApiProperty({ description: 'Project id' }) @Type(() => Number) @@ -141,10 +152,16 @@ export class CreateCopilotRequestDataDto { numHoursPerWeek: number; } +/** + * Partial payload for PATCH operations on copilot request data. + */ export class UpdateCopilotRequestDataDto extends PartialType( CreateCopilotRequestDataDto, ) {} +/** + * Top-level envelope wrapper for create request payloads. + */ export class CreateCopilotRequestDto { @ApiProperty({ type: () => CreateCopilotRequestDataDto }) @ValidateNested() @@ -152,6 +169,9 @@ export class CreateCopilotRequestDto { data: CreateCopilotRequestDataDto; } +/** + * Top-level envelope wrapper for update request payloads. + */ export class UpdateCopilotRequestDto { @ApiProperty({ type: () => UpdateCopilotRequestDataDto }) @ValidateNested() @@ -159,6 +179,9 @@ export class UpdateCopilotRequestDto { data: UpdateCopilotRequestDataDto; } +/** + * Embedded opportunity summary returned in request responses. + */ export class CopilotRequestOpportunityResponseDto { @ApiProperty() id: string; @@ -182,6 +205,9 @@ export class CopilotRequestOpportunityResponseDto { updatedAt: Date; } +/** + * Full response shape for a copilot request. + */ export class CopilotRequestResponseDto { @ApiProperty() id: string; @@ -214,6 +240,9 @@ export class CopilotRequestResponseDto { copilotOpportunity?: CopilotRequestOpportunityResponseDto[]; } +/** + * Pagination and sort query parameters for request listing endpoints. + */ export class CopilotRequestListQueryDto { @ApiPropertyOptional({ minimum: 1, default: 1 }) @IsOptional() diff --git a/src/api/metadata/dto/metadata-reference.dto.ts b/src/api/metadata/dto/metadata-reference.dto.ts index b99e4d2..738b92d 100644 --- a/src/api/metadata/dto/metadata-reference.dto.ts +++ b/src/api/metadata/dto/metadata-reference.dto.ts @@ -2,6 +2,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; +/** + * Versioned metadata reference payload. + * + * @property key Metadata key to resolve. + * @property version Optional metadata version. When omitted, callers resolve to + * latest. + */ export class MetadataReferenceDto { @ApiProperty({ example: 'design' }) @IsString() diff --git a/src/api/metadata/form/dto/create-form-revision.dto.ts b/src/api/metadata/form/dto/create-form-revision.dto.ts index a91dc50..a76aa60 100644 --- a/src/api/metadata/form/dto/create-form-revision.dto.ts +++ b/src/api/metadata/form/dto/create-form-revision.dto.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsObject } from 'class-validator'; +/** + * Request payload for creating a new form revision. + * + * @property config Form JSON configuration body. + */ export class CreateFormRevisionDto { @ApiProperty({ type: 'object', diff --git a/src/api/metadata/form/dto/create-form-version.dto.ts b/src/api/metadata/form/dto/create-form-version.dto.ts index 3536372..957c7fa 100644 --- a/src/api/metadata/form/dto/create-form-version.dto.ts +++ b/src/api/metadata/form/dto/create-form-version.dto.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsObject } from 'class-validator'; +/** + * Request payload for creating a new form version. + * + * @property config Form JSON configuration body. + */ export class CreateFormVersionDto { @ApiProperty({ type: 'object', diff --git a/src/api/metadata/form/dto/form-response.dto.ts b/src/api/metadata/form/dto/form-response.dto.ts index f3cace8..c3b7ed5 100644 --- a/src/api/metadata/form/dto/form-response.dto.ts +++ b/src/api/metadata/form/dto/form-response.dto.ts @@ -1,5 +1,18 @@ import { ApiProperty } from '@nestjs/swagger'; +/** + * API response payload for a form revision record. + * + * @property id Form record id. + * @property key Form key. + * @property version Major version. + * @property revision Minor revision. + * @property config Form configuration JSON. + * @property createdAt Creation timestamp. + * @property updatedAt Update timestamp. + * @property createdBy Creator user id. + * @property updatedBy Updater user id. + */ export class FormResponseDto { @ApiProperty() id: string; diff --git a/src/api/metadata/form/dto/update-form-version.dto.ts b/src/api/metadata/form/dto/update-form-version.dto.ts index 8041cc5..61ec897 100644 --- a/src/api/metadata/form/dto/update-form-version.dto.ts +++ b/src/api/metadata/form/dto/update-form-version.dto.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsObject } from 'class-validator'; +/** + * Request payload for updating the latest revision within a form version. + * + * @property config Replacement form JSON configuration body. + */ export class UpdateFormVersionDto { @ApiProperty({ type: 'object', diff --git a/src/api/metadata/form/form-revision.controller.ts b/src/api/metadata/form/form-revision.controller.ts index 9a1ba7d..7aed6f3 100644 --- a/src/api/metadata/form/form-revision.controller.ts +++ b/src/api/metadata/form/form-revision.controller.ts @@ -7,6 +7,7 @@ import { ApiTags, } from '@nestjs/swagger'; import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; +import { Public } from 'src/shared/decorators/public.decorator'; import { AdminOnly } from 'src/shared/guards/adminOnly.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { @@ -20,9 +21,16 @@ import { FormService } from './form.service'; @ApiTags('Metadata - Forms') @ApiBearerAuth() @Controller('/projects/metadata/form/:key/versions/:version/revisions') +/** + * REST controller for form revision operations. + * + * Read endpoints are public (`@Public()`); write endpoints require + * `@AdminOnly`. + */ export class FormRevisionController { constructor(private readonly formService: FormService) {} + @Public() @Get() @ApiOperation({ summary: 'List form revisions by version', @@ -31,6 +39,11 @@ export class FormRevisionController { @ApiParam({ name: 'version', description: 'Form version' }) @ApiResponse({ status: 200, type: [FormResponseDto] }) @ApiResponse({ status: 404, description: 'Form not found' }) + /** + * Lists all revisions for a form version. + * + * HTTP 200 on success. + */ async listRevisions( @Param('key') key: string, @Param('version') version: string, @@ -41,6 +54,7 @@ export class FormRevisionController { ); } + @Public() @Get(':revision') @ApiOperation({ summary: 'Get specific form revision', @@ -50,6 +64,11 @@ export class FormRevisionController { @ApiParam({ name: 'revision', description: 'Form revision' }) @ApiResponse({ status: 200, type: FormResponseDto }) @ApiResponse({ status: 404, description: 'Form not found' }) + /** + * Fetches one exact form revision. + * + * HTTP 200 on success. + */ async getRevision( @Param('key') key: string, @Param('version') version: string, @@ -72,6 +91,11 @@ export class FormRevisionController { @ApiResponse({ status: 201, type: FormResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Form not found' }) + /** + * Creates a new revision under a form version. + * + * HTTP 201 on success. + */ async createRevision( @Param('key') key: string, @Param('version') version: string, @@ -97,6 +121,11 @@ export class FormRevisionController { @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Form not found' }) + /** + * Soft deletes one form revision. + * + * HTTP 204 on success. + */ async deleteRevision( @Param('key') key: string, @Param('version') version: string, diff --git a/src/api/metadata/form/form-version.controller.ts b/src/api/metadata/form/form-version.controller.ts index 5ed0c12..afe6f60 100644 --- a/src/api/metadata/form/form-version.controller.ts +++ b/src/api/metadata/form/form-version.controller.ts @@ -15,6 +15,7 @@ import { ApiTags, } from '@nestjs/swagger'; import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; +import { Public } from 'src/shared/decorators/public.decorator'; import { AdminOnly } from 'src/shared/guards/adminOnly.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { @@ -29,9 +30,16 @@ import { FormService } from './form.service'; @ApiTags('Metadata - Forms') @ApiBearerAuth() @Controller('/projects/metadata/form/:key/versions') +/** + * REST controller for form version operations. + * + * Read endpoints are public (`@Public()`); write endpoints require + * `@AdminOnly`. + */ export class FormVersionController { constructor(private readonly formService: FormService) {} + @Public() @Get() @ApiOperation({ summary: 'List form versions', @@ -40,10 +48,16 @@ export class FormVersionController { @ApiParam({ name: 'key', description: 'Form key' }) @ApiResponse({ status: 200, type: [FormResponseDto] }) @ApiResponse({ status: 404, description: 'Form not found' }) + /** + * Lists the latest revision of each version for a form key. + * + * HTTP 200 on success. + */ async listVersions(@Param('key') key: string): Promise { return this.formService.findAllVersions(key); } + @Public() @Get(':version') @ApiOperation({ summary: 'Get latest revision by form version', @@ -52,6 +66,11 @@ export class FormVersionController { @ApiParam({ name: 'version', description: 'Form version' }) @ApiResponse({ status: 200, type: FormResponseDto }) @ApiResponse({ status: 404, description: 'Form not found' }) + /** + * Fetches latest revision for one form version. + * + * HTTP 200 on success. + */ async getVersion( @Param('key') key: string, @Param('version') version: string, @@ -70,6 +89,11 @@ export class FormVersionController { @ApiParam({ name: 'key', description: 'Form key' }) @ApiResponse({ status: 201, type: FormResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) + /** + * Creates a new form version. + * + * HTTP 201 on success. + */ async createVersion( @Param('key') key: string, @Body() dto: CreateFormVersionDto, @@ -92,6 +116,11 @@ export class FormVersionController { @ApiResponse({ status: 200, type: FormResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Form not found' }) + /** + * Updates the latest revision for a form version. + * + * HTTP 200 on success. + */ async updateVersion( @Param('key') key: string, @Param('version') version: string, @@ -116,6 +145,11 @@ export class FormVersionController { @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Form not found' }) + /** + * Soft deletes all revisions in a form version. + * + * HTTP 204 on success. + */ async deleteVersion( @Param('key') key: string, @Param('version') version: string, diff --git a/src/api/metadata/form/form.controller.ts b/src/api/metadata/form/form.controller.ts index ed89c37..8b37a9b 100644 --- a/src/api/metadata/form/form.controller.ts +++ b/src/api/metadata/form/form.controller.ts @@ -1,20 +1,18 @@ import { Controller, Get, Param } from '@nestjs/common'; -import { - ApiBearerAuth, - ApiOperation, - ApiParam, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from 'src/shared/decorators/public.decorator'; import { FormResponseDto } from './dto/form-response.dto'; import { FormService } from './form.service'; @ApiTags('Metadata - Forms') -@ApiBearerAuth() @Controller('/projects/metadata/form') +/** + * REST controller for reading public form metadata. + */ export class FormController { constructor(private readonly formService: FormService) {} + @Public() @Get(':key') @ApiOperation({ summary: 'Get latest form revision of latest version', @@ -24,6 +22,11 @@ export class FormController { @ApiParam({ name: 'key', description: 'Form key' }) @ApiResponse({ status: 200, type: FormResponseDto }) @ApiResponse({ status: 404, description: 'Form not found' }) + /** + * Returns the latest revision from the latest version for a form key. + * + * HTTP 200 on success, 404 when key does not exist. + */ async getLatest(@Param('key') key: string): Promise { return this.formService.findLatestRevisionOfLatestVersion(key); } diff --git a/src/api/metadata/form/form.module.ts b/src/api/metadata/form/form.module.ts index cb4eb83..db1998e 100644 --- a/src/api/metadata/form/form.module.ts +++ b/src/api/metadata/form/form.module.ts @@ -5,6 +5,10 @@ import { FormRevisionController } from './form-revision.controller'; import { FormVersionController } from './form-version.controller'; import { FormService } from './form.service'; +/** + * Registers form metadata controllers and `FormService`, and exports the + * service for other metadata modules that depend on versioned form operations. + */ @Module({ imports: [GlobalProvidersModule], controllers: [FormController, FormVersionController, FormRevisionController], diff --git a/src/api/metadata/form/form.service.ts b/src/api/metadata/form/form.service.ts index 98eaf88..eaa8bae 100644 --- a/src/api/metadata/form/form.service.ts +++ b/src/api/metadata/form/form.service.ts @@ -15,6 +15,16 @@ import { import { FormResponseDto } from './dto/form-response.dto'; @Injectable() +/** + * Manages versioned form configurations referenced by project and product + * templates. + * + * Each form is keyed by `key` and versioned with: + * - `version`: major version + * - `revision`: minor revision within a version + * + * Soft delete is used for all delete operations. + */ export class FormService { constructor( private readonly prisma: PrismaService, @@ -22,6 +32,13 @@ export class FormService { private readonly eventBusService: EventBusService, ) {} + /** + * Fetches the latest revision from the latest version for a key. + * + * @param key Metadata key. + * @returns Latest form revision DTO. + * @throws {NotFoundException} If no form exists for the key. + */ async findLatestRevisionOfLatestVersion( key: string, ): Promise { @@ -42,6 +59,13 @@ export class FormService { return this.toDto(form); } + /** + * Returns one record per version (latest revision of each version). + * + * @param key Metadata key. + * @returns Form DTOs, one per version. + * @throws {NotFoundException} If no form exists for the key. + */ async findAllVersions(key: string): Promise { const normalizedKey = this.normalizeKey(key); @@ -71,6 +95,14 @@ export class FormService { ); } + /** + * Fetches the latest revision for a specific version. + * + * @param key Metadata key. + * @param version Target version number. + * @returns Form DTO for the latest revision of the version. + * @throws {NotFoundException} If the key/version does not exist. + */ async findLatestRevisionOfVersion( key: string, version: bigint, @@ -95,6 +127,14 @@ export class FormService { return this.toDto(form); } + /** + * Fetches all revisions for a specific version, newest first. + * + * @param key Metadata key. + * @param version Target version number. + * @returns Form revision DTOs. + * @throws {NotFoundException} If the key/version does not exist. + */ async findAllRevisions( key: string, version: bigint, @@ -119,6 +159,15 @@ export class FormService { return forms.map((form) => this.toDto(form)); } + /** + * Fetches an exact key/version/revision record. + * + * @param key Metadata key. + * @param version Target version number. + * @param revision Target revision number. + * @returns Form DTO for the exact revision. + * @throws {NotFoundException} If the exact revision does not exist. + */ async findSpecificRevision( key: string, version: bigint, @@ -144,6 +193,15 @@ export class FormService { return this.toDto(form); } + /** + * Creates a new version for a key with revision `1`. + * + * @param key Metadata key. + * @param config Form configuration payload. + * @param userId Audit user id. + * @returns Created form DTO. + * @throws {BadRequestException} If key is empty. + */ async createVersion( key: string, config: Record, @@ -191,6 +249,17 @@ export class FormService { } } + /** + * Creates a new revision under an existing version. + * + * @param key Metadata key. + * @param version Target version number. + * @param config Form configuration payload. + * @param userId Audit user id. + * @returns Created form revision DTO. + * @throws {NotFoundException} If key/version does not exist. + * @throws {BadRequestException} If key is empty. + */ async createRevision( key: string, version: bigint, @@ -244,6 +313,16 @@ export class FormService { } } + /** + * Updates the config on the latest revision of a specific version. + * + * @param key Metadata key. + * @param version Target version number. + * @param config Replacement configuration payload. + * @param userId Audit user id. + * @returns Updated form DTO. + * @throws {NotFoundException} If key/version does not exist. + */ async updateVersion( key: string, version: bigint, @@ -296,6 +375,14 @@ export class FormService { } } + /** + * Soft deletes all revisions for a version. + * + * @param key Metadata key. + * @param version Target version number. + * @param userId Audit user id. + * @throws {NotFoundException} If key/version does not exist. + */ async deleteVersion( key: string, version: bigint, @@ -354,6 +441,15 @@ export class FormService { } } + /** + * Soft deletes a single revision. + * + * @param key Metadata key. + * @param version Target version number. + * @param revision Target revision number. + * @param userId Audit user id. + * @throws {NotFoundException} If key/version/revision does not exist. + */ async deleteRevision( key: string, version: bigint, @@ -405,6 +501,12 @@ export class FormService { } } + /** + * Maps Prisma entity to API DTO, converting bigint fields to strings. + * + * @param form Prisma form entity. + * @returns Serialized form response DTO. + */ private toDto(form: Form): FormResponseDto { return { id: form.id.toString(), @@ -422,6 +524,13 @@ export class FormService { }; } + /** + * Trims and validates metadata key. + * + * @param key Raw metadata key. + * @returns Normalized key. + * @throws {BadRequestException} If key is empty. + */ private normalizeKey(key: string): string { const normalized = String(key || '').trim(); @@ -432,6 +541,14 @@ export class FormService { return normalized; } + /** + * Re-throws framework HTTP exceptions and delegates Prisma errors to + * `PrismaErrorService`. + * + * @param error Unknown error. + * @param operation Operation name for error context. + * @throws {HttpException} Re-throws known HTTP errors. + */ private handleError(error: unknown, operation: string): never { if (error instanceof HttpException) { throw error; diff --git a/src/api/metadata/metadata-list-response.dto.ts b/src/api/metadata/metadata-list-response.dto.ts index 7459a4b..23444df 100644 --- a/src/api/metadata/metadata-list-response.dto.ts +++ b/src/api/metadata/metadata-list-response.dto.ts @@ -1,5 +1,17 @@ import { ApiProperty } from '@nestjs/swagger'; +/** + * Aggregated metadata list response payload. + * + * @property projectTemplates Active project templates. + * @property productTemplates Active product templates. + * @property projectTypes Active project types. + * @property productCategories Active product categories. + * @property milestoneTemplates Active milestone templates. + * @property forms Selected form records. + * @property planConfigs Selected plan config records. + * @property priceConfigs Selected price config records. + */ export class MetadataListResponseDto { @ApiProperty({ type: [Object] }) projectTemplates: Record[]; diff --git a/src/api/metadata/metadata-list.controller.ts b/src/api/metadata/metadata-list.controller.ts index 3373995..61bfe11 100644 --- a/src/api/metadata/metadata-list.controller.ts +++ b/src/api/metadata/metadata-list.controller.ts @@ -10,6 +10,13 @@ import { @ApiTags('Metadata') @Controller('/projects/metadata') +/** + * Exposes `GET /projects/metadata` as a single public endpoint that returns + * all active metadata in one payload for UI bootstrapping. + * + * This endpoint is consumed by `platform-ui` on initial load to hydrate + * project/product template and category selectors. + */ export class MetadataListController { constructor(private readonly metadataListService: MetadataListService) {} @@ -28,6 +35,15 @@ export class MetadataListController { 'When true, includes all versions referred by templates plus latest versions.', }) @ApiResponse({ status: 200, type: MetadataListResponseDto }) + /** + * Returns all active metadata grouped by resource type. + * + * @param includeAllReferred When `true`, includes template-referenced + * versions in addition to the latest version per key for form/plan/price + * configs. + * @returns Aggregated metadata payload. If no rows exist, resource arrays are + * returned empty. + */ async getAllMetadata( @Query('includeAllReferred') includeAllReferred?: string, ): Promise { diff --git a/src/api/metadata/metadata-list.service.ts b/src/api/metadata/metadata-list.service.ts index 9a7487a..c297457 100644 --- a/src/api/metadata/metadata-list.service.ts +++ b/src/api/metadata/metadata-list.service.ts @@ -25,9 +25,22 @@ interface UsedVersionsMap { } @Injectable() +/** + * Orchestrates metadata list assembly by querying each metadata table in + * parallel and applying version-selection logic for versioned resources + * (`form`, `planConfig`, and `priceConfig`). + */ export class MetadataListService { constructor(private readonly prisma: PrismaService) {} + /** + * Loads all active metadata and applies the requested version-selection + * strategy for versioned config resources. + * + * @param includeAllReferred Whether template-referenced versions should be + * included along with the latest version per key. + * @returns Metadata grouped by resource type. + */ async getAllMetadata( includeAllReferred: boolean, ): Promise { @@ -109,6 +122,14 @@ export class MetadataListService { }; } + /** + * Scans template-level metadata references and returns a map of + * `key -> used versions` for form/plan/price configs. + * + * @param projectTemplates Active project templates. + * @param productTemplates Active product templates. + * @returns Used version lookup map by metadata type. + */ getUsedVersions( projectTemplates: ProjectTemplate[], productTemplates: ProductTemplate[], @@ -140,6 +161,12 @@ export class MetadataListService { return modelUsed; } + /** + * Returns only the latest version per key for versioned metadata resources. + * + * @returns Tuple of `[forms, planConfigs, priceConfigs]`, each containing one + * latest revision record per key. + */ async getLatestVersions(): Promise< [ Record[], @@ -147,6 +174,7 @@ export class MetadataListService { Record[], ] > { + // TODO (DRY): getLatestVersions and getLatestVersionIncludeUsed execute identical Prisma queries; extract the three findMany calls into a private fetchAllVersionedRecords() helper. const [forms, planConfigs, priceConfigs] = await Promise.all([ this.prisma.form.findMany({ where: { @@ -175,6 +203,14 @@ export class MetadataListService { ]; } + /** + * Returns the latest version per key plus additional versions explicitly + * referenced by active templates. + * + * @param usedVersions Precomputed used-version sets by metadata type. + * @returns Tuple of `[forms, planConfigs, priceConfigs]` containing latest and + * template-referenced versions. + */ async getLatestVersionIncludeUsed( usedVersions: UsedVersionsMap, ): Promise< @@ -184,6 +220,7 @@ export class MetadataListService { Record[], ] > { + // TODO (DRY): getLatestVersions and getLatestVersionIncludeUsed execute identical Prisma queries; extract the three findMany calls into a private fetchAllVersionedRecords() helper. const [forms, planConfigs, priceConfigs] = await Promise.all([ this.prisma.form.findMany({ where: { @@ -212,6 +249,14 @@ export class MetadataListService { ]; } + /** + * Parses a template JSON reference field and stores the referenced version in + * a destination version map. + * + * @param destination Target map keyed by metadata key. + * @param value Raw Prisma JSON value from template record. + * @param name Metadata reference field name. + */ private pushReference( destination: Map>, value: Prisma.JsonValue | null, @@ -225,6 +270,12 @@ export class MetadataListService { this.pushUsedVersion(destination, reference); } + /** + * Adds a resolved metadata reference version to a destination map. + * + * @param destination Target map keyed by metadata key. + * @param reference Normalized metadata reference. + */ private pushUsedVersion( destination: Map>, reference: MetadataVersionReference, @@ -240,6 +291,13 @@ export class MetadataListService { destination.get(reference.key)?.add(reference.version); } + /** + * Picks one record per key from pre-sorted records (latest version/revision + * first) and serializes BigInt fields for JSON response output. + * + * @param records Sorted versioned metadata records. + * @returns Serialized latest record per key. + */ private pickLatestKeyVersions< T extends { key: string; version: bigint; revision: bigint }, >(records: T[]): Record[] { @@ -256,6 +314,14 @@ export class MetadataListService { ); } + /** + * Picks latest revision records for all versions included in the + * `usedVersions` map and ensures each key's latest version is always present. + * + * @param records Sorted versioned metadata records. + * @param usedVersions Version sets keyed by metadata key. + * @returns Serialized records containing latest + referenced versions. + */ private pickLatestAndUsedVersions< T extends { key: string; version: bigint; revision: bigint }, >( diff --git a/src/api/metadata/metadata.module.ts b/src/api/metadata/metadata.module.ts index 31eb594..957ee1b 100644 --- a/src/api/metadata/metadata.module.ts +++ b/src/api/metadata/metadata.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { FormModule } from './form/form.module'; +import { MilestoneTemplateModule } from './milestone-template/milestone-template.module'; import { MetadataListController } from './metadata-list.controller'; import { MetadataListService } from './metadata-list.service'; import { OrgConfigModule } from './org-config/org-config.module'; @@ -11,12 +12,31 @@ import { ProjectTemplateModule } from './project-template/project-template.modul import { ProjectTypeModule } from './project-type/project-type.module'; import { WorkManagementPermissionModule } from './work-management-permission/work-management-permission.module'; +/** + * Aggregates all metadata sub-modules used by the projects API. + * + * Imported modules: + * - `ProjectTemplateModule`: project-level template definitions. + * - `ProductTemplateModule`: product/work-item template definitions. + * - `ProjectTypeModule`: project type catalogs. + * - `ProductCategoryModule`: product category catalogs. + * - `MilestoneTemplateModule`: milestone template catalogs. + * - `OrgConfigModule`: organization-level config entries. + * - `FormModule`: versioned form definitions. + * - `PlanConfigModule`: versioned plan configuration definitions. + * - `PriceConfigModule`: versioned pricing configuration definitions. + * - `WorkManagementPermissionModule`: permission policy records by template. + * + * It also registers `MetadataListController` and `MetadataListService` for the + * consolidated metadata list endpoint. + */ @Module({ imports: [ ProjectTemplateModule, ProductTemplateModule, ProjectTypeModule, ProductCategoryModule, + MilestoneTemplateModule, OrgConfigModule, FormModule, PlanConfigModule, diff --git a/src/api/metadata/milestone-template/dto/clone-milestone-template.dto.ts b/src/api/metadata/milestone-template/dto/clone-milestone-template.dto.ts index 7e7a0eb..683d6c0 100644 --- a/src/api/metadata/milestone-template/dto/clone-milestone-template.dto.ts +++ b/src/api/metadata/milestone-template/dto/clone-milestone-template.dto.ts @@ -2,6 +2,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, IsOptional, IsString, Min } from 'class-validator'; +/** + * Request payload for cloning a milestone template. + * + * @property sourceMilestoneTemplateId Source milestone template id. + * @property reference Optional destination reference override. + * @property referenceId Optional destination reference id override. + */ export class CloneMilestoneTemplateDto { @ApiProperty({ description: 'Source milestone template id to clone.' }) @Type(() => Number) diff --git a/src/api/metadata/milestone-template/dto/create-milestone-template.dto.ts b/src/api/metadata/milestone-template/dto/create-milestone-template.dto.ts index e24720e..479d0b0 100644 --- a/src/api/metadata/milestone-template/dto/create-milestone-template.dto.ts +++ b/src/api/metadata/milestone-template/dto/create-milestone-template.dto.ts @@ -10,6 +10,23 @@ import { Min, } from 'class-validator'; +/** + * Request payload for creating a milestone template. + * + * @property name Milestone name. + * @property description Optional milestone description. + * @property duration Estimated duration. + * @property type Milestone type. + * @property order Display order. + * @property plannedText Planned-state label text. + * @property activeText Active-state label text. + * @property completedText Completed-state label text. + * @property blockedText Blocked-state label text. + * @property reference Reference target type. + * @property referenceId Reference target id. + * @property metadata Optional metadata object. + * @property hidden Hidden flag. + */ export class CreateMilestoneTemplateDto { @ApiProperty() @IsString() diff --git a/src/api/metadata/milestone-template/dto/milestone-template-response.dto.ts b/src/api/metadata/milestone-template/dto/milestone-template-response.dto.ts index 5a3dc74..059ded8 100644 --- a/src/api/metadata/milestone-template/dto/milestone-template-response.dto.ts +++ b/src/api/metadata/milestone-template/dto/milestone-template-response.dto.ts @@ -1,5 +1,27 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** + * API response payload for milestone templates. + * + * @property id Record id. + * @property name Milestone name. + * @property description Optional milestone description. + * @property duration Estimated duration. + * @property type Milestone type. + * @property order Display order. + * @property plannedText Planned-state text. + * @property activeText Active-state text. + * @property completedText Completed-state text. + * @property blockedText Blocked-state text. + * @property hidden Hidden flag. + * @property reference Reference type. + * @property referenceId Reference id. + * @property metadata Metadata object. + * @property createdAt Creation timestamp. + * @property updatedAt Update timestamp. + * @property createdBy Creator user id. + * @property updatedBy Updater user id. + */ export class MilestoneTemplateResponseDto { @ApiProperty() id: string; diff --git a/src/api/metadata/milestone-template/dto/update-milestone-template.dto.ts b/src/api/metadata/milestone-template/dto/update-milestone-template.dto.ts index ab9808d..975946b 100644 --- a/src/api/metadata/milestone-template/dto/update-milestone-template.dto.ts +++ b/src/api/metadata/milestone-template/dto/update-milestone-template.dto.ts @@ -1,6 +1,23 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateMilestoneTemplateDto } from './create-milestone-template.dto'; +/** + * Request payload for partially updating a milestone template. + * + * @property name Optional milestone name override. + * @property description Optional description override. + * @property duration Optional duration override. + * @property type Optional type override. + * @property order Optional order override. + * @property plannedText Optional planned text override. + * @property activeText Optional active text override. + * @property completedText Optional completed text override. + * @property blockedText Optional blocked text override. + * @property reference Optional reference override. + * @property referenceId Optional reference id override. + * @property metadata Optional metadata override. + * @property hidden Optional hidden flag override. + */ export class UpdateMilestoneTemplateDto extends PartialType( CreateMilestoneTemplateDto, ) {} diff --git a/src/api/metadata/milestone-template/milestone-template.module.ts b/src/api/metadata/milestone-template/milestone-template.module.ts index 1d99270..5ba2656 100644 --- a/src/api/metadata/milestone-template/milestone-template.module.ts +++ b/src/api/metadata/milestone-template/milestone-template.module.ts @@ -2,6 +2,11 @@ import { Module } from '@nestjs/common'; import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; import { MilestoneTemplateService } from './milestone-template.service'; +/** + * Registers milestone template service and exports it for metadata operations. + * + * Note: this module is currently not imported by `MetadataModule`. + */ @Module({ imports: [GlobalProvidersModule], providers: [MilestoneTemplateService], diff --git a/src/api/metadata/milestone-template/milestone-template.service.ts b/src/api/metadata/milestone-template/milestone-template.service.ts index 55c76d1..f4bc57b 100644 --- a/src/api/metadata/milestone-template/milestone-template.service.ts +++ b/src/api/metadata/milestone-template/milestone-template.service.ts @@ -14,13 +14,26 @@ import { MilestoneTemplateResponseDto } from './dto/milestone-template-response. import { UpdateMilestoneTemplateDto } from './dto/update-milestone-template.dto'; @Injectable() +/** + * Manages milestone templates, which are reusable milestone definitions linked + * to a target `reference` and `referenceId`. + * + * Milestones can be cloned from an existing template to a new target. + * + * Note: this service's module is currently not imported by MetadataModule, so + * endpoints are unreachable until that is fixed. + */ export class MilestoneTemplateService { + // TODO (BUG): This service's module (MilestoneTemplateModule) is not imported in MetadataModule. All endpoints are unreachable until it is added. constructor( private readonly prisma: PrismaService, private readonly prismaErrorService: PrismaErrorService, private readonly eventBusService: EventBusService, ) {} + /** + * Lists all non-deleted milestone templates. + */ async findAll(): Promise { const records = await this.prisma.milestoneTemplate.findMany({ where: { @@ -32,6 +45,9 @@ export class MilestoneTemplateService { return records.map((record) => this.toDto(record)); } + /** + * Finds one milestone template by id. + */ async findOne(id: bigint): Promise { const record = await this.prisma.milestoneTemplate.findFirst({ where: { @@ -49,6 +65,9 @@ export class MilestoneTemplateService { return this.toDto(record); } + /** + * Creates a milestone template. + */ async create( dto: CreateMilestoneTemplateDto, userId: bigint, @@ -89,6 +108,11 @@ export class MilestoneTemplateService { } } + /** + * Clones an existing milestone template to a new reference target. + * + * Throws NotFoundException when `sourceMilestoneTemplateId` is invalid. + */ async clone( dto: CloneMilestoneTemplateDto, userId: bigint, @@ -145,6 +169,9 @@ export class MilestoneTemplateService { } } + /** + * Updates a milestone template by id. + */ async update( id: bigint, dto: UpdateMilestoneTemplateDto, @@ -219,6 +246,9 @@ export class MilestoneTemplateService { } } + /** + * Soft deletes a milestone template. + */ async delete(id: bigint, userId: bigint): Promise { try { const existing = await this.prisma.milestoneTemplate.findFirst({ @@ -261,10 +291,16 @@ export class MilestoneTemplateService { } } + /** + * Parses milestone template id parameter. + */ parseId(value: string): bigint { return parseBigIntParam(value, 'milestoneTemplateId'); } + /** + * Maps Prisma entity to response DTO. + */ private toDto(record: MilestoneTemplate): MilestoneTemplateResponseDto { return { id: record.id.toString(), @@ -291,6 +327,9 @@ export class MilestoneTemplateService { }; } + /** + * Re-throws framework HTTP exceptions and delegates unknown errors to Prisma. + */ private handleError(error: unknown, operation: string): never { if (error instanceof HttpException) { throw error; diff --git a/src/api/metadata/org-config/dto/create-org-config.dto.ts b/src/api/metadata/org-config/dto/create-org-config.dto.ts index 68c5cda..9eb2314 100644 --- a/src/api/metadata/org-config/dto/create-org-config.dto.ts +++ b/src/api/metadata/org-config/dto/create-org-config.dto.ts @@ -1,6 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +/** + * Request payload for creating an org config entry. + * + * @property orgId Organization id. + * @property configName Config key name. + * @property configValue Optional config value. + */ export class CreateOrgConfigDto { @ApiProperty() @IsString() diff --git a/src/api/metadata/org-config/dto/org-config-response.dto.ts b/src/api/metadata/org-config/dto/org-config-response.dto.ts index 10e4489..e8e59e8 100644 --- a/src/api/metadata/org-config/dto/org-config-response.dto.ts +++ b/src/api/metadata/org-config/dto/org-config-response.dto.ts @@ -1,5 +1,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** + * API response payload for org config entries. + * + * @property id Record id. + * @property orgId Organization id. + * @property configName Config key name. + * @property configValue Optional config value. + * @property createdAt Creation timestamp. + * @property updatedAt Update timestamp. + * @property createdBy Creator user id. + * @property updatedBy Updater user id. + */ export class OrgConfigResponseDto { @ApiProperty() id: string; diff --git a/src/api/metadata/org-config/dto/update-org-config.dto.ts b/src/api/metadata/org-config/dto/update-org-config.dto.ts index 6c498e3..4ac9e94 100644 --- a/src/api/metadata/org-config/dto/update-org-config.dto.ts +++ b/src/api/metadata/org-config/dto/update-org-config.dto.ts @@ -1,4 +1,11 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateOrgConfigDto } from './create-org-config.dto'; +/** + * Request payload for partially updating an org config entry. + * + * @property orgId Optional organization id override. + * @property configName Optional config key override. + * @property configValue Optional config value override. + */ export class UpdateOrgConfigDto extends PartialType(CreateOrgConfigDto) {} diff --git a/src/api/metadata/org-config/org-config.controller.ts b/src/api/metadata/org-config/org-config.controller.ts index 2152c00..b8335c2 100644 --- a/src/api/metadata/org-config/org-config.controller.ts +++ b/src/api/metadata/org-config/org-config.controller.ts @@ -27,21 +27,32 @@ import { OrgConfigService } from './org-config.service'; @ApiTags('Metadata - Org Config') @ApiBearerAuth() @Controller('/projects/metadata/orgConfig') +/** + * REST controller for organization config metadata. + */ export class OrgConfigController { constructor(private readonly orgConfigService: OrgConfigService) {} + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get() @ApiOperation({ summary: 'List org configs' }) @ApiResponse({ status: 200, type: [OrgConfigResponseDto] }) + /** + * Lists org config entries. + */ async list(): Promise { return this.orgConfigService.findAll(); } + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get(':id') @ApiOperation({ summary: 'Get org config by id' }) @ApiParam({ name: 'id', description: 'Org config id' }) @ApiResponse({ status: 200, type: OrgConfigResponseDto }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Gets one org config entry by id. + */ async getOne(@Param('id') id: string): Promise { return this.orgConfigService.findOne(this.orgConfigService.parseId(id)); } @@ -51,6 +62,9 @@ export class OrgConfigController { @ApiOperation({ summary: 'Create org config' }) @ApiResponse({ status: 201, type: OrgConfigResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) + /** + * Creates an org config entry. + */ async create( @Body() dto: CreateOrgConfigDto, @CurrentUser() user: JwtUser, @@ -65,6 +79,9 @@ export class OrgConfigController { @ApiResponse({ status: 200, type: OrgConfigResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Updates an org config entry. + */ async update( @Param('id') id: string, @Body() dto: UpdateOrgConfigDto, @@ -85,6 +102,9 @@ export class OrgConfigController { @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Soft deletes an org config entry. + */ async delete( @Param('id') id: string, @CurrentUser() user: JwtUser, diff --git a/src/api/metadata/org-config/org-config.module.ts b/src/api/metadata/org-config/org-config.module.ts index ab54dc3..13fbdba 100644 --- a/src/api/metadata/org-config/org-config.module.ts +++ b/src/api/metadata/org-config/org-config.module.ts @@ -3,6 +3,10 @@ import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders import { OrgConfigController } from './org-config.controller'; import { OrgConfigService } from './org-config.service'; +/** + * Registers org config controller/service and exports `OrgConfigService` for + * use by metadata aggregation and dependent modules. + */ @Module({ imports: [GlobalProvidersModule], controllers: [OrgConfigController], diff --git a/src/api/metadata/org-config/org-config.service.ts b/src/api/metadata/org-config/org-config.service.ts index ff8e150..31ff053 100644 --- a/src/api/metadata/org-config/org-config.service.ts +++ b/src/api/metadata/org-config/org-config.service.ts @@ -18,6 +18,12 @@ import { OrgConfigResponseDto } from './dto/org-config-response.dto'; import { UpdateOrgConfigDto } from './dto/update-org-config.dto'; @Injectable() +/** + * Manages organization-specific config key/value records. + * + * Each active record is uniquely identified by `(orgId, configName)` and is + * used for org-level project behavior overrides. + */ export class OrgConfigService { constructor( private readonly prisma: PrismaService, @@ -25,6 +31,9 @@ export class OrgConfigService { private readonly eventBusService: EventBusService, ) {} + /** + * Lists all non-deleted org config records. + */ async findAll(): Promise { const records = await this.prisma.orgConfig.findMany({ where: { @@ -36,6 +45,9 @@ export class OrgConfigService { return records.map((record) => this.toDto(record)); } + /** + * Finds one org config record by id. + */ async findOne(id: bigint): Promise { const record = await this.prisma.orgConfig.findFirst({ where: { @@ -53,6 +65,11 @@ export class OrgConfigService { return this.toDto(record); } + /** + * Creates an org config record. + * + * Throws ConflictException when `(orgId, configName)` already exists. + */ async create( dto: CreateOrgConfigDto, userId: bigint, @@ -97,6 +114,12 @@ export class OrgConfigService { } } + /** + * Updates an org config record. + * + * Throws ConflictException when the resulting `(orgId, configName)` conflicts + * with another active record. + */ async update( id: bigint, dto: UpdateOrgConfigDto, @@ -166,6 +189,9 @@ export class OrgConfigService { } } + /** + * Soft deletes an org config record. + */ async delete(id: bigint, userId: bigint): Promise { try { const existing = await this.prisma.orgConfig.findFirst({ @@ -208,10 +234,16 @@ export class OrgConfigService { } } + /** + * Parses an org config id route parameter. + */ parseId(id: string): bigint { return parseBigIntParam(id, 'id'); } + /** + * Maps Prisma entity to response DTO. + */ private toDto(record: OrgConfig): OrgConfigResponseDto { return { id: record.id.toString(), @@ -225,6 +257,9 @@ export class OrgConfigService { }; } + /** + * Re-throws framework HTTP exceptions and delegates unknown errors to Prisma. + */ private handleError(error: unknown, operation: string): never { if (error instanceof HttpException) { throw error; diff --git a/src/api/metadata/plan-config/dto/create-plan-config-revision.dto.ts b/src/api/metadata/plan-config/dto/create-plan-config-revision.dto.ts index c3fa5f9..83898f5 100644 --- a/src/api/metadata/plan-config/dto/create-plan-config-revision.dto.ts +++ b/src/api/metadata/plan-config/dto/create-plan-config-revision.dto.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsObject } from 'class-validator'; +/** + * Request payload for creating a new plan config revision. + * + * @property config Plan config JSON payload. + */ export class CreatePlanConfigRevisionDto { @ApiProperty({ type: 'object', diff --git a/src/api/metadata/plan-config/dto/create-plan-config-version.dto.ts b/src/api/metadata/plan-config/dto/create-plan-config-version.dto.ts index a0be1d2..4c5c4c8 100644 --- a/src/api/metadata/plan-config/dto/create-plan-config-version.dto.ts +++ b/src/api/metadata/plan-config/dto/create-plan-config-version.dto.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsObject } from 'class-validator'; +/** + * Request payload for creating a new plan config version. + * + * @property config Plan config JSON payload. + */ export class CreatePlanConfigVersionDto { @ApiProperty({ type: 'object', diff --git a/src/api/metadata/plan-config/dto/plan-config-response.dto.ts b/src/api/metadata/plan-config/dto/plan-config-response.dto.ts index 47fe05c..4c438e9 100644 --- a/src/api/metadata/plan-config/dto/plan-config-response.dto.ts +++ b/src/api/metadata/plan-config/dto/plan-config-response.dto.ts @@ -1,5 +1,18 @@ import { ApiProperty } from '@nestjs/swagger'; +/** + * API response payload for a plan config revision record. + * + * @property id Record id. + * @property key Metadata key. + * @property version Major version. + * @property revision Minor revision. + * @property config Plan config JSON. + * @property createdAt Creation timestamp. + * @property updatedAt Update timestamp. + * @property createdBy Creator user id. + * @property updatedBy Updater user id. + */ export class PlanConfigResponseDto { @ApiProperty() id: string; diff --git a/src/api/metadata/plan-config/dto/update-plan-config-version.dto.ts b/src/api/metadata/plan-config/dto/update-plan-config-version.dto.ts index d0bd63b..4d9515b 100644 --- a/src/api/metadata/plan-config/dto/update-plan-config-version.dto.ts +++ b/src/api/metadata/plan-config/dto/update-plan-config-version.dto.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsObject } from 'class-validator'; +/** + * Request payload for updating the latest revision of a plan config version. + * + * @property config Replacement plan config JSON payload. + */ export class UpdatePlanConfigVersionDto { @ApiProperty({ type: 'object', diff --git a/src/api/metadata/plan-config/plan-config-revision.controller.ts b/src/api/metadata/plan-config/plan-config-revision.controller.ts index d27edb7..74c513b 100644 --- a/src/api/metadata/plan-config/plan-config-revision.controller.ts +++ b/src/api/metadata/plan-config/plan-config-revision.controller.ts @@ -20,9 +20,15 @@ import { PlanConfigService } from './plan-config.service'; @ApiTags('Metadata - Plan Configs') @ApiBearerAuth() @Controller('/projects/metadata/planConfig/:key/versions/:version/revisions') +/** + * REST controller for plan config revision operations. + * + * Read endpoints are currently unguarded; write endpoints require `@AdminOnly`. + */ export class PlanConfigRevisionController { constructor(private readonly planConfigService: PlanConfigService) {} + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get() @ApiOperation({ summary: 'List planConfig revisions by version', @@ -31,6 +37,9 @@ export class PlanConfigRevisionController { @ApiParam({ name: 'version', description: 'PlanConfig version' }) @ApiResponse({ status: 200, type: [PlanConfigResponseDto] }) @ApiResponse({ status: 404, description: 'PlanConfig not found' }) + /** + * Lists all revisions for a plan config version. + */ async listRevisions( @Param('key') key: string, @Param('version') version: string, @@ -41,6 +50,7 @@ export class PlanConfigRevisionController { ); } + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get(':revision') @ApiOperation({ summary: 'Get specific planConfig revision', @@ -50,6 +60,9 @@ export class PlanConfigRevisionController { @ApiParam({ name: 'revision', description: 'PlanConfig revision' }) @ApiResponse({ status: 200, type: PlanConfigResponseDto }) @ApiResponse({ status: 404, description: 'PlanConfig not found' }) + /** + * Fetches one specific plan config revision. + */ async getRevision( @Param('key') key: string, @Param('version') version: string, @@ -72,6 +85,9 @@ export class PlanConfigRevisionController { @ApiResponse({ status: 201, type: PlanConfigResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'PlanConfig not found' }) + /** + * Creates a new revision under a plan config version. + */ async createRevision( @Param('key') key: string, @Param('version') version: string, @@ -97,6 +113,9 @@ export class PlanConfigRevisionController { @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'PlanConfig not found' }) + /** + * Soft deletes one plan config revision. + */ async deleteRevision( @Param('key') key: string, @Param('version') version: string, diff --git a/src/api/metadata/plan-config/plan-config-version.controller.ts b/src/api/metadata/plan-config/plan-config-version.controller.ts index efc715a..1606f2b 100644 --- a/src/api/metadata/plan-config/plan-config-version.controller.ts +++ b/src/api/metadata/plan-config/plan-config-version.controller.ts @@ -29,9 +29,15 @@ import { PlanConfigService } from './plan-config.service'; @ApiTags('Metadata - Plan Configs') @ApiBearerAuth() @Controller('/projects/metadata/planConfig/:key/versions') +/** + * REST controller for plan config version operations. + * + * Read endpoints are currently unguarded; write endpoints require `@AdminOnly`. + */ export class PlanConfigVersionController { constructor(private readonly planConfigService: PlanConfigService) {} + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get() @ApiOperation({ summary: 'List planConfig versions', @@ -41,12 +47,16 @@ export class PlanConfigVersionController { @ApiParam({ name: 'key', description: 'PlanConfig key' }) @ApiResponse({ status: 200, type: [PlanConfigResponseDto] }) @ApiResponse({ status: 404, description: 'PlanConfig not found' }) + /** + * Lists latest revision per version for a plan config key. + */ async listVersions( @Param('key') key: string, ): Promise { return this.planConfigService.findAllVersions(key); } + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get(':version') @ApiOperation({ summary: 'Get latest revision by planConfig version', @@ -55,6 +65,9 @@ export class PlanConfigVersionController { @ApiParam({ name: 'version', description: 'PlanConfig version' }) @ApiResponse({ status: 200, type: PlanConfigResponseDto }) @ApiResponse({ status: 404, description: 'PlanConfig not found' }) + /** + * Fetches latest revision for one plan config version. + */ async getVersion( @Param('key') key: string, @Param('version') version: string, @@ -73,6 +86,9 @@ export class PlanConfigVersionController { @ApiParam({ name: 'key', description: 'PlanConfig key' }) @ApiResponse({ status: 201, type: PlanConfigResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) + /** + * Creates a new plan config version. + */ async createVersion( @Param('key') key: string, @Body() dto: CreatePlanConfigVersionDto, @@ -95,6 +111,9 @@ export class PlanConfigVersionController { @ApiResponse({ status: 200, type: PlanConfigResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'PlanConfig not found' }) + /** + * Updates the latest revision of a plan config version. + */ async updateVersion( @Param('key') key: string, @Param('version') version: string, @@ -119,6 +138,9 @@ export class PlanConfigVersionController { @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'PlanConfig not found' }) + /** + * Soft deletes all revisions of a plan config version. + */ async deleteVersion( @Param('key') key: string, @Param('version') version: string, diff --git a/src/api/metadata/plan-config/plan-config.controller.ts b/src/api/metadata/plan-config/plan-config.controller.ts index dc22c21..564b534 100644 --- a/src/api/metadata/plan-config/plan-config.controller.ts +++ b/src/api/metadata/plan-config/plan-config.controller.ts @@ -1,20 +1,18 @@ import { Controller, Get, Param } from '@nestjs/common'; -import { - ApiBearerAuth, - ApiOperation, - ApiParam, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from 'src/shared/decorators/public.decorator'; import { PlanConfigResponseDto } from './dto/plan-config-response.dto'; import { PlanConfigService } from './plan-config.service'; @ApiTags('Metadata - Plan Configs') -@ApiBearerAuth() @Controller('/projects/metadata/planConfig') +/** + * REST controller for reading public plan config metadata. + */ export class PlanConfigController { constructor(private readonly planConfigService: PlanConfigService) {} + @Public() @Get(':key') @ApiOperation({ summary: 'Get latest planConfig revision of latest version', @@ -24,6 +22,11 @@ export class PlanConfigController { @ApiParam({ name: 'key', description: 'PlanConfig key' }) @ApiResponse({ status: 200, type: PlanConfigResponseDto }) @ApiResponse({ status: 404, description: 'PlanConfig not found' }) + /** + * Returns latest revision from latest version for a plan config key. + * + * HTTP 200 on success. + */ async getLatest(@Param('key') key: string): Promise { return this.planConfigService.findLatestRevisionOfLatestVersion(key); } diff --git a/src/api/metadata/plan-config/plan-config.module.ts b/src/api/metadata/plan-config/plan-config.module.ts index 7dfa9ec..b38d985 100644 --- a/src/api/metadata/plan-config/plan-config.module.ts +++ b/src/api/metadata/plan-config/plan-config.module.ts @@ -5,6 +5,11 @@ import { PlanConfigRevisionController } from './plan-config-revision.controller' import { PlanConfigVersionController } from './plan-config-version.controller'; import { PlanConfigService } from './plan-config.service'; +/** + * Registers plan config controllers and service, and exports + * `PlanConfigService` for consumers that validate and resolve plan config + * references. + */ @Module({ imports: [GlobalProvidersModule], controllers: [ diff --git a/src/api/metadata/plan-config/plan-config.service.ts b/src/api/metadata/plan-config/plan-config.service.ts index dc1e885..a72cd32 100644 --- a/src/api/metadata/plan-config/plan-config.service.ts +++ b/src/api/metadata/plan-config/plan-config.service.ts @@ -15,13 +15,25 @@ import { import { PlanConfigResponseDto } from './dto/plan-config-response.dto'; @Injectable() +/** + * Manages versioned plan configurations (phase and milestone plans) referenced + * by project templates. + */ export class PlanConfigService { + // TODO (DRY): FormService, PlanConfigService, and PriceConfigService are structurally identical. Consider extracting a generic AbstractVersionedConfigService base class parameterized on the Prisma delegate and DTO mapper. constructor( private readonly prisma: PrismaService, private readonly prismaErrorService: PrismaErrorService, private readonly eventBusService: EventBusService, ) {} + /** + * Returns the latest revision from the latest version for a key. + * + * @param key Metadata key. + * @returns Latest revision DTO for the latest version. + * @throws {NotFoundException} If the key does not exist. + */ async findLatestRevisionOfLatestVersion( key: string, ): Promise { @@ -44,6 +56,13 @@ export class PlanConfigService { return this.toDto(planConfig); } + /** + * Returns one record per version (latest revision of each version). + * + * @param key Metadata key. + * @returns Latest revision DTO per version. + * @throws {NotFoundException} If the key does not exist. + */ async findAllVersions(key: string): Promise { const normalizedKey = this.normalizeKey(key); @@ -75,6 +94,14 @@ export class PlanConfigService { ); } + /** + * Returns the latest revision of a specific version. + * + * @param key Metadata key. + * @param version Target version number. + * @returns Latest revision DTO for the version. + * @throws {NotFoundException} If the key/version does not exist. + */ async findLatestRevisionOfVersion( key: string, version: bigint, @@ -99,12 +126,21 @@ export class PlanConfigService { return this.toDto(planConfig); } + /** + * Returns all revisions for a specific version, newest first. + * + * @param key Metadata key. + * @param version Target version number. + * @returns Revision DTOs for the version. + * @throws {NotFoundException} If the key/version does not exist. + */ async findAllRevisions( key: string, version: bigint, ): Promise { const normalizedKey = this.normalizeKey(key); + // TODO (DRY): variable named 'forms' should be 'planConfigs' — copy-paste error. const forms = await this.prisma.planConfig.findMany({ where: { key: normalizedKey, @@ -123,6 +159,15 @@ export class PlanConfigService { return forms.map((planConfig) => this.toDto(planConfig)); } + /** + * Returns an exact key/version/revision record. + * + * @param key Metadata key. + * @param version Target version number. + * @param revision Target revision number. + * @returns Matching revision DTO. + * @throws {NotFoundException} If the exact revision does not exist. + */ async findSpecificRevision( key: string, version: bigint, @@ -148,6 +193,15 @@ export class PlanConfigService { return this.toDto(planConfig); } + /** + * Creates a new version with revision `1`. + * + * @param key Metadata key. + * @param config Configuration payload. + * @param userId Audit user id. + * @returns Created version DTO. + * @throws {BadRequestException} If key is empty. + */ async createVersion( key: string, config: Record, @@ -198,6 +252,17 @@ export class PlanConfigService { } } + /** + * Creates a new revision for an existing version. + * + * @param key Metadata key. + * @param version Target version number. + * @param config Configuration payload. + * @param userId Audit user id. + * @returns Created revision DTO. + * @throws {NotFoundException} If the key/version does not exist. + * @throws {BadRequestException} If key is empty. + */ async createRevision( key: string, version: bigint, @@ -251,6 +316,16 @@ export class PlanConfigService { } } + /** + * Updates configuration on the latest revision of a version. + * + * @param key Metadata key. + * @param version Target version number. + * @param config Replacement configuration payload. + * @param userId Audit user id. + * @returns Updated version DTO. + * @throws {NotFoundException} If the key/version does not exist. + */ async updateVersion( key: string, version: bigint, @@ -303,6 +378,14 @@ export class PlanConfigService { } } + /** + * Soft deletes all revisions for a version. + * + * @param key Metadata key. + * @param version Target version number. + * @param userId Audit user id. + * @throws {NotFoundException} If the key/version does not exist. + */ async deleteVersion( key: string, version: bigint, @@ -311,6 +394,7 @@ export class PlanConfigService { const normalizedKey = this.normalizeKey(key); try { + // TODO (DRY): variable named 'forms' should be 'planConfigs' — copy-paste error. const forms = await this.prisma.planConfig.findMany({ where: { key: normalizedKey, @@ -361,6 +445,15 @@ export class PlanConfigService { } } + /** + * Soft deletes a single revision. + * + * @param key Metadata key. + * @param version Target version number. + * @param revision Target revision number. + * @param userId Audit user id. + * @throws {NotFoundException} If the key/version/revision does not exist. + */ async deleteRevision( key: string, version: bigint, @@ -412,6 +505,12 @@ export class PlanConfigService { } } + /** + * Maps Prisma entity fields into API DTO fields. + * + * @param planConfig Prisma entity. + * @returns Response DTO with bigint values converted to strings. + */ private toDto(planConfig: PlanConfig): PlanConfigResponseDto { return { id: planConfig.id.toString(), @@ -429,6 +528,13 @@ export class PlanConfigService { }; } + /** + * Trims and validates a metadata key. + * + * @param key Raw key input. + * @returns Normalized key. + * @throws {BadRequestException} If key is empty. + */ private normalizeKey(key: string): string { const normalized = String(key || '').trim(); @@ -439,6 +545,14 @@ export class PlanConfigService { return normalized; } + /** + * Re-throws `HttpException` and delegates non-HTTP errors to + * `PrismaErrorService`. + * + * @param error Unknown error. + * @param operation Operation label. + * @throws {HttpException} Re-throws HTTP exceptions. + */ private handleError(error: unknown, operation: string): never { if (error instanceof HttpException) { throw error; diff --git a/src/api/metadata/price-config/dto/create-price-config-revision.dto.ts b/src/api/metadata/price-config/dto/create-price-config-revision.dto.ts index a1e5179..2e225d0 100644 --- a/src/api/metadata/price-config/dto/create-price-config-revision.dto.ts +++ b/src/api/metadata/price-config/dto/create-price-config-revision.dto.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsObject } from 'class-validator'; +/** + * Request payload for creating a new price config revision. + * + * @property config Price config JSON payload. + */ export class CreatePriceConfigRevisionDto { @ApiProperty({ type: 'object', diff --git a/src/api/metadata/price-config/dto/create-price-config-version.dto.ts b/src/api/metadata/price-config/dto/create-price-config-version.dto.ts index 2d930f6..b0bf685 100644 --- a/src/api/metadata/price-config/dto/create-price-config-version.dto.ts +++ b/src/api/metadata/price-config/dto/create-price-config-version.dto.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsObject } from 'class-validator'; +/** + * Request payload for creating a new price config version. + * + * @property config Price config JSON payload. + */ export class CreatePriceConfigVersionDto { @ApiProperty({ type: 'object', diff --git a/src/api/metadata/price-config/dto/price-config-response.dto.ts b/src/api/metadata/price-config/dto/price-config-response.dto.ts index d8cf0d7..51e5c7f 100644 --- a/src/api/metadata/price-config/dto/price-config-response.dto.ts +++ b/src/api/metadata/price-config/dto/price-config-response.dto.ts @@ -1,5 +1,18 @@ import { ApiProperty } from '@nestjs/swagger'; +/** + * API response payload for a price config revision record. + * + * @property id Record id. + * @property key Metadata key. + * @property version Major version. + * @property revision Minor revision. + * @property config Price config JSON. + * @property createdAt Creation timestamp. + * @property updatedAt Update timestamp. + * @property createdBy Creator user id. + * @property updatedBy Updater user id. + */ export class PriceConfigResponseDto { @ApiProperty() id: string; diff --git a/src/api/metadata/price-config/dto/update-price-config-version.dto.ts b/src/api/metadata/price-config/dto/update-price-config-version.dto.ts index 017b18a..eef9368 100644 --- a/src/api/metadata/price-config/dto/update-price-config-version.dto.ts +++ b/src/api/metadata/price-config/dto/update-price-config-version.dto.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsObject } from 'class-validator'; +/** + * Request payload for updating the latest revision of a price config version. + * + * @property config Replacement price config JSON payload. + */ export class UpdatePriceConfigVersionDto { @ApiProperty({ type: 'object', diff --git a/src/api/metadata/price-config/price-config-revision.controller.ts b/src/api/metadata/price-config/price-config-revision.controller.ts index 4daeca0..51cc19c 100644 --- a/src/api/metadata/price-config/price-config-revision.controller.ts +++ b/src/api/metadata/price-config/price-config-revision.controller.ts @@ -20,9 +20,15 @@ import { PriceConfigService } from './price-config.service'; @ApiTags('Metadata - Price Configs') @ApiBearerAuth() @Controller('/projects/metadata/priceConfig/:key/versions/:version/revisions') +/** + * REST controller for price config revision operations. + * + * Read endpoints are currently unguarded; write endpoints require `@AdminOnly`. + */ export class PriceConfigRevisionController { constructor(private readonly priceConfigService: PriceConfigService) {} + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get() @ApiOperation({ summary: 'List priceConfig revisions by version', @@ -31,6 +37,9 @@ export class PriceConfigRevisionController { @ApiParam({ name: 'version', description: 'PriceConfig version' }) @ApiResponse({ status: 200, type: [PriceConfigResponseDto] }) @ApiResponse({ status: 404, description: 'PriceConfig not found' }) + /** + * Lists all revisions for a price config version. + */ async listRevisions( @Param('key') key: string, @Param('version') version: string, @@ -41,6 +50,7 @@ export class PriceConfigRevisionController { ); } + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get(':revision') @ApiOperation({ summary: 'Get specific priceConfig revision', @@ -50,6 +60,9 @@ export class PriceConfigRevisionController { @ApiParam({ name: 'revision', description: 'PriceConfig revision' }) @ApiResponse({ status: 200, type: PriceConfigResponseDto }) @ApiResponse({ status: 404, description: 'PriceConfig not found' }) + /** + * Fetches one specific price config revision. + */ async getRevision( @Param('key') key: string, @Param('version') version: string, @@ -72,6 +85,9 @@ export class PriceConfigRevisionController { @ApiResponse({ status: 201, type: PriceConfigResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'PriceConfig not found' }) + /** + * Creates a new revision under a price config version. + */ async createRevision( @Param('key') key: string, @Param('version') version: string, @@ -97,6 +113,9 @@ export class PriceConfigRevisionController { @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'PriceConfig not found' }) + /** + * Soft deletes one price config revision. + */ async deleteRevision( @Param('key') key: string, @Param('version') version: string, diff --git a/src/api/metadata/price-config/price-config-version.controller.ts b/src/api/metadata/price-config/price-config-version.controller.ts index 343c575..b4d91f4 100644 --- a/src/api/metadata/price-config/price-config-version.controller.ts +++ b/src/api/metadata/price-config/price-config-version.controller.ts @@ -29,9 +29,15 @@ import { PriceConfigService } from './price-config.service'; @ApiTags('Metadata - Price Configs') @ApiBearerAuth() @Controller('/projects/metadata/priceConfig/:key/versions') +/** + * REST controller for price config version operations. + * + * Read endpoints are currently unguarded; write endpoints require `@AdminOnly`. + */ export class PriceConfigVersionController { constructor(private readonly priceConfigService: PriceConfigService) {} + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get() @ApiOperation({ summary: 'List priceConfig versions', @@ -41,12 +47,16 @@ export class PriceConfigVersionController { @ApiParam({ name: 'key', description: 'PriceConfig key' }) @ApiResponse({ status: 200, type: [PriceConfigResponseDto] }) @ApiResponse({ status: 404, description: 'PriceConfig not found' }) + /** + * Lists latest revision per version for a price config key. + */ async listVersions( @Param('key') key: string, ): Promise { return this.priceConfigService.findAllVersions(key); } + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get(':version') @ApiOperation({ summary: 'Get latest revision by priceConfig version', @@ -55,6 +65,9 @@ export class PriceConfigVersionController { @ApiParam({ name: 'version', description: 'PriceConfig version' }) @ApiResponse({ status: 200, type: PriceConfigResponseDto }) @ApiResponse({ status: 404, description: 'PriceConfig not found' }) + /** + * Fetches latest revision for one price config version. + */ async getVersion( @Param('key') key: string, @Param('version') version: string, @@ -73,6 +86,9 @@ export class PriceConfigVersionController { @ApiParam({ name: 'key', description: 'PriceConfig key' }) @ApiResponse({ status: 201, type: PriceConfigResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) + /** + * Creates a new price config version. + */ async createVersion( @Param('key') key: string, @Body() dto: CreatePriceConfigVersionDto, @@ -95,6 +111,9 @@ export class PriceConfigVersionController { @ApiResponse({ status: 200, type: PriceConfigResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'PriceConfig not found' }) + /** + * Updates the latest revision of a price config version. + */ async updateVersion( @Param('key') key: string, @Param('version') version: string, @@ -119,6 +138,9 @@ export class PriceConfigVersionController { @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'PriceConfig not found' }) + /** + * Soft deletes all revisions of a price config version. + */ async deleteVersion( @Param('key') key: string, @Param('version') version: string, diff --git a/src/api/metadata/price-config/price-config.controller.ts b/src/api/metadata/price-config/price-config.controller.ts index e067120..5b37817 100644 --- a/src/api/metadata/price-config/price-config.controller.ts +++ b/src/api/metadata/price-config/price-config.controller.ts @@ -1,20 +1,18 @@ import { Controller, Get, Param } from '@nestjs/common'; -import { - ApiBearerAuth, - ApiOperation, - ApiParam, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from 'src/shared/decorators/public.decorator'; import { PriceConfigResponseDto } from './dto/price-config-response.dto'; import { PriceConfigService } from './price-config.service'; @ApiTags('Metadata - Price Configs') -@ApiBearerAuth() @Controller('/projects/metadata/priceConfig') +/** + * REST controller for reading public price config metadata. + */ export class PriceConfigController { constructor(private readonly priceConfigService: PriceConfigService) {} + @Public() @Get(':key') @ApiOperation({ summary: 'Get latest priceConfig revision of latest version', @@ -24,6 +22,9 @@ export class PriceConfigController { @ApiParam({ name: 'key', description: 'PriceConfig key' }) @ApiResponse({ status: 200, type: PriceConfigResponseDto }) @ApiResponse({ status: 404, description: 'PriceConfig not found' }) + /** + * Returns latest revision from latest version for a price config key. + */ async getLatest(@Param('key') key: string): Promise { return this.priceConfigService.findLatestRevisionOfLatestVersion(key); } diff --git a/src/api/metadata/price-config/price-config.module.ts b/src/api/metadata/price-config/price-config.module.ts index 637f8b8..93081a6 100644 --- a/src/api/metadata/price-config/price-config.module.ts +++ b/src/api/metadata/price-config/price-config.module.ts @@ -5,6 +5,11 @@ import { PriceConfigRevisionController } from './price-config-revision.controlle import { PriceConfigVersionController } from './price-config-version.controller'; import { PriceConfigService } from './price-config.service'; +/** + * Registers price config controllers and service, and exports + * `PriceConfigService` for consumers that validate and resolve price config + * references. + */ @Module({ imports: [GlobalProvidersModule], controllers: [ diff --git a/src/api/metadata/price-config/price-config.service.ts b/src/api/metadata/price-config/price-config.service.ts index 8637731..8a8a7a7 100644 --- a/src/api/metadata/price-config/price-config.service.ts +++ b/src/api/metadata/price-config/price-config.service.ts @@ -15,13 +15,27 @@ import { import { PriceConfigResponseDto } from './dto/price-config-response.dto'; @Injectable() +/** + * Manages versioned price configurations referenced by project templates. + * + * Records are keyed by `key` and versioned by `version` and `revision`. Soft + * delete is used for removal operations. + */ export class PriceConfigService { + // TODO (DRY): FormService, PlanConfigService, and PriceConfigService are structurally identical. Consider extracting a generic AbstractVersionedConfigService base class parameterized on the Prisma delegate and DTO mapper. constructor( private readonly prisma: PrismaService, private readonly prismaErrorService: PrismaErrorService, private readonly eventBusService: EventBusService, ) {} + /** + * Returns the latest revision from the latest version for a key. + * + * @param key Metadata key. + * @returns Latest price config revision DTO. + * @throws {NotFoundException} If the key does not exist. + */ async findLatestRevisionOfLatestVersion( key: string, ): Promise { @@ -44,6 +58,13 @@ export class PriceConfigService { return this.toDto(priceConfig); } + /** + * Returns one record per version (latest revision for each version). + * + * @param key Metadata key. + * @returns Latest revision DTO per version. + * @throws {NotFoundException} If the key does not exist. + */ async findAllVersions(key: string): Promise { const normalizedKey = this.normalizeKey(key); @@ -75,6 +96,14 @@ export class PriceConfigService { ); } + /** + * Returns the latest revision for a specific version. + * + * @param key Metadata key. + * @param version Target version number. + * @returns Latest revision DTO for the version. + * @throws {NotFoundException} If the key/version does not exist. + */ async findLatestRevisionOfVersion( key: string, version: bigint, @@ -99,12 +128,21 @@ export class PriceConfigService { return this.toDto(priceConfig); } + /** + * Returns all revisions for a specific version. + * + * @param key Metadata key. + * @param version Target version number. + * @returns Revision DTOs for the version. + * @throws {NotFoundException} If the key/version does not exist. + */ async findAllRevisions( key: string, version: bigint, ): Promise { const normalizedKey = this.normalizeKey(key); + // TODO (DRY): variable named 'forms' should be 'priceConfigs' — copy-paste error. const forms = await this.prisma.priceConfig.findMany({ where: { key: normalizedKey, @@ -123,6 +161,15 @@ export class PriceConfigService { return forms.map((priceConfig) => this.toDto(priceConfig)); } + /** + * Returns an exact key/version/revision record. + * + * @param key Metadata key. + * @param version Target version number. + * @param revision Target revision number. + * @returns Matching revision DTO. + * @throws {NotFoundException} If the exact revision does not exist. + */ async findSpecificRevision( key: string, version: bigint, @@ -148,6 +195,15 @@ export class PriceConfigService { return this.toDto(priceConfig); } + /** + * Creates a new version and starts at revision `1`. + * + * @param key Metadata key. + * @param config Configuration payload. + * @param userId Audit user id. + * @returns Created version DTO. + * @throws {BadRequestException} If key is empty. + */ async createVersion( key: string, config: Record, @@ -198,6 +254,17 @@ export class PriceConfigService { } } + /** + * Creates a new revision under an existing version. + * + * @param key Metadata key. + * @param version Target version number. + * @param config Configuration payload. + * @param userId Audit user id. + * @returns Created revision DTO. + * @throws {NotFoundException} If key/version does not exist. + * @throws {BadRequestException} If key is empty. + */ async createRevision( key: string, version: bigint, @@ -251,6 +318,16 @@ export class PriceConfigService { } } + /** + * Updates the latest revision for a specific version. + * + * @param key Metadata key. + * @param version Target version number. + * @param config Replacement configuration payload. + * @param userId Audit user id. + * @returns Updated version DTO. + * @throws {NotFoundException} If key/version does not exist. + */ async updateVersion( key: string, version: bigint, @@ -303,6 +380,14 @@ export class PriceConfigService { } } + /** + * Soft deletes all revisions for a specific version. + * + * @param key Metadata key. + * @param version Target version number. + * @param userId Audit user id. + * @throws {NotFoundException} If key/version does not exist. + */ async deleteVersion( key: string, version: bigint, @@ -311,6 +396,7 @@ export class PriceConfigService { const normalizedKey = this.normalizeKey(key); try { + // TODO (DRY): variable named 'forms' should be 'priceConfigs' — copy-paste error. const forms = await this.prisma.priceConfig.findMany({ where: { key: normalizedKey, @@ -361,6 +447,15 @@ export class PriceConfigService { } } + /** + * Soft deletes a single revision. + * + * @param key Metadata key. + * @param version Target version number. + * @param revision Target revision number. + * @param userId Audit user id. + * @throws {NotFoundException} If key/version/revision does not exist. + */ async deleteRevision( key: string, version: bigint, @@ -412,6 +507,12 @@ export class PriceConfigService { } } + /** + * Maps a Prisma entity to the API DTO shape. + * + * @param priceConfig Prisma entity. + * @returns Response DTO with bigint values converted to strings. + */ private toDto(priceConfig: PriceConfig): PriceConfigResponseDto { return { id: priceConfig.id.toString(), @@ -429,6 +530,13 @@ export class PriceConfigService { }; } + /** + * Trims and validates metadata key. + * + * @param key Raw key input. + * @returns Normalized key. + * @throws {BadRequestException} If key is empty. + */ private normalizeKey(key: string): string { const normalized = String(key || '').trim(); @@ -439,6 +547,13 @@ export class PriceConfigService { return normalized; } + /** + * Re-throws HTTP exceptions and delegates unknown errors to PrismaErrorService. + * + * @param error Unknown error. + * @param operation Operation label. + * @throws {HttpException} Re-throws HTTP exceptions. + */ private handleError(error: unknown, operation: string): never { if (error instanceof HttpException) { throw error; diff --git a/src/api/metadata/product-category/dto/create-product-category.dto.ts b/src/api/metadata/product-category/dto/create-product-category.dto.ts index fb4aa95..a5251e2 100644 --- a/src/api/metadata/product-category/dto/create-product-category.dto.ts +++ b/src/api/metadata/product-category/dto/create-product-category.dto.ts @@ -7,6 +7,18 @@ import { IsString, } from 'class-validator'; +/** + * Request payload for creating a product category. + * + * @property key Category key. + * @property displayName Category display name. + * @property icon Icon identifier. + * @property question Prompt text. + * @property info Informational text. + * @property aliases Alias list. + * @property disabled Disabled flag. + * @property hidden Hidden flag. + */ export class CreateProductCategoryDto { @ApiProperty() @IsString() diff --git a/src/api/metadata/product-category/dto/product-category-response.dto.ts b/src/api/metadata/product-category/dto/product-category-response.dto.ts index 72ea53d..3b6839a 100644 --- a/src/api/metadata/product-category/dto/product-category-response.dto.ts +++ b/src/api/metadata/product-category/dto/product-category-response.dto.ts @@ -1,5 +1,21 @@ import { ApiProperty } from '@nestjs/swagger'; +/** + * API response payload for product categories. + * + * @property key Category key. + * @property displayName Category display name. + * @property icon Icon identifier. + * @property question Prompt text. + * @property info Informational text. + * @property aliases Alias list. + * @property disabled Disabled flag. + * @property hidden Hidden flag. + * @property createdAt Creation timestamp. + * @property updatedAt Update timestamp. + * @property createdBy Creator user id. + * @property updatedBy Updater user id. + */ export class ProductCategoryResponseDto { @ApiProperty() key: string; diff --git a/src/api/metadata/product-category/dto/update-product-category.dto.ts b/src/api/metadata/product-category/dto/update-product-category.dto.ts index 705ff59..4f82647 100644 --- a/src/api/metadata/product-category/dto/update-product-category.dto.ts +++ b/src/api/metadata/product-category/dto/update-product-category.dto.ts @@ -1,6 +1,17 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateProductCategoryDto } from './create-product-category.dto'; +/** + * Request payload for partially updating a product category. + * + * @property displayName Optional display name override. + * @property icon Optional icon override. + * @property question Optional prompt override. + * @property info Optional informational text override. + * @property aliases Optional aliases override. + * @property disabled Optional disabled flag override. + * @property hidden Optional hidden flag override. + */ export class UpdateProductCategoryDto extends PartialType( CreateProductCategoryDto, ) {} diff --git a/src/api/metadata/product-category/product-category.controller.ts b/src/api/metadata/product-category/product-category.controller.ts index 57533d4..f9e78a1 100644 --- a/src/api/metadata/product-category/product-category.controller.ts +++ b/src/api/metadata/product-category/product-category.controller.ts @@ -27,23 +27,34 @@ import { ProductCategoryService } from './product-category.service'; @ApiTags('Metadata - Product Categories') @ApiBearerAuth() @Controller('/projects/metadata/productCategories') +/** + * REST controller for product category metadata. + */ export class ProductCategoryController { constructor( private readonly productCategoryService: ProductCategoryService, ) {} + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get() @ApiOperation({ summary: 'List product categories' }) @ApiResponse({ status: 200, type: [ProductCategoryResponseDto] }) + /** + * Lists product categories. + */ async list(): Promise { return this.productCategoryService.findAll(); } + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get(':key') @ApiOperation({ summary: 'Get product category by key' }) @ApiParam({ name: 'key', description: 'Product category key' }) @ApiResponse({ status: 200, type: ProductCategoryResponseDto }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Gets one product category by key. + */ async getOne(@Param('key') key: string): Promise { return this.productCategoryService.findByKey(key); } @@ -53,6 +64,9 @@ export class ProductCategoryController { @ApiOperation({ summary: 'Create product category' }) @ApiResponse({ status: 201, type: ProductCategoryResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) + /** + * Creates a product category. + */ async create( @Body() dto: CreateProductCategoryDto, @CurrentUser() user: JwtUser, @@ -67,6 +81,9 @@ export class ProductCategoryController { @ApiResponse({ status: 200, type: ProductCategoryResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Updates a product category. + */ async update( @Param('key') key: string, @Body() dto: UpdateProductCategoryDto, @@ -87,6 +104,9 @@ export class ProductCategoryController { @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Soft deletes a product category. + */ async delete( @Param('key') key: string, @CurrentUser() user: JwtUser, diff --git a/src/api/metadata/product-category/product-category.module.ts b/src/api/metadata/product-category/product-category.module.ts index b9f1555..fedf10d 100644 --- a/src/api/metadata/product-category/product-category.module.ts +++ b/src/api/metadata/product-category/product-category.module.ts @@ -3,6 +3,10 @@ import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders import { ProductCategoryController } from './product-category.controller'; import { ProductCategoryService } from './product-category.service'; +/** + * Registers product category controller/service and exports + * `ProductCategoryService` for metadata workflows. + */ @Module({ imports: [GlobalProvidersModule], controllers: [ProductCategoryController], diff --git a/src/api/metadata/product-category/product-category.service.ts b/src/api/metadata/product-category/product-category.service.ts index 6ae0f04..5f0f117 100644 --- a/src/api/metadata/product-category/product-category.service.ts +++ b/src/api/metadata/product-category/product-category.service.ts @@ -18,6 +18,11 @@ import { ProductCategoryResponseDto } from './dto/product-category-response.dto' import { UpdateProductCategoryDto } from './dto/update-product-category.dto'; @Injectable() +/** + * Manages product category metadata records used to classify product templates. + * + * Categories are keyed by unique string `key`. + */ export class ProductCategoryService { constructor( private readonly prisma: PrismaService, @@ -25,6 +30,9 @@ export class ProductCategoryService { private readonly eventBusService: EventBusService, ) {} + /** + * Lists all non-deleted product categories. + */ async findAll(): Promise { const records = await this.prisma.productCategory.findMany({ where: { @@ -36,6 +44,9 @@ export class ProductCategoryService { return records.map((record) => this.toDto(record)); } + /** + * Finds one product category by key. + */ async findByKey(key: string): Promise { const record = await this.prisma.productCategory.findFirst({ where: { @@ -51,11 +62,15 @@ export class ProductCategoryService { return this.toDto(record); } + /** + * Creates a product category. + */ async create( dto: CreateProductCategoryDto, userId: number, ): Promise { try { + // TODO (BUG): create() uses findUnique({ where: { key } }) without deletedAt: null. A soft-deleted record with the same key will trigger a ConflictException, preventing re-creation. Use findFirst with deletedAt: null instead. const existing = await this.prisma.productCategory.findUnique({ where: { key: dto.key, @@ -98,6 +113,9 @@ export class ProductCategoryService { } } + /** + * Updates a product category by key. + */ async update( key: string, dto: UpdateProductCategoryDto, @@ -156,6 +174,9 @@ export class ProductCategoryService { } } + /** + * Soft deletes a product category. + */ async delete(key: string, userId: number): Promise { try { const existing = await this.prisma.productCategory.findFirst({ @@ -198,6 +219,9 @@ export class ProductCategoryService { } } + /** + * Maps Prisma entity to response DTO. + */ private toDto(record: ProductCategory): ProductCategoryResponseDto { return { key: record.key, @@ -215,6 +239,9 @@ export class ProductCategoryService { }; } + /** + * Re-throws framework HTTP exceptions and delegates unknown errors to Prisma. + */ private handleError(error: unknown, operation: string): never { if (error instanceof HttpException) { throw error; diff --git a/src/api/metadata/product-template/dto/create-product-template.dto.ts b/src/api/metadata/product-template/dto/create-product-template.dto.ts index 33b30b9..27e5988 100644 --- a/src/api/metadata/product-template/dto/create-product-template.dto.ts +++ b/src/api/metadata/product-template/dto/create-product-template.dto.ts @@ -11,6 +11,23 @@ import { } from 'class-validator'; import { MetadataReferenceDto } from '../../dto/metadata-reference.dto'; +/** + * Request payload for creating a product template. + * + * @property name Template name. + * @property productKey Product key used for lookups. + * @property category Category value. + * @property subCategory Sub-category value. + * @property icon Icon identifier. + * @property brief Brief description. + * @property details Detailed description. + * @property aliases Alias list. + * @property template Legacy inline template JSON. + * @property form Optional versioned form reference. + * @property disabled Disabled flag. + * @property hidden Hidden flag. + * @property isAddOn Add-on flag. + */ export class CreateProductTemplateDto { @ApiProperty() @IsString() diff --git a/src/api/metadata/product-template/dto/product-template-response.dto.ts b/src/api/metadata/product-template/dto/product-template-response.dto.ts index 50eeb6a..e6ac72a 100644 --- a/src/api/metadata/product-template/dto/product-template-response.dto.ts +++ b/src/api/metadata/product-template/dto/product-template-response.dto.ts @@ -1,5 +1,27 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** + * API response payload for product templates. + * + * @property id Template id. + * @property name Template name. + * @property productKey Product key. + * @property category Category value. + * @property subCategory Sub-category value. + * @property icon Icon identifier. + * @property brief Brief description. + * @property details Detailed description. + * @property aliases Alias list. + * @property template Legacy inline template payload. + * @property form Resolved form reference payload. + * @property disabled Disabled flag. + * @property hidden Hidden flag. + * @property isAddOn Add-on flag. + * @property createdAt Creation timestamp. + * @property updatedAt Update timestamp. + * @property createdBy Creator user id. + * @property updatedBy Updater user id. + */ export class ProductTemplateResponseDto { @ApiProperty() id: string; diff --git a/src/api/metadata/product-template/dto/update-product-template.dto.ts b/src/api/metadata/product-template/dto/update-product-template.dto.ts index 159bf9d..2ab4d3f 100644 --- a/src/api/metadata/product-template/dto/update-product-template.dto.ts +++ b/src/api/metadata/product-template/dto/update-product-template.dto.ts @@ -1,6 +1,23 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateProductTemplateDto } from './create-product-template.dto'; +/** + * Request payload for partially updating a product template. + * + * @property name Optional template name. + * @property productKey Optional product key. + * @property category Optional category. + * @property subCategory Optional sub-category. + * @property icon Optional icon identifier. + * @property brief Optional brief description. + * @property details Optional details description. + * @property aliases Optional aliases list. + * @property template Optional legacy template payload. + * @property form Optional form reference override. + * @property disabled Optional disabled flag. + * @property hidden Optional hidden flag. + * @property isAddOn Optional add-on flag. + */ export class UpdateProductTemplateDto extends PartialType( CreateProductTemplateDto, ) {} diff --git a/src/api/metadata/product-template/dto/upgrade-product-template.dto.ts b/src/api/metadata/product-template/dto/upgrade-product-template.dto.ts index c7b77f4..875d12d 100644 --- a/src/api/metadata/product-template/dto/upgrade-product-template.dto.ts +++ b/src/api/metadata/product-template/dto/upgrade-product-template.dto.ts @@ -3,6 +3,16 @@ import { Type } from 'class-transformer'; import { IsOptional, ValidateNested } from 'class-validator'; import { MetadataReferenceDto } from '../../dto/metadata-reference.dto'; +/** + * Request payload for upgrading a legacy product template. + * + * Migration semantics: + * - If `form` is omitted, the existing form reference is preserved. + * - If `form` is explicitly `null`, the legacy `template` payload is used to + * auto-create a new versioned form record. + * + * @property form Optional target form reference. + */ export class UpgradeProductTemplateDto { @ApiPropertyOptional({ type: () => MetadataReferenceDto }) @IsOptional() diff --git a/src/api/metadata/product-template/product-template.controller.ts b/src/api/metadata/product-template/product-template.controller.ts index 3838c47..7a755fa 100644 --- a/src/api/metadata/product-template/product-template.controller.ts +++ b/src/api/metadata/product-template/product-template.controller.ts @@ -18,6 +18,7 @@ import { ApiTags, } from '@nestjs/swagger'; import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; +import { Public } from 'src/shared/decorators/public.decorator'; import { AdminOnly } from 'src/shared/guards/adminOnly.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { @@ -33,11 +34,18 @@ import { ProductTemplateService } from './product-template.service'; @ApiTags('Metadata - Product Templates') @ApiBearerAuth() @Controller('/projects/metadata/productTemplates') +/** + * REST controller for product template metadata. + * + * Read endpoints are public (`@Public()`); write endpoints require + * `@AdminOnly`. + */ export class ProductTemplateController { constructor( private readonly productTemplateService: ProductTemplateService, ) {} + @Public() @Get() @ApiOperation({ summary: 'List product templates', @@ -48,6 +56,9 @@ export class ProductTemplateController { type: Boolean, }) @ApiResponse({ status: 200, type: [ProductTemplateResponseDto] }) + /** + * Lists product templates. + */ async list( @Query('includeDisabled') includeDisabled?: string, ): Promise { @@ -56,11 +67,15 @@ export class ProductTemplateController { ); } + @Public() @Get(':templateId') @ApiOperation({ summary: 'Get product template by id' }) @ApiParam({ name: 'templateId', description: 'Product template id' }) @ApiResponse({ status: 200, type: ProductTemplateResponseDto }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Gets one product template by id. + */ async getOne( @Param('templateId') templateId: string, ): Promise { @@ -74,6 +89,9 @@ export class ProductTemplateController { @ApiOperation({ summary: 'Create product template' }) @ApiResponse({ status: 201, type: ProductTemplateResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) + /** + * Creates a product template. + */ async create( @Body() dto: CreateProductTemplateDto, @CurrentUser() user: JwtUser, @@ -88,6 +106,9 @@ export class ProductTemplateController { @ApiResponse({ status: 200, type: ProductTemplateResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Updates a product template by id. + */ async update( @Param('templateId') templateId: string, @Body() dto: UpdateProductTemplateDto, @@ -108,6 +129,9 @@ export class ProductTemplateController { @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Soft deletes a product template. + */ async delete( @Param('templateId') templateId: string, @CurrentUser() user: JwtUser, @@ -125,6 +149,9 @@ export class ProductTemplateController { @ApiResponse({ status: 201, type: ProductTemplateResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Upgrades legacy product template configuration to versioned references. + */ async upgrade( @Param('templateId') templateId: string, @Body() dto: UpgradeProductTemplateDto, diff --git a/src/api/metadata/product-template/product-template.module.ts b/src/api/metadata/product-template/product-template.module.ts index d37f527..51d0abd 100644 --- a/src/api/metadata/product-template/product-template.module.ts +++ b/src/api/metadata/product-template/product-template.module.ts @@ -4,6 +4,10 @@ import { FormModule } from '../form/form.module'; import { ProductTemplateController } from './product-template.controller'; import { ProductTemplateService } from './product-template.service'; +/** + * Registers product template controller/service and exports + * `ProductTemplateService` for metadata composition and validation flows. + */ @Module({ imports: [GlobalProvidersModule, FormModule], controllers: [ProductTemplateController], diff --git a/src/api/metadata/product-template/product-template.service.ts b/src/api/metadata/product-template/product-template.service.ts index 7c2dab4..ddb3e01 100644 --- a/src/api/metadata/product-template/product-template.service.ts +++ b/src/api/metadata/product-template/product-template.service.ts @@ -26,6 +26,12 @@ import { UpdateProductTemplateDto } from './dto/update-product-template.dto'; import { UpgradeProductTemplateDto } from './dto/upgrade-product-template.dto'; @Injectable() +/** + * Manages product templates used for work products within a project. + * + * Supports both legacy inline `template` JSON and modern versioned `form` + * references. The `upgrade` operation migrates legacy templates. + */ export class ProductTemplateService { constructor( private readonly prisma: PrismaService, @@ -34,6 +40,9 @@ export class ProductTemplateService { private readonly formService: FormService, ) {} + /** + * Lists product templates, optionally including disabled entries. + */ async findAll( includeDisabled = false, ): Promise { @@ -48,6 +57,9 @@ export class ProductTemplateService { return Promise.all(records.map((record) => this.toDto(record, false))); } + /** + * Loads one product template by id. + */ async findOne(id: bigint): Promise { const template = await this.prisma.productTemplate.findFirst({ where: { @@ -65,6 +77,9 @@ export class ProductTemplateService { return this.toDto(template, true); } + /** + * Creates a product template and validates the optional form reference. + */ async create( dto: CreateProductTemplateDto, userId: bigint, @@ -109,6 +124,9 @@ export class ProductTemplateService { } } + /** + * Updates a product template and validates the optional form reference. + */ async update( id: bigint, dto: UpdateProductTemplateDto, @@ -198,6 +216,9 @@ export class ProductTemplateService { } } + /** + * Soft deletes a product template. + */ async delete(id: bigint, userId: bigint): Promise { try { const existing = await this.prisma.productTemplate.findFirst({ @@ -240,6 +261,9 @@ export class ProductTemplateService { } } + /** + * Upgrades a legacy product template to use a versioned form reference. + */ async upgrade( id: bigint, dto: UpgradeProductTemplateDto, @@ -315,6 +339,9 @@ export class ProductTemplateService { } } + /** + * Maps Prisma records to API DTOs and optionally resolves full form details. + */ private async toDto( template: ProductTemplate, resolveForm: boolean, @@ -348,6 +375,9 @@ export class ProductTemplateService { }; } + /** + * Resolves the stored form reference into the latest matching form record. + */ private async resolveFormReference( value: Prisma.JsonValue | null, ): Promise | null> { @@ -385,6 +415,10 @@ export class ProductTemplateService { : null; } + // TODO (DRY): toRecord, mergeJson, toNullableJson, getStoredFormReference are duplicated in ProjectTemplateService. Move to a shared metadata-template.utils.ts file. + /** + * Converts optional values to a Prisma nullable JSON payload. + */ private toNullableJson( value: | Record @@ -403,6 +437,9 @@ export class ProductTemplateService { return value as Prisma.InputJsonValue; } + /** + * Reads and normalizes a stored form reference from JSON. + */ private getStoredFormReference( value: Prisma.JsonValue | null, ): MetadataVersionReference | null { @@ -416,6 +453,9 @@ export class ProductTemplateService { } } + /** + * Converts JSON values to plain object maps when possible. + */ private toRecord( value: Prisma.JsonValue | null, ): Record | null { @@ -426,6 +466,9 @@ export class ProductTemplateService { return value as Record; } + /** + * Merges incoming JSON fields over existing object values. + */ private mergeJson( current: Prisma.JsonValue | null, next: Record, @@ -437,6 +480,9 @@ export class ProductTemplateService { }; } + /** + * Validates mutually exclusive legacy and versioned config fields. + */ private validateTemplateConfigConstraints( dto: CreateProductTemplateDto | UpdateProductTemplateDto, ): void { @@ -447,10 +493,18 @@ export class ProductTemplateService { } } + /** + * Parses a template id route parameter. + */ parseTemplateId(templateId: string): bigint { return parseBigIntParam(templateId, 'templateId'); } + // TODO (DRY): handleError is duplicated across all metadata services. Consider a shared base class or utility. + /** + * Re-throws framework HTTP exceptions and delegates unexpected errors to + * PrismaErrorService. + */ private handleError(error: unknown, operation: string): never { if (error instanceof HttpException) { throw error; diff --git a/src/api/metadata/project-template/dto/create-project-template.dto.ts b/src/api/metadata/project-template/dto/create-project-template.dto.ts index 5c83f5b..0d6224e 100644 --- a/src/api/metadata/project-template/dto/create-project-template.dto.ts +++ b/src/api/metadata/project-template/dto/create-project-template.dto.ts @@ -11,6 +11,26 @@ import { } from 'class-validator'; import { MetadataReferenceDto } from '../../dto/metadata-reference.dto'; +/** + * Request payload for creating a project template. + * + * @property name Template display name. + * @property key Template key used for lookups. + * @property category High-level category label. + * @property subCategory Optional sub-category label. + * @property metadata Optional metadata object. + * @property icon Icon identifier. + * @property question Prompt text. + * @property info Informational text. + * @property aliases Alias list. + * @property scope Legacy inline scope configuration. + * @property phases Legacy inline phases configuration. + * @property form Optional versioned form reference. + * @property planConfig Optional versioned plan config reference. + * @property priceConfig Optional versioned price config reference. + * @property disabled Disabled flag. + * @property hidden Hidden flag. + */ export class CreateProjectTemplateDto { @ApiProperty() @IsString() diff --git a/src/api/metadata/project-template/dto/project-template-response.dto.ts b/src/api/metadata/project-template/dto/project-template-response.dto.ts index a15bcf8..12b2a6e 100644 --- a/src/api/metadata/project-template/dto/project-template-response.dto.ts +++ b/src/api/metadata/project-template/dto/project-template-response.dto.ts @@ -1,5 +1,30 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** + * API response payload for project templates. + * + * @property id Template id. + * @property name Template name. + * @property key Template key. + * @property category Category value. + * @property subCategory Optional sub-category value. + * @property metadata Template metadata object. + * @property icon Icon identifier. + * @property question Prompt text. + * @property info Informational text. + * @property aliases Alias list. + * @property scope Legacy inline scope payload. + * @property phases Legacy inline phases payload. + * @property form Resolved form reference payload. + * @property planConfig Resolved plan config reference payload. + * @property priceConfig Resolved price config reference payload. + * @property disabled Disabled flag. + * @property hidden Hidden flag. + * @property createdAt Creation timestamp. + * @property updatedAt Update timestamp. + * @property createdBy Creator user id. + * @property updatedBy Updater user id. + */ export class ProjectTemplateResponseDto { @ApiProperty() id: string; diff --git a/src/api/metadata/project-template/dto/update-project-template.dto.ts b/src/api/metadata/project-template/dto/update-project-template.dto.ts index 2862ab6..222f952 100644 --- a/src/api/metadata/project-template/dto/update-project-template.dto.ts +++ b/src/api/metadata/project-template/dto/update-project-template.dto.ts @@ -1,6 +1,26 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateProjectTemplateDto } from './create-project-template.dto'; +/** + * Request payload for partially updating a project template. + * + * @property name Optional template name. + * @property key Optional template key. + * @property category Optional category. + * @property subCategory Optional sub-category. + * @property metadata Optional metadata object. + * @property icon Optional icon identifier. + * @property question Optional prompt text. + * @property info Optional informational text. + * @property aliases Optional aliases list. + * @property scope Optional legacy scope payload. + * @property phases Optional legacy phases payload. + * @property form Optional form reference override. + * @property planConfig Optional plan config reference override. + * @property priceConfig Optional price config reference override. + * @property disabled Optional disabled flag. + * @property hidden Optional hidden flag. + */ export class UpdateProjectTemplateDto extends PartialType( CreateProjectTemplateDto, ) {} diff --git a/src/api/metadata/project-template/dto/upgrade-project-template.dto.ts b/src/api/metadata/project-template/dto/upgrade-project-template.dto.ts index f3fba74..1b17ccb 100644 --- a/src/api/metadata/project-template/dto/upgrade-project-template.dto.ts +++ b/src/api/metadata/project-template/dto/upgrade-project-template.dto.ts @@ -3,6 +3,19 @@ import { Type } from 'class-transformer'; import { IsOptional, ValidateNested } from 'class-validator'; import { MetadataReferenceDto } from '../../dto/metadata-reference.dto'; +/** + * Request payload for upgrading a legacy project template. + * + * Migration semantics: + * - If a reference field is omitted, the existing stored reference is + * preserved. + * - If a reference field is explicitly `null`, legacy inline payloads + * (`scope`/`phases`) are used to auto-create a new versioned metadata record. + * + * @property form Optional target form reference. + * @property planConfig Optional target plan config reference. + * @property priceConfig Optional target price config reference. + */ export class UpgradeProjectTemplateDto { @ApiPropertyOptional({ type: () => MetadataReferenceDto }) @IsOptional() diff --git a/src/api/metadata/project-template/project-template.controller.ts b/src/api/metadata/project-template/project-template.controller.ts index 4ea176d..4a3d795 100644 --- a/src/api/metadata/project-template/project-template.controller.ts +++ b/src/api/metadata/project-template/project-template.controller.ts @@ -18,6 +18,7 @@ import { ApiTags, } from '@nestjs/swagger'; import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; +import { Public } from 'src/shared/decorators/public.decorator'; import { AdminOnly } from 'src/shared/guards/adminOnly.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { @@ -33,11 +34,18 @@ import { ProjectTemplateService } from './project-template.service'; @ApiTags('Metadata - Project Templates') @ApiBearerAuth() @Controller('/projects/metadata/projectTemplates') +/** + * REST controller for project template metadata. + * + * Read endpoints are public (`@Public()`); write endpoints require + * `@AdminOnly`. + */ export class ProjectTemplateController { constructor( private readonly projectTemplateService: ProjectTemplateService, ) {} + @Public() @Get() @ApiOperation({ summary: 'List project templates', @@ -51,6 +59,9 @@ export class ProjectTemplateController { description: 'Include disabled templates when true.', }) @ApiResponse({ status: 200, type: [ProjectTemplateResponseDto] }) + /** + * Lists project templates. + */ async list( @Query('includeDisabled') includeDisabled?: string, ): Promise { @@ -59,11 +70,15 @@ export class ProjectTemplateController { ); } + @Public() @Get(':templateId') @ApiOperation({ summary: 'Get project template by id' }) @ApiParam({ name: 'templateId', description: 'Project template id' }) @ApiResponse({ status: 200, type: ProjectTemplateResponseDto }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Gets one project template by id. + */ async getOne( @Param('templateId') templateId: string, ): Promise { @@ -77,6 +92,9 @@ export class ProjectTemplateController { @ApiOperation({ summary: 'Create project template' }) @ApiResponse({ status: 201, type: ProjectTemplateResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) + /** + * Creates a project template. + */ async create( @Body() dto: CreateProjectTemplateDto, @CurrentUser() user: JwtUser, @@ -91,6 +109,9 @@ export class ProjectTemplateController { @ApiResponse({ status: 200, type: ProjectTemplateResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Updates a project template by id. + */ async update( @Param('templateId') templateId: string, @Body() dto: UpdateProjectTemplateDto, @@ -111,6 +132,9 @@ export class ProjectTemplateController { @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Soft deletes a project template. + */ async delete( @Param('templateId') templateId: string, @CurrentUser() user: JwtUser, @@ -128,6 +152,9 @@ export class ProjectTemplateController { @ApiResponse({ status: 201, type: ProjectTemplateResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Upgrades legacy project template configuration to versioned references. + */ async upgrade( @Param('templateId') templateId: string, @Body() dto: UpgradeProjectTemplateDto, diff --git a/src/api/metadata/project-template/project-template.module.ts b/src/api/metadata/project-template/project-template.module.ts index 7590c6c..c33cda6 100644 --- a/src/api/metadata/project-template/project-template.module.ts +++ b/src/api/metadata/project-template/project-template.module.ts @@ -6,6 +6,10 @@ import { PriceConfigModule } from '../price-config/price-config.module'; import { ProjectTemplateController } from './project-template.controller'; import { ProjectTemplateService } from './project-template.service'; +/** + * Registers project template controller/service and exports + * `ProjectTemplateService` for metadata APIs and dependent services. + */ @Module({ imports: [ GlobalProvidersModule, diff --git a/src/api/metadata/project-template/project-template.service.ts b/src/api/metadata/project-template/project-template.service.ts index 0f0f191..6303dd7 100644 --- a/src/api/metadata/project-template/project-template.service.ts +++ b/src/api/metadata/project-template/project-template.service.ts @@ -32,6 +32,12 @@ import { UpdateProjectTemplateDto } from './dto/update-project-template.dto'; import { UpgradeProjectTemplateDto } from './dto/upgrade-project-template.dto'; @Injectable() +/** + * Manages project templates, including legacy inline config fields and modern + * versioned metadata references (`form`, `planConfig`, `priceConfig`). + * + * The `upgrade` flow migrates legacy templates into the versioned format. + */ export class ProjectTemplateService { constructor( private readonly prisma: PrismaService, @@ -42,6 +48,9 @@ export class ProjectTemplateService { private readonly priceConfigService: PriceConfigService, ) {} + /** + * Lists project templates, optionally including disabled entries. + */ async findAll( includeDisabled = false, ): Promise { @@ -56,6 +65,9 @@ export class ProjectTemplateService { return Promise.all(records.map((record) => this.toDto(record, false))); } + /** + * Loads one project template by id. + */ async findOne(id: bigint): Promise { const template = await this.prisma.projectTemplate.findFirst({ where: { @@ -73,6 +85,9 @@ export class ProjectTemplateService { return this.toDto(template, true); } + /** + * Creates a project template and validates metadata references. + */ async create( dto: CreateProjectTemplateDto, userId: bigint, @@ -128,6 +143,9 @@ export class ProjectTemplateService { } } + /** + * Updates a project template and validates metadata references. + */ async update( id: bigint, dto: UpdateProjectTemplateDto, @@ -240,6 +258,9 @@ export class ProjectTemplateService { } } + /** + * Soft deletes a project template. + */ async delete(id: bigint, userId: bigint): Promise { try { const existing = await this.prisma.projectTemplate.findFirst({ @@ -282,12 +303,16 @@ export class ProjectTemplateService { } } + /** + * Upgrades a legacy template to versioned metadata references. + */ async upgrade( id: bigint, dto: UpgradeProjectTemplateDto, userId: bigint, ): Promise { try { + // TODO (SECURITY): upgrade() creates form, planConfig, and priceConfig versions in separate DB calls with no wrapping transaction. A failure mid-upgrade leaves the template in a partially upgraded state. Wrap in prisma.$transaction(). const existing = await this.prisma.projectTemplate.findFirst({ where: { id, @@ -438,6 +463,10 @@ export class ProjectTemplateService { } } + /** + * Maps Prisma records to API DTOs and optionally resolves full referenced + * metadata entities. + */ private async toDto( template: ProjectTemplate, resolveReferences: boolean, @@ -486,6 +515,11 @@ export class ProjectTemplateService { }; } + // TODO (DRY): resolveVersionedReference has three near-identical branches for form/planConfig/priceConfig. Extract a generic resolveReference(key, version, prismaDelegate) helper. + /** + * Resolves a stored metadata reference into the latest matching versioned + * config record. + */ private async resolveVersionedReference( value: Prisma.JsonValue | null, type: 'form' | 'planConfig' | 'priceConfig', @@ -577,6 +611,10 @@ export class ProjectTemplateService { : null; } + // TODO (DRY): toRecord, mergeJson, toNullableJson, getStoredReference are duplicated in ProductTemplateService. Move to a shared metadata-template.utils.ts file. + /** + * Converts optional values to a Prisma nullable JSON payload. + */ private toNullableJson( value: | MetadataVersionReference @@ -595,6 +633,9 @@ export class ProjectTemplateService { return value as Prisma.InputJsonValue; } + /** + * Reads and normalizes a stored metadata reference from JSON. + */ private getStoredReference( value: Prisma.JsonValue | null, type: 'form' | 'planConfig' | 'priceConfig', @@ -609,6 +650,9 @@ export class ProjectTemplateService { } } + /** + * Converts JSON values to plain object maps when possible. + */ private toRecord( value: Prisma.JsonValue | null, ): Record | null { @@ -619,6 +663,9 @@ export class ProjectTemplateService { return value as Record; } + /** + * Merges incoming JSON fields over existing object values. + */ private mergeJson( current: Prisma.JsonValue | null, next: Record, @@ -630,6 +677,9 @@ export class ProjectTemplateService { }; } + /** + * Validates mutually exclusive legacy and versioned config fields. + */ private validateTemplateConfigConstraints( dto: CreateProjectTemplateDto | UpdateProjectTemplateDto, ): void { @@ -652,10 +702,18 @@ export class ProjectTemplateService { } } + /** + * Parses a template id route parameter. + */ parseTemplateId(templateId: string): bigint { return parseBigIntParam(templateId, 'templateId'); } + // TODO (DRY): handleError is duplicated across all metadata services. Consider a shared base class or utility. + /** + * Re-throws framework HTTP exceptions and delegates unexpected errors to + * PrismaErrorService. + */ private handleError(error: unknown, operation: string): never { if (error instanceof HttpException) { throw error; diff --git a/src/api/metadata/project-type/dto/create-project-type.dto.ts b/src/api/metadata/project-type/dto/create-project-type.dto.ts index d337040..7b5e72b 100644 --- a/src/api/metadata/project-type/dto/create-project-type.dto.ts +++ b/src/api/metadata/project-type/dto/create-project-type.dto.ts @@ -8,6 +8,19 @@ import { IsString, } from 'class-validator'; +/** + * Request payload for creating a project type. + * + * @property key Project type key. + * @property displayName Project type display name. + * @property icon Icon identifier. + * @property question Prompt text. + * @property info Informational text. + * @property aliases Alias list. + * @property metadata Metadata object. + * @property disabled Disabled flag. + * @property hidden Hidden flag. + */ export class CreateProjectTypeDto { @ApiProperty() @IsString() diff --git a/src/api/metadata/project-type/dto/project-type-response.dto.ts b/src/api/metadata/project-type/dto/project-type-response.dto.ts index 93e52cd..c585e04 100644 --- a/src/api/metadata/project-type/dto/project-type-response.dto.ts +++ b/src/api/metadata/project-type/dto/project-type-response.dto.ts @@ -1,5 +1,22 @@ import { ApiProperty } from '@nestjs/swagger'; +/** + * API response payload for project types. + * + * @property key Project type key. + * @property displayName Display name. + * @property icon Icon identifier. + * @property question Prompt text. + * @property info Informational text. + * @property aliases Alias list. + * @property metadata Metadata object. + * @property disabled Disabled flag. + * @property hidden Hidden flag. + * @property createdAt Creation timestamp. + * @property updatedAt Update timestamp. + * @property createdBy Creator user id. + * @property updatedBy Updater user id. + */ export class ProjectTypeResponseDto { @ApiProperty() key: string; diff --git a/src/api/metadata/project-type/dto/update-project-type.dto.ts b/src/api/metadata/project-type/dto/update-project-type.dto.ts index 8bf622f..1a6c9e3 100644 --- a/src/api/metadata/project-type/dto/update-project-type.dto.ts +++ b/src/api/metadata/project-type/dto/update-project-type.dto.ts @@ -1,4 +1,16 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateProjectTypeDto } from './create-project-type.dto'; +/** + * Request payload for partially updating a project type. + * + * @property displayName Optional display name override. + * @property icon Optional icon override. + * @property question Optional prompt override. + * @property info Optional informational text override. + * @property aliases Optional aliases override. + * @property metadata Optional metadata override. + * @property disabled Optional disabled flag override. + * @property hidden Optional hidden flag override. + */ export class UpdateProjectTypeDto extends PartialType(CreateProjectTypeDto) {} diff --git a/src/api/metadata/project-type/project-type.controller.ts b/src/api/metadata/project-type/project-type.controller.ts index 700e5cc..644f398 100644 --- a/src/api/metadata/project-type/project-type.controller.ts +++ b/src/api/metadata/project-type/project-type.controller.ts @@ -27,21 +27,32 @@ import { ProjectTypeService } from './project-type.service'; @ApiTags('Metadata - Project Types') @ApiBearerAuth() @Controller('/projects/metadata/projectTypes') +/** + * REST controller for project type metadata. + */ export class ProjectTypeController { constructor(private readonly projectTypeService: ProjectTypeService) {} + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get() @ApiOperation({ summary: 'List project types' }) @ApiResponse({ status: 200, type: [ProjectTypeResponseDto] }) + /** + * Lists project types. + */ async list(): Promise { return this.projectTypeService.findAll(); } + // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get(':key') @ApiOperation({ summary: 'Get project type by key' }) @ApiParam({ name: 'key', description: 'Project type key' }) @ApiResponse({ status: 200, type: ProjectTypeResponseDto }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Gets one project type by key. + */ async getOne(@Param('key') key: string): Promise { return this.projectTypeService.findByKey(key); } @@ -51,6 +62,9 @@ export class ProjectTypeController { @ApiOperation({ summary: 'Create project type' }) @ApiResponse({ status: 201, type: ProjectTypeResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) + /** + * Creates a project type. + */ async create( @Body() dto: CreateProjectTypeDto, @CurrentUser() user: JwtUser, @@ -65,6 +79,9 @@ export class ProjectTypeController { @ApiResponse({ status: 200, type: ProjectTypeResponseDto }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Updates a project type. + */ async update( @Param('key') key: string, @Body() dto: UpdateProjectTypeDto, @@ -81,6 +98,9 @@ export class ProjectTypeController { @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Soft deletes a project type. + */ async delete( @Param('key') key: string, @CurrentUser() user: JwtUser, diff --git a/src/api/metadata/project-type/project-type.module.ts b/src/api/metadata/project-type/project-type.module.ts index 7615419..2308ea6 100644 --- a/src/api/metadata/project-type/project-type.module.ts +++ b/src/api/metadata/project-type/project-type.module.ts @@ -3,6 +3,10 @@ import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders import { ProjectTypeController } from './project-type.controller'; import { ProjectTypeService } from './project-type.service'; +/** + * Registers project type controller/service and exports `ProjectTypeService` + * for metadata consumers. + */ @Module({ imports: [GlobalProvidersModule], controllers: [ProjectTypeController], diff --git a/src/api/metadata/project-type/project-type.service.ts b/src/api/metadata/project-type/project-type.service.ts index 34190b9..d4fd9d3 100644 --- a/src/api/metadata/project-type/project-type.service.ts +++ b/src/api/metadata/project-type/project-type.service.ts @@ -18,6 +18,11 @@ import { ProjectTypeResponseDto } from './dto/project-type-response.dto'; import { UpdateProjectTypeDto } from './dto/update-project-type.dto'; @Injectable() +/** + * Manages project type metadata records used to classify projects. + * + * Project types are keyed by unique string `key`. + */ export class ProjectTypeService { constructor( private readonly prisma: PrismaService, @@ -25,6 +30,9 @@ export class ProjectTypeService { private readonly eventBusService: EventBusService, ) {} + /** + * Lists all non-deleted project types. + */ async findAll(): Promise { const records = await this.prisma.projectType.findMany({ where: { @@ -36,6 +44,9 @@ export class ProjectTypeService { return records.map((record) => this.toDto(record)); } + /** + * Finds one project type by key. + */ async findByKey(key: string): Promise { const record = await this.prisma.projectType.findFirst({ where: { @@ -51,11 +62,15 @@ export class ProjectTypeService { return this.toDto(record); } + /** + * Creates a project type. + */ async create( dto: CreateProjectTypeDto, userId: number, ): Promise { try { + // TODO (BUG): create() uses findUnique({ where: { key } }) without deletedAt: null. A soft-deleted record with the same key will trigger a ConflictException, preventing re-creation. Use findFirst with deletedAt: null instead. const existing = await this.prisma.projectType.findUnique({ where: { key: dto.key, @@ -99,6 +114,9 @@ export class ProjectTypeService { } } + /** + * Updates a project type by key. + */ async update( key: string, dto: UpdateProjectTypeDto, @@ -158,6 +176,9 @@ export class ProjectTypeService { } } + /** + * Soft deletes a project type. + */ async delete(key: string, userId: number): Promise { try { const existing = await this.prisma.projectType.findFirst({ @@ -198,6 +219,9 @@ export class ProjectTypeService { } } + /** + * Maps Prisma entity to response DTO. + */ private toDto(record: ProjectType): ProjectTypeResponseDto { return { key: record.key, @@ -219,6 +243,9 @@ export class ProjectTypeService { }; } + /** + * Re-throws framework HTTP exceptions and delegates unknown errors to Prisma. + */ private handleError(error: unknown, operation: string): never { if (error instanceof HttpException) { throw error; diff --git a/src/api/metadata/utils/metadata-event.utils.ts b/src/api/metadata/utils/metadata-event.utils.ts index 5d0be86..c255a6d 100644 --- a/src/api/metadata/utils/metadata-event.utils.ts +++ b/src/api/metadata/utils/metadata-event.utils.ts @@ -1,3 +1,10 @@ +/** + * Metadata event publishing utility. + * + * `publishMetadataEvent` is intentionally a no-op because metadata Kafka topics + * were retired. The function signature remains in place to avoid changing every + * metadata call site that used to publish events. + */ import { PROJECT_METADATA_RESOURCE, ProjectMetadataResource, @@ -9,6 +16,17 @@ export type MetadataEventAction = | 'PROJECT_METADATA_UPDATE' | 'PROJECT_METADATA_DELETE'; +/** + * Publishes a metadata event (currently no-op). + * + * @param eventBus Event bus dependency retained for compatibility. + * @param action Event action name. + * @param resource Metadata resource identifier. + * @param id Resource identifier. + * @param data Event payload. + * @param userId Acting user id. + * @returns Always resolves immediately. + */ export function publishMetadataEvent( eventBus: EventBusService, action: MetadataEventAction, @@ -17,6 +35,7 @@ export function publishMetadataEvent( data: unknown, userId: bigint | number, ): Promise { + // TODO (DEAD CODE): This function is a no-op. If metadata events are never re-enabled, consider removing all call sites and this utility to reduce noise. If re-enabling, replace the no-op body with actual EventBusService.publish() calls. // Metadata Kafka topics were retired. Keep this helper as a no-op to avoid // changing call sites while preventing publication to removed topics. void eventBus; diff --git a/src/api/metadata/utils/metadata-utils.ts b/src/api/metadata/utils/metadata-utils.ts index 4596884..b2e9ea7 100644 --- a/src/api/metadata/utils/metadata-utils.ts +++ b/src/api/metadata/utils/metadata-utils.ts @@ -1,3 +1,10 @@ +/** + * Shared utilities for metadata APIs: + * - route/query parameter parsing + * - audit user id extraction + * - metadata reference normalization + * - deep BigInt serialization for JSON responses + */ import { BadRequestException, ForbiddenException, @@ -10,6 +17,14 @@ export interface MetadataVersionReference { version: number; } +/** + * Parses a route parameter into `bigint`. + * + * @param value Raw route parameter value. + * @param fieldName Parameter name used in error messages. + * @returns Parsed bigint value. + * @throws {BadRequestException} When value is not numeric. + */ export function parseBigIntParam(value: string, fieldName: string): bigint { const normalized = String(value || '').trim(); @@ -20,6 +35,14 @@ export function parseBigIntParam(value: string, fieldName: string): bigint { return BigInt(normalized); } +/** + * Parses and validates a safe positive integer parameter. + * + * @param value Raw route/query value. + * @param fieldName Parameter name used in error messages. + * @returns Parsed safe positive integer. + * @throws {BadRequestException} When value is not a safe positive integer. + */ export function parsePositiveIntegerParam( value: string, fieldName: string, @@ -40,6 +63,16 @@ export function parsePositiveIntegerParam( return parsed; } +/** + * Parses an optional boolean query parameter. + * + * Accepted values: `true`, `false`, `'true'`, `'false'`. + * + * @param value Raw query parameter value. + * @returns Parsed boolean or `undefined` when absent. + * @throws {BadRequestException} When value is not a supported boolean + * representation. + */ export function parseOptionalBooleanQuery(value: unknown): boolean | undefined { if (typeof value === 'undefined') { return undefined; @@ -65,6 +98,15 @@ export function parseOptionalBooleanQuery(value: unknown): boolean | undefined { ); } +// TODO (SECURITY): getAuditUserIdNumber and getAuditUserIdBigInt duplicate the same validation logic. Consolidate into one function and derive the other from it. +/** + * Extracts the authenticated user id as a safe positive integer. + * + * @param user Authenticated JWT user payload. + * @returns Numeric user id for audit columns typed as `number`. + * @throws {ForbiddenException} When user id is missing, non-numeric, or outside + * the safe integer range. + */ export function getAuditUserIdNumber(user: JwtUser): number { const rawUserId = String(user.userId || '').trim(); @@ -83,6 +125,13 @@ export function getAuditUserIdNumber(user: JwtUser): number { return parsed; } +/** + * Extracts the authenticated user id as bigint. + * + * @param user Authenticated JWT user payload. + * @returns BigInt user id for audit columns typed as `bigint`. + * @throws {ForbiddenException} When user id is missing or non-numeric. + */ export function getAuditUserIdBigInt(user: JwtUser): bigint { const rawUserId = String(user.userId || '').trim(); @@ -93,6 +142,17 @@ export function getAuditUserIdBigInt(user: JwtUser): bigint { return BigInt(rawUserId); } +/** + * Normalizes JSON metadata references to a `{ key, version }` shape. + * + * Empty/null values resolve to `null`. Missing version resolves to `0`, which + * callers treat as "latest". + * + * @param value Raw JSON payload value. + * @param fieldName Field name used in validation messages. + * @returns Normalized reference or `null`. + * @throws {BadRequestException} When payload shape is invalid. + */ export function normalizeMetadataReference( value: unknown, fieldName: string, @@ -136,6 +196,12 @@ export function normalizeMetadataReference( }; } +/** + * Recursively converts `bigint` values into strings for JSON-safe responses. + * + * @param value Any serializable value. + * @returns Value with all nested bigint values stringified. + */ export function toSerializable(value: unknown): unknown { if (typeof value === 'bigint') { return value.toString(); @@ -158,6 +224,13 @@ export function toSerializable(value: unknown): unknown { return value; } +/** + * Re-throws HTTP framework errors and ignores non-HTTP errors. + * + * @param error Unknown thrown value. + * @returns `void` when error is not an HttpException. + * @throws {HttpException} Re-throws incoming HttpException instances. + */ export function rethrowHttpError(error: unknown): void { if (error instanceof HttpException) { throw error; diff --git a/src/api/metadata/utils/metadata-validation.utils.ts b/src/api/metadata/utils/metadata-validation.utils.ts index 2bb6284..684aee3 100644 --- a/src/api/metadata/utils/metadata-validation.utils.ts +++ b/src/api/metadata/utils/metadata-validation.utils.ts @@ -1,3 +1,11 @@ +/** + * Validates and resolves metadata version references (form, planConfig, + * priceConfig) against the database. + * + * These helpers are used during template create/update/upgrade flows to ensure + * referenced metadata versions exist and to resolve omitted versions to the + * latest available version. + */ import { BadRequestException } from '@nestjs/common'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { @@ -5,10 +13,23 @@ import { normalizeMetadataReference, } from './metadata-utils'; +/** + * Converts a reference version to `bigint` for Prisma filters. + * + * @param version Numeric version from request payload. + * @returns Truncated bigint version. + */ function toVersionBigInt(version: number): bigint { return BigInt(Math.trunc(version)); } +/** + * Normalizes a validated reference with the resolved persisted version. + * + * @param reference Reference payload. + * @param resolvedVersion Version found in the database. + * @returns Reference with resolved numeric version value. + */ function normalizeResolvedReference( reference: MetadataVersionReference, resolvedVersion: bigint, @@ -19,6 +40,15 @@ function normalizeResolvedReference( }; } +/** + * Validates a form reference and resolves version `0` to the latest version. + * + * @param formRef Raw form reference payload. + * @param prisma Prisma service used to validate existence. + * @returns Resolved metadata reference or `null` when omitted. + * @throws {BadRequestException} When the referenced form key/version is not + * found. + */ export async function validateFormReference( formRef: unknown, prisma: PrismaService, @@ -69,6 +99,16 @@ export async function validateFormReference( return normalizeResolvedReference(reference, latest.version); } +/** + * Validates a planConfig reference and resolves version `0` to the latest + * version. + * + * @param planConfigRef Raw planConfig reference payload. + * @param prisma Prisma service used to validate existence. + * @returns Resolved metadata reference or `null` when omitted. + * @throws {BadRequestException} When the referenced planConfig key/version is + * not found. + */ export async function validatePlanConfigReference( planConfigRef: unknown, prisma: PrismaService, @@ -121,6 +161,17 @@ export async function validatePlanConfigReference( return normalizeResolvedReference(reference, latest.version); } +// TODO (DRY): validateFormReference, validatePlanConfigReference, and validatePriceConfigReference are identical except for the Prisma model used. Extract a generic validateVersionedReference(ref, prismaDelegate, entityName) helper. +/** + * Validates a priceConfig reference and resolves version `0` to the latest + * version. + * + * @param priceConfigRef Raw priceConfig reference payload. + * @param prisma Prisma service used to validate existence. + * @returns Resolved metadata reference or `null` when omitted. + * @throws {BadRequestException} When the referenced priceConfig key/version is + * not found. + */ export async function validatePriceConfigReference( priceConfigRef: unknown, prisma: PrismaService, diff --git a/src/api/metadata/work-management-permission/dto/create-work-management-permission.dto.ts b/src/api/metadata/work-management-permission/dto/create-work-management-permission.dto.ts index e7d1dce..fcf8609 100644 --- a/src/api/metadata/work-management-permission/dto/create-work-management-permission.dto.ts +++ b/src/api/metadata/work-management-permission/dto/create-work-management-permission.dto.ts @@ -2,6 +2,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, IsNotEmpty, IsObject, IsString, Min } from 'class-validator'; +/** + * Request payload for creating a work management permission record. + * + * @property policy Policy identifier. + * @property permission Permission payload object. + * @property projectTemplateId Project template id. + */ export class CreateWorkManagementPermissionDto { @ApiProperty() @IsString() diff --git a/src/api/metadata/work-management-permission/dto/update-work-management-permission-request.dto.ts b/src/api/metadata/work-management-permission/dto/update-work-management-permission-request.dto.ts index db9bed5..439b6a3 100644 --- a/src/api/metadata/work-management-permission/dto/update-work-management-permission-request.dto.ts +++ b/src/api/metadata/work-management-permission/dto/update-work-management-permission-request.dto.ts @@ -3,6 +3,14 @@ import { Type } from 'class-transformer'; import { IsInt, Min } from 'class-validator'; import { UpdateWorkManagementPermissionDto } from './update-work-management-permission.dto'; +/** + * Request payload for updating a work management permission by id. + * + * @property id Permission record id. + * @property policy Optional policy override. + * @property permission Optional permission payload override. + * @property projectTemplateId Optional project template id override. + */ export class UpdateWorkManagementPermissionRequestDto extends UpdateWorkManagementPermissionDto { @ApiProperty({ description: 'Work management permission id.', diff --git a/src/api/metadata/work-management-permission/dto/update-work-management-permission.dto.ts b/src/api/metadata/work-management-permission/dto/update-work-management-permission.dto.ts index b1e7cb7..54b0fe1 100644 --- a/src/api/metadata/work-management-permission/dto/update-work-management-permission.dto.ts +++ b/src/api/metadata/work-management-permission/dto/update-work-management-permission.dto.ts @@ -1,6 +1,13 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateWorkManagementPermissionDto } from './create-work-management-permission.dto'; +/** + * Request payload for partially updating a work management permission record. + * + * @property policy Optional policy override. + * @property permission Optional permission payload override. + * @property projectTemplateId Optional project template id override. + */ export class UpdateWorkManagementPermissionDto extends PartialType( CreateWorkManagementPermissionDto, ) {} diff --git a/src/api/metadata/work-management-permission/dto/work-management-permission-criteria.dto.ts b/src/api/metadata/work-management-permission/dto/work-management-permission-criteria.dto.ts index 9744356..656ead4 100644 --- a/src/api/metadata/work-management-permission/dto/work-management-permission-criteria.dto.ts +++ b/src/api/metadata/work-management-permission/dto/work-management-permission-criteria.dto.ts @@ -2,6 +2,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, Min } from 'class-validator'; +/** + * Filter criteria payload for listing work management permissions. + * + * @property projectTemplateId Project template id filter. + */ export class WorkManagementPermissionCriteriaDto { @ApiProperty({ description: 'Project template id used to filter permission metadata.', diff --git a/src/api/metadata/work-management-permission/dto/work-management-permission-id-query.dto.ts b/src/api/metadata/work-management-permission/dto/work-management-permission-id-query.dto.ts index 7c5b5ec..80b3141 100644 --- a/src/api/metadata/work-management-permission/dto/work-management-permission-id-query.dto.ts +++ b/src/api/metadata/work-management-permission/dto/work-management-permission-id-query.dto.ts @@ -2,6 +2,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, Min } from 'class-validator'; +/** + * Query payload for addressing one permission by id. + * + * @property id Permission id. + */ export class WorkManagementPermissionIdQueryDto { @ApiProperty({ description: 'Work management permission id.', diff --git a/src/api/metadata/work-management-permission/dto/work-management-permission-query.dto.ts b/src/api/metadata/work-management-permission/dto/work-management-permission-query.dto.ts index 4b6a401..b156717 100644 --- a/src/api/metadata/work-management-permission/dto/work-management-permission-query.dto.ts +++ b/src/api/metadata/work-management-permission/dto/work-management-permission-query.dto.ts @@ -2,6 +2,12 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, IsOptional, Min } from 'class-validator'; +/** + * Query payload for list-or-get permission requests. + * + * @property id Optional permission id for single-record mode. + * @property projectTemplateId Optional project template id for list mode. + */ export class WorkManagementPermissionQueryDto { @ApiPropertyOptional({ description: diff --git a/src/api/metadata/work-management-permission/dto/work-management-permission-response.dto.ts b/src/api/metadata/work-management-permission/dto/work-management-permission-response.dto.ts index 0f99959..b59222f 100644 --- a/src/api/metadata/work-management-permission/dto/work-management-permission-response.dto.ts +++ b/src/api/metadata/work-management-permission/dto/work-management-permission-response.dto.ts @@ -1,5 +1,17 @@ import { ApiProperty } from '@nestjs/swagger'; +/** + * API response payload for work management permissions. + * + * @property id Permission record id. + * @property policy Policy identifier. + * @property permission Permission payload object. + * @property projectTemplateId Project template id. + * @property createdAt Creation timestamp. + * @property updatedAt Update timestamp. + * @property createdBy Creator user id. + * @property updatedBy Updater user id. + */ export class WorkManagementPermissionResponseDto { @ApiProperty() id: string; diff --git a/src/api/metadata/work-management-permission/work-management-permission.controller.ts b/src/api/metadata/work-management-permission/work-management-permission.controller.ts index 092c355..8701bfb 100644 --- a/src/api/metadata/work-management-permission/work-management-permission.controller.ts +++ b/src/api/metadata/work-management-permission/work-management-permission.controller.ts @@ -43,11 +43,19 @@ const WORK_MANAGEMENT_PERMISSION_ROLES = Object.values(UserRole); @ApiTags('Metadata - Work Management Permission') @ApiBearerAuth() @Controller('/projects/metadata/workManagementPermission') +/** + * REST controller for work management permissions. + * + * All endpoints are authenticated. `GET` requires + * `WORK_MANAGEMENT_PERMISSION_VIEW`; write operations require + * `@AdminOnly()` and `WORK_MANAGEMENT_PERMISSION_EDIT`. + */ export class WorkManagementPermissionController { constructor( private readonly workManagementPermissionService: WorkManagementPermissionService, ) {} + // TODO (SECURITY): The GET endpoint applies PermissionGuard + RequirePermission(WORK_MANAGEMENT_PERMISSION_VIEW). Verify this is intentional — other metadata read endpoints (FormController, ProjectTemplateController) have no auth guard on GET routes, creating an inconsistency. @Get() @UseGuards(PermissionGuard) @Roles(...WORK_MANAGEMENT_PERMISSION_ROLES) @@ -79,8 +87,14 @@ export class WorkManagementPermissionController { @ApiResponse({ status: 400, description: 'Bad Request' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Forbidden' }) + // TODO (QUALITY): Duplicate @ApiResponse({ status: 200 }) decorators on listOrGet — remove the second one (line 82). @ApiResponse({ status: 200, type: WorkManagementPermissionResponseDto }) @ApiResponse({ status: 404, description: 'Not found' }) + /** + * Lists permissions for a project template or fetches one by id. + * + * HTTP 200 on success, 400 on invalid query, 404 when id is not found. + */ async listOrGet( @Query() query: WorkManagementPermissionQueryDto, ): Promise< @@ -105,6 +119,11 @@ export class WorkManagementPermissionController { return this.workManagementPermissionService.findAll(criteria); } + /** + * Creates a permission record. + * + * HTTP 201 on success. + */ @Post() @AdminOnly() @UseGuards(PermissionGuard) @@ -129,6 +148,11 @@ export class WorkManagementPermissionController { ); } + /** + * Updates a permission record by request body `id`. + * + * HTTP 200 on success. + */ @Patch() @AdminOnly() @UseGuards(PermissionGuard) @@ -160,6 +184,11 @@ export class WorkManagementPermissionController { ); } + /** + * Soft deletes a permission record. + * + * HTTP 204 on success. + */ @Delete() @HttpCode(204) @AdminOnly() diff --git a/src/api/metadata/work-management-permission/work-management-permission.module.ts b/src/api/metadata/work-management-permission/work-management-permission.module.ts index 2b013f0..27cd27c 100644 --- a/src/api/metadata/work-management-permission/work-management-permission.module.ts +++ b/src/api/metadata/work-management-permission/work-management-permission.module.ts @@ -3,6 +3,10 @@ import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders import { WorkManagementPermissionController } from './work-management-permission.controller'; import { WorkManagementPermissionService } from './work-management-permission.service'; +/** + * Registers work management permission controller/service and exports the + * service for authorization-aware metadata workflows. + */ @Module({ imports: [GlobalProvidersModule], controllers: [WorkManagementPermissionController], diff --git a/src/api/metadata/work-management-permission/work-management-permission.service.ts b/src/api/metadata/work-management-permission/work-management-permission.service.ts index e298464..01bd71c 100644 --- a/src/api/metadata/work-management-permission/work-management-permission.service.ts +++ b/src/api/metadata/work-management-permission/work-management-permission.service.ts @@ -19,6 +19,13 @@ import { UpdateWorkManagementPermissionDto } from './dto/update-work-management- import { WorkManagementPermissionResponseDto } from './dto/work-management-permission-response.dto'; @Injectable() +/** + * Manages work management permission records for each `policy` and + * `projectTemplateId` pair. + * + * These records are used by platform UI flows to enforce permission-driven + * behavior per project template. + */ export class WorkManagementPermissionService { constructor( private readonly prisma: PrismaService, @@ -26,6 +33,9 @@ export class WorkManagementPermissionService { private readonly eventBusService: EventBusService, ) {} + /** + * Lists permissions for a project template. + */ async findAll( criteria: WorkManagementPermissionCriteriaDto, ): Promise { @@ -40,6 +50,9 @@ export class WorkManagementPermissionService { return records.map((record) => this.toDto(record)); } + /** + * Finds one permission by id. + */ async findOne(id: bigint): Promise { const record = await this.prisma.workManagementPermission.findFirst({ where: { @@ -57,6 +70,13 @@ export class WorkManagementPermissionService { return this.toDto(record); } + /** + * Creates a permission record. + * + * Throws: + * - NotFoundException when `projectTemplateId` does not exist. + * - ConflictException when `(policy, projectTemplateId)` already exists. + */ async create( dto: CreateWorkManagementPermissionDto, userId: number, @@ -103,6 +123,13 @@ export class WorkManagementPermissionService { } } + /** + * Updates a permission record. + * + * Throws: + * - NotFoundException when record or project template does not exist. + * - ConflictException when resulting `(policy, projectTemplateId)` conflicts. + */ async update( id: bigint, dto: UpdateWorkManagementPermissionDto, @@ -180,6 +207,9 @@ export class WorkManagementPermissionService { } } + /** + * Soft deletes a permission record. + */ async delete(id: bigint, userId: number): Promise { try { const existing = await this.prisma.workManagementPermission.findFirst({ @@ -225,10 +255,16 @@ export class WorkManagementPermissionService { } } + /** + * Parses a permission id route/query parameter. + */ parseId(value: string): bigint { return parseBigIntParam(value, 'id'); } + /** + * Validates that referenced project template exists. + */ private async ensureProjectTemplateExists( projectTemplateId: bigint, ): Promise { @@ -249,6 +285,9 @@ export class WorkManagementPermissionService { } } + /** + * Maps Prisma entity to response DTO. + */ private toDto( record: WorkManagementPermission, ): WorkManagementPermissionResponseDto { @@ -267,6 +306,9 @@ export class WorkManagementPermissionService { }; } + /** + * Re-throws framework HTTP exceptions and delegates unknown errors to Prisma. + */ private handleError(error: unknown, operation: string): never { if (error instanceof HttpException) { throw error; diff --git a/src/api/project-attachment/dto/attachment-list-query.dto.ts b/src/api/project-attachment/dto/attachment-list-query.dto.ts index e8086d3..99a02e7 100644 --- a/src/api/project-attachment/dto/attachment-list-query.dto.ts +++ b/src/api/project-attachment/dto/attachment-list-query.dto.ts @@ -1 +1,5 @@ +/** + * Query payload for `GET /projects/:projectId/attachments`. + */ +// TODO [QUALITY]: Class is empty. Add `type`, `category`, `tags` filter fields or remove. export class AttachmentListQueryDto {} diff --git a/src/api/project-attachment/dto/attachment-response.dto.ts b/src/api/project-attachment/dto/attachment-response.dto.ts index 7c576db..4cadc27 100644 --- a/src/api/project-attachment/dto/attachment-response.dto.ts +++ b/src/api/project-attachment/dto/attachment-response.dto.ts @@ -1,6 +1,9 @@ import { AttachmentType } from '@prisma/client'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** + * Response payload for project attachment endpoints. + */ export class AttachmentResponseDto { @ApiProperty() id: string; @@ -51,12 +54,13 @@ export class AttachmentResponseDto { updatedBy: number; @ApiPropertyOptional({ - description: 'Presigned URL for file download on single fetch endpoint.', + description: + 'Presigned URL for file download on single fetch endpoint (single-fetch use).', }) url?: string; @ApiPropertyOptional({ - description: 'Presigned URL returned when creating file attachments.', + description: 'Presigned URL returned on file attachment create responses.', }) downloadUrl?: string; } diff --git a/src/api/project-attachment/dto/update-attachment.dto.ts b/src/api/project-attachment/dto/update-attachment.dto.ts index 7ab049a..148c66e 100644 --- a/src/api/project-attachment/dto/update-attachment.dto.ts +++ b/src/api/project-attachment/dto/update-attachment.dto.ts @@ -14,6 +14,7 @@ function parseOptionalInteger(value: unknown): number | undefined { return Math.trunc(parsed); } +// TODO [DRY]: Duplicated in `create-attachment.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. function parseAllowedUsers(value: unknown): number[] | undefined { if (!Array.isArray(value)) { @@ -24,7 +25,13 @@ function parseAllowedUsers(value: unknown): number[] | undefined { .map((entry) => parseOptionalInteger(entry)) .filter((entry): entry is number => typeof entry === 'number'); } +// TODO [DRY]: Duplicated in `create-attachment.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +/** + * Update payload for `PATCH /projects/:projectId/attachments/:id`. + * `type` and `contentType` are immutable after creation and intentionally + * excluded from this DTO. + */ export class UpdateAttachmentDto { @ApiPropertyOptional() @IsOptional() diff --git a/src/api/workstream/workstream.dto.ts b/src/api/workstream/workstream.dto.ts index b3927d4..b8c52fb 100644 --- a/src/api/workstream/workstream.dto.ts +++ b/src/api/workstream/workstream.dto.ts @@ -25,6 +25,7 @@ function parseOptionalNumber(value: unknown): number | undefined { return parsed; } +// TODO [DRY]: Duplicated in `create-phase.dto.ts` and `create-phase-product.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. function parseOptionalInteger(value: unknown): number | undefined { const parsed = parseOptionalNumber(value); @@ -35,6 +36,7 @@ function parseOptionalInteger(value: unknown): number | undefined { return Math.trunc(parsed); } +// TODO [DRY]: Duplicated in `create-phase.dto.ts` and `create-phase-product.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. function parseOptionalBoolean(value: unknown): boolean | undefined { if (typeof value === 'boolean') { @@ -55,14 +57,18 @@ function parseOptionalBoolean(value: unknown): boolean | undefined { return undefined; } +// TODO [DRY]: Duplicated in `phase-list-query.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +/** + * Create payload for `POST /projects/:projectId/workstreams`. + */ export class CreateWorkStreamDto { - @ApiProperty() + @ApiProperty({ description: 'Work stream name.' }) @IsString() @IsNotEmpty() name: string; - @ApiProperty() + @ApiProperty({ description: 'Work stream type key.' }) @IsString() @IsNotEmpty() type: string; @@ -75,8 +81,14 @@ export class CreateWorkStreamDto { status: WorkStreamStatus; } +/** + * Partial update payload for `PATCH /projects/:projectId/workstreams/:id`. + */ export class UpdateWorkStreamDto extends PartialType(CreateWorkStreamDto) {} +/** + * Summary model for linked works embedded in work stream responses. + */ export class WorkSummaryDto { @ApiProperty() id: string; @@ -88,6 +100,9 @@ export class WorkSummaryDto { status?: string | null; } +/** + * Response payload for work stream endpoints. + */ export class WorkStreamResponseDto { @ApiProperty() id: string; @@ -119,10 +134,18 @@ export class WorkStreamResponseDto { @ApiProperty() updatedBy: string; - @ApiPropertyOptional({ type: () => [WorkSummaryDto] }) + @ApiPropertyOptional({ + type: () => [WorkSummaryDto], + description: + 'Linked works (project phases), included when `includeWorks=true`.', + }) works?: WorkSummaryDto[]; } +/** + * List query payload for `GET /projects/:projectId/workstreams`. + * Pagination defaults: `page=1`, `perPage=20`. + */ export class WorkStreamListCriteria { @ApiPropertyOptional({ enum: WorkStreamStatus, @@ -141,14 +164,23 @@ export class WorkStreamListCriteria { @IsString() sort?: string; - @ApiPropertyOptional({ minimum: 1, default: 1 }) + @ApiPropertyOptional({ + minimum: 1, + default: 1, + description: 'Page number (default: 1).', + }) @IsOptional() @Transform(({ value }) => parseOptionalInteger(value)) @IsInt() @Min(1) page?: number; - @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 }) + @ApiPropertyOptional({ + minimum: 1, + maximum: 100, + default: 20, + description: 'Page size (default: 20).', + }) @IsOptional() @Transform(({ value }) => parseOptionalInteger(value)) @IsInt() @@ -157,6 +189,9 @@ export class WorkStreamListCriteria { perPage?: number; } +/** + * Query payload for `GET /projects/:projectId/workstreams/:id`. + */ export class WorkStreamGetCriteria { @ApiPropertyOptional({ description: From 3c43d6eef041ce181df7574fbf9aaa25c82c0f0b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 20 Feb 2026 16:07:18 +1100 Subject: [PATCH 05/41] Refactoring to reduce redundancies --- docs/DEPENDENCIES.md | 15 +- package.json | 10 +- patches/@eslint__eslintrc@3.3.3.patch | 52 ++++ patches/eslint@9.39.2.patch | 50 ++++ pnpm-lock.yaml | 149 ++++----- .../copilot/copilot-application.service.ts | 17 +- .../copilot/copilot-notification.service.ts | 32 +- .../copilot/copilot-opportunity.service.ts | 19 +- src/api/copilot/copilot-request.service.ts | 53 +--- src/api/copilot/copilot.utils.ts | 69 +++-- .../copilot/dto/copilot-application.dto.ts | 22 +- .../copilot/dto/copilot-opportunity.dto.ts | 46 +-- src/api/copilot/dto/copilot-request.dto.ts | 22 +- src/api/metadata/form/form.service.ts | 23 +- src/api/metadata/metadata-list.service.ts | 49 ++- .../plan-config/plan-config.service.ts | 39 +-- .../price-config/price-config.service.ts | 39 +-- .../product-template.service.ts | 51 +--- .../project-template.service.ts | 110 ++----- .../metadata/utils/metadata-template.utils.ts | 86 ++++++ .../utils/metadata-validation.utils.ts | 206 +++++-------- .../metadata/utils/versioned-config.utils.ts | 35 +++ .../dto/create-phase-product.dto.ts | 29 +- .../phase-product/phase-product.service.ts | 94 +----- src/api/phase-product/workitem.controller.ts | 23 +- .../dto/create-attachment.dto.ts | 31 +- .../dto/update-attachment.dto.ts | 28 +- .../project-attachment.service.ts | 84 +----- .../project-invite/project-invite.service.ts | 179 +++-------- .../project-member/project-member.service.ts | 154 +++------- src/api/project-phase/dto/create-phase.dto.ts | 41 +-- .../project-phase/dto/phase-list-query.dto.ts | 22 +- .../project-phase/project-phase.service.ts | 142 ++------- src/api/project-phase/work.controller.ts | 23 +- .../project-setting.controller.ts | 12 +- .../project-setting.service.ts | 25 +- src/api/workstream/workstream.controller.ts | 23 +- src/api/workstream/workstream.dto.ts | 51 +--- src/api/workstream/workstream.service.ts | 34 +-- src/shared/config/service-endpoints.config.ts | 7 + src/shared/constants/roles.ts | 14 + .../project-permission-context.interface.ts | 25 ++ src/shared/modules/global/jwt.service.ts | 70 +---- src/shared/modules/global/m2m.service.ts | 21 +- src/shared/services/identity.service.ts | 4 +- src/shared/services/member.service.ts | 6 +- src/shared/utils/dto-transform.utils.ts | 96 ++++++ src/shared/utils/entity.utils.ts | 40 +++ src/shared/utils/event.utils.ts | 19 ++ src/shared/utils/member.utils.ts | 1 + src/shared/utils/query.utils.ts | 36 +++ src/shared/utils/scope.utils.ts | 34 +++ src/shared/utils/service.utils.ts | 283 ++++++++++++++++++ 53 files changed, 1283 insertions(+), 1562 deletions(-) create mode 100644 patches/@eslint__eslintrc@3.3.3.patch create mode 100644 patches/eslint@9.39.2.patch create mode 100644 src/api/metadata/utils/metadata-template.utils.ts create mode 100644 src/api/metadata/utils/versioned-config.utils.ts create mode 100644 src/shared/config/service-endpoints.config.ts create mode 100644 src/shared/constants/roles.ts create mode 100644 src/shared/interfaces/project-permission-context.interface.ts create mode 100644 src/shared/utils/dto-transform.utils.ts create mode 100644 src/shared/utils/entity.utils.ts create mode 100644 src/shared/utils/query.utils.ts create mode 100644 src/shared/utils/scope.utils.ts create mode 100644 src/shared/utils/service.utils.ts diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 887af99..59bca09 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -24,14 +24,13 @@ To re-run checks: | GHSA-43fc-jf86-j433 | **HIGH** | `axios` | `<=0.30.2` and `>=1.0.0 <=1.13.4` | `>=0.30.3`, `>=1.13.5` | ✅ Cleared in production/transitive paths | Applied `pnpm.overrides.axios = 1.13.5`; verified `tc-core-library-js` now resolves `axios@1.13.5`. | | GHSA-gq3j-xvxp-8hrf | **LOW** | `hono` | `<4.11.10` | `>=4.11.10` | ✅ Cleared | Updated `pnpm.overrides.hono` to `4.11.10`; Prisma transitive paths resolve `hono@4.11.10`. | | GHSA-3ppc-4f35-3m26 | **HIGH** | `minimatch` | `<10.2.1` | `>=10.2.1` | ✅ Cleared | Added `pnpm.overrides.minimatch = 10.2.1`; audit no longer reports minimatch. | -| GHSA-2g4f-4pwh-qvx6 | **MODERATE** | `ajv` | `<8.18.0` | `>=8.18.0` | ⚠️ Still open (2 moderate findings in `pnpm audit`) | Direct `ajv` bumped to `^8.18.0`, but transitive `ajv@6.12.6` remains via `@eslint/eslintrc@3.3.3` and `@nestjs/cli -> fork-ts-checker-webpack-plugin -> schema-utils@3.3.0`. Required fix is upstream/toolchain migration off Ajv v6 paths. | +| GHSA-2g4f-4pwh-qvx6 | **MODERATE** | `ajv` | `<8.18.0` | `>=8.18.0` | ✅ Cleared | Enforced `pnpm.overrides.ajv = 8.18.0` and added compatibility patches for `eslint@9.39.2` / `@eslint/eslintrc@3.3.3` to preserve lint behavior with Ajv v8. | | CVE-2025-65945 | **HIGH** | `jws` (transitive via `jsonwebtoken`, `jwks-rsa`) | `<=3.2.2`, `4.0.0` | `3.2.3`, `4.0.1` | ✅ Cleared | Existing `jws` override retained (`>=3.2.3 <4.0.0 || >=4.0.1`). | Current `pnpm audit` summary: ```text -2 vulnerabilities found -Severity: 2 moderate +No known vulnerabilities found ``` ## Outdated Dependencies @@ -71,6 +70,7 @@ Regenerated from `pnpm outdated` after dependency/security updates. | Override | Pinned To | Reason | |---|---|---| +| `ajv` | `8.18.0` | Fix advisory GHSA-2g4f-4pwh-qvx6 across all transitive paths | | `axios` | `1.13.5` | Force patched axios across transitive paths (`tc-core-library-js` included) | | `fast-xml-parser` | `5.3.6` | Security hardening | | `hono` | `4.11.10` | Fix advisory GHSA-gq3j-xvxp-8hrf | @@ -79,6 +79,13 @@ Regenerated from `pnpm outdated` after dependency/security updates. | `minimatch` | `10.2.1` | Fix advisory GHSA-3ppc-4f35-3m26 | | `qs` | `6.14.2` | Prototype pollution / DoS fix | +## pnpm Patched Dependencies + +| Package | Patch File | Reason | +|---|---|---| +| `@eslint/eslintrc@3.3.3` | `patches/@eslint__eslintrc@3.3.3.patch` | Adapt Ajv initialization to work with enforced `ajv@8.18.0` | +| `eslint@9.39.2` | `patches/eslint@9.39.2.patch` | Replace Ajv v6-specific draft-04 path/API usage with Ajv v8-compatible behavior | + ## Verification Log Commands run in `projects-api-v6/` (with `nvm use` before each): @@ -86,7 +93,7 @@ Commands run in `projects-api-v6/` (with `nvm use` before each): | Command | Result | |---|---| | `pnpm install` | ✅ Passed | -| `pnpm audit` | ⚠️ Fails with 2 moderate `ajv` vulnerabilities (upstream Ajv v6 transitive deps) | +| `pnpm audit` | ✅ Passed (`No known vulnerabilities found`) | | `pnpm outdated` | ✅ Completed (table above updated) | | `pnpm lint` | ✅ Passed | | `pnpm build` | ✅ Passed | diff --git a/package.json b/package.json index 5edf89c..1138e08 100644 --- a/package.json +++ b/package.json @@ -58,8 +58,8 @@ "@swc/core": "^1.10.7", "@types/autocannon": "^7.12.7", "@types/express": "^5.0.0", - "@types/jsonwebtoken": "^9.0.9", "@types/jest": "^29.5.14", + "@types/jsonwebtoken": "^9.0.9", "@types/lodash": "^4.17.20", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", @@ -101,10 +101,10 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" }, - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" - , + "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", "pnpm": { "overrides": { + "ajv": "8.18.0", "axios": "1.13.5", "fast-xml-parser": "5.3.6", "hono": "4.11.10", @@ -112,6 +112,10 @@ "lodash": "4.17.23", "minimatch": "10.2.1", "qs": "6.14.2" + }, + "patchedDependencies": { + "@eslint/eslintrc@3.3.3": "patches/@eslint__eslintrc@3.3.3.patch", + "eslint@9.39.2": "patches/eslint@9.39.2.patch" } } } diff --git a/patches/@eslint__eslintrc@3.3.3.patch b/patches/@eslint__eslintrc@3.3.3.patch new file mode 100644 index 0000000..34af125 --- /dev/null +++ b/patches/@eslint__eslintrc@3.3.3.patch @@ -0,0 +1,52 @@ +diff --git a/dist/eslintrc-universal.cjs b/dist/eslintrc-universal.cjs +index 6000445f7ad1c90b22987842d8b68217c1d9b007..b754545f08a3091787afabd5134d07622e353db2 100644 +--- a/dist/eslintrc-universal.cjs ++++ b/dist/eslintrc-universal.cjs +@@ -374,15 +374,17 @@ var ajvOrig = (additionalOptions = {}) => { + meta: false, + useDefaults: true, + validateSchema: false, +- missingRefs: "ignore", + verbose: true, +- schemaId: "auto", + ...additionalOptions + }); + + ajv.addMetaSchema(metaSchema); +- // eslint-disable-next-line no-underscore-dangle -- part of the API +- ajv._opts.defaultMeta = metaSchema.id; ++ if (ajv.opts) { ++ ajv.opts.defaultMeta = metaSchema.id; ++ } else if (ajv._opts) { ++ // eslint-disable-next-line no-underscore-dangle -- Ajv v6 path ++ ajv._opts.defaultMeta = metaSchema.id; ++ } + + return ajv; + }; +diff --git a/dist/eslintrc.cjs b/dist/eslintrc.cjs +index 1b76091f34e680456e5911651ea84f0c622436c8..106d2a5dcb7b791cd3c6e644dfd02e0e4e053d73 100644 +--- a/dist/eslintrc.cjs ++++ b/dist/eslintrc.cjs +@@ -1607,15 +1607,17 @@ var ajvOrig = (additionalOptions = {}) => { + meta: false, + useDefaults: true, + validateSchema: false, +- missingRefs: "ignore", + verbose: true, +- schemaId: "auto", + ...additionalOptions + }); + + ajv.addMetaSchema(metaSchema); +- // eslint-disable-next-line no-underscore-dangle -- part of the API +- ajv._opts.defaultMeta = metaSchema.id; ++ if (ajv.opts) { ++ ajv.opts.defaultMeta = metaSchema.id; ++ } else if (ajv._opts) { ++ // eslint-disable-next-line no-underscore-dangle -- Ajv v6 path ++ ajv._opts.defaultMeta = metaSchema.id; ++ } + + return ajv; + }; diff --git a/patches/eslint@9.39.2.patch b/patches/eslint@9.39.2.patch new file mode 100644 index 0000000..f5c7827 --- /dev/null +++ b/patches/eslint@9.39.2.patch @@ -0,0 +1,50 @@ +diff --git a/lib/shared/ajv.js b/lib/shared/ajv.js +index 5c5c41bd7470bd190bd4efb5caab88c73ae01f56..6aa3190f4c4f7fe94c50980d77b4538b51953c02 100644 +--- a/lib/shared/ajv.js ++++ b/lib/shared/ajv.js +@@ -8,8 +8,13 @@ + // Requirements + //------------------------------------------------------------------------------ + +-const Ajv = require("ajv"), +- metaSchema = require("ajv/lib/refs/json-schema-draft-04.json"); ++const Ajv = require("ajv"); ++ ++const metaSchema = { ++ id: "http://json-schema.org/draft-04/schema#", ++ $id: "http://json-schema.org/draft-04/schema#", ++ $schema: "http://json-schema.org/draft-04/schema#", ++}; + + //------------------------------------------------------------------------------ + // Public Interface +@@ -20,15 +25,24 @@ module.exports = (additionalOptions = {}) => { + meta: false, + useDefaults: true, + validateSchema: false, +- missingRefs: "ignore", + verbose: true, +- schemaId: "auto", + ...additionalOptions, + }); + +- ajv.addMetaSchema(metaSchema); +- // eslint-disable-next-line no-underscore-dangle -- Ajv's API +- ajv._opts.defaultMeta = metaSchema.id; ++ if (typeof ajv.addMetaSchema === "function") { ++ try { ++ ajv.addMetaSchema(metaSchema); ++ } catch { ++ // Ignore duplicate/incompatible meta schema errors across Ajv major versions. ++ } ++ } ++ ++ if (ajv.opts) { ++ ajv.opts.defaultMeta = metaSchema.$id || metaSchema.id; ++ } else if (ajv._opts) { ++ // eslint-disable-next-line no-underscore-dangle -- Ajv v6 path ++ ajv._opts.defaultMeta = metaSchema.id; ++ } + + return ajv; + }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82436f2..3129253 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + ajv: 8.18.0 axios: 1.13.5 fast-xml-parser: 5.3.6 hono: 4.11.10 @@ -13,6 +14,14 @@ overrides: minimatch: 10.2.1 qs: 6.14.2 +patchedDependencies: + '@eslint/eslintrc@3.3.3': + hash: zrzh6kkfky44bxmprbtcvs3pcu + path: patches/@eslint__eslintrc@3.3.3.patch + eslint@9.39.2: + hash: kkxs4nnfkabf36btu5xbcihjjq + path: patches/eslint@9.39.2.patch + importers: .: @@ -92,7 +101,7 @@ importers: devDependencies: '@eslint/eslintrc': specifier: ^3.2.0 - version: 3.3.3 + version: 3.3.3(patch_hash=zrzh6kkfky44bxmprbtcvs3pcu) '@eslint/js': specifier: ^9.18.0 version: 9.39.2 @@ -133,20 +142,20 @@ importers: specifier: ^6.0.2 version: 6.0.3 ajv: - specifier: ^8.18.0 + specifier: 8.18.0 version: 8.18.0 autocannon: specifier: ^8.0.0 version: 8.0.0 eslint: specifier: ^9.18.0 - version: 9.39.2(jiti@2.6.1) + version: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) eslint-config-prettier: specifier: ^10.0.1 - version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + version: 10.1.8(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)) eslint-plugin-prettier: specifier: ^5.2.2 - version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)))(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(prettier@3.8.1) globals: specifier: ^15.14.0 version: 15.15.0 @@ -182,7 +191,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.20.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) packages: @@ -1876,7 +1885,7 @@ packages: ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: - ajv: ^8.0.0 + ajv: 8.18.0 peerDependenciesMeta: ajv: optional: true @@ -1884,7 +1893,7 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: - ajv: ^8.0.0 + ajv: 8.18.0 peerDependenciesMeta: ajv: optional: true @@ -1892,18 +1901,12 @@ packages: ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: - ajv: ^6.9.1 + ajv: 8.18.0 ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: - ajv: ^8.8.2 - - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv: 8.18.0 ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -3200,9 +3203,6 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -3795,10 +3795,6 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -4383,9 +4379,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4531,8 +4524,8 @@ snapshots: '@angular-devkit/core@19.2.17(chokidar@4.0.3)': dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) jsonc-parser: 3.3.1 picomatch: 4.0.2 rxjs: 7.8.1 @@ -4542,8 +4535,8 @@ snapshots: '@angular-devkit/core@19.2.19(chokidar@4.0.3)': dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) jsonc-parser: 3.3.1 picomatch: 4.0.2 rxjs: 7.8.1 @@ -5319,9 +5312,9 @@ snapshots: '@electric-sql/pglite@0.3.15': {} - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -5342,9 +5335,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.3(patch_hash=zrzh6kkfky44bxmprbtcvs3pcu)': dependencies: - ajv: 6.12.6 + ajv: 8.18.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 @@ -6662,15 +6655,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.54.0 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -6678,14 +6671,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.54.0 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6708,13 +6701,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -6737,13 +6730,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6951,33 +6944,19 @@ snapshots: optionalDependencies: ajv: 8.18.0 - ajv-formats@3.0.1(ajv@8.17.1): + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.18.0 - ajv-keywords@3.5.2(ajv@6.12.6): + ajv-keywords@3.5.2(ajv@8.18.0): dependencies: - ajv: 6.12.6 + ajv: 8.18.0 ajv-keywords@5.1.0(ajv@8.18.0): dependencies: ajv: 8.18.0 fast-deep-equal: 3.1.3 - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -7559,19 +7538,19 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) - eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)))(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(prettier@3.8.1): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: '@types/eslint': 9.6.1 - eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + eslint-config-prettier: 10.1.8(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)) eslint-scope@5.1.1: dependencies: @@ -7587,21 +7566,21 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2(jiti@2.6.1): + eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 + '@eslint/eslintrc': 3.3.3(patch_hash=zrzh6kkfky44bxmprbtcvs3pcu) '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 + ajv: 8.18.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -8516,8 +8495,6 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -9049,8 +9026,6 @@ snapshots: proxy-from-env@1.1.0: {} - punycode@2.3.1: {} - pure-rand@6.1.0: {} qs@6.14.2: @@ -9193,8 +9168,8 @@ snapshots: schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) + ajv: 8.18.0 + ajv-keywords: 3.5.2(ajv@8.18.0) schema-utils@4.3.3: dependencies: @@ -9600,13 +9575,13 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -9639,10 +9614,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - util-deprecate@1.0.2: {} uuid-parse@1.1.0: {} diff --git a/src/api/copilot/copilot-application.service.ts b/src/api/copilot/copilot-application.service.ts index 313d5af..55ae29d 100644 --- a/src/api/copilot/copilot-application.service.ts +++ b/src/api/copilot/copilot-application.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, - ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -22,6 +21,7 @@ import { CreateCopilotApplicationDto, } from './dto/copilot-application.dto'; import { + ensureNamedPermission, isAdminOrPm, normalizeEntity, parseNumericId, @@ -63,7 +63,7 @@ export class CopilotApplicationService { dto: CreateCopilotApplicationDto, user: JwtUser, ): Promise { - this.ensurePermission(NamedPermission.APPLY_COPILOT_OPPORTUNITY, user); + ensureNamedPermission(this.permissionService, NamedPermission.APPLY_COPILOT_OPPORTUNITY, user); const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity'); const parsedUserId = this.parseUserId(user); @@ -264,17 +264,4 @@ export class CopilotApplicationService { return BigInt(normalized); } - /** - * Enforces a named permission for the current user. - * - * @param permission Permission constant. - * @param user Authenticated JWT user. - * @throws ForbiddenException If permission is missing. - */ - private ensurePermission(permission: NamedPermission, user: JwtUser): void { - // TODO [DRY]: Identical ensurePermission method exists in CopilotRequestService and CopilotOpportunityService; extract shared utility/base class. - if (!this.permissionService.hasNamedPermission(permission, user)) { - throw new ForbiddenException('Insufficient permissions'); - } - } } diff --git a/src/api/copilot/copilot-notification.service.ts b/src/api/copilot/copilot-notification.service.ts index 396b10b..28e06f8 100644 --- a/src/api/copilot/copilot-notification.service.ts +++ b/src/api/copilot/copilot-notification.service.ts @@ -9,7 +9,7 @@ import { import { LoggerService } from 'src/shared/modules/global/logger.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { MemberService } from 'src/shared/services/member.service'; -import { getCopilotRequestData, getCopilotTypeLabel } from './copilot.utils'; +import { getCopilotRequestData, getCopilotTypeLabel, readString } from './copilot.utils'; // TODO [CONFIG]: TEMPLATE_IDS are hardcoded SendGrid template ids; move these values to environment-based configuration. const TEMPLATE_IDS = { @@ -113,7 +113,7 @@ export class CopilotNotificationService { work_manager_url: this.getWorkManagerUrl(), opportunity_type: getCopilotTypeLabel(opportunityType), opportunity_title: - this.readString(requestData.opportunityTitle) || + readString(requestData.opportunityTitle) || `Opportunity ${opportunity.id.toString()}`, }, ), @@ -170,7 +170,7 @@ export class CopilotNotificationService { work_manager_url: this.getWorkManagerUrl(), opportunity_type: getCopilotTypeLabel(opportunityType), opportunity_title: - this.readString(requestData.opportunityTitle) || + readString(requestData.opportunityTitle) || `Opportunity ${opportunity.id.toString()}`, start_date: this.formatDate(requestData.startDate), }); @@ -218,7 +218,7 @@ export class CopilotNotificationService { opportunity_details_url: this.getCopilotPortalUrl(), work_manager_url: this.getWorkManagerUrl(), opportunity_title: - this.readString(requestData.opportunityTitle) || + readString(requestData.opportunityTitle) || `Opportunity ${opportunity.id.toString()}`, }, ), @@ -263,7 +263,7 @@ export class CopilotNotificationService { opportunity_details_url: this.getCopilotPortalUrl(), work_manager_url: this.getWorkManagerUrl(), opportunity_title: - this.readString(requestData.opportunityTitle) || + readString(requestData.opportunityTitle) || `Opportunity ${opportunity.id.toString()}`, }, ), @@ -336,7 +336,7 @@ export class CopilotNotificationService { opportunity: CopilotOpportunity, requestData: Record, ): CopilotOpportunityType { - const projectType = (this.readString(requestData.projectType) || '') + const projectType = (readString(requestData.projectType) || '') .toLowerCase() .trim(); @@ -363,7 +363,7 @@ export class CopilotNotificationService { return ''; } - const normalizedValue = this.readString(value); + const normalizedValue = readString(value); if (!normalizedValue) { return ''; @@ -382,22 +382,4 @@ export class CopilotNotificationService { return `${day}-${month}-${year}`; } - /** - * Reads a string-like primitive value. - * - * @param value Input value. - * @returns String value or undefined. - */ - private readString(value: unknown): string | undefined { - // TODO [DRY]: Identical readString exists in CopilotRequestService; extract to copilot.utils.ts. - if (typeof value === 'string') { - return value; - } - - if (typeof value === 'number') { - return `${value}`; - } - - return undefined; - } } diff --git a/src/api/copilot/copilot-opportunity.service.ts b/src/api/copilot/copilot-opportunity.service.ts index f798538..53773fa 100644 --- a/src/api/copilot/copilot-opportunity.service.ts +++ b/src/api/copilot/copilot-opportunity.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, - ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -25,6 +24,7 @@ import { ListOpportunitiesQueryDto, } from './dto/copilot-opportunity.dto'; import { + ensureNamedPermission, getAuditUserId, getCopilotRequestData, isAdminOrManager, @@ -241,7 +241,7 @@ export class CopilotOpportunityService { dto: AssignCopilotDto, user: JwtUser, ): Promise<{ id: string }> { - this.ensurePermission(NamedPermission.ASSIGN_COPILOT_OPPORTUNITY, user); + ensureNamedPermission(this.permissionService, NamedPermission.ASSIGN_COPILOT_OPPORTUNITY, user); const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity'); const parsedApplicationId = parseNumericId( @@ -437,7 +437,7 @@ export class CopilotOpportunityService { opportunityId: string, user: JwtUser, ): Promise<{ id: string }> { - this.ensurePermission(NamedPermission.CANCEL_COPILOT_OPPORTUNITY, user); + ensureNamedPermission(this.permissionService, NamedPermission.CANCEL_COPILOT_OPPORTUNITY, user); const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity'); const auditUserId = getAuditUserId(user); @@ -625,17 +625,4 @@ export class CopilotOpportunityService { ); } - /** - * Enforces a named permission for the current user. - * - * @param permission Permission constant. - * @param user Authenticated JWT user. - * @throws ForbiddenException If permission is missing. - */ - private ensurePermission(permission: NamedPermission, user: JwtUser): void { - // TODO [DRY]: Identical ensurePermission method exists in CopilotRequestService and CopilotApplicationService; extract shared utility/base class. - if (!this.permissionService.hasNamedPermission(permission, user)) { - throw new ForbiddenException('Insufficient permissions'); - } - } } diff --git a/src/api/copilot/copilot-request.service.ts b/src/api/copilot/copilot-request.service.ts index 8534631..a29fb46 100644 --- a/src/api/copilot/copilot-request.service.ts +++ b/src/api/copilot/copilot-request.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, ConflictException, - ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -24,12 +23,14 @@ import { UpdateCopilotRequestDto, } from './dto/copilot-request.dto'; import { + ensureNamedPermission, getAuditUserId, getCopilotRequestData, isAdminOrManager, normalizeEntity, parseNumericId, parseSortExpression, + readString, } from './copilot.utils'; const REQUEST_SORTS = [ @@ -85,7 +86,7 @@ export class CopilotRequestService { query: CopilotRequestListQueryDto, user: JwtUser, ): Promise { - this.ensurePermission(NamedPermission.MANAGE_COPILOT_REQUEST, user); + ensureNamedPermission(this.permissionService, NamedPermission.MANAGE_COPILOT_REQUEST, user); const includeProjectInResponse = isAdminOrManager(user); const parsedProjectId = projectId @@ -162,7 +163,7 @@ export class CopilotRequestService { copilotRequestId: string, user: JwtUser, ): Promise { - this.ensurePermission(NamedPermission.MANAGE_COPILOT_REQUEST, user); + ensureNamedPermission(this.permissionService, NamedPermission.MANAGE_COPILOT_REQUEST, user); const parsedRequestId = parseNumericId(copilotRequestId, 'Copilot request'); @@ -210,7 +211,7 @@ export class CopilotRequestService { dto: CreateCopilotRequestDto, user: JwtUser, ): Promise { - this.ensurePermission(NamedPermission.MANAGE_COPILOT_REQUEST, user); + ensureNamedPermission(this.permissionService, NamedPermission.MANAGE_COPILOT_REQUEST, user); const parsedProjectId = parseNumericId(projectId, 'Project'); const auditUserId = getAuditUserId(user); @@ -299,7 +300,7 @@ export class CopilotRequestService { dto: UpdateCopilotRequestDto, user: JwtUser, ): Promise { - this.ensurePermission(NamedPermission.MANAGE_COPILOT_REQUEST, user); + ensureNamedPermission(this.permissionService, NamedPermission.MANAGE_COPILOT_REQUEST, user); const parsedRequestId = parseNumericId(copilotRequestId, 'Copilot request'); const auditUserId = getAuditUserId(user); @@ -413,7 +414,7 @@ export class CopilotRequestService { type: string | undefined, user: JwtUser, ): Promise { - this.ensurePermission(NamedPermission.MANAGE_COPILOT_REQUEST, user); + ensureNamedPermission(this.permissionService, NamedPermission.MANAGE_COPILOT_REQUEST, user); const parsedProjectId = parseNumericId(projectId, 'Project'); const parsedRequestId = parseNumericId(copilotRequestId, 'Copilot request'); @@ -471,8 +472,8 @@ export class CopilotRequestService { const requestData = getCopilotRequestData(request.data); const requestedType = ( - this.readString(type) || - this.readString(requestData.projectType) || + readString(type) || + readString(requestData.projectType) || '' ).trim(); @@ -714,8 +715,8 @@ export class CopilotRequestService { return left.getTime() - right.getTime(); } - const leftValue = this.readString(left)?.toLowerCase() || ''; - const rightValue = this.readString(right)?.toLowerCase() || ''; + const leftValue = readString(left)?.toLowerCase() || ''; + const rightValue = readString(right)?.toLowerCase() || ''; if (leftValue < rightValue) { return -1; @@ -728,36 +729,4 @@ export class CopilotRequestService { return 0; } - /** - * Enforces a named permission for the current user. - * - * @param permission Permission constant. - * @param user Authenticated JWT user. - * @throws ForbiddenException If permission is missing. - */ - private ensurePermission(permission: NamedPermission, user: JwtUser): void { - // TODO [DRY]: Identical ensurePermission method exists in CopilotOpportunityService and CopilotApplicationService; extract shared utility/base class. - if (!this.permissionService.hasNamedPermission(permission, user)) { - throw new ForbiddenException('Insufficient permissions'); - } - } - - /** - * Reads a string-like primitive value. - * - * @param value Input value. - * @returns String value or undefined. - */ - private readString(value: unknown): string | undefined { - // TODO [DRY]: Identical readString exists in CopilotNotificationService; extract to copilot.utils.ts. - if (typeof value === 'string') { - return value; - } - - if (typeof value === 'number') { - return `${value}`; - } - - return undefined; - } } diff --git a/src/api/copilot/copilot.utils.ts b/src/api/copilot/copilot.utils.ts index 92d1648..bc6e0e2 100644 --- a/src/api/copilot/copilot.utils.ts +++ b/src/api/copilot/copilot.utils.ts @@ -1,7 +1,10 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { CopilotOpportunityType, Prisma } from '@prisma/client'; +import { Permission as NamedPermission } from 'src/shared/constants/permissions'; import { UserRole } from 'src/shared/enums/userRole.enum'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { PermissionService } from 'src/shared/services/permission.service'; +import { normalizeEntity as normalizePrismaEntity } from 'src/shared/utils/entity.utils'; /** * Shared pure-function toolkit for the copilot feature. @@ -46,41 +49,9 @@ export function getCopilotRequestData( /** * Recursively normalizes entity values for API responses. - * - * @param payload Prisma entity payload. - * @returns Same shape with bigint values converted to string and Decimal values converted to number. */ export function normalizeEntity(payload: T): T { - const walk = (input: unknown): unknown => { - if (typeof input === 'bigint') { - return input.toString(); - } - - if (input instanceof Prisma.Decimal) { - return Number(input.toString()); - } - - if (Array.isArray(input)) { - return input.map((entry) => walk(entry)); - } - - if (input && typeof input === 'object') { - if (input instanceof Date) { - return input; - } - - const output: Record = {}; - for (const [key, value] of Object.entries(input)) { - output[key] = walk(value); - } - - return output; - } - - return input; - }; - - return walk(payload) as T; + return normalizePrismaEntity(payload); } /** @@ -185,4 +156,32 @@ export function getAuditUserId(user: JwtUser): number { return value; } -// TODO [DRY]: Extract and export readString here; it is duplicated in CopilotRequestService and CopilotNotificationService. +/** + * Enforces a named permission for the current user. + * + * @throws ForbiddenException If permission is missing. + */ +export function ensureNamedPermission( + permissionService: PermissionService, + permission: NamedPermission, + user: JwtUser, +): void { + if (!permissionService.hasNamedPermission(permission, user)) { + throw new ForbiddenException('Insufficient permissions'); + } +} + +/** + * Reads a string-like primitive value. + */ +export function readString(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'number') { + return `${value}`; + } + + return undefined; +} diff --git a/src/api/copilot/dto/copilot-application.dto.ts b/src/api/copilot/dto/copilot-application.dto.ts index 6c4d549..cf747c4 100644 --- a/src/api/copilot/dto/copilot-application.dto.ts +++ b/src/api/copilot/dto/copilot-application.dto.ts @@ -2,27 +2,11 @@ import { CopilotApplicationStatus } from '@prisma/client'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; +import { parseOptionalLooseInteger } from 'src/shared/utils/dto-transform.utils'; /** * DTOs for copilot applications (apply, list, assign). */ -function parseOptionalInteger(value: unknown): number | undefined { - // TODO [DRY]: parseOptionalInteger is duplicated verbatim in copilot-request.dto.ts and copilot-opportunity.dto.ts; extract shared dto transform utilities. - if (typeof value === 'undefined' || value === null || value === '') { - return undefined; - } - - if (typeof value === 'number') { - return Number.isNaN(value) ? undefined : Math.trunc(value); - } - - if (typeof value === 'string') { - const parsed = Number.parseInt(value, 10); - return Number.isNaN(parsed) ? undefined : parsed; - } - - return undefined; -} /** * Request body for applying to an opportunity. @@ -92,14 +76,14 @@ export class AssignCopilotDto { export class CopilotApplicationListQueryDto { @ApiPropertyOptional({ minimum: 1, default: 1 }) @IsOptional() - @Transform(({ value }) => parseOptionalInteger(value)) + @Transform(({ value }) => parseOptionalLooseInteger(value)) @IsInt() @Min(1) page?: number; @ApiPropertyOptional({ minimum: 1, maximum: 200, default: 20 }) @IsOptional() - @Transform(({ value }) => parseOptionalInteger(value)) + @Transform(({ value }) => parseOptionalLooseInteger(value)) @IsInt() @Min(1) pageSize?: number; diff --git a/src/api/copilot/dto/copilot-opportunity.dto.ts b/src/api/copilot/dto/copilot-opportunity.dto.ts index 151eb82..e77c5e2 100644 --- a/src/api/copilot/dto/copilot-opportunity.dto.ts +++ b/src/api/copilot/dto/copilot-opportunity.dto.ts @@ -5,49 +5,15 @@ import { import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean, IsInt, IsOptional, IsString, Min } from 'class-validator'; +import { + parseOptionalBoolean, + parseOptionalLooseInteger, +} from 'src/shared/utils/dto-transform.utils'; import { CopilotSkillDto } from './copilot-request.dto'; /** * DTOs for listing and responding with copilot opportunities. */ -function parseOptionalInteger(value: unknown): number | undefined { - // TODO [DRY]: parseOptionalInteger is duplicated across copilot-request.dto.ts and copilot-application.dto.ts; extract shared dto transform utilities. - if (typeof value === 'undefined' || value === null || value === '') { - return undefined; - } - - if (typeof value === 'number') { - return Number.isNaN(value) ? undefined : Math.trunc(value); - } - - if (typeof value === 'string') { - const parsed = Number.parseInt(value, 10); - return Number.isNaN(parsed) ? undefined : parsed; - } - - return undefined; -} - -function parseOptionalBoolean(value: unknown): boolean | undefined { - // TODO [DRY]: parseOptionalBoolean duplicates query transform logic; extract shared dto transform utilities. - if (typeof value === 'boolean') { - return value; - } - - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - - if (normalized === 'true') { - return true; - } - - if (normalized === 'false') { - return false; - } - } - - return undefined; -} /** * Flattened response merging opportunity fields with request data. @@ -138,14 +104,14 @@ export class CopilotOpportunityResponseDto { export class ListOpportunitiesQueryDto { @ApiPropertyOptional({ minimum: 1, default: 1 }) @IsOptional() - @Transform(({ value }) => parseOptionalInteger(value)) + @Transform(({ value }) => parseOptionalLooseInteger(value)) @IsInt() @Min(1) page?: number; @ApiPropertyOptional({ minimum: 1, maximum: 200, default: 20 }) @IsOptional() - @Transform(({ value }) => parseOptionalInteger(value)) + @Transform(({ value }) => parseOptionalLooseInteger(value)) @IsInt() @Min(1) pageSize?: number; diff --git a/src/api/copilot/dto/copilot-request.dto.ts b/src/api/copilot/dto/copilot-request.dto.ts index 04efa83..6cb16c3 100644 --- a/src/api/copilot/dto/copilot-request.dto.ts +++ b/src/api/copilot/dto/copilot-request.dto.ts @@ -19,27 +19,11 @@ import { MinLength, ValidateNested, } from 'class-validator'; +import { parseOptionalLooseInteger } from 'src/shared/utils/dto-transform.utils'; /** * Input/output DTOs for the copilot request lifecycle. */ -function parseOptionalInteger(value: unknown): number | undefined { - // TODO [DRY]: parseOptionalInteger is duplicated verbatim in copilot-opportunity.dto.ts and copilot-application.dto.ts; extract to src/shared/utils/dto-transforms.utils.ts (or similar). - if (typeof value === 'undefined' || value === null || value === '') { - return undefined; - } - - if (typeof value === 'number') { - return Number.isNaN(value) ? undefined : Math.trunc(value); - } - - if (typeof value === 'string') { - const parsed = Number.parseInt(value, 10); - return Number.isNaN(parsed) ? undefined : parsed; - } - - return undefined; -} export enum CopilotComplexity { LOW = 'low', @@ -246,14 +230,14 @@ export class CopilotRequestResponseDto { export class CopilotRequestListQueryDto { @ApiPropertyOptional({ minimum: 1, default: 1 }) @IsOptional() - @Transform(({ value }) => parseOptionalInteger(value)) + @Transform(({ value }) => parseOptionalLooseInteger(value)) @IsInt() @Min(1) page?: number; @ApiPropertyOptional({ minimum: 1, maximum: 200, default: 20 }) @IsOptional() - @Transform(({ value }) => parseOptionalInteger(value)) + @Transform(({ value }) => parseOptionalLooseInteger(value)) @IsInt() @Min(1) pageSize?: number; diff --git a/src/api/metadata/form/form.service.ts b/src/api/metadata/form/form.service.ts index eaa8bae..164af21 100644 --- a/src/api/metadata/form/form.service.ts +++ b/src/api/metadata/form/form.service.ts @@ -12,6 +12,10 @@ import { PROJECT_METADATA_RESOURCE, publishMetadataEvent, } from '../utils/metadata-event.utils'; +import { + normalizeVersionedConfigKey, + pickLatestRevisionPerVersion, +} from '../utils/versioned-config.utils'; import { FormResponseDto } from './dto/form-response.dto'; @Injectable() @@ -81,16 +85,7 @@ export class FormService { throw new NotFoundException(`Form not found for key ${normalizedKey}.`); } - const latestByVersion = new Map(); - - for (const record of records) { - const versionKey = record.version.toString(); - if (!latestByVersion.has(versionKey)) { - latestByVersion.set(versionKey, record); - } - } - - return Array.from(latestByVersion.values()).map((record) => + return pickLatestRevisionPerVersion(records).map((record) => this.toDto(record), ); } @@ -532,13 +527,7 @@ export class FormService { * @throws {BadRequestException} If key is empty. */ private normalizeKey(key: string): string { - const normalized = String(key || '').trim(); - - if (!normalized) { - throw new BadRequestException('Metadata key is required.'); - } - - return normalized; + return normalizeVersionedConfigKey(key, 'Metadata'); } /** diff --git a/src/api/metadata/metadata-list.service.ts b/src/api/metadata/metadata-list.service.ts index c297457..c7ee3b2 100644 --- a/src/api/metadata/metadata-list.service.ts +++ b/src/api/metadata/metadata-list.service.ts @@ -174,27 +174,8 @@ export class MetadataListService { Record[], ] > { - // TODO (DRY): getLatestVersions and getLatestVersionIncludeUsed execute identical Prisma queries; extract the three findMany calls into a private fetchAllVersionedRecords() helper. - const [forms, planConfigs, priceConfigs] = await Promise.all([ - this.prisma.form.findMany({ - where: { - deletedAt: null, - }, - orderBy: [{ key: 'asc' }, { version: 'desc' }, { revision: 'desc' }], - }), - this.prisma.planConfig.findMany({ - where: { - deletedAt: null, - }, - orderBy: [{ key: 'asc' }, { version: 'desc' }, { revision: 'desc' }], - }), - this.prisma.priceConfig.findMany({ - where: { - deletedAt: null, - }, - orderBy: [{ key: 'asc' }, { version: 'desc' }, { revision: 'desc' }], - }), - ]); + const [forms, planConfigs, priceConfigs] = + await this.fetchAllVersionedRecords(); return [ this.pickLatestKeyVersions(forms), @@ -220,8 +201,24 @@ export class MetadataListService { Record[], ] > { - // TODO (DRY): getLatestVersions and getLatestVersionIncludeUsed execute identical Prisma queries; extract the three findMany calls into a private fetchAllVersionedRecords() helper. - const [forms, planConfigs, priceConfigs] = await Promise.all([ + const [forms, planConfigs, priceConfigs] = + await this.fetchAllVersionedRecords(); + + return [ + this.pickLatestAndUsedVersions(forms, usedVersions.form), + this.pickLatestAndUsedVersions(planConfigs, usedVersions.planConfig), + this.pickLatestAndUsedVersions(priceConfigs, usedVersions.priceConfig), + ]; + } + + private fetchAllVersionedRecords(): Promise< + [ + Array<{ key: string; version: bigint; revision: bigint }>, + Array<{ key: string; version: bigint; revision: bigint }>, + Array<{ key: string; version: bigint; revision: bigint }>, + ] + > { + return Promise.all([ this.prisma.form.findMany({ where: { deletedAt: null, @@ -241,12 +238,6 @@ export class MetadataListService { orderBy: [{ key: 'asc' }, { version: 'desc' }, { revision: 'desc' }], }), ]); - - return [ - this.pickLatestAndUsedVersions(forms, usedVersions.form), - this.pickLatestAndUsedVersions(planConfigs, usedVersions.planConfig), - this.pickLatestAndUsedVersions(priceConfigs, usedVersions.priceConfig), - ]; } /** diff --git a/src/api/metadata/plan-config/plan-config.service.ts b/src/api/metadata/plan-config/plan-config.service.ts index a72cd32..4645f89 100644 --- a/src/api/metadata/plan-config/plan-config.service.ts +++ b/src/api/metadata/plan-config/plan-config.service.ts @@ -12,6 +12,10 @@ import { PROJECT_METADATA_RESOURCE, publishMetadataEvent, } from '../utils/metadata-event.utils'; +import { + normalizeVersionedConfigKey, + pickLatestRevisionPerVersion, +} from '../utils/versioned-config.utils'; import { PlanConfigResponseDto } from './dto/plan-config-response.dto'; @Injectable() @@ -20,7 +24,6 @@ import { PlanConfigResponseDto } from './dto/plan-config-response.dto'; * by project templates. */ export class PlanConfigService { - // TODO (DRY): FormService, PlanConfigService, and PriceConfigService are structurally identical. Consider extracting a generic AbstractVersionedConfigService base class parameterized on the Prisma delegate and DTO mapper. constructor( private readonly prisma: PrismaService, private readonly prismaErrorService: PrismaErrorService, @@ -80,16 +83,7 @@ export class PlanConfigService { ); } - const latestByVersion = new Map(); - - for (const record of records) { - const versionKey = record.version.toString(); - if (!latestByVersion.has(versionKey)) { - latestByVersion.set(versionKey, record); - } - } - - return Array.from(latestByVersion.values()).map((record) => + return pickLatestRevisionPerVersion(records).map((record) => this.toDto(record), ); } @@ -139,9 +133,7 @@ export class PlanConfigService { version: bigint, ): Promise { const normalizedKey = this.normalizeKey(key); - - // TODO (DRY): variable named 'forms' should be 'planConfigs' — copy-paste error. - const forms = await this.prisma.planConfig.findMany({ + const planConfigs = await this.prisma.planConfig.findMany({ where: { key: normalizedKey, version, @@ -150,13 +142,13 @@ export class PlanConfigService { orderBy: [{ revision: 'desc' }], }); - if (forms.length === 0) { + if (planConfigs.length === 0) { throw new NotFoundException( `PlanConfig not found for key ${normalizedKey} version ${version.toString()}.`, ); } - return forms.map((planConfig) => this.toDto(planConfig)); + return planConfigs.map((planConfig) => this.toDto(planConfig)); } /** @@ -394,8 +386,7 @@ export class PlanConfigService { const normalizedKey = this.normalizeKey(key); try { - // TODO (DRY): variable named 'forms' should be 'planConfigs' — copy-paste error. - const forms = await this.prisma.planConfig.findMany({ + const planConfigs = await this.prisma.planConfig.findMany({ where: { key: normalizedKey, version, @@ -406,7 +397,7 @@ export class PlanConfigService { }, }); - if (forms.length === 0) { + if (planConfigs.length === 0) { throw new NotFoundException( `PlanConfig not found for key ${normalizedKey} version ${version.toString()}.`, ); @@ -426,7 +417,7 @@ export class PlanConfigService { }); await Promise.all( - forms.map((planConfig) => + planConfigs.map((planConfig) => publishMetadataEvent( this.eventBusService, 'PROJECT_METADATA_DELETE', @@ -536,13 +527,7 @@ export class PlanConfigService { * @throws {BadRequestException} If key is empty. */ private normalizeKey(key: string): string { - const normalized = String(key || '').trim(); - - if (!normalized) { - throw new BadRequestException('Metadata key is required.'); - } - - return normalized; + return normalizeVersionedConfigKey(key, 'Metadata'); } /** diff --git a/src/api/metadata/price-config/price-config.service.ts b/src/api/metadata/price-config/price-config.service.ts index 8a8a7a7..a4bacd0 100644 --- a/src/api/metadata/price-config/price-config.service.ts +++ b/src/api/metadata/price-config/price-config.service.ts @@ -12,6 +12,10 @@ import { PROJECT_METADATA_RESOURCE, publishMetadataEvent, } from '../utils/metadata-event.utils'; +import { + normalizeVersionedConfigKey, + pickLatestRevisionPerVersion, +} from '../utils/versioned-config.utils'; import { PriceConfigResponseDto } from './dto/price-config-response.dto'; @Injectable() @@ -22,7 +26,6 @@ import { PriceConfigResponseDto } from './dto/price-config-response.dto'; * delete is used for removal operations. */ export class PriceConfigService { - // TODO (DRY): FormService, PlanConfigService, and PriceConfigService are structurally identical. Consider extracting a generic AbstractVersionedConfigService base class parameterized on the Prisma delegate and DTO mapper. constructor( private readonly prisma: PrismaService, private readonly prismaErrorService: PrismaErrorService, @@ -82,16 +85,7 @@ export class PriceConfigService { ); } - const latestByVersion = new Map(); - - for (const record of records) { - const versionKey = record.version.toString(); - if (!latestByVersion.has(versionKey)) { - latestByVersion.set(versionKey, record); - } - } - - return Array.from(latestByVersion.values()).map((record) => + return pickLatestRevisionPerVersion(records).map((record) => this.toDto(record), ); } @@ -141,9 +135,7 @@ export class PriceConfigService { version: bigint, ): Promise { const normalizedKey = this.normalizeKey(key); - - // TODO (DRY): variable named 'forms' should be 'priceConfigs' — copy-paste error. - const forms = await this.prisma.priceConfig.findMany({ + const priceConfigs = await this.prisma.priceConfig.findMany({ where: { key: normalizedKey, version, @@ -152,13 +144,13 @@ export class PriceConfigService { orderBy: [{ revision: 'desc' }], }); - if (forms.length === 0) { + if (priceConfigs.length === 0) { throw new NotFoundException( `PriceConfig not found for key ${normalizedKey} version ${version.toString()}.`, ); } - return forms.map((priceConfig) => this.toDto(priceConfig)); + return priceConfigs.map((priceConfig) => this.toDto(priceConfig)); } /** @@ -396,8 +388,7 @@ export class PriceConfigService { const normalizedKey = this.normalizeKey(key); try { - // TODO (DRY): variable named 'forms' should be 'priceConfigs' — copy-paste error. - const forms = await this.prisma.priceConfig.findMany({ + const priceConfigs = await this.prisma.priceConfig.findMany({ where: { key: normalizedKey, version, @@ -408,7 +399,7 @@ export class PriceConfigService { }, }); - if (forms.length === 0) { + if (priceConfigs.length === 0) { throw new NotFoundException( `PriceConfig not found for key ${normalizedKey} version ${version.toString()}.`, ); @@ -428,7 +419,7 @@ export class PriceConfigService { }); await Promise.all( - forms.map((priceConfig) => + priceConfigs.map((priceConfig) => publishMetadataEvent( this.eventBusService, 'PROJECT_METADATA_DELETE', @@ -538,13 +529,7 @@ export class PriceConfigService { * @throws {BadRequestException} If key is empty. */ private normalizeKey(key: string): string { - const normalized = String(key || '').trim(); - - if (!normalized) { - throw new BadRequestException('Metadata key is required.'); - } - - return normalized; + return normalizeVersionedConfigKey(key, 'Metadata'); } /** diff --git a/src/api/metadata/product-template/product-template.service.ts b/src/api/metadata/product-template/product-template.service.ts index ddb3e01..fcb63f3 100644 --- a/src/api/metadata/product-template/product-template.service.ts +++ b/src/api/metadata/product-template/product-template.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, - HttpException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -18,6 +17,13 @@ import { parseBigIntParam, toSerializable, } from '../utils/metadata-utils'; +import { + getStoredReference as getStoredReferenceValue, + handleMetadataServiceError, + mergeJson as mergeJsonValue, + toNullableJson as toNullableJsonValue, + toRecord as toRecordValue, +} from '../utils/metadata-template.utils'; import { validateFormReference } from '../utils/metadata-validation.utils'; import { FormService } from '../form/form.service'; import { CreateProductTemplateDto } from './dto/create-product-template.dto'; @@ -415,7 +421,6 @@ export class ProductTemplateService { : null; } - // TODO (DRY): toRecord, mergeJson, toNullableJson, getStoredFormReference are duplicated in ProjectTemplateService. Move to a shared metadata-template.utils.ts file. /** * Converts optional values to a Prisma nullable JSON payload. */ @@ -426,15 +431,7 @@ export class ProductTemplateService { | null | undefined, ): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | undefined { - if (typeof value === 'undefined') { - return undefined; - } - - if (value === null) { - return Prisma.JsonNull; - } - - return value as Prisma.InputJsonValue; + return toNullableJsonValue(value); } /** @@ -443,14 +440,7 @@ export class ProductTemplateService { private getStoredFormReference( value: Prisma.JsonValue | null, ): MetadataVersionReference | null { - try { - return normalizeMetadataReference(value, 'form'); - } catch (error) { - if (error instanceof BadRequestException) { - return null; - } - throw error; - } + return getStoredReferenceValue(value, 'form'); } /** @@ -459,11 +449,7 @@ export class ProductTemplateService { private toRecord( value: Prisma.JsonValue | null, ): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null; - } - - return value as Record; + return toRecordValue(value); } /** @@ -473,11 +459,7 @@ export class ProductTemplateService { current: Prisma.JsonValue | null, next: Record, ): Record { - const currentRecord = this.toRecord(current); - return { - ...(currentRecord || {}), - ...next, - }; + return mergeJsonValue(current, next); } /** @@ -500,16 +482,15 @@ export class ProductTemplateService { return parseBigIntParam(templateId, 'templateId'); } - // TODO (DRY): handleError is duplicated across all metadata services. Consider a shared base class or utility. /** * Re-throws framework HTTP exceptions and delegates unexpected errors to * PrismaErrorService. */ private handleError(error: unknown, operation: string): never { - if (error instanceof HttpException) { - throw error; - } - - this.prismaErrorService.handleError(error, operation); + return handleMetadataServiceError( + error, + operation, + this.prismaErrorService, + ); } } diff --git a/src/api/metadata/project-template/project-template.service.ts b/src/api/metadata/project-template/project-template.service.ts index 6303dd7..97f507d 100644 --- a/src/api/metadata/project-template/project-template.service.ts +++ b/src/api/metadata/project-template/project-template.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, - HttpException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -18,6 +17,13 @@ import { parseBigIntParam, toSerializable, } from '../utils/metadata-utils'; +import { + getStoredReference as getStoredReferenceValue, + handleMetadataServiceError, + mergeJson as mergeJsonValue, + toNullableJson as toNullableJsonValue, + toRecord as toRecordValue, +} from '../utils/metadata-template.utils'; import { validateFormReference, validatePlanConfigReference, @@ -515,7 +521,6 @@ export class ProjectTemplateService { }; } - // TODO (DRY): resolveVersionedReference has three near-identical branches for form/planConfig/priceConfig. Extract a generic resolveReference(key, version, prismaDelegate) helper. /** * Resolves a stored metadata reference into the latest matching versioned * config record. @@ -534,59 +539,13 @@ export class ProjectTemplateService { return null; } - if (type === 'form') { - const latest = await this.prisma.form.findFirst({ - where: { - key: reference.key, - ...(reference.version > 0 - ? { version: BigInt(reference.version) } - : {}), - deletedAt: null, - }, - orderBy: [{ version: 'desc' }, { revision: 'desc' }], - }); - - return latest - ? { - id: latest.id.toString(), - key: latest.key, - version: latest.version.toString(), - revision: latest.revision.toString(), - config: toSerializable(latest.config || {}) as Record< - string, - unknown - >, - } - : null; - } + const delegates = { + form: this.prisma.form, + planConfig: this.prisma.planConfig, + priceConfig: this.prisma.priceConfig, + } as const; - if (type === 'planConfig') { - const latest = await this.prisma.planConfig.findFirst({ - where: { - key: reference.key, - ...(reference.version > 0 - ? { version: BigInt(reference.version) } - : {}), - deletedAt: null, - }, - orderBy: [{ version: 'desc' }, { revision: 'desc' }], - }); - - return latest - ? { - id: latest.id.toString(), - key: latest.key, - version: latest.version.toString(), - revision: latest.revision.toString(), - config: toSerializable(latest.config || {}) as Record< - string, - unknown - >, - } - : null; - } - - const latest = await this.prisma.priceConfig.findFirst({ + const latest = await delegates[type].findFirst({ where: { key: reference.key, ...(reference.version > 0 @@ -611,7 +570,6 @@ export class ProjectTemplateService { : null; } - // TODO (DRY): toRecord, mergeJson, toNullableJson, getStoredReference are duplicated in ProductTemplateService. Move to a shared metadata-template.utils.ts file. /** * Converts optional values to a Prisma nullable JSON payload. */ @@ -622,15 +580,7 @@ export class ProjectTemplateService { | null | undefined, ): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | undefined { - if (typeof value === 'undefined') { - return undefined; - } - - if (value === null) { - return Prisma.JsonNull; - } - - return value as Prisma.InputJsonValue; + return toNullableJsonValue(value); } /** @@ -640,14 +590,7 @@ export class ProjectTemplateService { value: Prisma.JsonValue | null, type: 'form' | 'planConfig' | 'priceConfig', ): MetadataVersionReference | null { - try { - return normalizeMetadataReference(value, type); - } catch (error) { - if (error instanceof BadRequestException) { - return null; - } - throw error; - } + return getStoredReferenceValue(value, type); } /** @@ -656,11 +599,7 @@ export class ProjectTemplateService { private toRecord( value: Prisma.JsonValue | null, ): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null; - } - - return value as Record; + return toRecordValue(value); } /** @@ -670,11 +609,7 @@ export class ProjectTemplateService { current: Prisma.JsonValue | null, next: Record, ): Record { - const currentRecord = this.toRecord(current); - return { - ...(currentRecord || {}), - ...next, - }; + return mergeJsonValue(current, next); } /** @@ -709,16 +644,15 @@ export class ProjectTemplateService { return parseBigIntParam(templateId, 'templateId'); } - // TODO (DRY): handleError is duplicated across all metadata services. Consider a shared base class or utility. /** * Re-throws framework HTTP exceptions and delegates unexpected errors to * PrismaErrorService. */ private handleError(error: unknown, operation: string): never { - if (error instanceof HttpException) { - throw error; - } - - this.prismaErrorService.handleError(error, operation); + return handleMetadataServiceError( + error, + operation, + this.prismaErrorService, + ); } } diff --git a/src/api/metadata/utils/metadata-template.utils.ts b/src/api/metadata/utils/metadata-template.utils.ts new file mode 100644 index 0000000..1063a54 --- /dev/null +++ b/src/api/metadata/utils/metadata-template.utils.ts @@ -0,0 +1,86 @@ +import { BadRequestException, HttpException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaErrorService } from 'src/shared/modules/global/prisma-error.service'; +import { + MetadataVersionReference, + normalizeMetadataReference, +} from './metadata-utils'; + +/** + * Converts optional values to Prisma nullable JSON input. + */ +export function toNullableJson( + value: Record | MetadataVersionReference | null | undefined, +): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | undefined { + if (typeof value === 'undefined') { + return undefined; + } + + if (value === null) { + return Prisma.JsonNull; + } + + return value as Prisma.InputJsonValue; +} + +/** + * Parses a stored metadata reference from JSON. + * + * Returns `null` when reference shape is missing/invalid. + */ +export function getStoredReference( + value: Prisma.JsonValue | null, + type: 'form' | 'planConfig' | 'priceConfig', +): MetadataVersionReference | null { + try { + return normalizeMetadataReference(value, type); + } catch (error) { + if (error instanceof BadRequestException) { + return null; + } + throw error; + } +} + +/** + * Converts JSON values to plain object maps when possible. + */ +export function toRecord( + value: Prisma.JsonValue | null, +): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + return value as Record; +} + +/** + * Merges incoming JSON fields over existing object values. + */ +export function mergeJson( + current: Prisma.JsonValue | null, + next: Record, +): Record { + const currentRecord = toRecord(current); + return { + ...(currentRecord || {}), + ...next, + }; +} + +/** + * Re-throws framework HTTP exceptions and delegates unknown errors to + * PrismaErrorService. + */ +export function handleMetadataServiceError( + error: unknown, + operation: string, + prismaErrorService: PrismaErrorService, +): never { + if (error instanceof HttpException) { + throw error; + } + + prismaErrorService.handleError(error, operation); +} diff --git a/src/api/metadata/utils/metadata-validation.utils.ts b/src/api/metadata/utils/metadata-validation.utils.ts index 684aee3..bca6f14 100644 --- a/src/api/metadata/utils/metadata-validation.utils.ts +++ b/src/api/metadata/utils/metadata-validation.utils.ts @@ -40,63 +40,83 @@ function normalizeResolvedReference( }; } -/** - * Validates a form reference and resolves version `0` to the latest version. - * - * @param formRef Raw form reference payload. - * @param prisma Prisma service used to validate existence. - * @returns Resolved metadata reference or `null` when omitted. - * @throws {BadRequestException} When the referenced form key/version is not - * found. - */ -export async function validateFormReference( - formRef: unknown, - prisma: PrismaService, +type VersionedReferenceDelegate = { + findFirst(args: { + where: { + key: string; + version?: bigint; + deletedAt: null; + }; + orderBy: Array<{ + version?: 'desc'; + revision: 'desc'; + }>; + select: { + version: true; + }; + }): Promise<{ + version: bigint; + } | null>; +}; + +async function validateVersionedReference( + rawReference: unknown, + fieldName: 'form' | 'planConfig' | 'priceConfig', + entityName: 'Form' | 'PlanConfig' | 'PriceConfig', + delegate: VersionedReferenceDelegate, ): Promise { - const reference = normalizeMetadataReference(formRef, 'form'); + const reference = normalizeMetadataReference(rawReference, fieldName); if (!reference) { return null; } - if (reference.version > 0) { - const found = await prisma.form.findFirst({ - where: { - key: reference.key, - version: toVersionBigInt(reference.version), - deletedAt: null, - }, - orderBy: [{ revision: 'desc' }], - select: { - version: true, - }, - }); - - if (!found) { - throw new BadRequestException( - `Form not found for key ${reference.key} version ${reference.version}.`, - ); - } - - return normalizeResolvedReference(reference, found.version); - } - - const latest = await prisma.form.findFirst({ + const found = await delegate.findFirst({ where: { key: reference.key, + ...(reference.version > 0 + ? { + version: toVersionBigInt(reference.version), + } + : {}), deletedAt: null, }, - orderBy: [{ version: 'desc' }, { revision: 'desc' }], + orderBy: + reference.version > 0 + ? [{ revision: 'desc' }] + : [{ version: 'desc' }, { revision: 'desc' }], select: { version: true, }, }); - if (!latest) { - throw new BadRequestException(`Form not found for key ${reference.key}.`); + if (!found) { + if (reference.version > 0) { + throw new BadRequestException( + `${entityName} not found for key ${reference.key} version ${reference.version}.`, + ); + } + + throw new BadRequestException(`${entityName} not found for key ${reference.key}.`); } - return normalizeResolvedReference(reference, latest.version); + return normalizeResolvedReference(reference, found.version); +} + +/** + * Validates a form reference and resolves version `0` to the latest version. + * + * @param formRef Raw form reference payload. + * @param prisma Prisma service used to validate existence. + * @returns Resolved metadata reference or `null` when omitted. + * @throws {BadRequestException} When the referenced form key/version is not + * found. + */ +export async function validateFormReference( + formRef: unknown, + prisma: PrismaService, +): Promise { + return validateVersionedReference(formRef, 'form', 'Form', prisma.form); } /** @@ -113,55 +133,13 @@ export async function validatePlanConfigReference( planConfigRef: unknown, prisma: PrismaService, ): Promise { - const reference = normalizeMetadataReference(planConfigRef, 'planConfig'); - - if (!reference) { - return null; - } - - if (reference.version > 0) { - const found = await prisma.planConfig.findFirst({ - where: { - key: reference.key, - version: toVersionBigInt(reference.version), - deletedAt: null, - }, - orderBy: [{ revision: 'desc' }], - select: { - version: true, - }, - }); - - if (!found) { - throw new BadRequestException( - `PlanConfig not found for key ${reference.key} version ${reference.version}.`, - ); - } - - return normalizeResolvedReference(reference, found.version); - } - - const latest = await prisma.planConfig.findFirst({ - where: { - key: reference.key, - deletedAt: null, - }, - orderBy: [{ version: 'desc' }, { revision: 'desc' }], - select: { - version: true, - }, - }); - - if (!latest) { - throw new BadRequestException( - `PlanConfig not found for key ${reference.key}.`, - ); - } - - return normalizeResolvedReference(reference, latest.version); + return validateVersionedReference( + planConfigRef, + 'planConfig', + 'PlanConfig', + prisma.planConfig, + ); } - -// TODO (DRY): validateFormReference, validatePlanConfigReference, and validatePriceConfigReference are identical except for the Prisma model used. Extract a generic validateVersionedReference(ref, prismaDelegate, entityName) helper. /** * Validates a priceConfig reference and resolves version `0` to the latest * version. @@ -176,50 +154,10 @@ export async function validatePriceConfigReference( priceConfigRef: unknown, prisma: PrismaService, ): Promise { - const reference = normalizeMetadataReference(priceConfigRef, 'priceConfig'); - - if (!reference) { - return null; - } - - if (reference.version > 0) { - const found = await prisma.priceConfig.findFirst({ - where: { - key: reference.key, - version: toVersionBigInt(reference.version), - deletedAt: null, - }, - orderBy: [{ revision: 'desc' }], - select: { - version: true, - }, - }); - - if (!found) { - throw new BadRequestException( - `PriceConfig not found for key ${reference.key} version ${reference.version}.`, - ); - } - - return normalizeResolvedReference(reference, found.version); - } - - const latest = await prisma.priceConfig.findFirst({ - where: { - key: reference.key, - deletedAt: null, - }, - orderBy: [{ version: 'desc' }, { revision: 'desc' }], - select: { - version: true, - }, - }); - - if (!latest) { - throw new BadRequestException( - `PriceConfig not found for key ${reference.key}.`, - ); - } - - return normalizeResolvedReference(reference, latest.version); + return validateVersionedReference( + priceConfigRef, + 'priceConfig', + 'PriceConfig', + prisma.priceConfig, + ); } diff --git a/src/api/metadata/utils/versioned-config.utils.ts b/src/api/metadata/utils/versioned-config.utils.ts new file mode 100644 index 0000000..d3a6459 --- /dev/null +++ b/src/api/metadata/utils/versioned-config.utils.ts @@ -0,0 +1,35 @@ +import { BadRequestException } from '@nestjs/common'; + +/** + * Validates and normalizes a versioned metadata key. + */ +export function normalizeVersionedConfigKey( + key: string, + entityName: string, +): string { + const normalized = String(key || '').trim(); + + if (!normalized) { + throw new BadRequestException(`${entityName} key is required.`); + } + + return normalized; +} + +/** + * Returns one record per version (latest revision first). + */ +export function pickLatestRevisionPerVersion( + records: T[], +): T[] { + const latestByVersion = new Map(); + + for (const record of records) { + const versionKey = record.version.toString(); + if (!latestByVersion.has(versionKey)) { + latestByVersion.set(versionKey, record); + } + } + + return Array.from(latestByVersion.values()); +} diff --git a/src/api/phase-product/dto/create-phase-product.dto.ts b/src/api/phase-product/dto/create-phase-product.dto.ts index 63fbb06..0d7287c 100644 --- a/src/api/phase-product/dto/create-phase-product.dto.ts +++ b/src/api/phase-product/dto/create-phase-product.dto.ts @@ -8,31 +8,10 @@ import { IsString, Min, } from 'class-validator'; - -function parseOptionalNumber(value: unknown): number | undefined { - if (typeof value === 'undefined' || value === null || value === '') { - return undefined; - } - - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return undefined; - } - - return parsed; -} -// TODO [DRY]: Duplicated in `create-phase.dto.ts` and `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. - -function parseOptionalInteger(value: unknown): number | undefined { - const parsed = parseOptionalNumber(value); - - if (typeof parsed === 'undefined') { - return undefined; - } - - return Math.trunc(parsed); -} -// TODO [DRY]: Duplicated in `create-phase.dto.ts` and `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +import { + parseOptionalInteger, + parseOptionalNumber, +} from 'src/shared/utils/dto-transform.utils'; /** * Create payload for phase product/work-item endpoints: diff --git a/src/api/phase-product/phase-product.service.ts b/src/api/phase-product/phase-product.service.ts index 0b83cb9..b5a67e1 100644 --- a/src/api/phase-product/phase-product.service.ts +++ b/src/api/phase-product/phase-product.service.ts @@ -9,23 +9,20 @@ import { CreatePhaseProductDto } from 'src/api/phase-product/dto/create-phase-pr import { UpdatePhaseProductDto } from 'src/api/phase-product/dto/update-phase-product.dto'; import { Permission } from 'src/shared/constants/permissions'; import { APP_CONFIG } from 'src/shared/config/app.config'; +import { ProjectPermissionContext } from 'src/shared/interfaces/project-permission-context.interface'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { PermissionService } from 'src/shared/services/permission.service'; +import { + ensureProjectNamedPermission, + getAuditUserIdOrDefault, + loadProjectPermissionContext, + parseBigIntId, + toDetailsObject as toDetailsObjectValue, + toJsonInput as toJsonInputValue, +} from 'src/shared/utils/service.utils'; import { PhaseProductResponseDto } from './dto/phase-product-response.dto'; -// TODO [DRY]: Move to `src/shared/interfaces/project-permission-context.interface.ts`. -interface ProjectPermissionContext { - id: bigint; - directProjectId: bigint | null; - billingAccountId: bigint | null; - members: Array<{ - userId: bigint; - role: string; - deletedAt: Date | null; - }>; -} - @Injectable() /** * Business logic for phase products. Enforces a per-phase product count limit @@ -419,39 +416,10 @@ export class PhaseProductService { * @returns Project permission context. * @throws {NotFoundException} When the project does not exist. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private async getProjectPermissionContext( projectId: bigint, ): Promise { - const project = await this.prisma.project.findFirst({ - where: { - id: projectId, - deletedAt: null, - }, - select: { - id: true, - directProjectId: true, - billingAccountId: true, - members: { - where: { - deletedAt: null, - }, - select: { - userId: true, - role: true, - deletedAt: true, - }, - }, - }, - }); - - if (!project) { - throw new NotFoundException( - `Project with id ${projectId} was not found.`, - ); - } - - return project; + return loadProjectPermissionContext(this.prisma, projectId); } /** @@ -463,7 +431,6 @@ export class PhaseProductService { * @returns Nothing. * @throws {ForbiddenException} When permission is missing. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private ensureNamedPermission( permission: Permission, user: JwtUser, @@ -473,15 +440,12 @@ export class PhaseProductService { deletedAt: Date | null; }>, ): void { - const hasPermission = this.permissionService.hasNamedPermission( + ensureProjectNamedPermission( + this.permissionService, permission, user, projectMembers, ); - - if (!hasPermission) { - throw new ForbiddenException('Insufficient permissions'); - } } /** @@ -520,13 +484,8 @@ export class PhaseProductService { * @param value - Candidate JSON value. * @returns Object details payload. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private toDetailsObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {}; - } - - return value as Record; + return toDetailsObjectValue(value); } /** @@ -535,19 +494,10 @@ export class PhaseProductService { * @param value - Candidate JSON value. * @returns Prisma JSON value, JsonNull, or undefined. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private toJsonInput( value: unknown, ): Prisma.InputJsonValue | Prisma.JsonNullValueInput | undefined { - if (typeof value === 'undefined') { - return undefined; - } - - if (value === null) { - return Prisma.JsonNull; - } - - return value as Prisma.InputJsonValue; + return toJsonInputValue(value); } /** @@ -558,13 +508,8 @@ export class PhaseProductService { * @returns Parsed id. * @throws {BadRequestException} When parsing fails. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private parseId(value: string, entityName: string): bigint { - try { - return BigInt(value); - } catch { - throw new BadRequestException(`${entityName} id is invalid.`); - } + return parseBigIntId(value, entityName); } /** @@ -574,14 +519,7 @@ export class PhaseProductService { * @returns Numeric audit user id. */ // TODO [SECURITY]: Returning `-1` silently when `user.userId` is invalid can corrupt audit trails; throw `UnauthorizedException` instead. - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private getAuditUserId(user: JwtUser): number { - const userId = Number.parseInt(String(user.userId || ''), 10); - - if (Number.isNaN(userId)) { - return -1; - } - - return userId; + return getAuditUserIdOrDefault(user, -1); } } diff --git a/src/api/phase-product/workitem.controller.ts b/src/api/phase-product/workitem.controller.ts index 26dc603..2b079f9 100644 --- a/src/api/phase-product/workitem.controller.ts +++ b/src/api/phase-product/workitem.controller.ts @@ -19,11 +19,11 @@ import { } from '@nestjs/swagger'; import { WorkStreamService } from 'src/api/workstream/workstream.service'; import { Permission } from 'src/shared/constants/permissions'; +import { WORK_LAYER_ALLOWED_ROLES } from 'src/shared/constants/roles'; import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; import { RequirePermission } from 'src/shared/decorators/requirePermission.decorator'; import { Scopes } from 'src/shared/decorators/scopes.decorator'; import { Scope } from 'src/shared/enums/scopes.enum'; -import { UserRole } from 'src/shared/enums/userRole.enum'; import { PermissionGuard } from 'src/shared/guards/permission.guard'; import { Roles } from 'src/shared/guards/tokenRoles.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; @@ -32,17 +32,6 @@ import { PhaseProductResponseDto } from './dto/phase-product-response.dto'; import { UpdatePhaseProductDto } from './dto/update-phase-product.dto'; import { PhaseProductService } from './phase-product.service'; -const WORKITEM_ALLOWED_ROLES = [ - UserRole.TOPCODER_ADMIN, - UserRole.CONNECT_ADMIN, - UserRole.TG_ADMIN, - UserRole.MANAGER, - UserRole.COPILOT, - UserRole.TC_COPILOT, - UserRole.COPILOT_MANAGER, -]; -// TODO [DRY]: Extract a single `WORK_LAYER_ALLOWED_ROLES` constant to `src/shared/constants/roles.ts`. - @ApiTags('WorkItem') @ApiBearerAuth() @Controller( @@ -73,7 +62,7 @@ export class WorkItemController { */ @Get() @UseGuards(PermissionGuard) - @Roles(...WORKITEM_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -128,7 +117,7 @@ export class WorkItemController { */ @Get(':id') @UseGuards(PermissionGuard) - @Roles(...WORKITEM_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -190,7 +179,7 @@ export class WorkItemController { */ @Post() @UseGuards(PermissionGuard) - @Roles(...WORKITEM_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORKITEM_CREATE) @ApiOperation({ @@ -248,7 +237,7 @@ export class WorkItemController { */ @Patch(':id') @UseGuards(PermissionGuard) - @Roles(...WORKITEM_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORKITEM_EDIT) @ApiOperation({ @@ -309,7 +298,7 @@ export class WorkItemController { @Delete(':id') @HttpCode(204) @UseGuards(PermissionGuard) - @Roles(...WORKITEM_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORKITEM_DELETE) @ApiOperation({ diff --git a/src/api/project-attachment/dto/create-attachment.dto.ts b/src/api/project-attachment/dto/create-attachment.dto.ts index 58eb31b..0c0d239 100644 --- a/src/api/project-attachment/dto/create-attachment.dto.ts +++ b/src/api/project-attachment/dto/create-attachment.dto.ts @@ -11,31 +11,10 @@ import { IsString, ValidateIf, } from 'class-validator'; - -function parseOptionalInteger(value: unknown): number | undefined { - if (typeof value === 'undefined' || value === null || value === '') { - return undefined; - } - - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return undefined; - } - - return Math.trunc(parsed); -} -// TODO [DRY]: Duplicated in `update-attachment.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. - -function parseAllowedUsers(value: unknown): number[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - - return value - .map((entry) => parseOptionalInteger(entry)) - .filter((entry): entry is number => typeof entry === 'number'); -} -// TODO [DRY]: Duplicated in `update-attachment.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +import { + parseOptionalInteger, + parseOptionalIntegerArray, +} from 'src/shared/utils/dto-transform.utils'; /** * Create payload for project attachment endpoints: @@ -106,7 +85,7 @@ export class CreateAttachmentDto { @ApiPropertyOptional({ type: [Number] }) @IsOptional() @IsArray() - @Transform(({ value }) => parseAllowedUsers(value)) + @Transform(({ value }) => parseOptionalIntegerArray(value)) @IsInt({ each: true }) allowedUsers?: number[]; } diff --git a/src/api/project-attachment/dto/update-attachment.dto.ts b/src/api/project-attachment/dto/update-attachment.dto.ts index 148c66e..7032915 100644 --- a/src/api/project-attachment/dto/update-attachment.dto.ts +++ b/src/api/project-attachment/dto/update-attachment.dto.ts @@ -1,31 +1,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsArray, IsInt, IsOptional, IsString } from 'class-validator'; - -function parseOptionalInteger(value: unknown): number | undefined { - if (typeof value === 'undefined' || value === null || value === '') { - return undefined; - } - - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return undefined; - } - - return Math.trunc(parsed); -} -// TODO [DRY]: Duplicated in `create-attachment.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. - -function parseAllowedUsers(value: unknown): number[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - - return value - .map((entry) => parseOptionalInteger(entry)) - .filter((entry): entry is number => typeof entry === 'number'); -} -// TODO [DRY]: Duplicated in `create-attachment.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +import { parseOptionalIntegerArray } from 'src/shared/utils/dto-transform.utils'; /** * Update payload for `PATCH /projects/:projectId/attachments/:id`. @@ -46,7 +22,7 @@ export class UpdateAttachmentDto { @ApiPropertyOptional({ type: [Number] }) @IsOptional() @IsArray() - @Transform(({ value }) => parseAllowedUsers(value)) + @Transform(({ value }) => parseOptionalIntegerArray(value)) @IsInt({ each: true }) allowedUsers?: number[]; diff --git a/src/api/project-attachment/project-attachment.service.ts b/src/api/project-attachment/project-attachment.service.ts index b4be629..b60d578 100644 --- a/src/api/project-attachment/project-attachment.service.ts +++ b/src/api/project-attachment/project-attachment.service.ts @@ -10,25 +10,22 @@ import { CreateAttachmentDto } from 'src/api/project-attachment/dto/create-attac import { UpdateAttachmentDto } from 'src/api/project-attachment/dto/update-attachment.dto'; import { Permission } from 'src/shared/constants/permissions'; import { APP_CONFIG } from 'src/shared/config/app.config'; +import { ProjectPermissionContextBase } from 'src/shared/interfaces/project-permission-context.interface'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { LoggerService } from 'src/shared/modules/global/logger.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { FileService } from 'src/shared/services/file.service'; import { MemberService } from 'src/shared/services/member.service'; import { PermissionService } from 'src/shared/services/permission.service'; -import { hasAdminRole } from 'src/shared/utils/permission.utils'; +import { + ensureProjectNamedPermission, + getAuditUserIdOrDefault, + isAdminProjectUser, + loadProjectPermissionContextBase, + parseBigIntId, +} from 'src/shared/utils/service.utils'; import { AttachmentResponseDto } from './dto/attachment-response.dto'; -// TODO [DRY]: Move to `src/shared/interfaces/project-permission-context.interface.ts`. -interface ProjectPermissionContext { - id: bigint; - members: Array<{ - userId: bigint; - role: string; - deletedAt: Date | null; - }>; -} - @Injectable() /** * Business logic for project attachments. Handles two attachment types: `link` @@ -444,37 +441,10 @@ export class ProjectAttachmentService { * @returns Permission context. * @throws {NotFoundException} When project does not exist. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private async getProjectPermissionContext( projectId: bigint, - ): Promise { - const project = await this.prisma.project.findFirst({ - where: { - id: projectId, - deletedAt: null, - }, - select: { - id: true, - members: { - where: { - deletedAt: null, - }, - select: { - userId: true, - role: true, - deletedAt: true, - }, - }, - }, - }); - - if (!project) { - throw new NotFoundException( - `Project with id ${projectId} was not found.`, - ); - } - - return project; + ): Promise { + return loadProjectPermissionContextBase(this.prisma, projectId); } /** @@ -532,7 +502,6 @@ export class ProjectAttachmentService { * @returns Nothing. * @throws {ForbiddenException} When permission is missing. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private ensureNamedPermission( permission: Permission, user: JwtUser, @@ -542,15 +511,12 @@ export class ProjectAttachmentService { deletedAt: Date | null; }>, ): void { - const hasPermission = this.permissionService.hasNamedPermission( + ensureProjectNamedPermission( + this.permissionService, permission, user, projectMembers, ); - - if (!hasPermission) { - throw new ForbiddenException('Insufficient permissions'); - } } /** @@ -560,7 +526,6 @@ export class ProjectAttachmentService { * @param projectMembers - Active project members. * @returns `true` when caller has admin-level access. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private isAdminUser( user: JwtUser, projectMembers: Array<{ @@ -569,14 +534,7 @@ export class ProjectAttachmentService { deletedAt: Date | null; }>, ): boolean { - return ( - hasAdminRole(user) || - this.permissionService.hasNamedPermission( - Permission.READ_PROJECT_ANY, - user, - projectMembers, - ) - ); + return isAdminProjectUser(this.permissionService, user, projectMembers); } /** @@ -727,13 +685,8 @@ export class ProjectAttachmentService { * @returns Parsed bigint id. * @throws {BadRequestException} When parsing fails. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private parseId(value: string, entityName: string): bigint { - try { - return BigInt(value); - } catch { - throw new BadRequestException(`${entityName} id is invalid.`); - } + return parseBigIntId(value, entityName); } /** @@ -743,14 +696,7 @@ export class ProjectAttachmentService { * @returns Numeric user id. */ // TODO [SECURITY]: Returning `-1` silently when `user.userId` is invalid can corrupt audit trails; throw `UnauthorizedException` instead. - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private getAuditUserId(user: JwtUser): number { - const userId = Number.parseInt(String(user.userId || ''), 10); - - if (Number.isNaN(userId)) { - return -1; - } - - return userId; + return getAuditUserIdOrDefault(user, -1); } } diff --git a/src/api/project-invite/project-invite.service.ts b/src/api/project-invite/project-invite.service.ts index acbe7b9..60c9ac8 100644 --- a/src/api/project-invite/project-invite.service.ts +++ b/src/api/project-invite/project-invite.service.ts @@ -39,7 +39,16 @@ import { enrichInvitesWithUserDetails, validateUserHasProjectRole, } from 'src/shared/utils/member.utils'; -import { publishMemberEvent } from 'src/shared/utils/event.utils'; +import { publishMemberEventSafely } from 'src/shared/utils/event.utils'; +import { normalizeEntity as normalizePrismaEntity } from 'src/shared/utils/entity.utils'; +import { + ensureRoleScopedPermission, + getActorUserId as getActorUserIdFromJwt, + getAuditUserId as getAuditUserIdFromJwt, + parseCsvFields, + parseNumericStringId, + parseOptionalNumericStringId, +} from 'src/shared/utils/service.utils'; interface InviteTargetByUser { userId: bigint; @@ -739,19 +748,15 @@ export class ProjectInviteService { ); const lowerCaseHandles = dto.handles.map((handle) => handle.toLowerCase()); - // TODO: DRY: `(foundUser as any).handleLower` and `(user as any).userId` - // rely on unsafe casts. Type `MemberDetail` to include these fields. const filteredUsers = foundUsers.filter((foundUser) => lowerCaseHandles.includes( - String( - (foundUser as any).handleLower || foundUser.handle || '', - ).toLowerCase(), + String(foundUser.handleLower || foundUser.handle || '').toLowerCase(), ), ); const existingHandles = new Set( filteredUsers.map((user) => - String((user as any).handleLower || user.handle || '').toLowerCase(), + String(user.handleLower || user.handle || '').toLowerCase(), ), ); @@ -770,7 +775,7 @@ export class ProjectInviteService { const targets: InviteTargetByUser[] = []; for (const user of filteredUsers) { - const userId = this.parseOptionalId((user as any).userId); + const userId = this.parseOptionalId(user.userId); if (!userId) { continue; } @@ -1228,52 +1233,34 @@ export class ProjectInviteService { * @returns Nothing. * @throws {ForbiddenException} If required delete permission is missing. */ - // TODO: SECURITY: `projectMembers` is typed as `any[]`, which bypasses type - // safety and can hide invalid data shape issues. Use `ProjectMember[]`. private ensureDeleteInvitePermission( role: ProjectMemberRole, user: JwtUser, - projectMembers: any[], + projectMembers: ProjectMember[], ): void { - if ( - role !== ProjectMemberRole.customer && - role !== ProjectMemberRole.copilot && - !this.permissionService.hasNamedPermission( - Permission.DELETE_PROJECT_INVITE_NOT_OWN_TOPCODER, - user, - projectMembers, - ) - ) { - throw new ForbiddenException( - "You don't have permissions to cancel invites to Topcoder Team for other users.", - ); - } - - if ( - role === ProjectMemberRole.customer && - !this.permissionService.hasNamedPermission( - Permission.DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER, - user, - projectMembers, - ) - ) { - throw new ForbiddenException( - "You don't have permissions to cancel invites to Customer Team for other users.", - ); - } - - if ( - role === ProjectMemberRole.copilot && - !this.permissionService.hasNamedPermission( - Permission.DELETE_PROJECT_INVITE_NOT_OWN_COPILOT, - user, - projectMembers, - ) - ) { - throw new ForbiddenException( - "You don't have permissions to cancel invites to Copilot Team for other users.", - ); - } + ensureRoleScopedPermission( + this.permissionService, + role, + user, + projectMembers, + { + permission: Permission.DELETE_PROJECT_INVITE_NOT_OWN_TOPCODER, + message: + "You don't have permissions to cancel invites to Topcoder Team for other users.", + }, + { + [ProjectMemberRole.customer]: { + permission: Permission.DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER, + message: + "You don't have permissions to cancel invites to Customer Team for other users.", + }, + [ProjectMemberRole.copilot]: { + permission: Permission.DELETE_PROJECT_INVITE_NOT_OWN_COPILOT, + message: + "You don't have permissions to cancel invites to Copilot Team for other users.", + }, + }, + ); } /** @@ -1382,15 +1369,8 @@ export class ProjectInviteService { * @returns Parsed bigint id. * @throws {BadRequestException} If value is not numeric. */ - // TODO: DRY: `parseId` is duplicated across project member, invite, and - // setting services; consolidate into shared helper/base service. private parseId(value: string, label: string): bigint { - const normalized = String(value || '').trim(); - if (!/^\d+$/.test(normalized)) { - throw new BadRequestException(`${label} id must be a numeric string.`); - } - - return BigInt(normalized); + return parseNumericStringId(value, `${label} id`); } /** @@ -1400,21 +1380,10 @@ export class ProjectInviteService { * @returns Parsed bigint or `null` when missing/invalid. * @throws {BadRequestException} If parsing fails unexpectedly. */ - // TODO: DRY: `parseOptionalId` follows patterns duplicated in other services; - // consolidate into shared helper/base service. private parseOptionalId( value: string | number | bigint | null | undefined, ): bigint | null { - if (value === null || typeof value === 'undefined') { - return null; - } - - const normalized = String(value).trim(); - if (!/^\d+$/.test(normalized)) { - return null; - } - - return BigInt(normalized); + return parseOptionalNumericStringId(value); } /** @@ -1424,14 +1393,8 @@ export class ProjectInviteService { * @returns Trimmed caller id. * @throws {ForbiddenException} If caller id is missing. */ - // TODO: DRY: `getActorUserId` is duplicated across project member, invite, - // and setting services; centralize. private getActorUserId(user: JwtUser): string { - if (!user?.userId || String(user.userId).trim().length === 0) { - throw new ForbiddenException('Authenticated user id is missing.'); - } - - return String(user.userId).trim(); + return getActorUserIdFromJwt(user); } /** @@ -1441,16 +1404,8 @@ export class ProjectInviteService { * @returns Numeric audit user id. * @throws {ForbiddenException} If caller id is missing or non-numeric. */ - // TODO: DRY: `getAuditUserId` is duplicated across project member, invite, - // and setting services; centralize. private getAuditUserId(user: JwtUser): number { - const parsedUserId = Number.parseInt(this.getActorUserId(user), 10); - - if (Number.isNaN(parsedUserId)) { - throw new ForbiddenException('Authenticated user id must be numeric.'); - } - - return parsedUserId; + return getAuditUserIdFromJwt(user); } /** @@ -1460,16 +1415,8 @@ export class ProjectInviteService { * @returns Normalized field names. * @throws {BadRequestException} If field parsing fails. */ - // TODO: DRY: `parseFields` is duplicated across services; centralize. private parseFields(fields?: string): string[] { - if (!fields || fields.trim().length === 0) { - return []; - } - - return fields - .split(',') - .map((field) => field.trim()) - .filter((field) => field.length > 0); + return parseCsvFields(fields); } /** @@ -1518,39 +1465,8 @@ export class ProjectInviteService { * @returns Payload with bigint and decimal values normalized. * @throws {TypeError} If recursive traversal fails. */ - // TODO: DRY: `normalizeEntity` is identical to member service implementation; - // extract to shared utility (`src/shared/utils/entity.utils.ts`). private normalizeEntity(payload: T): T { - const walk = (input: unknown): unknown => { - if (typeof input === 'bigint') { - return input.toString(); - } - - if (input instanceof Prisma.Decimal) { - return Number(input.toString()); - } - - if (Array.isArray(input)) { - return input.map((entry) => walk(entry)); - } - - if (input && typeof input === 'object') { - if (input instanceof Date) { - return input; - } - - const output: Record = {}; - for (const [key, value] of Object.entries(input)) { - output[key] = walk(value); - } - - return output; - } - - return input; - }; - - return walk(payload) as T; + return normalizePrismaEntity(payload); } /** @@ -1561,14 +1477,7 @@ export class ProjectInviteService { * @returns Nothing. * @throws {Error} Publisher errors are caught and logged. */ - // TODO: DRY: `publishMember` is near-identical to member service - // `publishEvent`; consolidate into shared helper. private publishMember(topic: string, payload: unknown): void { - void publishMemberEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish member event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); + publishMemberEventSafely(topic, payload, this.logger); } } diff --git a/src/api/project-member/project-member.service.ts b/src/api/project-member/project-member.service.ts index f3da901..110cee3 100644 --- a/src/api/project-member/project-member.service.ts +++ b/src/api/project-member/project-member.service.ts @@ -32,7 +32,15 @@ import { getDefaultProjectRole, validateUserHasProjectRole, } from 'src/shared/utils/member.utils'; -import { publishMemberEvent } from 'src/shared/utils/event.utils'; +import { publishMemberEventSafely } from 'src/shared/utils/event.utils'; +import { normalizeEntity as normalizePrismaEntity } from 'src/shared/utils/entity.utils'; +import { + getActorUserId as getActorUserIdFromJwt, + getAuditUserId as getAuditUserIdFromJwt, + ensureRoleScopedPermission, + parseCsvFields, + parseNumericStringId, +} from 'src/shared/utils/service.utils'; /** * Manages the project member lifecycle: add, update, delete, list, and get. @@ -579,53 +587,34 @@ export class ProjectMemberService { * @returns Nothing. * @throws {ForbiddenException} If role-specific delete permission is missing. */ - // TODO: DRY: This role-branch permission check mirrors - // `ensureDeleteInvitePermission`; extract shared - // `assertRoleDeletePermission(role, user, members, permMap)` helper. private ensureDeletePermission( role: ProjectMemberRole, user: JwtUser, projectMembers: ProjectMember[], ): void { - if ( - role !== ProjectMemberRole.customer && - role !== ProjectMemberRole.copilot && - !this.permissionService.hasNamedPermission( - Permission.DELETE_PROJECT_MEMBER_TOPCODER, - user, - projectMembers, - ) - ) { - throw new ForbiddenException( - "You don't have permissions to delete other members from Topcoder Team.", - ); - } - - if ( - role === ProjectMemberRole.customer && - !this.permissionService.hasNamedPermission( - Permission.DELETE_PROJECT_MEMBER_CUSTOMER, - user, - projectMembers, - ) - ) { - throw new ForbiddenException( - 'You don\'t have permissions to delete other members with "customer" role.', - ); - } - - if ( - role === ProjectMemberRole.copilot && - !this.permissionService.hasNamedPermission( - Permission.DELETE_PROJECT_MEMBER_COPILOT, - user, - projectMembers, - ) - ) { - throw new ForbiddenException( - 'You don\'t have permissions to delete other members with "copilot" role.', - ); - } + ensureRoleScopedPermission( + this.permissionService, + role, + user, + projectMembers, + { + permission: Permission.DELETE_PROJECT_MEMBER_TOPCODER, + message: + "You don't have permissions to delete other members from Topcoder Team.", + }, + { + [ProjectMemberRole.customer]: { + permission: Permission.DELETE_PROJECT_MEMBER_CUSTOMER, + message: + 'You don\'t have permissions to delete other members with "customer" role.', + }, + [ProjectMemberRole.copilot]: { + permission: Permission.DELETE_PROJECT_MEMBER_COPILOT, + message: + 'You don\'t have permissions to delete other members with "copilot" role.', + }, + }, + ); } /** @@ -827,15 +816,8 @@ export class ProjectMemberService { * @returns Parsed bigint id. * @throws {BadRequestException} If the id is not numeric. */ - // TODO: DRY: `parseId` is duplicated across project member, invite, and - // setting flows; extract a shared service helper. private parseId(value: string, label: string): bigint { - const normalized = String(value || '').trim(); - if (!/^\d+$/.test(normalized)) { - throw new BadRequestException(`${label} id must be a numeric string.`); - } - - return BigInt(normalized); + return parseNumericStringId(value, `${label} id`); } /** @@ -845,14 +827,8 @@ export class ProjectMemberService { * @returns Normalized user id string. * @throws {ForbiddenException} If the user id is missing. */ - // TODO: DRY: `getActorUserId` is duplicated across project member, invite, - // and setting flows; extract a shared service helper. private getActorUserId(user: JwtUser): string { - if (!user?.userId || String(user.userId).trim().length === 0) { - throw new ForbiddenException('Authenticated user id is missing.'); - } - - return String(user.userId).trim(); + return getActorUserIdFromJwt(user); } /** @@ -862,16 +838,8 @@ export class ProjectMemberService { * @returns Numeric user id used for audit columns. * @throws {ForbiddenException} If the user id is missing or non-numeric. */ - // TODO: DRY: `getAuditUserId` is duplicated across project member, invite, - // and setting flows; extract a shared service helper. private getAuditUserId(user: JwtUser): number { - const parsedUserId = Number.parseInt(this.getActorUserId(user), 10); - - if (Number.isNaN(parsedUserId)) { - throw new ForbiddenException('Authenticated user id must be numeric.'); - } - - return parsedUserId; + return getAuditUserIdFromJwt(user); } /** @@ -881,17 +849,8 @@ export class ProjectMemberService { * @returns Normalized list of field names. * @throws {BadRequestException} When field input fails validation. */ - // TODO: DRY: `parseFields` is duplicated across project member, invite, and - // setting flows; extract a shared service helper. private parseFields(fields?: string): string[] { - if (!fields || fields.trim().length === 0) { - return []; - } - - return fields - .split(',') - .map((field) => field.trim()) - .filter((field) => field.length > 0); + return parseCsvFields(fields); } /** @@ -901,38 +860,8 @@ export class ProjectMemberService { * @returns Payload with bigint/decimal values normalized. * @throws {TypeError} If recursive traversal encounters unsupported values. */ - // TODO: DRY: `normalizeEntity` is identical to the implementation in - // `project-invite.service.ts`; extract to `src/shared/utils/entity.utils.ts`. private normalizeEntity(payload: T): T { - const walk = (input: unknown): unknown => { - if (typeof input === 'bigint') { - return input.toString(); - } - - if (input instanceof Prisma.Decimal) { - return Number(input.toString()); - } - - if (Array.isArray(input)) { - return input.map((entry) => walk(entry)); - } - - if (input && typeof input === 'object') { - if (input instanceof Date) { - return input; - } - - const output: Record = {}; - for (const [key, value] of Object.entries(input)) { - output[key] = walk(value); - } - return output; - } - - return input; - }; - - return walk(payload) as T; + return normalizePrismaEntity(payload); } /** @@ -943,14 +872,7 @@ export class ProjectMemberService { * @returns Nothing. * @throws {Error} The underlying publisher may reject and is handled here. */ - // TODO: DRY: `publishEvent` is near-identical to `publishMember` in - // `project-invite.service.ts`; consolidate into shared event helper. private publishEvent(topic: string, payload: unknown): void { - void publishMemberEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish member event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); + publishMemberEventSafely(topic, payload, this.logger); } } diff --git a/src/api/project-phase/dto/create-phase.dto.ts b/src/api/project-phase/dto/create-phase.dto.ts index b89ec4f..d2a32eb 100644 --- a/src/api/project-phase/dto/create-phase.dto.ts +++ b/src/api/project-phase/dto/create-phase.dto.ts @@ -13,42 +13,11 @@ import { IsString, Min, } from 'class-validator'; - -function parseOptionalNumber(value: unknown): number | undefined { - if (typeof value === 'undefined' || value === null || value === '') { - return undefined; - } - - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return undefined; - } - - return parsed; -} -// TODO [DRY]: Duplicated in `create-phase-product.dto.ts` and `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. - -function parseOptionalInteger(value: unknown): number | undefined { - const parsed = parseOptionalNumber(value); - - if (typeof parsed === 'undefined') { - return undefined; - } - - return Math.trunc(parsed); -} -// TODO [DRY]: Duplicated in `create-phase-product.dto.ts` and `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. - -function parseOptionalIntegerArray(value: unknown): number[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - - return value - .map((entry) => parseOptionalInteger(entry)) - .filter((entry): entry is number => typeof entry === 'number'); -} -// TODO [DRY]: Duplicated in `create-phase-product.dto.ts` and `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +import { + parseOptionalInteger, + parseOptionalIntegerArray, + parseOptionalNumber, +} from 'src/shared/utils/dto-transform.utils'; /** * Create payload for project phase creation endpoints: diff --git a/src/api/project-phase/dto/phase-list-query.dto.ts b/src/api/project-phase/dto/phase-list-query.dto.ts index bd96a44..80fd8ea 100644 --- a/src/api/project-phase/dto/phase-list-query.dto.ts +++ b/src/api/project-phase/dto/phase-list-query.dto.ts @@ -1,27 +1,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean, IsOptional, IsString } from 'class-validator'; - -function parseOptionalBoolean(value: unknown): boolean | undefined { - if (typeof value === 'boolean') { - return value; - } - - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - - if (normalized === 'true') { - return true; - } - - if (normalized === 'false') { - return false; - } - } - - return undefined; -} -// TODO [DRY]: Duplicated in `workstream.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +import { parseOptionalBoolean } from 'src/shared/utils/dto-transform.utils'; /** * Query params for phase/work listing endpoints: diff --git a/src/api/project-phase/project-phase.service.ts b/src/api/project-phase/project-phase.service.ts index 03de716..c2b3d45 100644 --- a/src/api/project-phase/project-phase.service.ts +++ b/src/api/project-phase/project-phase.service.ts @@ -22,22 +22,20 @@ import { } from 'src/api/project-phase/dto/phase-response.dto'; import { UpdatePhaseDto } from 'src/api/project-phase/dto/update-phase.dto'; import { Permission } from 'src/shared/constants/permissions'; +import { ProjectPermissionContext } from 'src/shared/interfaces/project-permission-context.interface'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { PermissionService } from 'src/shared/services/permission.service'; -import { hasAdminRole } from 'src/shared/utils/permission.utils'; - -// TODO [DRY]: Move to `src/shared/interfaces/project-permission-context.interface.ts`. -interface ProjectPermissionContext { - id: bigint; - directProjectId: bigint | null; - billingAccountId: bigint | null; - members: Array<{ - userId: bigint; - role: string; - deletedAt: Date | null; - }>; -} +import { parseSortParam } from 'src/shared/utils/query.utils'; +import { + ensureProjectNamedPermission, + getAuditUserIdOrDefault, + isAdminProjectUser, + loadProjectPermissionContext, + parseBigIntId, + toDetailsObject as toDetailsObjectValue, + toJsonInput as toJsonInputValue, +} from 'src/shared/utils/service.utils'; type PhaseWithRelations = ProjectPhase & { products?: PhaseProduct[]; @@ -688,41 +686,12 @@ export class ProjectPhaseService { * @returns Prisma-compatible orderBy object. * @throws {BadRequestException} When sort field or direction is invalid. */ - // TODO [DRY]: Extract a shared `parseSortParam(sort, allowedFields)` helper to `src/shared/utils/query.utils.ts`. private parseSortCriteria(sort?: string): { [key: string]: 'asc' | 'desc'; } { - if (!sort || sort.trim().length === 0) { - return { - startDate: 'asc', - }; - } - - const normalized = sort.trim(); - const withDirection = normalized.includes(' ') - ? normalized - : `${normalized} asc`; - - const [field, direction] = withDirection.split(/\s+/); - - if (!field || !direction) { - throw new BadRequestException('Invalid sort criteria.'); - } - - if ( - !PHASE_SORT_FIELDS.includes(field as (typeof PHASE_SORT_FIELDS)[number]) - ) { - throw new BadRequestException('Invalid sort criteria.'); - } - - const normalizedDirection = direction.toLowerCase(); - if (normalizedDirection !== 'asc' && normalizedDirection !== 'desc') { - throw new BadRequestException('Invalid sort criteria.'); - } - - return { - [field]: normalizedDirection, - }; + return parseSortParam(sort, PHASE_SORT_FIELDS, { + startDate: 'asc', + }); } /** @@ -1090,13 +1059,8 @@ export class ProjectPhaseService { * @param value - Candidate details value. * @returns Plain object, or empty object when invalid. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private toDetailsObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {}; - } - - return value as Record; + return toDetailsObjectValue(value); } /** @@ -1105,19 +1069,10 @@ export class ProjectPhaseService { * @param value - Candidate JSON value. * @returns Prisma JSON input value, JsonNull, or undefined. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private toJsonInput( value: unknown, ): Prisma.InputJsonValue | Prisma.JsonNullValueInput | undefined { - if (typeof value === 'undefined') { - return undefined; - } - - if (value === null) { - return Prisma.JsonNull; - } - - return value as Prisma.InputJsonValue; + return toJsonInputValue(value); } /** @@ -1160,7 +1115,6 @@ export class ProjectPhaseService { * @param projectMembers - Active project members. * @returns `true` if caller has admin access. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private isAdminUser( user: JwtUser, projectMembers: Array<{ @@ -1169,14 +1123,7 @@ export class ProjectPhaseService { deletedAt: Date | null; }>, ): boolean { - return ( - hasAdminRole(user) || - this.permissionService.hasNamedPermission( - Permission.READ_PROJECT_ANY, - user, - projectMembers, - ) - ); + return isAdminProjectUser(this.permissionService, user, projectMembers); } /** @@ -1188,7 +1135,6 @@ export class ProjectPhaseService { * @returns Nothing. * @throws {ForbiddenException} When permission is missing. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private ensureNamedPermission( permission: Permission, user: JwtUser, @@ -1198,15 +1144,12 @@ export class ProjectPhaseService { deletedAt: Date | null; }>, ): void { - const hasPermission = this.permissionService.hasNamedPermission( + ensureProjectNamedPermission( + this.permissionService, permission, user, projectMembers, ); - - if (!hasPermission) { - throw new ForbiddenException('Insufficient permissions'); - } } /** @@ -1216,39 +1159,10 @@ export class ProjectPhaseService { * @returns Permission context payload. * @throws {NotFoundException} When the project does not exist. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private async getProjectPermissionContext( projectId: bigint, ): Promise { - const project = await this.prisma.project.findFirst({ - where: { - id: projectId, - deletedAt: null, - }, - select: { - id: true, - directProjectId: true, - billingAccountId: true, - members: { - where: { - deletedAt: null, - }, - select: { - userId: true, - role: true, - deletedAt: true, - }, - }, - }, - }); - - if (!project) { - throw new NotFoundException( - `Project with id ${projectId} was not found.`, - ); - } - - return project; + return loadProjectPermissionContext(this.prisma, projectId); } /** @@ -1259,13 +1173,8 @@ export class ProjectPhaseService { * @returns Parsed bigint id. * @throws {BadRequestException} When parsing fails. */ - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private parseId(value: string, entityName: string): bigint { - try { - return BigInt(value); - } catch { - throw new BadRequestException(`${entityName} id is invalid.`); - } + return parseBigIntId(value, entityName); } /** @@ -1275,14 +1184,7 @@ export class ProjectPhaseService { * @returns Numeric user id. */ // TODO [SECURITY]: Returning `-1` silently when `user.userId` is invalid can corrupt audit trails; throw `UnauthorizedException` instead. - // TODO [DRY]: Extract to `src/shared/utils/service.utils.ts` or a shared base service. private getAuditUserId(user: JwtUser): number { - const userId = Number.parseInt(String(user.userId || ''), 10); - - if (Number.isNaN(userId)) { - return -1; - } - - return userId; + return getAuditUserIdOrDefault(user, -1); } } diff --git a/src/api/project-phase/work.controller.ts b/src/api/project-phase/work.controller.ts index ec8f19c..4ab9d9a 100644 --- a/src/api/project-phase/work.controller.ts +++ b/src/api/project-phase/work.controller.ts @@ -21,11 +21,11 @@ import { } from '@nestjs/swagger'; import { WorkStreamService } from 'src/api/workstream/workstream.service'; import { Permission } from 'src/shared/constants/permissions'; +import { WORK_LAYER_ALLOWED_ROLES } from 'src/shared/constants/roles'; import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; import { RequirePermission } from 'src/shared/decorators/requirePermission.decorator'; import { Scopes } from 'src/shared/decorators/scopes.decorator'; import { Scope } from 'src/shared/enums/scopes.enum'; -import { UserRole } from 'src/shared/enums/userRole.enum'; import { PermissionGuard } from 'src/shared/guards/permission.guard'; import { Roles } from 'src/shared/guards/tokenRoles.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; @@ -35,17 +35,6 @@ import { PhaseResponseDto } from './dto/phase-response.dto'; import { UpdatePhaseDto } from './dto/update-phase.dto'; import { ProjectPhaseService } from './project-phase.service'; -const WORK_ALLOWED_ROLES = [ - UserRole.TOPCODER_ADMIN, - UserRole.CONNECT_ADMIN, - UserRole.TG_ADMIN, - UserRole.MANAGER, - UserRole.COPILOT, - UserRole.TC_COPILOT, - UserRole.COPILOT_MANAGER, -]; -// TODO [DRY]: Extract a single `WORK_LAYER_ALLOWED_ROLES` constant to `src/shared/constants/roles.ts`. - @ApiTags('Work') @ApiBearerAuth() @Controller('/projects/:projectId/workstreams/:workStreamId/works') @@ -75,7 +64,7 @@ export class WorkController { */ @Get() @UseGuards(PermissionGuard) - @Roles(...WORK_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -140,7 +129,7 @@ export class WorkController { */ @Get(':id') @UseGuards(PermissionGuard) - @Roles(...WORK_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -194,7 +183,7 @@ export class WorkController { */ @Post() @UseGuards(PermissionGuard) - @Roles(...WORK_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORK_CREATE) @ApiOperation({ @@ -250,7 +239,7 @@ export class WorkController { */ @Patch(':id') @UseGuards(PermissionGuard) - @Roles(...WORK_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORK_EDIT) @ApiOperation({ @@ -302,7 +291,7 @@ export class WorkController { @Delete(':id') @HttpCode(204) @UseGuards(PermissionGuard) - @Roles(...WORK_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORK_DELETE) @ApiOperation({ diff --git a/src/api/project-setting/project-setting.controller.ts b/src/api/project-setting/project-setting.controller.ts index 8faf23e..e3e5bc8 100644 --- a/src/api/project-setting/project-setting.controller.ts +++ b/src/api/project-setting/project-setting.controller.ts @@ -1,5 +1,4 @@ import { - BadRequestException, Body, Controller, Delete, @@ -28,6 +27,7 @@ import { PermissionGuard } from 'src/shared/guards/permission.guard'; import { Roles } from 'src/shared/guards/tokenRoles.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; +import { parseNumericStringId } from 'src/shared/utils/service.utils'; import { CreateProjectSettingDto } from './dto/create-project-setting.dto'; import { ProjectSettingResponseDto } from './dto/project-setting-response.dto'; import { UpdateProjectSettingDto } from './dto/update-project-setting.dto'; @@ -248,15 +248,7 @@ export class ProjectSettingController { * @returns Parsed bigint project id. * @throws {BadRequestException} If project id is not numeric. */ - // TODO: DRY: Duplicates `parseBigIntParam` in `ProjectSettingService`. - // Remove once member lookup is moved into the service layer. private parseProjectId(projectId: string): bigint { - const normalizedProjectId = projectId.trim(); - - if (!/^\d+$/.test(normalizedProjectId)) { - throw new BadRequestException('Project id must be a numeric string.'); - } - - return BigInt(normalizedProjectId); + return parseNumericStringId(projectId, 'Project id'); } } diff --git a/src/api/project-setting/project-setting.service.ts b/src/api/project-setting/project-setting.service.ts index 43410d0..c59b11c 100644 --- a/src/api/project-setting/project-setting.service.ts +++ b/src/api/project-setting/project-setting.service.ts @@ -19,6 +19,10 @@ import { import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { PermissionService } from 'src/shared/services/permission.service'; +import { + getAuditUserId as getAuditUserIdFromJwt, + parseNumericStringId, +} from 'src/shared/utils/service.utils'; /** * Manages per-project key/value settings persisted in `ProjectSetting`. @@ -361,16 +365,8 @@ export class ProjectSettingService { * @returns Parsed bigint value. * @throws {BadRequestException} If value is not numeric. */ - // TODO: DRY: `parseBigIntParam` logic is duplicated in other services. - // Consolidate shared id parsing helpers. private parseBigIntParam(value: string, name: string): bigint { - const normalized = value.trim(); - - if (!/^\d+$/.test(normalized)) { - throw new BadRequestException(`${name} must be a numeric string.`); - } - - return BigInt(normalized); + return parseNumericStringId(value, name); } /** @@ -380,17 +376,8 @@ export class ProjectSettingService { * @returns Numeric audit user id. * @throws {ForbiddenException} If user id is missing or non-numeric. */ - // TODO: DRY: `getAuditUserId` logic is duplicated in other services. - // Consolidate shared user-id helper logic. private getAuditUserId(user: JwtUser): number { - const normalizedUserId = String(user.userId || '').trim(); - const parsedUserId = Number.parseInt(normalizedUserId, 10); - - if (Number.isNaN(parsedUserId)) { - throw new ForbiddenException('Authenticated user id must be numeric.'); - } - - return parsedUserId; + return getAuditUserIdFromJwt(user); } /** diff --git a/src/api/workstream/workstream.controller.ts b/src/api/workstream/workstream.controller.ts index 662bc52..7d3bd9d 100644 --- a/src/api/workstream/workstream.controller.ts +++ b/src/api/workstream/workstream.controller.ts @@ -23,7 +23,7 @@ import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; import { RequirePermission } from 'src/shared/decorators/requirePermission.decorator'; import { Scopes } from 'src/shared/decorators/scopes.decorator'; import { Scope } from 'src/shared/enums/scopes.enum'; -import { UserRole } from 'src/shared/enums/userRole.enum'; +import { WORK_LAYER_ALLOWED_ROLES } from 'src/shared/constants/roles'; import { PermissionGuard } from 'src/shared/guards/permission.guard'; import { Roles } from 'src/shared/guards/tokenRoles.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; @@ -36,17 +36,6 @@ import { } from './workstream.dto'; import { WorkStreamService } from './workstream.service'; -const WORKSTREAM_ALLOWED_ROLES = [ - UserRole.TOPCODER_ADMIN, - UserRole.CONNECT_ADMIN, - UserRole.TG_ADMIN, - UserRole.MANAGER, - UserRole.COPILOT, - UserRole.TC_COPILOT, - UserRole.COPILOT_MANAGER, -]; -// TODO [DRY]: Extract a single `WORK_LAYER_ALLOWED_ROLES` constant to `src/shared/constants/roles.ts`. - @ApiTags('WorkStream') @ApiBearerAuth() @Controller('/projects/:projectId/workstreams') @@ -70,7 +59,7 @@ export class WorkStreamController { */ @Get() @UseGuards(PermissionGuard) - @Roles(...WORKSTREAM_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -108,7 +97,7 @@ export class WorkStreamController { */ @Post() @UseGuards(PermissionGuard) - @Roles(...WORKSTREAM_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORKSTREAM_CREATE) @ApiOperation({ @@ -145,7 +134,7 @@ export class WorkStreamController { */ @Get(':id') @UseGuards(PermissionGuard) - @Roles(...WORKSTREAM_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -187,7 +176,7 @@ export class WorkStreamController { */ @Patch(':id') @UseGuards(PermissionGuard) - @Roles(...WORKSTREAM_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORKSTREAM_EDIT) @ApiOperation({ @@ -225,7 +214,7 @@ export class WorkStreamController { @Delete(':id') @HttpCode(204) @UseGuards(PermissionGuard) - @Roles(...WORKSTREAM_ALLOWED_ROLES) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORKSTREAM_DELETE) @ApiOperation({ diff --git a/src/api/workstream/workstream.dto.ts b/src/api/workstream/workstream.dto.ts index b8c52fb..66ce325 100644 --- a/src/api/workstream/workstream.dto.ts +++ b/src/api/workstream/workstream.dto.ts @@ -12,52 +12,11 @@ import { Max, Min, } from 'class-validator'; - -function parseOptionalNumber(value: unknown): number | undefined { - if (typeof value === 'undefined' || value === null || value === '') { - return undefined; - } - - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return undefined; - } - - return parsed; -} -// TODO [DRY]: Duplicated in `create-phase.dto.ts` and `create-phase-product.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. - -function parseOptionalInteger(value: unknown): number | undefined { - const parsed = parseOptionalNumber(value); - - if (typeof parsed === 'undefined') { - return undefined; - } - - return Math.trunc(parsed); -} -// TODO [DRY]: Duplicated in `create-phase.dto.ts` and `create-phase-product.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. - -function parseOptionalBoolean(value: unknown): boolean | undefined { - if (typeof value === 'boolean') { - return value; - } - - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - - if (normalized === 'true') { - return true; - } - - if (normalized === 'false') { - return false; - } - } - - return undefined; -} -// TODO [DRY]: Duplicated in `phase-list-query.dto.ts`; extract to `src/shared/utils/dto-transform.utils.ts`. +import { + parseOptionalBoolean, + parseOptionalInteger, + parseOptionalNumber, +} from 'src/shared/utils/dto-transform.utils'; /** * Create payload for `POST /projects/:projectId/workstreams`. diff --git a/src/api/workstream/workstream.service.ts b/src/api/workstream/workstream.service.ts index ca18918..661e857 100644 --- a/src/api/workstream/workstream.service.ts +++ b/src/api/workstream/workstream.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { Prisma, WorkStream } from '@prisma/client'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; +import { parseSortParam } from 'src/shared/utils/query.utils'; import { CreateWorkStreamDto, UpdateWorkStreamDto, @@ -34,7 +35,6 @@ const WORK_STREAM_SORT_FIELDS = ['name', 'status', 'createdAt', 'updatedAt']; * controllers. */ export class WorkStreamService { - // TODO [DRY]: `WORKSTREAM_ALLOWED_ROLES` is duplicated with `WORK_ALLOWED_ROLES` and `WORKITEM_ALLOWED_ROLES`; extract `WORK_LAYER_ALLOWED_ROLES` to `src/shared/constants/roles.ts`. constructor(private readonly prisma: PrismaService) {} /** @@ -430,36 +430,10 @@ export class WorkStreamService { * @returns Prisma orderBy object. * @throws {BadRequestException} When field/direction is invalid. */ - // TODO [DRY]: Extract a shared `parseSortParam(sort, allowedFields)` helper to `src/shared/utils/query.utils.ts`. private parseSort(sort?: string): Prisma.WorkStreamOrderByWithRelationInput { - if (!sort || sort.trim().length === 0) { - return { - updatedAt: 'desc', - }; - } - - const normalized = sort.trim(); - const withDirection = normalized.includes(' ') - ? normalized - : `${normalized} asc`; - const [field, direction] = withDirection.split(/\s+/); - - if (!field || !direction) { - throw new BadRequestException('Invalid sort criteria.'); - } - - if (!WORK_STREAM_SORT_FIELDS.includes(field)) { - throw new BadRequestException('Invalid sort criteria.'); - } - - const normalizedDirection = direction.toLowerCase(); - if (normalizedDirection !== 'asc' && normalizedDirection !== 'desc') { - throw new BadRequestException('Invalid sort criteria.'); - } - - return { - [field]: normalizedDirection, - }; + return parseSortParam(sort, WORK_STREAM_SORT_FIELDS, { + updatedAt: 'desc', + }) as Prisma.WorkStreamOrderByWithRelationInput; } /** diff --git a/src/shared/config/service-endpoints.config.ts b/src/shared/config/service-endpoints.config.ts new file mode 100644 index 0000000..c390212 --- /dev/null +++ b/src/shared/config/service-endpoints.config.ts @@ -0,0 +1,7 @@ +/** + * Runtime service endpoint configuration. + */ +export const SERVICE_ENDPOINTS = { + memberApiUrl: process.env.MEMBER_API_URL || '', + identityApiUrl: process.env.IDENTITY_API_URL || '', +}; diff --git a/src/shared/constants/roles.ts b/src/shared/constants/roles.ts new file mode 100644 index 0000000..819dc82 --- /dev/null +++ b/src/shared/constants/roles.ts @@ -0,0 +1,14 @@ +import { UserRole } from 'src/shared/enums/userRole.enum'; + +/** + * Roles allowed for workstream/work/workitem endpoints. + */ +export const WORK_LAYER_ALLOWED_ROLES = [ + UserRole.TOPCODER_ADMIN, + UserRole.CONNECT_ADMIN, + UserRole.TG_ADMIN, + UserRole.MANAGER, + UserRole.COPILOT, + UserRole.TC_COPILOT, + UserRole.COPILOT_MANAGER, +] as const; diff --git a/src/shared/interfaces/project-permission-context.interface.ts b/src/shared/interfaces/project-permission-context.interface.ts new file mode 100644 index 0000000..76df666 --- /dev/null +++ b/src/shared/interfaces/project-permission-context.interface.ts @@ -0,0 +1,25 @@ +/** + * Shared project-member shape used by permission checks. + */ +export interface ProjectPermissionMember { + userId: bigint; + role: string; + deletedAt: Date | null; +} + +/** + * Base project permission context. + */ +export interface ProjectPermissionContextBase { + id: bigint; + members: ProjectPermissionMember[]; +} + +/** + * Full project permission context used by phase/product services. + */ +export interface ProjectPermissionContext + extends ProjectPermissionContextBase { + directProjectId: bigint | null; + billingAccountId: bigint | null; +} diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index 250f5c0..7a47c9a 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import * as jwt from 'jsonwebtoken'; import * as jwksClient from 'jwks-rsa'; +import { extractScopesFromPayload } from 'src/shared/utils/scope.utils'; import { LoggerService } from './logger.service'; /** @@ -326,18 +327,6 @@ export class JwtService implements OnModuleInit { if (userId) { user.userId = userId; } - - const handle = this.extractString(payload, ['handle']); - if (handle) { - user.handle = handle; - } - - const roles = this.extractRoles(payload); - if (roles.length > 0) { - user.roles = roles; - } - - // TODO (quality): The second Object.keys(payload) loop (line 245) partially re-extracts userId, handle, and roles that were already extracted in the first pass. Consolidate into a single extraction pass to eliminate the DRY violation. for (const key of Object.keys(payload)) { const lowerKey = key.toLowerCase(); @@ -383,62 +372,7 @@ export class JwtService implements OnModuleInit { * @returns {string[]} Normalized list of scopes. */ private extractScopes(payload: JwtPayloadRecord): string[] { - // TODO (quality): This method is nearly identical to M2MService.extractScopes(). Extract to a shared utility function (e.g., src/shared/utils/scope.utils.ts) to eliminate duplication. - const rawScope = payload.scope || payload.scopes; - - if (typeof rawScope === 'string') { - return rawScope - .split(' ') - .map((scope) => scope.trim()) - .filter((scope) => scope.length > 0); - } - - if (Array.isArray(rawScope)) { - return rawScope - .map((scope) => String(scope).trim()) - .filter((scope) => scope.length > 0); - } - - return []; - } - - /** - * Extracts role values from token claims. - * - * @param {JwtPayloadRecord} payload Token payload. - * @returns {string[]} Normalized role names. - */ - private extractRoles(payload: JwtPayloadRecord): string[] { - const rawRoles = payload.roles; - - if (!Array.isArray(rawRoles)) { - return []; - } - - return rawRoles - .map((role) => String(role).trim()) - .filter((role) => role.length > 0); - } - - /** - * Extracts the first non-empty string value for a set of claim keys. - * - * @param {JwtPayloadRecord} payload Token payload. - * @param {string[]} keys Candidate claim keys. - * @returns {string | undefined} Claim value when found. - */ - private extractString( - payload: JwtPayloadRecord, - keys: string[], - ): string | undefined { - for (const key of keys) { - const value = payload[key]; - if (typeof value === 'string' && value.trim().length > 0) { - return value; - } - } - - return undefined; + return extractScopesFromPayload(payload, (scope) => scope.trim()); } /** diff --git a/src/shared/modules/global/m2m.service.ts b/src/shared/modules/global/m2m.service.ts index 82d6a7f..106f2da 100644 --- a/src/shared/modules/global/m2m.service.ts +++ b/src/shared/modules/global/m2m.service.ts @@ -4,6 +4,7 @@ import { SCOPE_HIERARCHY, SCOPE_SYNONYMS, } from 'src/shared/enums/scopes.enum'; +import { extractScopesFromPayload } from 'src/shared/utils/scope.utils'; import { LoggerService } from './logger.service'; /** @@ -117,23 +118,9 @@ export class M2MService { * @returns {string[]} Normalized scopes. */ extractScopes(payload: TokenPayload): string[] { - // TODO (quality): Duplicates JwtService.extractScopes(). Move to a shared utility to satisfy DRY. - const rawScopes = payload.scope || payload.scopes; - - if (typeof rawScopes === 'string') { - return rawScopes - .split(' ') - .map((scope) => this.normalizeScope(scope)) - .filter((scope) => scope.length > 0); - } - - if (Array.isArray(rawScopes)) { - return rawScopes - .map((scope) => this.normalizeScope(String(scope))) - .filter((scope) => scope.length > 0); - } - - return []; + return extractScopesFromPayload(payload, (scope) => + this.normalizeScope(scope), + ); } /** diff --git a/src/shared/services/identity.service.ts b/src/shared/services/identity.service.ts index 46d7a86..796e8bf 100644 --- a/src/shared/services/identity.service.ts +++ b/src/shared/services/identity.service.ts @@ -1,6 +1,7 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { firstValueFrom } from 'rxjs'; +import { SERVICE_ENDPOINTS } from 'src/shared/config/service-endpoints.config'; import { M2MService } from 'src/shared/modules/global/m2m.service'; import { LoggerService } from 'src/shared/modules/global/logger.service'; @@ -19,8 +20,7 @@ export interface IdentityUser { @Injectable() export class IdentityService { private readonly logger = LoggerService.forRoot('IdentityService'); - // TODO: DRY violation - consider a shared config constant or ConfigService. - private readonly identityApiUrl = process.env.IDENTITY_API_URL || ''; + private readonly identityApiUrl = SERVICE_ENDPOINTS.identityApiUrl; constructor( private readonly httpService: HttpService, diff --git a/src/shared/services/member.service.ts b/src/shared/services/member.service.ts index b146783..0ccaebc 100644 --- a/src/shared/services/member.service.ts +++ b/src/shared/services/member.service.ts @@ -1,6 +1,7 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { firstValueFrom } from 'rxjs'; +import { SERVICE_ENDPOINTS } from 'src/shared/config/service-endpoints.config'; import { M2MService } from 'src/shared/modules/global/m2m.service'; import { LoggerService } from 'src/shared/modules/global/logger.service'; import { MemberDetail } from 'src/shared/utils/member.utils'; @@ -23,10 +24,9 @@ export interface MemberRoleRecord { export class MemberService { private readonly logger = LoggerService.forRoot('MemberService'); // TODO: validate required env vars at startup (e.g., in onModuleInit). - private readonly memberApiUrl = process.env.MEMBER_API_URL || ''; - // TODO: DRY violation - consider a shared config constant or ConfigService. + private readonly memberApiUrl = SERVICE_ENDPOINTS.memberApiUrl; // TODO: validate required env vars at startup (e.g., in onModuleInit). - private readonly identityApiUrl = process.env.IDENTITY_API_URL || ''; + private readonly identityApiUrl = SERVICE_ENDPOINTS.identityApiUrl; constructor( private readonly httpService: HttpService, diff --git a/src/shared/utils/dto-transform.utils.ts b/src/shared/utils/dto-transform.utils.ts new file mode 100644 index 0000000..a92bf32 --- /dev/null +++ b/src/shared/utils/dto-transform.utils.ts @@ -0,0 +1,96 @@ +/** + * Shared DTO transform helpers used by class-transformer decorators. + */ + +/** + * Parses optional numeric values from string/number input. + * + * Returns `undefined` for missing or invalid values. + */ +export function parseOptionalNumber(value: unknown): number | undefined { + if (typeof value === 'undefined' || value === null || value === '') { + return undefined; + } + + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return undefined; + } + + return parsed; +} + +/** + * Parses optional integer values from string/number input. + * + * Uses `Number()` semantics (strict string parsing), then truncates. + */ +export function parseOptionalInteger(value: unknown): number | undefined { + const parsed = parseOptionalNumber(value); + + if (typeof parsed === 'undefined') { + return undefined; + } + + return Math.trunc(parsed); +} + +/** + * Parses optional integer values from string/number input. + * + * Uses `parseInt` semantics for string values to preserve legacy behavior. + */ +export function parseOptionalLooseInteger(value: unknown): number | undefined { + if (typeof value === 'undefined' || value === null || value === '') { + return undefined; + } + + if (typeof value === 'number') { + return Number.isNaN(value) ? undefined : Math.trunc(value); + } + + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? undefined : parsed; + } + + return undefined; +} + +/** + * Parses an array of optional integers, dropping invalid entries. + */ +export function parseOptionalIntegerArray( + value: unknown, +): number[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + return value + .map((entry) => parseOptionalInteger(entry)) + .filter((entry): entry is number => typeof entry === 'number'); +} + +/** + * Parses optional boolean values from boolean/string input. + */ +export function parseOptionalBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + + if (normalized === 'true') { + return true; + } + + if (normalized === 'false') { + return false; + } + } + + return undefined; +} diff --git a/src/shared/utils/entity.utils.ts b/src/shared/utils/entity.utils.ts new file mode 100644 index 0000000..e1be1ad --- /dev/null +++ b/src/shared/utils/entity.utils.ts @@ -0,0 +1,40 @@ +import { Prisma } from '@prisma/client'; + +/** + * Recursively normalizes Prisma entity values for API/event serialization. + * + * - `bigint` -> `string` + * - `Prisma.Decimal` -> `number` + */ +export function normalizeEntity(payload: T): T { + const walk = (input: unknown): unknown => { + if (typeof input === 'bigint') { + return input.toString(); + } + + if (input instanceof Prisma.Decimal) { + return Number(input.toString()); + } + + if (Array.isArray(input)) { + return input.map((entry) => walk(entry)); + } + + if (input && typeof input === 'object') { + if (input instanceof Date) { + return input; + } + + const output: Record = {}; + for (const [key, value] of Object.entries(input)) { + output[key] = walk(value); + } + + return output; + } + + return input; + }; + + return walk(payload) as T; +} diff --git a/src/shared/utils/event.utils.ts b/src/shared/utils/event.utils.ts index 30778ee..8828411 100644 --- a/src/shared/utils/event.utils.ts +++ b/src/shared/utils/event.utils.ts @@ -28,6 +28,9 @@ type BusApiClient = { * Optional callback invoked after successful publish. */ type PublishCallback = (event: BusApiEvent) => void; +type ErrorLogger = { + error: (message: string, trace?: string) => void; +}; /** * Originator value embedded into outbound events. @@ -493,6 +496,22 @@ export async function publishMemberEvent( } } +/** + * Fire-and-forget wrapper for member events with caller-provided logger. + */ +export function publishMemberEventSafely( + topic: string, + payload: unknown, + errorLogger: ErrorLogger, +): void { + void publishMemberEvent(topic, payload).catch((error) => { + errorLogger.error( + `Failed to publish member event topic=${topic}: ${toErrorMessage(error)}`, + toErrorStack(error), + ); + }); +} + /** * Publishes a project-member-invite event envelope. */ diff --git a/src/shared/utils/member.utils.ts b/src/shared/utils/member.utils.ts index bf28107..0b4fd95 100644 --- a/src/shared/utils/member.utils.ts +++ b/src/shared/utils/member.utils.ts @@ -14,6 +14,7 @@ import { JwtUser } from 'src/shared/modules/global/jwt.service'; export type MemberDetail = { userId?: string | number | bigint | null; handle?: string | null; + handleLower?: string | null; email?: string | null; firstName?: string | null; lastName?: string | null; diff --git a/src/shared/utils/query.utils.ts b/src/shared/utils/query.utils.ts new file mode 100644 index 0000000..c6ce9aa --- /dev/null +++ b/src/shared/utils/query.utils.ts @@ -0,0 +1,36 @@ +import { BadRequestException } from '@nestjs/common'; + +export type SortDirection = 'asc' | 'desc'; + +/** + * Parses `sort` query expressions (`field` or `field direction`) into + * Prisma-compatible `orderBy` objects. + */ +export function parseSortParam( + sort: string | undefined, + allowedFields: readonly string[], + defaultOrder: Record, +): Record { + if (!sort || sort.trim().length === 0) { + return defaultOrder; + } + + const normalized = sort.trim(); + const withDirection = normalized.includes(' ') + ? normalized + : `${normalized} asc`; + const [field, direction] = withDirection.split(/\s+/); + + if (!field || !direction || !allowedFields.includes(field)) { + throw new BadRequestException('Invalid sort criteria.'); + } + + const normalizedDirection = direction.toLowerCase(); + if (normalizedDirection !== 'asc' && normalizedDirection !== 'desc') { + throw new BadRequestException('Invalid sort criteria.'); + } + + return { + [field]: normalizedDirection, + }; +} diff --git a/src/shared/utils/scope.utils.ts b/src/shared/utils/scope.utils.ts new file mode 100644 index 0000000..fa57821 --- /dev/null +++ b/src/shared/utils/scope.utils.ts @@ -0,0 +1,34 @@ +/** + * Normalizer function for individual scope strings. + */ +type ScopeNormalizer = (scope: string) => string; + +type ScopePayload = { + scope?: unknown; + scopes?: unknown; +}; + +/** + * Extracts `scope` / `scopes` claims from a payload and normalizes values. + */ +export function extractScopesFromPayload( + payload: ScopePayload, + normalizeScope: ScopeNormalizer = (scope) => scope.trim(), +): string[] { + const rawScopes = payload.scope || payload.scopes; + + if (typeof rawScopes === 'string') { + return rawScopes + .split(' ') + .map((scope) => normalizeScope(scope)) + .filter((scope) => scope.length > 0); + } + + if (Array.isArray(rawScopes)) { + return rawScopes + .map((scope) => normalizeScope(String(scope))) + .filter((scope) => scope.length > 0); + } + + return []; +} diff --git a/src/shared/utils/service.utils.ts b/src/shared/utils/service.utils.ts new file mode 100644 index 0000000..9d0942a --- /dev/null +++ b/src/shared/utils/service.utils.ts @@ -0,0 +1,283 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, ProjectMemberRole } from '@prisma/client'; +import { Permission } from 'src/shared/constants/permissions'; +import { + ProjectPermissionContext, + ProjectPermissionContextBase, + ProjectPermissionMember, +} from 'src/shared/interfaces/project-permission-context.interface'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { PrismaService } from 'src/shared/modules/global/prisma.service'; +import { PermissionService } from 'src/shared/services/permission.service'; +import { hasAdminRole } from 'src/shared/utils/permission.utils'; + +/** + * Parses an id using `BigInt(...)` semantics and throws on invalid input. + */ +export function parseBigIntId(value: string, entityName: string): bigint { + try { + return BigInt(value); + } catch { + throw new BadRequestException(`${entityName} id is invalid.`); + } +} + +/** + * Parses a strictly numeric-string id (`/^[0-9]+$/`) as bigint. + */ +export function parseNumericStringId(value: string, fieldName: string): bigint { + const normalized = String(value || '').trim(); + + if (!/^\d+$/.test(normalized)) { + throw new BadRequestException(`${fieldName} must be a numeric string.`); + } + + return BigInt(normalized); +} + +/** + * Parses an optional id-like value as bigint. + * + * Returns `null` for missing or non-numeric values. + */ +export function parseOptionalNumericStringId( + value: string | number | bigint | null | undefined, +): bigint | null { + if (value === null || typeof value === 'undefined') { + return null; + } + + const normalized = String(value).trim(); + if (!/^\d+$/.test(normalized)) { + return null; + } + + return BigInt(normalized); +} + +/** + * Resolves the authenticated user id as a trimmed string. + */ +export function getActorUserId(user: JwtUser): string { + if (!user?.userId || String(user.userId).trim().length === 0) { + throw new ForbiddenException('Authenticated user id is missing.'); + } + + return String(user.userId).trim(); +} + +/** + * Resolves the authenticated user id as a numeric audit id. + */ +export function getAuditUserId(user: JwtUser): number { + const parsedUserId = Number.parseInt(getActorUserId(user), 10); + + if (Number.isNaN(parsedUserId)) { + throw new ForbiddenException('Authenticated user id must be numeric.'); + } + + return parsedUserId; +} + +/** + * Resolves the authenticated user id as an audit id, with fallback. + */ +export function getAuditUserIdOrDefault( + user: JwtUser, + fallback = -1, +): number { + const userId = Number.parseInt(String(user.userId || ''), 10); + + if (Number.isNaN(userId)) { + return fallback; + } + + return userId; +} + +/** + * Parses CSV fields from string or string[] input. + */ +export function parseCsvFields(fields?: string | string[]): string[] { + if (!fields) { + return []; + } + + if (Array.isArray(fields)) { + return fields + .map((field) => String(field).trim()) + .filter((field) => field.length > 0); + } + + if (fields.trim().length === 0) { + return []; + } + + return fields + .split(',') + .map((field) => field.trim()) + .filter((field) => field.length > 0); +} + +/** + * Normalizes unknown JSON-like values into plain object payloads. + */ +export function toDetailsObject(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return value as Record; +} + +/** + * Converts arbitrary values to Prisma JSON input semantics. + */ +export function toJsonInput( + value: unknown, +): Prisma.InputJsonValue | Prisma.JsonNullValueInput | undefined { + if (typeof value === 'undefined') { + return undefined; + } + + if (value === null) { + return Prisma.JsonNull; + } + + return value as Prisma.InputJsonValue; +} + +/** + * Checks if a user is an admin for project-member scoped operations. + */ +export function isAdminProjectUser( + permissionService: PermissionService, + user: JwtUser, + projectMembers: ProjectPermissionMember[], +): boolean { + return ( + hasAdminRole(user) || + permissionService.hasNamedPermission( + Permission.READ_PROJECT_ANY, + user, + projectMembers, + ) + ); +} + +/** + * Enforces a named permission against project members. + */ +export function ensureProjectNamedPermission( + permissionService: PermissionService, + permission: Permission, + user: JwtUser, + projectMembers: ProjectPermissionMember[], +): void { + const hasPermission = permissionService.hasNamedPermission( + permission, + user, + projectMembers, + ); + + if (!hasPermission) { + throw new ForbiddenException('Insufficient permissions'); + } +} + +type RolePermissionRule = { + permission: Permission; + message: string; +}; + +/** + * Enforces role-specific permission rules with a default fallback rule. + */ +export function ensureRoleScopedPermission( + permissionService: PermissionService, + role: ProjectMemberRole, + user: JwtUser, + projectMembers: ProjectPermissionMember[], + defaultRule: RolePermissionRule, + roleRules: Partial>, +): void { + const rule = roleRules[role] || defaultRule; + + if (!permissionService.hasNamedPermission(rule.permission, user, projectMembers)) { + throw new ForbiddenException(rule.message); + } +} + +/** + * Loads full project permission context (including direct/billing ids). + */ +export async function loadProjectPermissionContext( + prisma: PrismaService, + projectId: bigint, +): Promise { + const project = await prisma.project.findFirst({ + where: { + id: projectId, + deletedAt: null, + }, + select: { + id: true, + directProjectId: true, + billingAccountId: true, + members: { + where: { + deletedAt: null, + }, + select: { + userId: true, + role: true, + deletedAt: true, + }, + }, + }, + }); + + if (!project) { + throw new NotFoundException(`Project with id ${projectId} was not found.`); + } + + return project; +} + +/** + * Loads base project permission context. + */ +export async function loadProjectPermissionContextBase( + prisma: PrismaService, + projectId: bigint, +): Promise { + const project = await prisma.project.findFirst({ + where: { + id: projectId, + deletedAt: null, + }, + select: { + id: true, + members: { + where: { + deletedAt: null, + }, + select: { + userId: true, + role: true, + deletedAt: true, + }, + }, + }, + }); + + if (!project) { + throw new NotFoundException(`Project with id ${projectId} was not found.`); + } + + return project; +} From 1919dee29456c82733b8a61af6302bd6b0b7bceb Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 20 Feb 2026 16:20:47 +1100 Subject: [PATCH 06/41] Build fix --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8fb6467..9224fe5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:22.13.1-alpine -RUN apk add --no-cache bash +RUN apk add --no-cache bash git RUN apk update ARG RESET_DB_ARG=false @@ -17,4 +17,4 @@ RUN npm install pnpm -g RUN pnpm install RUN pnpm run build RUN chmod +x appStartUp.sh -CMD ./appStartUp.sh \ No newline at end of file +CMD ./appStartUp.sh From df968eca82e0aee61ee199f2b2663e8ed2c85121 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 20 Feb 2026 16:21:53 +1100 Subject: [PATCH 07/41] Lint --- .../copilot/copilot-application.service.ts | 7 +++-- .../copilot/copilot-notification.service.ts | 7 +++-- .../copilot/copilot-opportunity.service.ts | 13 ++++++-- src/api/copilot/copilot-request.service.ts | 31 +++++++++++++++---- .../utils/metadata-validation.utils.ts | 4 ++- .../project-permission-context.interface.ts | 3 +- src/shared/utils/service.utils.ts | 9 +++--- 7 files changed, 53 insertions(+), 21 deletions(-) diff --git a/src/api/copilot/copilot-application.service.ts b/src/api/copilot/copilot-application.service.ts index 55ae29d..887448b 100644 --- a/src/api/copilot/copilot-application.service.ts +++ b/src/api/copilot/copilot-application.service.ts @@ -63,7 +63,11 @@ export class CopilotApplicationService { dto: CreateCopilotApplicationDto, user: JwtUser, ): Promise { - ensureNamedPermission(this.permissionService, NamedPermission.APPLY_COPILOT_OPPORTUNITY, user); + ensureNamedPermission( + this.permissionService, + NamedPermission.APPLY_COPILOT_OPPORTUNITY, + user, + ); const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity'); const parsedUserId = this.parseUserId(user); @@ -263,5 +267,4 @@ export class CopilotApplicationService { return BigInt(normalized); } - } diff --git a/src/api/copilot/copilot-notification.service.ts b/src/api/copilot/copilot-notification.service.ts index 28e06f8..396ed82 100644 --- a/src/api/copilot/copilot-notification.service.ts +++ b/src/api/copilot/copilot-notification.service.ts @@ -9,7 +9,11 @@ import { import { LoggerService } from 'src/shared/modules/global/logger.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { MemberService } from 'src/shared/services/member.service'; -import { getCopilotRequestData, getCopilotTypeLabel, readString } from './copilot.utils'; +import { + getCopilotRequestData, + getCopilotTypeLabel, + readString, +} from './copilot.utils'; // TODO [CONFIG]: TEMPLATE_IDS are hardcoded SendGrid template ids; move these values to environment-based configuration. const TEMPLATE_IDS = { @@ -381,5 +385,4 @@ export class CopilotNotificationService { return `${day}-${month}-${year}`; } - } diff --git a/src/api/copilot/copilot-opportunity.service.ts b/src/api/copilot/copilot-opportunity.service.ts index 53773fa..2a8c21b 100644 --- a/src/api/copilot/copilot-opportunity.service.ts +++ b/src/api/copilot/copilot-opportunity.service.ts @@ -241,7 +241,11 @@ export class CopilotOpportunityService { dto: AssignCopilotDto, user: JwtUser, ): Promise<{ id: string }> { - ensureNamedPermission(this.permissionService, NamedPermission.ASSIGN_COPILOT_OPPORTUNITY, user); + ensureNamedPermission( + this.permissionService, + NamedPermission.ASSIGN_COPILOT_OPPORTUNITY, + user, + ); const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity'); const parsedApplicationId = parseNumericId( @@ -437,7 +441,11 @@ export class CopilotOpportunityService { opportunityId: string, user: JwtUser, ): Promise<{ id: string }> { - ensureNamedPermission(this.permissionService, NamedPermission.CANCEL_COPILOT_OPPORTUNITY, user); + ensureNamedPermission( + this.permissionService, + NamedPermission.CANCEL_COPILOT_OPPORTUNITY, + user, + ); const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity'); const auditUserId = getAuditUserId(user); @@ -624,5 +632,4 @@ export class CopilotOpportunityService { memberships.map((membership) => membership.projectId.toString()), ); } - } diff --git a/src/api/copilot/copilot-request.service.ts b/src/api/copilot/copilot-request.service.ts index a29fb46..4033a01 100644 --- a/src/api/copilot/copilot-request.service.ts +++ b/src/api/copilot/copilot-request.service.ts @@ -86,7 +86,11 @@ export class CopilotRequestService { query: CopilotRequestListQueryDto, user: JwtUser, ): Promise { - ensureNamedPermission(this.permissionService, NamedPermission.MANAGE_COPILOT_REQUEST, user); + ensureNamedPermission( + this.permissionService, + NamedPermission.MANAGE_COPILOT_REQUEST, + user, + ); const includeProjectInResponse = isAdminOrManager(user); const parsedProjectId = projectId @@ -163,7 +167,11 @@ export class CopilotRequestService { copilotRequestId: string, user: JwtUser, ): Promise { - ensureNamedPermission(this.permissionService, NamedPermission.MANAGE_COPILOT_REQUEST, user); + ensureNamedPermission( + this.permissionService, + NamedPermission.MANAGE_COPILOT_REQUEST, + user, + ); const parsedRequestId = parseNumericId(copilotRequestId, 'Copilot request'); @@ -211,7 +219,11 @@ export class CopilotRequestService { dto: CreateCopilotRequestDto, user: JwtUser, ): Promise { - ensureNamedPermission(this.permissionService, NamedPermission.MANAGE_COPILOT_REQUEST, user); + ensureNamedPermission( + this.permissionService, + NamedPermission.MANAGE_COPILOT_REQUEST, + user, + ); const parsedProjectId = parseNumericId(projectId, 'Project'); const auditUserId = getAuditUserId(user); @@ -300,7 +312,11 @@ export class CopilotRequestService { dto: UpdateCopilotRequestDto, user: JwtUser, ): Promise { - ensureNamedPermission(this.permissionService, NamedPermission.MANAGE_COPILOT_REQUEST, user); + ensureNamedPermission( + this.permissionService, + NamedPermission.MANAGE_COPILOT_REQUEST, + user, + ); const parsedRequestId = parseNumericId(copilotRequestId, 'Copilot request'); const auditUserId = getAuditUserId(user); @@ -414,7 +430,11 @@ export class CopilotRequestService { type: string | undefined, user: JwtUser, ): Promise { - ensureNamedPermission(this.permissionService, NamedPermission.MANAGE_COPILOT_REQUEST, user); + ensureNamedPermission( + this.permissionService, + NamedPermission.MANAGE_COPILOT_REQUEST, + user, + ); const parsedProjectId = parseNumericId(projectId, 'Project'); const parsedRequestId = parseNumericId(copilotRequestId, 'Copilot request'); @@ -728,5 +748,4 @@ export class CopilotRequestService { return 0; } - } diff --git a/src/api/metadata/utils/metadata-validation.utils.ts b/src/api/metadata/utils/metadata-validation.utils.ts index bca6f14..40c25f9 100644 --- a/src/api/metadata/utils/metadata-validation.utils.ts +++ b/src/api/metadata/utils/metadata-validation.utils.ts @@ -97,7 +97,9 @@ async function validateVersionedReference( ); } - throw new BadRequestException(`${entityName} not found for key ${reference.key}.`); + throw new BadRequestException( + `${entityName} not found for key ${reference.key}.`, + ); } return normalizeResolvedReference(reference, found.version); diff --git a/src/shared/interfaces/project-permission-context.interface.ts b/src/shared/interfaces/project-permission-context.interface.ts index 76df666..98a0202 100644 --- a/src/shared/interfaces/project-permission-context.interface.ts +++ b/src/shared/interfaces/project-permission-context.interface.ts @@ -18,8 +18,7 @@ export interface ProjectPermissionContextBase { /** * Full project permission context used by phase/product services. */ -export interface ProjectPermissionContext - extends ProjectPermissionContextBase { +export interface ProjectPermissionContext extends ProjectPermissionContextBase { directProjectId: bigint | null; billingAccountId: bigint | null; } diff --git a/src/shared/utils/service.utils.ts b/src/shared/utils/service.utils.ts index 9d0942a..9c88d17 100644 --- a/src/shared/utils/service.utils.ts +++ b/src/shared/utils/service.utils.ts @@ -86,10 +86,7 @@ export function getAuditUserId(user: JwtUser): number { /** * Resolves the authenticated user id as an audit id, with fallback. */ -export function getAuditUserIdOrDefault( - user: JwtUser, - fallback = -1, -): number { +export function getAuditUserIdOrDefault(user: JwtUser, fallback = -1): number { const userId = Number.parseInt(String(user.userId || ''), 10); if (Number.isNaN(userId)) { @@ -207,7 +204,9 @@ export function ensureRoleScopedPermission( ): void { const rule = roleRules[role] || defaultRule; - if (!permissionService.hasNamedPermission(rule.permission, user, projectMembers)) { + if ( + !permissionService.hasNamedPermission(rule.permission, user, projectMembers) + ) { throw new ForbiddenException(rule.message); } } From b32254fb04e81ae1524787f904a4d41b390b2c3d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 20 Feb 2026 16:38:56 +1100 Subject: [PATCH 08/41] Build and lint fixes --- src/api/metadata/form/form.service.ts | 7 +-- .../plan-config/plan-config.service.ts | 7 +-- .../price-config/price-config.service.ts | 7 +-- .../project-template.service.ts | 60 ++++++++++++++----- .../utils/metadata-validation.utils.ts | 12 ++-- .../phase-product/phase-product.service.ts | 1 - .../project-phase/project-phase.service.ts | 1 - .../project-setting.service.ts | 1 - src/api/workstream/workstream.dto.ts | 1 - 9 files changed, 57 insertions(+), 40 deletions(-) diff --git a/src/api/metadata/form/form.service.ts b/src/api/metadata/form/form.service.ts index 164af21..e449db4 100644 --- a/src/api/metadata/form/form.service.ts +++ b/src/api/metadata/form/form.service.ts @@ -1,9 +1,4 @@ -import { - BadRequestException, - HttpException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { HttpException, Injectable, NotFoundException } from '@nestjs/common'; import { Form, Prisma } from '@prisma/client'; import { PrismaErrorService } from 'src/shared/modules/global/prisma-error.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; diff --git a/src/api/metadata/plan-config/plan-config.service.ts b/src/api/metadata/plan-config/plan-config.service.ts index 4645f89..d33837d 100644 --- a/src/api/metadata/plan-config/plan-config.service.ts +++ b/src/api/metadata/plan-config/plan-config.service.ts @@ -1,9 +1,4 @@ -import { - BadRequestException, - HttpException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { HttpException, Injectable, NotFoundException } from '@nestjs/common'; import { PlanConfig, Prisma } from '@prisma/client'; import { PrismaErrorService } from 'src/shared/modules/global/prisma-error.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; diff --git a/src/api/metadata/price-config/price-config.service.ts b/src/api/metadata/price-config/price-config.service.ts index a4bacd0..a529b23 100644 --- a/src/api/metadata/price-config/price-config.service.ts +++ b/src/api/metadata/price-config/price-config.service.ts @@ -1,9 +1,4 @@ -import { - BadRequestException, - HttpException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { HttpException, Injectable, NotFoundException } from '@nestjs/common'; import { PriceConfig, Prisma } from '@prisma/client'; import { PrismaErrorService } from 'src/shared/modules/global/prisma-error.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; diff --git a/src/api/metadata/project-template/project-template.service.ts b/src/api/metadata/project-template/project-template.service.ts index 97f507d..95abf25 100644 --- a/src/api/metadata/project-template/project-template.service.ts +++ b/src/api/metadata/project-template/project-template.service.ts @@ -539,22 +539,54 @@ export class ProjectTemplateService { return null; } - const delegates = { - form: this.prisma.form, - planConfig: this.prisma.planConfig, - priceConfig: this.prisma.priceConfig, + const where = { + key: reference.key, + ...(reference.version > 0 + ? { + version: BigInt(reference.version), + } + : {}), + deletedAt: null, + }; + const orderBy = [ + { version: 'desc' as const }, + { revision: 'desc' as const }, + ]; + const select = { + id: true, + key: true, + version: true, + revision: true, + config: true, } as const; - const latest = await delegates[type].findFirst({ - where: { - key: reference.key, - ...(reference.version > 0 - ? { version: BigInt(reference.version) } - : {}), - deletedAt: null, - }, - orderBy: [{ version: 'desc' }, { revision: 'desc' }], - }); + let latest: { + id: bigint; + key: string; + version: bigint; + revision: bigint; + config: Prisma.JsonValue | null; + } | null = null; + + switch (type) { + case 'form': + latest = await this.prisma.form.findFirst({ where, orderBy, select }); + break; + case 'planConfig': + latest = await this.prisma.planConfig.findFirst({ + where, + orderBy, + select, + }); + break; + case 'priceConfig': + latest = await this.prisma.priceConfig.findFirst({ + where, + orderBy, + select, + }); + break; + } return latest ? { diff --git a/src/api/metadata/utils/metadata-validation.utils.ts b/src/api/metadata/utils/metadata-validation.utils.ts index 40c25f9..7562691 100644 --- a/src/api/metadata/utils/metadata-validation.utils.ts +++ b/src/api/metadata/utils/metadata-validation.utils.ts @@ -47,10 +47,14 @@ type VersionedReferenceDelegate = { version?: bigint; deletedAt: null; }; - orderBy: Array<{ - version?: 'desc'; - revision: 'desc'; - }>; + orderBy: Array< + | { + version: 'desc'; + } + | { + revision: 'desc'; + } + >; select: { version: true; }; diff --git a/src/api/phase-product/phase-product.service.ts b/src/api/phase-product/phase-product.service.ts index b5a67e1..04eb684 100644 --- a/src/api/phase-product/phase-product.service.ts +++ b/src/api/phase-product/phase-product.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, - ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; diff --git a/src/api/project-phase/project-phase.service.ts b/src/api/project-phase/project-phase.service.ts index c2b3d45..31646a9 100644 --- a/src/api/project-phase/project-phase.service.ts +++ b/src/api/project-phase/project-phase.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, - ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; diff --git a/src/api/project-setting/project-setting.service.ts b/src/api/project-setting/project-setting.service.ts index c59b11c..df9eef3 100644 --- a/src/api/project-setting/project-setting.service.ts +++ b/src/api/project-setting/project-setting.service.ts @@ -1,5 +1,4 @@ import { - BadRequestException, ConflictException, ForbiddenException, Injectable, diff --git a/src/api/workstream/workstream.dto.ts b/src/api/workstream/workstream.dto.ts index 66ce325..9a080bf 100644 --- a/src/api/workstream/workstream.dto.ts +++ b/src/api/workstream/workstream.dto.ts @@ -15,7 +15,6 @@ import { import { parseOptionalBoolean, parseOptionalInteger, - parseOptionalNumber, } from 'src/shared/utils/dto-transform.utils'; /** From bdbcb10eccaa0b91e29fc4b1c579743c2017a553 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 22 Feb 2026 19:07:46 +1100 Subject: [PATCH 09/41] PM-2648: send copilot application emails to PM users and creator What was broken - PM users and the opportunity creator were not receiving emails when a copilot applied to an opportunity. Root cause - Copilot notification email publishing was stubbed out in CopilotNotificationService.publishEmail, so no external.action.email events were sent. - Apply recipient resolution diverged from legacy behavior by using project-member managers and copilot-request creator instead of global Project Manager role users plus the opportunity creator. What was changed - Re-enabled copilot email dispatch by publishing to external.action.email through EventBusService. - Added MemberService.getRoleSubjects(roleName) to resolve Identity role subjects. - Updated apply notification recipients to include all Project Manager role users and opportunity.createdBy, with email deduplication. Any added/updated tests - Added src/api/copilot/copilot-notification.service.spec.ts to verify apply notifications go to PM role users + creator, deduplicate recipients, and do not throw if event publishing fails. --- .../copilot-notification.service.spec.ts | 138 ++++++++++++++++ .../copilot/copilot-notification.service.ts | 151 ++++++++++-------- src/shared/services/member.service.ts | 97 +++++++++++ 3 files changed, 319 insertions(+), 67 deletions(-) create mode 100644 src/api/copilot/copilot-notification.service.spec.ts diff --git a/src/api/copilot/copilot-notification.service.spec.ts b/src/api/copilot/copilot-notification.service.spec.ts new file mode 100644 index 0000000..5c0e7f0 --- /dev/null +++ b/src/api/copilot/copilot-notification.service.spec.ts @@ -0,0 +1,138 @@ +import { + CopilotApplication, + CopilotApplicationStatus, + CopilotOpportunity, + CopilotOpportunityStatus, + CopilotOpportunityType, +} from '@prisma/client'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { EventBusService } from 'src/shared/modules/global/eventBus.service'; +import { MemberService } from 'src/shared/services/member.service'; +import { CopilotNotificationService } from './copilot-notification.service'; + +describe('CopilotNotificationService', () => { + const memberServiceMock = { + getRoleSubjects: jest.fn(), + getMemberDetailsByUserIds: jest.fn(), + }; + + const eventBusServiceMock = { + publishProjectEvent: jest.fn(), + }; + + let service: CopilotNotificationService; + + function createOpportunity(): CopilotOpportunity & { copilotRequest: any } { + const now = new Date('2025-01-01T00:00:00.000Z'); + + return { + id: BigInt(21), + projectId: BigInt(1001), + copilotRequestId: BigInt(301), + status: CopilotOpportunityStatus.active, + type: CopilotOpportunityType.dev, + createdAt: now, + updatedAt: now, + createdBy: 777, + updatedBy: 777, + deletedAt: null, + deletedBy: null, + copilotRequest: { + data: { + projectType: 'dev', + opportunityTitle: 'Need migration copilot', + }, + }, + }; + } + + function createApplication(): CopilotApplication { + const now = new Date('2025-01-01T00:00:00.000Z'); + + return { + id: BigInt(501), + opportunityId: BigInt(21), + userId: BigInt(999), + notes: 'I can help', + status: CopilotApplicationStatus.pending, + createdAt: now, + updatedAt: now, + createdBy: 999, + updatedBy: 999, + deletedAt: null, + deletedBy: null, + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + memberServiceMock.getRoleSubjects.mockResolvedValue([]); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); + eventBusServiceMock.publishProjectEvent.mockResolvedValue(undefined); + + service = new CopilotNotificationService( + memberServiceMock as unknown as MemberService, + eventBusServiceMock as unknown as EventBusService, + ); + }); + + it('publishes apply notifications to all PM users and opportunity creator', async () => { + memberServiceMock.getRoleSubjects.mockResolvedValue([ + { email: 'pm1@topcoder.com', handle: 'pm1' }, + { email: 'creator@topcoder.com', handle: 'pm-creator' }, + ]); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([ + { email: 'Creator@topcoder.com', handle: 'creator-user' }, + ]); + + await service.sendCopilotApplicationNotification( + createOpportunity(), + createApplication(), + ); + + expect(memberServiceMock.getRoleSubjects).toHaveBeenCalledWith( + UserRole.PROJECT_MANAGER, + ); + expect(memberServiceMock.getMemberDetailsByUserIds).toHaveBeenCalledWith([ + 777, + ]); + expect(eventBusServiceMock.publishProjectEvent).toHaveBeenCalledTimes(2); + + const recipients = eventBusServiceMock.publishProjectEvent.mock.calls + .map(([, payload]) => (payload as { recipients?: string[] }).recipients) + .map((recipientList) => recipientList?.[0]) + .sort(); + + expect(recipients).toEqual(['creator@topcoder.com', 'pm1@topcoder.com']); + + for (const [topic, payload] of eventBusServiceMock.publishProjectEvent.mock + .calls) { + const normalizedPayload = payload as { + sendgrid_template_id?: string; + version?: string; + }; + + expect(topic).toBe('external.action.email'); + expect(normalizedPayload.sendgrid_template_id).toBe( + 'd-d7c1f48628654798a05c8e09e52db14f', + ); + expect(normalizedPayload.version).toBe('v3'); + } + }); + + it('does not throw when publishing apply notification fails', async () => { + memberServiceMock.getRoleSubjects.mockResolvedValue([ + { email: 'pm1@topcoder.com', handle: 'pm1' }, + ]); + eventBusServiceMock.publishProjectEvent.mockRejectedValue( + new Error('Event bus unavailable'), + ); + + await expect( + service.sendCopilotApplicationNotification( + createOpportunity(), + createApplication(), + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/api/copilot/copilot-notification.service.ts b/src/api/copilot/copilot-notification.service.ts index 396ed82..48f979d 100644 --- a/src/api/copilot/copilot-notification.service.ts +++ b/src/api/copilot/copilot-notification.service.ts @@ -4,10 +4,10 @@ import { CopilotOpportunity, CopilotOpportunityType, CopilotRequest, - ProjectMemberRole, } from '@prisma/client'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { EventBusService } from 'src/shared/modules/global/eventBus.service'; import { LoggerService } from 'src/shared/modules/global/logger.service'; -import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { MemberService } from 'src/shared/services/member.service'; import { getCopilotRequestData, @@ -23,6 +23,7 @@ const TEMPLATE_IDS = { COPILOT_OPPORTUNITY_COMPLETED: 'd-dc448919d11b4e7d8b4ba351c4b67b8b', COPILOT_OPPORTUNITY_CANCELED: 'd-2a67ba71e82f4d70891fe6989c3522a3', } as const; +const EXTERNAL_ACTION_EMAIL_TOPIC = 'external.action.email'; type OpportunityWithRequest = CopilotOpportunity & { copilotRequest?: CopilotRequest | null; @@ -34,23 +35,28 @@ type ApplicationWithMembership = CopilotApplication & { }; }; +type NotificationRecipient = { + email?: string | null; + handle?: string | null; +}; + /** * Email notification dispatcher for copilot lifecycle events. - * Email dispatch is currently disabled: publishEmail is a stub that logs and returns - * without sending to Kafka/SendGrid. */ @Injectable() export class CopilotNotificationService { private readonly logger = LoggerService.forRoot('CopilotNotificationService'); constructor( - private readonly prisma: PrismaService, private readonly memberService: MemberService, + private readonly eventBusService: EventBusService, ) {} /** - * Sends application notifications to project managers and request creator. - * Recipient resolution: manager/project_manager project members + request creator, deduplicated. + * Sends application notifications to all Project Manager role users and the + * opportunity creator. + * Recipient resolution: identity-role subjects for `Project Manager` plus + * `opportunity.createdBy`, deduplicated by email. * * @param opportunity Opportunity that must include copilotRequest relation. * @param application Newly created application. @@ -60,44 +66,20 @@ export class CopilotNotificationService { opportunity: OpportunityWithRequest, application: CopilotApplication, ): Promise { - if (!opportunity.projectId) { - return; - } - - const projectMembers = await this.prisma.projectMember.findMany({ - where: { - projectId: opportunity.projectId, - role: { - in: [ProjectMemberRole.manager, ProjectMemberRole.project_manager], - }, - deletedAt: null, - }, - select: { - userId: true, - }, - }); + const [projectManagerUsers, opportunityCreatorUsers] = await Promise.all([ + this.memberService.getRoleSubjects(UserRole.PROJECT_MANAGER), + this.memberService.getMemberDetailsByUserIds([opportunity.createdBy]), + ]); - const requestCreatorId = - typeof opportunity.copilotRequest?.createdBy === 'number' - ? BigInt(opportunity.copilotRequest.createdBy) - : null; - - const recipientIds = Array.from( - new Set( - [ - ...projectMembers.map((member) => member.userId), - ...(requestCreatorId ? [requestCreatorId] : []), - ].map((id) => id.toString()), - ), - ); + const recipients = this.deduplicateRecipientsByEmail([ + ...projectManagerUsers, + ...opportunityCreatorUsers, + ]); - if (recipientIds.length === 0) { + if (recipients.length === 0) { return; } - const recipients = - await this.memberService.getMemberDetailsByUserIds(recipientIds); - const requestData = getCopilotRequestData(opportunity.copilotRequest?.data); const opportunityType = this.resolveOpportunityType( opportunity, @@ -105,23 +87,17 @@ export class CopilotNotificationService { ); await Promise.all( - recipients - .filter((recipient) => Boolean(recipient.email)) - .map((recipient) => - this.publishEmail( - TEMPLATE_IDS.APPLY_COPILOT, - [String(recipient.email)], - { - user_name: recipient.handle, - opportunity_details_url: `${this.getCopilotPortalUrl()}/opportunity/${opportunity.id.toString()}#applications`, - work_manager_url: this.getWorkManagerUrl(), - opportunity_type: getCopilotTypeLabel(opportunityType), - opportunity_title: - readString(requestData.opportunityTitle) || - `Opportunity ${opportunity.id.toString()}`, - }, - ), - ), + recipients.map((recipient) => + this.publishEmail(TEMPLATE_IDS.APPLY_COPILOT, [recipient.email], { + user_name: recipient.handle, + opportunity_details_url: `${this.getCopilotPortalUrl()}/opportunity/${opportunity.id.toString()}#applications`, + work_manager_url: this.getWorkManagerUrl(), + opportunity_type: getCopilotTypeLabel(opportunityType), + opportunity_title: + readString(requestData.opportunityTitle) || + `Opportunity ${opportunity.id.toString()}`, + }), + ), ); this.logger.log( @@ -285,24 +261,65 @@ export class CopilotNotificationService { * @param templateId Template identifier. * @param recipients Recipient emails. * @param data Template payload. - * @returns Resolved promise (dispatch currently disabled). + * @returns Resolved promise when publish succeeds or fails. */ - private publishEmail( + private async publishEmail( templateId: string, recipients: string[], data: Record, ): Promise { if (recipients.length === 0) { - return Promise.resolve(); + return; } - // TODO [SECURITY/FUNCTIONALITY]: Email dispatch is fully disabled; templateId and data are discarded and nothing is sent. Re-enable Kafka/SendGrid before production use. - void templateId; - void data; - this.logger.warn( - `Copilot email Kafka publication is disabled. Skipped ${recipients.length} recipient(s).`, - ); - return Promise.resolve(); + try { + await this.eventBusService.publishProjectEvent( + EXTERNAL_ACTION_EMAIL_TOPIC, + { + data, + sendgrid_template_id: templateId, + recipients, + version: 'v3', + }, + ); + } catch (error) { + this.logger.error( + `Failed to publish copilot email event to ${EXTERNAL_ACTION_EMAIL_TOPIC}: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + /** + * Deduplicates recipients by normalized email. + * + * @param recipients Potential recipients from role and creator lookups. + * @returns Deduplicated recipients preserving first-seen handle values. + */ + private deduplicateRecipientsByEmail( + recipients: NotificationRecipient[], + ): Array<{ email: string; handle: string }> { + const recipientByEmail = new Map< + string, + { email: string; handle: string } + >(); + + recipients.forEach((recipient) => { + const normalizedEmail = String(recipient.email || '') + .trim() + .toLowerCase(); + + if (!normalizedEmail || recipientByEmail.has(normalizedEmail)) { + return; + } + + recipientByEmail.set(normalizedEmail, { + email: normalizedEmail, + handle: String(recipient.handle || '').trim() || normalizedEmail, + }); + }); + + return Array.from(recipientByEmail.values()); } /** diff --git a/src/shared/services/member.service.ts b/src/shared/services/member.service.ts index 0ccaebc..29e26cc 100644 --- a/src/shared/services/member.service.ts +++ b/src/shared/services/member.service.ts @@ -7,9 +7,21 @@ import { LoggerService } from 'src/shared/modules/global/logger.service'; import { MemberDetail } from 'src/shared/utils/member.utils'; export interface MemberRoleRecord { + id?: string | number; roleName: string; } +type RoleSubjectRecord = { + subjectID?: string | number; + userId?: string | number; + handle?: string; + email?: string; +}; + +type RoleDetailResponse = { + subjects?: RoleSubjectRecord[]; +}; + /** * Member and identity enrichment service. * @@ -196,4 +208,89 @@ export class MemberService { return []; } } + + /** + * Looks up role members from Identity API by role name. + * + * Resolves `/roles?filter=roleName=` and then + * `/roles/:id?fields=subjects`, returning the `subjects` list normalized to + * `MemberDetail` shape. + * + * @param roleName identity role name (for example `Project Manager`) + * @returns role subject list with optional `userId`, `handle`, and `email` + */ + async getRoleSubjects(roleName: string): Promise { + if (!this.identityApiUrl) { + this.logger.warn('IDENTITY_API_URL is not configured.'); + return []; + } + + const normalizedRoleName = String(roleName || '').trim(); + if (!normalizedRoleName) { + return []; + } + + try { + const token = await this.m2mService.getM2MToken(); + const identityApiBaseUrl = this.identityApiUrl.replace(/\/$/, ''); + + const rolesResponse = await firstValueFrom( + this.httpService.get(`${identityApiBaseUrl}/roles`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + params: { + filter: `roleName=${normalizedRoleName}`, + }, + }), + ); + + const roles = Array.isArray(rolesResponse.data) + ? (rolesResponse.data as MemberRoleRecord[]) + : []; + + const roleId = String( + roles.find( + (role) => + String(role.roleName || '').toLowerCase() === + normalizedRoleName.toLowerCase(), + )?.id || '', + ).trim(); + + if (!roleId) { + return []; + } + + const roleDetailResponse = await firstValueFrom( + this.httpService.get(`${identityApiBaseUrl}/roles/${roleId}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + params: { + fields: 'subjects', + }, + }), + ); + + const subjects = Array.isArray( + (roleDetailResponse.data as RoleDetailResponse)?.subjects, + ) + ? ((roleDetailResponse.data as RoleDetailResponse) + .subjects as RoleSubjectRecord[]) + : []; + + return subjects.map((subject) => ({ + userId: subject.subjectID || subject.userId || null, + handle: subject.handle || null, + email: subject.email ? String(subject.email).toLowerCase() : null, + })); + } catch (error) { + this.logger.warn( + `Failed to fetch role subjects for roleName=${normalizedRoleName}: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + } + } } From a4d61e056fb55f4e033455ab88a4b7b0c5642807 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 22 Feb 2026 19:28:47 +1100 Subject: [PATCH 10/41] PM-2684: add member-api fallback for invite email resolution What was broken - Invite email target resolution depended only on Identity API email lookups. - When those lookups failed, existing Topcoder members invited by email were treated as non-members and routed through the registration-template path. Root cause - had no fallback source for unresolved emails and silently returned no user matches. What was changed - Updated to keep Identity API as primary lookup and fallback unresolved emails to Member API email search (). - Mapped Member API records into shape and deduplicated merged results. - Added safe primitive-string normalization helpers used during response parsing. Any added/updated tests - Added with coverage for Identity API success and Member API fallback paths. --- src/shared/services/identity.service.spec.ts | 139 +++++++++++ src/shared/services/identity.service.ts | 249 +++++++++++++++---- 2 files changed, 335 insertions(+), 53 deletions(-) create mode 100644 src/shared/services/identity.service.spec.ts diff --git a/src/shared/services/identity.service.spec.ts b/src/shared/services/identity.service.spec.ts new file mode 100644 index 0000000..720e1a4 --- /dev/null +++ b/src/shared/services/identity.service.spec.ts @@ -0,0 +1,139 @@ +import { HttpService } from '@nestjs/axios'; +import { of, throwError } from 'rxjs'; +import { M2MService } from 'src/shared/modules/global/m2m.service'; +import { IdentityService } from './identity.service'; + +jest.mock('src/shared/config/service-endpoints.config', () => ({ + SERVICE_ENDPOINTS: { + identityApiUrl: 'https://identity.test', + memberApiUrl: 'https://member.test', + }, +})); + +describe('IdentityService', () => { + const httpServiceMock = { + get: jest.fn(), + }; + + const m2mServiceMock = { + getM2MToken: jest.fn().mockResolvedValue('m2m-token'), + }; + + let service: IdentityService; + + beforeEach(() => { + jest.clearAllMocks(); + + service = new IdentityService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + }); + + it('returns users from Identity API when lookups succeed', async () => { + httpServiceMock.get.mockImplementation( + (url: string, options: { params?: { filter?: string } }) => { + if ( + url === 'https://identity.test/users' && + options.params?.filter === 'email=member1@topcoder.com' + ) { + return of({ + data: [ + { + id: '1001', + handle: 'member1', + email: 'member1@topcoder.com', + }, + ], + }); + } + + if ( + url === 'https://identity.test/users' && + options.params?.filter === 'email=member2@topcoder.com' + ) { + return of({ + data: [ + { + id: '1002', + handle: 'member2', + email: 'member2@topcoder.com', + }, + ], + }); + } + + return of({ data: [] }); + }, + ); + + const result = await service.lookupMultipleUserEmails([ + 'member1@topcoder.com', + 'member2@topcoder.com', + ]); + + expect(result).toEqual([ + { + id: '1001', + handle: 'member1', + email: 'member1@topcoder.com', + }, + { + id: '1002', + handle: 'member2', + email: 'member2@topcoder.com', + }, + ]); + + expect(m2mServiceMock.getM2MToken).toHaveBeenCalledTimes(1); + }); + + it('falls back to Member API for unresolved emails', async () => { + httpServiceMock.get.mockImplementation( + (url: string, options: { params?: { email?: string } }) => { + if (url === 'https://identity.test/users') { + return throwError(() => new Error('identity lookup failed')); + } + + if ( + url === 'https://member.test' && + options.params?.email === 'existing@topcoder.com' + ) { + return of({ + data: [ + { + userId: 2001, + handle: 'existing', + email: 'existing@topcoder.com', + }, + ], + }); + } + + if ( + url === 'https://member.test' && + options.params?.email === 'newuser@topcoder.com' + ) { + return of({ data: [] }); + } + + return of({ data: [] }); + }, + ); + + const result = await service.lookupMultipleUserEmails([ + 'existing@topcoder.com', + 'newuser@topcoder.com', + ]); + + expect(result).toEqual([ + { + id: '2001', + handle: 'existing', + email: 'existing@topcoder.com', + }, + ]); + + expect(m2mServiceMock.getM2MToken).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/services/identity.service.ts b/src/shared/services/identity.service.ts index 796e8bf..1c63ee3 100644 --- a/src/shared/services/identity.service.ts +++ b/src/shared/services/identity.service.ts @@ -15,12 +15,14 @@ export interface IdentityUser { * Identity lookup service for resolving users by email. * * Used by project invite flows to map invite email addresses into - * `IdentityUser` records (`id`, `handle`, `email`) from the Identity API. + * `IdentityUser` records (`id`, `handle`, `email`) from Identity API and, + * when needed, Member API fallback lookups. */ @Injectable() export class IdentityService { private readonly logger = LoggerService.forRoot('IdentityService'); private readonly identityApiUrl = SERVICE_ENDPOINTS.identityApiUrl; + private readonly memberApiUrl = SERVICE_ENDPOINTS.memberApiUrl; constructor( private readonly httpService: HttpService, @@ -30,11 +32,8 @@ export class IdentityService { /** * Looks up identity users for multiple emails. * - * Executes one request per email in parallel via `Promise.all`, swallows - * per-email request failures with `.catch(() => null)`, and de-duplicates - * successful responses by `id::email`. - * - * Returns an empty array when `IDENTITY_API_URL` is not configured. + * Resolves users from the Identity API first. For any unresolved emails, + * falls back to Member API email lookup before returning unique users. * * @param emails email addresses to resolve * @returns matched identity users @@ -42,7 +41,7 @@ export class IdentityService { async lookupMultipleUserEmails( emails: string[] = [], ): Promise { - if (!this.identityApiUrl || emails.length === 0) { + if (emails.length === 0) { return []; } @@ -60,61 +59,205 @@ export class IdentityService { try { const token = await this.m2mService.getM2MToken(); + const identityUsers = await this.lookupUsersFromIdentityApi( + normalizedEmails, + token, + ); + const resolvedEmails = new Set(identityUsers.map((user) => user.email)); + const unresolvedEmails = normalizedEmails.filter( + (email) => !resolvedEmails.has(email), + ); - const responses = await Promise.all( - normalizedEmails.map((email) => - // TODO: URL-encode the email value in the filter param to prevent query string injection. - firstValueFrom( - this.httpService.get( - `${this.identityApiUrl.replace(/\/$/, '')}/users`, - { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - params: { - fields: 'handle,id,email', - filter: `email=${email}`, - }, - timeout: 15000, - }, - ), - ).catch(() => null), - ), + const memberUsers = + unresolvedEmails.length > 0 + ? await this.lookupUsersFromMemberApi(unresolvedEmails, token) + : []; + + return this.deduplicateUsers(identityUsers.concat(memberUsers)); + } catch (error) { + this.logger.warn( + `Failed to lookup emails in identity service: ${error instanceof Error ? error.message : String(error)}`, ); - // TODO: consider batching or throttling parallel email lookups. + return []; + } + } - const users = responses.flatMap((response) => { - if (!response || !Array.isArray(response.data)) { - return []; - } + private async lookupUsersFromIdentityApi( + emails: string[], + token: string, + ): Promise { + if (!this.identityApiUrl || emails.length === 0) { + return []; + } - return response.data as IdentityUser[]; - }); + const identityApiBaseUrl = this.identityApiUrl.replace(/\/$/, ''); + const responses = await Promise.all( + emails.map((email) => + // TODO: URL-encode the email value in the filter param to prevent query string injection. + firstValueFrom( + this.httpService.get(`${identityApiBaseUrl}/users`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + params: { + fields: 'handle,id,email', + filter: `email=${email}`, + }, + timeout: 15000, + }), + ).catch(() => null), + ), + ); + // TODO: consider batching or throttling parallel email lookups. - const uniqueUsers = new Map(); - for (const user of users) { - const key = `${String(user.id || '').trim()}::${String(user.email || '') - .trim() - .toLowerCase()}`; - // TODO: strengthen deduplication guard - skip entries where id is empty. - if (!key.trim()) { - continue; + return responses.flatMap((response) => + this.normalizeIdentityUsersFromIdentityResponse(response), + ); + } + + private async lookupUsersFromMemberApi( + emails: string[], + token: string, + ): Promise { + if (!this.memberApiUrl || emails.length === 0) { + return []; + } + + const memberApiBaseUrl = this.memberApiUrl.replace(/\/$/, ''); + const responses = await Promise.all( + emails.map((email) => + firstValueFrom( + this.httpService.get(memberApiBaseUrl, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + params: { + email, + fields: 'userId,handle,email', + page: 1, + perPage: 1, + }, + timeout: 15000, + }), + ).catch(() => null), + ), + ); + + return responses.flatMap((response, index) => + this.normalizeIdentityUsersFromMemberResponse(response, emails[index]), + ); + } + + private normalizeIdentityUsersFromIdentityResponse( + response: unknown, + ): IdentityUser[] { + if ( + !response || + typeof response !== 'object' || + !Array.isArray((response as { data?: unknown }).data) + ) { + return []; + } + + return ((response as { data: unknown[] }).data || []) + .map((user) => this.normalizeIdentityUser(user)) + .filter((user): user is IdentityUser => !!user); + } + + private normalizeIdentityUsersFromMemberResponse( + response: unknown, + requestedEmail: string, + ): IdentityUser[] { + if ( + !response || + typeof response !== 'object' || + !Array.isArray((response as { data?: unknown }).data) + ) { + return []; + } + + const normalizedRequestedEmail = this.asPrimitiveString(requestedEmail) + .trim() + .toLowerCase(); + + const matchedEntry = (response as { data: unknown[] }).data.find( + (entry) => { + if (!entry || typeof entry !== 'object') { + return false; } - uniqueUsers.set(key, { - id: String(user.id), - handle: user.handle, - email: String(user.email || '').toLowerCase(), - }); - } + return ( + this.asPrimitiveString((entry as { email?: unknown }).email) + .trim() + .toLowerCase() === normalizedRequestedEmail + ); + }, + ); - return Array.from(uniqueUsers.values()); - } catch (error) { - this.logger.warn( - `Failed to lookup emails in identity service: ${error instanceof Error ? error.message : String(error)}`, - ); + if (!matchedEntry || typeof matchedEntry !== 'object') { return []; } + + const normalizedUser = this.normalizeIdentityUser({ + id: (matchedEntry as { userId?: unknown }).userId, + handle: (matchedEntry as { handle?: unknown }).handle, + email: (matchedEntry as { email?: unknown }).email, + }); + + return normalizedUser ? [normalizedUser] : []; + } + + private normalizeIdentityUser(value: unknown): IdentityUser | null { + if (!value || typeof value !== 'object') { + return null; + } + + const id = this.asPrimitiveString((value as { id?: unknown }).id).trim(); + const email = this.asPrimitiveString((value as { email?: unknown }).email) + .trim() + .toLowerCase(); + const handleValue = (value as { handle?: unknown }).handle; + + if (!id || !email) { + return null; + } + + return { + id, + handle: + typeof handleValue === 'string' && handleValue.trim().length > 0 + ? handleValue + : undefined, + email, + }; + } + + private deduplicateUsers(users: IdentityUser[]): IdentityUser[] { + const uniqueUsers = new Map(); + + for (const user of users) { + const key = `${user.id}::${user.email}`; + uniqueUsers.set(key, user); + } + + return Array.from(uniqueUsers.values()); + } + + private asPrimitiveString(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if ( + typeof value === 'number' || + typeof value === 'bigint' || + typeof value === 'boolean' + ) { + return String(value); + } + + return ''; } } From a0488183c371451c6b6a1c5d2aa4ff521058f3f6 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 23 Feb 2026 07:46:13 +1100 Subject: [PATCH 11/41] Potential build fix --- .circleci/config.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a000aa1..ae610ad 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -71,15 +71,12 @@ jobs: V6_BASE_URL: "${DEPLOYMENT_VALIDATION_DEV_BASE_URL}" steps: - checkout - - run: - name: Enable pnpm - command: corepack enable - run: name: Install dependencies - command: pnpm install --frozen-lockfile + command: corepack pnpm install --frozen-lockfile - run: name: Run deployment validation tests - command: pnpm test:deployment + command: corepack pnpm test:deployment deployment-validation-prod: <<: *node_defaults @@ -89,15 +86,12 @@ jobs: V6_BASE_URL: "${DEPLOYMENT_VALIDATION_PROD_BASE_URL}" steps: - checkout - - run: - name: Enable pnpm - command: corepack enable - run: name: Install dependencies - command: pnpm install --frozen-lockfile + command: corepack pnpm install --frozen-lockfile - run: name: Run deployment validation tests - command: pnpm test:deployment + command: corepack pnpm test:deployment workflows: version: 2 From f977b4b722f2f219c3067d8d8b476a7246fa41eb Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 23 Feb 2026 10:04:15 +1100 Subject: [PATCH 12/41] Build updates --- .circleci/config.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ae610ad..28b28f2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -73,10 +73,10 @@ jobs: - checkout - run: name: Install dependencies - command: corepack pnpm install --frozen-lockfile + command: npm exec --yes pnpm@10.28.2 -- install --frozen-lockfile - run: name: Run deployment validation tests - command: corepack pnpm test:deployment + command: npm exec --yes pnpm@10.28.2 -- test:deployment deployment-validation-prod: <<: *node_defaults @@ -88,10 +88,10 @@ jobs: - checkout - run: name: Install dependencies - command: corepack pnpm install --frozen-lockfile + command: npm exec --yes pnpm@10.28.2 -- install --frozen-lockfile - run: name: Run deployment validation tests - command: corepack pnpm test:deployment + command: npm exec --yes pnpm@10.28.2 -- test:deployment workflows: version: 2 From f3744aa021bb4f2a316903e4e13836afc9db260b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 23 Feb 2026 10:52:09 +1100 Subject: [PATCH 13/41] Build fix --- pnpm-lock.yaml | 83 ++++++++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3129253..b822065 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,10 +16,10 @@ overrides: patchedDependencies: '@eslint/eslintrc@3.3.3': - hash: zrzh6kkfky44bxmprbtcvs3pcu + hash: 780ee0d2d0120b91573affb05b91c8db28baad3435843917b3278da30274d238 path: patches/@eslint__eslintrc@3.3.3.patch eslint@9.39.2: - hash: kkxs4nnfkabf36btu5xbcihjjq + hash: dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd path: patches/eslint@9.39.2.patch importers: @@ -101,7 +101,7 @@ importers: devDependencies: '@eslint/eslintrc': specifier: ^3.2.0 - version: 3.3.3(patch_hash=zrzh6kkfky44bxmprbtcvs3pcu) + version: 3.3.3(patch_hash=780ee0d2d0120b91573affb05b91c8db28baad3435843917b3278da30274d238) '@eslint/js': specifier: ^9.18.0 version: 9.39.2 @@ -149,13 +149,13 @@ importers: version: 8.0.0 eslint: specifier: ^9.18.0 - version: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) + version: 9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1) eslint-config-prettier: specifier: ^10.0.1 - version: 10.1.8(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)) + version: 10.1.8(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1)) eslint-plugin-prettier: specifier: ^5.2.2 - version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)))(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(prettier@3.8.1) + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1)))(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(prettier@3.8.1) globals: specifier: ^15.14.0 version: 15.15.0 @@ -191,7 +191,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.20.0 - version: 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) + version: 8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3) packages: @@ -983,42 +983,49 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-arm64-musl@1.1.1': resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/nice-linux-ppc64-gnu@1.1.1': resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-riscv64-gnu@1.1.1': resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-s390x-gnu@1.1.1': resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-gnu@1.1.1': resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-musl@1.1.1': resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/nice-openharmony-arm64@1.1.1': resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} @@ -1511,24 +1518,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.11': resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.11': resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.11': resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.11': resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} @@ -5312,9 +5323,9 @@ snapshots: '@electric-sql/pglite@0.3.15': {} - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))': dependencies: - eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) + eslint: 9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -5335,7 +5346,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3(patch_hash=zrzh6kkfky44bxmprbtcvs3pcu)': + '@eslint/eslintrc@3.3.3(patch_hash=780ee0d2d0120b91573affb05b91c8db28baad3435843917b3278da30274d238)': dependencies: ajv: 8.18.0 debug: 4.4.3 @@ -6655,15 +6666,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.54.0 - eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) + eslint: 9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -6671,14 +6682,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.54.0 debug: 4.4.3 - eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) + eslint: 9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6701,13 +6712,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) + eslint: 9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -6730,13 +6741,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) + eslint: 9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -7538,19 +7549,19 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1)): dependencies: - eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) + eslint: 9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1) - eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)))(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1)))(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(prettier@3.8.1): dependencies: - eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) + eslint: 9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1) prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: '@types/eslint': 9.6.1 - eslint-config-prettier: 10.1.8(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)) + eslint-config-prettier: 10.1.8(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1)) eslint-scope@5.1.1: dependencies: @@ -7566,14 +7577,14 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1): + eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3(patch_hash=zrzh6kkfky44bxmprbtcvs3pcu) + '@eslint/eslintrc': 3.3.3(patch_hash=780ee0d2d0120b91573affb05b91c8db28baad3435843917b3278da30274d238) '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -9575,13 +9586,13 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(patch_hash=kkxs4nnfkabf36btu5xbcihjjq)(jiti@2.6.1) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color From c85401857bb33fac4cacdc133e6a6f82915ebc8a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 23 Feb 2026 11:18:45 +1100 Subject: [PATCH 14/41] Build updates --- .circleci/config.yml | 50 -------------------------------------------- 1 file changed, 50 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 28b28f2..e12516a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,10 +9,6 @@ defaults: &defaults docker: - image: cimg/python:3.13.2-browsers -node_defaults: &node_defaults - docker: - - image: cimg/node:22.13 - install_dependency: &install_dependency name: Installation of build and deployment dependencies. command: | @@ -63,36 +59,6 @@ jobs: DEPLOYMENT_ENVIRONMENT: "prod" steps: *builddeploy_steps - deployment-validation-dev: - <<: *node_defaults - environment: - DEPLOYMENT_VALIDATION_ENABLED: "true" - DEPLOYMENT_SMOKE_ENABLED: "false" - V6_BASE_URL: "${DEPLOYMENT_VALIDATION_DEV_BASE_URL}" - steps: - - checkout - - run: - name: Install dependencies - command: npm exec --yes pnpm@10.28.2 -- install --frozen-lockfile - - run: - name: Run deployment validation tests - command: npm exec --yes pnpm@10.28.2 -- test:deployment - - deployment-validation-prod: - <<: *node_defaults - environment: - DEPLOYMENT_VALIDATION_ENABLED: "true" - DEPLOYMENT_SMOKE_ENABLED: "false" - V6_BASE_URL: "${DEPLOYMENT_VALIDATION_PROD_BASE_URL}" - steps: - - checkout - - run: - name: Install dependencies - command: npm exec --yes pnpm@10.28.2 -- install --frozen-lockfile - - run: - name: Run deployment validation tests - command: npm exec --yes pnpm@10.28.2 -- test:deployment - workflows: version: 2 build-dev: @@ -103,14 +69,6 @@ workflows: branches: only: - dev - - deployment-validation-dev: - context: org-global - requires: - - build-dev - filters: - branches: - only: - - dev build-prod: jobs: @@ -120,11 +78,3 @@ workflows: branches: only: - master - - deployment-validation-prod: - context: org-global - requires: - - build-prod - filters: - branches: - only: - - master From b168d71a5d4a685efe9e4b458892f20260e7672d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 23 Feb 2026 13:07:51 +1100 Subject: [PATCH 15/41] Additional debugging information for sending emails failure --- .../project-invite/project-invite.service.ts | 33 ++++++++ src/shared/modules/global/eventBus.service.ts | 76 +++++++++++++++++-- src/shared/services/email.service.ts | 8 +- 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/api/project-invite/project-invite.service.ts b/src/api/project-invite/project-invite.service.ts index 60c9ac8..1895c85 100644 --- a/src/api/project-invite/project-invite.service.ts +++ b/src/api/project-invite/project-invite.service.ts @@ -249,11 +249,15 @@ export class ProjectInviteService { for (const invite of success) { const normalizedInvite = this.normalizeEntity(invite); + const recipient = invite.email?.trim().toLowerCase(); if ( invite.email && !invite.userId && invite.status === InviteStatus.pending ) { + this.logger.log( + `Dispatching invite email publish for inviteId=${String(invite.id)} projectId=${projectId} recipient=${recipient}`, + ); void this.emailService.sendInviteEmail( projectId, normalizedInvite, @@ -263,6 +267,13 @@ export class ProjectInviteService { }, project.name, ); + continue; + } + + if (invite.email) { + this.logger.log( + `Skipping invite email publish for inviteId=${String(invite.id)} projectId=${projectId} recipient=${recipient} reason=${this.getInviteEmailSkipReason(invite)}`, + ); } } @@ -1287,6 +1298,28 @@ export class ProjectInviteService { return null; } + /** + * Returns a stable reason when invite-email publishing is not attempted. + * + * @param invite Invite entity. + * @returns Machine-friendly skip reason. + */ + private getInviteEmailSkipReason(invite: ProjectMemberInvite): string { + if (!invite.email) { + return 'missing-email'; + } + + if (invite.userId) { + return 'invite-linked-to-user'; + } + + if (invite.status !== InviteStatus.pending) { + return `status-${String(invite.status)}`; + } + + return 'not-eligible'; + } + /** * Determines whether the invite belongs to the current user. * diff --git a/src/shared/modules/global/eventBus.service.ts b/src/shared/modules/global/eventBus.service.ts index d51d82b..c4d45e0 100644 --- a/src/shared/modules/global/eventBus.service.ts +++ b/src/shared/modules/global/eventBus.service.ts @@ -37,6 +37,18 @@ type EventBusClient = { }) => Promise; }; +const EVENT_BUS_REQUIRED_ENV_KEYS = [ + 'BUSAPI_URL', + 'KAFKA_URL', + 'AUTH0_URL', + 'AUTH0_AUDIENCE', + 'AUTH0_CLIENT_ID', + 'AUTH0_CLIENT_SECRET', +] as const; + +type EventBusRequiredEnvKey = (typeof EVENT_BUS_REQUIRED_ENV_KEYS)[number]; +type EventBusConfigStatus = Record; + @Injectable() /** * Service responsible for publishing events to Kafka through tc-bus-api-wrapper. @@ -44,6 +56,7 @@ type EventBusClient = { export class EventBusService { private readonly logger = LoggerService.forRoot('EventBusService'); private readonly client: EventBusClient | null; + private clientInitReason = 'uninitialized'; /** * Creates the event-bus client if the runtime supports it. @@ -64,6 +77,9 @@ export class EventBusService { async publishProjectEvent(topic: string, payload: unknown): Promise { // TODO (security): The 'topic' parameter is not validated. A caller passing an untrusted or user-supplied topic string could publish to unintended Kafka topics. Validate against an allowlist of known topics. if (!this.client) { + this.logger.error( + `Event bus client unavailable for topic ${topic}. initReason=${this.clientInitReason}. configStatus=${this.serializeConfigStatus(this.buildConfigStatus())}`, + ); throw new ServiceUnavailableException( 'Event bus client is not configured.', ); @@ -100,25 +116,36 @@ export class EventBusService { const busApiFactory = busApi as unknown as ( config: Record, ) => EventBusClient; + const configStatus = this.buildConfigStatus(); if (typeof busApiFactory !== 'function') { + this.clientInitReason = 'bus-api-wrapper-unavailable'; this.logger.warn( - 'tc-bus-api-wrapper is not available. Event publishing disabled.', + `tc-bus-api-wrapper is not available. Event publishing disabled. configStatus=${this.serializeConfigStatus(configStatus)}`, ); return null; } - if (!process.env.AUTH0_URL || !process.env.AUTH0_AUDIENCE) { + const missingAuthEnv = this.getMissingAuthEnv(configStatus); + if (missingAuthEnv.length > 0) { + this.clientInitReason = `missing-auth-env:${missingAuthEnv.join(',')}`; this.logger.warn( - 'Missing AUTH0_URL or AUTH0_AUDIENCE. Event publishing disabled.', + `Missing ${missingAuthEnv.join(', ')}. Event publishing disabled. configStatus=${this.serializeConfigStatus(configStatus)}`, ); return null; } + const missingRequiredEnv = this.getMissingRequiredEnv(configStatus); + if (missingRequiredEnv.length > 0) { + this.logger.warn( + `Event bus config has empty required values: ${missingRequiredEnv.join(', ')}. Initialization may fail. configStatus=${this.serializeConfigStatus(configStatus)}`, + ); + } + try { // TODO (quality): TOKEN_CACHE_TIME is hardcoded to 900 seconds. Expose as an environment variable (e.g., AUTH0_TOKEN_CACHE_TIME) for operational flexibility. // TODO (security): KAFKA_CLIENT_CERT and KAFKA_CLIENT_CERT_KEY are passed directly from environment variables without validation. Ensure these are properly formatted PEM strings before passing to the client. - return busApiFactory({ + const client = busApiFactory({ BUSAPI_URL: process.env.BUSAPI_URL, KAFKA_URL: process.env.KAFKA_URL, KAFKA_CLIENT_CERT: process.env.KAFKA_CLIENT_CERT, @@ -130,11 +157,50 @@ export class EventBusService { AUTH0_PROXY_SERVER_URL: process.env.AUTH0_PROXY_SERVER_URL, TOKEN_CACHE_TIME: 900, }); + this.clientInitReason = 'initialized'; + return client; } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.clientInitReason = `client-init-failed:${errorMessage}`; this.logger.warn( - `Failed to initialize event bus client: ${error instanceof Error ? error.message : String(error)}`, + `Failed to initialize event bus client: ${errorMessage}. configStatus=${this.serializeConfigStatus(configStatus)}`, ); return null; } } + + private buildConfigStatus(): EventBusConfigStatus { + return { + BUSAPI_URL: this.toConfigStatus(process.env.BUSAPI_URL), + KAFKA_URL: this.toConfigStatus(process.env.KAFKA_URL), + AUTH0_URL: this.toConfigStatus(process.env.AUTH0_URL), + AUTH0_AUDIENCE: this.toConfigStatus(process.env.AUTH0_AUDIENCE), + AUTH0_CLIENT_ID: this.toConfigStatus(process.env.AUTH0_CLIENT_ID), + AUTH0_CLIENT_SECRET: this.toConfigStatus(process.env.AUTH0_CLIENT_SECRET), + }; + } + + private toConfigStatus(value: string | undefined): 'set' | 'empty' { + return typeof value === 'string' && value.trim().length > 0 + ? 'set' + : 'empty'; + } + + private getMissingAuthEnv(status: EventBusConfigStatus): string[] { + const authKeys: Array = [ + 'AUTH0_URL', + 'AUTH0_AUDIENCE', + ]; + + return authKeys.filter((key) => status[key] === 'empty'); + } + + private getMissingRequiredEnv(status: EventBusConfigStatus): string[] { + return EVENT_BUS_REQUIRED_ENV_KEYS.filter((key) => status[key] === 'empty'); + } + + private serializeConfigStatus(status: EventBusConfigStatus): string { + return JSON.stringify(status); + } } diff --git a/src/shared/services/email.service.ts b/src/shared/services/email.service.ts index 4c408d3..b55b579 100644 --- a/src/shared/services/email.service.ts +++ b/src/shared/services/email.service.ts @@ -60,6 +60,9 @@ export class EmailService { // TODO: add basic email format validation before publishing the event. if (!recipient) { + this.logger.warn( + `Skipping invite email publish for projectId=${projectId}: recipient email is missing.`, + ); return; } @@ -68,7 +71,7 @@ export class EmailService { const templateId = process.env.SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED; if (!templateId) { this.logger.warn( - 'SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED is not configured.', + `Skipping invite email publish for projectId=${projectId} recipient=${recipient}: SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED is not configured.`, ); return; } @@ -110,6 +113,9 @@ export class EmailService { }; try { + this.logger.log( + `Publishing invite email event to ${EXTERNAL_ACTION_EMAIL_TOPIC} for projectId=${projectId} recipient=${recipient}`, + ); await this.eventBusService.publishProjectEvent( EXTERNAL_ACTION_EMAIL_TOPIC, payload, From 334a029b16ed3f0026d730e90d7a3802fc761fb5 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 23 Feb 2026 13:32:41 +1100 Subject: [PATCH 16/41] Fix issue with event bus client --- .env.example | 1 + README.md | 1 + src/shared/modules/global/eventBus.service.ts | 3 +++ src/shared/utils/event.utils.ts | 1 + test/deployment-validation.e2e-spec.ts | 2 ++ 5 files changed, 8 insertions(+) diff --git a/.env.example b/.env.example index 9a16c6b..5e936af 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ AUTH0_CLIENT_SECRET="" # Kafka Event Bus KAFKA_URL="localhost:9092" +KAFKA_ERROR_TOPIC="common.error.reporting" KAFKA_CLIENT_CERT="" KAFKA_CLIENT_CERT_KEY="" BUSAPI_URL="https://api.topcoder-dev.com/v5" diff --git a/README.md b/README.md index ce7fccd..95bdfa0 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,7 @@ Reference source: `.env.example`. | `AUTH0_CLIENT_ID` | ✅ | - | M2M client ID | | `AUTH0_CLIENT_SECRET` | ✅ | - | M2M client secret | | `KAFKA_URL` | ✅ | - | Kafka broker URL | +| `KAFKA_ERROR_TOPIC` | ✅ | - | Kafka topic used by `tc-bus-api-wrapper` for error events | | `KAFKA_CLIENT_CERT` | - | - | Kafka TLS cert | | `KAFKA_CLIENT_CERT_KEY` | - | - | Kafka TLS key | | `BUSAPI_URL` | ✅ | - | Topcoder Bus API base URL | diff --git a/src/shared/modules/global/eventBus.service.ts b/src/shared/modules/global/eventBus.service.ts index c4d45e0..f55a1ad 100644 --- a/src/shared/modules/global/eventBus.service.ts +++ b/src/shared/modules/global/eventBus.service.ts @@ -40,6 +40,7 @@ type EventBusClient = { const EVENT_BUS_REQUIRED_ENV_KEYS = [ 'BUSAPI_URL', 'KAFKA_URL', + 'KAFKA_ERROR_TOPIC', 'AUTH0_URL', 'AUTH0_AUDIENCE', 'AUTH0_CLIENT_ID', @@ -148,6 +149,7 @@ export class EventBusService { const client = busApiFactory({ BUSAPI_URL: process.env.BUSAPI_URL, KAFKA_URL: process.env.KAFKA_URL, + KAFKA_ERROR_TOPIC: process.env.KAFKA_ERROR_TOPIC, KAFKA_CLIENT_CERT: process.env.KAFKA_CLIENT_CERT, KAFKA_CLIENT_CERT_KEY: process.env.KAFKA_CLIENT_CERT_KEY, AUTH0_URL: process.env.AUTH0_URL, @@ -174,6 +176,7 @@ export class EventBusService { return { BUSAPI_URL: this.toConfigStatus(process.env.BUSAPI_URL), KAFKA_URL: this.toConfigStatus(process.env.KAFKA_URL), + KAFKA_ERROR_TOPIC: this.toConfigStatus(process.env.KAFKA_ERROR_TOPIC), AUTH0_URL: this.toConfigStatus(process.env.AUTH0_URL), AUTH0_AUDIENCE: this.toConfigStatus(process.env.AUTH0_AUDIENCE), AUTH0_CLIENT_ID: this.toConfigStatus(process.env.AUTH0_CLIENT_ID), diff --git a/src/shared/utils/event.utils.ts b/src/shared/utils/event.utils.ts index 8828411..ec72c0c 100644 --- a/src/shared/utils/event.utils.ts +++ b/src/shared/utils/event.utils.ts @@ -85,6 +85,7 @@ function buildBusApiConfig(): Record { return { BUSAPI_URL: process.env.BUSAPI_URL, KAFKA_URL: process.env.KAFKA_URL, + KAFKA_ERROR_TOPIC: process.env.KAFKA_ERROR_TOPIC, KAFKA_CLIENT_CERT: process.env.KAFKA_CLIENT_CERT, KAFKA_CLIENT_CERT_KEY: process.env.KAFKA_CLIENT_CERT_KEY, AUTH0_URL: process.env.AUTH0_URL, diff --git a/test/deployment-validation.e2e-spec.ts b/test/deployment-validation.e2e-spec.ts index c262885..a9618a1 100644 --- a/test/deployment-validation.e2e-spec.ts +++ b/test/deployment-validation.e2e-spec.ts @@ -128,7 +128,9 @@ describe('Deployment validation', () => { 'AUTH0_AUDIENCE', 'AUTH0_CLIENT_ID', 'AUTH0_CLIENT_SECRET', + 'BUSAPI_URL', 'KAFKA_URL', + 'KAFKA_ERROR_TOPIC', ]; const missing = required.filter((key) => !process.env[key]); From 34a86641da76e4cb6bc0f84cd5468b10b7afff31 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 23 Feb 2026 16:07:11 +1100 Subject: [PATCH 17/41] Further configuration of bus API handling --- .env.example | 4 +++- README.md | 8 ++++---- src/shared/modules/global/eventBus.service.ts | 6 ------ src/shared/utils/event.utils.ts | 3 --- test/deployment-validation.e2e-spec.ts | 1 - 5 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 5e936af..2590707 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,9 @@ AUTH0_PROXY_SERVER_URL="https://auth0proxy.topcoder-dev.com" AUTH0_CLIENT_ID="" AUTH0_CLIENT_SECRET="" -# Kafka Event Bus +# Bus API client configuration (via tc-bus-api-wrapper) +# KAFKA_URL is retained only for compatibility with shared env packs; +# current wrapper initialization does not use it. KAFKA_URL="localhost:9092" KAFKA_ERROR_TOPIC="common.error.reporting" KAFKA_CLIENT_CERT="" diff --git a/README.md b/README.md index 95bdfa0..9583974 100644 --- a/README.md +++ b/README.md @@ -293,10 +293,10 @@ Reference source: `.env.example`. | `AUTH0_PROXY_SERVER_URL` | - | - | Auth0 proxy (optional) | | `AUTH0_CLIENT_ID` | ✅ | - | M2M client ID | | `AUTH0_CLIENT_SECRET` | ✅ | - | M2M client secret | -| `KAFKA_URL` | ✅ | - | Kafka broker URL | -| `KAFKA_ERROR_TOPIC` | ✅ | - | Kafka topic used by `tc-bus-api-wrapper` for error events | -| `KAFKA_CLIENT_CERT` | - | - | Kafka TLS cert | -| `KAFKA_CLIENT_CERT_KEY` | - | - | Kafka TLS key | +| `KAFKA_URL` | - | - | Legacy/compatibility setting; not used by current `tc-bus-api-wrapper` client init | +| `KAFKA_ERROR_TOPIC` | ✅ | - | Kafka topic used by `tc-bus-api-wrapper` for `postError` routing (required by wrapper init) | +| `KAFKA_CLIENT_CERT` | - | - | Legacy/compatibility setting; not used by current `tc-bus-api-wrapper` client init | +| `KAFKA_CLIENT_CERT_KEY` | - | - | Legacy/compatibility setting; not used by current `tc-bus-api-wrapper` client init | | `BUSAPI_URL` | ✅ | - | Topcoder Bus API base URL | | `KAFKA_PROJECT_CREATED_TOPIC` | ✅ | `project.created` | Kafka topic | | `KAFKA_PROJECT_UPDATED_TOPIC` | ✅ | `project.updated` | Kafka topic | diff --git a/src/shared/modules/global/eventBus.service.ts b/src/shared/modules/global/eventBus.service.ts index f55a1ad..e92fc4f 100644 --- a/src/shared/modules/global/eventBus.service.ts +++ b/src/shared/modules/global/eventBus.service.ts @@ -39,7 +39,6 @@ type EventBusClient = { const EVENT_BUS_REQUIRED_ENV_KEYS = [ 'BUSAPI_URL', - 'KAFKA_URL', 'KAFKA_ERROR_TOPIC', 'AUTH0_URL', 'AUTH0_AUDIENCE', @@ -145,13 +144,9 @@ export class EventBusService { try { // TODO (quality): TOKEN_CACHE_TIME is hardcoded to 900 seconds. Expose as an environment variable (e.g., AUTH0_TOKEN_CACHE_TIME) for operational flexibility. - // TODO (security): KAFKA_CLIENT_CERT and KAFKA_CLIENT_CERT_KEY are passed directly from environment variables without validation. Ensure these are properly formatted PEM strings before passing to the client. const client = busApiFactory({ BUSAPI_URL: process.env.BUSAPI_URL, - KAFKA_URL: process.env.KAFKA_URL, KAFKA_ERROR_TOPIC: process.env.KAFKA_ERROR_TOPIC, - KAFKA_CLIENT_CERT: process.env.KAFKA_CLIENT_CERT, - KAFKA_CLIENT_CERT_KEY: process.env.KAFKA_CLIENT_CERT_KEY, AUTH0_URL: process.env.AUTH0_URL, AUTH0_AUDIENCE: process.env.AUTH0_AUDIENCE, AUTH0_CLIENT_ID: process.env.AUTH0_CLIENT_ID, @@ -175,7 +170,6 @@ export class EventBusService { private buildConfigStatus(): EventBusConfigStatus { return { BUSAPI_URL: this.toConfigStatus(process.env.BUSAPI_URL), - KAFKA_URL: this.toConfigStatus(process.env.KAFKA_URL), KAFKA_ERROR_TOPIC: this.toConfigStatus(process.env.KAFKA_ERROR_TOPIC), AUTH0_URL: this.toConfigStatus(process.env.AUTH0_URL), AUTH0_AUDIENCE: this.toConfigStatus(process.env.AUTH0_AUDIENCE), diff --git a/src/shared/utils/event.utils.ts b/src/shared/utils/event.utils.ts index ec72c0c..5e7393b 100644 --- a/src/shared/utils/event.utils.ts +++ b/src/shared/utils/event.utils.ts @@ -84,10 +84,7 @@ const logger = LoggerService.forRoot('EventUtils'); function buildBusApiConfig(): Record { return { BUSAPI_URL: process.env.BUSAPI_URL, - KAFKA_URL: process.env.KAFKA_URL, KAFKA_ERROR_TOPIC: process.env.KAFKA_ERROR_TOPIC, - KAFKA_CLIENT_CERT: process.env.KAFKA_CLIENT_CERT, - KAFKA_CLIENT_CERT_KEY: process.env.KAFKA_CLIENT_CERT_KEY, AUTH0_URL: process.env.AUTH0_URL, AUTH0_AUDIENCE: process.env.AUTH0_AUDIENCE, AUTH0_CLIENT_ID: process.env.AUTH0_CLIENT_ID, diff --git a/test/deployment-validation.e2e-spec.ts b/test/deployment-validation.e2e-spec.ts index a9618a1..a346bed 100644 --- a/test/deployment-validation.e2e-spec.ts +++ b/test/deployment-validation.e2e-spec.ts @@ -129,7 +129,6 @@ describe('Deployment validation', () => { 'AUTH0_CLIENT_ID', 'AUTH0_CLIENT_SECRET', 'BUSAPI_URL', - 'KAFKA_URL', 'KAFKA_ERROR_TOPIC', ]; From 8d970f770b704a3d5385f630f405987209d0ee5a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 23 Feb 2026 16:37:31 +1100 Subject: [PATCH 18/41] Fix up known member vs. unknown email when sending project invites --- .../project-invite.service.spec.ts | 162 +++++++++++++++++- .../project-invite/project-invite.service.ts | 16 +- src/shared/services/email.service.ts | 9 +- 3 files changed, 180 insertions(+), 7 deletions(-) diff --git a/src/api/project-invite/project-invite.service.spec.ts b/src/api/project-invite/project-invite.service.spec.ts index f4a1a26..6101b26 100644 --- a/src/api/project-invite/project-invite.service.spec.ts +++ b/src/api/project-invite/project-invite.service.spec.ts @@ -9,7 +9,7 @@ import { PermissionService } from 'src/shared/services/permission.service'; import { ProjectInviteService } from './project-invite.service'; jest.mock('src/shared/utils/event.utils', () => ({ - publishMemberEvent: jest.fn(() => Promise.resolve()), + publishMemberEventSafely: jest.fn(), })); const eventUtils = jest.requireMock('src/shared/utils/event.utils'); @@ -128,6 +128,163 @@ describe('ProjectInviteService', () => { ); expect(response.success).toHaveLength(1); + expect(emailServiceMock.sendInviteEmail).not.toHaveBeenCalled(); + }); + + it('sends invite email with isSSO=true for known user invited by email', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + name: 'Demo', + members: [], + }); + + prismaMock.projectMemberInvite.findMany.mockResolvedValue([]); + + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); + identityServiceMock.lookupMultipleUserEmails.mockResolvedValue([ + { + id: '123', + email: 'member@topcoder.com', + handle: 'member', + }, + ]); + + const txMock = { + projectMemberInvite: { + create: jest.fn().mockResolvedValue({ + id: BigInt(1), + projectId: BigInt(1001), + userId: BigInt(123), + email: 'member@topcoder.com', + role: ProjectMemberRole.customer, + status: InviteStatus.pending, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 99, + updatedBy: 99, + deletedAt: null, + deletedBy: null, + applicationId: null, + }), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => { + if (permission === Permission.CREATE_PROJECT_INVITE_COPILOT) { + return false; + } + + return true; + }, + ); + + await service.createInvites( + '1001', + { + emails: ['member@topcoder.com'], + role: ProjectMemberRole.customer, + }, + { + userId: '99', + isMachine: false, + }, + undefined, + ); + + expect(emailServiceMock.sendInviteEmail).toHaveBeenCalledTimes(1); + expect(emailServiceMock.sendInviteEmail).toHaveBeenCalledWith( + '1001', + expect.objectContaining({ + email: 'member@topcoder.com', + }), + expect.objectContaining({ + userId: '99', + }), + 'Demo', + { + isSSO: true, + }, + ); + }); + + it('sends invite email with isSSO=false for unknown email', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + name: 'Demo', + members: [], + }); + + prismaMock.projectMemberInvite.findMany.mockResolvedValue([]); + + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); + identityServiceMock.lookupMultipleUserEmails.mockResolvedValue([]); + + const txMock = { + projectMemberInvite: { + create: jest.fn().mockResolvedValue({ + id: BigInt(2), + projectId: BigInt(1001), + userId: null, + email: 'unknown@topcoder.com', + role: ProjectMemberRole.customer, + status: InviteStatus.pending, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 99, + updatedBy: 99, + deletedAt: null, + deletedBy: null, + applicationId: null, + }), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => { + if (permission === Permission.CREATE_PROJECT_INVITE_COPILOT) { + return false; + } + + return true; + }, + ); + + await service.createInvites( + '1001', + { + emails: ['unknown@topcoder.com'], + role: ProjectMemberRole.customer, + }, + { + userId: '99', + isMachine: false, + }, + undefined, + ); + + expect(emailServiceMock.sendInviteEmail).toHaveBeenCalledTimes(1); + expect(emailServiceMock.sendInviteEmail).toHaveBeenCalledWith( + '1001', + expect.objectContaining({ + email: 'unknown@topcoder.com', + }), + expect.objectContaining({ + userId: '99', + }), + 'Demo', + { + isSSO: false, + }, + ); }); it('publishes member.added when invite is accepted', async () => { @@ -206,7 +363,7 @@ describe('ProjectInviteService', () => { undefined, ); - expect(eventUtils.publishMemberEvent).toHaveBeenCalledWith( + expect(eventUtils.publishMemberEventSafely).toHaveBeenCalledWith( KAFKA_TOPIC.PROJECT_MEMBER_ADDED, expect.objectContaining({ projectId: '1001', @@ -214,6 +371,7 @@ describe('ProjectInviteService', () => { role: ProjectMemberRole.customer, userId: '123', }), + expect.anything(), ); }); diff --git a/src/api/project-invite/project-invite.service.ts b/src/api/project-invite/project-invite.service.ts index 1895c85..3024012 100644 --- a/src/api/project-invite/project-invite.service.ts +++ b/src/api/project-invite/project-invite.service.ts @@ -204,6 +204,9 @@ export class ProjectInviteService { ); const emailOnlyTargets = emailTargets.emailOnlyTargets; + const userIdsResolvedFromEmails = new Set( + emailTargets.userTargets.map((target) => String(target.userId)), + ); const status = dto.role !== ProjectMemberRole.copilot || canInviteCopilotDirectly @@ -250,13 +253,17 @@ export class ProjectInviteService { for (const invite of success) { const normalizedInvite = this.normalizeEntity(invite); const recipient = invite.email?.trim().toLowerCase(); + const isKnownEmailUserInvite = + invite.userId !== null && + userIdsResolvedFromEmails.has(String(invite.userId)); + if ( invite.email && - !invite.userId && - invite.status === InviteStatus.pending + invite.status === InviteStatus.pending && + (!invite.userId || isKnownEmailUserInvite) ) { this.logger.log( - `Dispatching invite email publish for inviteId=${String(invite.id)} projectId=${projectId} recipient=${recipient}`, + `Dispatching invite email publish for inviteId=${String(invite.id)} projectId=${projectId} recipient=${recipient} isSSO=${String(Boolean(isKnownEmailUserInvite))}`, ); void this.emailService.sendInviteEmail( projectId, @@ -266,6 +273,9 @@ export class ProjectInviteService { handle: user.handle, }, project.name, + { + isSSO: Boolean(isKnownEmailUserInvite), + }, ); continue; } diff --git a/src/shared/services/email.service.ts b/src/shared/services/email.service.ts index b55b579..c6aabcc 100644 --- a/src/shared/services/email.service.ts +++ b/src/shared/services/email.service.ts @@ -18,6 +18,10 @@ export interface InviteEmailInitiator { email?: string; } +export interface InviteEmailOptions { + isSSO?: boolean; +} + const EXTERNAL_ACTION_EMAIL_TOPIC = 'external.action.email'; const DEFAULT_INVITE_EMAIL_SUBJECT = 'You are invited to Topcoder'; const DEFAULT_INVITE_EMAIL_SECTION_TITLE = 'Project Invitation'; @@ -48,6 +52,7 @@ export class EmailService { * @param invite invite payload containing recipient info * @param initiator invite initiator details * @param projectName optional project display name + * @param options additional invite-email options * @returns resolved promise when publish succeeds or is skipped */ async sendInviteEmail( @@ -55,6 +60,7 @@ export class EmailService { invite: InviteEmailPayload, initiator: InviteEmailInitiator, projectName?: string, + options?: InviteEmailOptions, ): Promise { const recipient = invite.email?.trim().toLowerCase(); // TODO: add basic email format validation before publishing the event. @@ -100,8 +106,7 @@ export class EmailService { projectName: normalizedProjectName, projectId, initiator: this.normalizeInitiator(initiator), - // TODO: determine if SSO status should be dynamic based on the invitee's identity provider. - isSSO: false, + isSSO: options?.isSSO ?? false, }, ], }, From c90c1e2fcb913e6b631780eead4ea38f83f414d0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 23 Feb 2026 17:12:16 +1100 Subject: [PATCH 19/41] PM-2684: split invite email template selection for known vs unknown users What was broken:\nInvite emails for existing Topcoder users could render the register CTA instead of the join/decline flow.\n\nRoot cause (if identifiable):\nprojects-api-v6 always sent a single SendGrid invite template id and relied on template-side conditional logic, so known-vs-unknown invite behavior was not enforced by the API.\n\nWhat was changed:\n- Added explicit invite template resolution in EmailService using dedicated env vars for known and unknown users.\n- Known users now use SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID.\n- Unknown emails now use SENDGRID_PROJECT_INVITATION_UNKNOWN_USER_TEMPLATE_ID.\n- Preserved backward compatibility by falling back to SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED when dedicated vars are unset.\n- Updated .env.example and README environment variable docs.\n\nAny added/updated tests:\n- Added src/shared/services/email.service.spec.ts with coverage for known-user template selection, unknown-user template selection, legacy fallback, and no-template skip behavior.\n- Re-ran existing project-invite unit tests to confirm isSSO routing still works. --- .env.example | 6 + README.md | 4 +- src/shared/services/email.service.spec.ts | 152 ++++++++++++++++++++++ src/shared/services/email.service.ts | 38 +++++- 4 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 src/shared/services/email.service.spec.ts diff --git a/.env.example b/.env.example index 2590707..ff1b534 100644 --- a/.env.example +++ b/.env.example @@ -58,7 +58,13 @@ INVITE_EMAIL_SECTION_TITLE="" COPILOT_PORTAL_URL="" WORK_MANAGER_URL="" ACCOUNTS_APP_URL="" +# Dedicated project-invite templates: +# - known user (Join/Decline flow): SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID +# - unknown email (Register flow): SENDGRID_PROJECT_INVITATION_UNKNOWN_USER_TEMPLATE_ID +# Legacy fallback for both invite types: SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED="" +SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID="" +SENDGRID_PROJECT_INVITATION_UNKNOWN_USER_TEMPLATE_ID="" SENDGRID_TEMPLATE_COPILOT_ALREADY_PART_OF_PROJECT="" SENDGRID_TEMPLATE_INFORM_PM_COPILOT_APPLICATION_ACCEPTED="" UNIQUE_GMAIL_VALIDATION=false diff --git a/README.md b/README.md index 9583974..38156a4 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,9 @@ Reference source: `.env.example`. | `SFDC_BILLING_ACCOUNT_MARKUP_FIELD` | - | `Mark_Up__c` | SOQL field name | | `SFDC_BILLING_ACCOUNT_ACTIVE_FIELD` | - | `Active__c` | SOQL field name | | `INVITE_EMAIL_SUBJECT` | - | - | Email subject for invites | -| `SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED` | - | - | SendGrid template ID | +| `SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID` | - | - | SendGrid template ID for registered users (Join/Decline invite email) | +| `SENDGRID_PROJECT_INVITATION_UNKNOWN_USER_TEMPLATE_ID` | - | - | SendGrid template ID for unregistered emails (Register invite email) | +| `SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED` | - | - | Legacy fallback SendGrid template ID when dedicated invite template vars are unset | | `SENDGRID_TEMPLATE_COPILOT_ALREADY_PART_OF_PROJECT` | - | - | SendGrid template ID | | `SENDGRID_TEMPLATE_INFORM_PM_COPILOT_APPLICATION_ACCEPTED` | - | - | SendGrid template ID | | `COPILOT_PORTAL_URL` | - | - | Copilot portal URL (used in invite emails) | diff --git a/src/shared/services/email.service.spec.ts b/src/shared/services/email.service.spec.ts new file mode 100644 index 0000000..d669e52 --- /dev/null +++ b/src/shared/services/email.service.spec.ts @@ -0,0 +1,152 @@ +import { EventBusService } from 'src/shared/modules/global/eventBus.service'; +import { EmailService } from './email.service'; + +describe('EmailService', () => { + const eventBusServiceMock = { + publishProjectEvent: jest.fn(), + }; + + const originalEnv = process.env; + let service: EmailService; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + eventBusServiceMock.publishProjectEvent.mockResolvedValue(undefined); + service = new EmailService( + eventBusServiceMock as unknown as EventBusService, + ); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('uses known-user invite template when isSSO=true', async () => { + process.env.SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID = + 'known-template-id'; + process.env.SENDGRID_PROJECT_INVITATION_UNKNOWN_USER_TEMPLATE_ID = + 'unknown-template-id'; + + await service.sendInviteEmail( + '1001', + { + email: 'KnownUser@topcoder.com', + }, + { + userId: '123', + handle: 'pm', + }, + 'Demo', + { + isSSO: true, + }, + ); + + expect(eventBusServiceMock.publishProjectEvent).toHaveBeenCalledTimes(1); + const [topic, payload] = + eventBusServiceMock.publishProjectEvent.mock.calls[0]; + const normalizedPayload = payload as { + sendgrid_template_id?: string; + recipients?: string[]; + data?: { + projects?: Array<{ + sections?: Array<{ isSSO?: boolean }>; + }>; + }; + }; + + expect(topic).toBe('external.action.email'); + expect(normalizedPayload.sendgrid_template_id).toBe('known-template-id'); + expect(normalizedPayload.recipients).toEqual(['knownuser@topcoder.com']); + expect(normalizedPayload.data?.projects?.[0]?.sections?.[0]?.isSSO).toBe( + true, + ); + }); + + it('uses unknown-user invite template when isSSO=false', async () => { + process.env.SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID = + 'known-template-id'; + process.env.SENDGRID_PROJECT_INVITATION_UNKNOWN_USER_TEMPLATE_ID = + 'unknown-template-id'; + + await service.sendInviteEmail( + '1001', + { + email: 'unknown@topcoder.com', + }, + { + userId: '123', + handle: 'pm', + }, + 'Demo', + { + isSSO: false, + }, + ); + + expect(eventBusServiceMock.publishProjectEvent).toHaveBeenCalledTimes(1); + const [, payload] = eventBusServiceMock.publishProjectEvent.mock.calls[0]; + const normalizedPayload = payload as { + sendgrid_template_id?: string; + data?: { + projects?: Array<{ + sections?: Array<{ isSSO?: boolean }>; + }>; + }; + }; + + expect(normalizedPayload.sendgrid_template_id).toBe('unknown-template-id'); + expect(normalizedPayload.data?.projects?.[0]?.sections?.[0]?.isSSO).toBe( + false, + ); + }); + + it('falls back to legacy invite template when dedicated template is missing', async () => { + process.env.SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED = 'legacy-template-id'; + + await service.sendInviteEmail( + '1001', + { + email: 'known@topcoder.com', + }, + { + userId: '123', + handle: 'pm', + }, + 'Demo', + { + isSSO: true, + }, + ); + + expect(eventBusServiceMock.publishProjectEvent).toHaveBeenCalledTimes(1); + const [, payload] = eventBusServiceMock.publishProjectEvent.mock.calls[0]; + expect( + (payload as { sendgrid_template_id?: string }).sendgrid_template_id, + ).toBe('legacy-template-id'); + }); + + it('skips publish when no invite template is configured', async () => { + delete process.env.SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID; + delete process.env.SENDGRID_PROJECT_INVITATION_UNKNOWN_USER_TEMPLATE_ID; + delete process.env.SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED; + + await service.sendInviteEmail( + '1001', + { + email: 'known@topcoder.com', + }, + { + userId: '123', + handle: 'pm', + }, + 'Demo', + { + isSSO: true, + }, + ); + + expect(eventBusServiceMock.publishProjectEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/services/email.service.ts b/src/shared/services/email.service.ts index c6aabcc..55c6023 100644 --- a/src/shared/services/email.service.ts +++ b/src/shared/services/email.service.ts @@ -19,6 +19,12 @@ export interface InviteEmailInitiator { } export interface InviteEmailOptions { + /** + * Indicates invite target already has a Topcoder account. + * + * `true` uses the known-user invite template and Join/Decline flow. + * `false` uses the unknown-user invite template and Register flow. + */ isSSO?: boolean; } @@ -44,7 +50,7 @@ export class EmailService { * Publishes a project invite email event. * * No-ops when `invite.email` is empty or when - * `SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED` is unset. + * no invite template id env var can be resolved. * * Publishes payload to `external.action.email`. * @@ -72,12 +78,10 @@ export class EmailService { return; } - // TODO: validate this env var at startup and throw if missing, rather than silently skipping. - // TODO: cache these values as private readonly fields in the constructor, consistent with other services. - const templateId = process.env.SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED; + const templateId = this.resolveInviteTemplateId(Boolean(options?.isSSO)); if (!templateId) { this.logger.warn( - `Skipping invite email publish for projectId=${projectId} recipient=${recipient}: SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED is not configured.`, + `Skipping invite email publish for projectId=${projectId} recipient=${recipient}: no invite template id is configured for knownUser=${String(Boolean(options?.isSSO))}.`, ); return; } @@ -133,6 +137,30 @@ export class EmailService { } } + /** + * Resolves invite email template id by target account state. + * + * Prefers dedicated known/unknown template env vars and falls back to the + * legacy `SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED` for compatibility. + * + * @param isKnownUser Whether the invite target is an existing Topcoder user. + * @returns SendGrid template id, or `null` when no compatible env var exists. + */ + private resolveInviteTemplateId(isKnownUser: boolean): string | null { + const knownTemplateId = + process.env.SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID; + const unknownTemplateId = + process.env.SENDGRID_PROJECT_INVITATION_UNKNOWN_USER_TEMPLATE_ID; + const legacyTemplateId = + process.env.SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED; + + if (isKnownUser) { + return knownTemplateId || legacyTemplateId || null; + } + + return unknownTemplateId || legacyTemplateId || null; + } + /** * Normalizes initiator details and applies display-name defaults. * From e3f0af212a2f6c829b84a6c2869d6b9fb4851a1d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 24 Feb 2026 11:57:09 +1100 Subject: [PATCH 20/41] Better formatting of the email payload for Sendgrid for new project invite templates. --- .../project-invite/project-invite.service.ts | 129 +++++++++++++- src/shared/services/email.service.spec.ts | 103 +++++++++-- src/shared/services/email.service.ts | 166 ++++++++++++++---- 3 files changed, 345 insertions(+), 53 deletions(-) diff --git a/src/api/project-invite/project-invite.service.ts b/src/api/project-invite/project-invite.service.ts index 3024012..757d166 100644 --- a/src/api/project-invite/project-invite.service.ts +++ b/src/api/project-invite/project-invite.service.ts @@ -30,7 +30,10 @@ import { KAFKA_TOPIC } from 'src/shared/config/kafka.config'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { LoggerService } from 'src/shared/modules/global/logger.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; -import { EmailService } from 'src/shared/services/email.service'; +import { + EmailService, + type InviteEmailInitiator, +} from 'src/shared/services/email.service'; import { IdentityService } from 'src/shared/services/identity.service'; import { MemberService } from 'src/shared/services/member.service'; import { PermissionService } from 'src/shared/services/permission.service'; @@ -60,6 +63,11 @@ interface InviteTargetByEmail { email: string; } +interface InviteMemberName { + firstName?: string; + lastName?: string; +} + /** * Manages project invite lifecycle across creation, updates, reads, and cancel. * @@ -207,6 +215,9 @@ export class ProjectInviteService { const userIdsResolvedFromEmails = new Set( emailTargets.userTargets.map((target) => String(target.userId)), ); + const knownInviteeNamesByUserId = await this.getMemberNameMapByUserIds( + emailTargets.userTargets.map((target) => target.userId), + ); const status = dto.role !== ProjectMemberRole.copilot || canInviteCopilotDirectly @@ -250,6 +261,8 @@ export class ProjectInviteService { return created; }); + let inviteInitiatorPromise: Promise | null = null; + for (const invite of success) { const normalizedInvite = this.normalizeEntity(invite); const recipient = invite.email?.trim().toLowerCase(); @@ -262,16 +275,27 @@ export class ProjectInviteService { invite.status === InviteStatus.pending && (!invite.userId || isKnownEmailUserInvite) ) { + if (!inviteInitiatorPromise) { + inviteInitiatorPromise = this.resolveInviteEmailInitiator(user); + } + + const inviteeNames = + invite.userId !== null + ? knownInviteeNamesByUserId.get(String(invite.userId)) + : undefined; + const invitePayload = { + ...normalizedInvite, + firstName: inviteeNames?.firstName, + lastName: inviteeNames?.lastName, + }; + this.logger.log( `Dispatching invite email publish for inviteId=${String(invite.id)} projectId=${projectId} recipient=${recipient} isSSO=${String(Boolean(isKnownEmailUserInvite))}`, ); void this.emailService.sendInviteEmail( projectId, - normalizedInvite, - { - userId: user.userId, - handle: user.handle, - }, + invitePayload, + await inviteInitiatorPromise, project.name, { isSSO: Boolean(isKnownEmailUserInvite), @@ -1486,6 +1510,99 @@ export class ProjectInviteService { return undefined; } + /** + * Resolves invite initiator details for email templates. + * + * Uses token context first and enriches with Member API profile fields when + * the caller user id is available. + * + * @param user Authenticated caller. + * @returns Normalized initiator payload for invite emails. + */ + private async resolveInviteEmailInitiator( + user: JwtUser, + ): Promise { + const initiator: InviteEmailInitiator = { + userId: user.userId, + handle: user.handle, + email: this.getUserEmail(user), + }; + + const initiatorUserId = this.parseOptionalId(user.userId); + if (!initiatorUserId) { + return initiator; + } + + const memberDetails = await this.memberService.getMemberDetailsByUserIds([ + initiatorUserId, + ]); + const matchedInitiator = memberDetails.find( + (detail) => + String(detail.userId || '').trim() === String(initiatorUserId), + ); + + if (!matchedInitiator) { + return initiator; + } + + if (matchedInitiator.firstName) { + initiator.firstName = String(matchedInitiator.firstName).trim(); + } + if (matchedInitiator.lastName) { + initiator.lastName = String(matchedInitiator.lastName).trim(); + } + if (matchedInitiator.email) { + initiator.email = String(matchedInitiator.email).trim().toLowerCase(); + } + if (matchedInitiator.handle) { + initiator.handle = String(matchedInitiator.handle).trim(); + } + + return initiator; + } + + /** + * Builds a lookup map of member first/last names keyed by user id. + * + * @param userIds User ids to resolve in Member API. + * @returns Map of user id to first/last name values. + */ + private async getMemberNameMapByUserIds( + userIds: Array, + ): Promise> { + const normalizedUserIds = Array.from( + new Set( + userIds + .map((userId) => String(userId || '').trim()) + .filter((userId) => userId.length > 0), + ), + ); + + if (normalizedUserIds.length === 0) { + return new Map(); + } + + const details = + await this.memberService.getMemberDetailsByUserIds(normalizedUserIds); + const detailsMap = new Map(); + + for (const detail of details) { + const userId = String(detail.userId || '').trim(); + if (!userId) { + continue; + } + + detailsMap.set(userId, { + firstName: detail.firstName + ? String(detail.firstName).trim() + : undefined, + lastName: detail.lastName ? String(detail.lastName).trim() : undefined, + }); + } + + return detailsMap; + } + /** * Resolves UNIQUE_GMAIL_VALIDATION runtime toggle. * diff --git a/src/shared/services/email.service.spec.ts b/src/shared/services/email.service.spec.ts index d669e52..cc4898a 100644 --- a/src/shared/services/email.service.spec.ts +++ b/src/shared/services/email.service.spec.ts @@ -31,11 +31,16 @@ describe('EmailService', () => { await service.sendInviteEmail( '1001', { + id: 'inv-100', email: 'KnownUser@topcoder.com', + firstName: 'John', + lastName: 'Doe', }, { userId: '123', handle: 'pm', + firstName: 'Jane', + lastName: 'Smith', }, 'Demo', { @@ -49,19 +54,24 @@ describe('EmailService', () => { const normalizedPayload = payload as { sendgrid_template_id?: string; recipients?: string[]; - data?: { - projects?: Array<{ - sections?: Array<{ isSSO?: boolean }>; - }>; - }; + data?: Record; }; expect(topic).toBe('external.action.email'); expect(normalizedPayload.sendgrid_template_id).toBe('known-template-id'); expect(normalizedPayload.recipients).toEqual(['knownuser@topcoder.com']); - expect(normalizedPayload.data?.projects?.[0]?.sections?.[0]?.isSSO).toBe( - true, - ); + expect(normalizedPayload.data).toEqual({ + projectName: 'Demo', + firstName: 'John', + lastName: 'Doe', + projectId: '1001', + joinProjectUrl: + 'https://work.topcoder-dev.com/projects/1001/accept/inv-100', + declineProjectUrl: + 'https://work.topcoder-dev.com/projects/1001/decline/inv-100', + initiatorFirstName: 'Jane', + initiatorLastName: 'Smith', + }); }); it('uses unknown-user invite template when isSSO=false', async () => { @@ -73,6 +83,7 @@ describe('EmailService', () => { await service.sendInviteEmail( '1001', { + id: 321, email: 'unknown@topcoder.com', }, { @@ -89,17 +100,20 @@ describe('EmailService', () => { const [, payload] = eventBusServiceMock.publishProjectEvent.mock.calls[0]; const normalizedPayload = payload as { sendgrid_template_id?: string; - data?: { - projects?: Array<{ - sections?: Array<{ isSSO?: boolean }>; - }>; - }; + data?: Record; }; expect(normalizedPayload.sendgrid_template_id).toBe('unknown-template-id'); - expect(normalizedPayload.data?.projects?.[0]?.sections?.[0]?.isSSO).toBe( - false, - ); + expect(normalizedPayload.data).toEqual({ + projectName: 'Demo', + firstName: '', + lastName: '', + projectId: '1001', + registerUrl: + 'https://accounts.topcoder-dev.com/?mode=signUp®Source=tcBusiness&retUrl=https://work.topcoder-dev.com/projects/1001/accept/321', + initiatorFirstName: 'Connect', + initiatorLastName: 'User', + }); }); it('falls back to legacy invite template when dedicated template is missing', async () => { @@ -108,6 +122,7 @@ describe('EmailService', () => { await service.sendInviteEmail( '1001', { + id: 'legacy-1', email: 'known@topcoder.com', }, { @@ -132,6 +147,29 @@ describe('EmailService', () => { delete process.env.SENDGRID_PROJECT_INVITATION_UNKNOWN_USER_TEMPLATE_ID; delete process.env.SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED; + await service.sendInviteEmail( + '1001', + { + id: 'no-template', + email: 'known@topcoder.com', + }, + { + userId: '123', + handle: 'pm', + }, + 'Demo', + { + isSSO: true, + }, + ); + + expect(eventBusServiceMock.publishProjectEvent).not.toHaveBeenCalled(); + }); + + it('skips publish when invite id is missing', async () => { + process.env.SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID = + 'known-template-id'; + await service.sendInviteEmail( '1001', { @@ -149,4 +187,37 @@ describe('EmailService', () => { expect(eventBusServiceMock.publishProjectEvent).not.toHaveBeenCalled(); }); + + it('uses topcoder.com links in production', async () => { + process.env.NODE_ENV = 'production'; + process.env.SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID = + 'known-template-id'; + + await service.sendInviteEmail( + '1001', + { + id: 'prod-1', + email: 'known@topcoder.com', + }, + { + userId: '123', + handle: 'pm', + }, + 'Demo', + { + isSSO: true, + }, + ); + + const [, payload] = eventBusServiceMock.publishProjectEvent.mock.calls[0]; + const normalizedPayload = payload as { + data?: Record; + }; + + expect(normalizedPayload.data).toMatchObject({ + joinProjectUrl: 'https://work.topcoder.com/projects/1001/accept/prod-1', + declineProjectUrl: + 'https://work.topcoder.com/projects/1001/decline/prod-1', + }); + }); }); diff --git a/src/shared/services/email.service.ts b/src/shared/services/email.service.ts index 55c6023..1737e19 100644 --- a/src/shared/services/email.service.ts +++ b/src/shared/services/email.service.ts @@ -8,6 +8,8 @@ export interface InviteEmailPayload { role?: string; email?: string | null; status?: string; + firstName?: string | null; + lastName?: string | null; } export interface InviteEmailInitiator { @@ -29,8 +31,29 @@ export interface InviteEmailOptions { } const EXTERNAL_ACTION_EMAIL_TOPIC = 'external.action.email'; -const DEFAULT_INVITE_EMAIL_SUBJECT = 'You are invited to Topcoder'; -const DEFAULT_INVITE_EMAIL_SECTION_TITLE = 'Project Invitation'; +const TOPCODER_PROD_ROOT_URL = 'topcoder.com'; +const TOPCODER_DEV_ROOT_URL = 'topcoder-dev.com'; + +type KnownUserInviteTemplatePayload = { + projectName: string; + firstName: string; + lastName: string; + projectId: string; + joinProjectUrl: string; + declineProjectUrl: string; + initiatorFirstName: string; + initiatorLastName: string; +}; + +type UnknownUserInviteTemplatePayload = { + projectName: string; + firstName: string; + lastName: string; + projectId: string; + registerUrl: string; + initiatorFirstName: string; + initiatorLastName: string; +}; /** * Project-invite email publisher. @@ -49,7 +72,7 @@ export class EmailService { /** * Publishes a project invite email event. * - * No-ops when `invite.email` is empty or when + * No-ops when `invite.email` is empty, `invite.id` is missing, or when * no invite template id env var can be resolved. * * Publishes payload to `external.action.email`. @@ -78,6 +101,14 @@ export class EmailService { return; } + const inviteId = this.normalizeId(invite.id); + if (!inviteId) { + this.logger.warn( + `Skipping invite email publish for projectId=${projectId} recipient=${recipient}: invite id is missing.`, + ); + return; + } + const templateId = this.resolveInviteTemplateId(Boolean(options?.isSSO)); if (!templateId) { this.logger.warn( @@ -86,36 +117,45 @@ export class EmailService { return; } + const normalizedProjectId = String(projectId).trim(); const normalizedProjectName = projectName?.trim() || `Project ${projectId}`; + const rootUrl = this.resolveRootUrl(); + const joinProjectUrl = this.buildWorkInviteActionUrl( + rootUrl, + normalizedProjectId, + inviteId, + 'accept', + ); + const declineProjectUrl = this.buildWorkInviteActionUrl( + rootUrl, + normalizedProjectId, + inviteId, + 'decline', + ); + const registerUrl = this.buildRegisterUrl(rootUrl, joinProjectUrl); + const normalizedInitiator = this.normalizeInitiator(initiator); + const knownUserPayload: KnownUserInviteTemplatePayload = { + projectName: normalizedProjectName, + firstName: this.normalizeName(invite.firstName), + lastName: this.normalizeName(invite.lastName), + projectId: normalizedProjectId, + joinProjectUrl, + declineProjectUrl, + initiatorFirstName: this.normalizeName(normalizedInitiator.firstName), + initiatorLastName: this.normalizeName(normalizedInitiator.lastName), + }; + const unknownUserPayload: UnknownUserInviteTemplatePayload = { + projectName: normalizedProjectName, + firstName: this.normalizeName(invite.firstName), + lastName: this.normalizeName(invite.lastName), + projectId: normalizedProjectId, + registerUrl, + initiatorFirstName: this.normalizeName(normalizedInitiator.firstName), + initiatorLastName: this.normalizeName(normalizedInitiator.lastName), + }; + const payload = { - data: { - // TODO: cache these values as private readonly fields in the constructor, consistent with other services. - workManagerUrl: process.env.WORK_MANAGER_URL || '', - // TODO: cache these values as private readonly fields in the constructor, consistent with other services. - accountsAppURL: process.env.ACCOUNTS_APP_URL || '', - subject: - // TODO: cache these values as private readonly fields in the constructor, consistent with other services. - process.env.INVITE_EMAIL_SUBJECT || DEFAULT_INVITE_EMAIL_SUBJECT, - projects: [ - { - name: normalizedProjectName, - projectId, - sections: [ - { - EMAIL_INVITES: true, - title: - // TODO: cache these values as private readonly fields in the constructor, consistent with other services. - process.env.INVITE_EMAIL_SECTION_TITLE || - DEFAULT_INVITE_EMAIL_SECTION_TITLE, - projectName: normalizedProjectName, - projectId, - initiator: this.normalizeInitiator(initiator), - isSSO: options?.isSSO ?? false, - }, - ], - }, - ], - }, + data: options?.isSSO ? knownUserPayload : unknownUserPayload, sendgrid_template_id: templateId, recipients: [recipient], version: 'v3', @@ -178,4 +218,68 @@ export class EmailService { email: initiator.email, }; } + + /** + * Resolves the Topcoder root URL for invite links. + * + * `production` uses `topcoder.com`; all other environments use + * `topcoder-dev.com`. + * + * @returns Root URL domain. + */ + private resolveRootUrl(): string { + return process.env.NODE_ENV === 'production' + ? TOPCODER_PROD_ROOT_URL + : TOPCODER_DEV_ROOT_URL; + } + + /** + * Builds a Work app invite action URL. + * + * @param rootUrl Root URL domain. + * @param projectId Project id. + * @param inviteId Invite id. + * @param action URL action segment (`accept` or `decline`). + * @returns Work invite action URL. + */ + private buildWorkInviteActionUrl( + rootUrl: string, + projectId: string, + inviteId: string, + action: 'accept' | 'decline', + ): string { + return `https://work.${rootUrl}/projects/${projectId}/${action}/${inviteId}`; + } + + /** + * Builds accounts registration URL for unknown-user invites. + * + * @param rootUrl Root URL domain. + * @param returnUrl Return URL after registration. + * @returns Accounts sign-up URL including regSource and return URL. + */ + private buildRegisterUrl(rootUrl: string, returnUrl: string): string { + return `https://accounts.${rootUrl}/?mode=signUp®Source=tcBusiness&retUrl=${returnUrl}`; + } + + /** + * Normalizes template name values to plain strings. + * + * @param value Raw name value. + * @returns Trimmed string or empty string. + */ + private normalizeName(value?: string | null): string { + return String(value || '').trim(); + } + + /** + * Converts id-like values to trimmed string. + * + * @param value Raw id-like value. + * @returns Normalized id string or null when empty. + */ + private normalizeId(value?: string | number | bigint | null): string | null { + const normalized = String(value ?? '').trim(); + return normalized.length > 0 ? normalized : null; + } } From 149a31b67362700bb4b71d54d009446c2ac680fd Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 24 Feb 2026 13:06:13 +1100 Subject: [PATCH 21/41] Fix up invite acceptance / decline --- .../project-invite.service.spec.ts | 100 ++++++++++++++++ src/api/project/project.service.spec.ts | 89 +++++++++++++++ src/shared/modules/global/jwt.service.spec.ts | 11 ++ src/shared/modules/global/jwt.service.ts | 11 ++ .../services/permission.service.spec.ts | 42 +++++++ src/shared/services/permission.service.ts | 74 +++++++++++- src/shared/utils/project.utils.ts | 107 ++++++++++++++---- 7 files changed, 409 insertions(+), 25 deletions(-) diff --git a/src/api/project-invite/project-invite.service.spec.ts b/src/api/project-invite/project-invite.service.spec.ts index 6101b26..58f53bd 100644 --- a/src/api/project-invite/project-invite.service.spec.ts +++ b/src/api/project-invite/project-invite.service.spec.ts @@ -375,6 +375,106 @@ describe('ProjectInviteService', () => { ); }); + it('accepts email-only invite and creates project member for authenticated user', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [], + }); + + prismaMock.projectMemberInvite.findFirst.mockResolvedValue({ + id: BigInt(11), + projectId: BigInt(1001), + userId: null, + email: 'jmgasper+devtest140@gmail.com', + role: ProjectMemberRole.customer, + status: InviteStatus.pending, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: 1, + deletedAt: null, + deletedBy: null, + applicationId: null, + }); + + const txMock = { + projectMemberInvite: { + update: jest.fn().mockResolvedValue({ + id: BigInt(11), + projectId: BigInt(1001), + userId: null, + email: 'jmgasper+devtest140@gmail.com', + role: ProjectMemberRole.customer, + status: InviteStatus.accepted, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: 99, + deletedAt: null, + deletedBy: null, + applicationId: null, + }), + }, + projectMember: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ + id: BigInt(778), + projectId: BigInt(1001), + userId: BigInt(88770025), + role: ProjectMemberRole.customer, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 99, + updatedBy: 99, + deletedAt: null, + deletedBy: null, + }), + }, + copilotRequest: { + findMany: jest.fn().mockResolvedValue([]), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + await service.updateInvite( + '1001', + '11', + { + status: InviteStatus.accepted, + }, + { + userId: '88770025', + isMachine: false, + tokenPayload: { + email: 'jmgasper+devtest140@gmail.com', + }, + }, + undefined, + ); + + expect(txMock.projectMember.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + projectId: BigInt(1001), + role: ProjectMemberRole.customer, + userId: BigInt(88770025), + }), + }), + ); + expect(eventUtils.publishMemberEventSafely).toHaveBeenCalledWith( + KAFKA_TOPIC.PROJECT_MEMBER_ADDED, + expect.objectContaining({ + id: '778', + projectId: '1001', + userId: '88770025', + }), + expect.anything(), + ); + }); + it('blocks deleting not-own requested invite without permission', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index ac8e899..7d0b493 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -151,6 +151,95 @@ describe('ProjectService', () => { ); }); + it('lists own email invites when invite userId is missing', async () => { + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => { + if (permission === Permission.READ_PROJECT_ANY) { + return false; + } + + if (permission === Permission.READ_PROJECT_MEMBER) { + return true; + } + + if (permission === Permission.READ_PROJECT_INVITE_NOT_OWN) { + return false; + } + + if (permission === Permission.READ_PROJECT_INVITE_OWN) { + return true; + } + + return true; + }, + ); + + prismaMock.project.count.mockResolvedValue(1); + prismaMock.project.findMany.mockResolvedValue([ + { + id: BigInt(1001), + name: 'Demo', + type: 'app', + status: 'in_review', + lastActivityAt: new Date(), + lastActivityUserId: '100', + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 100, + updatedBy: 100, + version: 'v3', + terms: [], + groups: [], + members: [], + invites: [ + { + id: BigInt(1), + projectId: BigInt(1001), + userId: null, + email: 'JMGasper+devtest140@gmail.com', + role: 'customer', + status: 'pending', + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: BigInt(2), + projectId: BigInt(1001), + userId: null, + email: 'someone-else@example.com', + role: 'customer', + status: 'pending', + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + attachments: [], + }, + ]); + + const result = await service.listProjects( + { + page: 1, + perPage: 20, + fields: 'invites', + }, + { + userId: '88770025', + email: 'jmgasper+devtest140@gmail.com', + isMachine: false, + }, + ); + + expect(result.total).toBe(1); + expect(result.data).toHaveLength(1); + expect(result.data[0].invites).toHaveLength(1); + expect(result.data[0].invites?.[0].email).toBe( + 'JMGasper+devtest140@gmail.com', + ); + }); + it('does not load relation payloads by default in project listing', async () => { permissionServiceMock.hasNamedPermission.mockImplementation( (permission: Permission): boolean => diff --git a/src/shared/modules/global/jwt.service.spec.ts b/src/shared/modules/global/jwt.service.spec.ts index 89ef867..18a0787 100644 --- a/src/shared/modules/global/jwt.service.spec.ts +++ b/src/shared/modules/global/jwt.service.spec.ts @@ -43,4 +43,15 @@ describe('JwtService', () => { expect(user.userId).toBe('auth0|abcd'); }); + + it('extracts lower-cased email from namespaced email claim', async () => { + const token = signToken({ + sub: 'auth0|abcd', + 'https://topcoder-dev.com/email': 'User+Alias@Example.com', + }); + + const user = await service.validateToken(token); + + expect(user.email).toBe('user+alias@example.com'); + }); }); diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index 7a47c9a..7aa96e9 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -32,6 +32,10 @@ export interface JwtUser { * Primary user identifier when present (for example `userId` or `sub`). */ userId?: string; + /** + * User email extracted from token claims. + */ + email?: string; /** * Topcoder handle extracted from token claims. */ @@ -349,6 +353,13 @@ export class JwtService implements OnModuleInit { } } + if (!user.email && lowerKey.endsWith('email')) { + const value = payload[key]; + if (typeof value === 'string' && value.trim().length > 0) { + user.email = value.trim().toLowerCase(); + } + } + if ( (!user.roles || user.roles.length === 0) && lowerKey.endsWith('roles') diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index 6a7e98b..85e3366 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -213,6 +213,48 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it('allows viewing project for pending email invite that matches user email', () => { + const allowed = service.hasNamedPermission( + Permission.VIEW_PROJECT, + { + userId: '88770025', + email: 'jmgasper+devtest140@gmail.com', + isMachine: false, + }, + [], + [ + { + email: 'JMGasper+devtest140@gmail.com', + status: 'pending', + }, + ], + ); + + expect(allowed).toBe(true); + }); + + it('allows viewing project for pending email invite using namespaced email claim', () => { + const allowed = service.hasNamedPermission( + Permission.VIEW_PROJECT, + { + userId: '88770025', + isMachine: false, + tokenPayload: { + 'https://topcoder-dev.com/email': 'jmgasper+devtest140@gmail.com', + }, + }, + [], + [ + { + email: 'jmgasper+devtest140@gmail.com', + status: 'pending', + }, + ], + ); + + expect(allowed).toBe(true); + }); + it('allows editing project for machine token with project write scope', () => { const allowed = service.hasNamedPermission(Permission.EDIT_PROJECT, { scopes: [Scope.PROJECTS_WRITE], diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index 1b2f931..41315b6 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -196,14 +196,18 @@ export class PermissionService { return false; } - if (!invite.userId) { - return false; + if ( + invite.userId && + this.normalizeUserId(invite.userId) === + this.normalizeUserId(user.userId) + ) { + return true; } - return ( - this.normalizeUserId(invite.userId) === - this.normalizeUserId(user.userId) - ); + const inviteEmail = this.normalizeEmail(invite.email); + const userEmail = this.getUserEmail(user); + + return Boolean(inviteEmail && userEmail && inviteEmail === userEmail); }); // TODO: extract to private isAdminManagerOrCopilot() helper to reduce duplication. @@ -573,6 +577,64 @@ export class PermissionService { return String(userId || '').trim(); } + /** + * Reads normalized user email from parsed claims. + * + * Uses `JwtUser.email` first, then falls back to suffix-based lookup in + * `tokenPayload` for compatibility with namespaced claims. + * + * @param user authenticated JWT user + * @returns lower-cased email or `undefined` + */ + private getUserEmail(user: JwtUser): string | undefined { + const directEmail = this.normalizeEmail(user.email); + + if (directEmail) { + return directEmail; + } + + const payload = user.tokenPayload; + + if (!payload || typeof payload !== 'object') { + return undefined; + } + + for (const key of Object.keys(payload)) { + if (!key.toLowerCase().endsWith('email')) { + continue; + } + + const value = (payload as Record)[key]; + if (typeof value !== 'string') { + continue; + } + + const normalizedEmail = this.normalizeEmail(value); + + if (normalizedEmail) { + return normalizedEmail; + } + } + + return undefined; + } + + /** + * Normalizes email values for case-insensitive comparisons. + * + * @param value raw email value + * @returns lower-cased trimmed email or `undefined` + */ + private normalizeEmail(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalizedEmail = value.trim().toLowerCase(); + + return normalizedEmail.length > 0 ? normalizedEmail : undefined; + } + /** * Evaluates a project-role rule against a specific member. * diff --git a/src/shared/utils/project.utils.ts b/src/shared/utils/project.utils.ts index d8ee3ce..4b84c74 100644 --- a/src/shared/utils/project.utils.ts +++ b/src/shared/utils/project.utils.ts @@ -59,6 +59,50 @@ function normalizeUserId( return String(value).trim(); } +/** + * Normalizes emails for case-insensitive comparisons. + */ +function normalizeEmail(value: unknown): string { + if (typeof value !== 'string') { + return ''; + } + + return value.trim().toLowerCase(); +} + +/** + * Extracts normalized user email from parsed claims. + * + * Prefers `JwtUser.email` and falls back to namespaced payload keys ending in + * `email`. + */ +function getUserEmail(user: JwtUser): string { + const directEmail = normalizeEmail(user.email); + if (directEmail.length > 0) { + return directEmail; + } + + const payload = user.tokenPayload; + if (!payload || typeof payload !== 'object') { + return ''; + } + + for (const key of Object.keys(payload)) { + if (!key.toLowerCase().endsWith('email')) { + continue; + } + + const normalizedEmail = normalizeEmail( + (payload as Record)[key], + ); + if (normalizedEmail.length > 0) { + return normalizedEmail; + } + } + + return ''; +} + /** * Parses comma-separated values into a trimmed non-empty string list. */ @@ -366,28 +410,46 @@ export function buildProjectWhereClause( if (!isAdmin || memberOnly) { const userId = toBigInt(normalizeUserId(user.userId)); + const userEmail = getUserEmail(user); + const visibilityFilters: Prisma.ProjectWhereInput[] = []; if (userId) { - appendAndCondition(where, { - OR: [ - { - members: { - some: { - userId, - deletedAt: null, - }, + visibilityFilters.push( + { + members: { + some: { + userId, + deletedAt: null, }, }, - { - invites: { - some: { - userId, - status: 'pending', - deletedAt: null, - }, + }, + { + invites: { + some: { + userId, + status: 'pending', + deletedAt: null, }, }, - ], + }, + ); + } + + if (userEmail.length > 0) { + visibilityFilters.push({ + invites: { + some: { + email: userEmail, + status: 'pending', + deletedAt: null, + }, + }, + }); + } + + if (visibilityFilters.length > 0) { + appendAndCondition(where, { + OR: visibilityFilters, }); } } @@ -443,6 +505,7 @@ export function buildProjectIncludeClause( type InviteLike = { userId?: string | number | bigint | null; + email?: string | null; }; /** @@ -450,7 +513,7 @@ type InviteLike = { * * Returns: * - all invites when `hasReadAll` is true, - * - only own invites when `hasReadOwn` is true, + * - only own invites (by `userId` or `email`) when `hasReadOwn` is true, * - empty list otherwise. */ export function filterInvitesByPermission( @@ -472,13 +535,19 @@ export function filterInvitesByPermission( } const normalizedUserId = normalizeUserId(user.userId); + const normalizedUserEmail = getUserEmail(user); return invites.filter((invite) => { - if (!invite.userId) { + const inviteUserId = normalizeUserId(invite.userId); + if (inviteUserId.length > 0 && inviteUserId === normalizedUserId) { + return true; + } + + if (normalizedUserEmail.length === 0) { return false; } - return normalizeUserId(invite.userId) === normalizedUserId; + return normalizeEmail(invite.email) === normalizedUserEmail; }); } From b56d778ad2e8230b9ee55c8e5ea00a0ed2336d55 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 24 Feb 2026 17:15:14 +1100 Subject: [PATCH 22/41] PM-2684: preserve legacy invite payload on legacy template fallback What was broken: When dedicated known/unknown invite template IDs were unset and the service fell back to SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED, it still sent the new payload shape instead of the legacy payload expected by that template. Root cause (if identifiable): Template ID fallback remained in place after payload formatting was split for the new templates, but legacy payload compatibility (including sections[].isSSO) was not preserved. What was changed: - Added legacy-template detection in EmailService when resolved template ID matches SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED. - Restored legacy payload construction for that path, including work/accounts URLs, subject/title fields, and sections[].isSSO. - Kept dedicated known/unknown template payloads unchanged. Any added/updated tests: - Updated EmailService fallback test to validate legacy payload structure and isSSO propagation when legacy template fallback is used. --- src/shared/services/email.service.spec.ts | 18 ++++- src/shared/services/email.service.ts | 89 ++++++++++++++++++++++- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/shared/services/email.service.spec.ts b/src/shared/services/email.service.spec.ts index cc4898a..82ca40a 100644 --- a/src/shared/services/email.service.spec.ts +++ b/src/shared/services/email.service.spec.ts @@ -137,9 +137,21 @@ describe('EmailService', () => { expect(eventBusServiceMock.publishProjectEvent).toHaveBeenCalledTimes(1); const [, payload] = eventBusServiceMock.publishProjectEvent.mock.calls[0]; - expect( - (payload as { sendgrid_template_id?: string }).sendgrid_template_id, - ).toBe('legacy-template-id'); + const normalizedPayload = payload as { + sendgrid_template_id?: string; + data?: { + projects?: Array<{ + sections?: Array<{ + isSSO?: boolean; + }>; + }>; + }; + }; + + expect(normalizedPayload.sendgrid_template_id).toBe('legacy-template-id'); + expect(normalizedPayload.data?.projects?.[0]?.sections?.[0]?.isSSO).toBe( + true, + ); }); it('skips publish when no invite template is configured', async () => { diff --git a/src/shared/services/email.service.ts b/src/shared/services/email.service.ts index 1737e19..09a779b 100644 --- a/src/shared/services/email.service.ts +++ b/src/shared/services/email.service.ts @@ -33,6 +33,8 @@ export interface InviteEmailOptions { const EXTERNAL_ACTION_EMAIL_TOPIC = 'external.action.email'; const TOPCODER_PROD_ROOT_URL = 'topcoder.com'; const TOPCODER_DEV_ROOT_URL = 'topcoder-dev.com'; +const DEFAULT_INVITE_EMAIL_SUBJECT = 'You are invited to Topcoder'; +const DEFAULT_INVITE_EMAIL_SECTION_TITLE = 'Project Invitation'; type KnownUserInviteTemplatePayload = { projectName: string; @@ -55,6 +57,24 @@ type UnknownUserInviteTemplatePayload = { initiatorLastName: string; }; +type LegacyInviteTemplatePayload = { + workManagerUrl: string; + accountsAppURL: string; + subject: string; + projects: Array<{ + name: string; + projectId: string; + sections: Array<{ + EMAIL_INVITES: boolean; + title: string; + projectName: string; + projectId: string; + initiator: InviteEmailInitiator; + isSSO: boolean; + }>; + }>; +}; + /** * Project-invite email publisher. * @@ -109,10 +129,11 @@ export class EmailService { return; } - const templateId = this.resolveInviteTemplateId(Boolean(options?.isSSO)); + const isKnownUser = Boolean(options?.isSSO); + const templateId = this.resolveInviteTemplateId(isKnownUser); if (!templateId) { this.logger.warn( - `Skipping invite email publish for projectId=${projectId} recipient=${recipient}: no invite template id is configured for knownUser=${String(Boolean(options?.isSSO))}.`, + `Skipping invite email publish for projectId=${projectId} recipient=${recipient}: no invite template id is configured for knownUser=${String(isKnownUser)}.`, ); return; } @@ -153,9 +174,19 @@ export class EmailService { initiatorFirstName: this.normalizeName(normalizedInitiator.firstName), initiatorLastName: this.normalizeName(normalizedInitiator.lastName), }; + const useLegacyPayload = this.isLegacyTemplateId(templateId); const payload = { - data: options?.isSSO ? knownUserPayload : unknownUserPayload, + data: useLegacyPayload + ? this.buildLegacyInviteTemplatePayload( + normalizedProjectName, + normalizedProjectId, + normalizedInitiator, + isKnownUser, + ) + : isKnownUser + ? knownUserPayload + : unknownUserPayload, sendgrid_template_id: templateId, recipients: [recipient], version: 'v3', @@ -201,6 +232,18 @@ export class EmailService { return unknownTemplateId || legacyTemplateId || null; } + /** + * Checks whether selected template id maps to the legacy invite template. + * + * @param templateId Resolved template id. + * @returns `true` when legacy template id is being used. + */ + private isLegacyTemplateId(templateId: string): boolean { + const legacyTemplateId = + process.env.SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED; + return Boolean(legacyTemplateId && templateId === legacyTemplateId); + } + /** * Normalizes initiator details and applies display-name defaults. * @@ -219,6 +262,46 @@ export class EmailService { }; } + /** + * Builds payload expected by legacy invite template. + * + * @param projectName Project display name. + * @param projectId Project id. + * @param initiator Normalized invite initiator. + * @param isKnownUser Whether invite target already has a Topcoder account. + * @returns Legacy invite payload data. + */ + private buildLegacyInviteTemplatePayload( + projectName: string, + projectId: string, + initiator: InviteEmailInitiator, + isKnownUser: boolean, + ): LegacyInviteTemplatePayload { + return { + workManagerUrl: process.env.WORK_MANAGER_URL || '', + accountsAppURL: process.env.ACCOUNTS_APP_URL || '', + subject: process.env.INVITE_EMAIL_SUBJECT || DEFAULT_INVITE_EMAIL_SUBJECT, + projects: [ + { + name: projectName, + projectId, + sections: [ + { + EMAIL_INVITES: true, + title: + process.env.INVITE_EMAIL_SECTION_TITLE || + DEFAULT_INVITE_EMAIL_SECTION_TITLE, + projectName, + projectId, + initiator, + isSSO: isKnownUser, + }, + ], + }, + ], + }; + } + /** * Resolves the Topcoder root URL for invite links. * From 6acde8382bf94c907e806834fdbdbda31027823f Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 25 Feb 2026 14:11:30 +1100 Subject: [PATCH 23/41] Fix for pulling metadata with auth token (for old work-manager compatibility) --- .../project-type/project-type.controller.ts | 7 +++++-- test/metadata.e2e-spec.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/api/metadata/project-type/project-type.controller.ts b/src/api/metadata/project-type/project-type.controller.ts index 644f398..aafa258 100644 --- a/src/api/metadata/project-type/project-type.controller.ts +++ b/src/api/metadata/project-type/project-type.controller.ts @@ -17,6 +17,7 @@ import { } from '@nestjs/swagger'; import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; import { AdminOnly } from 'src/shared/guards/adminOnly.guard'; +import { AnyAuthenticated } from 'src/shared/guards/tokenRoles.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { getAuditUserIdNumber } from '../utils/metadata-utils'; import { CreateProjectTypeDto } from './dto/create-project-type.dto'; @@ -26,14 +27,17 @@ import { ProjectTypeService } from './project-type.service'; @ApiTags('Metadata - Project Types') @ApiBearerAuth() +@AnyAuthenticated() @Controller('/projects/metadata/projectTypes') /** * REST controller for project type metadata. + * + * Read endpoints allow any authenticated caller. Write endpoints require admin + * access via `@AdminOnly()`. */ export class ProjectTypeController { constructor(private readonly projectTypeService: ProjectTypeService) {} - // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get() @ApiOperation({ summary: 'List project types' }) @ApiResponse({ status: 200, type: [ProjectTypeResponseDto] }) @@ -44,7 +48,6 @@ export class ProjectTypeController { return this.projectTypeService.findAll(); } - // TODO (SECURITY): This GET endpoint has no auth guard and is not marked @Public(). Clarify intent. @Get(':key') @ApiOperation({ summary: 'Get project type by key' }) @ApiParam({ name: 'key', description: 'Project type key' }) diff --git a/test/metadata.e2e-spec.ts b/test/metadata.e2e-spec.ts index fcfe1dd..bcdd1d6 100644 --- a/test/metadata.e2e-spec.ts +++ b/test/metadata.e2e-spec.ts @@ -438,6 +438,21 @@ describe('Metadata (e2e)', () => { ); }); + it('lists project types for authenticated users', async () => { + const response = await request(app.getHttpServer()) + .get('/v6/projects/metadata/projectTypes') + .set('Authorization', 'Bearer user-token') + .expect(200); + + expect(response.body).toEqual([]); + }); + + it('rejects project type list without authentication', async () => { + await request(app.getHttpServer()) + .get('/v6/projects/metadata/projectTypes') + .expect(401); + }); + it('creates a new form version with auto-incremented version', async () => { const response = await request(app.getHttpServer()) .post('/v6/projects/metadata/form/form_key/versions') From 286ecdfc3f5bd611f3b2f99483a701078a6e9ef0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 27 Feb 2026 15:02:34 +1100 Subject: [PATCH 24/41] Better copilot notifications for all of the various emails that need to be sent. --- .env.example | 2 + .../copilot/copilot-notification.service.ts | 275 +++++++++++++----- src/api/copilot/copilot-request.service.ts | 34 ++- .../project-invite/project-invite.module.ts | 3 +- .../project-invite.service.spec.ts | 6 + .../project-invite/project-invite.service.ts | 58 ++++ src/api/project/project.service.spec.ts | 72 +++++ src/api/project/project.service.ts | 53 +++- src/shared/services/member.service.ts | 103 +++++++ 9 files changed, 530 insertions(+), 76 deletions(-) diff --git a/.env.example b/.env.example index 9a16c6b..bb05ec6 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,8 @@ ACCOUNTS_APP_URL="" SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED="" SENDGRID_TEMPLATE_COPILOT_ALREADY_PART_OF_PROJECT="" SENDGRID_TEMPLATE_INFORM_PM_COPILOT_APPLICATION_ACCEPTED="" +SENDGRID_TEMPLATE_COPILOT_REQUEST_CREATED="" +COPILOTS_SLACK_EMAIL="" UNIQUE_GMAIL_VALIDATION=false # API Configuration diff --git a/src/api/copilot/copilot-notification.service.ts b/src/api/copilot/copilot-notification.service.ts index 5b20397..1c6da61 100644 --- a/src/api/copilot/copilot-notification.service.ts +++ b/src/api/copilot/copilot-notification.service.ts @@ -4,15 +4,22 @@ import { CopilotOpportunity, CopilotOpportunityType, CopilotRequest, - ProjectMemberRole, } from '@prisma/client'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { EventBusService } from 'src/shared/modules/global/eventBus.service'; import { LoggerService } from 'src/shared/modules/global/logger.service'; -import { PrismaService } from 'src/shared/modules/global/prisma.service'; -import { MemberService } from 'src/shared/services/member.service'; +import { + MemberService, + RoleSubjectRecord, +} from 'src/shared/services/member.service'; import { getCopilotRequestData, getCopilotTypeLabel } from './copilot.utils'; +const EXTERNAL_ACTION_EMAIL_TOPIC = 'external.action.email'; + const TEMPLATE_IDS = { APPLY_COPILOT: 'd-d7c1f48628654798a05c8e09e52db14f', + CREATE_REQUEST: 'd-3efdc91da580479d810c7acd50a4c17f', + INFORM_PM_COPILOT_APPLICATION_ACCEPTED: 'd-b35d073e302b4279a1bd208fcfe96f58', COPILOT_APPLICATION_ACCEPTED: 'd-eef5e7568c644940b250e76d026ced5b', COPILOT_ALREADY_PART_OF_PROJECT: 'd-003d41cdc9de4bbc9e14538e8f2e0585', COPILOT_OPPORTUNITY_COMPLETED: 'd-dc448919d11b4e7d8b4ba351c4b67b8b', @@ -29,81 +36,122 @@ type ApplicationWithMembership = CopilotApplication & { }; }; +type EmailRecipient = { + email?: string | null; + handle?: string | null; +}; + @Injectable() export class CopilotNotificationService { private readonly logger = LoggerService.forRoot('CopilotNotificationService'); constructor( - private readonly prisma: PrismaService, private readonly memberService: MemberService, + private readonly eventBusService: EventBusService, ) {} - async sendCopilotApplicationNotification( - opportunity: OpportunityWithRequest, - application: CopilotApplication, + async sendOpportunityPostedNotification( + opportunity: CopilotOpportunity, + copilotRequest?: CopilotRequest | null, ): Promise { - if (!opportunity.projectId) { - return; - } - - const projectMembers = await this.prisma.projectMember.findMany({ - where: { - projectId: opportunity.projectId, - role: { - in: [ProjectMemberRole.manager, ProjectMemberRole.project_manager], - }, - deletedAt: null, - }, - select: { - userId: true, - }, - }); - - const requestCreatorId = - typeof opportunity.copilotRequest?.createdBy === 'number' - ? BigInt(opportunity.copilotRequest.createdBy) - : null; - - const recipientIds = Array.from( - new Set( - [ - ...projectMembers.map((member) => member.userId), - ...(requestCreatorId ? [requestCreatorId] : []), - ].map((id) => id.toString()), - ), + const roleSubjects = await this.memberService.getRoleSubjectsByRoleName( + UserRole.TC_COPILOT, ); - if (recipientIds.length === 0) { - return; - } - - const recipients = - await this.memberService.getMemberDetailsByUserIds(recipientIds); - - const requestData = getCopilotRequestData(opportunity.copilotRequest?.data); + const recipients = this.mergeRecipients(roleSubjects); + const requestData = getCopilotRequestData(copilotRequest?.data); const opportunityType = this.resolveOpportunityType( opportunity, requestData, ); - await Promise.all( - recipients - .filter((recipient) => Boolean(recipient.email)) - .map((recipient) => + if (recipients.length > 0) { + await Promise.all( + recipients.map((recipient) => this.publishEmail( - TEMPLATE_IDS.APPLY_COPILOT, - [String(recipient.email)], + process.env.SENDGRID_TEMPLATE_COPILOT_REQUEST_CREATED || + TEMPLATE_IDS.CREATE_REQUEST, + [recipient.email], { - user_name: recipient.handle, - opportunity_details_url: `${this.getCopilotPortalUrl()}/opportunity/${opportunity.id.toString()}#applications`, + user_name: recipient.handle || 'Copilot', + opportunity_details_url: `${this.getCopilotPortalUrl()}/opportunity/${opportunity.id.toString()}`, work_manager_url: this.getWorkManagerUrl(), opportunity_type: getCopilotTypeLabel(opportunityType), opportunity_title: this.readString(requestData.opportunityTitle) || `Opportunity ${opportunity.id.toString()}`, + start_date: this.formatDate(requestData.startDate), }, ), ), + ); + } + + const slackRecipient = String(process.env.COPILOTS_SLACK_EMAIL || '') + .trim() + .toLowerCase(); + + if (slackRecipient) { + await this.publishEmail( + process.env.SENDGRID_TEMPLATE_COPILOT_REQUEST_CREATED || + TEMPLATE_IDS.CREATE_REQUEST, + [slackRecipient], + { + user_name: 'Copilots', + opportunity_details_url: `${this.getCopilotPortalUrl()}/opportunity/${opportunity.id.toString()}`, + work_manager_url: this.getWorkManagerUrl(), + opportunity_type: getCopilotTypeLabel(opportunityType), + opportunity_title: + this.readString(requestData.opportunityTitle) || + `Opportunity ${opportunity.id.toString()}`, + start_date: this.formatDate(requestData.startDate), + }, + ); + } + + this.logger.log( + `Sent new copilot opportunity notifications for opportunity=${opportunity.id.toString()}`, + ); + } + + async sendCopilotApplicationNotification( + opportunity: OpportunityWithRequest, + application: CopilotApplication, + ): Promise { + const pmRoleRecipients = await this.memberService.getRoleSubjectsByRoleName( + UserRole.PROJECT_MANAGER, + ); + const creatorRecipients = + await this.memberService.getMemberDetailsByUserIds([ + opportunity.createdBy, + ]); + const normalizedRecipients = this.mergeRecipients( + pmRoleRecipients, + creatorRecipients, + ); + + if (normalizedRecipients.length === 0) { + return; + } + + const requestData = getCopilotRequestData(opportunity.copilotRequest?.data); + const opportunityType = this.resolveOpportunityType( + opportunity, + requestData, + ); + + await Promise.all( + normalizedRecipients.map((recipient) => + this.publishEmail(TEMPLATE_IDS.APPLY_COPILOT, [recipient.email], { + user_name: recipient.handle || 'Project Manager', + opportunity_details_url: `${this.getCopilotPortalUrl()}/opportunity/${opportunity.id.toString()}#applications`, + work_manager_url: this.getWorkManagerUrl(), + opportunity_type: getCopilotTypeLabel(opportunityType), + opportunity_title: + this.readString(requestData.opportunityTitle) || + `Opportunity ${opportunity.id.toString()}`, + }), + ), ); this.logger.log( @@ -132,11 +180,7 @@ export class CopilotNotificationService { requestData, ); - const membershipRole = String( - application.existingMembership?.role || '', - ).toLowerCase(); - - const templateId = ['copilot', 'manager'].includes(membershipRole) + const templateId = application.existingMembership ? TEMPLATE_IDS.COPILOT_ALREADY_PART_OF_PROJECT : TEMPLATE_IDS.COPILOT_APPLICATION_ACCEPTED; @@ -156,6 +200,58 @@ export class CopilotNotificationService { ); } + async sendCopilotInviteAcceptedNotification( + opportunity: OpportunityWithRequest, + application: CopilotApplication, + ): Promise { + const pmRoleRecipients = await this.memberService.getRoleSubjectsByRoleName( + UserRole.PROJECT_MANAGER, + ); + const creatorRecipients = + await this.memberService.getMemberDetailsByUserIds([ + opportunity.createdBy, + ]); + const normalizedRecipients = this.mergeRecipients( + pmRoleRecipients, + creatorRecipients, + ); + + if (normalizedRecipients.length === 0) { + return; + } + + const [invitee] = await this.memberService.getMemberDetailsByUserIds([ + application.userId, + ]); + const requestData = getCopilotRequestData(opportunity.copilotRequest?.data); + const opportunityType = this.resolveOpportunityType( + opportunity, + requestData, + ); + const templateId = + process.env.SENDGRID_TEMPLATE_INFORM_PM_COPILOT_APPLICATION_ACCEPTED || + TEMPLATE_IDS.INFORM_PM_COPILOT_APPLICATION_ACCEPTED; + + await Promise.all( + normalizedRecipients.map((recipient) => + this.publishEmail(templateId, [recipient.email], { + user_name: recipient.handle || 'Project Manager', + opportunity_details_url: `${this.getCopilotPortalUrl()}/opportunity/${opportunity.id.toString()}#applications`, + work_manager_url: this.getWorkManagerUrl(), + opportunity_type: getCopilotTypeLabel(opportunityType), + opportunity_title: + this.readString(requestData.opportunityTitle) || + `Opportunity ${opportunity.id.toString()}`, + copilot_handle: invitee?.handle || '', + }), + ), + ); + + this.logger.log( + `Sent copilot invite accepted notifications for opportunity=${opportunity.id.toString()} application=${application.id.toString()}`, + ); + } + async sendCopilotRejectedNotification( opportunity: OpportunityWithRequest, applications: CopilotApplication[], @@ -235,21 +331,39 @@ export class CopilotNotificationService { ); } - private publishEmail( + private async publishEmail( templateId: string, recipients: string[], data: Record, ): Promise { - if (recipients.length === 0) { - return Promise.resolve(); + const normalizedRecipients = recipients + .map((recipient) => + String(recipient || '') + .trim() + .toLowerCase(), + ) + .filter((recipient) => recipient.length > 0); + + if (normalizedRecipients.length === 0) { + return; } - void templateId; - void data; - this.logger.warn( - `Copilot email Kafka publication is disabled. Skipped ${recipients.length} recipient(s).`, - ); - return Promise.resolve(); + try { + await this.eventBusService.publishProjectEvent( + EXTERNAL_ACTION_EMAIL_TOPIC, + { + data, + sendgrid_template_id: templateId, + recipients: normalizedRecipients, + version: 'v3', + }, + ); + } catch (error) { + this.logger.error( + `Failed to publish copilot email event for recipients=${normalizedRecipients.join(',')}: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ); + } } private getWorkManagerUrl(): string { @@ -317,4 +431,33 @@ export class CopilotNotificationService { return undefined; } + + private mergeRecipients( + ...sources: Array> + ): Array<{ email: string; handle?: string }> { + const recipients = new Map(); + + sources.flat().forEach((recipient) => { + const email = String(recipient.email || '') + .trim() + .toLowerCase(); + if (!email) { + return; + } + + const handle = String(recipient.handle || '').trim(); + const existing = recipients.get(email); + + if (!existing) { + recipients.set(email, { email, ...(handle ? { handle } : {}) }); + return; + } + + if (!existing.handle && handle) { + recipients.set(email, { ...existing, handle }); + } + }); + + return Array.from(recipients.values()); + } } diff --git a/src/api/copilot/copilot-request.service.ts b/src/api/copilot/copilot-request.service.ts index fd67e25..7aae099 100644 --- a/src/api/copilot/copilot-request.service.ts +++ b/src/api/copilot/copilot-request.service.ts @@ -17,6 +17,7 @@ import { Permission as NamedPermission } from 'src/shared/constants/permissions' import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { PermissionService } from 'src/shared/services/permission.service'; +import { CopilotNotificationService } from './copilot-notification.service'; import { CopilotRequestListQueryDto, CopilotRequestResponseDto, @@ -65,6 +66,7 @@ export class CopilotRequestService { constructor( private readonly prisma: PrismaService, private readonly permissionService: PermissionService, + private readonly notificationService: CopilotNotificationService, ) {} async listRequests( @@ -205,7 +207,7 @@ export class CopilotRequestService { }, }); - await this.approveRequestInternal( + const opportunity = await this.approveRequestInternal( tx, parsedProjectId, request.id, @@ -213,7 +215,7 @@ export class CopilotRequestService { auditUserId, ); - return tx.copilotRequest.findFirst({ + const createdRequest = await tx.copilotRequest.findFirst({ where: { id: request.id, deletedAt: null, @@ -229,13 +231,23 @@ export class CopilotRequestService { }, }, }); + + return { + opportunity, + request: createdRequest, + }; }); - if (!created) { + if (!created.request) { throw new NotFoundException('Unable to create copilot request.'); } - return this.formatRequest(created, isAdminOrManager(user)); + await this.notificationService.sendOpportunityPostedNotification( + created.opportunity, + created.request, + ); + + return this.formatRequest(created.request, isAdminOrManager(user)); } async updateRequest( @@ -360,6 +372,20 @@ export class CopilotRequestService { ), ); + const request = opportunity.copilotRequestId + ? await this.prisma.copilotRequest.findFirst({ + where: { + id: opportunity.copilotRequestId, + deletedAt: null, + }, + }) + : null; + + await this.notificationService.sendOpportunityPostedNotification( + opportunity, + request, + ); + return normalizeEntity(opportunity); } diff --git a/src/api/project-invite/project-invite.module.ts b/src/api/project-invite/project-invite.module.ts index a944a29..c04b731 100644 --- a/src/api/project-invite/project-invite.module.ts +++ b/src/api/project-invite/project-invite.module.ts @@ -1,5 +1,6 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { CopilotNotificationService } from 'src/api/copilot/copilot-notification.service'; import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; import { ProjectInviteController } from './project-invite.controller'; import { ProjectInviteService } from './project-invite.service'; @@ -7,7 +8,7 @@ import { ProjectInviteService } from './project-invite.service'; @Module({ imports: [HttpModule, GlobalProvidersModule], controllers: [ProjectInviteController], - providers: [ProjectInviteService], + providers: [ProjectInviteService, CopilotNotificationService], exports: [ProjectInviteService], }) export class ProjectInviteModule {} diff --git a/src/api/project-invite/project-invite.service.spec.ts b/src/api/project-invite/project-invite.service.spec.ts index f4a1a26..c1aba55 100644 --- a/src/api/project-invite/project-invite.service.spec.ts +++ b/src/api/project-invite/project-invite.service.spec.ts @@ -6,6 +6,7 @@ import { EmailService } from 'src/shared/services/email.service'; import { IdentityService } from 'src/shared/services/identity.service'; import { MemberService } from 'src/shared/services/member.service'; import { PermissionService } from 'src/shared/services/permission.service'; +import { CopilotNotificationService } from '../copilot/copilot-notification.service'; import { ProjectInviteService } from './project-invite.service'; jest.mock('src/shared/utils/event.utils', () => ({ @@ -44,6 +45,10 @@ describe('ProjectInviteService', () => { sendInviteEmail: jest.fn(), }; + const copilotNotificationServiceMock = { + sendCopilotInviteAcceptedNotification: jest.fn(), + }; + let service: ProjectInviteService; beforeEach(() => { @@ -55,6 +60,7 @@ describe('ProjectInviteService', () => { memberServiceMock as unknown as MemberService, identityServiceMock as unknown as IdentityService, emailServiceMock as unknown as EmailService, + copilotNotificationServiceMock as unknown as CopilotNotificationService, ); }); diff --git a/src/api/project-invite/project-invite.service.ts b/src/api/project-invite/project-invite.service.ts index 4ce4063..aeb5719 100644 --- a/src/api/project-invite/project-invite.service.ts +++ b/src/api/project-invite/project-invite.service.ts @@ -15,6 +15,7 @@ import { ProjectMemberRole, ProjectMemberInvite, } from '@prisma/client'; +import { CopilotNotificationService } from 'src/api/copilot/copilot-notification.service'; import { CreateInviteDto } from 'src/api/project-invite/dto/create-invite.dto'; import { GetInviteQueryDto, @@ -61,6 +62,7 @@ export class ProjectInviteService { private readonly memberService: MemberService, private readonly identityService: IdentityService, private readonly emailService: EmailService, + private readonly copilotNotificationService: CopilotNotificationService, ) {} async createInvites( @@ -411,6 +413,10 @@ export class ProjectInviteService { ); } + if (this.shouldNotifyCopilotInviteAccepted(updatedInvite, source)) { + await this.notifyCopilotInviteAccepted(updatedInvite.applicationId); + } + return this.hydrateInviteResponse(updatedInvite, fields); } @@ -943,6 +949,58 @@ export class ProjectInviteService { } } + private shouldNotifyCopilotInviteAccepted( + invite: ProjectMemberInvite, + source: string, + ): invite is ProjectMemberInvite & { applicationId: bigint } { + return ( + source === 'copilot_portal' && + Boolean(invite.applicationId) && + (invite.status === InviteStatus.accepted || + invite.status === InviteStatus.request_approved) + ); + } + + private async notifyCopilotInviteAccepted( + applicationId: bigint, + ): Promise { + const application = await this.prisma.copilotApplication.findFirst({ + where: { + id: applicationId, + deletedAt: null, + }, + }); + + if (!application) { + this.logger.warn( + `Skipping copilot invite accepted notification: application=${applicationId.toString()} not found.`, + ); + return; + } + + const opportunity = await this.prisma.copilotOpportunity.findFirst({ + where: { + id: application.opportunityId, + deletedAt: null, + }, + include: { + copilotRequest: true, + }, + }); + + if (!opportunity) { + this.logger.warn( + `Skipping copilot invite accepted notification: opportunity=${application.opportunityId.toString()} not found.`, + ); + return; + } + + await this.copilotNotificationService.sendCopilotInviteAcceptedNotification( + opportunity, + application, + ); + } + private async cancelProjectCopilotWorkflow( tx: Prisma.TransactionClient, projectId: bigint, diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index a1047ae..4e6e058 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -126,6 +126,7 @@ describe('ProjectService', () => { { page: 1, perPage: 20, + fields: 'invites', }, { userId: '100', @@ -137,6 +138,77 @@ describe('ProjectService', () => { expect(result.data).toHaveLength(1); expect(result.data[0].invites).toHaveLength(1); expect(result.data[0].invites?.[0].userId).toBe('100'); + expect(prismaMock.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.objectContaining({ + members: expect.any(Object), + invites: expect.any(Object), + }), + }), + ); + }); + + it('does not load relation payloads by default in project listing', async () => { + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.READ_PROJECT_ANY || + permission === Permission.READ_PROJECT_MEMBER, + ); + + const now = new Date(); + + prismaMock.project.count.mockResolvedValue(1); + prismaMock.project.findMany.mockResolvedValue([ + { + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'active', + billingAccountId: null, + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + utm: null, + details: null, + challengeEligibility: null, + cancelReason: null, + templateId: null, + version: 'v3', + lastActivityAt: now, + lastActivityUserId: '100', + createdAt: now, + updatedAt: now, + createdBy: 100, + updatedBy: 100, + }, + ]); + + const result = await service.listProjects( + { + page: 1, + perPage: 20, + }, + { + userId: '100', + roles: ['administrator'], + isMachine: false, + }, + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].members).toBeUndefined(); + expect(result.data[0].invites).toBeUndefined(); + expect(result.data[0].attachments).toBeUndefined(); + expect(prismaMock.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: {}, + }), + ); }); it('throws NotFoundException when project is missing', async () => { diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index 67554cb..da252b5 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -79,8 +79,9 @@ export class ProjectService { ); const where = buildProjectWhereClause(criteria, user, isAdmin); - const fields = parseFieldsParameter(criteria.fields); - const include = buildProjectIncludeClause(fields); + const requestedFields = this.resolveListFields(criteria.fields); + const includeFields = this.resolveListIncludeFields(requestedFields); + const include = buildProjectIncludeClause(includeFields); const orderBy = this.resolveSort(criteria.sort); const [total, projects] = await Promise.all([ @@ -94,9 +95,19 @@ export class ProjectService { }), ]); - const data = projects.map((project) => - this.toDto(this.filterProjectRelations(project, user, isAdmin)), - ); + const data = projects.map((project) => { + const filteredProject = this.filterProjectRelations( + project, + user, + isAdmin, + ); + const projectWithRequestedFields = this.filterProjectFields( + filteredProject, + requestedFields, + ); + + return this.toDto(projectWithRequestedFields); + }); return { data, @@ -903,6 +914,38 @@ export class ProjectService { } as Prisma.ProjectOrderByWithRelationInput; } + private resolveListFields(fieldsParam?: string): ParsedProjectFields { + const parsedFields = parseFieldsParameter(fieldsParam); + + if (fieldsParam && fieldsParam.trim().length > 0) { + return parsedFields; + } + + return { + ...parsedFields, + project_members: false, + project_member_invites: false, + attachments: false, + }; + } + + private resolveListIncludeFields( + requestedFields: ParsedProjectFields, + ): ParsedProjectFields { + if (requestedFields.project_members) { + return requestedFields; + } + + if (requestedFields.project_member_invites || requestedFields.attachments) { + return { + ...requestedFields, + project_members: true, + }; + } + + return requestedFields; + } + private filterProjectRelations( project: ProjectWithRawRelations, user: JwtUser, diff --git a/src/shared/services/member.service.ts b/src/shared/services/member.service.ts index b367718..e389e58 100644 --- a/src/shared/services/member.service.ts +++ b/src/shared/services/member.service.ts @@ -6,9 +6,21 @@ import { LoggerService } from 'src/shared/modules/global/logger.service'; import { MemberDetail } from 'src/shared/utils/member.utils'; export interface MemberRoleRecord { + id?: string | number; roleName: string; } +export interface RoleSubjectRecord { + email?: string; + handle?: string; + subjectID?: string | number; + userId?: string | number; +} + +interface RoleSubjectsResponseRecord { + subjects?: RoleSubjectRecord[]; +} + @Injectable() export class MemberService { private readonly logger = LoggerService.forRoot('MemberService'); @@ -154,4 +166,95 @@ export class MemberService { return []; } } + + async getRoleSubjectsByRoleName( + roleName: string, + ): Promise { + if (!this.identityApiUrl) { + this.logger.warn('IDENTITY_API_URL is not configured.'); + return []; + } + + const normalizedRoleName = String(roleName || '').trim(); + if (!normalizedRoleName) { + return []; + } + + const identityUrl = this.identityApiUrl.replace(/\/$/, ''); + + try { + const token = await this.m2mService.getM2MToken(); + + const rolesResponse = await firstValueFrom( + this.httpService.get(`${identityUrl}/roles`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + params: { + filter: `roleName=${normalizedRoleName}`, + }, + }), + ); + + const roles = Array.isArray(rolesResponse.data) + ? (rolesResponse.data as MemberRoleRecord[]) + : []; + + const roleIds = roles + .filter( + (role) => String(role.roleName || '').trim() === normalizedRoleName, + ) + .map((role) => String(role.id || '').trim()) + .filter((roleId) => roleId.length > 0); + + if (roleIds.length === 0) { + return []; + } + + const subjectLists = await Promise.all( + roleIds.map(async (roleId) => { + const roleResponse = await firstValueFrom( + this.httpService.get(`${identityUrl}/roles/${roleId}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + params: { + fields: 'subjects', + }, + }), + ); + + const payload = roleResponse.data as RoleSubjectsResponseRecord; + return Array.isArray(payload?.subjects) ? payload.subjects : []; + }), + ); + + const uniqueSubjects = new Map(); + + subjectLists.flat().forEach((subject) => { + const email = String(subject.email || '') + .trim() + .toLowerCase(); + const subjectId = String( + subject.subjectID || subject.userId || '', + ).trim(); + + const key = email || subjectId; + if (!key || uniqueSubjects.has(key)) { + return; + } + + uniqueSubjects.set(key, subject); + }); + + return Array.from(uniqueSubjects.values()); + } catch (error) { + this.logger.warn( + `Failed to fetch role subjects for role=${normalizedRoleName}: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + } + } } From e9b3aabbc4f89347e356e2c4ad6322303145c5e1 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 3 Mar 2026 15:20:57 +1100 Subject: [PATCH 25/41] Allow talent manager access to edit projects --- src/shared/services/permission.service.spec.ts | 13 +++++++++++++ src/shared/services/permission.service.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index 85e3366..8eae838 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -264,6 +264,19 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it.each([UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER])( + 'allows editing project for %s Topcoder role without project membership', + (role) => { + const allowed = service.hasNamedPermission(Permission.EDIT_PROJECT, { + userId: '555', + roles: [role], + isMachine: false, + }); + + expect(allowed).toBe(true); + }, + ); + it('marks billing account permissions as requiring project member context', () => { expect( service.isNamedPermissionRequireProjectMembers( diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index 41315b6..ab8c00e 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -232,6 +232,7 @@ export class PermissionService { isAdmin || isManagementMember || this.isCopilot(member?.role) || + this.hasProjectUpdateTopcoderRole(user) || hasProjectWriteScope ); @@ -719,4 +720,17 @@ export class PermissionService { UserRole.TOPCODER_TALENT_MANAGER, ]); } + + /** + * Checks Topcoder roles allowed to edit projects. + * + * @param user authenticated JWT user context + * @returns `true` when user has one of project-edit roles + */ + private hasProjectUpdateTopcoderRole(user: JwtUser): boolean { + return this.hasIntersection(user.roles || [], [ + UserRole.TALENT_MANAGER, + UserRole.TOPCODER_TALENT_MANAGER, + ]); + } } From 714e6361654a92a358e81e8db13134aa00d89d39 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 3 Mar 2026 16:39:00 +1100 Subject: [PATCH 26/41] Allow M2M tokens to check project membership --- src/shared/services/permission.service.spec.ts | 9 +++++++++ src/shared/services/permission.service.ts | 10 +++++++++- test/project-member.e2e-spec.ts | 13 +++++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index 8eae838..b67433c 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -213,6 +213,15 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it('allows reading project members for machine token with project-member read scope', () => { + const allowed = service.hasNamedPermission(Permission.READ_PROJECT_MEMBER, { + scopes: [Scope.PROJECT_MEMBERS_READ], + isMachine: true, + }); + + expect(allowed).toBe(true); + }); + it('allows viewing project for pending email invite that matches user email', () => { const allowed = service.hasNamedPermission( Permission.VIEW_PROJECT, diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index ab8c00e..401b23e 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -180,6 +180,14 @@ export class PermissionService { user.scopes || [], [Scope.CONNECT_PROJECT_ADMIN, Scope.PROJECTS_ALL, Scope.PROJECTS_WRITE], ); + const hasProjectMemberReadScope = this.m2mService.hasRequiredScopes( + user.scopes || [], + [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECT_MEMBERS_ALL, + Scope.PROJECT_MEMBERS_READ, + ], + ); const member = this.getProjectMember(user.userId, projectMembers); const hasProjectMembership = Boolean(member); @@ -248,7 +256,7 @@ export class PermissionService { // Project member management permissions. case NamedPermission.READ_PROJECT_MEMBER: - return isAdmin || hasProjectMembership; + return isAdmin || hasProjectMembership || hasProjectMemberReadScope; case NamedPermission.CREATE_PROJECT_MEMBER_OWN: return isAuthenticated; diff --git a/test/project-member.e2e-spec.ts b/test/project-member.e2e-spec.ts index 44ff43b..2bcd9db 100644 --- a/test/project-member.e2e-spec.ts +++ b/test/project-member.e2e-spec.ts @@ -225,7 +225,7 @@ describe('Project Member endpoints (e2e)', () => { expect(projectMemberServiceMock.deleteMember).toHaveBeenCalled(); }); - it('rejects m2m token for member list without named permission match', async () => { + it('lists members for m2m token with project-member read scope', async () => { (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ scopes: [Scope.PROJECT_MEMBERS_READ], isMachine: true, @@ -238,6 +238,15 @@ describe('Project Member endpoints (e2e)', () => { await request(app.getHttpServer()) .get('/v6/projects/1001/members') .set('Authorization', 'Bearer m2m-member-read') - .expect(403); + .expect(200); + + expect(projectMemberServiceMock.listMembers).toHaveBeenCalledWith( + '1001', + expect.any(Object), + expect.objectContaining({ + scopes: [Scope.PROJECT_MEMBERS_READ], + isMachine: true, + }), + ); }); }); From 377b9c2c6109805388454e9b76eee6f5b2e8df6e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Mar 2026 09:53:19 +1100 Subject: [PATCH 27/41] Fix up issue with notification emails --- src/shared/services/member.service.spec.ts | 96 ++++++++++++++++++++++ src/shared/services/member.service.ts | 25 +++++- 2 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 src/shared/services/member.service.spec.ts diff --git a/src/shared/services/member.service.spec.ts b/src/shared/services/member.service.spec.ts new file mode 100644 index 0000000..2fd5bf5 --- /dev/null +++ b/src/shared/services/member.service.spec.ts @@ -0,0 +1,96 @@ +import { HttpService } from '@nestjs/axios'; +import { of, throwError } from 'rxjs'; +import { M2MService } from 'src/shared/modules/global/m2m.service'; +import { MemberService } from './member.service'; + +jest.mock('src/shared/config/service-endpoints.config', () => ({ + SERVICE_ENDPOINTS: { + identityApiUrl: 'https://identity.test', + memberApiUrl: 'https://member.test', + }, +})); + +describe('MemberService', () => { + const httpServiceMock = { + get: jest.fn(), + }; + + const m2mServiceMock = { + getM2MToken: jest.fn().mockResolvedValue('m2m-token'), + }; + + let service: MemberService; + + beforeEach(() => { + jest.clearAllMocks(); + + service = new MemberService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + }); + + it('returns subjects from successful role lookups when one role detail request fails', async () => { + httpServiceMock.get.mockImplementation( + (url: string, options: { params?: { filter?: string } }) => { + if ( + url === 'https://identity.test/roles' && + options.params?.filter === 'roleName=copilot' + ) { + return of({ + data: [ + { id: '101', roleName: 'copilot' }, + { id: '102', roleName: 'copilot' }, + ], + }); + } + + if (url === 'https://identity.test/roles/101') { + return of({ + data: { + subjects: [ + { + subjectID: 4001, + handle: 'copilot-one', + email: 'Copilot.One@Topcoder.com', + }, + ], + }, + }); + } + + if (url === 'https://identity.test/roles/102') { + return throwError(() => new Error('role lookup failed')); + } + + return of({ data: {} }); + }, + ); + + const result = await service.getRoleSubjects('copilot'); + + expect(result).toEqual([ + { + userId: 4001, + handle: 'copilot-one', + email: 'copilot.one@topcoder.com', + }, + ]); + expect(m2mServiceMock.getM2MToken).toHaveBeenCalledTimes(1); + }); + + it('returns empty list when role list lookup fails', async () => { + httpServiceMock.get.mockImplementation((url: string) => { + if (url === 'https://identity.test/roles') { + return throwError(() => new Error('identity unavailable')); + } + + return of({ data: {} }); + }); + + const result = await service.getRoleSubjects('Project Manager'); + + expect(result).toEqual([]); + expect(m2mServiceMock.getM2MToken).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/services/member.service.ts b/src/shared/services/member.service.ts index f02025c..56aeb7d 100644 --- a/src/shared/services/member.service.ts +++ b/src/shared/services/member.service.ts @@ -213,8 +213,11 @@ export class MemberService { * Looks up role members from Identity API by role name. * * Resolves `/roles?filter=roleName=` and then - * `/roles/:id?fields=subjects`, returning the `subjects` list normalized to - * `MemberDetail` shape. + * `/roles/:id?fields=subjects`, returning the merged `subjects` list + * normalized to `MemberDetail` shape. + * + * Role-detail lookups are tolerant to partial failures; one failing role id + * does not prevent subjects from other matching roles from being returned. * * @param roleName identity role name (for example `Project Manager`) * @returns role subject list with optional `userId`, `handle`, and `email` @@ -263,7 +266,7 @@ export class MemberService { return []; } - const subjectLists = await Promise.all( + const settledRoleDetails = await Promise.allSettled( roleIds.map(async (roleId) => { const roleResponse = await firstValueFrom( this.httpService.get(`${identityApiBaseUrl}/roles/${roleId}`, { @@ -282,6 +285,22 @@ export class MemberService { }), ); + const subjectLists = settledRoleDetails + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map((result) => result.value); + + settledRoleDetails.forEach((result, index) => { + if (result.status === 'rejected') { + const roleId = roleIds[index]; + this.logger.warn( + `Failed to fetch subjects for roleId=${roleId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`, + ); + } + }); + const uniqueSubjects = new Map(); subjectLists.flat().forEach((subject) => { From 945e56ed504d882658242930ae050a470f50d9d2 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Mar 2026 11:28:29 +1100 Subject: [PATCH 28/41] Fixes for email notifications and billing accounts --- .../copilot-notification.service.spec.ts | 75 ++++++++++++++++++ .../copilot/copilot-notification.service.ts | 79 ++++++++++++++++--- src/api/project/project.service.spec.ts | 20 +++++ src/api/project/project.service.ts | 22 +++++- 4 files changed, 183 insertions(+), 13 deletions(-) diff --git a/src/api/copilot/copilot-notification.service.spec.ts b/src/api/copilot/copilot-notification.service.spec.ts index 5c0e7f0..2cb737a 100644 --- a/src/api/copilot/copilot-notification.service.spec.ts +++ b/src/api/copilot/copilot-notification.service.spec.ts @@ -11,6 +11,7 @@ import { MemberService } from 'src/shared/services/member.service'; import { CopilotNotificationService } from './copilot-notification.service'; describe('CopilotNotificationService', () => { + const originalSlackEmail = process.env.COPILOTS_SLACK_EMAIL; const memberServiceMock = { getRoleSubjects: jest.fn(), getMemberDetailsByUserIds: jest.fn(), @@ -66,6 +67,7 @@ describe('CopilotNotificationService', () => { beforeEach(() => { jest.clearAllMocks(); + process.env.COPILOTS_SLACK_EMAIL = ''; memberServiceMock.getRoleSubjects.mockResolvedValue([]); memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); eventBusServiceMock.publishProjectEvent.mockResolvedValue(undefined); @@ -76,6 +78,10 @@ describe('CopilotNotificationService', () => { ); }); + afterAll(() => { + process.env.COPILOTS_SLACK_EMAIL = originalSlackEmail; + }); + it('publishes apply notifications to all PM users and opportunity creator', async () => { memberServiceMock.getRoleSubjects.mockResolvedValue([ { email: 'pm1@topcoder.com', handle: 'pm1' }, @@ -135,4 +141,73 @@ describe('CopilotNotificationService', () => { ), ).resolves.toBeUndefined(); }); + + it('resolves copilot recipient emails from userIds for new-opportunity notification', async () => { + memberServiceMock.getRoleSubjects.mockResolvedValue([ + { userId: 4001, handle: 'copilot1' }, + { userId: 4002, handle: 'copilot2' }, + ]); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([ + { userId: 4001, handle: 'copilot1', email: 'copilot1@topcoder.com' }, + { userId: 4002, handle: 'copilot2', email: 'copilot2@topcoder.com' }, + ]); + + const opportunity = createOpportunity(); + await service.sendOpportunityPostedNotification( + opportunity, + opportunity.copilotRequest, + ); + + expect(memberServiceMock.getRoleSubjects).toHaveBeenCalledWith( + UserRole.TC_COPILOT, + ); + expect(memberServiceMock.getMemberDetailsByUserIds).toHaveBeenCalledWith([ + '4001', + '4002', + ]); + expect(eventBusServiceMock.publishProjectEvent).toHaveBeenCalledTimes(2); + + const recipients = eventBusServiceMock.publishProjectEvent.mock.calls + .map(([, payload]) => (payload as { recipients?: string[] }).recipients) + .map((recipientList) => recipientList?.[0]) + .sort(); + + expect(recipients).toEqual([ + 'copilot1@topcoder.com', + 'copilot2@topcoder.com', + ]); + }); + + it('resolves PM recipient emails from userIds for application notification', async () => { + memberServiceMock.getRoleSubjects.mockResolvedValue([ + { userId: 5001, handle: 'pm1' }, + ]); + memberServiceMock.getMemberDetailsByUserIds.mockImplementation( + (userIds: Array) => { + if (userIds.length === 1 && String(userIds[0]) === '777') { + return Promise.resolve([ + { userId: 777, handle: 'creator', email: 'creator@topcoder.com' }, + ]); + } + + return Promise.resolve([ + { userId: 5001, handle: 'pm1', email: 'pm1@topcoder.com' }, + ]); + }, + ); + + await service.sendCopilotApplicationNotification( + createOpportunity(), + createApplication(), + ); + + expect(eventBusServiceMock.publishProjectEvent).toHaveBeenCalledTimes(2); + + const recipients = eventBusServiceMock.publishProjectEvent.mock.calls + .map(([, payload]) => (payload as { recipients?: string[] }).recipients) + .map((recipientList) => recipientList?.[0]) + .sort(); + + expect(recipients).toEqual(['creator@topcoder.com', 'pm1@topcoder.com']); + }); }); diff --git a/src/api/copilot/copilot-notification.service.ts b/src/api/copilot/copilot-notification.service.ts index 8e31229..4d38141 100644 --- a/src/api/copilot/copilot-notification.service.ts +++ b/src/api/copilot/copilot-notification.service.ts @@ -38,6 +38,7 @@ type ApplicationWithMembership = CopilotApplication & { }; type NotificationRecipient = { + userId?: string | number | bigint | null; email?: string | null; handle?: string | null; }; @@ -62,7 +63,7 @@ export class CopilotNotificationService { UserRole.TC_COPILOT, ); - const recipients = this.deduplicateRecipientsByEmail(roleSubjects); + const recipients = await this.deduplicateRecipientsByEmail(roleSubjects); const requestData = getCopilotRequestData(copilotRequest?.data); const opportunityType = this.resolveOpportunityType( opportunity, @@ -89,6 +90,12 @@ export class CopilotNotificationService { ); } + if (recipients.length === 0) { + this.logger.warn( + `No copilot email recipients resolved for opportunity=${opportunity.id.toString()}. Only slack recipient (if configured) will be notified.`, + ); + } + const slackRecipient = String(process.env.COPILOTS_SLACK_EMAIL || '') .trim() .toLowerCase(); @@ -107,7 +114,7 @@ export class CopilotNotificationService { } this.logger.log( - `Sent new copilot opportunity notifications for opportunity=${opportunity.id.toString()}`, + `Sent new copilot opportunity notifications for opportunity=${opportunity.id.toString()} recipients=${recipients.length} slackRecipient=${slackRecipient ? 'yes' : 'no'}`, ); } @@ -130,12 +137,15 @@ export class CopilotNotificationService { this.memberService.getMemberDetailsByUserIds([opportunity.createdBy]), ]); - const recipients = this.deduplicateRecipientsByEmail([ + const recipients = await this.deduplicateRecipientsByEmail([ ...projectManagerUsers, ...opportunityCreatorUsers, ]); if (recipients.length === 0) { + this.logger.warn( + `No Project Manager recipients resolved for opportunity=${opportunity.id.toString()} application=${application.id.toString()}.`, + ); return; } @@ -160,7 +170,7 @@ export class CopilotNotificationService { ); this.logger.log( - `Sent copilot application notifications for opportunity=${opportunity.id.toString()} application=${application.id.toString()}`, + `Sent copilot application notifications for opportunity=${opportunity.id.toString()} application=${application.id.toString()} recipients=${recipients.length}`, ); } @@ -230,12 +240,15 @@ export class CopilotNotificationService { this.memberService.getMemberDetailsByUserIds([application.userId]), ]); - const recipients = this.deduplicateRecipientsByEmail([ + const recipients = await this.deduplicateRecipientsByEmail([ ...projectManagerUsers, ...opportunityCreatorUsers, ]); if (recipients.length === 0) { + this.logger.warn( + `No Project Manager recipients resolved for invite-accepted notification opportunity=${opportunity.id.toString()} application=${application.id.toString()}.`, + ); return; } @@ -265,7 +278,7 @@ export class CopilotNotificationService { ); this.logger.log( - `Sent copilot invite accepted notifications for opportunity=${opportunity.id.toString()} application=${application.id.toString()}`, + `Sent copilot invite accepted notifications for opportunity=${opportunity.id.toString()} application=${application.id.toString()} recipients=${recipients.length}`, ); } @@ -413,20 +426,29 @@ export class CopilotNotificationService { * @param recipients Potential recipients from role and creator lookups. * @returns Deduplicated recipients preserving first-seen handle values. */ - private deduplicateRecipientsByEmail( + private async deduplicateRecipientsByEmail( recipients: NotificationRecipient[], - ): Array<{ email: string; handle: string }> { + ): Promise> { const recipientByEmail = new Map< string, { email: string; handle: string } >(); + const unresolvedUserIds = new Set(); recipients.forEach((recipient) => { const normalizedEmail = String(recipient.email || '') .trim() .toLowerCase(); - if (!normalizedEmail || recipientByEmail.has(normalizedEmail)) { + if (!normalizedEmail) { + const normalizedUserId = this.normalizeNumericUserId(recipient.userId); + if (normalizedUserId) { + unresolvedUserIds.add(normalizedUserId); + } + return; + } + + if (recipientByEmail.has(normalizedEmail)) { return; } @@ -436,9 +458,48 @@ export class CopilotNotificationService { }); }); + if (unresolvedUserIds.size > 0) { + const fallbackUsers = await this.memberService.getMemberDetailsByUserIds( + Array.from(unresolvedUserIds), + ); + + fallbackUsers.forEach((user) => { + const normalizedEmail = String(user.email || '') + .trim() + .toLowerCase(); + + if (!normalizedEmail || recipientByEmail.has(normalizedEmail)) { + return; + } + + recipientByEmail.set(normalizedEmail, { + email: normalizedEmail, + handle: String(user.handle || '').trim() || normalizedEmail, + }); + }); + } + return Array.from(recipientByEmail.values()); } + /** + * Normalizes numeric user-id values. + * + * @param userId Candidate user id. + * @returns Numeric user id string or undefined when invalid. + */ + private normalizeNumericUserId( + userId: string | number | bigint | null | undefined, + ): string | undefined { + const normalizedUserId = String(userId ?? '').trim(); + + if (!/^\d+$/.test(normalizedUserId)) { + return undefined; + } + + return normalizedUserId; + } + /** * Returns the configured Work Manager base URL. * diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index 7a5dcd3..28d96e9 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -501,6 +501,26 @@ describe('ProjectService', () => { }); }); + it('falls back to project billingAccountId when Salesforce billing lookup is empty', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + billingAccountId: BigInt(12), + }); + billingAccountServiceMock.getDefaultBillingAccount.mockResolvedValue(null); + + const result = await service.getProjectBillingAccount('1001', { + userId: '123', + isMachine: false, + }); + + expect(result).toEqual({ + tcBillingAccountId: '12', + }); + expect( + billingAccountServiceMock.getDefaultBillingAccount, + ).toHaveBeenCalledWith('12'); + }); + it('throws when billing account is not attached to the project', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index dfb9942..42ecceb 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -913,6 +913,10 @@ export class ProjectService { /** * Returns the default billing account configured on a project. * + * When Salesforce lookup is unavailable or returns an empty payload, this + * method still returns the project-level billing-account id so downstream + * services can continue linking billing context. + * * @param projectId Project id path parameter. * @param user Authenticated caller context. * @returns Default billing account details. @@ -947,10 +951,20 @@ export class ProjectService { throw new NotFoundException('Billing Account not found'); } - const billingAccount = - (await this.billingAccountService.getDefaultBillingAccount( - project.billingAccountId.toString(), - )) || {}; + const projectBillingAccountId = project.billingAccountId.toString(); + const billingAccountFromSalesforce = + await this.billingAccountService.getDefaultBillingAccount( + projectBillingAccountId, + ); + + const hasResolvedBillingAccountId = + typeof billingAccountFromSalesforce?.tcBillingAccountId === 'string' && + billingAccountFromSalesforce.tcBillingAccountId.trim().length > 0; + const billingAccount: BillingAccount = hasResolvedBillingAccountId + ? billingAccountFromSalesforce + : { + tcBillingAccountId: projectBillingAccountId, + }; if (user.isMachine) { return billingAccount; From e15bbb9d324ec1ffc8d435d686c14641a3ad7153 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Mar 2026 11:45:18 +1100 Subject: [PATCH 29/41] QA fixes --- .../project-invite.service.spec.ts | 84 +++++ .../project-invite/project-invite.service.ts | 42 ++- src/api/project/dto/create-project.dto.ts | 9 + src/api/project/dto/project-response.dto.ts | 3 + src/api/project/dto/update-project.dto.ts | 19 +- src/api/project/project.service.spec.ts | 337 ++++++++++++++++++ src/api/project/project.service.ts | 33 +- src/shared/config/kafka.config.ts | 24 ++ src/shared/utils/event.utils.ts | 16 + 9 files changed, 556 insertions(+), 11 deletions(-) diff --git a/src/api/project-invite/project-invite.service.spec.ts b/src/api/project-invite/project-invite.service.spec.ts index 4effa61..072ef29 100644 --- a/src/api/project-invite/project-invite.service.spec.ts +++ b/src/api/project-invite/project-invite.service.spec.ts @@ -11,6 +11,7 @@ import { ProjectInviteService } from './project-invite.service'; jest.mock('src/shared/utils/event.utils', () => ({ publishMemberEventSafely: jest.fn(), + publishInviteEventSafely: jest.fn(), })); const eventUtils = jest.requireMock('src/shared/utils/event.utils'); @@ -135,6 +136,16 @@ describe('ProjectInviteService', () => { expect(response.success).toHaveLength(1); expect(emailServiceMock.sendInviteEmail).not.toHaveBeenCalled(); + expect(eventUtils.publishInviteEventSafely).toHaveBeenCalledWith( + KAFKA_TOPIC.PROJECT_MEMBER_INVITE_CREATED, + expect.objectContaining({ + id: '1', + projectId: '1001', + userId: '123', + source: 'work_manager', + }), + expect.anything(), + ); }); it('sends invite email with isSSO=true for known user invited by email', async () => { @@ -379,6 +390,15 @@ describe('ProjectInviteService', () => { }), expect.anything(), ); + expect(eventUtils.publishInviteEventSafely).toHaveBeenCalledWith( + KAFKA_TOPIC.PROJECT_MEMBER_INVITE_UPDATED, + expect.objectContaining({ + id: '10', + projectId: '1001', + status: InviteStatus.accepted, + }), + expect.anything(), + ); }); it('accepts email-only invite and creates project member for authenticated user', async () => { @@ -520,4 +540,68 @@ describe('ProjectInviteService', () => { }), ).rejects.toBeInstanceOf(ForbiddenException); }); + + it('publishes invite.deleted when invite is canceled', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [], + }); + + prismaMock.projectMemberInvite.findFirst.mockResolvedValue({ + id: BigInt(12), + projectId: BigInt(1001), + userId: BigInt(500), + email: null, + role: ProjectMemberRole.copilot, + status: InviteStatus.pending, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: 1, + deletedAt: null, + deletedBy: null, + applicationId: null, + }); + + permissionServiceMock.hasNamedPermission.mockReturnValue(true); + + const txMock = { + projectMemberInvite: { + update: jest.fn().mockResolvedValue({ + id: BigInt(12), + projectId: BigInt(1001), + userId: BigInt(500), + email: null, + role: ProjectMemberRole.copilot, + status: InviteStatus.canceled, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: 99, + deletedAt: null, + deletedBy: null, + applicationId: null, + }), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + await service.deleteInvite('1001', '12', { + userId: '999', + isMachine: false, + }); + + expect(eventUtils.publishInviteEventSafely).toHaveBeenCalledWith( + KAFKA_TOPIC.PROJECT_MEMBER_INVITE_REMOVED, + expect.objectContaining({ + id: '12', + projectId: '1001', + status: InviteStatus.canceled, + }), + expect.anything(), + ); + }); }); diff --git a/src/api/project-invite/project-invite.service.ts b/src/api/project-invite/project-invite.service.ts index 644921a..12c6193 100644 --- a/src/api/project-invite/project-invite.service.ts +++ b/src/api/project-invite/project-invite.service.ts @@ -43,7 +43,10 @@ import { enrichInvitesWithUserDetails, validateUserHasProjectRole, } from 'src/shared/utils/member.utils'; -import { publishMemberEventSafely } from 'src/shared/utils/event.utils'; +import { + publishInviteEventSafely, + publishMemberEventSafely, +} from 'src/shared/utils/event.utils'; import { normalizeEntity as normalizePrismaEntity } from 'src/shared/utils/entity.utils'; import { ensureRoleScopedPermission, @@ -74,6 +77,7 @@ interface InviteMemberName { * * The service supports bulk invite creation from handles/emails, invite state * transitions, and copilot workflow side effects. + * Invite lifecycle events are published for create/update/delete operations. * * Accepting or request-approving an invite auto-creates a `ProjectMember` * record when needed and updates linked copilot application state. @@ -108,6 +112,7 @@ export class ProjectInviteService { * @throws {NotFoundException} If the project is not found. * @throws {ForbiddenException} If role-specific invite permission is missing. * @throws {BadRequestException} If handles/emails are both missing. + * @emits `project.member.invite.created` for each created invite. */ async createInvites( projectId: string, @@ -271,6 +276,15 @@ export class ProjectInviteService { const isKnownEmailUserInvite = invite.userId !== null && userIdsResolvedFromEmails.has(String(invite.userId)); + const inviteCreatedPayload = { + ...normalizedInvite, + source: 'work_manager', + }; + + this.publishInvite( + KAFKA_TOPIC.PROJECT_MEMBER_INVITE_CREATED, + inviteCreatedPayload, + ); if ( invite.email && @@ -337,6 +351,7 @@ export class ProjectInviteService { * @throws {ForbiddenException} If update permission is missing. * @throws {BadRequestException} If status/id/user resolution is invalid. * @throws {ConflictException} If linked opportunity is not active. + * @emits `project.member.invite.updated`. */ async updateInvite( projectId: string, @@ -509,6 +524,11 @@ export class ProjectInviteService { }, ); + this.publishInvite( + KAFKA_TOPIC.PROJECT_MEMBER_INVITE_UPDATED, + this.normalizeEntity(updatedInvite), + ); + if (projectMember) { this.publishMember( KAFKA_TOPIC.PROJECT_MEMBER_ADDED, @@ -533,6 +553,7 @@ export class ProjectInviteService { * @throws {NotFoundException} If the project or invite is not found. * @throws {ForbiddenException} If delete permission is missing. * @throws {BadRequestException} If ids are invalid. + * @emits `project.member.invite.deleted`. */ async deleteInvite( projectId: string, @@ -599,7 +620,7 @@ export class ProjectInviteService { this.ensureDeleteInvitePermission(invite.role, user, project.members); } - await this.prisma.$transaction(async (tx) => { + const canceledInvite = await this.prisma.$transaction(async (tx) => { const updated = await tx.projectMemberInvite.update({ where: { id: parsedInviteId, @@ -619,6 +640,11 @@ export class ProjectInviteService { return updated; }); + + this.publishInvite( + KAFKA_TOPIC.PROJECT_MEMBER_INVITE_REMOVED, + this.normalizeEntity(canceledInvite), + ); } /** @@ -1698,4 +1724,16 @@ export class ProjectInviteService { private publishMember(topic: string, payload: unknown): void { publishMemberEventSafely(topic, payload, this.logger); } + + /** + * Publishes invite event payloads and logs failures. + * + * @param topic Kafka topic. + * @param payload Event payload. + * @returns Nothing. + * @throws {Error} Publisher errors are caught and logged. + */ + private publishInvite(topic: string, payload: unknown): void { + publishInviteEventSafely(topic, payload, this.logger); + } } diff --git a/src/api/project/dto/create-project.dto.ts b/src/api/project/dto/create-project.dto.ts index 4ac381a..162dcbc 100644 --- a/src/api/project/dto/create-project.dto.ts +++ b/src/api/project/dto/create-project.dto.ts @@ -336,6 +336,15 @@ export class CreateProjectDto { @IsEnum(ProjectStatus) status?: ProjectStatus; + @ApiPropertyOptional({ + description: 'Cancellation reason for cancelled projects.', + maxLength: 255, + }) + @IsOptional() + @IsString() + @Length(1, 255) + cancelReason?: string; + @ApiPropertyOptional() @IsOptional() @Transform(({ value }) => parseOptionalInteger(value)) diff --git a/src/api/project/dto/project-response.dto.ts b/src/api/project/dto/project-response.dto.ts index fe668c9..e2145c7 100644 --- a/src/api/project/dto/project-response.dto.ts +++ b/src/api/project/dto/project-response.dto.ts @@ -131,6 +131,9 @@ export class ProjectResponseDto { }) status: ProjectStatus; + @ApiPropertyOptional() + cancelReason?: string | null; + @ApiPropertyOptional() billingAccountId?: string | null; diff --git a/src/api/project/dto/update-project.dto.ts b/src/api/project/dto/update-project.dto.ts index 18228d5..80f6b13 100644 --- a/src/api/project/dto/update-project.dto.ts +++ b/src/api/project/dto/update-project.dto.ts @@ -1,9 +1,26 @@ +import { ApiHideProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { PartialType } from '@nestjs/mapped-types'; import { CreateProjectDto } from './create-project.dto'; +/** + * Resolves whether the patch payload explicitly requests clearing + * `billingAccountId`. + * + * @param value Raw `billingAccountId` value from request payload. + * @returns `true` when caller explicitly sends `null` or an empty string. + */ +function parseClearBillingAccountFlag(value: unknown): boolean { + return value === null || value === ''; +} + /** * Request DTO for `PATCH /projects/:projectId`. * * Reuses `CreateProjectDto` and makes all fields optional via `PartialType`. */ -export class UpdateProjectDto extends PartialType(CreateProjectDto) {} +export class UpdateProjectDto extends PartialType(CreateProjectDto) { + @ApiHideProperty() + @Transform(({ obj }) => parseClearBillingAccountFlag(obj?.billingAccountId)) + clearBillingAccountId?: boolean; +} diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index 28d96e9..1e90079 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -704,6 +704,343 @@ describe('ProjectService', () => { ).rejects.toBeInstanceOf(ForbiddenException); }); + it('clears billing account id when explicitly requested', async () => { + const transactionUpdate = jest.fn().mockResolvedValue({ + id: BigInt(1001), + }); + + prismaMock.project.findFirst + .mockResolvedValueOnce({ + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'in_review', + billingAccountId: BigInt(12), + directProjectId: null, + details: {}, + bookmarks: null, + members: [ + { + userId: BigInt(100), + role: 'manager', + deletedAt: null, + }, + ], + invites: [], + }) + .mockResolvedValueOnce({ + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'in_review', + billingAccountId: null, + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + details: {}, + challengeEligibility: null, + templateId: null, + version: 'v3', + lastActivityAt: new Date(), + lastActivityUserId: '100', + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 100, + updatedBy: 100, + members: [], + invites: [], + attachments: [], + phases: [], + }); + + prismaMock.$transaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => + callback({ + project: { + update: transactionUpdate, + }, + projectHistory: { + create: jest.fn().mockResolvedValue({}), + }, + }), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => { + if (permission === Permission.EDIT_PROJECT) { + return true; + } + + if (permission === Permission.MANAGE_PROJECT_BILLING_ACCOUNT_ID) { + return true; + } + + if (permission === Permission.READ_PROJECT_ANY) { + return true; + } + + return true; + }, + ); + + await service.updateProject( + '1001', + { + clearBillingAccountId: true, + } as any, + { + userId: '100', + isMachine: false, + }, + ); + + expect(transactionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + billingAccountId: null, + }), + }), + ); + }); + + it('does not require billing account manage permission when clearing an already-empty billing account', async () => { + const transactionUpdate = jest.fn().mockResolvedValue({ + id: BigInt(1001), + }); + + prismaMock.project.findFirst + .mockResolvedValueOnce({ + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'in_review', + billingAccountId: null, + directProjectId: null, + details: {}, + bookmarks: null, + members: [ + { + userId: BigInt(100), + role: 'manager', + deletedAt: null, + }, + ], + invites: [], + }) + .mockResolvedValueOnce({ + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'in_review', + billingAccountId: null, + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + details: {}, + challengeEligibility: null, + templateId: null, + version: 'v3', + lastActivityAt: new Date(), + lastActivityUserId: '100', + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 100, + updatedBy: 100, + members: [], + invites: [], + attachments: [], + phases: [], + }); + + prismaMock.$transaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => + callback({ + project: { + update: transactionUpdate, + }, + projectHistory: { + create: jest.fn().mockResolvedValue({}), + }, + }), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => { + if (permission === Permission.EDIT_PROJECT) { + return true; + } + + if (permission === Permission.MANAGE_PROJECT_BILLING_ACCOUNT_ID) { + return false; + } + + if (permission === Permission.READ_PROJECT_ANY) { + return true; + } + + return true; + }, + ); + + await expect( + service.updateProject( + '1001', + { + clearBillingAccountId: true, + } as any, + { + userId: '100', + isMachine: false, + }, + ), + ).resolves.toBeDefined(); + + expect( + permissionServiceMock.hasNamedPermission.mock.calls.some( + ([permission]: [Permission]) => + permission === Permission.MANAGE_PROJECT_BILLING_ACCOUNT_ID, + ), + ).toBe(false); + expect(transactionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + billingAccountId: null, + }), + }), + ); + }); + + it('persists cancelReason and project-history cancelReason on cancellation updates', async () => { + const transactionUpdate = jest.fn().mockResolvedValue({ + id: BigInt(1001), + }); + const transactionHistoryCreate = jest.fn().mockResolvedValue({}); + + prismaMock.project.findFirst + .mockResolvedValueOnce({ + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'in_review', + billingAccountId: BigInt(11), + directProjectId: null, + details: {}, + bookmarks: null, + members: [ + { + userId: BigInt(100), + role: 'manager', + deletedAt: null, + }, + ], + invites: [], + }) + .mockResolvedValueOnce({ + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'cancelled', + cancelReason: 'Client requested cancellation', + billingAccountId: null, + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + details: {}, + challengeEligibility: null, + templateId: null, + version: 'v3', + lastActivityAt: new Date(), + lastActivityUserId: '100', + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 100, + updatedBy: 100, + members: [], + invites: [], + attachments: [], + phases: [], + }); + + prismaMock.$transaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => + callback({ + project: { + update: transactionUpdate, + }, + projectHistory: { + create: transactionHistoryCreate, + }, + }), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => { + if (permission === Permission.EDIT_PROJECT) { + return true; + } + + if (permission === Permission.MANAGE_PROJECT_BILLING_ACCOUNT_ID) { + return true; + } + + if (permission === Permission.READ_PROJECT_ANY) { + return true; + } + + return true; + }, + ); + + await service.updateProject( + '1001', + { + status: 'cancelled' as any, + cancelReason: 'Client requested cancellation', + clearBillingAccountId: true, + } as any, + { + userId: '100', + isMachine: false, + }, + ); + + expect(transactionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'cancelled', + cancelReason: 'Client requested cancellation', + billingAccountId: null, + }), + }), + ); + expect(transactionHistoryCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'cancelled', + cancelReason: 'Client requested cancellation', + }), + }), + ); + }); + it('soft deletes project and emits event', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index 42ecceb..d115855 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -325,6 +325,8 @@ export class ProjectService { description: dto.description || null, type: dto.type, status: dto.status || ProjectStatus.in_review, + cancelReason: + typeof dto.cancelReason === 'string' ? dto.cancelReason : null, billingAccountId: typeof dto.billingAccountId === 'number' ? BigInt(dto.billingAccountId) @@ -534,8 +536,9 @@ export class ProjectService { * Partially updates a project with permission-aware field guards. * * Performs additional checks for `billingAccountId` and `directProjectId`, - * appends project history when status changes, and publishes - * `project.updated`. + * supports explicit billing-account clearing, persists optional + * `cancelReason`, appends project history when status changes, and + * publishes `project.updated`. * * @param projectId Project id path parameter. * @param dto Patch payload. @@ -592,10 +595,18 @@ export class ProjectService { throw new ForbiddenException('Insufficient permissions'); } + const requestedBillingAccountId = + dto.clearBillingAccountId === true + ? null + : typeof dto.billingAccountId === 'number' + ? String(dto.billingAccountId) + : undefined; + const existingBillingAccountId = + this.toOptionalBigintString(existingProject.billingAccountId) ?? null; + if ( - typeof dto.billingAccountId !== 'undefined' && - String(existingProject.billingAccountId || '') !== - String(dto.billingAccountId) + typeof requestedBillingAccountId !== 'undefined' && + existingBillingAccountId !== requestedBillingAccountId ) { const hasPermission = this.permissionService.hasNamedPermission( Permission.MANAGE_PROJECT_BILLING_ACCOUNT_ID, @@ -641,10 +652,14 @@ export class ProjectService { description: dto.description, type: dto.type, status: dto.status, + cancelReason: + typeof dto.cancelReason === 'string' ? dto.cancelReason : undefined, billingAccountId: - typeof dto.billingAccountId === 'number' - ? BigInt(dto.billingAccountId) - : undefined, + dto.clearBillingAccountId === true + ? null + : typeof dto.billingAccountId === 'number' + ? BigInt(dto.billingAccountId) + : undefined, directProjectId: typeof dto.directProjectId === 'number' ? BigInt(dto.directProjectId) @@ -695,6 +710,8 @@ export class ProjectService { data: { projectId: updated.id, status: dto.status, + cancelReason: + typeof dto.cancelReason === 'string' ? dto.cancelReason : null, updatedBy: auditUserId, }, }); diff --git a/src/shared/config/kafka.config.ts b/src/shared/config/kafka.config.ts index c5a45db..ee830e9 100644 --- a/src/shared/config/kafka.config.ts +++ b/src/shared/config/kafka.config.ts @@ -31,6 +31,30 @@ export const KAFKA_TOPIC = { */ PROJECT_MEMBER_REMOVED: process.env.KAFKA_PROJECT_MEMBER_REMOVED_TOPIC || 'project.member.removed', + /** + * Project member invite created topic. + * Env: `KAFKA_PROJECT_MEMBER_INVITE_CREATED_TOPIC`, + * default: `project.member.invite.created`. + */ + PROJECT_MEMBER_INVITE_CREATED: + process.env.KAFKA_PROJECT_MEMBER_INVITE_CREATED_TOPIC || + 'project.member.invite.created', + /** + * Project member invite updated topic. + * Env: `KAFKA_PROJECT_MEMBER_INVITE_UPDATED_TOPIC`, + * default: `project.member.invite.updated`. + */ + PROJECT_MEMBER_INVITE_UPDATED: + process.env.KAFKA_PROJECT_MEMBER_INVITE_UPDATED_TOPIC || + 'project.member.invite.updated', + /** + * Project member invite removed topic. + * Env: `KAFKA_PROJECT_MEMBER_INVITE_REMOVED_TOPIC`, + * default: `project.member.invite.deleted`. + */ + PROJECT_MEMBER_INVITE_REMOVED: + process.env.KAFKA_PROJECT_MEMBER_INVITE_REMOVED_TOPIC || + 'project.member.invite.deleted', } as const; /** diff --git a/src/shared/utils/event.utils.ts b/src/shared/utils/event.utils.ts index 5e7393b..769fad9 100644 --- a/src/shared/utils/event.utils.ts +++ b/src/shared/utils/event.utils.ts @@ -510,6 +510,22 @@ export function publishMemberEventSafely( }); } +/** + * Fire-and-forget wrapper for invite events with caller-provided logger. + */ +export function publishInviteEventSafely( + topic: string, + payload: unknown, + errorLogger: ErrorLogger, +): void { + void publishInviteEvent(topic, payload).catch((error) => { + errorLogger.error( + `Failed to publish invite event topic=${topic}: ${toErrorMessage(error)}`, + toErrorStack(error), + ); + }); +} + /** * Publishes a project-member-invite event envelope. */ From a67ce773cf56cb99e6dd643b08dd5b556bc5d9c0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Mar 2026 14:03:15 +1100 Subject: [PATCH 30/41] Fix for how we get email addresses for copilot notification --- .../project-invite.service.spec.ts | 17 +++++++++++-- .../project-invite/project-invite.service.ts | 24 +++++-------------- src/shared/services/member.service.spec.ts | 9 +++++++ src/shared/services/member.service.ts | 6 +++-- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/api/project-invite/project-invite.service.spec.ts b/src/api/project-invite/project-invite.service.spec.ts index 072ef29..e2a95b8 100644 --- a/src/api/project-invite/project-invite.service.spec.ts +++ b/src/api/project-invite/project-invite.service.spec.ts @@ -65,7 +65,7 @@ describe('ProjectInviteService', () => { ); }); - it('creates invite', async () => { + it('creates invite by handle and sends known-user email', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), name: 'Demo', @@ -135,7 +135,20 @@ describe('ProjectInviteService', () => { ); expect(response.success).toHaveLength(1); - expect(emailServiceMock.sendInviteEmail).not.toHaveBeenCalled(); + expect(emailServiceMock.sendInviteEmail).toHaveBeenCalledTimes(1); + expect(emailServiceMock.sendInviteEmail).toHaveBeenCalledWith( + '1001', + expect.objectContaining({ + email: 'member@topcoder.com', + }), + expect.objectContaining({ + userId: '99', + }), + 'Demo', + { + isSSO: true, + }, + ); expect(eventUtils.publishInviteEventSafely).toHaveBeenCalledWith( KAFKA_TOPIC.PROJECT_MEMBER_INVITE_CREATED, expect.objectContaining({ diff --git a/src/api/project-invite/project-invite.service.ts b/src/api/project-invite/project-invite.service.ts index 12c6193..932119e 100644 --- a/src/api/project-invite/project-invite.service.ts +++ b/src/api/project-invite/project-invite.service.ts @@ -113,6 +113,7 @@ export class ProjectInviteService { * @throws {ForbiddenException} If role-specific invite permission is missing. * @throws {BadRequestException} If handles/emails are both missing. * @emits `project.member.invite.created` for each created invite. + * @emits `external.action.email` for pending invites with recipient emails. */ async createInvites( projectId: string, @@ -219,11 +220,8 @@ export class ProjectInviteService { ); const emailOnlyTargets = emailTargets.emailOnlyTargets; - const userIdsResolvedFromEmails = new Set( - emailTargets.userTargets.map((target) => String(target.userId)), - ); const knownInviteeNamesByUserId = await this.getMemberNameMapByUserIds( - emailTargets.userTargets.map((target) => target.userId), + validatedUserTargets.map((target) => target.userId), ); const status = @@ -273,9 +271,7 @@ export class ProjectInviteService { for (const invite of success) { const normalizedInvite = this.normalizeEntity(invite); const recipient = invite.email?.trim().toLowerCase(); - const isKnownEmailUserInvite = - invite.userId !== null && - userIdsResolvedFromEmails.has(String(invite.userId)); + const isKnownUserInvite = invite.userId !== null; const inviteCreatedPayload = { ...normalizedInvite, source: 'work_manager', @@ -286,11 +282,7 @@ export class ProjectInviteService { inviteCreatedPayload, ); - if ( - invite.email && - invite.status === InviteStatus.pending && - (!invite.userId || isKnownEmailUserInvite) - ) { + if (invite.email && invite.status === InviteStatus.pending) { if (!inviteInitiatorPromise) { inviteInitiatorPromise = this.resolveInviteEmailInitiator(user); } @@ -306,7 +298,7 @@ export class ProjectInviteService { }; this.logger.log( - `Dispatching invite email publish for inviteId=${String(invite.id)} projectId=${projectId} recipient=${recipient} isSSO=${String(Boolean(isKnownEmailUserInvite))}`, + `Dispatching invite email publish for inviteId=${String(invite.id)} projectId=${projectId} recipient=${recipient} isSSO=${String(isKnownUserInvite)}`, ); void this.emailService.sendInviteEmail( projectId, @@ -314,7 +306,7 @@ export class ProjectInviteService { await inviteInitiatorPromise, project.name, { - isSSO: Boolean(isKnownEmailUserInvite), + isSSO: isKnownUserInvite, }, ); continue; @@ -1427,10 +1419,6 @@ export class ProjectInviteService { return 'missing-email'; } - if (invite.userId) { - return 'invite-linked-to-user'; - } - if (invite.status !== InviteStatus.pending) { return `status-${String(invite.status)}`; } diff --git a/src/shared/services/member.service.spec.ts b/src/shared/services/member.service.spec.ts index 2fd5bf5..a032f7b 100644 --- a/src/shared/services/member.service.spec.ts +++ b/src/shared/services/member.service.spec.ts @@ -77,6 +77,15 @@ describe('MemberService', () => { }, ]); expect(m2mServiceMock.getM2MToken).toHaveBeenCalledTimes(1); + expect(httpServiceMock.get).toHaveBeenCalledWith( + 'https://identity.test/roles/101', + expect.objectContaining({ + params: expect.objectContaining({ + selector: 'subjects', + perPage: 200, + }), + }), + ); }); it('returns empty list when role list lookup fails', async () => { diff --git a/src/shared/services/member.service.ts b/src/shared/services/member.service.ts index 56aeb7d..b8f68e0 100644 --- a/src/shared/services/member.service.ts +++ b/src/shared/services/member.service.ts @@ -213,7 +213,8 @@ export class MemberService { * Looks up role members from Identity API by role name. * * Resolves `/roles?filter=roleName=` and then - * `/roles/:id?fields=subjects`, returning the merged `subjects` list + * `/roles/:id?selector=subjects&perPage=200`, returning the merged + * `subjects` list * normalized to `MemberDetail` shape. * * Role-detail lookups are tolerant to partial failures; one failing role id @@ -275,7 +276,8 @@ export class MemberService { 'Content-Type': 'application/json', }, params: { - fields: 'subjects', + selector: 'subjects', + perPage: 200, }, }), ); From 812ac821f6a21247b93486f751ccaef98029a014 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Mar 2026 15:24:33 +1100 Subject: [PATCH 31/41] Allow additional roles for update --- src/shared/services/permission.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index 401b23e..304b70a 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -739,6 +739,7 @@ export class PermissionService { return this.hasIntersection(user.roles || [], [ UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER, + UserRole.PROJECT_MANAGER, ]); } } From 40ecc40cbc4120b9bf31c56e0329ef7591497e97 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Mar 2026 16:40:58 +1100 Subject: [PATCH 32/41] Raise billing account change event --- .env.example | 3 +- LISTENERS.md | 4 +- README.md | 3 + docs/event-schemas.md | 29 +++++- src/api/project/project.service.spec.ts | 118 ++++++++++++++++++++++++ src/api/project/project.service.ts | 41 +++++++- src/shared/config/kafka.config.ts | 8 ++ 7 files changed, 201 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 62e37c2..41bf42a 100644 --- a/.env.example +++ b/.env.example @@ -21,9 +21,10 @@ KAFKA_CLIENT_CERT="" KAFKA_CLIENT_CERT_KEY="" BUSAPI_URL="https://api.topcoder-dev.com/v5" -# Project event topics (only active topics) +# Project event topics KAFKA_PROJECT_CREATED_TOPIC="project.created" KAFKA_PROJECT_UPDATED_TOPIC="project.updated" +KAFKA_PROJECT_BILLING_ACCOUNT_UPDATED_TOPIC="project.action.billingAccount.update" KAFKA_PROJECT_DELETED_TOPIC="project.deleted" KAFKA_PROJECT_MEMBER_ADDED_TOPIC="project.member.added" KAFKA_PROJECT_MEMBER_REMOVED_TOPIC="project.member.removed" diff --git a/LISTENERS.md b/LISTENERS.md index b146596..21bc0ae 100644 --- a/LISTENERS.md +++ b/LISTENERS.md @@ -1,6 +1,6 @@ # Kafka Listener Audit for `projects-api-v6` Topics -Date: 2026-02-08 +Date: 2026-03-04 Scope: all top-level services/apps in this monorepo. Excluded per request: `projects-api-v6`, `tc-project-service`. @@ -8,6 +8,7 @@ Excluded per request: `projects-api-v6`, `tc-project-service`. - `project.created` - `project.updated` +- `project.action.billingAccount.update` - `project.deleted` - `project.member.added` - `project.member.removed` @@ -22,6 +23,7 @@ No non-excluded service in this monorepo statically subscribes to these topics. |---|---|---|---| | `KAFKA_PROJECT_CREATED_TOPIC` | `project.created` | None found | N/A | | `KAFKA_PROJECT_UPDATED_TOPIC` | `project.updated` | None found | N/A | +| `KAFKA_PROJECT_BILLING_ACCOUNT_UPDATED_TOPIC` | `project.action.billingAccount.update` | None found | N/A | | `KAFKA_PROJECT_DELETED_TOPIC` | `project.deleted` | None found | N/A | | `KAFKA_PROJECT_MEMBER_ADDED_TOPIC` | `project.member.added` | None found | N/A | | `KAFKA_PROJECT_MEMBER_REMOVED_TOPIC` | `project.member.removed` | None found | N/A | diff --git a/README.md b/README.md index 38156a4..e73e0ce 100644 --- a/README.md +++ b/README.md @@ -271,12 +271,14 @@ For full event envelope and payload schemas, see `docs/event-schemas.md`. | --- | --- | | `project.created` | `POST /v6/projects` | | `project.updated` | `PATCH /v6/projects/:projectId` (status change or field update) | +| `project.action.billingAccount.update` | `PATCH /v6/projects/:projectId` (only when `billingAccountId` changes) | | `project.deleted` | `DELETE /v6/projects/:projectId` | | `project.member.added` | `POST /v6/projects/:projectId/members` | | `project.member.removed` | `DELETE /v6/projects/:projectId/members/:id` | - Originator: `project-service-v6`. - Status-change events are emitted only when status actually changes. +- Billing-account update event payload matches legacy `tc-project-service` contract and is sent as raw payload (no `resource/data` wrapper). - Metadata event publishing is currently disabled. ## Environment Variables @@ -300,6 +302,7 @@ Reference source: `.env.example`. | `BUSAPI_URL` | ✅ | - | Topcoder Bus API base URL | | `KAFKA_PROJECT_CREATED_TOPIC` | ✅ | `project.created` | Kafka topic | | `KAFKA_PROJECT_UPDATED_TOPIC` | ✅ | `project.updated` | Kafka topic | +| `KAFKA_PROJECT_BILLING_ACCOUNT_UPDATED_TOPIC` | ✅ | `project.action.billingAccount.update` | Kafka topic | | `KAFKA_PROJECT_DELETED_TOPIC` | ✅ | `project.deleted` | Kafka topic | | `KAFKA_PROJECT_MEMBER_ADDED_TOPIC` | ✅ | `project.member.added` | Kafka topic | | `KAFKA_PROJECT_MEMBER_REMOVED_TOPIC` | ✅ | `project.member.removed` | Kafka topic | diff --git a/docs/event-schemas.md b/docs/event-schemas.md index ee926f7..392a7e0 100644 --- a/docs/event-schemas.md +++ b/docs/event-schemas.md @@ -1,9 +1,10 @@ # Event Schemas -As of 2026-02-08, `projects-api-v6` publishes only these Kafka topics: +As of 2026-03-04, `projects-api-v6` publishes these Kafka topics: - `project.created` - `project.updated` +- `project.action.billingAccount.update` - `project.deleted` - `project.member.added` - `project.member.removed` @@ -35,7 +36,31 @@ All events are published through the event bus with this envelope shape: - `project.member.added` -> `resource: "project.member"` - `project.member.removed` -> `resource: "project.member"` +## Billing Account Update Event + +`project.action.billingAccount.update` follows the legacy tc-project-service +payload contract and is intentionally published **without** `resource/data` +wrapping: + +```json +{ + "topic": "project.action.billingAccount.update", + "originator": "project-service-v6", + "timestamp": "2026-03-04T00:00:00.000Z", + "mime-type": "application/json", + "payload": { + "projectId": "1001", + "projectName": "Demo Project", + "directProjectId": null, + "status": "active", + "oldBillingAccountId": "11", + "newBillingAccountId": "22" + } +} +``` + ## Notes -- Legacy non-core event topics are removed from `projects-api-v6`. +- `project.action.billingAccount.update` is emitted only when + `billingAccountId` changes during `PATCH /v6/projects/:projectId`. - Metadata event publishing is currently disabled. diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index 1e90079..df42a99 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -6,6 +6,7 @@ import { ProjectService } from './project.service'; jest.mock('src/shared/utils/event.utils', () => ({ publishProjectEvent: jest.fn(() => Promise.resolve()), + publishRawEvent: jest.fn(() => Promise.resolve()), })); const eventUtils = jest.requireMock('src/shared/utils/event.utils'); @@ -1217,6 +1218,10 @@ describe('ProjectService', () => { project: { update: jest.fn().mockResolvedValue({ id: BigInt(1001), + name: 'Demo', + status: 'active', + billingAccountId: BigInt(22), + directProjectId: null, }), }, projectHistory: { @@ -1242,5 +1247,118 @@ describe('ProjectService', () => { KAFKA_TOPIC.PROJECT_UPDATED, expect.any(Object), ); + expect(eventUtils.publishRawEvent).toHaveBeenCalledWith( + KAFKA_TOPIC.PROJECT_BILLING_ACCOUNT_UPDATED, + { + projectId: '1001', + projectName: 'Demo', + directProjectId: null, + status: 'active', + oldBillingAccountId: '11', + newBillingAccountId: '22', + }, + ); + }); + + it('does not publish billing-account update event when billingAccountId is unchanged', async () => { + prismaMock.project.findFirst + .mockResolvedValueOnce({ + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'in_review', + billingAccountId: BigInt(11), + directProjectId: null, + details: {}, + bookmarks: null, + members: [ + { + userId: BigInt(100), + role: 'manager', + deletedAt: null, + }, + ], + invites: [], + }) + .mockResolvedValueOnce({ + id: BigInt(1001), + name: 'Demo Updated', + description: null, + type: 'app', + status: 'active', + billingAccountId: BigInt(11), + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + details: {}, + challengeEligibility: null, + templateId: null, + version: 'v3', + lastActivityAt: new Date(), + lastActivityUserId: '100', + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 100, + updatedBy: 100, + members: [ + { + id: BigInt(1), + projectId: BigInt(1001), + userId: BigInt(100), + role: 'manager', + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + deletedBy: null, + createdBy: 100, + updatedBy: 100, + }, + ], + invites: [], + attachments: [], + phases: [], + }); + prismaMock.$transaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => + callback({ + project: { + update: jest.fn().mockResolvedValue({ + id: BigInt(1001), + name: 'Demo Updated', + status: 'active', + billingAccountId: BigInt(11), + directProjectId: null, + }), + }, + projectHistory: { + create: jest.fn().mockResolvedValue({}), + }, + }), + ); + permissionServiceMock.hasNamedPermission.mockReturnValue(true); + + await service.updateProject( + '1001', + { + status: 'active' as any, + name: 'Demo Updated', + }, + { + userId: '100', + isMachine: false, + }, + ); + + expect(eventUtils.publishProjectEvent).toHaveBeenCalledWith( + KAFKA_TOPIC.PROJECT_UPDATED, + expect.any(Object), + ); + expect(eventUtils.publishRawEvent).not.toHaveBeenCalled(); }); }); diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index d115855..46247f6 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -27,7 +27,10 @@ import { BillingAccountService, } from 'src/shared/services/billingAccount.service'; import { PermissionService } from 'src/shared/services/permission.service'; -import { publishProjectEvent } from 'src/shared/utils/event.utils'; +import { + publishProjectEvent, + publishRawEvent as publishRawBusEvent, +} from 'src/shared/utils/event.utils'; import { ParsedProjectFields, buildProjectIncludeClause, @@ -540,6 +543,10 @@ export class ProjectService { * `cancelReason`, appends project history when status changes, and * publishes `project.updated`. * + * When `billingAccountId` changes, also emits + * `project.action.billingAccount.update` with the legacy + * tc-project-service payload contract. + * * @param projectId Project id path parameter. * @param dto Patch payload. * @param user Authenticated caller context. @@ -719,6 +726,10 @@ export class ProjectService { return updated; }); + const updatedBillingAccountId = + this.toOptionalBigintString(updatedProject.billingAccountId) ?? null; + const billingAccountChanged = + existingBillingAccountId !== updatedBillingAccountId; const project = await this.prisma.project.findFirst({ where: { @@ -751,6 +762,17 @@ export class ProjectService { ); this.publishEvent(KAFKA_TOPIC.PROJECT_UPDATED, response); + if (billingAccountChanged) { + this.publishRawEvent(KAFKA_TOPIC.PROJECT_BILLING_ACCOUNT_UPDATED, { + projectId: response.id, + projectName: response.name, + directProjectId: + this.toOptionalBigintString(updatedProject.directProjectId) ?? null, + status: updatedProject.status, + oldBillingAccountId: existingBillingAccountId, + newBillingAccountId: updatedBillingAccountId, + }); + } return response; } @@ -1612,6 +1634,23 @@ export class ProjectService { }); } + /** + * Fire-and-forget raw event publication wrapper (no resource envelope). + * + * Logs publication failures and intentionally does not rethrow. + * + * @param topic Kafka topic name. + * @param payload Raw event payload. + */ + private publishRawEvent(topic: string, payload: unknown): void { + void publishRawBusEvent(topic, payload).catch((error) => { + this.logger.error( + `Failed to publish raw event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ); + }); + } + /** * Converts raw project entity into API DTO shape. * diff --git a/src/shared/config/kafka.config.ts b/src/shared/config/kafka.config.ts index ee830e9..1f3b31f 100644 --- a/src/shared/config/kafka.config.ts +++ b/src/shared/config/kafka.config.ts @@ -14,6 +14,14 @@ export const KAFKA_TOPIC = { * Env: `KAFKA_PROJECT_UPDATED_TOPIC`, default: `project.updated`. */ PROJECT_UPDATED: process.env.KAFKA_PROJECT_UPDATED_TOPIC || 'project.updated', + /** + * Project billing-account update topic. + * Env: `KAFKA_PROJECT_BILLING_ACCOUNT_UPDATED_TOPIC`, + * default: `project.action.billingAccount.update`. + */ + PROJECT_BILLING_ACCOUNT_UPDATED: + process.env.KAFKA_PROJECT_BILLING_ACCOUNT_UPDATED_TOPIC || + 'project.action.billingAccount.update', /** * Project deleted topic. * Env: `KAFKA_PROJECT_DELETED_TOPIC`, default: `project.deleted`. From e77c2d521768d7f298b7f9bf0bace5cff1270460 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 6 Mar 2026 12:07:35 +1100 Subject: [PATCH 33/41] Better user lookup for users page in WM --- src/api/project/dto/project-list-query.dto.ts | 2 +- src/api/project/project.service.spec.ts | 6 ++ src/api/project/project.service.ts | 80 ++++++++++++++++++- src/shared/modules/global/jwt.service.ts | 33 ++++++-- src/shared/utils/project.utils.ts | 6 ++ src/shared/utils/swagger.utils.ts | 36 ++++++++- 6 files changed, 153 insertions(+), 10 deletions(-) diff --git a/src/api/project/dto/project-list-query.dto.ts b/src/api/project/dto/project-list-query.dto.ts index 1a346ed..f7a7225 100644 --- a/src/api/project/dto/project-list-query.dto.ts +++ b/src/api/project/dto/project-list-query.dto.ts @@ -111,7 +111,7 @@ export class ProjectListQueryDto extends PaginationDto { @ApiPropertyOptional({ description: - 'When true, return projects where current user is member/invitee', + 'When true, return projects where current user is member/invitee. Accepts boolean true/false and string "true"/"false".', }) @IsOptional() @Transform(({ value }) => parseBoolean(value)) diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index df42a99..89688b1 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -42,15 +42,21 @@ describe('ProjectService', () => { getDefaultBillingAccount: jest.fn(), }; + const memberServiceMock = { + getMemberDetailsByUserIds: jest.fn(), + }; + let service: ProjectService; beforeEach(() => { jest.clearAllMocks(); prismaMock.$queryRaw.mockResolvedValue([]); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); service = new ProjectService( prismaMock as any, permissionServiceMock as unknown as PermissionService, billingAccountServiceMock as any, + memberServiceMock as any, ); }); diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index 46247f6..742ffdc 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -26,6 +26,7 @@ import { BillingAccount, BillingAccountService, } from 'src/shared/services/billingAccount.service'; +import { MemberService } from 'src/shared/services/member.service'; import { PermissionService } from 'src/shared/services/permission.service'; import { publishProjectEvent, @@ -84,6 +85,7 @@ export class ProjectService { private readonly prisma: PrismaService, private readonly permissionService: PermissionService, private readonly billingAccountService: BillingAccountService, + private readonly memberService: MemberService, ) {} /** @@ -1182,6 +1184,11 @@ export class ProjectService { /** * Loads member handles keyed by user id from the members schema. * + * Falls back to Member API lookup when the cross-schema query fails or + * does not fully resolve all requested user ids. If all lookups fail, this + * returns an empty map; + * callers should inspect logs for cross-schema or downstream API errors. + * * @param userIds User ids to resolve. * @returns Map keyed by normalized user id string. * @security Uses `Prisma.join` parameterization for safe binding and avoids @@ -1208,7 +1215,7 @@ export class ProjectService { WHERE m."userId" IN (${Prisma.join(userIds)}) `); - return rows.reduce>((acc, row) => { + const handlesByUserId = rows.reduce>((acc, row) => { const parsedUserId = this.parseUserIdValue(row.userId); const handle = this.toOptionalHandle(row.handle); @@ -1218,9 +1225,78 @@ export class ProjectService { return acc; }, new Map()); + + this.logger.debug( + `Resolved ${handlesByUserId.size} member handle pairs from members.member for ${userIds.length} user ids.`, + ); + + const unresolvedUserIds = userIds.filter( + (userId) => !handlesByUserId.has(userId.toString()), + ); + + if (unresolvedUserIds.length) { + if (handlesByUserId.size === 0) { + this.logger.warn( + `Cross-schema handle lookup returned no usable rows for ${userIds.length} user ids; falling back to Member API.`, + ); + } else { + this.logger.warn( + `Cross-schema handle lookup partially resolved member handles (${handlesByUserId.size}/${userIds.length}); falling back to Member API for ${unresolvedUserIds.length} unresolved user ids.`, + ); + } + + const fallbackHandlesByUserId = + await this.fetchMemberHandlesViaMemberApi(unresolvedUserIds); + fallbackHandlesByUserId.forEach((handle, resolvedUserId) => { + handlesByUserId.set(resolvedUserId, handle); + }); + } + + return handlesByUserId; } catch (error) { - this.logger.warn( + this.logger.error( `Failed to fetch member handles from members.member: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ); + return this.fetchMemberHandlesViaMemberApi(userIds); + } + } + + /** + * Loads member handles through Member API as a fallback for DB handle lookups. + * + * @param userIds User ids to resolve. + * @returns Map keyed by normalized user id string. + */ + private async fetchMemberHandlesViaMemberApi( + userIds: bigint[], + ): Promise> { + try { + const memberDetails = + await this.memberService.getMemberDetailsByUserIds(userIds); + const handlesByUserId = memberDetails.reduce>( + (acc, memberDetail) => { + const parsedUserId = this.parseUserIdValue(memberDetail.userId); + const handle = this.toOptionalHandle(memberDetail.handle); + + if (parsedUserId && handle) { + acc.set(parsedUserId.toString(), handle); + } + + return acc; + }, + new Map(), + ); + + this.logger.debug( + `Resolved ${handlesByUserId.size} member handle pairs from Member API fallback for ${userIds.length} user ids.`, + ); + + return handlesByUserId; + } catch (error) { + this.logger.error( + `Failed Member API fallback for member handles: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, ); return new Map(); } diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index 7aa96e9..87b42ee 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -29,7 +29,11 @@ type JwtPayloadRecord = Record; */ export interface JwtUser { /** - * Primary user identifier when present (for example `userId` or `sub`). + * Primary user identifier when present. + * + * Expected to be a numeric string parseable by `BigInt` (for example from + * `userId`/`sub` claims). Extraction prefers numeric identifiers and falls + * back to other identifier claims when necessary. */ userId?: string; /** @@ -373,6 +377,14 @@ export class JwtService implements OnModuleInit { } } + if ( + (!user.userId || !this.isNumericIdentifier(user.userId)) && + user.handle && + this.isNumericIdentifier(user.handle) + ) { + user.userId = user.handle; + } + return user; } @@ -390,15 +402,24 @@ export class JwtService implements OnModuleInit { * Extracts a user identifier from common claims. * * @param {JwtPayloadRecord} payload Token payload. - * @returns {string | undefined} User identifier from `userId` or `sub`. + * @returns {string | undefined} User identifier from `userId`/`sub`/`handle`. + * Numeric values are preferred so downstream `BigInt` parsing can succeed. */ private extractUserId(payload: JwtPayloadRecord): string | undefined { - const userId = this.extractIdentifier(payload.userId); - if (userId) { - return userId; + const candidates = [ + this.extractIdentifier(payload.userId), + this.extractIdentifier(payload.sub), + this.extractIdentifier(payload.handle), + ].filter((value): value is string => typeof value === 'string'); + + const numericCandidate = candidates.find((value) => + this.isNumericIdentifier(value), + ); + if (numericCandidate) { + return numericCandidate; } - return this.extractIdentifier(payload.sub); + return candidates[0]; } /** diff --git a/src/shared/utils/project.utils.ts b/src/shared/utils/project.utils.ts index 4b84c74..ea92d5a 100644 --- a/src/shared/utils/project.utils.ts +++ b/src/shared/utils/project.utils.ts @@ -255,6 +255,8 @@ export function parseFieldsParameter(fields?: string): ParsedProjectFields { * - `code`: case-insensitive contains on name. * - `customer` / `manager`: member-subquery constraints. * - non-admin or `memberOnly=true`: restrict to membership/invite ownership. + * When the caller has no resolvable membership identity (no parseable userId + * and no email), applies an impossible `id = -1` guard to return zero rows. */ export function buildProjectWhereClause( criteria: ProjectListQueryDto, @@ -451,6 +453,10 @@ export function buildProjectWhereClause( appendAndCondition(where, { OR: visibilityFilters, }); + } else { + appendAndCondition(where, { + id: -1n, + }); } } diff --git a/src/shared/utils/swagger.utils.ts b/src/shared/utils/swagger.utils.ts index e9ecdc7..3437b21 100644 --- a/src/shared/utils/swagger.utils.ts +++ b/src/shared/utils/swagger.utils.ts @@ -20,6 +20,7 @@ import { SWAGGER_ANY_AUTHENTICATED_KEY, SWAGGER_REQUIRED_ROLES_KEY, } from '../guards/tokenRoles.guard'; +import { UserRole } from '../enums/userRole.enum'; type SwaggerOperation = { description?: string; @@ -38,6 +39,10 @@ const HTTP_METHODS = [ 'trace', ] as const; +const ALL_KNOWN_USER_ROLES = new Set( + Object.values(UserRole).map((role) => role.trim().toLowerCase()), +); + /** * Safely coerces unknown values to a trimmed `string[]`. */ @@ -122,6 +127,34 @@ function ensureErrorResponses(operation: SwaggerOperation): void { } } +/** + * Detects whether Swagger role metadata effectively means "any known user role". + * + * Many endpoints use this broad role gate as a coarse auth pass-through and + * rely on policy permissions for actual access decisions. + */ +function isAllKnownUserRoles(roles: string[]): boolean { + if (roles.length === 0) { + return false; + } + + const normalizedRoles = new Set( + roles.map((role) => role.trim().toLowerCase()).filter(Boolean), + ); + + if (normalizedRoles.size !== ALL_KNOWN_USER_ROLES.size) { + return false; + } + + for (const knownRole of ALL_KNOWN_USER_ROLES) { + if (!normalizedRoles.has(knownRole)) { + return false; + } + } + + return true; +} + /** * Builds human-readable authorization lines from custom Swagger extensions. */ @@ -141,12 +174,13 @@ function getAuthorizationLines(operation: SwaggerOperation): string[] { ); const authorizationLines: string[] = []; + const hasAllKnownUserRoles = isAllKnownUserRoles(roles); if (isAnyAuthenticated) { authorizationLines.push('Any authenticated token is allowed.'); } - if (roles.length > 0) { + if (roles.length > 0 && !(hasAllKnownUserRoles && permissions.length > 0)) { authorizationLines.push(`Allowed user roles (any): ${roles.join(', ')}`); } From 30ec7f70916e8d869a046464bb6eccda4f6d4496 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 6 Mar 2026 15:25:02 +1100 Subject: [PATCH 34/41] Additional template email fixes for invite / decline --- README.md | 2 +- src/shared/services/email.service.spec.ts | 37 +++++++++++++++++--- src/shared/services/email.service.ts | 41 ++++++++++++++--------- 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e73e0ce..0a0fe0a 100644 --- a/README.md +++ b/README.md @@ -329,7 +329,7 @@ Reference source: `.env.example`. | `SENDGRID_TEMPLATE_COPILOT_ALREADY_PART_OF_PROJECT` | - | - | SendGrid template ID | | `SENDGRID_TEMPLATE_INFORM_PM_COPILOT_APPLICATION_ACCEPTED` | - | - | SendGrid template ID | | `COPILOT_PORTAL_URL` | - | - | Copilot portal URL (used in invite emails) | -| `WORK_MANAGER_URL` | - | - | Work Manager URL (used in invite emails) | +| `WORK_MANAGER_URL` | ✅ | - | Work Manager base URL used to build invite action links in emails. Format: `https://work.topcoder.com`. Must not have a trailing slash. | | `ACCOUNTS_APP_URL` | - | - | Accounts app URL (used in invite emails) | | `UNIQUE_GMAIL_VALIDATION` | - | `false` | Treat Gmail `+` aliases as same address | | `PORT` | - | `3000` | HTTP listen port | diff --git a/src/shared/services/email.service.spec.ts b/src/shared/services/email.service.spec.ts index 82ca40a..f5876c7 100644 --- a/src/shared/services/email.service.spec.ts +++ b/src/shared/services/email.service.spec.ts @@ -12,6 +12,7 @@ describe('EmailService', () => { beforeEach(() => { jest.clearAllMocks(); process.env = { ...originalEnv }; + process.env.WORK_MANAGER_URL = 'https://work.topcoder-dev.com'; eventBusServiceMock.publishProjectEvent.mockResolvedValue(undefined); service = new EmailService( eventBusServiceMock as unknown as EventBusService, @@ -66,9 +67,9 @@ describe('EmailService', () => { lastName: 'Doe', projectId: '1001', joinProjectUrl: - 'https://work.topcoder-dev.com/projects/1001/accept/inv-100', + 'https://work.topcoder-dev.com/projects/1001/invitation/accepted?source=email', declineProjectUrl: - 'https://work.topcoder-dev.com/projects/1001/decline/inv-100', + 'https://work.topcoder-dev.com/projects/1001/invitation/refused?source=email', initiatorFirstName: 'Jane', initiatorLastName: 'Smith', }); @@ -110,7 +111,7 @@ describe('EmailService', () => { lastName: '', projectId: '1001', registerUrl: - 'https://accounts.topcoder-dev.com/?mode=signUp®Source=tcBusiness&retUrl=https://work.topcoder-dev.com/projects/1001/accept/321', + 'https://accounts.topcoder-dev.com/?mode=signUp®Source=tcBusiness&retUrl=https://work.topcoder-dev.com/projects/1001/invitation/accepted?source=email', initiatorFirstName: 'Connect', initiatorLastName: 'User', }); @@ -200,8 +201,33 @@ describe('EmailService', () => { expect(eventBusServiceMock.publishProjectEvent).not.toHaveBeenCalled(); }); + it('skips publish when WORK_MANAGER_URL is missing', async () => { + process.env.SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID = + 'known-template-id'; + delete process.env.WORK_MANAGER_URL; + + await service.sendInviteEmail( + '1001', + { + id: 'wm-missing', + email: 'known@topcoder.com', + }, + { + userId: '123', + handle: 'pm', + }, + 'Demo', + { + isSSO: true, + }, + ); + + expect(eventBusServiceMock.publishProjectEvent).not.toHaveBeenCalled(); + }); + it('uses topcoder.com links in production', async () => { process.env.NODE_ENV = 'production'; + process.env.WORK_MANAGER_URL = 'https://work.topcoder.com'; process.env.SENDGRID_PROJECT_INVITATION_KNOWN_USER_TEMPLATE_ID = 'known-template-id'; @@ -227,9 +253,10 @@ describe('EmailService', () => { }; expect(normalizedPayload.data).toMatchObject({ - joinProjectUrl: 'https://work.topcoder.com/projects/1001/accept/prod-1', + joinProjectUrl: + 'https://work.topcoder.com/projects/1001/invitation/accepted?source=email', declineProjectUrl: - 'https://work.topcoder.com/projects/1001/decline/prod-1', + 'https://work.topcoder.com/projects/1001/invitation/refused?source=email', }); }); }); diff --git a/src/shared/services/email.service.ts b/src/shared/services/email.service.ts index 09a779b..8196a07 100644 --- a/src/shared/services/email.service.ts +++ b/src/shared/services/email.service.ts @@ -93,7 +93,8 @@ export class EmailService { * Publishes a project invite email event. * * No-ops when `invite.email` is empty, `invite.id` is missing, or when - * no invite template id env var can be resolved. + * no invite template id env var can be resolved. Also no-ops when + * `WORK_MANAGER_URL` is not configured. * * Publishes payload to `external.action.email`. * @@ -138,21 +139,25 @@ export class EmailService { return; } + const workManagerUrl = process.env.WORK_MANAGER_URL?.trim(); + if (!workManagerUrl) { + this.logger.warn( + `Skipping invite email publish for projectId=${projectId} recipient=${recipient}: WORK_MANAGER_URL is not configured.`, + ); + return; + } + const normalizedProjectId = String(projectId).trim(); const normalizedProjectName = projectName?.trim() || `Project ${projectId}`; - const rootUrl = this.resolveRootUrl(); const joinProjectUrl = this.buildWorkInviteActionUrl( - rootUrl, normalizedProjectId, - inviteId, - 'accept', + 'accepted', ); const declineProjectUrl = this.buildWorkInviteActionUrl( - rootUrl, normalizedProjectId, - inviteId, - 'decline', + 'refused', ); + const rootUrl = this.resolveRootUrl(); const registerUrl = this.buildRegisterUrl(rootUrl, joinProjectUrl); const normalizedInitiator = this.normalizeInitiator(initiator); const knownUserPayload: KnownUserInviteTemplatePayload = { @@ -319,19 +324,23 @@ export class EmailService { /** * Builds a Work app invite action URL. * - * @param rootUrl Root URL domain. * @param projectId Project id. - * @param inviteId Invite id. - * @param action URL action segment (`accept` or `decline`). - * @returns Work invite action URL. + * @param action URL action segment (`accepted` or `refused`). + * @returns Full Work Manager invite action URL built from `WORK_MANAGER_URL`. */ private buildWorkInviteActionUrl( - rootUrl: string, projectId: string, - inviteId: string, - action: 'accept' | 'decline', + action: 'accepted' | 'refused', ): string { - return `https://work.${rootUrl}/projects/${projectId}/${action}/${inviteId}`; + const workManagerUrl = process.env.WORK_MANAGER_URL?.trim(); + if (!workManagerUrl) { + this.logger.warn( + `Cannot build invite action URL for projectId=${projectId}: WORK_MANAGER_URL is not configured.`, + ); + return ''; + } + + return `${workManagerUrl}/projects/${projectId}/invitation/${action}?source=email`; } /** From bc020f1fe85cd0fec7b93c03cbd0154078baa924 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 9 Mar 2026 14:18:58 +1100 Subject: [PATCH 35/41] QA fixes --- docs/PERMISSIONS.md | 2 +- src/api/copilot/copilot.utils.spec.ts | 24 ++ src/api/copilot/copilot.utils.ts | 9 +- src/api/project/project.controller.spec.ts | 49 ++++ src/api/project/project.service.spec.ts | 274 ++++++++++++++++++ src/api/project/project.service.ts | 223 +++++++++++++- src/shared/guards/adminOnly.guard.ts | 19 +- src/shared/guards/auth-metadata.constants.ts | 4 + src/shared/guards/permission.guard.spec.ts | 56 +++- src/shared/guards/permission.guard.ts | 20 +- src/shared/guards/tokenRoles.guard.spec.ts | 49 ++++ src/shared/guards/tokenRoles.guard.ts | 23 +- src/shared/interfaces/permission.interface.ts | 4 + .../services/permission.service.spec.ts | 32 ++ src/shared/services/permission.service.ts | 16 +- src/shared/utils/swagger.utils.ts | 18 +- test/copilot.e2e-spec.ts | 48 +++ 17 files changed, 836 insertions(+), 34 deletions(-) create mode 100644 src/api/copilot/copilot.utils.spec.ts create mode 100644 src/shared/guards/auth-metadata.constants.ts diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 105b1e0..343310d 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -31,7 +31,7 @@ Supported shapes: ```ts { - topcoderRoles: [UserRole.CONNECT_ADMIN], + topcoderRoles: [UserRole.TOPCODER_ADMIN], projectRoles: [ProjectMemberRole.MANAGER], scopes: [Scope.PROJECTS_READ], } diff --git a/src/api/copilot/copilot.utils.spec.ts b/src/api/copilot/copilot.utils.spec.ts new file mode 100644 index 0000000..ae04965 --- /dev/null +++ b/src/api/copilot/copilot.utils.spec.ts @@ -0,0 +1,24 @@ +import { BadRequestException } from '@nestjs/common'; +import { getAuditUserId } from './copilot.utils'; + +describe('copilot utils', () => { + it('returns -1 for machine tokens without numeric user ids', () => { + expect( + getAuditUserId({ + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + }, + }), + ).toBe(-1); + }); + + it('throws for human tokens without numeric user ids', () => { + expect(() => + getAuditUserId({ + userId: 'not-a-number', + isMachine: false, + }), + ).toThrow(BadRequestException); + }); +}); diff --git a/src/api/copilot/copilot.utils.ts b/src/api/copilot/copilot.utils.ts index bc6e0e2..9197e32 100644 --- a/src/api/copilot/copilot.utils.ts +++ b/src/api/copilot/copilot.utils.ts @@ -143,13 +143,18 @@ export function parseSortExpression( * Parses the numeric audit user id from an authenticated JWT user. * * @param user Authenticated JWT user. - * @returns Numeric user id for audit fields. - * @throws BadRequestException If user.userId is not numeric. + * @returns Numeric user id for audit fields, or `-1` for machine tokens that + * do not carry a numeric user id. + * @throws BadRequestException If a human token has a non-numeric user id. */ export function getAuditUserId(user: JwtUser): number { const value = Number.parseInt(String(user.userId || '').trim(), 10); if (Number.isNaN(value)) { + if (user.isMachine) { + return -1; + } + throw new BadRequestException('Authenticated user id must be numeric.'); } diff --git a/src/api/project/project.controller.spec.ts b/src/api/project/project.controller.spec.ts index 3f92876..fb7ac2a 100644 --- a/src/api/project/project.controller.spec.ts +++ b/src/api/project/project.controller.spec.ts @@ -8,7 +8,9 @@ describe('ProjectController', () => { getProject: jest.fn(), listProjectBillingAccounts: jest.fn(), getProjectBillingAccount: jest.fn(), + getProjectPermissions: jest.fn(), createProject: jest.fn(), + upgradeProject: jest.fn(), updateProject: jest.fn(), deleteProject: jest.fn(), }; @@ -151,6 +153,53 @@ describe('ProjectController', () => { expect(serviceMock.createProject).toHaveBeenCalled(); }); + it('gets project permissions', async () => { + serviceMock.getProjectPermissions.mockResolvedValue({ + manage_team: true, + }); + + const result = await controller.getProjectPermissions('303', { + userId: '123', + isMachine: false, + }); + + expect(result).toEqual({ + manage_team: true, + }); + expect(serviceMock.getProjectPermissions).toHaveBeenCalledWith( + '303', + expect.objectContaining({ userId: '123' }), + ); + }); + + it('upgrades project', async () => { + serviceMock.upgradeProject.mockResolvedValue({ + message: 'Project successfully upgraded', + }); + + const result = await controller.upgradeProject( + '303', + { + targetVersion: 'v3', + } as any, + { + userId: '123', + isMachine: false, + }, + ); + + expect(result).toEqual({ + message: 'Project successfully upgraded', + }); + expect(serviceMock.upgradeProject).toHaveBeenCalledWith( + '303', + { + targetVersion: 'v3', + }, + expect.objectContaining({ userId: '123' }), + ); + }); + it('updates project', async () => { serviceMock.updateProject.mockResolvedValue({ id: '303', name: 'Updated' }); diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index 89688b1..0558b4c 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -28,12 +28,20 @@ describe('ProjectService', () => { projectMemberInvite: { findMany: jest.fn(), }, + projectMember: { + findMany: jest.fn(), + }, + workManagementPermission: { + findMany: jest.fn(), + }, $queryRaw: jest.fn(), $transaction: jest.fn(), }; const permissionServiceMock = { hasNamedPermission: jest.fn(), + hasPermission: jest.fn(), + hasIntersection: jest.fn(), }; const billingAccountServiceMock = { @@ -667,6 +675,272 @@ describe('ProjectService', () => { expect(result.invites).toBeUndefined(); }); + it('normalizes stored work-management permissions for project permission responses', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + templateId: BigInt(501), + }); + prismaMock.projectMember.findMany.mockResolvedValue([ + { + id: BigInt(1), + projectId: BigInt(1001), + userId: BigInt(100), + role: 'manager', + isPrimary: true, + deletedAt: null, + }, + ]); + prismaMock.workManagementPermission.findMany.mockResolvedValue([ + { + policy: 'manage_team', + permission: { + allow: { + roles: ['manager'], + }, + }, + }, + ]); + permissionServiceMock.hasPermission.mockReturnValue(true); + + const result = await service.getProjectPermissions('1001', { + userId: '100', + isMachine: false, + }); + + expect(result).toEqual({ + manage_team: true, + }); + expect(permissionServiceMock.hasPermission).toHaveBeenCalledWith( + { + allowRule: { + projectRoles: ['manager'], + }, + }, + expect.objectContaining({ userId: '100' }), + [ + expect.objectContaining({ + role: 'manager', + }), + ], + ); + }); + + it('creates projects for machine tokens without creating a synthetic owner member', async () => { + const transactionProjectCreate = jest.fn().mockResolvedValue({ + id: BigInt(1001), + status: 'in_review', + }); + const transactionProjectMemberCreate = jest.fn().mockResolvedValue({}); + const transactionProjectHistoryCreate = jest.fn().mockResolvedValue({}); + + prismaMock.projectType.findFirst.mockResolvedValue({ + key: 'app', + }); + prismaMock.$transaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => + callback({ + project: { + create: transactionProjectCreate, + }, + projectMember: { + create: transactionProjectMemberCreate, + createMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + projectHistory: { + create: transactionProjectHistoryCreate, + }, + }), + ); + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + name: 'Machine Project', + description: null, + type: 'app', + status: 'in_review', + billingAccountId: null, + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + utm: null, + details: null, + challengeEligibility: null, + cancelReason: null, + templateId: null, + version: 'v3', + lastActivityAt: new Date(), + lastActivityUserId: 'svc-projects', + createdAt: new Date(), + updatedAt: new Date(), + createdBy: -1, + updatedBy: -1, + members: [], + invites: [], + attachments: [], + phases: [], + }); + permissionServiceMock.hasNamedPermission.mockReturnValue(false); + + const result = await service.createProject( + { + name: 'Machine Project', + type: 'app', + }, + { + isMachine: true, + scopes: ['write:projects'], + tokenPayload: { + sub: 'svc-projects', + }, + }, + ); + + expect(result.id).toBe('1001'); + expect(transactionProjectCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + lastActivityUserId: 'svc-projects', + createdBy: -1, + updatedBy: -1, + }), + }), + ); + expect(transactionProjectMemberCreate).not.toHaveBeenCalled(); + expect(transactionProjectHistoryCreate).toHaveBeenCalled(); + }); + + it('updates projects for machine tokens using fallback audit identity', async () => { + const transactionUpdate = jest.fn().mockResolvedValue({ + id: BigInt(1001), + name: 'Updated by machine', + status: 'active', + billingAccountId: null, + directProjectId: null, + }); + + prismaMock.project.findFirst + .mockResolvedValueOnce({ + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'in_review', + billingAccountId: null, + directProjectId: null, + details: {}, + bookmarks: null, + members: [], + invites: [], + }) + .mockResolvedValueOnce({ + id: BigInt(1001), + name: 'Updated by machine', + description: null, + type: 'app', + status: 'active', + billingAccountId: null, + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + details: {}, + challengeEligibility: null, + templateId: null, + version: 'v3', + lastActivityAt: new Date(), + lastActivityUserId: 'svc-projects', + createdAt: new Date(), + updatedAt: new Date(), + createdBy: -1, + updatedBy: -1, + members: [], + invites: [], + attachments: [], + phases: [], + }); + prismaMock.$transaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => + callback({ + project: { + update: transactionUpdate, + }, + projectHistory: { + create: jest.fn().mockResolvedValue({}), + }, + }), + ); + permissionServiceMock.hasNamedPermission.mockReturnValue(true); + + await service.updateProject( + '1001', + { + name: 'Updated by machine', + status: 'active' as any, + }, + { + isMachine: true, + scopes: ['write:projects'], + tokenPayload: { + sub: 'svc-projects', + }, + }, + ); + + expect(transactionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + lastActivityUserId: 'svc-projects', + updatedBy: -1, + }), + }), + ); + }); + + it('deletes projects for machine tokens using fallback audit identity', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [], + }); + prismaMock.project.update.mockResolvedValue({ + id: BigInt(1001), + }); + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.DELETE_PROJECT, + ); + + await service.deleteProject('1001', { + isMachine: true, + scopes: ['write:projects'], + tokenPayload: { + sub: 'svc-projects', + }, + }); + + expect(prismaMock.project.update).toHaveBeenCalledWith({ + where: { + id: BigInt(1001), + }, + data: { + deletedAt: expect.any(Date), + deletedBy: BigInt(-1), + updatedBy: -1, + }, + }); + expect(eventUtils.publishProjectEvent).toHaveBeenCalledWith( + KAFKA_TOPIC.PROJECT_DELETED, + { + id: '1001', + deletedBy: 'svc-projects', + }, + ); + }); + it('blocks billing account id updates without permission', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index 742ffdc..9096422 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -18,7 +18,10 @@ import { Permission } from 'src/shared/constants/permissions'; import { KAFKA_TOPIC } from 'src/shared/config/kafka.config'; import { Scope } from 'src/shared/enums/scopes.enum'; import { ADMIN_ROLES } from 'src/shared/enums/userRole.enum'; -import { Permission as JsonPermission } from 'src/shared/interfaces/permission.interface'; +import { + Permission as JsonPermission, + PermissionRule as JsonPermissionRule, +} from 'src/shared/interfaces/permission.interface'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { LoggerService } from 'src/shared/modules/global/logger.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; @@ -366,16 +369,18 @@ export class ProjectService { }, }); - await tx.projectMember.create({ - data: { - projectId: createdProject.id, - userId: BigInt(actorUserId), - role: primaryRole, - isPrimary: true, - createdBy: auditUserId, - updatedBy: auditUserId, - }, - }); + if (this.isNumericIdentifier(actorUserId)) { + await tx.projectMember.create({ + data: { + projectId: createdProject.id, + userId: BigInt(actorUserId), + role: primaryRole, + isPrimary: true, + createdBy: auditUserId, + updatedBy: auditUserId, + }, + }); + } if (Array.isArray(dto.members) && dto.members.length > 0) { const additionalMembers = dto.members.filter( @@ -792,6 +797,7 @@ export class ProjectService { */ async deleteProject(projectId: string, user: JwtUser): Promise { const parsedProjectId = this.parseProjectId(projectId); + const actorUserId = this.getActorUserId(user); const auditUserId = this.getAuditUserId(user); const existingProject = await this.prisma.project.findFirst({ @@ -837,7 +843,7 @@ export class ProjectService { this.publishEvent(KAFKA_TOPIC.PROJECT_DELETED, { id: projectId, - deletedBy: user.userId, + deletedBy: actorUserId, }); } @@ -908,7 +914,7 @@ export class ProjectService { for (const permissionRecord of workManagementPermissions) { const hasPermission = this.permissionService.hasPermission( - (permissionRecord.permission || {}) as JsonPermission, + this.normalizeStoredPermission(permissionRecord.permission), user, projectMembers, ); @@ -1043,8 +1049,11 @@ export class ProjectService { user.roles || [], ADMIN_ROLES, ); - const hasAdminScope = (user.scopes || []).includes( - Scope.CONNECT_PROJECT_ADMIN, + const hasAdminScope = this.permissionService.hasPermission( + { + scopes: [Scope.CONNECT_PROJECT_ADMIN], + }, + user, ); if (!hasAdminRole && !hasAdminScope) { @@ -1585,6 +1594,11 @@ export class ProjectService { */ private getActorUserId(user: JwtUser): string { if (!user.userId || String(user.userId).trim().length === 0) { + const machineActorId = this.getMachineActorId(user); + if (machineActorId) { + return machineActorId; + } + throw new ForbiddenException('Authenticated user id is missing.'); } @@ -1603,12 +1617,191 @@ export class ProjectService { const parsedActorId = Number.parseInt(actorId, 10); if (Number.isNaN(parsedActorId)) { + if (user.isMachine) { + return -1; + } + throw new ForbiddenException('Authenticated user id must be numeric.'); } return parsedActorId; } + /** + * Normalizes stored work-management permission payloads into the runtime + * permission shape expected by `PermissionService`. + * + * Legacy rows may use `{ allow: { roles: [...] } }`, where `roles` maps to + * project-member roles. + */ + private normalizeStoredPermission(permission: unknown): JsonPermission { + const normalizedPermission = this.toJsonRecord(permission); + + if (!normalizedPermission) { + return {}; + } + + const hasExplicitAllow = + Object.prototype.hasOwnProperty.call(normalizedPermission, 'allowRule') || + Object.prototype.hasOwnProperty.call(normalizedPermission, 'allow'); + const allowSource = hasExplicitAllow + ? (normalizedPermission.allowRule ?? normalizedPermission.allow) + : normalizedPermission; + const allowRule = this.normalizeStoredPermissionRule(allowSource); + const denyRule = this.normalizeStoredPermissionRule( + normalizedPermission.denyRule ?? normalizedPermission.deny, + ); + + if (hasExplicitAllow) { + return { + ...(allowRule ? { allowRule } : {}), + ...(denyRule ? { denyRule } : {}), + }; + } + + return { + ...(allowRule || {}), + ...(denyRule ? { denyRule } : {}), + }; + } + + /** + * Normalizes one stored permission rule. + */ + private normalizeStoredPermissionRule( + rule: unknown, + ): JsonPermissionRule | undefined { + const normalizedRule = this.toJsonRecord(rule); + + if (!normalizedRule) { + return undefined; + } + + const projectRoles = this.normalizeStoredProjectRoles( + normalizedRule.projectRoles ?? normalizedRule.roles, + ); + const topcoderRoles = this.normalizeStoredRoleList( + normalizedRule.topcoderRoles, + ); + const scopes = this.normalizeStoredStringArray(normalizedRule.scopes); + + return { + ...(typeof projectRoles === 'undefined' ? {} : { projectRoles }), + ...(typeof topcoderRoles === 'undefined' ? {} : { topcoderRoles }), + ...(typeof scopes === 'undefined' ? {} : { scopes }), + }; + } + + /** + * Coerces legacy string-list values into permission-list form. + */ + private normalizeStoredRoleList( + value: unknown, + ): string[] | boolean | undefined { + if (typeof value === 'boolean') { + return value; + } + + return this.normalizeStoredStringArray(value); + } + + /** + * Coerces legacy array values into trimmed string arrays. + */ + private normalizeStoredStringArray(value: unknown): string[] | undefined { + if (typeof value === 'boolean') { + return undefined; + } + + if (!Array.isArray(value)) { + return undefined; + } + + return value + .map((entry) => String(entry).trim()) + .filter((entry) => entry.length > 0); + } + + /** + * Coerces legacy project-role values into `PermissionRule.projectRoles`. + */ + private normalizeStoredProjectRoles( + value: unknown, + ): JsonPermissionRule['projectRoles'] | undefined { + if (typeof value === 'boolean') { + return value; + } + + if (!Array.isArray(value)) { + return undefined; + } + + return value + .map((entry) => { + if (typeof entry === 'string') { + const normalizedRole = entry.trim(); + return normalizedRole.length > 0 ? normalizedRole : null; + } + + const normalizedRule = this.toJsonRecord(entry); + const role = + typeof normalizedRule?.role === 'string' + ? normalizedRule.role.trim() + : ''; + + if (!role) { + return null; + } + + return { + role, + ...(typeof normalizedRule?.isPrimary === 'boolean' + ? { isPrimary: normalizedRule.isPrimary } + : {}), + }; + }) + .filter( + (entry): entry is string | { role: string; isPrimary?: boolean } => + Boolean(entry), + ); + } + + /** + * Returns a plain object record for JSON-like permission payloads. + */ + private toJsonRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + return value as Record; + } + + /** + * Checks whether an identifier is a numeric string. + */ + private isNumericIdentifier(value: string): boolean { + return /^\d+$/.test(value); + } + + /** + * Resolves a stable machine-principal identifier from token claims. + */ + private getMachineActorId(user: JwtUser): string | undefined { + if (!user.isMachine || !user.tokenPayload) { + return undefined; + } + + for (const key of ['sub', 'azp', 'client_id', 'clientId']) { + const value = user.tokenPayload[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + + return undefined; + } + /** * Safely extracts template phase objects from JSON payload. * diff --git a/src/shared/guards/adminOnly.guard.ts b/src/shared/guards/adminOnly.guard.ts index 728ad21..0704897 100644 --- a/src/shared/guards/adminOnly.guard.ts +++ b/src/shared/guards/adminOnly.guard.ts @@ -11,15 +11,17 @@ import { ExecutionContext, ForbiddenException, Injectable, + SetMetadata, UnauthorizedException, UseGuards, } from '@nestjs/common'; import { ApiExtension } from '@nestjs/swagger'; import { Scope } from '../enums/scopes.enum'; -import { ADMIN_ROLES, MANAGER_ROLES } from '../enums/userRole.enum'; +import { ADMIN_ROLES, MANAGER_ROLES, UserRole } from '../enums/userRole.enum'; import { AuthenticatedRequest } from '../interfaces/request.interface'; import { M2MService } from '../modules/global/m2m.service'; import { PermissionService } from '../services/permission.service'; +import { ADMIN_ONLY_KEY } from './auth-metadata.constants'; import { Roles } from './tokenRoles.guard'; /** @@ -35,6 +37,18 @@ export const SWAGGER_ADMIN_ALLOWED_ROLES_KEY = 'x-admin-only-roles'; */ export const SWAGGER_ADMIN_ALLOWED_SCOPES_KEY = 'x-admin-only-scopes'; +/** + * Human admin roles documented in Swagger for admin-only endpoints. + * + * Runtime admin detection still accepts legacy `Connect Admin` tokens to + * preserve backward compatibility, but the public contract is + * `administrator`/`tgadmin` plus the documented M2M scope. + */ +const DOCUMENTED_ADMIN_ROLES: UserRole[] = [ + UserRole.TOPCODER_ADMIN, + UserRole.TG_ADMIN, +]; + /** * Guard that permits only admin roles or admin-equivalent M2M scope. */ @@ -96,9 +110,10 @@ export class AdminOnlyGuard implements CanActivate { */ export const AdminOnly = () => applyDecorators( + SetMetadata(ADMIN_ONLY_KEY, true), UseGuards(AdminOnlyGuard), ApiExtension(SWAGGER_ADMIN_ONLY_KEY, true), - ApiExtension(SWAGGER_ADMIN_ALLOWED_ROLES_KEY, ADMIN_ROLES), + ApiExtension(SWAGGER_ADMIN_ALLOWED_ROLES_KEY, DOCUMENTED_ADMIN_ROLES), ApiExtension(SWAGGER_ADMIN_ALLOWED_SCOPES_KEY, [ Scope.CONNECT_PROJECT_ADMIN, ]), diff --git a/src/shared/guards/auth-metadata.constants.ts b/src/shared/guards/auth-metadata.constants.ts new file mode 100644 index 0000000..b227e30 --- /dev/null +++ b/src/shared/guards/auth-metadata.constants.ts @@ -0,0 +1,4 @@ +/** + * Shared guard metadata keys used across auth decorators and guards. + */ +export const ADMIN_ONLY_KEY = 'admin_only'; diff --git a/src/shared/guards/permission.guard.spec.ts b/src/shared/guards/permission.guard.spec.ts index 6562a8a..3025041 100644 --- a/src/shared/guards/permission.guard.spec.ts +++ b/src/shared/guards/permission.guard.spec.ts @@ -131,7 +131,7 @@ describe('PermissionGuard', () => { ); permissionServiceMock.hasPermission.mockReturnValue(true); - const request = { + const request: any = { user: { userId: '123', isMachine: false, @@ -197,7 +197,7 @@ describe('PermissionGuard', () => { permissionServiceMock.hasPermission.mockReturnValue(true); prismaServiceMock.projectMember.findMany.mockResolvedValue([]); - const request = { + const request: any = { user: { userId: '123', isMachine: false, @@ -214,4 +214,56 @@ describe('PermissionGuard', () => { expect(request.projectContext.projectMembersLoaded).toBe(true); expect(request.projectContext.projectMembers).toEqual([]); }); + + it('loads project invites for invite-aware named permissions on first project access', async () => { + reflectorMock.getAllAndOverride.mockReturnValue(['VIEW_PROJECT']); + permissionServiceMock.isNamedPermissionRequireProjectMembers.mockReturnValue( + true, + ); + permissionServiceMock.isNamedPermissionRequireProjectInvites.mockReturnValue( + true, + ); + permissionServiceMock.hasNamedPermission.mockReturnValue(true); + prismaServiceMock.projectMember.findMany.mockResolvedValue([]); + prismaServiceMock.projectMemberInvite.findMany.mockResolvedValue([ + { + id: BigInt(7), + projectId: BigInt(1001), + userId: BigInt(123), + email: 'user@example.com', + status: 'pending', + deletedAt: null, + }, + ]); + + const request: any = { + user: { + userId: '123', + email: 'user@example.com', + isMachine: false, + }, + params: { + projectId: '1001', + }, + }; + + const result = await guard.canActivate(createExecutionContext(request)); + + expect(result).toBe(true); + expect( + prismaServiceMock.projectMemberInvite.findMany, + ).toHaveBeenCalledTimes(1); + expect(permissionServiceMock.hasNamedPermission).toHaveBeenCalledWith( + 'VIEW_PROJECT', + request.user, + [], + [ + expect.objectContaining({ + email: 'user@example.com', + status: 'pending', + }), + ], + ); + expect(request.projectContext.projectInvitesLoaded).toBe(true); + }); }); diff --git a/src/shared/guards/permission.guard.ts b/src/shared/guards/permission.guard.ts index 057b671..e97fd9b 100644 --- a/src/shared/guards/permission.guard.ts +++ b/src/shared/guards/permission.guard.ts @@ -103,7 +103,10 @@ export class PermissionGuard implements CanActivate { * - Skips DB access if no project id or no project-scoped permission exists. * - Resets cached context when project id changes. * - Loads `projectMember` rows when required and members are not loaded yet. - * - Loads `projectMemberInvite` rows when required and invites are not loaded. + * - Loads `projectMemberInvite` rows when required and invites are not + * loaded yet. + * - Tracks independent `projectMembersLoaded` / `projectInvitesLoaded` + * flags so empty arrays do not suppress the first database load. * @todo Member/invite query and mapping logic is duplicated in multiple guards * and `ProjectContextInterceptor`; extract a shared `ProjectContextService`. */ @@ -142,6 +145,8 @@ export class PermissionGuard implements CanActivate { request.projectContext = { projectMembers: [], projectMembersLoaded: false, + projectInvites: [], + projectInvitesLoaded: false, }; } else if ( request.projectContext.projectMembersLoaded === undefined && @@ -152,11 +157,21 @@ export class PermissionGuard implements CanActivate { request.projectContext.projectMembersLoaded = true; } + if ( + request.projectContext.projectInvitesLoaded === undefined && + request.projectContext.projectId === normalizedProjectId && + Array.isArray(request.projectContext.projectInvites) + ) { + // Backward-compatible bridge for context objects that predate the flag. + request.projectContext.projectInvitesLoaded = true; + } + if (request.projectContext.projectId !== normalizedProjectId) { request.projectContext.projectId = normalizedProjectId; request.projectContext.projectMembers = []; request.projectContext.projectMembersLoaded = false; request.projectContext.projectInvites = []; + request.projectContext.projectInvitesLoaded = false; } if ( @@ -187,7 +202,7 @@ export class PermissionGuard implements CanActivate { if ( requiresProjectInvites && - !Array.isArray(request.projectContext.projectInvites) + request.projectContext.projectInvitesLoaded !== true ) { const projectInvites = await this.prisma.projectMemberInvite.findMany({ where: { @@ -208,6 +223,7 @@ export class PermissionGuard implements CanActivate { ...invite, status: String(invite.status), })); + request.projectContext.projectInvitesLoaded = true; } return { diff --git a/src/shared/guards/tokenRoles.guard.spec.ts b/src/shared/guards/tokenRoles.guard.spec.ts index b2a7456..015a4e6 100644 --- a/src/shared/guards/tokenRoles.guard.spec.ts +++ b/src/shared/guards/tokenRoles.guard.spec.ts @@ -8,6 +8,7 @@ import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { SCOPES_KEY } from '../decorators/scopes.decorator'; import { JwtService } from '../modules/global/jwt.service'; import { M2MService } from '../modules/global/m2m.service'; +import { ADMIN_ONLY_KEY } from './auth-metadata.constants'; import { ANY_AUTHENTICATED_KEY, ROLES_KEY, @@ -339,6 +340,54 @@ describe('TokenRolesGuard', () => { expect(result).toBe(true); }); + it('allows admin-only routes to defer authorization to AdminOnlyGuard', async () => { + const request: Record = { + headers: { + authorization: 'Bearer human-token', + }, + }; + + reflectorMock.getAllAndOverride.mockImplementation((key: string) => { + if (key === IS_PUBLIC_KEY) { + return false; + } + if (key === ROLES_KEY) { + return []; + } + if (key === SCOPES_KEY) { + return []; + } + if (key === ANY_AUTHENTICATED_KEY) { + return false; + } + if (key === ADMIN_ONLY_KEY) { + return true; + } + return undefined; + }); + + jwtServiceMock.validateToken.mockResolvedValue({ + roles: ['Topcoder User'], + scopes: [], + isMachine: false, + tokenPayload: { + sub: '123', + }, + }); + + const result = await guard.canActivate(createExecutionContext(request)); + + expect(result).toBe(true); + expect(request.user).toEqual( + expect.objectContaining({ + tokenPayload: { + sub: '123', + }, + }), + ); + expect(m2mServiceMock.validateMachineToken).not.toHaveBeenCalled(); + }); + it('throws ForbiddenException for machine token when endpoint only defines roles', async () => { reflectorMock.getAllAndOverride.mockImplementation((key: string) => { if (key === IS_PUBLIC_KEY) { diff --git a/src/shared/guards/tokenRoles.guard.ts b/src/shared/guards/tokenRoles.guard.ts index 4a31998..61bd685 100644 --- a/src/shared/guards/tokenRoles.guard.ts +++ b/src/shared/guards/tokenRoles.guard.ts @@ -23,6 +23,7 @@ import { SCOPES_KEY } from '../decorators/scopes.decorator'; import { AuthenticatedRequest } from '../interfaces/request.interface'; import { JwtService } from '../modules/global/jwt.service'; import { M2MService } from '../modules/global/m2m.service'; +import { ADMIN_ONLY_KEY } from './auth-metadata.constants'; /** * Metadata key for required Topcoder roles declared with `@Roles()`. @@ -89,9 +90,13 @@ export class TokenRolesGuard implements CanActivate { * - Throws `UnauthorizedException` when Bearer token is absent or malformed. * - Calls `JwtService.validateToken` and stores the validated user on request. * - Reads both `@Roles()` and `@Scopes()` metadata. - * - Requires one of `@Roles()`, `@Scopes()`, or `@AnyAuthenticated()`. + * - Requires one of `@Roles()`, `@Scopes()`, `@AnyAuthenticated()`, or + * `@AdminOnly()`. * - Throws `ForbiddenException` when no auth metadata is declared. * - Returns `true` for any authenticated user when `@AnyAuthenticated()` is present. + * - Returns `true` for `@AdminOnly()` routes without additional role/scope + * metadata so the route-specific `AdminOnlyGuard` can make the final + * authorization decision. * - For M2M tokens: requires declared scopes and checks scope intersection. * - For human tokens: allows if any required role or scope matches. * - Throws `ForbiddenException('Insufficient permissions')` otherwise. @@ -140,11 +145,17 @@ export class TokenRolesGuard implements CanActivate { context.getHandler(), context.getClass(), ]) || false; + const isAdminOnly = + this.reflector.getAllAndOverride(ADMIN_ONLY_KEY, [ + context.getHandler(), + context.getClass(), + ]) || false; if ( normalizedRequiredRoles.length === 0 && normalizedRequiredScopes.length === 0 && - !isAnyAuthenticated + !isAnyAuthenticated && + !isAdminOnly ) { throw new ForbiddenException('Authorization metadata is required'); } @@ -153,6 +164,14 @@ export class TokenRolesGuard implements CanActivate { return true; } + if ( + isAdminOnly && + normalizedRequiredRoles.length === 0 && + normalizedRequiredScopes.length === 0 + ) { + return true; + } + const machineContext = this.m2mService.validateMachineToken( user.tokenPayload, ); diff --git a/src/shared/interfaces/permission.interface.ts b/src/shared/interfaces/permission.interface.ts index 402f13e..4eae8b7 100644 --- a/src/shared/interfaces/permission.interface.ts +++ b/src/shared/interfaces/permission.interface.ts @@ -123,6 +123,10 @@ export interface ProjectContext { * Cached project members for the current project id. */ projectMembers: ProjectMember[]; + /** + * Whether invites were already loaded for the current `projectId`. + */ + projectInvitesLoaded?: boolean; /** * Cached project invites for the current project id. */ diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index b67433c..51ce242 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -273,6 +273,38 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it('does not allow unrelated human callers to edit projects based on scopes alone', () => { + const allowed = service.hasNamedPermission(Permission.EDIT_PROJECT, { + userId: '3001', + roles: [UserRole.TC_COPILOT], + scopes: [Scope.PROJECTS_WRITE], + isMachine: false, + }); + + expect(allowed).toBe(false); + }); + + it('allows deleting project for machine token with project write scope', () => { + const allowed = service.hasNamedPermission(Permission.DELETE_PROJECT, { + scopes: [Scope.PROJECTS_ALL], + isMachine: true, + }); + + expect(allowed).toBe(true); + }); + + it('allows managing copilot requests for machine token with connect-project scope', () => { + const allowed = service.hasNamedPermission( + Permission.MANAGE_COPILOT_REQUEST, + { + scopes: [Scope.CONNECT_PROJECT_ADMIN], + isMachine: true, + }, + ); + + expect(allowed).toBe(true); + }); + it.each([UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER])( 'allows editing project for %s Topcoder role without project membership', (role) => { diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index 304b70a..8cc90ae 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -180,6 +180,9 @@ export class PermissionService { user.scopes || [], [Scope.CONNECT_PROJECT_ADMIN, Scope.PROJECTS_ALL, Scope.PROJECTS_WRITE], ); + const hasMachineProjectWriteScope = Boolean( + user.isMachine && hasProjectWriteScope, + ); const hasProjectMemberReadScope = this.m2mService.hasRequiredScopes( user.scopes || [], [ @@ -241,12 +244,13 @@ export class PermissionService { isManagementMember || this.isCopilot(member?.role) || this.hasProjectUpdateTopcoderRole(user) || - hasProjectWriteScope + hasMachineProjectWriteScope ); case NamedPermission.DELETE_PROJECT: return ( isAdmin || + hasMachineProjectWriteScope || Boolean( member && this.normalizeRole(member.role) === @@ -348,10 +352,12 @@ export class PermissionService { case NamedPermission.MANAGE_COPILOT_REQUEST: case NamedPermission.ASSIGN_COPILOT_OPPORTUNITY: case NamedPermission.CANCEL_COPILOT_OPPORTUNITY: - return this.hasIntersection(user.roles || [], [ - UserRole.TOPCODER_ADMIN, - UserRole.PROJECT_MANAGER, - ]); + return ( + this.hasIntersection(user.roles || [], [ + UserRole.TOPCODER_ADMIN, + UserRole.PROJECT_MANAGER, + ]) || hasMachineProjectWriteScope + ); case NamedPermission.APPLY_COPILOT_OPPORTUNITY: return this.hasIntersection(user.roles || [], [UserRole.TC_COPILOT]); diff --git a/src/shared/utils/swagger.utils.ts b/src/shared/utils/swagger.utils.ts index 3437b21..94c856c 100644 --- a/src/shared/utils/swagger.utils.ts +++ b/src/shared/utils/swagger.utils.ts @@ -176,11 +176,11 @@ function getAuthorizationLines(operation: SwaggerOperation): string[] { const authorizationLines: string[] = []; const hasAllKnownUserRoles = isAllKnownUserRoles(roles); - if (isAnyAuthenticated) { - authorizationLines.push('Any authenticated token is allowed.'); + if (isAnyAuthenticated || hasAllKnownUserRoles) { + authorizationLines.push('Any authenticated user token is allowed.'); } - if (roles.length > 0 && !(hasAllKnownUserRoles && permissions.length > 0)) { + if (roles.length > 0 && !hasAllKnownUserRoles) { authorizationLines.push(`Allowed user roles (any): ${roles.join(', ')}`); } @@ -212,6 +212,17 @@ function getAuthorizationLines(operation: SwaggerOperation): string[] { return authorizationLines; } +/** + * Removes low-value "all roles" metadata from the final OpenAPI operation. + */ +function pruneSwaggerAuthExtensions(operation: SwaggerOperation): void { + const roles = parseStringArray(operation[SWAGGER_REQUIRED_ROLES_KEY]); + + if (isAllKnownUserRoles(roles)) { + delete operation[SWAGGER_REQUIRED_ROLES_KEY]; + } +} + /** * Enriches OpenAPI operation descriptions with authorization summaries. * @@ -231,6 +242,7 @@ export function enrichSwaggerAuthDocumentation( continue; } + pruneSwaggerAuthExtensions(operation); const authorizationLines = getAuthorizationLines(operation); if (authorizationLines.length === 0) { continue; diff --git a/test/copilot.e2e-spec.ts b/test/copilot.e2e-spec.ts index 90b8392..ab898de 100644 --- a/test/copilot.e2e-spec.ts +++ b/test/copilot.e2e-spec.ts @@ -478,6 +478,54 @@ describe('Copilot endpoints (e2e)', () => { .expect(200); }); + it('allows m2m token with connect-project scope to list copilot requests', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.CONNECT_PROJECT_ADMIN], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.CONNECT_PROJECT_ADMIN, + }, + }); + + await request(app.getHttpServer()) + .get('/v6/projects/copilots/requests') + .set('Authorization', 'Bearer m2m-token') + .expect(200); + }); + + it('allows m2m token with connect-project scope to create copilot requests', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.CONNECT_PROJECT_ADMIN], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.CONNECT_PROJECT_ADMIN, + }, + }); + + await request(app.getHttpServer()) + .post('/v6/projects/100/copilots/requests') + .set('Authorization', 'Bearer m2m-token') + .send({ + data: { + projectId: 100, + opportunityTitle: 'Need a dev copilot', + complexity: 'medium', + requiresCommunication: 'yes', + paymentType: 'standard', + projectType: 'dev', + overview: 'Detailed overview text', + skills: [{ id: '1', name: 'Node.js' }], + startDate: '2026-02-01T00:00:00.000Z', + numWeeks: 4, + tzRestrictions: 'UTC-5 to UTC+1', + numHoursPerWeek: 20, + }, + }) + .expect(201); + }); + it('returns same application payload for repeated apply (idempotency)', async () => { const first = await request(app.getHttpServer()) .post('/v6/projects/copilots/opportunity/21/apply') From 4066b1bf8a637ee1b6bdad43f39648399f934808 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 9 Mar 2026 14:44:12 +1100 Subject: [PATCH 36/41] QA fixes --- .../services/permission.service.spec.ts | 48 ++++++++ src/shared/services/permission.service.ts | 50 ++++++-- test/project-invite.e2e-spec.ts | 110 +++++++++++++++++- 3 files changed, 195 insertions(+), 13 deletions(-) diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index 51ce242..6780eb0 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -222,6 +222,54 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it('allows reading other users project invites for machine token with invite read scope', () => { + const allowed = service.hasNamedPermission( + Permission.READ_PROJECT_INVITE_NOT_OWN, + { + scopes: [Scope.PROJECT_INVITES_READ], + isMachine: true, + }, + ); + + expect(allowed).toBe(true); + }); + + it('allows creating project invites for machine token with invite write scope', () => { + const allowed = service.hasNamedPermission( + Permission.CREATE_PROJECT_INVITE_TOPCODER, + { + scopes: [Scope.PROJECT_INVITES_WRITE], + isMachine: true, + }, + ); + + expect(allowed).toBe(true); + }); + + it('allows updating non-own project invites for machine token with invite write scope', () => { + const allowed = service.hasNamedPermission( + Permission.UPDATE_PROJECT_INVITE_NOT_OWN, + { + scopes: [Scope.PROJECT_INVITES_WRITE], + isMachine: true, + }, + ); + + expect(allowed).toBe(true); + }); + + it('allows deleting non-own project invites for machine token with invite write scope', () => { + const allowed = service.hasNamedPermission( + Permission.DELETE_PROJECT_INVITE_NOT_OWN_TOPCODER, + { + scopes: [Scope.PROJECT_INVITES_WRITE], + isMachine: true, + }, + ); + + expect(allowed).toBe(true); + }); + it('allows viewing project for pending email invite that matches user email', () => { const allowed = service.hasNamedPermission( Permission.VIEW_PROJECT, diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index 8cc90ae..1a9b68d 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -191,6 +191,22 @@ export class PermissionService { Scope.PROJECT_MEMBERS_READ, ], ); + const hasProjectInviteReadScope = this.m2mService.hasRequiredScopes( + user.scopes || [], + [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECT_INVITES_ALL, + Scope.PROJECT_INVITES_READ, + ], + ); + const hasProjectInviteWriteScope = this.m2mService.hasRequiredScopes( + user.scopes || [], + [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECT_INVITES_ALL, + Scope.PROJECT_INVITES_WRITE, + ], + ); const member = this.getProjectMember(user.userId, projectMembers); const hasProjectMembership = Boolean(member); @@ -286,16 +302,25 @@ export class PermissionService { return isAuthenticated; case NamedPermission.READ_PROJECT_INVITE_NOT_OWN: - return isAdmin || hasProjectMembership; + return isAdmin || hasProjectMembership || hasProjectInviteReadScope; case NamedPermission.CREATE_PROJECT_INVITE_TOPCODER: - return isAdmin || isManagementMember; + return isAdmin || isManagementMember || hasProjectInviteWriteScope; case NamedPermission.CREATE_PROJECT_INVITE_CUSTOMER: - return isAdmin || isManagementMember || this.isCopilot(member?.role); + return ( + isAdmin || + isManagementMember || + this.isCopilot(member?.role) || + hasProjectInviteWriteScope + ); case NamedPermission.CREATE_PROJECT_INVITE_COPILOT: - return isAdmin || this.hasCopilotManagerRole(user); + return ( + isAdmin || + this.hasCopilotManagerRole(user) || + hasProjectInviteWriteScope + ); case NamedPermission.UPDATE_PROJECT_INVITE_OWN: case NamedPermission.DELETE_PROJECT_INVITE_OWN: @@ -304,22 +329,31 @@ export class PermissionService { case NamedPermission.UPDATE_PROJECT_INVITE_REQUESTED: case NamedPermission.DELETE_PROJECT_INVITE_REQUESTED: return ( - isAdmin || isManagementMember || this.hasCopilotManagerRole(user) + isAdmin || + isManagementMember || + this.hasCopilotManagerRole(user) || + hasProjectInviteWriteScope ); case NamedPermission.UPDATE_PROJECT_INVITE_NOT_OWN: case NamedPermission.DELETE_PROJECT_INVITE_NOT_OWN_TOPCODER: - return isAdmin || isManagementMember; + return isAdmin || isManagementMember || hasProjectInviteWriteScope; case NamedPermission.DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER: - return isAdmin || isManagementMember || this.isCopilot(member?.role); + return ( + isAdmin || + isManagementMember || + this.isCopilot(member?.role) || + hasProjectInviteWriteScope + ); case NamedPermission.DELETE_PROJECT_INVITE_NOT_OWN_COPILOT: return ( isAdmin || isManagementMember || this.isCopilot(member?.role) || - this.hasCopilotManagerRole(user) + this.hasCopilotManagerRole(user) || + hasProjectInviteWriteScope ); // Billing-account related permissions. diff --git a/test/project-invite.e2e-spec.ts b/test/project-invite.e2e-spec.ts index 37c9763..1e9ab19 100644 --- a/test/project-invite.e2e-spec.ts +++ b/test/project-invite.e2e-spec.ts @@ -9,6 +9,7 @@ import { InviteStatus, ProjectMemberRole } from '@prisma/client'; import * as request from 'supertest'; import { AppModule } from '../src/app.module'; import { ProjectInviteService } from '../src/api/project-invite/project-invite.service'; +import { Scope } from '../src/shared/enums/scopes.enum'; import { JwtService, JwtUser } from '../src/shared/modules/global/jwt.service'; import { M2MService } from '../src/shared/modules/global/m2m.service'; import { PrismaService } from '../src/shared/modules/global/prisma.service'; @@ -69,11 +70,30 @@ describe('Project Invite endpoints (e2e)', () => { }; const m2mServiceMock = { - validateMachineToken: jest.fn(() => ({ - isMachine: false, - scopes: [], - })), - hasRequiredScopes: jest.fn(() => false), + validateMachineToken: jest.fn((payload?: Record) => { + const rawScope = payload?.scope; + const scopes = + typeof rawScope === 'string' + ? rawScope.split(' ').map((scope) => scope.trim().toLowerCase()) + : []; + + return { + isMachine: payload?.gty === 'client-credentials', + scopes, + }; + }), + hasRequiredScopes: jest.fn( + (tokenScopes: string[], requiredScopes: string[]): boolean => { + if (requiredScopes.length === 0) { + return true; + } + + const normalized = tokenScopes.map((scope) => scope.toLowerCase()); + return requiredScopes.some((scope) => + normalized.includes(scope.toLowerCase()), + ); + }, + ), }; const prismaServiceMock = { @@ -173,6 +193,33 @@ describe('Project Invite endpoints (e2e)', () => { expect(projectInviteServiceMock.createInvites).toHaveBeenCalled(); }); + it('creates invites for m2m token with project-invite write scope', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.PROJECT_INVITES_WRITE], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECT_INVITES_WRITE, + }, + }); + + await request(app.getHttpServer()) + .post('/v6/projects/1001/invites') + .set('Authorization', 'Bearer m2m-invite-write') + .send({ handles: ['member'], role: ProjectMemberRole.customer }) + .expect(201); + + expect(projectInviteServiceMock.createInvites).toHaveBeenCalledWith( + '1001', + expect.objectContaining({ role: ProjectMemberRole.customer }), + expect.objectContaining({ + scopes: [Scope.PROJECT_INVITES_WRITE], + isMachine: true, + }), + undefined, + ); + }); + it('returns 403 for partial failures', async () => { projectInviteServiceMock.createInvites.mockResolvedValueOnce({ success: [{ id: '1' }], @@ -201,6 +248,34 @@ describe('Project Invite endpoints (e2e)', () => { expect(projectInviteServiceMock.updateInvite).toHaveBeenCalled(); }); + it('updates invite for m2m token with project-invite write scope', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.PROJECT_INVITES_WRITE], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECT_INVITES_WRITE, + }, + }); + + await request(app.getHttpServer()) + .patch('/v6/projects/1001/invites/10') + .set('Authorization', 'Bearer m2m-invite-write') + .send({ status: InviteStatus.accepted }) + .expect(200); + + expect(projectInviteServiceMock.updateInvite).toHaveBeenCalledWith( + '1001', + '10', + expect.objectContaining({ status: InviteStatus.accepted }), + expect.objectContaining({ + scopes: [Scope.PROJECT_INVITES_WRITE], + isMachine: true, + }), + undefined, + ); + }); + it('deletes invite', async () => { await request(app.getHttpServer()) .delete('/v6/projects/1001/invites/10') @@ -210,6 +285,31 @@ describe('Project Invite endpoints (e2e)', () => { expect(projectInviteServiceMock.deleteInvite).toHaveBeenCalled(); }); + it('deletes invite for m2m token with project-invite write scope', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.PROJECT_INVITES_WRITE], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECT_INVITES_WRITE, + }, + }); + + await request(app.getHttpServer()) + .delete('/v6/projects/1001/invites/10') + .set('Authorization', 'Bearer m2m-invite-write') + .expect(204); + + expect(projectInviteServiceMock.deleteInvite).toHaveBeenCalledWith( + '1001', + '10', + expect.objectContaining({ + scopes: [Scope.PROJECT_INVITES_WRITE], + isMachine: true, + }), + ); + }); + it('gets invite', async () => { await request(app.getHttpServer()) .get('/v6/projects/1001/invites/10') From b43995ea45944427e0d31412ddf409a744cefbe9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 9 Mar 2026 17:24:26 +1100 Subject: [PATCH 37/41] Better handling of non-numeric project IDs --- .../guards/copilotAndAbove.guard.spec.ts | 18 +++++++++++ src/shared/guards/copilotAndAbove.guard.ts | 8 ++++- src/shared/guards/permission.guard.spec.ts | 31 ++++++++++++++++++- src/shared/guards/permission.guard.ts | 12 +++++-- src/shared/guards/projectMember.guard.spec.ts | 18 +++++++++++ src/shared/guards/projectMember.guard.ts | 12 +++++-- .../projectContext.interceptor.spec.ts | 20 +++++++++++- .../projectContext.interceptor.ts | 7 +++-- 8 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/shared/guards/copilotAndAbove.guard.spec.ts b/src/shared/guards/copilotAndAbove.guard.spec.ts index b618fef..7d3d713 100644 --- a/src/shared/guards/copilotAndAbove.guard.spec.ts +++ b/src/shared/guards/copilotAndAbove.guard.spec.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ExecutionContext, ForbiddenException, UnauthorizedException, @@ -153,4 +154,21 @@ describe('CopilotAndAboveGuard', () => { undefined, ); }); + + it('throws BadRequestException when projectId is not numeric', async () => { + await expect( + guard.canActivate( + createExecutionContext({ + user: { + userId: '123', + }, + params: { + projectId: 'abc', + }, + }), + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prismaServiceMock.projectMember.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/src/shared/guards/copilotAndAbove.guard.ts b/src/shared/guards/copilotAndAbove.guard.ts index f3824df..e56c4ed 100644 --- a/src/shared/guards/copilotAndAbove.guard.ts +++ b/src/shared/guards/copilotAndAbove.guard.ts @@ -18,6 +18,7 @@ import { ProjectMember } from '../interfaces/permission.interface'; import { AuthenticatedRequest } from '../interfaces/request.interface'; import { PrismaService } from '../modules/global/prisma.service'; import { PermissionService } from '../services/permission.service'; +import { parseNumericStringId } from '../utils/service.utils'; /** * Guard enforcing the legacy copilot-and-above permission tier. @@ -40,6 +41,7 @@ export class CopilotAndAboveGuard implements CanActivate { * - Throws `UnauthorizedException` when `request.user` is missing. * - Loads project members via `resolveProjectMembers`. * - Calls `permissionService.hasPermission(...)`. + * - Throws `BadRequestException` when `projectId` is present but not numeric. * - Throws `ForbiddenException` when permission check fails. * * @deprecated `PERMISSION.ROLES_COPILOT_AND_ABOVE` is deprecated in @@ -88,6 +90,10 @@ export class CopilotAndAboveGuard implements CanActivate { } const normalizedProjectId = projectId.trim(); + const parsedProjectId = parseNumericStringId( + normalizedProjectId, + 'Project id', + ); if ( request.projectContext?.projectId === normalizedProjectId && @@ -98,7 +104,7 @@ export class CopilotAndAboveGuard implements CanActivate { const projectMembers = await this.prisma.projectMember.findMany({ where: { - projectId: BigInt(normalizedProjectId), + projectId: parsedProjectId, deletedAt: null, }, select: { diff --git a/src/shared/guards/permission.guard.spec.ts b/src/shared/guards/permission.guard.spec.ts index 3025041..9631533 100644 --- a/src/shared/guards/permission.guard.spec.ts +++ b/src/shared/guards/permission.guard.spec.ts @@ -1,4 +1,4 @@ -import { ForbiddenException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { ExecutionContext } from '@nestjs/common/interfaces'; import { Reflector } from '@nestjs/core'; import { UserRole } from '../enums/userRole.enum'; @@ -266,4 +266,33 @@ describe('PermissionGuard', () => { ); expect(request.projectContext.projectInvitesLoaded).toBe(true); }); + + it('throws BadRequestException when a required projectId is not numeric', async () => { + reflectorMock.getAllAndOverride.mockReturnValue(['VIEW_PROJECT']); + permissionServiceMock.isNamedPermissionRequireProjectMembers.mockReturnValue( + true, + ); + permissionServiceMock.isNamedPermissionRequireProjectInvites.mockReturnValue( + false, + ); + + await expect( + guard.canActivate( + createExecutionContext({ + user: { + userId: '123', + isMachine: false, + }, + params: { + projectId: 'abc', + }, + }), + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prismaServiceMock.projectMember.findMany).not.toHaveBeenCalled(); + expect( + prismaServiceMock.projectMemberInvite.findMany, + ).not.toHaveBeenCalled(); + }); }); diff --git a/src/shared/guards/permission.guard.ts b/src/shared/guards/permission.guard.ts index e97fd9b..d98cb8d 100644 --- a/src/shared/guards/permission.guard.ts +++ b/src/shared/guards/permission.guard.ts @@ -22,6 +22,7 @@ import { import { AuthenticatedRequest } from '../interfaces/request.interface'; import { PrismaService } from '../modules/global/prisma.service'; import { PermissionService } from '../services/permission.service'; +import { parseNumericStringId } from '../utils/service.utils'; /** * Policy guard that evaluates route-level permission requirements. @@ -47,6 +48,7 @@ export class PermissionGuard implements CanActivate { * - Throws `UnauthorizedException` if `request.user` is missing. * - Lazily loads project context via `resolveProjectContextIfRequired`. * - Evaluates each permission and allows if any match. + * - Throws `BadRequestException` when a required `projectId` param is not numeric. * - Throws `ForbiddenException('Insufficient permissions')` otherwise. * * @security Routes without `@RequirePermission()` bypass this guard's checks. @@ -101,6 +103,7 @@ export class PermissionGuard implements CanActivate { * * Behavior: * - Skips DB access if no project id or no project-scoped permission exists. + * - Throws `BadRequestException` when a required `projectId` param is not numeric. * - Resets cached context when project id changes. * - Loads `projectMember` rows when required and members are not loaded yet. * - Loads `projectMemberInvite` rows when required and invites are not @@ -141,6 +144,11 @@ export class PermissionGuard implements CanActivate { }; } + const parsedProjectId = parseNumericStringId( + normalizedProjectId, + 'Project id', + ); + if (!request.projectContext) { request.projectContext = { projectMembers: [], @@ -180,7 +188,7 @@ export class PermissionGuard implements CanActivate { ) { const projectMembers = await this.prisma.projectMember.findMany({ where: { - projectId: BigInt(normalizedProjectId), + projectId: parsedProjectId, deletedAt: null, }, select: { @@ -206,7 +214,7 @@ export class PermissionGuard implements CanActivate { ) { const projectInvites = await this.prisma.projectMemberInvite.findMany({ where: { - projectId: BigInt(normalizedProjectId), + projectId: parsedProjectId, deletedAt: null, }, select: { diff --git a/src/shared/guards/projectMember.guard.spec.ts b/src/shared/guards/projectMember.guard.spec.ts index 6ec6d16..90da327 100644 --- a/src/shared/guards/projectMember.guard.spec.ts +++ b/src/shared/guards/projectMember.guard.spec.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ExecutionContext, ForbiddenException, UnauthorizedException, @@ -216,4 +217,21 @@ describe('ProjectMemberGuard', () => { expect(result).toBe(true); expect(prismaServiceMock.projectMember.findMany).not.toHaveBeenCalled(); }); + + it('throws BadRequestException when projectId is not numeric', async () => { + await expect( + guard.canActivate( + createExecutionContext({ + user: { + userId: '123', + }, + params: { + projectId: 'abc', + }, + }), + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prismaServiceMock.projectMember.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/src/shared/guards/projectMember.guard.ts b/src/shared/guards/projectMember.guard.ts index 7a1a47d..a93da23 100644 --- a/src/shared/guards/projectMember.guard.ts +++ b/src/shared/guards/projectMember.guard.ts @@ -20,6 +20,7 @@ import { ProjectMember } from '../interfaces/permission.interface'; import { AuthenticatedRequest } from '../interfaces/request.interface'; import { PrismaService } from '../modules/global/prisma.service'; import { PermissionService } from '../services/permission.service'; +import { parseNumericStringId } from '../utils/service.utils'; /** * Metadata key for required project member roles. @@ -56,6 +57,7 @@ export class ProjectMemberGuard implements CanActivate { * Behavior: * - Throws `UnauthorizedException` if `user.userId` is missing. * - Throws `ForbiddenException` if `projectId` route param is missing. + * - Throws `BadRequestException` if `projectId` is not numeric. * - Resolves members from request cache or database. * - Throws `ForbiddenException('User is not a project member.')` when no * matching member exists. @@ -74,6 +76,7 @@ export class ProjectMemberGuard implements CanActivate { if (!projectId) { throw new ForbiddenException('projectId route param is required.'); } + const parsedProjectId = parseNumericStringId(projectId, 'Project id'); const requiredRoles = this.reflector.getAllAndOverride( @@ -81,7 +84,11 @@ export class ProjectMemberGuard implements CanActivate { [context.getHandler(), context.getClass()], ) || []; - const projectMembers = await this.resolveProjectMembers(request, projectId); + const projectMembers = await this.resolveProjectMembers( + request, + projectId, + parsedProjectId, + ); const member = projectMembers.find( (projectMember) => String(projectMember.userId).trim() === String(user.userId).trim(), @@ -133,6 +140,7 @@ export class ProjectMemberGuard implements CanActivate { private async resolveProjectMembers( request: AuthenticatedRequest, projectId: string, + parsedProjectId: bigint, ): Promise { if ( request.projectContext?.projectId === projectId && @@ -143,7 +151,7 @@ export class ProjectMemberGuard implements CanActivate { const projectMembers = await this.prisma.projectMember.findMany({ where: { - projectId: BigInt(projectId), + projectId: parsedProjectId, deletedAt: null, }, select: { diff --git a/src/shared/interceptors/projectContext.interceptor.spec.ts b/src/shared/interceptors/projectContext.interceptor.spec.ts index b503429..87ae2d7 100644 --- a/src/shared/interceptors/projectContext.interceptor.spec.ts +++ b/src/shared/interceptors/projectContext.interceptor.spec.ts @@ -1,4 +1,8 @@ -import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { + BadRequestException, + CallHandler, + ExecutionContext, +} from '@nestjs/common'; import { of } from 'rxjs'; import { ProjectContextInterceptor } from './projectContext.interceptor'; @@ -112,4 +116,18 @@ describe('ProjectContextInterceptor', () => { expect(request.projectContext.projectMembers).toEqual([]); expect(request.projectContext.projectId).toBe('1002'); }); + + it('throws BadRequestException when projectId is not numeric', async () => { + const request: any = { + params: { + projectId: 'abc', + }, + }; + + await expect( + interceptor.intercept(createExecutionContext(request), next), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prismaServiceMock.projectMember.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/src/shared/interceptors/projectContext.interceptor.ts b/src/shared/interceptors/projectContext.interceptor.ts index 5a1b186..2699dde 100644 --- a/src/shared/interceptors/projectContext.interceptor.ts +++ b/src/shared/interceptors/projectContext.interceptor.ts @@ -14,6 +14,7 @@ import { Observable } from 'rxjs'; import { AuthenticatedRequest } from '../interfaces/request.interface'; import { LoggerService } from '../modules/global/logger.service'; import { PrismaService } from '../modules/global/prisma.service'; +import { parseNumericStringId } from '../utils/service.utils'; /** * Interceptor that preloads and caches project membership context per request. @@ -38,9 +39,9 @@ export class ProjectContextInterceptor implements NestInterceptor { * - Initializes `request.projectContext` if absent. * - Short-circuits when no project id is present. * - Short-circuits on cache hits where project id already matches. + * - Throws `BadRequestException` when `projectId` is present but not numeric. * - Queries active project members and maps `role` to plain strings. * - On query error, logs a warning and stores `projectMembers = []`. - * - Never throws; request processing continues with `next.handle()`. * * @todo Member query + mapping logic is duplicated in multiple guards. * Introduce a shared `ProjectContextService` to centralize loading behavior. @@ -63,6 +64,8 @@ export class ProjectContextInterceptor implements NestInterceptor { return next.handle(); } + const parsedProjectId = parseNumericStringId(projectId, 'Project id'); + if ( request.projectContext.projectId === projectId && Array.isArray(request.projectContext.projectMembers) @@ -75,7 +78,7 @@ export class ProjectContextInterceptor implements NestInterceptor { try { const projectMembers = await this.prisma.projectMember.findMany({ where: { - projectId: BigInt(projectId), + projectId: parsedProjectId, deletedAt: null, }, select: { From 86c7a316cc0be0c04f64cf81b5701725c95a2fa8 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 10 Mar 2026 08:16:31 +1100 Subject: [PATCH 38/41] Fixes from QA --- docs/api-usage-analysis.md | 2 +- .../copilot/copilot-opportunity.controller.ts | 4 +- .../copilot-opportunity.service.spec.ts | 107 ++++++++++++++++++ .../copilot/copilot-opportunity.service.ts | 31 ++++- .../copilot/dto/copilot-opportunity.dto.ts | 12 ++ src/api/project/dto/project-list-query.dto.ts | 3 +- src/shared/utils/project.utils.spec.ts | 67 +++++++++++ src/shared/utils/project.utils.ts | 90 ++++++++++++--- 8 files changed, 289 insertions(+), 27 deletions(-) create mode 100644 src/api/copilot/copilot-opportunity.service.spec.ts create mode 100644 src/shared/utils/project.utils.spec.ts diff --git a/docs/api-usage-analysis.md b/docs/api-usage-analysis.md index a73e8a1..0f2a27a 100644 --- a/docs/api-usage-analysis.md +++ b/docs/api-usage-analysis.md @@ -95,7 +95,7 @@ | PATCH | `/v5/projects/copilots/requests/:copilotRequestId` | `platform-ui` | none | `{data:{...partial editable fields...}}` | Updated request object | | POST | `/v5/projects/:projectId/copilots/requests/:copilotRequestId/approve` | `platform-ui` | none | `{type}` | Created copilot opportunity object | | GET | `/v5/projects/copilots/opportunities` | `platform-ui`, `community-app` | `page`, `pageSize`, `sort`; `community-app` also sends `noGrouping=true` | none | Opportunity list derived from request `data`; public endpoint | -| GET | `/v5/projects/copilot/opportunity/:id` | `platform-ui` | none | none | Single opportunity details; includes flattened request fields, plus `members` and `canApplyAsCopilot` | +| GET | `/v5/projects/copilot/opportunity/:id` | `platform-ui` | none | none | Single opportunity details; includes flattened request fields, plus `members`, `canApplyAsCopilot`, and admin/manager-only `project` metadata | | POST | `/v5/projects/copilots/opportunity/:id/apply` | `platform-ui` | none | `{notes}` | Created (or existing) copilot application object | | GET | `/v5/projects/copilots/opportunity/:id/applications` | `platform-ui` | Optional `sort` | none | For admin/PM: full applications (`id,userId,status,notes,existingMembership,...`); for non-admin: reduced fields (`userId,status,createdAt`) | | POST | `/v5/projects/copilots/opportunity/:id/assign` | `platform-ui` | none | `{applicationId: string}` | `{id: applicationId}` and side effects (member/requests/opportunity state transitions) | diff --git a/src/api/copilot/copilot-opportunity.controller.ts b/src/api/copilot/copilot-opportunity.controller.ts index f91de7e..dd3ec74 100644 --- a/src/api/copilot/copilot-opportunity.controller.ts +++ b/src/api/copilot/copilot-opportunity.controller.ts @@ -69,7 +69,7 @@ export class CopilotOpportunityController { @ApiOperation({ summary: 'List copilot opportunities', description: - 'Lists available copilot opportunities. This endpoint is accessible to authenticated users, including copilots.', + 'Lists available copilot opportunities. This endpoint is accessible to authenticated users, including copilots. Admin and manager callers also receive minimal nested project metadata for v5 compatibility.', }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'pageSize', required: false, type: Number }) @@ -119,7 +119,7 @@ export class CopilotOpportunityController { @ApiOperation({ summary: 'Get copilot opportunity', description: - 'Returns one copilot opportunity with flattened request data and apply eligibility context for /projects/copilots/opportunity/:id.', + 'Returns one copilot opportunity with flattened request data and apply eligibility context for /projects/copilots/opportunity/:id. Admin and manager callers also receive minimal nested project metadata for v5 compatibility.', }) @ApiParam({ name: 'id', required: true, type: String }) @ApiResponse({ status: 200, type: CopilotOpportunityResponseDto }) diff --git a/src/api/copilot/copilot-opportunity.service.spec.ts b/src/api/copilot/copilot-opportunity.service.spec.ts new file mode 100644 index 0000000..201c3d4 --- /dev/null +++ b/src/api/copilot/copilot-opportunity.service.spec.ts @@ -0,0 +1,107 @@ +import { + CopilotOpportunityStatus, + CopilotOpportunityType, +} from '@prisma/client'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { CopilotOpportunityService } from './copilot-opportunity.service'; + +describe('CopilotOpportunityService', () => { + const prismaMock = { + copilotOpportunity: { + findFirst: jest.fn(), + findMany: jest.fn(), + }, + projectMember: { + findMany: jest.fn(), + }, + }; + + const permissionServiceMock = {}; + const notificationServiceMock = {}; + + const pmUser: JwtUser = { + userId: '1001', + roles: [UserRole.PROJECT_MANAGER], + isMachine: false, + }; + + const regularUser: JwtUser = { + userId: '3001', + roles: [UserRole.TOPCODER_USER], + isMachine: false, + }; + + const baseOpportunity = { + id: BigInt(21), + projectId: BigInt(100), + copilotRequestId: BigInt(11), + status: CopilotOpportunityStatus.active, + type: CopilotOpportunityType.dev, + createdAt: new Date('2026-03-01T00:00:00.000Z'), + updatedAt: new Date('2026-03-02T00:00:00.000Z'), + copilotRequest: { + data: { + opportunityTitle: 'Need a dev copilot', + projectType: 'dev', + }, + }, + project: { + id: BigInt(100), + name: 'Demo Project', + members: [ + { + userId: BigInt(3001), + }, + ], + }, + }; + + let service: CopilotOpportunityService; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.projectMember.findMany.mockResolvedValue([]); + service = new CopilotOpportunityService( + prismaMock as any, + permissionServiceMock as any, + notificationServiceMock as any, + ); + }); + + it('includes nested project metadata for project managers when fetching one opportunity', async () => { + prismaMock.copilotOpportunity.findFirst.mockResolvedValue(baseOpportunity); + + const response = await service.getOpportunity('21', pmUser); + + expect(response.projectId).toBe('100'); + expect(response.project).toEqual({ + name: 'Demo Project', + }); + expect(response.members).toEqual(['3001']); + expect(response.canApplyAsCopilot).toBe(true); + }); + + it('omits nested project metadata for regular users while preserving membership eligibility checks', async () => { + prismaMock.copilotOpportunity.findFirst.mockResolvedValue(baseOpportunity); + + const response = await service.getOpportunity('21', regularUser); + + expect(response.projectId).toBeUndefined(); + expect(response.project).toBeUndefined(); + expect(response.members).toEqual(['3001']); + expect(response.canApplyAsCopilot).toBe(false); + }); + + it('includes nested project metadata in list results for project managers', async () => { + prismaMock.copilotOpportunity.findMany.mockResolvedValue([baseOpportunity]); + + const response = await service.listOpportunities({}, pmUser); + + expect(response.data).toHaveLength(1); + expect(response.data[0].projectId).toBe('100'); + expect(response.data[0].project).toEqual({ + name: 'Demo Project', + }); + }); +}); diff --git a/src/api/copilot/copilot-opportunity.service.ts b/src/api/copilot/copilot-opportunity.service.ts index 2a8c21b..3d8c7ba 100644 --- a/src/api/copilot/copilot-opportunity.service.ts +++ b/src/api/copilot/copilot-opportunity.service.ts @@ -82,6 +82,7 @@ export class CopilotOpportunityService { * Lists opportunities with pagination and status-priority grouping. * Uses a two-phase fetch: opportunities first, then membership lookup to compute canApplyAsCopilot. * Default ordering groups by status priority active -> canceled -> completed unless noGrouping is true. + * Admin/manager responses also include minimal project metadata for v5 compatibility. * * @param query Pagination, sort, and noGrouping parameters. * @param user Authenticated JWT user. @@ -156,6 +157,7 @@ export class CopilotOpportunityService { /** * Returns a single opportunity with eligibility context. * canApplyAsCopilot is true when the user is not already a member of the project. + * Admin/manager responses also include minimal project metadata for v5 compatibility. * * @param opportunityId Opportunity id path value. * @param user Authenticated JWT user. @@ -169,6 +171,7 @@ export class CopilotOpportunityService { ): Promise { // TODO [SECURITY]: No permission check is applied; any authenticated user can access any opportunity by id. const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity'); + const includeProject = isAdminOrManager(user); const opportunity = await this.prisma.copilotOpportunity.findFirst({ where: { @@ -213,7 +216,7 @@ export class CopilotOpportunityService { opportunity, canApplyAsCopilot, members, - isAdminOrManager(user), + includeProject, ); } @@ -521,14 +524,14 @@ export class CopilotOpportunityService { * @param input Opportunity row with relations. * @param canApplyAsCopilot Whether caller can apply. * @param members Optional member userId list. - * @param includeProjectId Whether to include projectId in response. + * @param includeProjectDetails Whether to include admin/manager project metadata. * @returns Formatted opportunity response. */ private formatOpportunity( input: OpportunityWithRelations, canApplyAsCopilot: boolean, members: string[] | undefined, - includeProjectId: boolean, + includeProjectDetails: boolean, ): CopilotOpportunityResponseDto { const normalized = normalizeEntity(input) as Record; const requestData = getCopilotRequestData( @@ -549,8 +552,26 @@ export class CopilotOpportunityService { ...requestData, }; - if (includeProjectId && normalized.projectId) { - response.projectId = String(normalized.projectId); + const projectId = + normalized.projectId !== undefined && normalized.projectId !== null + ? String(normalized.projectId) + : normalized.project?.id !== undefined && + normalized.project?.id !== null + ? String(normalized.project.id) + : undefined; + + if (includeProjectDetails && projectId) { + response.projectId = projectId; + } + + if ( + includeProjectDetails && + projectId && + typeof normalized.project?.name === 'string' + ) { + response.project = { + name: normalized.project.name, + }; } return response; diff --git a/src/api/copilot/dto/copilot-opportunity.dto.ts b/src/api/copilot/dto/copilot-opportunity.dto.ts index e77c5e2..e39e304 100644 --- a/src/api/copilot/dto/copilot-opportunity.dto.ts +++ b/src/api/copilot/dto/copilot-opportunity.dto.ts @@ -15,9 +15,18 @@ import { CopilotSkillDto } from './copilot-request.dto'; * DTOs for listing and responding with copilot opportunities. */ +/** + * Minimal project metadata included in admin/manager opportunity responses. + */ +export class CopilotOpportunityProjectDto { + @ApiProperty() + name: string; +} + /** * Flattened response merging opportunity fields with request data. * canApplyAsCopilot indicates whether the current user is eligible to apply. + * Admin/manager callers also receive minimal nested project metadata. */ export class CopilotOpportunityResponseDto { @ApiProperty() @@ -94,6 +103,9 @@ export class CopilotOpportunityResponseDto { @ApiPropertyOptional({ type: [String] }) members?: string[]; + + @ApiPropertyOptional({ type: () => CopilotOpportunityProjectDto }) + project?: CopilotOpportunityProjectDto; } /** diff --git a/src/api/project/dto/project-list-query.dto.ts b/src/api/project/dto/project-list-query.dto.ts index f7a7225..ad881c7 100644 --- a/src/api/project/dto/project-list-query.dto.ts +++ b/src/api/project/dto/project-list-query.dto.ts @@ -119,7 +119,8 @@ export class ProjectListQueryDto extends PaginationDto { memberOnly?: boolean; @ApiPropertyOptional({ - description: 'Keyword for text search over name/description', + description: + 'Keyword for text search over name/description; accepts plain text and quoted phrases.', }) @IsOptional() @IsString() diff --git a/src/shared/utils/project.utils.spec.ts b/src/shared/utils/project.utils.spec.ts new file mode 100644 index 0000000..5ae104e --- /dev/null +++ b/src/shared/utils/project.utils.spec.ts @@ -0,0 +1,67 @@ +import { buildProjectWhereClause } from './project.utils'; + +describe('buildProjectWhereClause', () => { + it('normalizes quoted keyword phrases into safe search filters', () => { + const where = buildProjectWhereClause( + { + keyword: '"my ba"', + } as any, + { + userId: '123', + isMachine: false, + } as any, + true, + ); + + expect(where).toEqual( + expect.objectContaining({ + deletedAt: null, + AND: [ + { + OR: [ + { + name: { + search: 'my & ba', + }, + }, + { + description: { + search: 'my & ba', + }, + }, + { + name: { + contains: 'my ba', + mode: 'insensitive', + }, + }, + { + description: { + contains: 'my ba', + mode: 'insensitive', + }, + }, + ], + }, + ], + }), + ); + }); + + it('does not append invalid full-text filters for empty quoted keywords', () => { + const where = buildProjectWhereClause( + { + keyword: '""', + } as any, + { + userId: '123', + isMachine: false, + } as any, + true, + ); + + expect(where).toEqual({ + deletedAt: null, + }); + }); +}); diff --git a/src/shared/utils/project.utils.ts b/src/shared/utils/project.utils.ts index ea92d5a..0268baf 100644 --- a/src/shared/utils/project.utils.ts +++ b/src/shared/utils/project.utils.ts @@ -42,6 +42,52 @@ function normalize(value: string): string { return value.trim().toLowerCase(); } +/** + * Removes wrapper quotes that some clients add around keyword searches. + * + * Preserves inner quotes and returns a trimmed string. + */ +function normalizeKeywordSearchText(value: string): string { + const trimmed = value.trim(); + + if (trimmed.length < 2) { + return trimmed; + } + + const startsWithDoubleQuote = trimmed.startsWith('"'); + const endsWithDoubleQuote = trimmed.endsWith('"'); + const startsWithSingleQuote = trimmed.startsWith("'"); + const endsWithSingleQuote = trimmed.endsWith("'"); + + if ( + (startsWithDoubleQuote && endsWithDoubleQuote) || + (startsWithSingleQuote && endsWithSingleQuote) + ) { + return trimmed.slice(1, -1).trim(); + } + + return trimmed; +} + +/** + * Converts free-form keyword input into a safe PostgreSQL tsquery string. + * + * Prisma forwards `search` clauses to PostgreSQL full-text search, which will + * raise syntax errors for raw user input containing spaces or tsquery + * operators. This helper tokenizes the input and joins the terms with `&` so + * searches remain valid while still matching all words. + */ +function buildFullTextSearchQuery(value: string): string | undefined { + const normalizedKeyword = normalizeKeywordSearchText(value); + const tokens = normalizedKeyword.match(/[\p{L}\p{N}]+/gu) ?? []; + + if (tokens.length === 0) { + return undefined; + } + + return tokens.join(' & '); +} + /** * Normalizes user ids to trimmed string form. * @@ -250,8 +296,8 @@ export function parseFieldsParameter(fields?: string): ParsedProjectFields { * - `id`, `status`, `billingAccountId`, `type`: exact or multi-value `in`. * - `name`: case-insensitive contains match. * - `directProjectId`: exact bigint match. - * - `keyword`: full-text search (`search`) plus case-insensitive contains on - * name and description. + * - `keyword`: normalized text search across name/description using safe + * full-text terms plus case-insensitive contains matching. * - `code`: case-insensitive contains on name. * - `customer` / `manager`: member-subquery constraints. * - non-admin or `memberOnly=true`: restrict to membership/invite ownership. @@ -333,34 +379,42 @@ export function buildProjectWhereClause( } if (criteria.keyword) { - const keyword = criteria.keyword.trim(); + const keyword = normalizeKeywordSearchText(criteria.keyword); + const fullTextSearchQuery = buildFullTextSearchQuery(criteria.keyword); if (keyword.length > 0) { - appendAndCondition(where, { - OR: [ - { - name: { - search: keyword, - }, + const orConditions: Prisma.ProjectWhereInput[] = [ + { + name: { + contains: keyword, + mode: 'insensitive', }, - { - description: { - search: keyword, - }, + }, + { + description: { + contains: keyword, + mode: 'insensitive', }, + }, + ]; + + if (fullTextSearchQuery) { + orConditions.unshift( { name: { - contains: keyword, - mode: 'insensitive', + search: fullTextSearchQuery, }, }, { description: { - contains: keyword, - mode: 'insensitive', + search: fullTextSearchQuery, }, }, - ], + ); + } + + appendAndCondition(where, { + OR: orConditions, }); } } From 9caf043281a2e8479a86e42de7d355f40a26ac0a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 10 Mar 2026 09:00:37 +1100 Subject: [PATCH 39/41] QA fixes --- README.md | 2 +- docs/PERMISSIONS.md | 5 + docs/api-usage-analysis.md | 2 +- src/api/project/project.controller.spec.ts | 45 ++ src/api/project/project.controller.ts | 67 ++- src/api/project/project.service.spec.ts | 108 ++++- src/api/project/project.service.ts | 240 +++++++++- src/shared/utils/permission-docs.utils.ts | 502 +++++++++++++++++++++ src/shared/utils/swagger.utils.spec.ts | 107 +++++ src/shared/utils/swagger.utils.ts | 51 ++- 10 files changed, 1091 insertions(+), 38 deletions(-) create mode 100644 src/shared/utils/permission-docs.utils.ts create mode 100644 src/shared/utils/swagger.utils.spec.ts diff --git a/README.md b/README.md index 0a0fe0a..6f5059a 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ For the full v5 -> v6 mapping table, see `docs/api-usage-analysis.md`. | `DELETE` | `/v6/projects/:projectId` | Admin only | Soft-delete project | | `GET` | `/v6/projects/:projectId/billingAccount` | JWT / M2M | Default billing account (Salesforce) | | `GET` | `/v6/projects/:projectId/billingAccounts` | JWT / M2M | All billing accounts for project | -| `GET` | `/v6/projects/:projectId/permissions` | JWT | Work-management permission map | +| `GET` | `/v6/projects/:projectId/permissions` | JWT / M2M | JWT: caller work-management policy map. M2M: per-member permission matrix with project permissions and template policies | ### Members diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 343310d..206942b 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -11,6 +11,11 @@ Flow: 3. Route guards (`PermissionGuard`, `AdminOnlyGuard`, `ProjectMemberGuard`, `CopilotAndAboveGuard`) authorize access. 4. Controllers can read `@CurrentUser()` and `@ProjectMembers()`. +Swagger auth notes: + +- The Swagger `Authorization:` section now includes both auth-guard metadata and permission-derived summaries. +- Permission-derived summaries list allowed platform roles, allowed project member roles, pending-invite access, and permission-specific M2M scopes when applicable. + ## Core Building Blocks - Permission constants: `src/shared/constants/permissions.constants.ts` diff --git a/docs/api-usage-analysis.md b/docs/api-usage-analysis.md index 0f2a27a..fb58650 100644 --- a/docs/api-usage-analysis.md +++ b/docs/api-usage-analysis.md @@ -112,7 +112,7 @@ | GET | `/v5/projects/:projectId/attachments` | **unused** | none | none | Attachment array (read-access filtered) | | GET | `/v5/projects/:projectId/phases/:phaseId/products` | **unused** | none | none | Phase product array | | GET | `/v5/projects/:projectId/phases/:phaseId/products/:productId` | **unused** | none | none | Single phase product | -| GET | `/v5/projects/:projectId/permissions` | **unused** | none | none | Policy map `{ [policyName]: true }` for allowed work-management actions | +| GET | `/v5/projects/:projectId/permissions` | **unused** | none | none | JWT: policy map `{ [policyName]: true }` for allowed work-management actions. M2M in `/v6`: per-member permission matrix with memberships, project permissions, and template policies | | DELETE | `/v5/projects/:projectId` | **unused** | none | none | `204` | | GET | `/v5/projects/:projectId/phases/:phaseId` | **unused** | none | none | Phase object (includes members/approvals where present) | | POST | `/v5/projects/:projectId/phases` | **unused** | none | `{name,status,description?,requirements?,startDate?,endDate?,duration?,budget?,spentBudget?,progress?,details?,order?,productTemplateId?,members?}` | Created phase | diff --git a/src/api/project/project.controller.spec.ts b/src/api/project/project.controller.spec.ts index fb7ac2a..3f845d1 100644 --- a/src/api/project/project.controller.spec.ts +++ b/src/api/project/project.controller.spec.ts @@ -172,6 +172,51 @@ describe('ProjectController', () => { ); }); + it('gets project permission matrix for machine token callers', async () => { + serviceMock.getProjectPermissions.mockResolvedValue({ + '40158994': { + memberships: [ + { + memberId: '100956', + role: 'copilot', + isPrimary: true, + }, + ], + topcoderRoles: ['Connect Admin'], + projectPermissions: { + VIEW_PROJECT: true, + }, + workManagementPolicies: {}, + }, + }); + + const result = await controller.getProjectPermissions('303', { + isMachine: true, + scopes: ['read:projects'], + }); + + expect(result).toEqual({ + '40158994': { + memberships: [ + { + memberId: '100956', + role: 'copilot', + isPrimary: true, + }, + ], + topcoderRoles: ['Connect Admin'], + projectPermissions: { + VIEW_PROJECT: true, + }, + workManagementPolicies: {}, + }, + }); + expect(serviceMock.getProjectPermissions).toHaveBeenCalledWith( + '303', + expect.objectContaining({ isMachine: true }), + ); + }); + it('upgrades project', async () => { serviceMock.upgradeProject.mockResolvedValue({ message: 'Project successfully upgraded', diff --git a/src/api/project/project.controller.ts b/src/api/project/project.controller.ts index c8604e7..62ea401 100644 --- a/src/api/project/project.controller.ts +++ b/src/api/project/project.controller.ts @@ -42,7 +42,7 @@ import { import { ProjectWithRelationsDto } from './dto/project-response.dto'; import { UpgradeProjectDto } from './dto/upgrade-project.dto'; import { UpdateProjectDto } from './dto/update-project.dto'; -import { ProjectService } from './project.service'; +import { ProjectPermissionsResponse, ProjectService } from './project.service'; @ApiTags('Projects') @ApiBearerAuth() @@ -311,11 +311,13 @@ export class ProjectController { } /** - * Returns policy decisions for the caller on a project. + * Returns project permissions for the caller or, for M2M, every project user. * * @param projectId Project id path parameter. * @param user Authenticated caller context. - * @returns Policy-name to boolean map; returns `{}` when no template exists. + * @returns JWT callers receive a caller policy map. M2M callers receive a + * per-user matrix containing memberships, Topcoder roles, named project + * permissions, and template work-management policies. * @throws BadRequestException When `projectId` is not numeric. * @throws UnauthorizedException When the caller is unauthenticated. * @throws ForbiddenException When caller cannot access the project. @@ -339,12 +341,59 @@ export class ProjectController { }) @ApiResponse({ status: 200, - description: 'Policy map', + description: 'JWT caller policy map or M2M per-user permission matrix', schema: { - type: 'object', - additionalProperties: { - type: 'boolean', - }, + oneOf: [ + { + type: 'object', + additionalProperties: { + type: 'boolean', + }, + }, + { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + memberships: { + type: 'array', + items: { + type: 'object', + properties: { + memberId: { + type: 'string', + }, + role: { + type: 'string', + }, + isPrimary: { + type: 'boolean', + }, + }, + }, + }, + topcoderRoles: { + type: 'array', + items: { + type: 'string', + }, + }, + projectPermissions: { + type: 'object', + additionalProperties: { + type: 'boolean', + }, + }, + workManagementPolicies: { + type: 'object', + additionalProperties: { + type: 'boolean', + }, + }, + }, + }, + }, + ], }, }) @ApiResponse({ status: 400, description: 'Bad Request' }) @@ -355,7 +404,7 @@ export class ProjectController { async getProjectPermissions( @Param('projectId') projectId: string, @CurrentUser() user: JwtUser, - ): Promise> { + ): Promise { return this.service.getProjectPermissions(projectId, user); } diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index 0558b4c..380671f 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -1,6 +1,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Permission } from 'src/shared/constants/permissions'; import { KAFKA_TOPIC } from 'src/shared/config/kafka.config'; +import { Scope } from 'src/shared/enums/scopes.enum'; import { PermissionService } from 'src/shared/services/permission.service'; import { ProjectService } from './project.service'; @@ -42,6 +43,7 @@ describe('ProjectService', () => { hasNamedPermission: jest.fn(), hasPermission: jest.fn(), hasIntersection: jest.fn(), + isNamedPermissionRequireProjectMembers: jest.fn(), }; const billingAccountServiceMock = { @@ -52,6 +54,7 @@ describe('ProjectService', () => { const memberServiceMock = { getMemberDetailsByUserIds: jest.fn(), + getUserRoles: jest.fn(), }; let service: ProjectService; @@ -60,6 +63,7 @@ describe('ProjectService', () => { jest.clearAllMocks(); prismaMock.$queryRaw.mockResolvedValue([]); memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); + memberServiceMock.getUserRoles.mockResolvedValue([]); service = new ProjectService( prismaMock as any, permissionServiceMock as unknown as PermissionService, @@ -724,7 +728,93 @@ describe('ProjectService', () => { ); }); - it('creates projects for machine tokens without creating a synthetic owner member', async () => { + it('returns a per-user permission matrix for machine tokens even without a template', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + templateId: null, + }); + prismaMock.projectMember.findMany.mockResolvedValue([ + { + id: BigInt(1), + projectId: BigInt(1001), + userId: BigInt(100), + role: 'manager', + isPrimary: true, + deletedAt: null, + }, + { + id: BigInt(2), + projectId: BigInt(1001), + userId: BigInt(200), + role: 'customer', + isPrimary: false, + deletedAt: null, + }, + ]); + permissionServiceMock.isNamedPermissionRequireProjectMembers.mockImplementation( + (permission: Permission) => + [Permission.VIEW_PROJECT, Permission.EDIT_PROJECT].includes(permission), + ); + permissionServiceMock.hasNamedPermission.mockImplementation( + ( + permission: Permission, + matrixUser: { userId?: string }, + members: any[], + ) => + permission === Permission.VIEW_PROJECT || + (permission === Permission.EDIT_PROJECT && + matrixUser.userId === '100' && + members[0]?.role === 'manager'), + ); + memberServiceMock.getUserRoles + .mockResolvedValueOnce(['Connect Admin']) + .mockResolvedValueOnce([]); + + const result = await service.getProjectPermissions('1001', { + isMachine: true, + scopes: [Scope.PROJECTS_READ], + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECTS_READ, + }, + }); + + expect(result).toEqual({ + '100': { + memberships: [ + { + memberId: '1', + role: 'manager', + isPrimary: true, + }, + ], + topcoderRoles: ['Connect Admin'], + projectPermissions: { + VIEW_PROJECT: true, + EDIT_PROJECT: true, + }, + workManagementPolicies: {}, + }, + '200': { + memberships: [ + { + memberId: '2', + role: 'customer', + isPrimary: false, + }, + ], + topcoderRoles: [], + projectPermissions: { + VIEW_PROJECT: true, + }, + workManagementPolicies: {}, + }, + }); + expect(prismaMock.workManagementPermission.findMany).not.toHaveBeenCalled(); + expect(memberServiceMock.getUserRoles).toHaveBeenCalledWith('100'); + expect(memberServiceMock.getUserRoles).toHaveBeenCalledWith('200'); + }); + + it('creates projects for machine principals inferred from token claims without creating a synthetic owner member', async () => { const transactionProjectCreate = jest.fn().mockResolvedValue({ id: BigInt(1001), status: 'in_review', @@ -789,9 +879,11 @@ describe('ProjectService', () => { type: 'app', }, { - isMachine: true, + isMachine: false, scopes: ['write:projects'], tokenPayload: { + gty: 'client-credentials', + scope: 'write:projects', sub: 'svc-projects', }, }, @@ -811,7 +903,7 @@ describe('ProjectService', () => { expect(transactionProjectHistoryCreate).toHaveBeenCalled(); }); - it('updates projects for machine tokens using fallback audit identity', async () => { + it('updates projects for machine principals inferred from token claims using fallback audit identity', async () => { const transactionUpdate = jest.fn().mockResolvedValue({ id: BigInt(1001), name: 'Updated by machine', @@ -883,9 +975,11 @@ describe('ProjectService', () => { status: 'active' as any, }, { - isMachine: true, + isMachine: false, scopes: ['write:projects'], tokenPayload: { + gty: 'client-credentials', + scope: 'write:projects', sub: 'svc-projects', }, }, @@ -901,7 +995,7 @@ describe('ProjectService', () => { ); }); - it('deletes projects for machine tokens using fallback audit identity', async () => { + it('deletes projects for machine principals inferred from token claims using fallback audit identity', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), members: [], @@ -915,9 +1009,11 @@ describe('ProjectService', () => { ); await service.deleteProject('1001', { - isMachine: true, + isMachine: false, scopes: ['write:projects'], tokenPayload: { + gty: 'client-credentials', + scope: 'write:projects', sub: 'svc-projects', }, }); diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index 9096422..8eae430 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -56,6 +56,30 @@ interface PaginatedProjectResponse { total: number; } +type ProjectPolicyMap = Record; + +type WorkManagementPermissionRecord = { + policy: string; + permission: Prisma.JsonValue; +}; + +interface ProjectPermissionMembershipEntry { + memberId: string; + role: string; + isPrimary: boolean; +} + +interface ProjectMemberPermissionMatrixEntry { + memberships: ProjectPermissionMembershipEntry[]; + topcoderRoles: string[]; + projectPermissions: ProjectPolicyMap; + workManagementPolicies: ProjectPolicyMap; +} + +export type ProjectPermissionsResponse = + | ProjectPolicyMap + | Record; + type ProjectMemberWithHandle = ProjectMember & { handle?: string | null; }; @@ -848,19 +872,24 @@ export class ProjectService { } /** - * Computes project template policy decisions for the caller. + * Returns project permissions for the caller or, for M2M, every project user. * - * Returns an empty map when the project does not have a template. + * Human JWT callers keep the legacy v5/v6 behavior and receive the + * work-management policy map allowed for the authenticated caller. + * + * M2M callers receive a per-user matrix built from active project members. + * Each entry includes the user's active memberships, fetched Topcoder roles, + * named project permissions, and template work-management policies. * * @param projectId Project id path parameter. * @param user Authenticated caller context. - * @returns Policy-name to boolean decision map. + * @returns Legacy caller policy map or an M2M per-user permission matrix. * @throws NotFoundException When the project does not exist. */ async getProjectPermissions( projectId: string, user: JwtUser, - ): Promise> { + ): Promise { const parsedProjectId = this.parseProjectId(projectId); const project = await this.prisma.project.findFirst({ @@ -879,6 +908,46 @@ export class ProjectService { ); } + if (this.isMachinePrincipal(user)) { + const workManagementPermissionsPromise: Promise< + WorkManagementPermissionRecord[] + > = project.templateId + ? this.prisma.workManagementPermission.findMany({ + where: { + projectTemplateId: project.templateId, + deletedAt: null, + }, + select: { + policy: true, + permission: true, + }, + }) + : Promise.resolve([]); + + const [projectMembers, workManagementPermissions] = await Promise.all([ + this.prisma.projectMember.findMany({ + where: { + projectId: parsedProjectId, + deletedAt: null, + }, + select: { + id: true, + projectId: true, + userId: true, + role: true, + isPrimary: true, + deletedAt: true, + }, + }), + workManagementPermissionsPromise, + ]); + + return this.buildProjectMemberPermissionMatrix( + projectMembers, + workManagementPermissions, + ); + } + if (!project.templateId) { return {}; } @@ -910,7 +979,7 @@ export class ProjectService { }), ]); - const policyMap: Record = {}; + const policyMap: ProjectPolicyMap = {}; for (const permissionRecord of workManagementPermissions) { const hasPermission = this.permissionService.hasPermission( @@ -1585,12 +1654,123 @@ export class ProjectService { return BigInt(normalizedProjectId); } + /** + * Builds the M2M per-user permission matrix for a project's active members. + * + * Each user entry is keyed by numeric user id string and merges permission + * decisions across all active memberships for that user. + * + * @param projectMembers Active project members. + * @param workManagementPermissions Optional template policy rows. + * @returns Per-user permission matrix keyed by user id. + */ + private async buildProjectMemberPermissionMatrix( + projectMembers: Array< + Pick + >, + workManagementPermissions: WorkManagementPermissionRecord[], + ): Promise> { + const matrixPermissions = Object.values(Permission).filter( + (permission): permission is Permission => + this.permissionService.isNamedPermissionRequireProjectMembers( + permission, + ), + ); + const membershipsByUserId = new Map< + string, + Array> + >(); + + for (const member of projectMembers) { + const parsedUserId = this.parseUserIdValue(member.userId); + + if (!parsedUserId) { + continue; + } + + const normalizedUserId = parsedUserId.toString(); + const memberships = membershipsByUserId.get(normalizedUserId) || []; + memberships.push(member); + membershipsByUserId.set(normalizedUserId, memberships); + } + + const topcoderRolesByUserId = new Map( + await Promise.all( + Array.from(membershipsByUserId.keys()).map( + async (userId): Promise<[string, string[]]> => [ + userId, + await this.memberService.getUserRoles(userId), + ], + ), + ), + ); + const permissionMatrix: Record = + {}; + + for (const [userId, memberships] of membershipsByUserId.entries()) { + const topcoderRoles = topcoderRolesByUserId.get(userId) || []; + const matrixUser: JwtUser = { + userId, + roles: topcoderRoles, + scopes: [], + isMachine: false, + }; + const projectPermissions: ProjectPolicyMap = {}; + const workManagementPolicies: ProjectPolicyMap = {}; + + for (const permission of matrixPermissions) { + const hasPermission = memberships.some((membership) => + this.permissionService.hasNamedPermission(permission, matrixUser, [ + membership, + ]), + ); + + if (hasPermission) { + projectPermissions[permission] = true; + } + } + + for (const permissionRecord of workManagementPermissions) { + const normalizedPermission = this.normalizeStoredPermission( + permissionRecord.permission, + ); + const hasPermission = memberships.some((membership) => + this.permissionService.hasPermission( + normalizedPermission, + matrixUser, + [membership], + ), + ); + + if (hasPermission) { + workManagementPolicies[permissionRecord.policy] = true; + } + } + + permissionMatrix[userId] = { + memberships: memberships.map((membership) => ({ + memberId: + this.toOptionalBigintString(membership.id) || String(membership.id), + role: membership.role, + isPrimary: Boolean(membership.isPrimary), + })), + topcoderRoles, + projectPermissions, + workManagementPolicies, + }; + } + + return permissionMatrix; + } + /** * Returns the authenticated actor user id as trimmed string. * * @param user Authenticated caller context. - * @returns Trimmed actor user id. - * @throws ForbiddenException When `user.userId` is absent. + * @returns Trimmed actor user id, or a machine-principal id from token + * claims when a human user id is unavailable. + * @throws ForbiddenException When neither human nor machine actor identity is + * available. */ private getActorUserId(user: JwtUser): string { if (!user.userId || String(user.userId).trim().length === 0) { @@ -1609,15 +1789,17 @@ export class ProjectService { * Parses actor id into numeric audit column representation. * * @param user Authenticated caller context. - * @returns Numeric actor id. - * @throws ForbiddenException When actor id is missing or non-numeric. + * @returns Numeric actor id, or `-1` for machine principals without numeric + * ids. + * @throws ForbiddenException When actor id is missing or non-numeric for a + * non-machine caller. */ private getAuditUserId(user: JwtUser): number { const actorId = this.getActorUserId(user); const parsedActorId = Number.parseInt(actorId, 10); if (Number.isNaN(parsedActorId)) { - if (user.isMachine) { + if (this.isMachinePrincipal(user)) { return -1; } @@ -1788,7 +1970,7 @@ export class ProjectService { * Resolves a stable machine-principal identifier from token claims. */ private getMachineActorId(user: JwtUser): string | undefined { - if (!user.isMachine || !user.tokenPayload) { + if (!this.isMachinePrincipal(user) || !user.tokenPayload) { return undefined; } @@ -1802,6 +1984,42 @@ export class ProjectService { return undefined; } + /** + * Determines whether the caller is a machine principal. + * + * Falls back to raw token claims so service-layer audit logic still works + * when upstream normalization omits `user.isMachine`. + */ + private isMachinePrincipal(user: JwtUser): boolean { + if (user.isMachine) { + return true; + } + + if (!user.tokenPayload) { + return false; + } + + const grantType = user.tokenPayload.gty; + if ( + typeof grantType === 'string' && + grantType.trim().toLowerCase() === 'client-credentials' + ) { + return true; + } + + const rawScopes = user.tokenPayload.scope ?? user.tokenPayload.scopes; + + if (typeof rawScopes === 'string') { + return rawScopes.trim().length > 0; + } + + if (Array.isArray(rawScopes)) { + return rawScopes.some((scope) => String(scope).trim().length > 0); + } + + return false; + } + /** * Safely extracts template phase objects from JSON payload. * diff --git a/src/shared/utils/permission-docs.utils.ts b/src/shared/utils/permission-docs.utils.ts new file mode 100644 index 0000000..403053c --- /dev/null +++ b/src/shared/utils/permission-docs.utils.ts @@ -0,0 +1,502 @@ +/** + * Swagger-facing permission documentation helpers. + * + * These utilities translate named or inline permission metadata into + * human-readable access summaries without changing runtime authorization. + */ +import { RequiredPermission } from '../decorators/requirePermission.decorator'; +import { + ProjectMemberRole, + PROJECT_MEMBER_MANAGER_ROLES, +} from '../enums/projectMemberRole.enum'; +import { Scope } from '../enums/scopes.enum'; +import { ADMIN_ROLES, UserRole } from '../enums/userRole.enum'; +import { + Permission as PermissionPolicy, + PermissionRule, + ProjectRoleRule, +} from '../interfaces/permission.interface'; +import { Permission as NamedPermission } from '../constants/permissions'; + +/** + * Structured permission summary rendered into Swagger operation descriptions. + */ +export interface PermissionDocumentationSummary { + /** + * Whether the permission allows any authenticated human or machine caller. + */ + allowAnyAuthenticated: boolean; + /** + * Whether any existing project member satisfies the permission. + */ + allowAnyProjectMember: boolean; + /** + * Whether a pending invite recipient satisfies the permission. + */ + allowPendingInvite: boolean; + /** + * Human token roles that satisfy the permission. + */ + userRoles: string[]; + /** + * Project member roles that satisfy the permission. + */ + projectRoles: string[]; + /** + * Machine-token scopes that satisfy the permission. + */ + scopes: string[]; +} + +const LEGACY_TOPCODER_MANAGER_ROLE = 'topcoder_manager'; + +const ADMIN_AND_MANAGER_ROLES = [ + ...ADMIN_ROLES, + UserRole.MANAGER, + LEGACY_TOPCODER_MANAGER_ROLE, +]; + +const STRICT_ADMIN_ACCESS_ROLES = [...ADMIN_ROLES]; + +const PROJECT_UPDATE_TOPCODER_ROLES = [ + ...ADMIN_AND_MANAGER_ROLES, + UserRole.TALENT_MANAGER, + UserRole.TOPCODER_TALENT_MANAGER, + UserRole.PROJECT_MANAGER, +]; + +const PROJECT_BILLING_TOPCODER_ROLES = [ + ...ADMIN_ROLES, + UserRole.PROJECT_MANAGER, + UserRole.TASK_MANAGER, + UserRole.TOPCODER_TASK_MANAGER, + UserRole.TALENT_MANAGER, + UserRole.TOPCODER_TALENT_MANAGER, +]; + +const PROJECT_MEMBER_MANAGEMENT_ROLES = [...PROJECT_MEMBER_MANAGER_ROLES]; + +const PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES = [ + ...PROJECT_MEMBER_MANAGER_ROLES, + ProjectMemberRole.COPILOT, +]; + +const PROJECT_MEMBER_MANAGEMENT_COPILOT_AND_CUSTOMER_ROLES = [ + ...PROJECT_MEMBER_MANAGER_ROLES, + ProjectMemberRole.COPILOT, + ProjectMemberRole.CUSTOMER, +]; + +const PROJECT_READ_SCOPES = [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECTS_ALL, + Scope.PROJECTS_READ, + Scope.PROJECTS_WRITE, +]; + +const PROJECT_WRITE_SCOPES = [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECTS_ALL, + Scope.PROJECTS_WRITE, +]; + +const PROJECT_MEMBER_READ_SCOPES = [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECT_MEMBERS_ALL, + Scope.PROJECT_MEMBERS_READ, +]; + +const PROJECT_INVITE_READ_SCOPES = [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECT_INVITES_ALL, + Scope.PROJECT_INVITES_READ, +]; + +const PROJECT_INVITE_WRITE_SCOPES = [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECT_INVITES_ALL, + Scope.PROJECT_INVITES_WRITE, +]; + +const BILLING_ACCOUNT_READ_SCOPES = [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECTS_READ_USER_BILLING_ACCOUNTS, +]; + +const BILLING_ACCOUNT_DETAILS_SCOPES = [ + Scope.PROJECTS_READ_PROJECT_BILLING_ACCOUNT_DETAILS, +]; + +const STRICT_ADMIN_SCOPES = [Scope.CONNECT_PROJECT_ADMIN]; + +const COPILOT_REQUEST_USER_ROLES = [ + UserRole.TOPCODER_ADMIN, + UserRole.PROJECT_MANAGER, +]; + +/** + * Builds a normalized summary with deterministic ordering. + * + * @param partial summary fields to normalize and deduplicate + * @returns normalized documentation summary + */ +function createSummary( + partial: Partial, +): PermissionDocumentationSummary { + return { + allowAnyAuthenticated: Boolean(partial.allowAnyAuthenticated), + allowAnyProjectMember: Boolean(partial.allowAnyProjectMember), + allowPendingInvite: Boolean(partial.allowPendingInvite), + userRoles: dedupeStrings(partial.userRoles || []), + projectRoles: dedupeStrings(partial.projectRoles || []), + scopes: dedupeStrings(partial.scopes || []), + }; +} + +/** + * Deduplicates strings while preserving first-seen order. + * + * @param values string values to normalize + * @returns deduplicated string array + */ +function dedupeStrings(values: readonly string[]): string[] { + return Array.from( + new Set( + values.map((value) => String(value).trim()).filter((value) => value), + ), + ); +} + +/** + * Formats a project-role rule into a display-friendly string. + * + * @param rule project role string or structured primary-role matcher + * @returns role label for Swagger output + */ +function formatProjectRoleRule(rule: string | ProjectRoleRule): string { + if (typeof rule === 'string') { + return rule; + } + + if (rule.isPrimary) { + return `${rule.role} (primary only)`; + } + + return rule.role; +} + +/** + * Extracts allow-side documentation from an inline permission rule. + * + * @param permission inline permission object used by `@RequirePermission` + * @returns summary derived from allow-rule fields + */ +function getInlinePermissionDocumentation( + permission: PermissionPolicy, +): PermissionDocumentationSummary { + const allowRule: PermissionRule = permission.allowRule || permission; + + return createSummary({ + allowAnyAuthenticated: allowRule.topcoderRoles === true, + userRoles: Array.isArray(allowRule.topcoderRoles) + ? allowRule.topcoderRoles + : [], + allowAnyProjectMember: allowRule.projectRoles === true, + projectRoles: Array.isArray(allowRule.projectRoles) + ? allowRule.projectRoles.map((rule) => formatProjectRoleRule(rule)) + : [], + scopes: allowRule.scopes || [], + }); +} + +/** + * Resolves the documentation summary for a named permission. + * + * The returned data mirrors the current `PermissionService.hasNamedPermission` + * switch so Swagger reflects live authorization behavior. + * + * @param permission named permission enum value + * @returns resolved summary, or `undefined` when unmapped + */ +function getNamedPermissionDocumentation( + permission: NamedPermission, +): PermissionDocumentationSummary | undefined { + switch (permission) { + case NamedPermission.READ_PROJECT_ANY: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + }); + + case NamedPermission.VIEW_PROJECT: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + allowAnyProjectMember: true, + allowPendingInvite: true, + scopes: PROJECT_READ_SCOPES, + }); + + case NamedPermission.CREATE_PROJECT: + return createSummary({ + allowAnyAuthenticated: true, + }); + + case NamedPermission.EDIT_PROJECT: + return createSummary({ + userRoles: PROJECT_UPDATE_TOPCODER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES, + scopes: PROJECT_WRITE_SCOPES, + }); + + case NamedPermission.DELETE_PROJECT: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + projectRoles: [`${ProjectMemberRole.MANAGER}`], + scopes: PROJECT_WRITE_SCOPES, + }); + + case NamedPermission.READ_PROJECT_MEMBER: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + allowAnyProjectMember: true, + scopes: PROJECT_MEMBER_READ_SCOPES, + }); + + case NamedPermission.CREATE_PROJECT_MEMBER_OWN: + return createSummary({ + allowAnyAuthenticated: true, + }); + + case NamedPermission.CREATE_PROJECT_MEMBER_NOT_OWN: + case NamedPermission.UPDATE_PROJECT_MEMBER_NON_CUSTOMER: + case NamedPermission.DELETE_PROJECT_MEMBER_TOPCODER: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_ROLES, + }); + + case NamedPermission.DELETE_PROJECT_MEMBER_CUSTOMER: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES, + }); + + case NamedPermission.DELETE_PROJECT_MEMBER_COPILOT: + return createSummary({ + userRoles: [...ADMIN_AND_MANAGER_ROLES, UserRole.COPILOT_MANAGER], + projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES, + }); + + case NamedPermission.READ_PROJECT_INVITE_OWN: + return createSummary({ + allowAnyAuthenticated: true, + }); + + case NamedPermission.READ_PROJECT_INVITE_NOT_OWN: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + allowAnyProjectMember: true, + scopes: PROJECT_INVITE_READ_SCOPES, + }); + + case NamedPermission.CREATE_PROJECT_INVITE_TOPCODER: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_ROLES, + scopes: PROJECT_INVITE_WRITE_SCOPES, + }); + + case NamedPermission.CREATE_PROJECT_INVITE_CUSTOMER: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES, + scopes: PROJECT_INVITE_WRITE_SCOPES, + }); + + case NamedPermission.CREATE_PROJECT_INVITE_COPILOT: + return createSummary({ + userRoles: [...ADMIN_AND_MANAGER_ROLES, UserRole.COPILOT_MANAGER], + scopes: PROJECT_INVITE_WRITE_SCOPES, + }); + + case NamedPermission.UPDATE_PROJECT_INVITE_OWN: + case NamedPermission.DELETE_PROJECT_INVITE_OWN: + return createSummary({ + allowAnyAuthenticated: true, + }); + + case NamedPermission.UPDATE_PROJECT_INVITE_REQUESTED: + case NamedPermission.DELETE_PROJECT_INVITE_REQUESTED: + return createSummary({ + userRoles: [...ADMIN_AND_MANAGER_ROLES, UserRole.COPILOT_MANAGER], + projectRoles: PROJECT_MEMBER_MANAGEMENT_ROLES, + scopes: PROJECT_INVITE_WRITE_SCOPES, + }); + + case NamedPermission.UPDATE_PROJECT_INVITE_NOT_OWN: + case NamedPermission.DELETE_PROJECT_INVITE_NOT_OWN_TOPCODER: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_ROLES, + scopes: PROJECT_INVITE_WRITE_SCOPES, + }); + + case NamedPermission.DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES, + scopes: PROJECT_INVITE_WRITE_SCOPES, + }); + + case NamedPermission.DELETE_PROJECT_INVITE_NOT_OWN_COPILOT: + return createSummary({ + userRoles: [...ADMIN_AND_MANAGER_ROLES, UserRole.COPILOT_MANAGER], + projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES, + scopes: PROJECT_INVITE_WRITE_SCOPES, + }); + + case NamedPermission.MANAGE_PROJECT_BILLING_ACCOUNT_ID: + case NamedPermission.MANAGE_PROJECT_DIRECT_PROJECT_ID: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + }); + + case NamedPermission.READ_AVL_PROJECT_BILLING_ACCOUNTS: + return createSummary({ + userRoles: PROJECT_BILLING_TOPCODER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES, + scopes: BILLING_ACCOUNT_READ_SCOPES, + }); + + case NamedPermission.READ_PROJECT_BILLING_ACCOUNT_DETAILS: + return createSummary({ + userRoles: PROJECT_BILLING_TOPCODER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES, + scopes: BILLING_ACCOUNT_DETAILS_SCOPES, + }); + + case NamedPermission.MANAGE_COPILOT_REQUEST: + case NamedPermission.ASSIGN_COPILOT_OPPORTUNITY: + case NamedPermission.CANCEL_COPILOT_OPPORTUNITY: + return createSummary({ + userRoles: COPILOT_REQUEST_USER_ROLES, + scopes: PROJECT_WRITE_SCOPES, + }); + + case NamedPermission.APPLY_COPILOT_OPPORTUNITY: + return createSummary({ + userRoles: [UserRole.TC_COPILOT], + }); + + case NamedPermission.CREATE_PROJECT_AS_MANAGER: + return createSummary({ + userRoles: STRICT_ADMIN_ACCESS_ROLES, + }); + + case NamedPermission.VIEW_PROJECT_ATTACHMENT: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + allowAnyProjectMember: true, + }); + + case NamedPermission.CREATE_PROJECT_ATTACHMENT: + case NamedPermission.EDIT_PROJECT_ATTACHMENT: + case NamedPermission.DELETE_PROJECT_ATTACHMENT: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_COPILOT_AND_CUSTOMER_ROLES, + }); + + case NamedPermission.UPDATE_PROJECT_ATTACHMENT_NOT_OWN: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + }); + + case NamedPermission.ADD_PROJECT_PHASE: + case NamedPermission.UPDATE_PROJECT_PHASE: + case NamedPermission.DELETE_PROJECT_PHASE: + case NamedPermission.ADD_PHASE_PRODUCT: + case NamedPermission.UPDATE_PHASE_PRODUCT: + case NamedPermission.DELETE_PHASE_PRODUCT: + case NamedPermission.WORKSTREAM_CREATE: + case NamedPermission.WORKSTREAM_EDIT: + case NamedPermission.WORK_CREATE: + case NamedPermission.WORK_EDIT: + case NamedPermission.WORKITEM_CREATE: + case NamedPermission.WORKITEM_EDIT: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES, + }); + + case NamedPermission.WORKSTREAM_VIEW: + case NamedPermission.WORK_VIEW: + case NamedPermission.WORKITEM_VIEW: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + allowAnyProjectMember: true, + }); + + case NamedPermission.WORKSTREAM_DELETE: + case NamedPermission.WORK_DELETE: + case NamedPermission.WORKITEM_DELETE: + return createSummary({ + userRoles: ADMIN_AND_MANAGER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_ROLES, + }); + + case NamedPermission.WORK_MANAGEMENT_PERMISSION_VIEW: + return createSummary({ + allowAnyAuthenticated: true, + }); + + case NamedPermission.WORK_MANAGEMENT_PERMISSION_EDIT: + return createSummary({ + userRoles: STRICT_ADMIN_ACCESS_ROLES, + scopes: STRICT_ADMIN_SCOPES, + }); + + default: + return undefined; + } +} + +/** + * Merges multiple permission summaries using route-level OR semantics. + * + * @param summaries per-permission summaries + * @returns merged summary, or `undefined` when nothing resolved + */ +export function getRequiredPermissionsDocumentation( + summaries: RequiredPermission[], +): PermissionDocumentationSummary | undefined { + const resolvedSummaries = summaries + .map((permission) => + typeof permission === 'string' + ? getNamedPermissionDocumentation(permission) + : getInlinePermissionDocumentation(permission), + ) + .filter( + (summary): summary is PermissionDocumentationSummary => + typeof summary !== 'undefined', + ); + + if (resolvedSummaries.length === 0) { + return undefined; + } + + return resolvedSummaries.reduce( + (merged, summary) => + createSummary({ + allowAnyAuthenticated: + merged.allowAnyAuthenticated || summary.allowAnyAuthenticated, + allowAnyProjectMember: + merged.allowAnyProjectMember || summary.allowAnyProjectMember, + allowPendingInvite: + merged.allowPendingInvite || summary.allowPendingInvite, + userRoles: [...merged.userRoles, ...summary.userRoles], + projectRoles: [...merged.projectRoles, ...summary.projectRoles], + scopes: [...merged.scopes, ...summary.scopes], + }), + createSummary({}), + ); +} diff --git a/src/shared/utils/swagger.utils.spec.ts b/src/shared/utils/swagger.utils.spec.ts new file mode 100644 index 0000000..70164a9 --- /dev/null +++ b/src/shared/utils/swagger.utils.spec.ts @@ -0,0 +1,107 @@ +import { OpenAPIObject } from '@nestjs/swagger'; +import { Permission } from '../constants/permissions'; +import { SWAGGER_REQUIRED_PERMISSIONS_KEY } from '../decorators/requirePermission.decorator'; +import { SWAGGER_REQUIRED_SCOPES_KEY } from '../decorators/scopes.decorator'; +import { Scope } from '../enums/scopes.enum'; +import { UserRole } from '../enums/userRole.enum'; +import { SWAGGER_REQUIRED_ROLES_KEY } from '../guards/tokenRoles.guard'; +import { enrichSwaggerAuthDocumentation } from './swagger.utils'; + +describe('enrichSwaggerAuthDocumentation', () => { + function createDocument(operation: Record): OpenAPIObject { + return { + openapi: '3.0.0', + info: { + title: 'Swagger auth docs test', + version: '1.0.0', + }, + paths: { + '/test': { + get: { + responses: {}, + ...operation, + }, + }, + }, + } as unknown as OpenAPIObject; + } + + it('renders any-authenticated policy guidance instead of raw permission keys', () => { + const document = createDocument({ + description: 'Create a project.', + [SWAGGER_REQUIRED_ROLES_KEY]: Object.values(UserRole), + [SWAGGER_REQUIRED_SCOPES_KEY]: [ + Scope.PROJECTS_WRITE, + Scope.PROJECTS_ALL, + Scope.CONNECT_PROJECT_ADMIN, + ], + [SWAGGER_REQUIRED_PERMISSIONS_KEY]: [Permission.CREATE_PROJECT], + }); + + enrichSwaggerAuthDocumentation(document); + + const description = document.paths['/test'].get?.description; + + expect(description).toContain('Authorization:'); + expect(description).toContain( + 'Allowed token scopes (any): write:projects, all:projects, all:connect_project', + ); + expect(description).toContain('Policy allows any authenticated caller.'); + expect(description).not.toContain('Required policy permissions'); + }); + + it('documents project-member and invite-based access for project view routes', () => { + const document = createDocument({ + description: 'Get a project.', + [SWAGGER_REQUIRED_ROLES_KEY]: Object.values(UserRole), + [SWAGGER_REQUIRED_SCOPES_KEY]: [ + Scope.PROJECTS_READ, + Scope.PROJECTS_WRITE, + Scope.PROJECTS_ALL, + ], + [SWAGGER_REQUIRED_PERMISSIONS_KEY]: [Permission.VIEW_PROJECT], + }); + + enrichSwaggerAuthDocumentation(document); + + const description = document.paths['/test'].get?.description; + + expect(description).toContain( + 'Policy allows user roles (any): Connect Admin, administrator, tgadmin, Connect Manager, topcoder_manager', + ); + expect(description).toContain('Policy allows any current project member.'); + expect(description).toContain('Policy allows pending invite recipients.'); + expect(description).toContain( + 'Policy allows token scopes (any): all:connect_project, all:projects, read:projects, write:projects', + ); + }); + + it('documents permission-specific machine scopes when policy narrows route access', () => { + const document = createDocument({ + description: 'Get project billing account details.', + [SWAGGER_REQUIRED_ROLES_KEY]: Object.values(UserRole), + [SWAGGER_REQUIRED_SCOPES_KEY]: [ + Scope.PROJECTS_READ, + Scope.PROJECTS_WRITE, + Scope.PROJECTS_ALL, + ], + [SWAGGER_REQUIRED_PERMISSIONS_KEY]: [ + Permission.READ_PROJECT_BILLING_ACCOUNT_DETAILS, + ], + }); + + enrichSwaggerAuthDocumentation(document); + + const description = document.paths['/test'].get?.description; + + expect(description).toContain( + 'Policy allows user roles (any): Connect Admin, administrator, tgadmin, Project Manager, Task Manager, Topcoder Task Manager, Talent Manager, Topcoder Talent Manager', + ); + expect(description).toContain( + 'Policy allows project member roles (any): manager, account_manager, account_executive, project_manager, program_manager, solution_architect, copilot', + ); + expect(description).toContain( + 'Policy allows token scopes (any): read:project-billing-account-details', + ); + }); +}); diff --git a/src/shared/utils/swagger.utils.ts b/src/shared/utils/swagger.utils.ts index 94c856c..204fc5e 100644 --- a/src/shared/utils/swagger.utils.ts +++ b/src/shared/utils/swagger.utils.ts @@ -21,6 +21,7 @@ import { SWAGGER_REQUIRED_ROLES_KEY, } from '../guards/tokenRoles.guard'; import { UserRole } from '../enums/userRole.enum'; +import { getRequiredPermissionsDocumentation } from './permission-docs.utils'; type SwaggerOperation = { description?: string; @@ -57,18 +58,14 @@ function parseStringArray(value: unknown): string[] { } /** - * Parses required-permission extension values to display-friendly strings. - * - * Inline permission objects are JSON-stringified. + * Parses required-permission extension values from Swagger metadata. */ -function parsePermissionArray(value: unknown): string[] { +function parseRequiredPermissions(value: unknown): RequiredPermission[] { if (!Array.isArray(value)) { return []; } - return value - .map((entry) => stringifyPermission(entry as RequiredPermission)) - .filter((entry) => entry.length > 0); + return value as RequiredPermission[]; } /** @@ -162,9 +159,11 @@ function getAuthorizationLines(operation: SwaggerOperation): string[] { const roles = parseStringArray(operation[SWAGGER_REQUIRED_ROLES_KEY]); const scopes = parseStringArray(operation[SWAGGER_REQUIRED_SCOPES_KEY]); const isAnyAuthenticated = Boolean(operation[SWAGGER_ANY_AUTHENTICATED_KEY]); - const permissions = parsePermissionArray( + const permissions = parseRequiredPermissions( operation[SWAGGER_REQUIRED_PERMISSIONS_KEY], ); + const permissionDocumentation = + getRequiredPermissionsDocumentation(permissions); const isAdminOnly = Boolean(operation[SWAGGER_ADMIN_ONLY_KEY]); const adminRoles = parseStringArray( operation[SWAGGER_ADMIN_ALLOWED_ROLES_KEY], @@ -188,9 +187,41 @@ function getAuthorizationLines(operation: SwaggerOperation): string[] { authorizationLines.push(`Allowed token scopes (any): ${scopes.join(', ')}`); } - if (permissions.length > 0) { + if (permissionDocumentation?.allowAnyAuthenticated) { + authorizationLines.push('Policy allows any authenticated caller.'); + } + + if ((permissionDocumentation?.userRoles.length || 0) > 0) { + authorizationLines.push( + `Policy allows user roles (any): ${permissionDocumentation?.userRoles.join(', ')}`, + ); + } + + if (permissionDocumentation?.allowAnyProjectMember) { + authorizationLines.push('Policy allows any current project member.'); + } + + if ((permissionDocumentation?.projectRoles.length || 0) > 0) { + authorizationLines.push( + `Policy allows project member roles (any): ${permissionDocumentation?.projectRoles.join(', ')}`, + ); + } + + if (permissionDocumentation?.allowPendingInvite) { + authorizationLines.push('Policy allows pending invite recipients.'); + } + + if ((permissionDocumentation?.scopes.length || 0) > 0) { + authorizationLines.push( + `Policy allows token scopes (any): ${permissionDocumentation?.scopes.join(', ')}`, + ); + } + + if (permissions.length > 0 && !permissionDocumentation) { authorizationLines.push( - `Required policy permissions (any): ${permissions.join(', ')}`, + `Required policy permissions (any): ${permissions + .map((permission) => stringifyPermission(permission)) + .join(', ')}`, ); } From b5e2433e7ac361cfdff58620f4d81b924f5877d1 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 10 Mar 2026 09:25:25 +1100 Subject: [PATCH 40/41] QA Fixes --- README.md | 14 ++--- src/shared/modules/global/jwt.service.ts | 2 +- src/shared/modules/global/m2m.service.ts | 2 +- .../services/permission.service.spec.ts | 34 ++++++++++++ src/shared/services/permission.service.ts | 52 ++++++++++++++----- src/shared/utils/scope.utils.spec.ts | 27 ++++++++++ src/shared/utils/scope.utils.ts | 44 +++++++++++----- test/copilot.e2e-spec.ts | 28 ++++++---- 8 files changed, 159 insertions(+), 44 deletions(-) create mode 100644 src/shared/utils/scope.utils.spec.ts diff --git a/README.md b/README.md index 6f5059a..0b6d826 100644 --- a/README.md +++ b/README.md @@ -158,12 +158,12 @@ For the full v5 -> v6 mapping table, see `docs/api-usage-analysis.md`. | Method | Path | Auth | Description | | --- | --- | --- | --- | -| `GET` | `/v6/projects/copilots/requests` | JWT | List all copilot requests (admin/PM sees all; others see own) | -| `GET` | `/v6/projects/:projectId/copilots/requests` | JWT | Project-scoped copilot requests | -| `GET` | `/v6/projects/copilots/requests/:copilotRequestId` | JWT | Get single copilot request | -| `POST` | `/v6/projects/:projectId/copilots/requests` | JWT | Create copilot request | -| `PATCH` | `/v6/projects/copilots/requests/:copilotRequestId` | JWT | Update copilot request | -| `POST` | `/v6/projects/:projectId/copilots/requests/:copilotRequestId/approve` | JWT | Approve request -> creates opportunity | +| `GET` | `/v6/projects/copilots/requests` | JWT / M2M | List all copilot requests (admin/PM sees all; others see own) | +| `GET` | `/v6/projects/:projectId/copilots/requests` | JWT / M2M | Project-scoped copilot requests | +| `GET` | `/v6/projects/copilots/requests/:copilotRequestId` | JWT / M2M | Get single copilot request | +| `POST` | `/v6/projects/:projectId/copilots/requests` | JWT / M2M | Create copilot request | +| `PATCH` | `/v6/projects/copilots/requests/:copilotRequestId` | JWT / M2M | Update copilot request | +| `POST` | `/v6/projects/:projectId/copilots/requests/:copilotRequestId/approve` | JWT / M2M | Approve request -> creates opportunity | | `GET` | `/v6/projects/copilots/opportunities` | **Public** | List copilot opportunities | | `GET` | `/v6/projects/copilot/opportunity/:id` | **Public** | Get opportunity details | | `POST` | `/v6/projects/copilots/opportunity/:id/apply` | JWT | Apply as copilot | @@ -171,6 +171,8 @@ For the full v5 -> v6 mapping table, see `docs/api-usage-analysis.md`. | `POST` | `/v6/projects/copilots/opportunity/:id/assign` | JWT | Assign copilot (triggers member/state transitions) | | `DELETE` | `/v6/projects/copilots/opportunity/:id/cancel` | JWT | Cancel opportunity (cascade) | +Copilot request management routes accept M2M tokens with project-write authorization such as `write:projects`, `all:projects`, or `all:connect_project`. + ### Metadata See `docs/api-usage-analysis.md` (P2 section) for the complete metadata list. diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index 87b42ee..a576d6d 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -389,7 +389,7 @@ export class JwtService implements OnModuleInit { } /** - * Extracts token scopes from standard `scope`/`scopes` claims. + * Extracts token scopes from supported scope-like claims. * * @param {JwtPayloadRecord} payload Token payload. * @returns {string[]} Normalized list of scopes. diff --git a/src/shared/modules/global/m2m.service.ts b/src/shared/modules/global/m2m.service.ts index 106f2da..7fdb02f 100644 --- a/src/shared/modules/global/m2m.service.ts +++ b/src/shared/modules/global/m2m.service.ts @@ -112,7 +112,7 @@ export class M2MService { } /** - * Extracts scope claims from token payload. + * Extracts scope-like claims from token payload. * * @param {TokenPayload} payload Decoded token payload. * @returns {string[]} Normalized scopes. diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index 6780eb0..d878512 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -6,6 +6,24 @@ import { PermissionService } from './permission.service'; describe('PermissionService', () => { const m2mServiceMock = { + validateMachineToken: jest.fn((payload?: Record) => { + const rawScope = + payload?.scope ?? + payload?.scopes ?? + payload?.scp ?? + payload?.permissions; + const scopes = + typeof rawScope === 'string' + ? rawScope.split(/\s+/u).map((scope) => scope.trim().toLowerCase()) + : Array.isArray(rawScope) + ? rawScope.map((scope) => String(scope).trim().toLowerCase()) + : []; + + return { + isMachine: payload?.gty === 'client-credentials' || scopes.length > 0, + scopes: scopes.filter((scope) => scope.length > 0), + }; + }), hasRequiredScopes: jest.fn( (tokenScopes: string[], requiredScopes: string[]) => requiredScopes.some((requiredScope) => @@ -353,6 +371,22 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it('allows managing copilot requests when machine scope is only present on the raw token payload', () => { + const allowed = service.hasNamedPermission( + Permission.MANAGE_COPILOT_REQUEST, + { + scopes: [], + isMachine: false, + tokenPayload: { + gty: 'client-credentials', + permissions: [Scope.PROJECTS_ALL], + }, + }, + ); + + expect(allowed).toBe(true); + }); + it.each([UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER])( 'allows editing project for %s Topcoder role without project membership', (role) => { diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index 1a9b68d..b1558f5 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -55,6 +55,8 @@ export class PermissionService { return false; } + const machineContext = this.resolveMachineContext(user); + if (permissionRule.projectRoles && projectMembers) { const member = this.getProjectMember(user.userId, projectMembers); @@ -87,7 +89,7 @@ export class PermissionService { if (permissionRule.scopes) { hasScope = this.m2mService.hasRequiredScopes( - user.scopes || [], + machineContext.scopes, permissionRule.scopes, ); } @@ -149,11 +151,13 @@ export class PermissionService { return false; } + const machineContext = this.resolveMachineContext(user); + const effectiveScopes = machineContext.scopes; const isAuthenticated = Boolean(user.userId && String(user.userId).trim().length > 0) || (Array.isArray(user.roles) && user.roles.length > 0) || - (Array.isArray(user.scopes) && user.scopes.length > 0) || - user.isMachine; + effectiveScopes.length > 0 || + machineContext.isMachine; // TODO: intentionally permissive authentication gate for CREATE_PROJECT; reassess whether any role/scope/machine token should qualify. // TODO: replace 'topcoder_manager' string literal with UserRole enum value. @@ -164,11 +168,11 @@ export class PermissionService { ]); const hasStrictAdminAccess = this.hasIntersection(user.roles || [], ADMIN_ROLES) || - this.m2mService.hasRequiredScopes(user.scopes || [], [ + this.m2mService.hasRequiredScopes(effectiveScopes, [ Scope.CONNECT_PROJECT_ADMIN, ]); const hasProjectReadScope = this.m2mService.hasRequiredScopes( - user.scopes || [], + effectiveScopes, [ Scope.CONNECT_PROJECT_ADMIN, Scope.PROJECTS_ALL, @@ -177,14 +181,14 @@ export class PermissionService { ], ); const hasProjectWriteScope = this.m2mService.hasRequiredScopes( - user.scopes || [], + effectiveScopes, [Scope.CONNECT_PROJECT_ADMIN, Scope.PROJECTS_ALL, Scope.PROJECTS_WRITE], ); const hasMachineProjectWriteScope = Boolean( - user.isMachine && hasProjectWriteScope, + machineContext.isMachine && hasProjectWriteScope, ); const hasProjectMemberReadScope = this.m2mService.hasRequiredScopes( - user.scopes || [], + effectiveScopes, [ Scope.CONNECT_PROJECT_ADMIN, Scope.PROJECT_MEMBERS_ALL, @@ -192,7 +196,7 @@ export class PermissionService { ], ); const hasProjectInviteReadScope = this.m2mService.hasRequiredScopes( - user.scopes || [], + effectiveScopes, [ Scope.CONNECT_PROJECT_ADMIN, Scope.PROJECT_INVITES_ALL, @@ -200,7 +204,7 @@ export class PermissionService { ], ); const hasProjectInviteWriteScope = this.m2mService.hasRequiredScopes( - user.scopes || [], + effectiveScopes, [ Scope.CONNECT_PROJECT_ADMIN, Scope.PROJECT_INVITES_ALL, @@ -366,7 +370,7 @@ export class PermissionService { isManagementMember || this.isCopilot(member?.role) || this.hasProjectBillingTopcoderRole(user) || - this.m2mService.hasRequiredScopes(user.scopes || [], [ + this.m2mService.hasRequiredScopes(effectiveScopes, [ Scope.CONNECT_PROJECT_ADMIN, Scope.PROJECTS_READ_USER_BILLING_ACCOUNTS, ]) @@ -377,7 +381,7 @@ export class PermissionService { isManagementMember || this.isCopilot(member?.role) || this.hasProjectBillingTopcoderRole(user) || - this.m2mService.hasRequiredScopes(user.scopes || [], [ + this.m2mService.hasRequiredScopes(effectiveScopes, [ Scope.PROJECTS_READ_PROJECT_BILLING_ACCOUNT_DETAILS, ]) ); @@ -591,6 +595,30 @@ export class PermissionService { ); } + /** + * Resolves machine-token status and effective scopes from the normalized user + * and the raw token payload so guard and permission checks stay aligned. + * + * @param user authenticated JWT user context + * @returns machine classification and the scopes to evaluate + */ + private resolveMachineContext(user: JwtUser): { + isMachine: boolean; + scopes: string[]; + } { + const payloadMachineContext = this.m2mService.validateMachineToken( + user.tokenPayload, + ); + + return { + isMachine: Boolean(user.isMachine || payloadMachineContext.isMachine), + scopes: + Array.isArray(user.scopes) && user.scopes.length > 0 + ? user.scopes + : payloadMachineContext.scopes, + }; + } + /** * Finds a project member record for the given user id. * diff --git a/src/shared/utils/scope.utils.spec.ts b/src/shared/utils/scope.utils.spec.ts new file mode 100644 index 0000000..6a59c95 --- /dev/null +++ b/src/shared/utils/scope.utils.spec.ts @@ -0,0 +1,27 @@ +import { extractScopesFromPayload } from './scope.utils'; + +describe('extractScopesFromPayload', () => { + it('splits string claims on arbitrary whitespace', () => { + expect( + extractScopesFromPayload({ + scope: 'all:projects\twrite:projects\nread:projects', + }), + ).toEqual(['all:projects', 'write:projects', 'read:projects']); + }); + + it('merges supported scope claims and deduplicates values', () => { + expect( + extractScopesFromPayload({ + scope: 'all:projects', + scp: 'write:projects', + scopes: ['all:projects', 'read:projects'], + permissions: ['write:projects', 'all:connect_project'], + }), + ).toEqual([ + 'all:projects', + 'read:projects', + 'write:projects', + 'all:connect_project', + ]); + }); +}); diff --git a/src/shared/utils/scope.utils.ts b/src/shared/utils/scope.utils.ts index fa57821..0548b3e 100644 --- a/src/shared/utils/scope.utils.ts +++ b/src/shared/utils/scope.utils.ts @@ -6,29 +6,47 @@ type ScopeNormalizer = (scope: string) => string; type ScopePayload = { scope?: unknown; scopes?: unknown; + scp?: unknown; + permissions?: unknown; }; /** - * Extracts `scope` / `scopes` claims from a payload and normalizes values. + * Extracts scope-like claims from a payload and normalizes values. + * + * Supports `scope`, `scopes`, `scp`, and `permissions`. String claims are + * split on arbitrary whitespace so copied token payloads that contain tabs or + * newlines are handled the same as space-delimited scope strings. */ export function extractScopesFromPayload( payload: ScopePayload, normalizeScope: ScopeNormalizer = (scope) => scope.trim(), ): string[] { - const rawScopes = payload.scope || payload.scopes; + const extractedScopes: string[] = []; - if (typeof rawScopes === 'string') { - return rawScopes - .split(' ') - .map((scope) => normalizeScope(scope)) - .filter((scope) => scope.length > 0); - } + for (const rawScope of [ + payload.scope, + payload.scopes, + payload.scp, + payload.permissions, + ]) { + if (typeof rawScope === 'string') { + extractedScopes.push( + ...rawScope + .split(/\s+/u) + .map((scope) => normalizeScope(scope)) + .filter((scope) => scope.length > 0), + ); + continue; + } - if (Array.isArray(rawScopes)) { - return rawScopes - .map((scope) => normalizeScope(String(scope))) - .filter((scope) => scope.length > 0); + if (Array.isArray(rawScope)) { + extractedScopes.push( + ...rawScope + .map((scope) => normalizeScope(String(scope))) + .filter((scope) => scope.length > 0), + ); + } } - return []; + return Array.from(new Set(extractedScopes)); } diff --git a/test/copilot.e2e-spec.ts b/test/copilot.e2e-spec.ts index ab898de..a748b61 100644 --- a/test/copilot.e2e-spec.ts +++ b/test/copilot.e2e-spec.ts @@ -74,15 +74,21 @@ describe('Copilot endpoints (e2e)', () => { const m2mServiceMock = { validateMachineToken: jest.fn((payload?: Record) => { - const rawScope = payload?.scope; + const rawScope = + payload?.scope ?? + payload?.scopes ?? + payload?.scp ?? + payload?.permissions; const scopes = typeof rawScope === 'string' - ? rawScope.split(' ').map((scope) => scope.trim().toLowerCase()) - : []; + ? rawScope.split(/\s+/u).map((scope) => scope.trim().toLowerCase()) + : Array.isArray(rawScope) + ? rawScope.map((scope) => String(scope).trim().toLowerCase()) + : []; return { - isMachine: payload?.gty === 'client-credentials', - scopes, + isMachine: payload?.gty === 'client-credentials' || scopes.length > 0, + scopes: scopes.filter((scope) => scope.length > 0), }; }), hasRequiredScopes: jest.fn( @@ -478,13 +484,13 @@ describe('Copilot endpoints (e2e)', () => { .expect(200); }); - it('allows m2m token with connect-project scope to list copilot requests', async () => { + it('allows m2m token with all:projects scope to list copilot requests', async () => { (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ - scopes: [Scope.CONNECT_PROJECT_ADMIN], + scopes: [Scope.PROJECTS_ALL], isMachine: true, tokenPayload: { gty: 'client-credentials', - scope: Scope.CONNECT_PROJECT_ADMIN, + permissions: [Scope.PROJECTS_ALL], }, }); @@ -494,13 +500,13 @@ describe('Copilot endpoints (e2e)', () => { .expect(200); }); - it('allows m2m token with connect-project scope to create copilot requests', async () => { + it('allows m2m token with all:projects scope to create copilot requests', async () => { (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ - scopes: [Scope.CONNECT_PROJECT_ADMIN], + scopes: [Scope.PROJECTS_ALL], isMachine: true, tokenPayload: { gty: 'client-credentials', - scope: Scope.CONNECT_PROJECT_ADMIN, + permissions: [Scope.PROJECTS_ALL], }, }); From dc1b0a33d7a11e64333ffe3dd2896f7a670655e3 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 10 Mar 2026 09:49:40 +1100 Subject: [PATCH 41/41] Further tweaks for M2M token usage --- src/api/copilot/copilot.utils.spec.ts | 14 ++ src/api/copilot/copilot.utils.ts | 3 +- src/api/metadata/utils/metadata-utils.spec.ts | 41 +++ src/api/metadata/utils/metadata-utils.ts | 39 ++- .../project-invite.service.spec.ts | 234 ++++++++++++++++++ .../project-member.service.spec.ts | 219 +++++++++++++++- src/shared/utils/service.utils.ts | 70 +++++- 7 files changed, 610 insertions(+), 10 deletions(-) create mode 100644 src/api/metadata/utils/metadata-utils.spec.ts diff --git a/src/api/copilot/copilot.utils.spec.ts b/src/api/copilot/copilot.utils.spec.ts index ae04965..cf8f46b 100644 --- a/src/api/copilot/copilot.utils.spec.ts +++ b/src/api/copilot/copilot.utils.spec.ts @@ -13,6 +13,20 @@ describe('copilot utils', () => { ).toBe(-1); }); + it('returns -1 for machine principals inferred from token claims', () => { + expect( + getAuditUserId({ + isMachine: false, + scopes: ['write:projects'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:projects', + sub: 'svc-projects', + }, + }), + ).toBe(-1); + }); + it('throws for human tokens without numeric user ids', () => { expect(() => getAuditUserId({ diff --git a/src/api/copilot/copilot.utils.ts b/src/api/copilot/copilot.utils.ts index 9197e32..68eb382 100644 --- a/src/api/copilot/copilot.utils.ts +++ b/src/api/copilot/copilot.utils.ts @@ -5,6 +5,7 @@ import { UserRole } from 'src/shared/enums/userRole.enum'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { PermissionService } from 'src/shared/services/permission.service'; import { normalizeEntity as normalizePrismaEntity } from 'src/shared/utils/entity.utils'; +import { isMachinePrincipal } from 'src/shared/utils/service.utils'; /** * Shared pure-function toolkit for the copilot feature. @@ -151,7 +152,7 @@ export function getAuditUserId(user: JwtUser): number { const value = Number.parseInt(String(user.userId || '').trim(), 10); if (Number.isNaN(value)) { - if (user.isMachine) { + if (isMachinePrincipal(user)) { return -1; } diff --git a/src/api/metadata/utils/metadata-utils.spec.ts b/src/api/metadata/utils/metadata-utils.spec.ts new file mode 100644 index 0000000..7140692 --- /dev/null +++ b/src/api/metadata/utils/metadata-utils.spec.ts @@ -0,0 +1,41 @@ +import { ForbiddenException } from '@nestjs/common'; +import { getAuditUserIdBigInt, getAuditUserIdNumber } from './metadata-utils'; + +describe('metadata utils', () => { + it('returns -1 for machine principals inferred from token claims in number audit fields', () => { + expect( + getAuditUserIdNumber({ + isMachine: false, + scopes: ['write:projects'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:projects', + sub: 'svc-projects', + }, + }), + ).toBe(-1); + }); + + it('returns -1n for machine principals inferred from token claims in bigint audit fields', () => { + expect( + getAuditUserIdBigInt({ + isMachine: false, + scopes: ['write:projects'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:projects', + sub: 'svc-projects', + }, + }), + ).toBe(BigInt(-1)); + }); + + it('throws for human tokens with non-numeric user ids', () => { + expect(() => + getAuditUserIdNumber({ + userId: 'not-a-number', + isMachine: false, + }), + ).toThrow(ForbiddenException); + }); +}); diff --git a/src/api/metadata/utils/metadata-utils.ts b/src/api/metadata/utils/metadata-utils.ts index b2e9ea7..2743afc 100644 --- a/src/api/metadata/utils/metadata-utils.ts +++ b/src/api/metadata/utils/metadata-utils.ts @@ -11,6 +11,10 @@ import { HttpException, } from '@nestjs/common'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { + getMachineActorId, + isMachinePrincipal, +} from 'src/shared/utils/service.utils'; export interface MetadataVersionReference { key: string; @@ -103,20 +107,32 @@ export function parseOptionalBooleanQuery(value: unknown): boolean | undefined { * Extracts the authenticated user id as a safe positive integer. * * @param user Authenticated JWT user payload. - * @returns Numeric user id for audit columns typed as `number`. - * @throws {ForbiddenException} When user id is missing, non-numeric, or outside - * the safe integer range. + * @returns Numeric user id for audit columns typed as `number`, or `-1` for + * machine principals without a numeric actor id. + * @throws {ForbiddenException} When a non-machine user id is missing, + * non-numeric, or outside the safe integer range. */ export function getAuditUserIdNumber(user: JwtUser): number { - const rawUserId = String(user.userId || '').trim(); + const rawUserId = + user?.userId && String(user.userId).trim().length > 0 + ? String(user.userId).trim() + : (getMachineActorId(user) ?? ''); if (!/^\d+$/.test(rawUserId)) { + if (isMachinePrincipal(user)) { + return -1; + } + throw new ForbiddenException('Authenticated user id must be numeric.'); } const parsed = Number.parseInt(rawUserId, 10); if (!Number.isSafeInteger(parsed) || parsed <= 0) { + if (isMachinePrincipal(user)) { + return -1; + } + throw new ForbiddenException( 'Authenticated user id must be a safe positive integer.', ); @@ -129,13 +145,22 @@ export function getAuditUserIdNumber(user: JwtUser): number { * Extracts the authenticated user id as bigint. * * @param user Authenticated JWT user payload. - * @returns BigInt user id for audit columns typed as `bigint`. - * @throws {ForbiddenException} When user id is missing or non-numeric. + * @returns BigInt user id for audit columns typed as `bigint`, or `-1n` for + * machine principals without a numeric actor id. + * @throws {ForbiddenException} When a non-machine user id is missing or + * non-numeric. */ export function getAuditUserIdBigInt(user: JwtUser): bigint { - const rawUserId = String(user.userId || '').trim(); + const rawUserId = + user?.userId && String(user.userId).trim().length > 0 + ? String(user.userId).trim() + : (getMachineActorId(user) ?? ''); if (!/^\d+$/.test(rawUserId)) { + if (isMachinePrincipal(user)) { + return BigInt(-1); + } + throw new ForbiddenException('Authenticated user id must be numeric.'); } diff --git a/src/api/project-invite/project-invite.service.spec.ts b/src/api/project-invite/project-invite.service.spec.ts index e2a95b8..8bbf55b 100644 --- a/src/api/project-invite/project-invite.service.spec.ts +++ b/src/api/project-invite/project-invite.service.spec.ts @@ -317,6 +317,91 @@ describe('ProjectInviteService', () => { ); }); + it('creates invites for machine principals inferred from token claims', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + name: 'Demo', + members: [], + }); + + prismaMock.projectMemberInvite.findMany.mockResolvedValue([]); + memberServiceMock.getMemberDetailsByHandles.mockResolvedValue([ + { + userId: 123, + handle: 'member', + handleLower: 'member', + email: 'member@topcoder.com', + }, + ]); + memberServiceMock.getUserRoles.mockResolvedValue(['Topcoder User']); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); + identityServiceMock.lookupMultipleUserEmails.mockResolvedValue([]); + + const txMock = { + projectMemberInvite: { + create: jest.fn().mockResolvedValue({ + id: BigInt(21), + projectId: BigInt(1001), + userId: BigInt(123), + email: 'member@topcoder.com', + role: ProjectMemberRole.customer, + status: InviteStatus.pending, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: -1, + updatedBy: -1, + deletedAt: null, + deletedBy: null, + applicationId: null, + }), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => { + if (permission === Permission.CREATE_PROJECT_INVITE_COPILOT) { + return false; + } + + return true; + }, + ); + + await service.createInvites( + '1001', + { + handles: ['member'], + role: ProjectMemberRole.customer, + }, + { + isMachine: false, + scopes: ['write:project-invites'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:project-invites', + sub: 'svc-projects', + }, + }, + undefined, + ); + + expect(txMock.projectMemberInvite.create).toHaveBeenCalledWith({ + data: { + projectId: BigInt(1001), + userId: BigInt(123), + email: 'member@topcoder.com', + role: ProjectMemberRole.customer, + status: InviteStatus.pending, + createdBy: -1, + updatedBy: -1, + }, + }); + }); + it('publishes member.added when invite is accepted', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), @@ -554,6 +639,86 @@ describe('ProjectInviteService', () => { ).rejects.toBeInstanceOf(ForbiddenException); }); + it('updates invites for machine principals inferred from token claims', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [], + }); + + prismaMock.projectMemberInvite.findFirst.mockResolvedValue({ + id: BigInt(31), + projectId: BigInt(1001), + userId: BigInt(500), + email: null, + role: ProjectMemberRole.customer, + status: InviteStatus.pending, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: 1, + deletedAt: null, + deletedBy: null, + applicationId: null, + }); + + const txMock = { + projectMemberInvite: { + update: jest.fn().mockResolvedValue({ + id: BigInt(31), + projectId: BigInt(1001), + userId: BigInt(500), + email: null, + role: ProjectMemberRole.customer, + status: InviteStatus.refused, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: -1, + deletedAt: null, + deletedBy: null, + applicationId: null, + }), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.UPDATE_PROJECT_INVITE_NOT_OWN, + ); + + await service.updateInvite( + '1001', + '31', + { + status: InviteStatus.refused, + }, + { + isMachine: false, + scopes: ['write:project-invites'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:project-invites', + sub: 'svc-projects', + }, + }, + undefined, + ); + + expect(txMock.projectMemberInvite.update).toHaveBeenCalledWith({ + where: { + id: BigInt(31), + }, + data: { + status: InviteStatus.refused, + updatedBy: -1, + }, + }); + }); + it('publishes invite.deleted when invite is canceled', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), @@ -617,4 +782,73 @@ describe('ProjectInviteService', () => { expect.anything(), ); }); + + it('deletes invites for machine principals inferred from token claims', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [], + }); + + prismaMock.projectMemberInvite.findFirst.mockResolvedValue({ + id: BigInt(41), + projectId: BigInt(1001), + userId: BigInt(500), + email: null, + role: ProjectMemberRole.customer, + status: InviteStatus.pending, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: 1, + deletedAt: null, + deletedBy: null, + applicationId: null, + }); + + permissionServiceMock.hasNamedPermission.mockReturnValue(true); + + const txMock = { + projectMemberInvite: { + update: jest.fn().mockResolvedValue({ + id: BigInt(41), + projectId: BigInt(1001), + userId: BigInt(500), + email: null, + role: ProjectMemberRole.customer, + status: InviteStatus.canceled, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: -1, + deletedAt: null, + deletedBy: null, + applicationId: null, + }), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + await service.deleteInvite('1001', '41', { + isMachine: false, + scopes: ['write:project-invites'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:project-invites', + sub: 'svc-projects', + }, + }); + + expect(txMock.projectMemberInvite.update).toHaveBeenCalledWith({ + where: { + id: BigInt(41), + }, + data: { + status: InviteStatus.canceled, + updatedBy: -1, + }, + }); + }); }); diff --git a/src/api/project-member/project-member.service.spec.ts b/src/api/project-member/project-member.service.spec.ts index 545d1cb..8193ed8 100644 --- a/src/api/project-member/project-member.service.spec.ts +++ b/src/api/project-member/project-member.service.spec.ts @@ -8,6 +8,7 @@ import { ProjectMemberService } from './project-member.service'; jest.mock('src/shared/utils/event.utils', () => ({ publishMemberEvent: jest.fn(() => Promise.resolve()), + publishMemberEventSafely: jest.fn(), })); const eventUtils = jest.requireMock('src/shared/utils/event.utils'); @@ -101,13 +102,229 @@ describe('ProjectMemberService', () => { ); expect(txMock.projectMember.create).toHaveBeenCalled(); - expect(eventUtils.publishMemberEvent).toHaveBeenCalledWith( + expect(eventUtils.publishMemberEventSafely).toHaveBeenCalledWith( KAFKA_TOPIC.PROJECT_MEMBER_ADDED, expect.any(Object), + expect.any(Object), ); expect((result as any).id).toBe('1'); }); + it('adds a project member for machine principals inferred from token claims', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [], + }); + + const txMock = { + projectMember: { + create: jest.fn().mockResolvedValue({ + id: BigInt(2), + projectId: BigInt(1001), + userId: BigInt(456), + role: ProjectMemberRole.customer, + isPrimary: false, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: -1, + updatedBy: -1, + deletedAt: null, + deletedBy: null, + }), + }, + projectMemberInvite: { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.CREATE_PROJECT_MEMBER_NOT_OWN, + ); + memberServiceMock.getUserRoles.mockResolvedValue(['Topcoder User']); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); + + await service.addMember( + '1001', + { + userId: 456, + role: ProjectMemberRole.customer, + }, + { + isMachine: false, + scopes: ['write:project-members'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:project-members', + sub: 'svc-projects', + }, + }, + undefined, + ); + + expect(txMock.projectMember.create).toHaveBeenCalledWith({ + data: { + projectId: BigInt(1001), + userId: BigInt(456), + role: ProjectMemberRole.customer, + createdBy: -1, + updatedBy: -1, + }, + }); + }); + + it('updates a project member for machine principals inferred from token claims', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [ + { + id: BigInt(2), + projectId: BigInt(1001), + userId: BigInt(456), + role: ProjectMemberRole.customer, + isPrimary: false, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: 1, + deletedAt: null, + deletedBy: null, + }, + ], + }); + + const txMock = { + projectMember: { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + update: jest.fn().mockResolvedValue({ + id: BigInt(2), + projectId: BigInt(1001), + userId: BigInt(456), + role: ProjectMemberRole.customer, + isPrimary: false, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: -1, + deletedAt: null, + deletedBy: null, + }), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.UPDATE_PROJECT_MEMBER_NON_CUSTOMER, + ); + + await service.updateMember( + '1001', + '2', + { + role: ProjectMemberRole.customer, + }, + { + isMachine: false, + scopes: ['write:project-members'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:project-members', + sub: 'svc-projects', + }, + }, + undefined, + ); + + expect(txMock.projectMember.update).toHaveBeenCalledWith({ + where: { + id: BigInt(2), + }, + data: { + role: ProjectMemberRole.customer, + isPrimary: undefined, + updatedBy: -1, + }, + }); + }); + + it('deletes a project member for machine principals inferred from token claims', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [ + { + id: BigInt(9), + projectId: BigInt(1001), + userId: BigInt(456), + role: ProjectMemberRole.manager, + isPrimary: false, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: 1, + deletedAt: null, + deletedBy: null, + }, + ], + }); + + const txMock = { + projectMember: { + update: jest.fn().mockResolvedValue({ + id: BigInt(9), + projectId: BigInt(1001), + userId: BigInt(456), + role: ProjectMemberRole.manager, + isPrimary: false, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: -1, + deletedAt: new Date(), + deletedBy: BigInt(-1), + }), + findFirst: jest.fn().mockResolvedValue(null), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.DELETE_PROJECT_MEMBER_TOPCODER, + ); + + await service.deleteMember('1001', '9', { + isMachine: false, + scopes: ['write:project-members'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:project-members', + sub: 'svc-projects', + }, + }); + + expect(txMock.projectMember.update).toHaveBeenCalledWith({ + where: { + id: BigInt(9), + }, + data: { + deletedAt: expect.any(Date), + deletedBy: BigInt(-1), + updatedBy: -1, + }, + }); + }); + it('fails deleting topcoder member without permission', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), diff --git a/src/shared/utils/service.utils.ts b/src/shared/utils/service.utils.ts index 9c88d17..9a72309 100644 --- a/src/shared/utils/service.utils.ts +++ b/src/shared/utils/service.utils.ts @@ -59,11 +59,67 @@ export function parseOptionalNumericStringId( return BigInt(normalized); } +/** + * Determines whether the authenticated principal is a machine token. + */ +export function isMachinePrincipal(user: JwtUser): boolean { + if (user?.isMachine) { + return true; + } + + if (!user?.tokenPayload) { + return false; + } + + const grantType = user.tokenPayload.gty; + if ( + typeof grantType === 'string' && + grantType.trim().toLowerCase() === 'client-credentials' + ) { + return true; + } + + const rawScopes = user.tokenPayload.scope ?? user.tokenPayload.scopes; + + if (typeof rawScopes === 'string') { + return rawScopes.trim().length > 0; + } + + if (Array.isArray(rawScopes)) { + return rawScopes.some((scope) => String(scope).trim().length > 0); + } + + return false; +} + +/** + * Resolves a stable machine-principal identifier from token claims. + */ +export function getMachineActorId(user: JwtUser): string | undefined { + if (!isMachinePrincipal(user) || !user?.tokenPayload) { + return undefined; + } + + for (const key of ['sub', 'azp', 'client_id', 'clientId']) { + const value = user.tokenPayload[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + + return undefined; +} + /** * Resolves the authenticated user id as a trimmed string. */ export function getActorUserId(user: JwtUser): string { if (!user?.userId || String(user.userId).trim().length === 0) { + const machineActorId = getMachineActorId(user); + if (machineActorId) { + return machineActorId; + } + throw new ForbiddenException('Authenticated user id is missing.'); } @@ -72,11 +128,17 @@ export function getActorUserId(user: JwtUser): string { /** * Resolves the authenticated user id as a numeric audit id. + * + * Returns `-1` for machine principals without a numeric actor id. */ export function getAuditUserId(user: JwtUser): number { const parsedUserId = Number.parseInt(getActorUserId(user), 10); if (Number.isNaN(parsedUserId)) { + if (isMachinePrincipal(user)) { + return -1; + } + throw new ForbiddenException('Authenticated user id must be numeric.'); } @@ -85,9 +147,15 @@ export function getAuditUserId(user: JwtUser): number { /** * Resolves the authenticated user id as an audit id, with fallback. + * + * Uses the machine-principal actor id when a human `userId` is absent. */ export function getAuditUserIdOrDefault(user: JwtUser, fallback = -1): number { - const userId = Number.parseInt(String(user.userId || ''), 10); + const actorId = + user?.userId && String(user.userId).trim().length > 0 + ? String(user.userId).trim() + : (getMachineActorId(user) ?? ''); + const userId = Number.parseInt(actorId, 10); if (Number.isNaN(userId)) { return fallback;