diff --git a/.circleci/config.yml b/.circleci/config.yml index a000aa1..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,42 +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: Enable pnpm - command: corepack enable - - run: - name: Install dependencies - command: pnpm install --frozen-lockfile - - run: - name: Run deployment validation tests - command: pnpm 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: Enable pnpm - command: corepack enable - - run: - name: Install dependencies - command: pnpm install --frozen-lockfile - - run: - name: Run deployment validation tests - command: pnpm test:deployment - workflows: version: 2 build-dev: @@ -109,14 +69,6 @@ workflows: branches: only: - dev - - deployment-validation-dev: - context: org-global - requires: - - build-dev - filters: - branches: - only: - - dev build-prod: jobs: @@ -126,11 +78,3 @@ workflows: branches: only: - master - - deployment-validation-prod: - context: org-global - requires: - - build-prod - filters: - branches: - only: - - master diff --git a/.env.example b/.env.example index 9a16c6b..41bf42a 100644 --- a/.env.example +++ b/.env.example @@ -12,15 +12,19 @@ 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="" 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" @@ -55,9 +59,17 @@ 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="" +SENDGRID_TEMPLATE_COPILOT_REQUEST_CREATED="" +COPILOTS_SLACK_EMAIL="" UNIQUE_GMAIL_VALIDATION=false # API Configuration 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/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 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 c107638..0b6d826 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,352 @@ -# 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 / M2M | JWT: caller work-management policy map. M2M: per-member permission matrix with project permissions and template policies | + +### 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 / 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 | +| `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) | + +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. + +| 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. -## Technology Stack +### `community-app` -- TypeScript -- NestJS -- Prisma -- PostgreSQL -- Swagger -- pnpm +- Calls `GET /v6/projects/copilots/opportunities?noGrouping=true` (public endpoint) for the copilot marketplace listing. + +## 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. -## Key Differences from v5 +### M2M scope hierarchy -- No Elasticsearch dependency -- Only actively-used endpoints are ported -- API endpoints use `/v6` prefix instead of `/v5` +| 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 | -## Setup +## Event Publishing + +For full event envelope and payload schemas, see `docs/event-schemas.md`. + +| Kafka Topic | Trigger | +| --- | --- | +| `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 + +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` | - | - | 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 | +| `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 | +| `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_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) | +| `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 | +| `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 +355,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..59bca09 --- /dev/null +++ b/docs/DEPENDENCIES.md @@ -0,0 +1,99 @@ +# 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` | ✅ 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 +No known vulnerabilities found +``` + +## 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 | +|---|---|---| +| `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 | +| `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 | + +## 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): + +| Command | Result | +|---|---| +| `pnpm install` | ✅ Passed | +| `pnpm audit` | ✅ Passed (`No known vulnerabilities found`) | +| `pnpm outdated` | ✅ Completed (table above updated) | +| `pnpm lint` | ✅ Passed | +| `pnpm build` | ✅ Passed | diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 105b1e0..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` @@ -31,7 +36,7 @@ Supported shapes: ```ts { - topcoderRoles: [UserRole.CONNECT_ADMIN], + topcoderRoles: [UserRole.TOPCODER_ADMIN], projectRoles: [ProjectMemberRole.MANAGER], scopes: [Scope.PROJECTS_READ], } diff --git a/docs/api-usage-analysis.md b/docs/api-usage-analysis.md index a73e8a1..fb58650 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) | @@ -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/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/package.json b/package.json index c7352ce..1138e08 100644 --- a/package.json +++ b/package.json @@ -31,22 +31,22 @@ "@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", "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", "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", @@ -59,10 +59,11 @@ "@types/autocannon": "^7.12.7", "@types/express": "^5.0.0", "@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", - "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" @@ -101,5 +101,21 @@ "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", + "jws": ">=3.2.3 <4.0.0 || >=4.0.1", + "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 02c608b..b822065 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,24 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + ajv: 8.18.0 + axios: 1.13.5 + fast-xml-parser: 5.3.6 + 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 + +patchedDependencies: + '@eslint/eslintrc@3.3.3': + hash: 780ee0d2d0120b91573affb05b91c8db28baad3435843917b3278da30274d238 + path: patches/@eslint__eslintrc@3.3.3.patch + eslint@9.39.2: + hash: dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd + path: patches/eslint@9.39.2.patch + importers: .: @@ -16,7 +34,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) @@ -33,17 +51,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 @@ -60,11 +75,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 @@ -80,10 +95,13 @@ 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 - version: 3.3.3 + version: 3.3.3(patch_hash=780ee0d2d0120b91573affb05b91c8db28baad3435843917b3278da30274d238) '@eslint/js': specifier: ^9.18.0 version: 9.39.2 @@ -111,6 +129,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 @@ -121,20 +142,20 @@ 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 eslint: specifier: ^9.18.0 - version: 9.39.2(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(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(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=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 @@ -145,8 +166,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 @@ -170,10 +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) - winston: - specifier: ^3.17.0 - version: 3.19.0 + version: 8.54.0(eslint@9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))(typescript@5.9.3) packages: @@ -653,7 +671,7 @@ packages: resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.11.10 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -814,14 +832,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'} @@ -1049,7 +1059,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': @@ -1178,14 +1188,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: '*' @@ -1196,35 +1206,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==} @@ -1886,7 +1896,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 @@ -1894,7 +1904,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 @@ -1902,18 +1912,15 @@ 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.18.0 - 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==} @@ -1978,11 +1985,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==} @@ -2021,8 +2025,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==} @@ -2057,11 +2062,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==} @@ -2274,9 +2277,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} @@ -2649,8 +2649,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 +2833,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 +2895,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.10: + resolution: {integrity: sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -3214,9 +3214,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==} @@ -3338,9 +3335,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==} @@ -3464,17 +3458,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==} @@ -3788,8 +3775,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: @@ -3819,15 +3806,11 @@ 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==} - 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: @@ -4407,9 +4390,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==} @@ -4555,8 +4535,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 @@ -4566,8 +4546,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 @@ -5107,7 +5087,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 +5287,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': {} @@ -5343,9 +5323,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=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1))': dependencies: - eslint: 9.39.2(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': {} @@ -5354,7 +5334,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 10.2.1 transitivePeerDependencies: - supports-color @@ -5366,16 +5346,16 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.3(patch_hash=780ee0d2d0120b91573affb05b91c8db28baad3435843917b3278da30274d238)': dependencies: - ajv: 6.12.6 + ajv: 8.18.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 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 @@ -5405,9 +5385,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.10)': dependencies: - hono: 4.11.4 + hono: 4.11.10 '@humanfs/core@0.19.1': {} @@ -5560,12 +5540,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 @@ -5852,10 +5826,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)': @@ -5994,24 +5968,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 @@ -6022,20 +5996,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.4) + '@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.4 + hono: 4.11.10 http-status-codes: 2.3.0 pathe: 2.0.3 proper-lockfile: 4.1.2 @@ -6046,32 +6020,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': {} @@ -6447,7 +6421,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 @@ -6692,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(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=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(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(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=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(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) @@ -6708,14 +6682,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=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(jiti@2.6.1) + eslint: 9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6738,13 +6712,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=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(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(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: @@ -6759,7 +6733,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) @@ -6767,13 +6741,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=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(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=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(jiti@2.6.1) + eslint: 9.39.2(patch_hash=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6977,31 +6951,24 @@ 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): + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 - - ajv-keywords@3.5.2(ajv@6.12.6): - dependencies: - ajv: 6.12.6 + ajv: 8.18.0 - ajv-keywords@5.1.0(ajv@8.17.1): + ajv-keywords@3.5.2(ajv@8.18.0): dependencies: - ajv: 8.17.1 - fast-deep-equal: 3.1.3 + ajv: 8.18.0 - ajv@6.12.6: + ajv-keywords@5.1.0(ajv@8.18.0): dependencies: + ajv: 8.18.0 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: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 @@ -7077,15 +7044,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 @@ -7154,7 +7113,7 @@ snapshots: dependencies: precond: 0.2.3 - balanced-match@1.0.2: {} + balanced-match@4.0.3: {} bare-events@2.8.2: {} @@ -7187,7 +7146,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: @@ -7195,14 +7154,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: @@ -7310,7 +7264,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: @@ -7410,8 +7364,6 @@ snapshots: component-emitter@1.3.1: {} - concat-map@0.0.1: {} - concat-stream@2.0.0: dependencies: buffer-from: 1.1.2 @@ -7597,19 +7549,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=dd306e267766b2061f100c6e3d333d20d1ebc2cb8a1bfd88232259825c5dbfdd)(jiti@2.6.1)): dependencies: - eslint: 9.39.2(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(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=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(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(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: @@ -7625,21 +7577,21 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2(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(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 + '@eslint/eslintrc': 3.3.3(patch_hash=780ee0d2d0120b91573affb05b91c8db28baad3435843917b3278da30274d238) '@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 @@ -7658,7 +7610,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: @@ -7742,7 +7694,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 +7742,7 @@ snapshots: fast-uri@3.1.0: {} - fast-xml-parser@5.3.4: + fast-xml-parser@5.3.6: dependencies: strnum: 2.1.2 @@ -7892,7 +7844,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 @@ -7988,7 +7940,7 @@ snapshots: glob@13.0.0: dependencies: - minimatch: 10.1.2 + minimatch: 10.2.1 minipass: 7.1.2 path-scurry: 2.0.1 @@ -7996,7 +7948,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 @@ -8006,7 +7958,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 @@ -8067,7 +8019,7 @@ snapshots: hdr-histogram-percentiles-obj@3.0.0: {} - hono@4.11.4: {} + hono@4.11.10: {} html-escaper@2.0.2: {} @@ -8554,8 +8506,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: {} @@ -8667,8 +8617,6 @@ snapshots: lodash.once@4.1.1: {} - lodash@4.17.21: {} - lodash@4.17.23: {} log-symbols@4.1.0: @@ -8767,17 +8715,9 @@ snapshots: mimic-response@4.0.0: {} - minimatch@10.1.2: + minimatch@10.2.1: dependencies: - '@isaacs/brace-expansion': 5.0.1 - - minimatch@3.1.2: - 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: {} @@ -9061,11 +9001,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 @@ -9097,11 +9037,9 @@ snapshots: proxy-from-env@1.1.0: {} - punycode@2.3.1: {} - pure-rand@6.1.0: {} - qs@6.14.1: + qs@6.14.2: dependencies: side-channel: 1.1.0 @@ -9241,15 +9179,15 @@ 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: '@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: @@ -9441,7 +9379,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 @@ -9486,7 +9424,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 @@ -9519,7 +9457,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: @@ -9648,13 +9586,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=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(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=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(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=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 @@ -9687,10 +9625,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/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/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..887448b 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, @@ -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,12 +46,28 @@ 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, 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); @@ -90,6 +110,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 +124,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 +147,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 +228,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 +251,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(); @@ -218,10 +267,4 @@ export class CopilotApplicationService { return BigInt(normalized); } - - private ensurePermission(permission: NamedPermission, user: JwtUser): void { - if (!this.permissionService.hasNamedPermission(permission, user)) { - throw new ForbiddenException('Insufficient permissions'); - } - } } 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..2cb737a --- /dev/null +++ b/src/api/copilot/copilot-notification.service.spec.ts @@ -0,0 +1,213 @@ +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 originalSlackEmail = process.env.COPILOTS_SLACK_EMAIL; + 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(); + process.env.COPILOTS_SLACK_EMAIL = ''; + memberServiceMock.getRoleSubjects.mockResolvedValue([]); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); + eventBusServiceMock.publishProjectEvent.mockResolvedValue(undefined); + + service = new CopilotNotificationService( + memberServiceMock as unknown as MemberService, + eventBusServiceMock as unknown as EventBusService, + ); + }); + + 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' }, + { 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(); + }); + + 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 5b20397..4d38141 100644 --- a/src/api/copilot/copilot-notification.service.ts +++ b/src/api/copilot/copilot-notification.service.ts @@ -4,20 +4,28 @@ 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, 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 = { 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', COPILOT_OPPORTUNITY_CANCELED: 'd-2a67ba71e82f4d70891fe6989c3522a3', } as const; +const EXTERNAL_ACTION_EMAIL_TOPIC = 'external.action.email'; type OpportunityWithRequest = CopilotOpportunity & { copilotRequest?: CopilotRequest | null; @@ -29,57 +37,118 @@ type ApplicationWithMembership = CopilotApplication & { }; }; +type NotificationRecipient = { + userId?: string | number | bigint | null; + email?: string | null; + handle?: string | null; +}; + +/** + * Email notification dispatcher for copilot lifecycle events. + */ @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 roleSubjects = await this.memberService.getRoleSubjects( + UserRole.TC_COPILOT, + ); + + const recipients = await this.deduplicateRecipientsByEmail(roleSubjects); + const requestData = getCopilotRequestData(copilotRequest?.data); + const opportunityType = this.resolveOpportunityType( + opportunity, + requestData, + ); + const templateId = + process.env.SENDGRID_TEMPLATE_COPILOT_REQUEST_CREATED || + TEMPLATE_IDS.CREATE_REQUEST; + + if (recipients.length > 0) { + await Promise.all( + recipients.map((recipient) => + this.publishEmail(templateId, [recipient.email], { + 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: + readString(requestData.opportunityTitle) || + `Opportunity ${opportunity.id.toString()}`, + start_date: this.formatDate(requestData.startDate), + }), + ), + ); } - const projectMembers = await this.prisma.projectMember.findMany({ - where: { - projectId: opportunity.projectId, - role: { - in: [ProjectMemberRole.manager, ProjectMemberRole.project_manager], - }, - deletedAt: null, - }, - select: { - userId: true, - }, - }); + 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 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 slackRecipient = String(process.env.COPILOTS_SLACK_EMAIL || '') + .trim() + .toLowerCase(); + + if (slackRecipient) { + await this.publishEmail(templateId, [slackRecipient], { + user_name: 'Copilots', + opportunity_details_url: `${this.getCopilotPortalUrl()}/opportunity/${opportunity.id.toString()}`, + work_manager_url: this.getWorkManagerUrl(), + opportunity_type: getCopilotTypeLabel(opportunityType), + opportunity_title: + 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()} recipients=${recipients.length} slackRecipient=${slackRecipient ? 'yes' : 'no'}`, ); + } - if (recipientIds.length === 0) { + /** + * 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. + * @returns Resolves after notifications are queued. + */ + async sendCopilotApplicationNotification( + opportunity: OpportunityWithRequest, + application: CopilotApplication, + ): Promise { + const [projectManagerUsers, opportunityCreatorUsers] = await Promise.all([ + this.memberService.getRoleSubjects(UserRole.PROJECT_MANAGER), + this.memberService.getMemberDetailsByUserIds([opportunity.createdBy]), + ]); + + 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; } - const recipients = - await this.memberService.getMemberDetailsByUserIds(recipientIds); - const requestData = getCopilotRequestData(opportunity.copilotRequest?.data); const opportunityType = this.resolveOpportunityType( opportunity, @@ -87,30 +156,34 @@ 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: - this.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( - `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}`, ); } + /** + * 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, @@ -146,7 +219,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), }); @@ -156,6 +229,68 @@ export class CopilotNotificationService { ); } + async sendCopilotInviteAcceptedNotification( + opportunity: OpportunityWithRequest, + application: CopilotApplication, + ): Promise { + const [projectManagerUsers, opportunityCreatorUsers, inviteeUsers] = + await Promise.all([ + this.memberService.getRoleSubjects(UserRole.PROJECT_MANAGER), + this.memberService.getMemberDetailsByUserIds([opportunity.createdBy]), + this.memberService.getMemberDetailsByUserIds([application.userId]), + ]); + + 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; + } + + const [invitee] = inviteeUsers; + 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( + recipients.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: + 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()} recipients=${recipients.length}`, + ); + } + + /** + * 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[], @@ -185,7 +320,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()}`, }, ), @@ -197,6 +332,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[], @@ -223,7 +365,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()}`, }, ), @@ -235,39 +377,165 @@ export class CopilotNotificationService { ); } - private publishEmail( + /** + * Publishes a template email notification. + * + * @param templateId Template identifier. + * @param recipients Recipient emails. + * @param data Template payload. + * @returns Resolved promise when publish succeeds or fails. + */ + 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 to ${EXTERNAL_ACTION_EMAIL_TOPIC} for recipients=${normalizedRecipients.join(',')}: ${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 async deduplicateRecipientsByEmail( + recipients: NotificationRecipient[], + ): 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) { + const normalizedUserId = this.normalizeNumericUserId(recipient.userId); + if (normalizedUserId) { + unresolvedUserIds.add(normalizedUserId); + } + return; + } + + if (recipientByEmail.has(normalizedEmail)) { + return; + } + + recipientByEmail.set(normalizedEmail, { + email: normalizedEmail, + handle: String(recipient.handle || '').trim() || normalizedEmail, + }); + }); + + 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. + * + * @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, ): CopilotOpportunityType { - const projectType = (this.readString(requestData.projectType) || '') + const projectType = (readString(requestData.projectType) || '') .toLowerCase() .trim(); @@ -282,12 +550,19 @@ 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 ''; } - const normalizedValue = this.readString(value); + const normalizedValue = readString(value); if (!normalizedValue) { return ''; @@ -305,16 +580,4 @@ export class CopilotNotificationService { return `${day}-${month}-${year}`; } - - private 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/copilot-opportunity.controller.ts b/src/api/copilot/copilot-opportunity.controller.ts index 6b04417..dd3ec74 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( @@ -55,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 }) @@ -83,7 +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, @@ -94,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 }) @@ -108,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) @@ -134,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.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 74db7c0..3d8c7ba 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, @@ -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,21 @@ 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. + * Admin/manager responses also include minimal project metadata for v5 compatibility. + * + * @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 +101,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,11 +154,24 @@ 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. + * @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 includeProject = isAdminOrManager(user); const opportunity = await this.prisma.copilotOpportunity.findFirst({ where: { @@ -186,16 +216,39 @@ export class CopilotOpportunityService { opportunity, canApplyAsCopilot, members, - isAdminOrManager(user), + includeProject, ); } + /** + * 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, 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( @@ -263,6 +316,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,11 +430,25 @@ 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, ): 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); @@ -449,11 +517,21 @@ 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 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( @@ -474,13 +552,40 @@ 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; } + /** + * 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 +612,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, @@ -540,10 +653,4 @@ export class CopilotOpportunityService { memberships.map((membership) => membership.projectId.toString()), ); } - - private ensurePermission(permission: NamedPermission, user: JwtUser): void { - 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 fd67e25..f1dce17 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'; @@ -17,6 +16,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, @@ -24,12 +24,14 @@ import { UpdateCopilotRequestDto, } from './dto/copilot-request.dto'; import { + ensureNamedPermission, getAuditUserId, getCopilotRequestData, isAdminOrManager, normalizeEntity, parseNumericId, parseSortExpression, + readString, } from './copilot.utils'; const REQUEST_SORTS = [ @@ -47,10 +49,7 @@ const REQUEST_SORTS = [ type CopilotRequestWithRelations = CopilotRequest & { opportunities: CopilotOpportunity[]; - project?: { - id: bigint; - name: string; - } | null; + project?: ({ name?: string } & Record) | null; }; interface PaginatedRequestResponse { @@ -61,18 +60,40 @@ 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, + private readonly notificationService: CopilotNotificationService, ) {} + /** + * 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, 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 ? parseNumericId(projectId, 'Project') @@ -84,9 +105,9 @@ export class CopilotRequestService { 'createdAt desc', ); - const includeProject = - isAdminOrManager(user) || sortField.toLowerCase() === 'projectname'; + 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, @@ -101,14 +122,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,18 +146,34 @@ 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, }; } + /** + * 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, ): Promise { - this.ensurePermission(NamedPermission.MANAGE_COPILOT_REQUEST, user); + ensureNamedPermission( + this.permissionService, + NamedPermission.MANAGE_COPILOT_REQUEST, + user, + ); const parsedRequestId = parseNumericId(copilotRequestId, 'Copilot request'); @@ -164,15 +203,33 @@ 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, 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); + // 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), @@ -205,7 +262,7 @@ export class CopilotRequestService { }, }); - await this.approveRequestInternal( + const opportunity = await this.approveRequestInternal( tx, parsedProjectId, request.id, @@ -213,7 +270,7 @@ export class CopilotRequestService { auditUserId, ); - return tx.copilotRequest.findFirst({ + const createdRequest = await tx.copilotRequest.findFirst({ where: { id: request.id, deletedAt: null, @@ -229,21 +286,49 @@ 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)); } + /** + * 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, 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); @@ -338,13 +423,30 @@ 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, 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'); @@ -360,9 +462,38 @@ 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); } + /** + * 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, @@ -387,8 +518,8 @@ export class CopilotRequestService { const requestData = getCopilotRequestData(request.data); const requestedType = ( - this.readString(type) || - this.readString(requestData.projectType) || + readString(type) || + readString(requestData.projectType) || '' ).trim(); @@ -440,6 +571,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, @@ -463,6 +602,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, @@ -501,9 +649,17 @@ 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, - includeProjectId: boolean, + includeProjectInResponse: boolean, ): CopilotRequestResponseDto { const normalized = normalizeEntity(input) as Record; @@ -528,18 +684,31 @@ 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; } + /** + * 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) => { @@ -548,7 +717,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 ); } @@ -576,13 +748,21 @@ 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(); } - 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; @@ -594,22 +774,4 @@ export class CopilotRequestService { return 0; } - - private ensurePermission(permission: NamedPermission, user: JwtUser): void { - if (!this.permissionService.hasNamedPermission(permission, user)) { - throw new ForbiddenException('Insufficient permissions'); - } - } - - private 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/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.spec.ts b/src/api/copilot/copilot.utils.spec.ts new file mode 100644 index 0000000..cf8f46b --- /dev/null +++ b/src/api/copilot/copilot.utils.spec.ts @@ -0,0 +1,38 @@ +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('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({ + 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 903aa08..68eb382 100644 --- a/src/api/copilot/copilot.utils.ts +++ b/src/api/copilot/copilot.utils.ts @@ -1,10 +1,27 @@ -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'; +import { isMachinePrincipal } from 'src/shared/utils/service.utils'; + +/** + * 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 +32,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,39 +48,19 @@ export function getCopilotRequestData( return {}; } +/** + * Recursively normalizes entity values for API responses. + */ 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); } +/** + * 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 +78,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 +95,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 +111,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,12 +140,54 @@ 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, 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 (isMachinePrincipal(user)) { + return -1; + } + throw new BadRequestException('Authenticated user id must be numeric.'); } return value; } + +/** + * 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 a9d2740..cf747c4 100644 --- a/src/api/copilot/dto/copilot-application.dto.ts +++ b/src/api/copilot/dto/copilot-application.dto.ts @@ -2,24 +2,16 @@ 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'; -function parseOptionalInteger(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; -} +/** + * DTOs for copilot applications (apply, list, assign). + */ +/** + * Request body for applying to an opportunity. + * notes is required. + */ export class CreateCopilotApplicationDto { @ApiProperty() @IsString() @@ -27,11 +19,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 +60,9 @@ export class CopilotApplicationResponseDto { existingMembership?: ExistingMembershipDto; } +/** + * Request body for assigning a copilot application. + */ export class AssignCopilotDto { @ApiProperty() @IsString() @@ -68,17 +70,20 @@ export class AssignCopilotDto { applicationId: string; } +/** + * Pagination and sort query parameters for application listing endpoints. + */ 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 e8ab164..e39e304 100644 --- a/src/api/copilot/dto/copilot-opportunity.dto.ts +++ b/src/api/copilot/dto/copilot-opportunity.dto.ts @@ -5,45 +5,29 @@ 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'; -function parseOptionalInteger(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; -} - -function parseOptionalBoolean(value: unknown): boolean | undefined { - if (typeof value === 'boolean') { - return value; - } +/** + * DTOs for listing and responding with copilot opportunities. + */ - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - - if (normalized === 'true') { - return true; - } - - if (normalized === 'false') { - return false; - } - } - - return undefined; +/** + * 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() id: string; @@ -119,19 +103,27 @@ export class CopilotOpportunityResponseDto { @ApiPropertyOptional({ type: [String] }) members?: string[]; + + @ApiPropertyOptional({ type: () => CopilotOpportunityProjectDto }) + project?: CopilotOpportunityProjectDto; } +/** + * 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() - @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 5e202a0..6cb16c3 100644 --- a/src/api/copilot/dto/copilot-request.dto.ts +++ b/src/api/copilot/dto/copilot-request.dto.ts @@ -19,23 +19,11 @@ import { MinLength, ValidateNested, } from 'class-validator'; +import { parseOptionalLooseInteger } from 'src/shared/utils/dto-transform.utils'; -function parseOptionalInteger(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; -} +/** + * Input/output DTOs for the copilot request lifecycle. + */ export enum CopilotComplexity { LOW = 'low', @@ -53,6 +41,9 @@ export enum CopilotPaymentType { OTHER = 'other', } +/** + * Represents a skill tag attached to a copilot request. + */ export class CopilotSkillDto { @ApiProperty() @IsString() @@ -65,6 +56,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 +136,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 +153,9 @@ export class CreateCopilotRequestDto { data: CreateCopilotRequestDataDto; } +/** + * Top-level envelope wrapper for update request payloads. + */ export class UpdateCopilotRequestDto { @ApiProperty({ type: () => UpdateCopilotRequestDataDto }) @ValidateNested() @@ -159,6 +163,9 @@ export class UpdateCopilotRequestDto { data: UpdateCopilotRequestDataDto; } +/** + * Embedded opportunity summary returned in request responses. + */ export class CopilotRequestOpportunityResponseDto { @ApiProperty() id: string; @@ -182,6 +189,9 @@ export class CopilotRequestOpportunityResponseDto { updatedAt: Date; } +/** + * Full response shape for a copilot request. + */ export class CopilotRequestResponseDto { @ApiProperty() id: string; @@ -189,6 +199,9 @@ export class CopilotRequestResponseDto { @ApiPropertyOptional() projectId?: string; + @ApiPropertyOptional({ type: Object }) + project?: Record; + @ApiProperty({ enum: CopilotRequestStatus, enumName: 'CopilotRequestStatus' }) status: CopilotRequestStatus; @@ -211,17 +224,20 @@ export class CopilotRequestResponseDto { copilotOpportunity?: CopilotRequestOpportunityResponseDto[]; } +/** + * Pagination and sort query parameters for request listing endpoints. + */ 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/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/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..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'; @@ -12,9 +7,23 @@ 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() +/** + * 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 +31,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 +58,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); @@ -57,20 +80,19 @@ 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), ); } + /** + * 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 +117,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 +149,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 +183,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 +239,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 +303,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 +365,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 +431,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 +491,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,16 +514,25 @@ 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(); - - if (!normalized) { - throw new BadRequestException('Metadata key is required.'); - } - - return normalized; + return normalizeVersionedConfigKey(key, 'Metadata'); } + /** + * 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..c7ee3b2 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,26 +174,8 @@ export class MetadataListService { Record[], ] > { - 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), @@ -175,6 +184,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,7 +201,24 @@ export class MetadataListService { Record[], ] > { - 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, @@ -204,14 +238,16 @@ 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), - ]; } + /** + * 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 +261,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 +282,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 +305,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..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'; @@ -12,9 +7,17 @@ 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() +/** + * Manages versioned plan configurations (phase and milestone plans) referenced + * by project templates. + */ export class PlanConfigService { constructor( private readonly prisma: PrismaService, @@ -22,6 +25,13 @@ export class PlanConfigService { 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 +54,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); @@ -61,20 +78,19 @@ 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), ); } + /** + * 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,13 +115,20 @@ 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); - - const forms = await this.prisma.planConfig.findMany({ + const planConfigs = await this.prisma.planConfig.findMany({ where: { key: normalizedKey, version, @@ -114,15 +137,24 @@ 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)); } + /** + * 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 +180,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 +239,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 +303,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 +365,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,7 +381,7 @@ export class PlanConfigService { const normalizedKey = this.normalizeKey(key); try { - const forms = await this.prisma.planConfig.findMany({ + const planConfigs = await this.prisma.planConfig.findMany({ where: { key: normalizedKey, version, @@ -322,7 +392,7 @@ export class PlanConfigService { }, }); - if (forms.length === 0) { + if (planConfigs.length === 0) { throw new NotFoundException( `PlanConfig not found for key ${normalizedKey} version ${version.toString()}.`, ); @@ -342,7 +412,7 @@ export class PlanConfigService { }); await Promise.all( - forms.map((planConfig) => + planConfigs.map((planConfig) => publishMetadataEvent( this.eventBusService, 'PROJECT_METADATA_DELETE', @@ -361,6 +431,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 +491,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,16 +514,25 @@ 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(); - - if (!normalized) { - throw new BadRequestException('Metadata key is required.'); - } - - return normalized; + return normalizeVersionedConfigKey(key, 'Metadata'); } + /** + * 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..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'; @@ -12,9 +7,19 @@ 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() +/** + * 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 { constructor( private readonly prisma: PrismaService, @@ -22,6 +27,13 @@ export class PriceConfigService { 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 +56,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); @@ -61,20 +80,19 @@ 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), ); } + /** + * 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,13 +117,20 @@ 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); - - const forms = await this.prisma.priceConfig.findMany({ + const priceConfigs = await this.prisma.priceConfig.findMany({ where: { key: normalizedKey, version, @@ -114,15 +139,24 @@ 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)); } + /** + * 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 +182,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 +241,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 +305,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 +367,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,7 +383,7 @@ export class PriceConfigService { const normalizedKey = this.normalizeKey(key); try { - const forms = await this.prisma.priceConfig.findMany({ + const priceConfigs = await this.prisma.priceConfig.findMany({ where: { key: normalizedKey, version, @@ -322,7 +394,7 @@ export class PriceConfigService { }, }); - if (forms.length === 0) { + if (priceConfigs.length === 0) { throw new NotFoundException( `PriceConfig not found for key ${normalizedKey} version ${version.toString()}.`, ); @@ -342,7 +414,7 @@ export class PriceConfigService { }); await Promise.all( - forms.map((priceConfig) => + priceConfigs.map((priceConfig) => publishMetadataEvent( this.eventBusService, 'PROJECT_METADATA_DELETE', @@ -361,6 +433,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 +493,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,16 +516,24 @@ 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(); - - if (!normalized) { - throw new BadRequestException('Metadata key is required.'); - } - - return normalized; + return normalizeVersionedConfigKey(key, 'Metadata'); } + /** + * 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..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'; @@ -26,6 +32,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 +46,9 @@ export class ProductTemplateService { private readonly formService: FormService, ) {} + /** + * Lists product templates, optionally including disabled entries. + */ async findAll( includeDisabled = false, ): Promise { @@ -48,6 +63,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 +83,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 +130,9 @@ export class ProductTemplateService { } } + /** + * Updates a product template and validates the optional form reference. + */ async update( id: bigint, dto: UpdateProductTemplateDto, @@ -198,6 +222,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 +267,9 @@ export class ProductTemplateService { } } + /** + * Upgrades a legacy product template to use a versioned form reference. + */ async upgrade( id: bigint, dto: UpgradeProductTemplateDto, @@ -315,6 +345,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 +381,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 +421,9 @@ export class ProductTemplateService { : null; } + /** + * Converts optional values to a Prisma nullable JSON payload. + */ private toNullableJson( value: | Record @@ -392,51 +431,40 @@ 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); } + /** + * Reads and normalizes a stored form reference from JSON. + */ 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'); } + /** + * Converts JSON values to plain object maps when possible. + */ 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); } + /** + * Merges incoming JSON fields over existing object values. + */ private mergeJson( current: Prisma.JsonValue | null, next: Record, ): Record { - const currentRecord = this.toRecord(current); - return { - ...(currentRecord || {}), - ...next, - }; + return mergeJsonValue(current, next); } + /** + * Validates mutually exclusive legacy and versioned config fields. + */ private validateTemplateConfigConstraints( dto: CreateProductTemplateDto | UpdateProductTemplateDto, ): void { @@ -447,15 +475,22 @@ export class ProductTemplateService { } } + /** + * Parses a template id route parameter. + */ parseTemplateId(templateId: string): bigint { return parseBigIntParam(templateId, 'templateId'); } + /** + * 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/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..95abf25 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, @@ -32,6 +38,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 +54,9 @@ export class ProjectTemplateService { private readonly priceConfigService: PriceConfigService, ) {} + /** + * Lists project templates, optionally including disabled entries. + */ async findAll( includeDisabled = false, ): Promise { @@ -56,6 +71,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 +91,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 +149,9 @@ export class ProjectTemplateService { } } + /** + * Updates a project template and validates metadata references. + */ async update( id: bigint, dto: UpdateProjectTemplateDto, @@ -240,6 +264,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 +309,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 +469,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 +521,10 @@ export class ProjectTemplateService { }; } + /** + * Resolves a stored metadata reference into the latest matching versioned + * config record. + */ private async resolveVersionedReference( value: Prisma.JsonValue | null, type: 'form' | 'planConfig' | 'priceConfig', @@ -500,69 +539,55 @@ 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; - } - - 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 + const where = { + key: reference.key, + ...(reference.version > 0 ? { - id: latest.id.toString(), - key: latest.key, - version: latest.version.toString(), - revision: latest.revision.toString(), - config: toSerializable(latest.config || {}) as Record< - string, - unknown - >, + version: BigInt(reference.version), } - : null; + : {}), + 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; + + 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; } - const latest = await this.prisma.priceConfig.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(), @@ -577,6 +602,9 @@ export class ProjectTemplateService { : null; } + /** + * Converts optional values to a Prisma nullable JSON payload. + */ private toNullableJson( value: | MetadataVersionReference @@ -584,52 +612,41 @@ 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); } + /** + * Reads and normalizes a stored metadata reference from JSON. + */ private 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; - } + return getStoredReferenceValue(value, type); } + /** + * Converts JSON values to plain object maps when possible. + */ 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); } + /** + * Merges incoming JSON fields over existing object values. + */ private mergeJson( current: Prisma.JsonValue | null, next: Record, ): Record { - const currentRecord = this.toRecord(current); - return { - ...(currentRecord || {}), - ...next, - }; + return mergeJsonValue(current, next); } + /** + * Validates mutually exclusive legacy and versioned config fields. + */ private validateTemplateConfigConstraints( dto: CreateProjectTemplateDto | UpdateProjectTemplateDto, ): void { @@ -652,15 +669,22 @@ export class ProjectTemplateService { } } + /** + * Parses a template id route parameter. + */ parseTemplateId(templateId: string): bigint { return parseBigIntParam(templateId, 'templateId'); } + /** + * 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-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..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,13 +27,23 @@ 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) {} @Get() @ApiOperation({ summary: 'List project types' }) @ApiResponse({ status: 200, type: [ProjectTypeResponseDto] }) + /** + * Lists project types. + */ async list(): Promise { return this.projectTypeService.findAll(); } @@ -42,6 +53,9 @@ export class ProjectTypeController { @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 +65,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 +82,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 +101,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-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-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 4596884..2743afc 100644 --- a/src/api/metadata/utils/metadata-utils.ts +++ b/src/api/metadata/utils/metadata-utils.ts @@ -1,15 +1,34 @@ +/** + * 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, 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; 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 +39,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 +67,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,16 +102,37 @@ 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`, 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.', ); @@ -83,16 +141,43 @@ 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`, 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.'); } 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 +221,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 +249,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..7562691 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,156 +40,130 @@ function normalizeResolvedReference( }; } -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}.`); - } - - return normalizeResolvedReference(reference, latest.version); -} - -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) { + if (!found) { + if (reference.version > 0) { throw new BadRequestException( - `PlanConfig not found for key ${reference.key} version ${reference.version}.`, + `${entityName} 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}.`, + `${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); +} + +/** + * 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, +): Promise { + return validateVersionedReference( + planConfigRef, + 'planConfig', + 'PlanConfig', + prisma.planConfig, + ); +} +/** + * 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, ): 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/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/phase-product/dto/create-phase-product.dto.ts b/src/api/phase-product/dto/create-phase-product.dto.ts index 214f235..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,70 +8,62 @@ import { IsString, Min, } from 'class-validator'; +import { + parseOptionalInteger, + parseOptionalNumber, +} from 'src/shared/utils/dto-transform.utils'; -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; -} - -function parseOptionalInteger(value: unknown): number | undefined { - const parsed = parseOptionalNumber(value); - - if (typeof parsed === 'undefined') { - return undefined; - } - - return Math.trunc(parsed); -} - +/** + * 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..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'; @@ -9,29 +8,46 @@ 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'; -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 + * (`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 +79,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 +128,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 +212,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 +296,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 +305,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 +367,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,40 +408,28 @@ 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. + */ 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); } + /** + * 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. + */ private ensureNamedPermission( permission: Permission, user: JwtUser, @@ -372,17 +439,20 @@ 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'); - } } + /** + * 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,43 +477,48 @@ export class PhaseProductService { }; } + /** + * Safely converts unknown JSON-like details to plain object form. + * + * @param value - Candidate JSON value. + * @returns Object details payload. + */ private toDetailsObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {}; - } - - return value as Record; + return toDetailsObjectValue(value); } + /** + * Converts arbitrary values to Prisma JSON input semantics. + * + * @param value - Candidate JSON value. + * @returns Prisma JSON value, JsonNull, or undefined. + */ 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); } + /** + * 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. + */ private parseId(value: string, entityName: string): bigint { - try { - return BigInt(value); - } catch { - throw new BadRequestException(`${entityName} id is invalid.`); - } + return parseBigIntId(value, entityName); } + /** + * 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. 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 984c543..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,30 +32,37 @@ 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, -]; - @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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -96,9 +103,21 @@ 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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -146,9 +165,21 @@ 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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORKITEM_CREATE) @ApiOperation({ @@ -191,9 +222,22 @@ 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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORKITEM_EDIT) @ApiOperation({ @@ -239,10 +283,22 @@ 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) - @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/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/create-attachment.dto.ts b/src/api/project-attachment/dto/create-attachment.dto.ts index 75968e1..0c0d239 100644 --- a/src/api/project-attachment/dto/create-attachment.dto.ts +++ b/src/api/project-attachment/dto/create-attachment.dto.ts @@ -11,30 +11,15 @@ import { IsString, ValidateIf, } from 'class-validator'; +import { + parseOptionalInteger, + parseOptionalIntegerArray, +} from 'src/shared/utils/dto-transform.utils'; -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); -} - -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'); -} - +/** + * Create payload for project attachment endpoints: + * `POST /projects/:projectId/attachments`. + */ export class CreateAttachmentDto { @ApiProperty() @IsString() @@ -76,13 +61,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({ @@ -98,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 7ab049a..7032915 100644 --- a/src/api/project-attachment/dto/update-attachment.dto.ts +++ b/src/api/project-attachment/dto/update-attachment.dto.ts @@ -1,30 +1,13 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsArray, IsInt, IsOptional, IsString } from 'class-validator'; +import { parseOptionalIntegerArray } from 'src/shared/utils/dto-transform.utils'; -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); -} - -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'); -} - +/** + * 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() @@ -39,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.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..b60d578 100644 --- a/src/api/project-attachment/project-attachment.service.ts +++ b/src/api/project-attachment/project-attachment.service.ts @@ -10,25 +10,29 @@ 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'; -interface ProjectPermissionContext { - id: bigint; - members: Array<{ - userId: bigint; - role: string; - deletedAt: Date | null; - }>; -} - @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 +43,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 +92,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 +152,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 +213,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 +258,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 +277,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 +364,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 +422,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,43 +434,40 @@ 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. + */ 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); } + /** + * 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 +493,15 @@ 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. + */ private ensureNamedPermission( permission: Permission, user: JwtUser, @@ -437,17 +511,21 @@ 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'); - } } + /** + * 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. + */ private isAdminUser( user: JwtUser, projectMembers: Array<{ @@ -456,16 +534,17 @@ 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); } + /** + * 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 +559,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 +610,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 +645,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,21 +677,26 @@ 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. + */ private parseId(value: string, entityName: string): bigint { - try { - return BigInt(value); - } catch { - throw new BadRequestException(`${entityName} id is invalid.`); - } + return parseBigIntId(value, entityName); } + /** + * 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. 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/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.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..8bbf55b 100644 --- a/src/api/project-invite/project-invite.service.spec.ts +++ b/src/api/project-invite/project-invite.service.spec.ts @@ -6,10 +6,12 @@ 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', () => ({ - publishMemberEvent: jest.fn(() => Promise.resolve()), + publishMemberEventSafely: jest.fn(), + publishInviteEventSafely: jest.fn(), })); const eventUtils = jest.requireMock('src/shared/utils/event.utils'); @@ -44,6 +46,10 @@ describe('ProjectInviteService', () => { sendInviteEmail: jest.fn(), }; + const copilotNotificationServiceMock = { + sendCopilotInviteAcceptedNotification: jest.fn(), + }; + let service: ProjectInviteService; beforeEach(() => { @@ -55,10 +61,11 @@ describe('ProjectInviteService', () => { memberServiceMock as unknown as MemberService, identityServiceMock as unknown as IdentityService, emailServiceMock as unknown as EmailService, + copilotNotificationServiceMock as unknown as CopilotNotificationService, ); }); - it('creates invite', async () => { + it('creates invite by handle and sends known-user email', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), name: 'Demo', @@ -128,6 +135,271 @@ describe('ProjectInviteService', () => { ); expect(response.success).toHaveLength(1); + 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({ + 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 () => { + 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('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 () => { @@ -206,7 +478,7 @@ describe('ProjectInviteService', () => { undefined, ); - expect(eventUtils.publishMemberEvent).toHaveBeenCalledWith( + expect(eventUtils.publishMemberEventSafely).toHaveBeenCalledWith( KAFKA_TOPIC.PROJECT_MEMBER_ADDED, expect.objectContaining({ projectId: '1001', @@ -214,6 +486,116 @@ describe('ProjectInviteService', () => { role: ProjectMemberRole.customer, userId: '123', }), + 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 () => { + 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(), ); }); @@ -256,4 +638,217 @@ 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), + 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(), + ); + }); + + 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-invite/project-invite.service.ts b/src/api/project-invite/project-invite.service.ts index 4ce4063..932119e 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, @@ -30,7 +31,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'; @@ -39,7 +43,19 @@ import { enrichInvitesWithUserDetails, validateUserHasProjectRole, } from 'src/shared/utils/member.utils'; -import { publishMemberEvent } 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, + getActorUserId as getActorUserIdFromJwt, + getAuditUserId as getAuditUserIdFromJwt, + parseCsvFields, + parseNumericStringId, + parseOptionalNumericStringId, +} from 'src/shared/utils/service.utils'; interface InviteTargetByUser { userId: bigint; @@ -51,6 +67,27 @@ interface InviteTargetByEmail { email: string; } +interface InviteMemberName { + firstName?: string; + lastName?: 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. + * 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. + * + * 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'); @@ -61,8 +98,23 @@ export class ProjectInviteService { private readonly memberService: MemberService, private readonly identityService: IdentityService, private readonly emailService: EmailService, + private readonly copilotNotificationService: CopilotNotificationService, ) {} + /** + * 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. + * @emits `project.member.invite.created` for each created invite. + * @emits `external.action.email` for pending invites with recipient emails. + */ async createInvites( projectId: string, dto: CreateInviteDto, @@ -168,6 +220,9 @@ export class ProjectInviteService { ); const emailOnlyTargets = emailTargets.emailOnlyTargets; + const knownInviteeNamesByUserId = await this.getMemberNameMapByUserIds( + validatedUserTargets.map((target) => target.userId), + ); const status = dto.role !== ProjectMemberRole.copilot || canInviteCopilotDirectly @@ -211,21 +266,55 @@ export class ProjectInviteService { return created; }); + let inviteInitiatorPromise: Promise | null = null; + for (const invite of success) { const normalizedInvite = this.normalizeEntity(invite); - if ( - invite.email && - !invite.userId && - invite.status === InviteStatus.pending - ) { + const recipient = invite.email?.trim().toLowerCase(); + const isKnownUserInvite = invite.userId !== null; + const inviteCreatedPayload = { + ...normalizedInvite, + source: 'work_manager', + }; + + this.publishInvite( + KAFKA_TOPIC.PROJECT_MEMBER_INVITE_CREATED, + inviteCreatedPayload, + ); + + if (invite.email && invite.status === InviteStatus.pending) { + 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(isKnownUserInvite)}`, + ); void this.emailService.sendInviteEmail( projectId, - normalizedInvite, + invitePayload, + await inviteInitiatorPromise, + project.name, { - userId: user.userId, - handle: user.handle, + isSSO: isKnownUserInvite, }, - 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)}`, ); } } @@ -241,6 +330,21 @@ 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. + * @emits `project.member.invite.updated`. + */ async updateInvite( projectId: string, inviteId: string, @@ -253,6 +357,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 +435,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 +494,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 ( @@ -404,6 +516,11 @@ export class ProjectInviteService { }, ); + this.publishInvite( + KAFKA_TOPIC.PROJECT_MEMBER_INVITE_UPDATED, + this.normalizeEntity(updatedInvite), + ); + if (projectMember) { this.publishMember( KAFKA_TOPIC.PROJECT_MEMBER_ADDED, @@ -411,9 +528,25 @@ export class ProjectInviteService { ); } + if (this.shouldNotifyCopilotInviteAccepted(updatedInvite, source)) { + await this.notifyCopilotInviteAccepted(updatedInvite.applicationId); + } + 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. + * @emits `project.member.invite.deleted`. + */ async deleteInvite( projectId: string, inviteId: string, @@ -479,7 +612,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, @@ -499,8 +632,23 @@ export class ProjectInviteService { return updated; }); + + this.publishInvite( + KAFKA_TOPIC.PROJECT_MEMBER_INVITE_REMOVED, + this.normalizeEntity(canceledInvite), + ); } + /** + * 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 +712,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 +792,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[], @@ -650,15 +819,13 @@ export class ProjectInviteService { 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(), ), ); @@ -677,7 +844,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; } @@ -713,6 +880,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 +990,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 +1031,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 +1067,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 +1147,70 @@ 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, + ); + } + + /** + * 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 +1311,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,52 +1345,53 @@ 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. + */ 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.", + }, + }, + ); } + /** + * 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 +1408,32 @@ 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.status !== InviteStatus.pending) { + return `status-${String(invite.status)}`; + } + + return 'not-eligible'; + } + + /** + * 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 +1451,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 +1484,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,59 +1500,73 @@ 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. + */ 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`); } + /** + * 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. + */ 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); } + /** + * Resolves authenticated caller id as a normalized string. + * + * @param user Authenticated caller. + * @returns Trimmed caller id. + * @throws {ForbiddenException} If caller id is missing. + */ 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); } + /** + * 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. + */ 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); } + /** + * Parses CSV field selection for invite response hydration. + * + * @param fields Raw CSV field list. + * @returns Normalized field names. + * @throws {BadRequestException} If field parsing fails. + */ 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); } + /** + * 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,52 +1582,146 @@ export class ProjectInviteService { return undefined; } - private isUniqueGmailValidationEnabled(): boolean { - return ( - String(process.env.UNIQUE_GMAIL_VALIDATION || 'false').toLowerCase() === - 'true' + /** + * 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), ); - } - private normalizeEntity(payload: T): T { - const walk = (input: unknown): unknown => { - if (typeof input === 'bigint') { - return input.toString(); - } + if (!matchedInitiator) { + return initiator; + } - if (input instanceof Prisma.Decimal) { - return Number(input.toString()); - } + 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(); + } - if (Array.isArray(input)) { - return input.map((entry) => walk(entry)); - } + return initiator; + } - if (input && typeof input === 'object') { - if (input instanceof Date) { - return input; - } + /** + * 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), + ), + ); - const output: Record = {}; - for (const [key, value] of Object.entries(input)) { - output[key] = walk(value); - } + if (normalizedUserIds.length === 0) { + return new Map(); + } - return output; + 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; } - return input; - }; + 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. + * + * @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() === + 'true' + ); + } - return walk(payload) as T; + /** + * 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. + */ + private normalizeEntity(payload: T): T { + return normalizePrismaEntity(payload); } + /** + * 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. + */ 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); + } + + /** + * 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-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.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/api/project-member/project-member.service.ts b/src/api/project-member/project-member.service.ts index 8e071e5..110cee3 100644 --- a/src/api/project-member/project-member.service.ts +++ b/src/api/project-member/project-member.service.ts @@ -32,8 +32,28 @@ 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. + * + * 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 +64,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 +204,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 +334,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 +444,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 +510,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,52 +578,58 @@ 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. + */ 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.', + }, + }, + ); } + /** + * 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 +752,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 +780,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,82 +808,71 @@ 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. + */ 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`); } + /** + * 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. + */ 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); } + /** + * 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. + */ 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); } + /** + * 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. + */ 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); } + /** + * 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. + */ 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); } + /** + * 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. + */ 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 96e483b..d2a32eb 100644 --- a/src/api/project-phase/dto/create-phase.dto.ts +++ b/src/api/project-phase/dto/create-phase.dto.ts @@ -13,52 +13,29 @@ 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; -} - -function parseOptionalInteger(value: unknown): number | undefined { - const parsed = parseOptionalNumber(value); - - if (typeof parsed === 'undefined') { - return undefined; - } - - return Math.trunc(parsed); -} - -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'); -} - +import { + parseOptionalInteger, + parseOptionalIntegerArray, + parseOptionalNumber, +} from 'src/shared/utils/dto-transform.utils'; + +/** + * 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 +47,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 +101,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..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,31 +1,17 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { parseOptionalBoolean } from 'src/shared/utils/dto-transform.utils'; -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; -} - +/** + * 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 +19,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 +27,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..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'; @@ -22,21 +21,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'; - -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[]; @@ -78,12 +76,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 +194,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 +259,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 +428,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 +573,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 +650,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,42 +678,28 @@ 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. + */ 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', + }); } + /** + * 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 +725,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 +792,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 +810,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 +834,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 +855,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 +882,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 +905,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 +932,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 +961,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 +1023,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,28 +1052,34 @@ 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. + */ private toDetailsObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {}; - } - - return value as Record; + return toDetailsObjectValue(value); } + /** + * Converts an arbitrary value into Prisma JSON input semantics. + * + * @param value - Candidate JSON value. + * @returns Prisma JSON input value, JsonNull, or undefined. + */ 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); } + /** + * 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 +1089,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 +1107,13 @@ 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. + */ private isAdminUser( user: JwtUser, projectMembers: Array<{ @@ -975,16 +1122,18 @@ 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); } + /** + * 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. + */ private ensureNamedPermission( permission: Permission, user: JwtUser, @@ -994,66 +1143,47 @@ 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'); - } } + /** + * 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. + */ 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); } + /** + * 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. + */ private parseId(value: string, entityName: string): bigint { - try { - return BigInt(value); - } catch { - throw new BadRequestException(`${entityName} id is invalid.`); - } + return parseBigIntId(value, entityName); } + /** + * 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. 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 6dca986..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,28 +35,36 @@ 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, -]; - @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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -108,9 +116,20 @@ 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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -151,9 +170,20 @@ 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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORK_CREATE) @ApiOperation({ @@ -195,9 +225,21 @@ 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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORK_EDIT) @ApiOperation({ @@ -235,10 +277,21 @@ 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) - @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/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..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'; @@ -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,13 +241,14 @@ 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. + */ 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 0fa2bc4..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, @@ -19,7 +18,22 @@ 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`. + * + * 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 +41,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 +77,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 +180,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 +285,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,34 +352,53 @@ 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. + */ 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); } + /** + * 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. + */ 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); } + /** + * 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 +407,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..162dcbc 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', @@ -287,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/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..ad881c7 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"', @@ -87,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)) @@ -95,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() @@ -140,6 +165,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..e2145c7 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; @@ -116,6 +131,9 @@ export class ProjectResponseDto { }) status: ProjectStatus; + @ApiPropertyOptional() + cancelReason?: string | null; + @ApiPropertyOptional() billingAccountId?: string | null; @@ -186,6 +204,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..80f6b13 100644 --- a/src/api/project/dto/update-project.dto.ts +++ b/src/api/project/dto/update-project.dto.ts @@ -1,4 +1,26 @@ +import { ApiHideProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { PartialType } from '@nestjs/mapped-types'; import { CreateProjectDto } from './create-project.dto'; -export class UpdateProjectDto extends PartialType(CreateProjectDto) {} +/** + * 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) { + @ApiHideProperty() + @Transform(({ obj }) => parseClearBillingAccountFlag(obj?.billingAccountId)) + clearBillingAccountId?: boolean; +} 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.spec.ts b/src/api/project/project.controller.spec.ts index 3f92876..3f845d1 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,98 @@ 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('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', + }); + + 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.controller.ts b/src/api/project/project.controller.ts index 43f841f..62ea401 100644 --- a/src/api/project/project.controller.ts +++ b/src/api/project/project.controller.ts @@ -42,14 +42,38 @@ 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() @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,15 +162,22 @@ 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)) - @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', @@ -181,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)) @@ -220,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)) @@ -254,6 +310,19 @@ export class ProjectController { return this.service.getProjectBillingAccount(projectId, user); } + /** + * Returns project permissions for the caller or, for M2M, every project user. + * + * @param projectId Project id path parameter. + * @param user Authenticated caller context. + * @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. + * @throws NotFoundException When the project is missing. + */ @Get(':projectId/permissions') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @@ -272,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' }) @@ -288,10 +404,22 @@ export class ProjectController { async getProjectPermissions( @Param('projectId') projectId: string, @CurrentUser() user: JwtUser, - ): Promise> { + ): Promise { 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)) @@ -320,6 +448,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' }) @@ -354,6 +494,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)) @@ -387,6 +540,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.spec.ts b/src/api/project/project.service.spec.ts index ac8e899..380671f 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -1,11 +1,13 @@ 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'; 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'); @@ -27,12 +29,21 @@ 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(), + isNamedPermissionRequireProjectMembers: jest.fn(), }; const billingAccountServiceMock = { @@ -41,15 +52,23 @@ describe('ProjectService', () => { getDefaultBillingAccount: jest.fn(), }; + const memberServiceMock = { + getMemberDetailsByUserIds: jest.fn(), + getUserRoles: jest.fn(), + }; + let service: ProjectService; beforeEach(() => { jest.clearAllMocks(); prismaMock.$queryRaw.mockResolvedValue([]); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); + memberServiceMock.getUserRoles.mockResolvedValue([]); service = new ProjectService( prismaMock as any, permissionServiceMock as unknown as PermissionService, billingAccountServiceMock as any, + memberServiceMock as any, ); }); @@ -151,6 +170,94 @@ 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 => @@ -413,6 +520,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), @@ -552,81 +679,149 @@ describe('ProjectService', () => { expect(result.invites).toBeUndefined(); }); - it('blocks billing account id updates without permission', async () => { + it('normalizes stored work-management permissions for project permission responses', async () => { prismaMock.project.findFirst.mockResolvedValue({ - id: BigInt(1001), - billingAccountId: BigInt(12), - directProjectId: null, - status: 'in_review', - members: [ - { - userId: BigInt(100), - role: 'manager', - deletedAt: null, - }, - ], - invites: [], + 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); - permissionServiceMock.hasNamedPermission.mockImplementation( - (permission: Permission): boolean => { - if (permission === Permission.EDIT_PROJECT) { - return true; - } - - if (permission === Permission.MANAGE_PROJECT_BILLING_ACCOUNT_ID) { - return false; - } + const result = await service.getProjectPermissions('1001', { + userId: '100', + isMachine: false, + }); - return true; + expect(result).toEqual({ + manage_team: true, + }); + expect(permissionServiceMock.hasPermission).toHaveBeenCalledWith( + { + allowRule: { + projectRoles: ['manager'], + }, }, + expect.objectContaining({ userId: '100' }), + [ + expect.objectContaining({ + role: 'manager', + }), + ], ); - - await expect( - service.updateProject( - '1001', - { - billingAccountId: 99, - }, - { - userId: '100', - isMachine: false, - }, - ), - ).rejects.toBeInstanceOf(ForbiddenException); }); - it('soft deletes project and emits event', async () => { + it('returns a per-user permission matrix for machine tokens even without a template', async () => { prismaMock.project.findFirst.mockResolvedValue({ - id: BigInt(1001), - members: [ - { - userId: BigInt(100), - role: 'manager', - deletedAt: null, - }, - ], + 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): boolean => - permission === Permission.DELETE_PROJECT, + ( + 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([]); - prismaMock.project.update.mockResolvedValue({ - id: BigInt(1001), + const result = await service.getProjectPermissions('1001', { + isMachine: true, + scopes: [Scope.PROJECTS_READ], + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECTS_READ, + }, }); - await service.deleteProject('1001', { - userId: '100', - isMachine: false, + 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.project.update).toHaveBeenCalled(); - expect(eventUtils.publishProjectEvent).toHaveBeenCalled(); + expect(prismaMock.workManagementPermission.findMany).not.toHaveBeenCalled(); + expect(memberServiceMock.getUserRoles).toHaveBeenCalledWith('100'); + expect(memberServiceMock.getUserRoles).toHaveBeenCalledWith('200'); }); - it('creates project and publishes project.created event', async () => { + 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', + }); + const transactionProjectMemberCreate = jest.fn().mockResolvedValue({}); + const transactionProjectHistoryCreate = jest.fn().mockResolvedValue({}); + prismaMock.projectType.findFirst.mockResolvedValue({ key: 'app', }); @@ -634,22 +829,20 @@ describe('ProjectService', () => { async (callback: (tx: unknown) => Promise) => callback({ project: { - create: jest.fn().mockResolvedValue({ - id: BigInt(1001), - status: 'in_review', - }), + create: transactionProjectCreate, }, projectMember: { - create: jest.fn().mockResolvedValue({}), + create: transactionProjectMemberCreate, + createMany: jest.fn().mockResolvedValue({ count: 0 }), }, projectHistory: { - create: jest.fn().mockResolvedValue({}), + create: transactionProjectHistoryCreate, }, }), ); prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), - name: 'Demo Project', + name: 'Machine Project', description: null, type: 'app', status: 'in_review', @@ -661,48 +854,64 @@ describe('ProjectService', () => { groups: [], external: null, bookmarks: null, - details: { - utm: { - code: 'ABC', - }, - }, + utm: null, + details: null, challengeEligibility: null, + cancelReason: null, templateId: null, version: 'v3', lastActivityAt: new Date(), - lastActivityUserId: '100', + lastActivityUserId: 'svc-projects', createdAt: new Date(), updatedAt: new Date(), - createdBy: 100, - updatedBy: 100, + createdBy: -1, + updatedBy: -1, members: [], invites: [], attachments: [], phases: [], }); - permissionServiceMock.hasNamedPermission.mockImplementation( - (permission: Permission): boolean => - permission === Permission.CREATE_PROJECT_AS_MANAGER, - ); + permissionServiceMock.hasNamedPermission.mockReturnValue(false); - await service.createProject( + const result = await service.createProject( { - name: 'Demo Project', + name: 'Machine Project', type: 'app', }, { - userId: '100', isMachine: false, + scopes: ['write:projects'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:projects', + sub: 'svc-projects', + }, }, ); - expect(eventUtils.publishProjectEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_CREATED, - expect.any(Object), + 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('publishes project.updated event during update', 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', + status: 'active', + billingAccountId: null, + directProjectId: null, + }); + prismaMock.project.findFirst .mockResolvedValueOnce({ id: BigInt(1001), @@ -710,26 +919,20 @@ describe('ProjectService', () => { description: null, type: 'app', status: 'in_review', - billingAccountId: BigInt(11), + billingAccountId: null, directProjectId: null, details: {}, bookmarks: null, - members: [ - { - userId: BigInt(100), - role: 'manager', - deletedAt: null, - }, - ], + members: [], invites: [], }) .mockResolvedValueOnce({ id: BigInt(1001), - name: 'Demo', + name: 'Updated by machine', description: null, type: 'app', status: 'active', - billingAccountId: BigInt(22), + billingAccountId: null, directProjectId: null, estimatedPrice: null, actualPrice: null, @@ -742,7 +945,626 @@ describe('ProjectService', () => { templateId: null, version: 'v3', lastActivityAt: new Date(), - lastActivityUserId: '100', + 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: false, + scopes: ['write:projects'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:projects', + sub: 'svc-projects', + }, + }, + ); + + expect(transactionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + lastActivityUserId: 'svc-projects', + updatedBy: -1, + }), + }), + ); + }); + + it('deletes projects for machine principals inferred from token claims 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: false, + scopes: ['write:projects'], + tokenPayload: { + gty: 'client-credentials', + scope: 'write:projects', + 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), + billingAccountId: BigInt(12), + directProjectId: null, + status: 'in_review', + members: [ + { + userId: BigInt(100), + role: 'manager', + deletedAt: null, + }, + ], + invites: [], + }); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => { + if (permission === Permission.EDIT_PROJECT) { + return true; + } + + if (permission === Permission.MANAGE_PROJECT_BILLING_ACCOUNT_ID) { + return false; + } + + return true; + }, + ); + + await expect( + service.updateProject( + '1001', + { + billingAccountId: 99, + }, + { + userId: '100', + isMachine: false, + }, + ), + ).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), + members: [ + { + userId: BigInt(100), + role: 'manager', + deletedAt: null, + }, + ], + }); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.DELETE_PROJECT, + ); + + prismaMock.project.update.mockResolvedValue({ + id: BigInt(1001), + }); + + await service.deleteProject('1001', { + userId: '100', + isMachine: false, + }); + + expect(prismaMock.project.update).toHaveBeenCalled(); + expect(eventUtils.publishProjectEvent).toHaveBeenCalled(); + }); + + it('creates project and publishes project.created event', async () => { + prismaMock.projectType.findFirst.mockResolvedValue({ + key: 'app', + }); + prismaMock.$transaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => + callback({ + project: { + create: jest.fn().mockResolvedValue({ + id: BigInt(1001), + status: 'in_review', + }), + }, + projectMember: { + create: jest.fn().mockResolvedValue({}), + }, + projectHistory: { + create: jest.fn().mockResolvedValue({}), + }, + }), + ); + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + name: 'Demo Project', + description: null, + type: 'app', + status: 'in_review', + billingAccountId: null, + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + details: { + utm: { + code: 'ABC', + }, + }, + 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: [], + }); + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.CREATE_PROJECT_AS_MANAGER, + ); + + await service.createProject( + { + name: 'Demo Project', + type: 'app', + }, + { + userId: '100', + isMachine: false, + }, + ); + + expect(eventUtils.publishProjectEvent).toHaveBeenCalledWith( + KAFKA_TOPIC.PROJECT_CREATED, + expect.any(Object), + ); + }); + + it('publishes project.updated event during update', 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', + description: null, + type: 'app', + status: 'active', + billingAccountId: BigInt(22), + 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, @@ -772,6 +1594,10 @@ describe('ProjectService', () => { project: { update: jest.fn().mockResolvedValue({ id: BigInt(1001), + name: 'Demo', + status: 'active', + billingAccountId: BigInt(22), + directProjectId: null, }), }, projectHistory: { @@ -797,5 +1623,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 2476248..8eae430 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'; @@ -26,8 +29,12 @@ 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 } from 'src/shared/utils/event.utils'; +import { + publishProjectEvent, + publishRawEvent as publishRawBusEvent, +} from 'src/shared/utils/event.utils'; import { ParsedProjectFields, buildProjectIncludeClause, @@ -49,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; }; @@ -65,6 +96,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'); @@ -72,8 +112,23 @@ export class ProjectService { private readonly prisma: PrismaService, private readonly permissionService: PermissionService, private readonly billingAccountService: BillingAccountService, + private readonly memberService: MemberService, ) {} + /** + * 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 +195,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 +285,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, @@ -271,6 +357,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) @@ -305,16 +393,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( @@ -476,6 +566,25 @@ export class ProjectService { return response; } + /** + * Partially updates a project with permission-aware field guards. + * + * Performs additional checks for `billingAccountId` and `directProjectId`, + * supports explicit billing-account clearing, persists optional + * `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. + * @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, @@ -524,10 +633,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, @@ -573,10 +690,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) @@ -627,6 +748,8 @@ export class ProjectService { data: { projectId: updated.id, status: dto.status, + cancelReason: + typeof dto.cancelReason === 'string' ? dto.cancelReason : null, updatedBy: auditUserId, }, }); @@ -634,6 +757,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: { @@ -666,12 +793,35 @@ 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; } + /** + * 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 actorUserId = this.getActorUserId(user); const auditUserId = this.getAuditUserId(user); const existingProject = await this.prisma.project.findFirst({ @@ -717,14 +867,29 @@ export class ProjectService { this.publishEvent(KAFKA_TOPIC.PROJECT_DELETED, { id: projectId, - deletedBy: user.userId, + deletedBy: actorUserId, }); } + /** + * Returns project permissions for the caller or, for M2M, every project user. + * + * 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 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({ @@ -743,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 {}; } @@ -774,11 +979,11 @@ export class ProjectService { }), ]); - const policyMap: Record = {}; + const policyMap: ProjectPolicyMap = {}; for (const permissionRecord of workManagementPermissions) { const hasPermission = this.permissionService.hasPermission( - (permissionRecord.permission || {}) as JsonPermission, + this.normalizeStoredPermission(permissionRecord.permission), user, projectMembers, ); @@ -791,6 +996,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 +1026,20 @@ 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. + * @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, @@ -838,10 +1067,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; @@ -855,6 +1094,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, @@ -864,8 +1118,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) { @@ -922,6 +1179,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 +1229,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 +1259,19 @@ export class ProjectService { return Array.from(userIds).map((userId) => BigInt(userId)); } + /** + * 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 + * SQL injection even though it queries across schema boundary `members.member`. + */ private async fetchMemberHandlesByUserId( userIds: bigint[], ): Promise> { @@ -1007,7 +1293,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); @@ -1017,14 +1303,90 @@ 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(); } } + /** + * 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 +1400,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 +1428,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 +1444,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 +1497,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 +1521,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 +1546,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 +1607,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 +1637,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,25 +1654,378 @@ 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, 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) { + const machineActorId = this.getMachineActorId(user); + if (machineActorId) { + return machineActorId; + } + throw new ForbiddenException('Authenticated user id is missing.'); } return String(user.userId).trim(); } + /** + * Parses actor id into numeric audit column representation. + * + * @param user Authenticated caller context. + * @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 (this.isMachinePrincipal(user)) { + 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 (!this.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; + } + + /** + * 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. + * + * @param templatePhases Raw template phases JSON value. + * @returns Typed phase object list. + */ private extractTemplatePhases( templatePhases: Prisma.JsonValue, ): Array> { @@ -1260,6 +2039,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 +2104,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 +2121,43 @@ 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. + * + * @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 +2172,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 +2194,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 +2237,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 +2253,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..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,25 +36,30 @@ 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, -]; - @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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -80,9 +85,19 @@ 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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORKSTREAM_CREATE) @ApiOperation({ @@ -106,9 +121,20 @@ 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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes( Scope.PROJECTS_READ, Scope.PROJECTS_WRITE, @@ -137,9 +163,20 @@ 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) + @Roles(...WORK_LAYER_ALLOWED_ROLES) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) @RequirePermission(Permission.WORKSTREAM_EDIT) @ApiOperation({ @@ -164,10 +201,20 @@ 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) - @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 b3927d4..9a080bf 100644 --- a/src/api/workstream/workstream.dto.ts +++ b/src/api/workstream/workstream.dto.ts @@ -12,57 +12,21 @@ import { Max, Min, } from 'class-validator'; +import { + parseOptionalBoolean, + parseOptionalInteger, +} from 'src/shared/utils/dto-transform.utils'; -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; -} - -function parseOptionalInteger(value: unknown): number | undefined { - const parsed = parseOptionalNumber(value); - - if (typeof parsed === 'undefined') { - return undefined; - } - - return Math.trunc(parsed); -} - -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; -} - +/** + * 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 +39,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 +58,9 @@ export class WorkSummaryDto { status?: string | null; } +/** + * Response payload for work stream endpoints. + */ export class WorkStreamResponseDto { @ApiProperty() id: string; @@ -119,10 +92,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 +122,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 +147,9 @@ export class WorkStreamListCriteria { perPage?: number; } +/** + * Query payload for `GET /projects/:projectId/workstreams/:id`. + */ export class WorkStreamGetCriteria { @ApiPropertyOptional({ description: 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..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, @@ -26,9 +27,26 @@ 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 { 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,37 +423,25 @@ 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. + */ 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; } + /** + * 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 +468,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 +484,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 +506,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/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..8af8af2 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,11 +34,23 @@ 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(); } + if (value instanceof Date) { + return value; + } + if (Array.isArray(value)) { return value.map((entry) => serializeBigInt(entry)); } @@ -35,6 +67,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 +85,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 +98,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 +133,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 +149,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 +159,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 +201,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 +252,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 +268,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..1f3b31f 100644 --- a/src/shared/config/kafka.config.ts +++ b/src/shared/config/kafka.config.ts @@ -1,11 +1,71 @@ +/** + * 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 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`. + */ 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', + /** + * 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; +/** + * Union type of all topic names in `KAFKA_TOPIC`. + */ export type KafkaTopic = (typeof KAFKA_TOPIC)[keyof typeof KAFKA_TOPIC]; 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/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..f3ea70a 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,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: LIST_COPILOT_OPPORTUNITY. + */ LIST_COPILOT_OPPORTUNITY: { meta: { title: 'Apply copilot opportunity', @@ -301,10 +356,13 @@ 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, }, + /** + * @description Permission policy: MANAGE_PROJECT_BILLING_ACCOUNT_ID. + */ MANAGE_PROJECT_BILLING_ACCOUNT_ID: { meta: { title: 'Manage Project property "billingAccountId"', @@ -315,6 +373,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE_PROJECTS_BILLING_ACCOUNTS, }, + /** + * @description Permission policy: DELETE_PROJECT. + */ DELETE_PROJECT: { meta: { title: 'Delete Project', @@ -336,6 +397,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 +422,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 +447,9 @@ export const PERMISSION = { /* * Project Member */ + /** + * @description Permission policy: READ_PROJECT_MEMBER. + */ READ_PROJECT_MEMBER: { meta: { title: 'Read Project Member', @@ -390,6 +460,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 +474,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 +490,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 +503,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 +517,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 +531,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 +545,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 +560,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 +581,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 +594,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 +608,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 +622,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 +637,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 +651,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 +664,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 +677,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 +690,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 +703,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 +718,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 +733,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 +748,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 +768,9 @@ export const PERMISSION = { /* * Project Attachments */ + /** + * @description Permission policy: CREATE_PROJECT_ATTACHMENT. + */ CREATE_PROJECT_ATTACHMENT: { meta: { title: 'Create Project Attachment', @@ -649,6 +785,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: VIEW_PROJECT_ATTACHMENT. + */ VIEW_PROJECT_ATTACHMENT: { meta: { title: 'View Project Attachment', @@ -659,6 +798,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 +813,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 +827,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 +845,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 +858,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: EDIT_PROJECT_ATTACHMENT. + */ EDIT_PROJECT_ATTACHMENT: { meta: { title: 'Edit Project Attachment', @@ -722,6 +876,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 +894,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 +907,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_ATTACHMENT. + */ DELETE_PROJECT_ATTACHMENT: { meta: { title: 'Delete Project Attachment', @@ -762,6 +925,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: ADD_PROJECT_PHASE. + */ ADD_PROJECT_PHASE: { meta: { title: 'Add Project Phase', @@ -772,6 +938,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: UPDATE_PROJECT_PHASE. + */ UPDATE_PROJECT_PHASE: { meta: { title: 'Update Project Phase', @@ -782,6 +951,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: DELETE_PROJECT_PHASE. + */ DELETE_PROJECT_PHASE: { meta: { title: 'Delete Project Phase', @@ -792,6 +964,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: ADD_PHASE_PRODUCT. + */ ADD_PHASE_PRODUCT: { meta: { title: 'Add Phase Product', @@ -802,6 +977,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: UPDATE_PHASE_PRODUCT. + */ UPDATE_PHASE_PRODUCT: { meta: { title: 'Update Phase Product', @@ -812,6 +990,9 @@ export const PERMISSION = { scopes: SCOPES_PROJECTS_WRITE, }, + /** + * @description Permission policy: DELETE_PHASE_PRODUCT. + */ DELETE_PHASE_PRODUCT: { meta: { title: 'Delete Phase Product', @@ -825,6 +1006,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 +1023,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 +1038,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 +1051,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 +1064,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 +1079,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 +1104,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..09de334 100644 --- a/src/shared/constants/permissions.ts +++ b/src/shared/constants/permissions.ts @@ -1,61 +1,157 @@ +/** + * 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 { + /** + * Project permissions. + */ + /** 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', + /** + * Project member permissions. + */ + /** 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', + /** + * Project invite permissions. + */ + /** 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', + /** + * Attachment permissions. + */ + /** 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', + /** + * 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. */ WORKSTREAM_VIEW = 'workStream.view', + /** Workstream edit permission. */ WORKSTREAM_EDIT = 'workStream.edit', + /** Workstream delete permission. */ WORKSTREAM_DELETE = 'workStream.delete', + /** + * Work permissions. + */ + /** 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 permissions. + */ + /** 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', + /** + * Work-management permission settings. + */ + /** 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/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/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..0704897 100644 --- a/src/shared/guards/adminOnly.guard.ts +++ b/src/shared/guards/adminOnly.guard.ts @@ -1,31 +1,81 @@ +/** + * 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, 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'; +/** + * 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'; +/** + * 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. + */ @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,14 +105,24 @@ export class AdminOnlyGuard implements CanActivate { } } +/** + * Composite decorator that applies `AdminOnlyGuard` and Swagger auth metadata. + */ 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, ]), ); +/** + * 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/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/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 547c040..e56c4ed 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, @@ -12,14 +18,36 @@ 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. + */ @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 `BadRequestException` when `projectId` is present but not numeric. + * - 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 +73,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 { @@ -55,6 +90,10 @@ export class CopilotAndAboveGuard implements CanActivate { } const normalizedProjectId = projectId.trim(); + const parsedProjectId = parseNumericStringId( + normalizedProjectId, + 'Project id', + ); if ( request.projectContext?.projectId === normalizedProjectId && @@ -65,7 +104,7 @@ export class CopilotAndAboveGuard implements CanActivate { const projectMembers = await this.prisma.projectMember.findMany({ where: { - projectId: BigInt(normalizedProjectId), + projectId: parsedProjectId, deletedAt: null, }, select: { @@ -92,5 +131,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.spec.ts b/src/shared/guards/permission.guard.spec.ts index f811935..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'; @@ -131,7 +131,7 @@ describe('PermissionGuard', () => { ); permissionServiceMock.hasPermission.mockReturnValue(true); - const request = { + const request: any = { user: { userId: '123', isMachine: false, @@ -184,4 +184,115 @@ 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: any = { + 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([]); + }); + + 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); + }); + + 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 6cb213e..d98cb8d 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, @@ -17,15 +22,37 @@ 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. + */ @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 `BadRequestException` when a required `projectId` param is not numeric. + * - 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 +97,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. + * - 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 + * 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`. + */ private async resolveProjectContextIfRequired( request: AuthenticatedRequest, permissions: RequiredPermission[], @@ -101,25 +144,51 @@ export class PermissionGuard implements CanActivate { }; } + const parsedProjectId = parseNumericStringId( + normalizedProjectId, + 'Project id', + ); + if (!request.projectContext) { request.projectContext = { projectMembers: [], + projectMembersLoaded: false, + projectInvites: [], + projectInvitesLoaded: 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.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 ( requiresProjectMembers && - request.projectContext.projectMembers.length === 0 + request.projectContext.projectMembersLoaded !== true ) { const projectMembers = await this.prisma.projectMember.findMany({ where: { - projectId: BigInt(normalizedProjectId), + projectId: parsedProjectId, deletedAt: null, }, select: { @@ -136,15 +205,16 @@ export class PermissionGuard implements CanActivate { ...member, role: String(member.role), })); + request.projectContext.projectMembersLoaded = true; } if ( requiresProjectInvites && - !Array.isArray(request.projectContext.projectInvites) + request.projectContext.projectInvitesLoaded !== true ) { const projectInvites = await this.prisma.projectMemberInvite.findMany({ where: { - projectId: BigInt(normalizedProjectId), + projectId: parsedProjectId, deletedAt: null, }, select: { @@ -161,6 +231,7 @@ export class PermissionGuard implements CanActivate { ...invite, status: String(invite.status), })); + request.projectContext.projectInvitesLoaded = true; } return { @@ -169,6 +240,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 +255,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.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 a43c065..a93da23 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, @@ -14,20 +20,50 @@ 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. + */ 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. + * - 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. + * - 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; @@ -40,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( @@ -47,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(), @@ -67,6 +108,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,9 +123,24 @@ 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, + parsedProjectId: bigint, ): Promise { if ( request.projectContext?.projectId === projectId && @@ -90,7 +151,7 @@ export class ProjectMemberGuard implements CanActivate { const projectMembers = await this.prisma.projectMember.findMany({ where: { - projectId: BigInt(projectId), + projectId: parsedProjectId, deletedAt: null, }, select: { @@ -117,6 +178,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.spec.ts b/src/shared/guards/tokenRoles.guard.spec.ts index 6e5eb81..015a4e6 100644 --- a/src/shared/guards/tokenRoles.guard.spec.ts +++ b/src/shared/guards/tokenRoles.guard.spec.ts @@ -8,7 +8,12 @@ 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 { ADMIN_ONLY_KEY } from './auth-metadata.constants'; +import { + ANY_AUTHENTICATED_KEY, + ROLES_KEY, + TokenRolesGuard, +} from './tokenRoles.guard'; describe('TokenRolesGuard', () => { let guard: TokenRolesGuard; @@ -97,6 +102,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: { @@ -258,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 90a8920..61bd685 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, @@ -14,23 +23,84 @@ 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()`. + */ 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. + * + * 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), ); +/** + * 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. + */ @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. + * - 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. + */ async canActivate(context: ExecutionContext): Promise { const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ context.getHandler(), @@ -70,8 +140,32 @@ 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; + const isAdminOnly = + this.reflector.getAllAndOverride(ADMIN_ONLY_KEY, [ + context.getHandler(), + context.getClass(), + ]) || false; + + if ( + normalizedRequiredRoles.length === 0 && + normalizedRequiredScopes.length === 0 && + !isAnyAuthenticated && + !isAdminOnly + ) { + throw new ForbiddenException('Authorization metadata is required'); + } + + if (isAnyAuthenticated) { + return true; + } if ( + isAdminOnly && normalizedRequiredRoles.length === 0 && normalizedRequiredScopes.length === 0 ) { @@ -124,6 +218,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.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 fe404e2..2699dde 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, @@ -8,13 +14,38 @@ 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. + */ @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. + * - 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 = []`. + * + * @todo Member query + mapping logic is duplicated in multiple guards. + * Introduce a shared `ProjectContextService` to centralize loading behavior. + */ async intercept( context: ExecutionContext, next: CallHandler, @@ -33,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) @@ -45,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: { @@ -72,6 +105,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..4eae8b7 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,28 @@ export interface ProjectInvite { deletedAt?: Date | null; } +/** + * Per-request project context cache attached to `AuthenticatedRequest`. + */ 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. + */ projectMembers: ProjectMember[]; + /** + * Whether invites were already loaded for the current `projectId`. + */ + projectInvitesLoaded?: boolean; + /** + * Cached project invites for the current project id. + */ projectInvites?: ProjectInvite[]; } 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..98a0202 --- /dev/null +++ b/src/shared/interfaces/project-permission-context.interface.ts @@ -0,0 +1,24 @@ +/** + * 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/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..e92fc4f 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; @@ -17,17 +37,49 @@ type EventBusClient = { }) => Promise; }; +const EVENT_BUS_REQUIRED_ENV_KEYS = [ + 'BUSAPI_URL', + 'KAFKA_ERROR_TOPIC', + '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. + */ 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. + */ 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) { + 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.', ); @@ -52,31 +104,49 @@ 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, ) => 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 { - return busApiFactory({ + // TODO (quality): TOKEN_CACHE_TIME is hardcoded to 900 seconds. Expose as an environment variable (e.g., AUTH0_TOKEN_CACHE_TIME) for operational flexibility. + const client = busApiFactory({ BUSAPI_URL: process.env.BUSAPI_URL, - KAFKA_URL: process.env.KAFKA_URL, - KAFKA_CLIENT_CERT: process.env.KAFKA_CLIENT_CERT, - KAFKA_CLIENT_CERT_KEY: process.env.KAFKA_CLIENT_CERT_KEY, + KAFKA_ERROR_TOPIC: process.env.KAFKA_ERROR_TOPIC, AUTH0_URL: process.env.AUTH0_URL, AUTH0_AUDIENCE: process.env.AUTH0_AUDIENCE, AUTH0_CLIENT_ID: process.env.AUTH0_CLIENT_ID, @@ -84,11 +154,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_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), + 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/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.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 fa3e7de..a576d6d 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -5,24 +5,67 @@ 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'; +/** + * 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. + * + * 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; + /** + * User email extracted from token claims. + */ + email?: 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 +73,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 +102,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 +122,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 +143,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 +200,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 +209,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 +232,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 +260,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 +309,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, @@ -231,17 +335,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; - } - for (const key of Object.keys(payload)) { const lowerKey = key.toLowerCase(); @@ -264,6 +357,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') @@ -277,63 +377,57 @@ export class JwtService implements OnModuleInit { } } - return user; - } - - private extractScopes(payload: JwtPayloadRecord): string[] { - const rawScope = payload.scope || payload.scopes; - - if (typeof rawScope === 'string') { - return rawScope - .split(' ') - .map((scope) => scope.trim()) - .filter((scope) => scope.length > 0); + if ( + (!user.userId || !this.isNumericIdentifier(user.userId)) && + user.handle && + this.isNumericIdentifier(user.handle) + ) { + user.userId = user.handle; } - if (Array.isArray(rawScope)) { - return rawScope - .map((scope) => String(scope).trim()) - .filter((scope) => scope.length > 0); - } - - return []; - } - - 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); + return user; } - 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; + /** + * Extracts token scopes from supported scope-like claims. + * + * @param {JwtPayloadRecord} payload Token payload. + * @returns {string[]} Normalized list of scopes. + */ + private extractScopes(payload: JwtPayloadRecord): string[] { + return extractScopesFromPayload(payload, (scope) => scope.trim()); } + /** + * Extracts a user identifier from common claims. + * + * @param {JwtPayloadRecord} payload Token payload. + * @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]; } + /** + * 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 +444,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 +485,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 +498,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 +506,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..7fdb02f 100644 --- a/src/shared/modules/global/m2m.service.ts +++ b/src/shared/modules/global/m2m.service.ts @@ -4,8 +4,15 @@ import { SCOPE_HIERARCHY, SCOPE_SYNONYMS, } from 'src/shared/enums/scopes.enum'; +import { extractScopesFromPayload } from 'src/shared/utils/scope.utils'; 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 +20,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 +45,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 +80,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,25 +111,27 @@ export class M2MService { }; } + /** + * Extracts scope-like claims from token payload. + * + * @param {TokenPayload} payload Decoded token payload. + * @returns {string[]} Normalized scopes. + */ extractScopes(payload: TokenPayload): string[] { - 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), + ); } + /** + * 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 +172,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 +195,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.spec.ts b/src/shared/services/email.service.spec.ts new file mode 100644 index 0000000..f5876c7 --- /dev/null +++ b/src/shared/services/email.service.spec.ts @@ -0,0 +1,262 @@ +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 }; + process.env.WORK_MANAGER_URL = 'https://work.topcoder-dev.com'; + 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', + { + id: 'inv-100', + email: 'KnownUser@topcoder.com', + firstName: 'John', + lastName: 'Doe', + }, + { + userId: '123', + handle: 'pm', + firstName: 'Jane', + lastName: 'Smith', + }, + '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?: 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).toEqual({ + projectName: 'Demo', + firstName: 'John', + lastName: 'Doe', + projectId: '1001', + joinProjectUrl: + 'https://work.topcoder-dev.com/projects/1001/invitation/accepted?source=email', + declineProjectUrl: + 'https://work.topcoder-dev.com/projects/1001/invitation/refused?source=email', + initiatorFirstName: 'Jane', + initiatorLastName: 'Smith', + }); + }); + + 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', + { + id: 321, + 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?: Record; + }; + + expect(normalizedPayload.sendgrid_template_id).toBe('unknown-template-id'); + 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/invitation/accepted?source=email', + initiatorFirstName: 'Connect', + initiatorLastName: 'User', + }); + }); + + 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', + { + id: 'legacy-1', + email: 'known@topcoder.com', + }, + { + userId: '123', + handle: 'pm', + }, + 'Demo', + { + isSSO: true, + }, + ); + + 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('legacy-template-id'); + expect(normalizedPayload.data?.projects?.[0]?.sections?.[0]?.isSSO).toBe( + true, + ); + }); + + 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', + { + 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', + { + email: 'known@topcoder.com', + }, + { + userId: '123', + handle: 'pm', + }, + 'Demo', + { + isSSO: true, + }, + ); + + 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'; + + 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/invitation/accepted?source=email', + declineProjectUrl: + '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 27cb5c9..8196a07 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 { @@ -18,68 +20,187 @@ export interface InviteEmailInitiator { email?: string; } +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; +} + 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; + 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; +}; + +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. + * + * 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, `invite.id` is missing, or when + * 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`. + * + * @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 + * @param options additional invite-email options + * @returns resolved promise when publish succeeds or is skipped + */ async sendInviteEmail( projectId: string, 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. if (!recipient) { + this.logger.warn( + `Skipping invite email publish for projectId=${projectId}: recipient email is missing.`, + ); return; } - const templateId = process.env.SENDGRID_TEMPLATE_PROJECT_MEMBER_INVITED; + 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 isKnownUser = Boolean(options?.isSSO); + const templateId = this.resolveInviteTemplateId(isKnownUser); if (!templateId) { this.logger.warn( - '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(isKnownUser)}.`, ); 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 joinProjectUrl = this.buildWorkInviteActionUrl( + normalizedProjectId, + 'accepted', + ); + const declineProjectUrl = this.buildWorkInviteActionUrl( + normalizedProjectId, + 'refused', + ); + const rootUrl = this.resolveRootUrl(); + 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 useLegacyPayload = this.isLegacyTemplateId(templateId); + const payload = { - data: { - workManagerUrl: process.env.WORK_MANAGER_URL || '', - accountsAppURL: process.env.ACCOUNTS_APP_URL || '', - subject: - process.env.INVITE_EMAIL_SUBJECT || DEFAULT_INVITE_EMAIL_SUBJECT, - projects: [ - { - name: normalizedProjectName, - projectId, - sections: [ - { - EMAIL_INVITES: true, - title: - process.env.INVITE_EMAIL_SECTION_TITLE || - DEFAULT_INVITE_EMAIL_SECTION_TITLE, - projectName: normalizedProjectName, - projectId, - initiator: this.normalizeInitiator(initiator), - isSSO: false, - }, - ], - }, - ], - }, + data: useLegacyPayload + ? this.buildLegacyInviteTemplatePayload( + normalizedProjectName, + normalizedProjectId, + normalizedInitiator, + isKnownUser, + ) + : isKnownUser + ? knownUserPayload + : unknownUserPayload, sendgrid_template_id: templateId, recipients: [recipient], version: 'v3', }; 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, @@ -92,6 +213,48 @@ 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; + } + + /** + * 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. + * + * @param initiator initiator details from API/service context + * @returns normalized initiator with `'Connect'/'User'` defaults + */ private normalizeInitiator( initiator: InviteEmailInitiator, ): InviteEmailInitiator { @@ -103,4 +266,112 @@ export class EmailService { email: initiator.email, }; } + + /** + * 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. + * + * `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 projectId Project id. + * @param action URL action segment (`accepted` or `refused`). + * @returns Full Work Manager invite action URL built from `WORK_MANAGER_URL`. + */ + private buildWorkInviteActionUrl( + projectId: string, + action: 'accepted' | 'refused', + ): string { + 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`; + } + + /** + * 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; + } } 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.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 42251c8..1c63ee3 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'; @@ -10,20 +11,37 @@ 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 Identity API and, + * when needed, Member API fallback lookups. + */ @Injectable() export class IdentityService { private readonly logger = LoggerService.forRoot('IdentityService'); - private readonly identityApiUrl = process.env.IDENTITY_API_URL || ''; + private readonly identityApiUrl = SERVICE_ENDPOINTS.identityApiUrl; + private readonly memberApiUrl = SERVICE_ENDPOINTS.memberApiUrl; constructor( private readonly httpService: HttpService, private readonly m2mService: M2MService, ) {} + /** + * Looks up identity users for multiple emails. + * + * 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 + */ async lookupMultipleUserEmails( emails: string[] = [], ): Promise { - if (!this.identityApiUrl || emails.length === 0) { + if (emails.length === 0) { return []; } @@ -41,58 +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 memberUsers = + unresolvedEmails.length > 0 + ? await this.lookupUsersFromMemberApi(unresolvedEmails, token) + : []; - const responses = await Promise.all( - normalizedEmails.map((email) => - 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), - ), + 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)}`, ); + 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()}`; - 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 ''; } } diff --git a/src/shared/services/member.service.spec.ts b/src/shared/services/member.service.spec.ts new file mode 100644 index 0000000..a032f7b --- /dev/null +++ b/src/shared/services/member.service.spec.ts @@ -0,0 +1,105 @@ +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); + 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 () => { + 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 b367718..b8f68e0 100644 --- a/src/shared/services/member.service.ts +++ b/src/shared/services/member.service.ts @@ -1,25 +1,59 @@ 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'; 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. + * + * 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'); - private readonly memberApiUrl = process.env.MEMBER_API_URL || ''; - private readonly identityApiUrl = process.env.IDENTITY_API_URL || ''; + // TODO: validate required env vars at startup (e.g., in onModuleInit). + private readonly memberApiUrl = SERVICE_ENDPOINTS.memberApiUrl; + // TODO: validate required env vars at startup (e.g., in onModuleInit). + private readonly identityApiUrl = SERVICE_ENDPOINTS.identityApiUrl; constructor( private readonly httpService: HttpService, 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 +77,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 +106,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 +158,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 []; @@ -154,4 +208,133 @@ export class MemberService { return []; } } + + /** + * Looks up role members from Identity API by role name. + * + * Resolves `/roles?filter=roleName=` and then + * `/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 + * 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` + */ + 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 roleIds = roles + .filter( + (role) => + String(role.roleName || '').toLowerCase() === + normalizedRoleName.toLowerCase(), + ) + .map((role) => String(role.id || '').trim()) + .filter((roleId) => roleId.length > 0); + + if (roleIds.length === 0) { + return []; + } + + const settledRoleDetails = await Promise.allSettled( + roleIds.map(async (roleId) => { + const roleResponse = await firstValueFrom( + this.httpService.get(`${identityApiBaseUrl}/roles/${roleId}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + params: { + selector: 'subjects', + perPage: 200, + }, + }), + ); + + const payload = roleResponse.data as RoleDetailResponse; + return Array.isArray(payload?.subjects) ? payload.subjects : []; + }), + ); + + 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) => { + 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, { + userId: subject.subjectID || subject.userId || null, + handle: subject.handle || null, + email, + }); + }); + + return Array.from(uniqueSubjects.values()); + } catch (error) { + this.logger.warn( + `Failed to fetch role subjects for roleName=${normalizedRoleName}: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + } + } + + async getRoleSubjectsByRoleName(roleName: string): Promise { + return this.getRoleSubjects(roleName); + } } diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index bcdf3d7..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) => @@ -204,6 +222,184 @@ 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 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 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, + { + 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], + isMachine: true, + }); + + 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('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) => { + 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 072b7d0..b1558f5 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, @@ -34,6 +55,8 @@ export class PermissionService { return false; } + const machineContext = this.resolveMachineContext(user); + if (permissionRule.projectRoles && projectMembers) { const member = this.getProjectMember(user.userId, projectMembers); @@ -66,7 +89,7 @@ export class PermissionService { if (permissionRule.scopes) { hasScope = this.m2mService.hasRequiredScopes( - user.scopes || [], + machineContext.scopes, permissionRule.scopes, ); } @@ -74,6 +97,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 +125,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, @@ -102,12 +151,16 @@ 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. const isAdmin = this.hasIntersection(user.roles || [], [ ...ADMIN_ROLES, UserRole.MANAGER, @@ -115,9 +168,49 @@ 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( + effectiveScopes, + [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECTS_ALL, + Scope.PROJECTS_READ, + Scope.PROJECTS_WRITE, + ], + ); + const hasProjectWriteScope = this.m2mService.hasRequiredScopes( + effectiveScopes, + [Scope.CONNECT_PROJECT_ADMIN, Scope.PROJECTS_ALL, Scope.PROJECTS_WRITE], + ); + const hasMachineProjectWriteScope = Boolean( + machineContext.isMachine && hasProjectWriteScope, + ); + const hasProjectMemberReadScope = this.m2mService.hasRequiredScopes( + effectiveScopes, + [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECT_MEMBERS_ALL, + Scope.PROJECT_MEMBERS_READ, + ], + ); + const hasProjectInviteReadScope = this.m2mService.hasRequiredScopes( + effectiveScopes, + [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECT_INVITES_ALL, + Scope.PROJECT_INVITES_READ, + ], + ); + const hasProjectInviteWriteScope = this.m2mService.hasRequiredScopes( + effectiveScopes, + [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECT_INVITES_ALL, + Scope.PROJECT_INVITES_WRITE, + ], + ); const member = this.getProjectMember(user.userId, projectMembers); const hasProjectMembership = Boolean(member); @@ -134,32 +227,50 @@ 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. 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) || + this.hasProjectUpdateTopcoderRole(user) || + hasMachineProjectWriteScope + ); case NamedPermission.DELETE_PROJECT: return ( isAdmin || + hasMachineProjectWriteScope || Boolean( member && this.normalizeRole(member.role) === @@ -167,8 +278,9 @@ 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; @@ -189,20 +301,30 @@ export class PermissionService { this.hasCopilotManagerRole(user) ); + // Project invite read/write permissions. case NamedPermission.READ_PROJECT_INVITE_OWN: 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: @@ -211,24 +333,34 @@ 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. case NamedPermission.MANAGE_PROJECT_BILLING_ACCOUNT_ID: case NamedPermission.MANAGE_PROJECT_DIRECT_PROJECT_ID: return isAdmin; @@ -238,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, ]) @@ -249,18 +381,21 @@ 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, ]) ); + // Copilot opportunity permissions. 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]); @@ -271,6 +406,7 @@ export class PermissionService { UserRole.CONNECT_ADMIN, ]); + // Project attachment permissions. case NamedPermission.VIEW_PROJECT_ATTACHMENT: return isAdmin || hasProjectMembership; @@ -287,6 +423,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 +448,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 +460,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 +482,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 +539,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 +566,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 +595,37 @@ 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. + * + * @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 +641,84 @@ 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(); } + /** + * 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. + * + * @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 +737,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 +753,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 +770,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, @@ -486,4 +796,18 @@ 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, + UserRole.PROJECT_MANAGER, + ]); + } } 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 474b2c4..769fad9 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,30 +17,74 @@ 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; +type ErrorLogger = { + error: (message: string, trace?: string) => 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, - KAFKA_URL: process.env.KAFKA_URL, - KAFKA_CLIENT_CERT: process.env.KAFKA_CLIENT_CERT, - KAFKA_CLIENT_CERT_KEY: process.env.KAFKA_CLIENT_CERT_KEY, + KAFKA_ERROR_TOPIC: process.env.KAFKA_ERROR_TOPIC, AUTH0_URL: process.env.AUTH0_URL, AUTH0_AUDIENCE: process.env.AUTH0_AUDIENCE, AUTH0_CLIENT_ID: process.env.AUTH0_CLIENT_ID, @@ -42,14 +94,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 +140,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 +153,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 +184,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 +245,9 @@ export async function getBusApiClient(): Promise { ); } +/** + * Creates an outbound BUS API event envelope. + */ function createBusApiEvent(topic: string, payload: unknown): BusApiEvent { return { topic, @@ -169,6 +258,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, @@ -221,6 +320,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', @@ -228,6 +333,9 @@ function buildProjectEventPayload(project: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.member', data }` payload. + */ function buildMemberEventPayload(member: unknown): unknown { return { resource: 'project.member', @@ -235,6 +343,9 @@ function buildMemberEventPayload(member: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.member.invite', data }` payload. + */ function buildInviteEventPayload(invite: unknown): unknown { return { resource: 'project.member.invite', @@ -242,6 +353,9 @@ function buildInviteEventPayload(invite: unknown): unknown { }; } +/** + * Builds `{ resource: 'attachment', data }` payload. + */ function buildAttachmentEventPayload(attachment: unknown): unknown { return { resource: 'attachment', @@ -249,6 +363,9 @@ function buildAttachmentEventPayload(attachment: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.phase', data }` payload. + */ function buildPhaseEventPayload(phase: unknown): unknown { return { resource: 'project.phase', @@ -256,6 +373,9 @@ function buildPhaseEventPayload(phase: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.phase.product', data }` payload. + */ function buildPhaseProductEventPayload(phaseProduct: unknown): unknown { return { resource: 'project.phase.product', @@ -263,6 +383,9 @@ function buildPhaseProductEventPayload(phaseProduct: unknown): unknown { }; } +/** + * Builds `{ resource: 'timeline', data }` payload. + */ function buildTimelineEventPayload(timeline: unknown): unknown { return { resource: 'timeline', @@ -270,6 +393,9 @@ function buildTimelineEventPayload(timeline: unknown): unknown { }; } +/** + * Builds `{ resource: 'milestone', data }` payload. + */ function buildMilestoneEventPayload(milestone: unknown): unknown { return { resource: 'milestone', @@ -277,6 +403,9 @@ function buildMilestoneEventPayload(milestone: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.workstream', data }` payload. + */ function buildWorkstreamEventPayload(workstream: unknown): unknown { return { resource: 'project.workstream', @@ -284,6 +413,9 @@ function buildWorkstreamEventPayload(workstream: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.work', data }` payload. + */ function buildWorkEventPayload(work: unknown): unknown { return { resource: 'project.work', @@ -291,6 +423,9 @@ function buildWorkEventPayload(work: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.workitem', data }` payload. + */ function buildWorkItemEventPayload(workItem: unknown): unknown { return { resource: 'project.workitem', @@ -298,6 +433,9 @@ function buildWorkItemEventPayload(workItem: unknown): unknown { }; } +/** + * Builds `{ resource: 'project.setting', data }` payload. + */ function buildSettingEventPayload(setting: unknown): unknown { return { resource: 'project.setting', @@ -305,6 +443,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, @@ -324,6 +472,9 @@ export async function publishProjectEvent( } } +/** + * Publishes a project-member event envelope. + */ export async function publishMemberEvent( topic: string, member: unknown, @@ -343,6 +494,41 @@ 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), + ); + }); +} + +/** + * 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. + */ export async function publishInviteEvent( topic: string, invite: unknown, @@ -362,6 +548,9 @@ export async function publishInviteEvent( } } +/** + * Publishes an attachment event envelope. + */ export async function publishAttachmentEvent( topic: string, attachment: unknown, @@ -381,6 +570,9 @@ export async function publishAttachmentEvent( } } +/** + * Publishes a project-phase event envelope. + */ export async function publishPhaseEvent( topic: string, phase: unknown, @@ -400,6 +592,9 @@ export async function publishPhaseEvent( } } +/** + * Publishes a phase-product event envelope. + */ export async function publishPhaseProductEvent( topic: string, phaseProduct: unknown, @@ -419,6 +614,9 @@ export async function publishPhaseProductEvent( } } +/** + * Publishes a timeline event envelope. + */ export async function publishTimelineEvent( topic: string, timeline: unknown, @@ -438,6 +636,9 @@ export async function publishTimelineEvent( } } +/** + * Publishes a milestone event envelope. + */ export async function publishMilestoneEvent( topic: string, milestone: unknown, @@ -457,6 +658,9 @@ export async function publishMilestoneEvent( } } +/** + * Publishes a workstream event envelope. + */ export async function publishWorkstreamEvent( topic: string, workstream: unknown, @@ -476,6 +680,9 @@ export async function publishWorkstreamEvent( } } +/** + * Publishes a work event envelope. + */ export async function publishWorkEvent( topic: string, work: unknown, @@ -495,6 +702,9 @@ export async function publishWorkEvent( } } +/** + * Publishes a work-item event envelope. + */ export async function publishWorkItemEvent( topic: string, workItem: unknown, @@ -514,6 +724,9 @@ export async function publishWorkItemEvent( } } +/** + * Publishes a project-setting event envelope. + */ export async function publishSettingEvent( topic: string, setting: unknown, @@ -533,6 +746,9 @@ export async function publishSettingEvent( } } +/** + * Publishes a raw notification payload without resource wrapping. + */ export async function publishNotificationEvent( topic: string, payload: unknown, @@ -552,6 +768,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/member.utils.ts b/src/shared/utils/member.utils.ts index 82aed46..0b4fd95 100644 --- a/src/shared/utils/member.utils.ts +++ b/src/shared/utils/member.utils.ts @@ -1,16 +1,28 @@ +/** + * 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; + handleLower?: string | null; email?: string | null; firstName?: string | null; lastName?: string | null; }; +/** + * Project member shape accepted by enrichment helpers. + */ export type ProjectMemberLike = { id?: string | number | bigint; userId?: string | number | bigint; @@ -20,6 +32,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 +45,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 +65,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 +83,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 +107,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 +136,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 +154,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 +179,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 +201,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 +251,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 +301,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-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/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.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 be09c7f..0268baf 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,70 @@ const DEFAULT_FIELDS: ParsedProjectFields = { raw: [], }; +/** + * Normalizes string comparisons. + * + * @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(); } +/** + * 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. + * + * @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, ): string { @@ -33,6 +105,53 @@ 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. + */ function parseCsv(value: string): string[] { return value .split(',') @@ -40,6 +159,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 +202,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 +215,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 +235,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 +257,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 +289,21 @@ 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`: 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. + * 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, user: JwtUser, @@ -221,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, }); } } @@ -300,28 +466,50 @@ 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, + }); + } else { + appendAndCondition(where, { + id: -1n, }); } } @@ -329,6 +517,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 { @@ -372,8 +565,17 @@ export function buildProjectIncludeClause( type InviteLike = { userId?: string | number | bigint | null; + email?: string | null; }; +/** + * Filters project invites according to permission flags. + * + * Returns: + * - all invites when `hasReadAll` is true, + * - only own invites (by `userId` or `email`) when `hasReadOwn` is true, + * - empty list otherwise. + */ export function filterInvitesByPermission( invites: T[] | undefined, user: JwtUser, @@ -393,13 +595,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; }); } @@ -413,6 +621,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/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.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 new file mode 100644 index 0000000..0548b3e --- /dev/null +++ b/src/shared/utils/scope.utils.ts @@ -0,0 +1,52 @@ +/** + * Normalizer function for individual scope strings. + */ +type ScopeNormalizer = (scope: string) => string; + +type ScopePayload = { + scope?: unknown; + scopes?: unknown; + scp?: unknown; + permissions?: unknown; +}; + +/** + * 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 extractedScopes: string[] = []; + + 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(rawScope)) { + extractedScopes.push( + ...rawScope + .map((scope) => normalizeScope(String(scope))) + .filter((scope) => scope.length > 0), + ); + } + } + + return Array.from(new Set(extractedScopes)); +} diff --git a/src/shared/utils/service.utils.ts b/src/shared/utils/service.utils.ts new file mode 100644 index 0000000..9a72309 --- /dev/null +++ b/src/shared/utils/service.utils.ts @@ -0,0 +1,350 @@ +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); +} + +/** + * 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.'); + } + + return String(user.userId).trim(); +} + +/** + * 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.'); + } + + return parsedUserId; +} + +/** + * 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 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; + } + + 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; +} 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 d57b9c4..204fc5e 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, @@ -9,7 +16,12 @@ 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'; +import { UserRole } from '../enums/userRole.enum'; +import { getRequiredPermissionsDocumentation } from './permission-docs.utils'; type SwaggerOperation = { description?: string; @@ -28,6 +40,13 @@ 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[]`. + */ function parseStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -38,16 +57,20 @@ function parseStringArray(value: unknown): string[] { .filter((entry) => entry.length > 0); } -function parsePermissionArray(value: unknown): string[] { +/** + * Parses required-permission extension values from Swagger metadata. + */ +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[]; } +/** + * Stringifies a permission key or inline permission object. + */ function stringifyPermission(permission: RequiredPermission): string { if (typeof permission === 'string') { return permission; @@ -56,6 +79,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 +109,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,12 +124,46 @@ 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. + */ function getAuthorizationLines(operation: SwaggerOperation): string[] { const roles = parseStringArray(operation[SWAGGER_REQUIRED_ROLES_KEY]); const scopes = parseStringArray(operation[SWAGGER_REQUIRED_SCOPES_KEY]); - const permissions = parsePermissionArray( + const isAnyAuthenticated = Boolean(operation[SWAGGER_ANY_AUTHENTICATED_KEY]); + 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], @@ -107,8 +173,13 @@ function getAuthorizationLines(operation: SwaggerOperation): string[] { ); const authorizationLines: string[] = []; + const hasAllKnownUserRoles = isAllKnownUserRoles(roles); - if (roles.length > 0) { + if (isAnyAuthenticated || hasAllKnownUserRoles) { + authorizationLines.push('Any authenticated user token is allowed.'); + } + + if (roles.length > 0 && !hasAllKnownUserRoles) { authorizationLines.push(`Allowed user roles (any): ${roles.join(', ')}`); } @@ -116,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(', ')}`, ); } @@ -140,6 +243,26 @@ 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. + * + * 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 { @@ -150,6 +273,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..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,6 +484,54 @@ describe('Copilot endpoints (e2e)', () => { .expect(200); }); + it('allows m2m token with all:projects scope to list copilot requests', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.PROJECTS_ALL], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + permissions: [Scope.PROJECTS_ALL], + }, + }); + + await request(app.getHttpServer()) + .get('/v6/projects/copilots/requests') + .set('Authorization', 'Bearer m2m-token') + .expect(200); + }); + + it('allows m2m token with all:projects scope to create copilot requests', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.PROJECTS_ALL], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + permissions: [Scope.PROJECTS_ALL], + }, + }); + + 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') diff --git a/test/deployment-validation.e2e-spec.ts b/test/deployment-validation.e2e-spec.ts index c262885..a346bed 100644 --- a/test/deployment-validation.e2e-spec.ts +++ b/test/deployment-validation.e2e-spec.ts @@ -128,7 +128,8 @@ describe('Deployment validation', () => { 'AUTH0_AUDIENCE', 'AUTH0_CLIENT_ID', 'AUTH0_CLIENT_SECRET', - 'KAFKA_URL', + 'BUSAPI_URL', + 'KAFKA_ERROR_TOPIC', ]; const missing = required.filter((key) => !process.env[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') 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') 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, + }), + ); }); });