diff --git a/.circleci/config.yml b/.circleci/config.yml index 659b7dd..a000aa1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -50,7 +50,7 @@ jobs: environment: DEPLOY_ENV: "DEV" LOGICAL_ENV: "dev" - APPNAME: "project-service-v6" + APPNAME: "projects-api-v6" DEPLOYMENT_ENVIRONMENT: "dev" steps: *builddeploy_steps @@ -59,7 +59,7 @@ jobs: environment: DEPLOY_ENV: "PROD" LOGICAL_ENV: "prod" - APPNAME: "project-service-v6" + APPNAME: "projects-api-v6" DEPLOYMENT_ENVIRONMENT: "prod" steps: *builddeploy_steps @@ -108,7 +108,7 @@ workflows: filters: branches: only: - - develop + - dev - deployment-validation-dev: context: org-global requires: @@ -116,7 +116,7 @@ workflows: filters: branches: only: - - develop + - dev build-prod: jobs: diff --git a/.env.example b/.env.example index def40fa..9a16c6b 100644 --- a/.env.example +++ b/.env.example @@ -18,98 +18,12 @@ KAFKA_CLIENT_CERT="" KAFKA_CLIENT_CERT_KEY="" BUSAPI_URL="https://api.topcoder-dev.com/v5" -# Project resource topics -KAFKA_PROJECT_DRAFT_CREATED_TOPIC="project.draft.created" +# Project event topics (only active topics) +KAFKA_PROJECT_CREATED_TOPIC="project.created" KAFKA_PROJECT_UPDATED_TOPIC="project.updated" KAFKA_PROJECT_DELETED_TOPIC="project.deleted" -KAFKA_PROJECT_STATUS_CHANGED_TOPIC="project.status.changed" - -# Project notification topics -KAFKA_PROJECT_CREATED_TOPIC="connect.notification.project.created" -KAFKA_PROJECT_UPDATED_NOTIFICATION_TOPIC="connect.notification.project.updated" -KAFKA_PROJECT_SUBMITTED_FOR_REVIEW_TOPIC="connect.notification.project.submittedForReview" -KAFKA_PROJECT_APPROVED_TOPIC="connect.notification.project.approved" -KAFKA_PROJECT_ACTIVE_TOPIC="connect.notification.project.active" -KAFKA_PROJECT_PAUSED_TOPIC="connect.notification.project.paused" -KAFKA_PROJECT_COMPLETED_TOPIC="connect.notification.project.completed" -KAFKA_PROJECT_CANCELED_TOPIC="connect.notification.project.canceled" -KAFKA_PROJECT_SPECIFICATION_MODIFIED_TOPIC="connect.notification.project.updated.spec" -KAFKA_PROJECT_LINK_CREATED_TOPIC="connect.notification.project.linkCreated" -KAFKA_PROJECT_PLAN_UPDATED_TOPIC="connect.notification.project.plan.updated" -KAFKA_PROJECT_PLAN_READY_TOPIC="connect.notification.project.plan.ready" -KAFKA_PROJECT_BILLING_ACCOUNT_UPDATED_TOPIC="connect.notification.project.billingAccount.updated" - -# Member and invite topics KAFKA_PROJECT_MEMBER_ADDED_TOPIC="project.member.added" -KAFKA_PROJECT_MEMBER_UPDATED_TOPIC="project.member.updated" KAFKA_PROJECT_MEMBER_REMOVED_TOPIC="project.member.removed" -KAFKA_PROJECT_MEMBER_INVITE_CREATED_TOPIC="project.member.invite.created" -KAFKA_PROJECT_MEMBER_INVITE_UPDATED_TOPIC="project.member.invite.updated" -KAFKA_PROJECT_MEMBER_INVITE_REMOVED_TOPIC="project.member.invite.deleted" -KAFKA_MEMBER_JOINED_TOPIC="connect.notification.project.member.joined" -KAFKA_MEMBER_JOINED_COPILOT_TOPIC="connect.notification.project.member.copilotJoined" -KAFKA_MEMBER_JOINED_MANAGER_TOPIC="connect.notification.project.member.managerJoined" -KAFKA_MEMBER_LEFT_TOPIC="connect.notification.project.member.left" -KAFKA_MEMBER_REMOVED_TOPIC="connect.notification.project.member.removed" -KAFKA_MEMBER_ASSIGNED_AS_OWNER_TOPIC="connect.notification.project.member.assignedAsOwner" -KAFKA_PROJECT_TEAM_UPDATED_TOPIC="connect.notification.project.team.updated" - -# Attachment topics -KAFKA_PROJECT_ATTACHMENT_ADDED_TOPIC="project.attachment.added" -KAFKA_PROJECT_ATTACHMENT_UPDATED_TOPIC="project.attachment.updated" -KAFKA_PROJECT_ATTACHMENT_REMOVED_TOPIC="project.attachment.removed" -KAFKA_PROJECT_FILE_UPLOADED_TOPIC="connect.notification.project.fileUploaded" -KAFKA_PROJECT_ATTACHMENT_UPDATED_NOTIFICATION_TOPIC="connect.notification.project.attachment.updated" - -# Phase, work, and product topics -KAFKA_PROJECT_PHASE_ADDED_TOPIC="project.phase.added" -KAFKA_PROJECT_PHASE_UPDATED_TOPIC="project.phase.updated" -KAFKA_PROJECT_PHASE_REMOVED_TOPIC="project.phase.removed" -KAFKA_PROJECT_PHASE_PRODUCT_ADDED_TOPIC="project.phase.product.added" -KAFKA_PROJECT_PHASE_PRODUCT_UPDATED_TOPIC="project.phase.product.updated" -KAFKA_PROJECT_PHASE_PRODUCT_REMOVED_TOPIC="project.phase.product.removed" -KAFKA_PROJECT_PHASE_TRANSITION_ACTIVE_TOPIC="connect.notification.project.phase.transition.active" -KAFKA_PROJECT_PHASE_TRANSITION_COMPLETED_TOPIC="connect.notification.project.phase.transition.completed" -KAFKA_PROJECT_PHASE_UPDATE_PAYMENT_TOPIC="connect.notification.project.phase.update.payment" -KAFKA_PROJECT_PHASE_UPDATE_PROGRESS_TOPIC="connect.notification.project.phase.update.progress" -KAFKA_PROJECT_PHASE_UPDATE_SCOPE_TOPIC="connect.notification.project.phase.update.scope" -KAFKA_PROJECT_PRODUCT_SPECIFICATION_MODIFIED_TOPIC="connect.notification.project.product.update.spec" -KAFKA_PROJECT_WORKSTREAM_ADDED_TOPIC="project.workstream.added" -KAFKA_PROJECT_WORKSTREAM_UPDATED_TOPIC="project.workstream.updated" -KAFKA_PROJECT_WORKSTREAM_REMOVED_TOPIC="project.workstream.removed" -KAFKA_PROJECT_WORK_ADDED_TOPIC="project.work.added" -KAFKA_PROJECT_WORK_UPDATED_TOPIC="project.work.updated" -KAFKA_PROJECT_WORK_REMOVED_TOPIC="project.work.removed" -KAFKA_PROJECT_WORKITEM_ADDED_TOPIC="project.workitem.added" -KAFKA_PROJECT_WORKITEM_UPDATED_TOPIC="project.workitem.updated" -KAFKA_PROJECT_WORKITEM_REMOVED_TOPIC="project.workitem.removed" -KAFKA_PROJECT_WORK_TRANSITION_ACTIVE_TOPIC="connect.notification.project.work.transition.active" -KAFKA_PROJECT_WORK_TRANSITION_COMPLETED_TOPIC="connect.notification.project.work.transition.completed" -KAFKA_PROJECT_WORK_UPDATE_PAYMENT_TOPIC="connect.notification.project.work.update.payment" -KAFKA_PROJECT_WORK_UPDATE_PROGRESS_TOPIC="connect.notification.project.work.update.progress" -KAFKA_PROJECT_WORK_UPDATE_SCOPE_TOPIC="connect.notification.project.work.update.scope" -KAFKA_PROJECT_WORKITEM_SPECIFICATION_MODIFIED_TOPIC="connect.notification.project.workitem.update.spec" - -# Timeline and milestone topics -KAFKA_TIMELINE_ADDED_TOPIC="timeline.added" -KAFKA_TIMELINE_UPDATED_TOPIC="timeline.updated" -KAFKA_TIMELINE_REMOVED_TOPIC="timeline.removed" -KAFKA_MILESTONE_ADDED_TOPIC="milestone.added" -KAFKA_MILESTONE_UPDATED_TOPIC="milestone.updated" -KAFKA_MILESTONE_REMOVED_TOPIC="milestone.removed" -KAFKA_TIMELINE_ADJUSTED_TOPIC="connect.notification.project.timeline.adjusted" -KAFKA_MILESTONE_NOTIFICATION_ADDED_TOPIC="connect.notification.project.timeline.milestone.added" -KAFKA_MILESTONE_NOTIFICATION_UPDATED_TOPIC="connect.notification.project.timeline.milestone.updated" -KAFKA_MILESTONE_NOTIFICATION_REMOVED_TOPIC="connect.notification.project.timeline.milestone.removed" -KAFKA_MILESTONE_TRANSITION_ACTIVE_TOPIC="connect.notification.project.timeline.milestone.transition.active" -KAFKA_MILESTONE_TRANSITION_COMPLETED_TOPIC="connect.notification.project.timeline.milestone.transition.completed" -KAFKA_MILESTONE_TRANSITION_PAUSED_TOPIC="connect.notification.project.timeline.milestone.transition.paused" -KAFKA_MILESTONE_WAITING_CUSTOMER_TOPIC="connect.notification.project.timeline.milestone.waiting.customer" - -# Project setting topics -KAFKA_PROJECT_SETTING_CREATED_TOPIC="project.setting.created" -KAFKA_PROJECT_SETTING_UPDATED_TOPIC="project.setting.updated" -KAFKA_PROJECT_SETTING_DELETED_TOPIC="project.setting.deleted" # Attachment and phase-product configuration ATTACHMENTS_S3_BUCKET="topcoder-dev-media" @@ -122,6 +36,19 @@ ENABLE_FILE_UPLOAD=true MEMBER_API_URL="" IDENTITY_API_URL="" +# Salesforce Billing Account integration +SALESFORCE_CLIENT_ID="" +SALESFORCE_CLIENT_AUDIENCE="https://login.salesforce.com" +# Legacy alias used in tc-project-service config mapping: +# SALESFORCE_AUDIENCE can be used instead of SALESFORCE_CLIENT_AUDIENCE. +SALESFORCE_SUBJECT="" +SALESFORCE_CLIENT_KEY="" +SALESFORCE_LOGIN_BASE_URL="https://login.salesforce.com" +SALESFORCE_API_VERSION="v37.0" +SFDC_BILLING_ACCOUNT_NAME_FIELD="Billing_Account_name__c" +SFDC_BILLING_ACCOUNT_MARKUP_FIELD="Mark_Up__c" +SFDC_BILLING_ACCOUNT_ACTIVE_FIELD="Active__c" + # Invite notification templates and links INVITE_EMAIL_SUBJECT="" INVITE_EMAIL_SECTION_TITLE="" diff --git a/Dockerfile b/Dockerfile index 4f3d261..8fb6467 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,4 +17,4 @@ RUN npm install pnpm -g RUN pnpm install RUN pnpm run build RUN chmod +x appStartUp.sh -CMD ./appStartUp.sh +CMD ./appStartUp.sh \ No newline at end of file diff --git a/LISTENERS.md b/LISTENERS.md new file mode 100644 index 0000000..b146596 --- /dev/null +++ b/LISTENERS.md @@ -0,0 +1,31 @@ +# Kafka Listener Audit for `projects-api-v6` Topics + +Date: 2026-02-08 +Scope: all top-level services/apps in this monorepo. +Excluded per request: `projects-api-v6`, `tc-project-service`. + +## Active topics in `projects-api-v6/.env.example` + +- `project.created` +- `project.updated` +- `project.deleted` +- `project.member.added` +- `project.member.removed` + +## Confirmed listeners (outside excluded services) + +No non-excluded service in this monorepo statically subscribes to these topics. + +## Topic-by-topic matrix + +| Env Variable | Topic | Confirmed Listener(s) | Confirmed Usage | +|---|---|---|---| +| `KAFKA_PROJECT_CREATED_TOPIC` | `project.created` | None found | N/A | +| `KAFKA_PROJECT_UPDATED_TOPIC` | `project.updated` | 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 | + +## Runtime caveat + +`tc-email-service` subscribes dynamically via runtime `TEMPLATE_MAP` keys. Those values are not committed in this monorepo, so runtime subscriptions cannot be proven from source alone. diff --git a/appStartUp.sh b/appStartUp.sh index a8fd3cd..c60b4b9 100755 --- a/appStartUp.sh +++ b/appStartUp.sh @@ -3,14 +3,5 @@ set -eo pipefail export DATABASE_URL=$(echo -e ${DATABASE_URL}) -echo "Database - running migrations." -if [ "$RESET_DB" = "true" ]; then - echo "Resetting DB" - npx prisma migrate reset --force -else - echo "Running migrations" - npx prisma migrate deploy -fi - # Start the app pnpm start:prod diff --git a/build.sh b/build.sh index 21301b5..9c09f8f 100755 --- a/build.sh +++ b/build.sh @@ -1,3 +1,3 @@ #!/bin/bash set -eo pipefail -docker buildx build --no-cache=true --build-arg RESET_DB_ARG=false --build-arg SEED_DATA_ARG=${DEPLOYMENT_ENVIRONMENT:-dev} -t project-service-v6:latest . +docker buildx build --no-cache=true --build-arg RESET_DB_ARG=false --build-arg SEED_DATA_ARG=false -t project-service-v6:latest . diff --git a/docs/event-schemas.md b/docs/event-schemas.md index c022ce2..ee926f7 100644 --- a/docs/event-schemas.md +++ b/docs/event-schemas.md @@ -1,219 +1,41 @@ # Event Schemas -This document describes event topics emitted by `project-service-v6`. +As of 2026-02-08, `projects-api-v6` publishes only these Kafka topics: -## Event Types +- `project.created` +- `project.updated` +- `project.deleted` +- `project.member.added` +- `project.member.removed` -- `BUS API resource events`: payload format `{ resource, data }` -- `Notification events`: flat payload for downstream notification consumers +## Envelope -## BUS API Resource Event Shape +All events are published through the event bus with this envelope shape: ```json { - "topic": "project.phase.updated", + "topic": "project.updated", "originator": "project-service-v6", - "timestamp": "2026-02-07T12:00:00.000Z", + "timestamp": "2026-02-08T00:00:00.000Z", "mime-type": "application/json", "payload": { - "resource": "project.phase", - "data": {} + "resource": "project", + "data": { + "id": "1001" + } } } ``` -## Notification Event Shape +## Resource Mapping -```json -{ - "topic": "connect.notification.project.updated", - "originator": "project-service-v6", - "timestamp": "2026-02-07T12:00:00.000Z", - "mime-type": "application/json", - "payload": { - "projectId": "1001", - "projectName": "Demo Project", - "projectUrl": "https://platform.topcoder.com/connect/projects/1001", - "userId": "123", - "initiatorUserId": "123" - } -} -``` - -## Topics By Category - -### Project -- `project.draft.created` -- `project.updated` -- `project.deleted` -- `project.status.changed` -- `connect.notification.project.created` -- `connect.notification.project.updated` -- `connect.notification.project.submittedForReview` -- `connect.notification.project.approved` -- `connect.notification.project.active` -- `connect.notification.project.paused` -- `connect.notification.project.completed` -- `connect.notification.project.canceled` -- `connect.notification.project.updated.spec` -- `connect.notification.project.linkCreated` -- `connect.notification.project.billingAccount.updated` -- `connect.notification.project.plan.updated` -- `connect.notification.project.plan.ready` - -### Member -- `project.member.added` -- `project.member.updated` -- `project.member.removed` -- `connect.notification.project.member.joined` -- `connect.notification.project.member.copilotJoined` -- `connect.notification.project.member.managerJoined` -- `connect.notification.project.member.left` -- `connect.notification.project.member.removed` -- `connect.notification.project.member.assignedAsOwner` -- `connect.notification.project.team.updated` +- `project.created` -> `resource: "project"` +- `project.updated` -> `resource: "project"` +- `project.deleted` -> `resource: "project"` +- `project.member.added` -> `resource: "project.member"` +- `project.member.removed` -> `resource: "project.member"` -### Invite -- `project.member.invite.created` -- `project.member.invite.updated` -- `project.member.invite.deleted` -- `connect.notification.project.member.invite.sent` -- `connect.notification.project.member.invite.accepted` +## Notes -### Attachment -- `project.attachment.added` -- `project.attachment.updated` -- `project.attachment.removed` -- `connect.notification.project.fileUploaded` -- `connect.notification.project.attachment.updated` -- `connect.notification.project.linkCreated` - -### Phase / Work -- `project.phase.added` -- `project.phase.updated` -- `project.phase.removed` -- `project.work.added` -- `project.work.updated` -- `project.work.removed` -- `connect.notification.project.phase.transition.active` -- `connect.notification.project.phase.transition.completed` -- `connect.notification.project.phase.update.payment` -- `connect.notification.project.phase.update.progress` -- `connect.notification.project.phase.update.scope` -- `connect.notification.project.work.transition.active` -- `connect.notification.project.work.transition.completed` -- `connect.notification.project.work.update.payment` -- `connect.notification.project.work.update.progress` -- `connect.notification.project.work.update.scope` - -### Phase Product / Work Item -- `project.phase.product.added` -- `project.phase.product.updated` -- `project.phase.product.removed` -- `project.workitem.added` -- `project.workitem.updated` -- `project.workitem.removed` -- `connect.notification.project.product.update.spec` -- `connect.notification.project.workitem.update.spec` - -### Workstream -- `project.workstream.added` -- `project.workstream.updated` -- `project.workstream.removed` - -### Timeline / Milestone -- `timeline.added` -- `timeline.updated` -- `timeline.removed` -- `milestone.added` -- `milestone.updated` -- `milestone.removed` -- `connect.notification.project.timeline.adjusted` -- `connect.notification.project.timeline.milestone.added` -- `connect.notification.project.timeline.milestone.updated` -- `connect.notification.project.timeline.milestone.removed` -- `connect.notification.project.timeline.milestone.transition.active` -- `connect.notification.project.timeline.milestone.transition.completed` -- `connect.notification.project.timeline.milestone.transition.paused` -- `connect.notification.project.timeline.milestone.waiting.customer` - -### Project Setting -- `project.setting.created` -- `project.setting.updated` -- `project.setting.deleted` - -## Payload Field Notes - -- `projectId`: string id of the project. -- `projectName`: current project name at event time. -- `projectUrl`: work-manager URL for the project. -- `userId`: actor user id from JWT. -- `initiatorUserId`: same as `userId` for internal service operations. -- `refCode`: optional marketing/reference code from `project.details.utm.code`. -- `inviteId`: string id of the invite. -- `memberId`: string id of the created/resolved project member. -- `role`: project role for the invite/member. -- `email`: invite target email when available. -- `handle`: invite target handle when available. - -## Old -> New Mapping (tc-project-service to project-service-v6) - -| Old Event | New Topic | -|---|---| -| `connect.notification.project.submittedForReview` | `connect.notification.project.submittedForReview` | -| `connect.notification.project.approved` | `connect.notification.project.approved` | -| `connect.notification.project.updated.spec` | `connect.notification.project.updated.spec` | -| `connect.notification.project.team.updated` | `connect.notification.project.team.updated` | -| `connect.notification.project.timeline.adjusted` | `connect.notification.project.timeline.adjusted` | - -## Sequence: Create Project - -```mermaid -sequenceDiagram - participant API as API - participant Service as ProjectService - participant DB as Database - participant Bus as BUS API - API->>Service: createProject - Service->>DB: create project + owner member - DB-->>Service: project created - Service->>Bus: project.draft.created (resource event) - Service->>Bus: connect.notification.project.created - Service-->>API: response -``` - -## Sequence: Add Member - -```mermaid -sequenceDiagram - participant API as API - participant Service as ProjectMemberService - participant DB as Database - participant Bus as BUS API - API->>Service: addMember - Service->>DB: create member - DB-->>Service: member created - Service->>Bus: project.member.added (resource event) - Service->>Bus: connect.notification.project.member.* - Service->>Bus: connect.notification.project.team.updated - Service-->>API: response -``` - -## Sequence: Update Phase - -```mermaid -sequenceDiagram - participant API as API - participant Service as ProjectPhaseService - participant DB as Database - participant Bus as BUS API - API->>Service: updatePhase - Service->>DB: update phase - DB-->>Service: updated phase - Service->>Bus: project.phase.updated - Service->>Bus: project.work.updated - Service->>Bus: connect.notification.project.phase.* - Service->>Bus: connect.notification.project.work.* - Service->>Bus: connect.notification.project.plan.updated - Service-->>API: response -``` +- Legacy non-core event topics are removed from `projects-api-v6`. +- Metadata event publishing is currently disabled. diff --git a/package.json b/package.json index 0b19414..c7352ce 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.0.3", - "@prisma/client": "^6.17.1", + "@prisma/adapter-pg": "7.3.0", + "@prisma/client": "7.3.0", "@types/jsonwebtoken": "^9.0.9", "axios": "^1.9.0", "class-transformer": "^0.5.1", @@ -69,7 +70,7 @@ "globals": "^15.14.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^6.17.1", + "prisma": "7.3.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2711d31..02c608b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,12 @@ importers: '@nestjs/swagger': 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 '@prisma/client': - specifier: ^6.17.1 - version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) + 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 @@ -142,8 +145,8 @@ importers: specifier: ^3.4.2 version: 3.8.1 prisma: - specifier: ^6.17.1 - version: 6.19.2(typescript@5.9.3) + 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) source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -547,6 +550,18 @@ packages: '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@chevrotain/cst-dts-gen@10.5.0': + resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} + + '@chevrotain/gast@10.5.0': + resolution: {integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==} + + '@chevrotain/types@10.5.0': + resolution: {integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==} + + '@chevrotain/utils@10.5.0': + resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -562,6 +577,20 @@ packages: '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@electric-sql/pglite-socket@0.0.20': + resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.3.15 + + '@electric-sql/pglite-tools@0.2.20': + resolution: {integrity: sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==} + peerDependencies: + '@electric-sql/pglite': 0.3.15 + + '@electric-sql/pglite@0.3.15': + resolution: {integrity: sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==} + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -620,6 +649,12 @@ packages: '@hapi/topo@6.0.2': resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -893,6 +928,10 @@ packages: '@minimistjs/subarg@1.0.0': resolution: {integrity: sha512-Q/ONBiM2zNeYUy0mVSO44mWWKYM3UHuEK43PKIOzJCbvUnPoMH1K+gk3cf1kgnCVJFlWmddahQQCmrmBGlk9jQ==} + '@mrleebo/prisma-ast@0.13.1': + resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} + engines: {node: '>=16'} + '@napi-rs/nice-android-arm-eabi@1.1.1': resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} engines: {node: '>= 10'} @@ -934,42 +973,49 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-arm64-musl@1.1.1': resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/nice-linux-ppc64-gnu@1.1.1': resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-riscv64-gnu@1.1.1': resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-s390x-gnu@1.1.1': resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-gnu@1.1.1': resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-musl@1.1.1': resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/nice-openharmony-arm64@1.1.1': resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} @@ -1132,35 +1178,63 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@prisma/client@6.19.2': - resolution: {integrity: sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==} - engines: {node: '>=18.18'} + '@prisma/adapter-pg@7.3.0': + resolution: {integrity: sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg==} + + '@prisma/client-runtime-utils@7.3.0': + resolution: {integrity: sha512-dG/ceD9c+tnXATPk8G+USxxYM9E6UdMTnQeQ+1SZUDxTz7SgQcfxEqafqIQHcjdlcNK/pvmmLfSwAs3s2gYwUw==} + + '@prisma/client@7.3.0': + resolution: {integrity: sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ==} + engines: {node: ^20.19 || ^22.12 || >=24.0} peerDependencies: prisma: '*' - typescript: '>=5.1.0' + typescript: '>=5.4.0' peerDependenciesMeta: prisma: optional: true typescript: optional: true - '@prisma/config@6.19.2': - resolution: {integrity: sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==} + '@prisma/config@7.3.0': + resolution: {integrity: sha512-QyMV67+eXF7uMtKxTEeQqNu/Be7iH+3iDZOQZW5ttfbSwBamCSdwPszA0dum+Wx27I7anYTPLmRmMORKViSW1A==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.3.0': + resolution: {integrity: sha512-yh/tHhraCzYkffsI1/3a7SHX8tpgbJu1NPnuxS4rEpJdWAUDHUH25F1EDo6PPzirpyLNkgPPZdhojQK804BGtg==} - '@prisma/debug@6.19.2': - resolution: {integrity: sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==} + '@prisma/dev@0.20.0': + resolution: {integrity: sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==} - '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': - resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + '@prisma/driver-adapter-utils@7.3.0': + resolution: {integrity: sha512-Wdlezh1ck0Rq2dDINkfSkwbR53q53//Eo1vVqVLwtiZ0I6fuWDGNPxwq+SNAIHnsU+FD/m3aIJKevH3vF13U3w==} - '@prisma/engines@6.19.2': - resolution: {integrity: sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==} + '@prisma/engines-version@7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735': + resolution: {integrity: sha512-IH2va2ouUHihyiTTRW889LjKAl1CusZOvFfZxCDNpjSENt7g2ndFsK0vdIw/72v7+jCN6YgkHmdAP/BI7SDgyg==} - '@prisma/fetch-engine@6.19.2': - resolution: {integrity: sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==} + '@prisma/engines@7.3.0': + resolution: {integrity: sha512-cWRQoPDXPtR6stOWuWFZf9pHdQ/o8/QNWn0m0zByxf5Kd946Q875XdEJ52pEsX88vOiXUmjuPG3euw82mwQNMg==} - '@prisma/get-platform@6.19.2': - resolution: {integrity: sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==} + '@prisma/fetch-engine@7.3.0': + resolution: {integrity: sha512-Mm0F84JMqM9Vxk70pzfNpGJ1lE4hYjOeLMu7nOOD1i83nvp8MSAcFYBnHqLvEZiA6onUR+m8iYogtOY4oPO5lQ==} + + '@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/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/studio-core@0.13.1': + resolution: {integrity: sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -1434,24 +1508,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.11': resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.11': resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.11': resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.11': resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} @@ -1601,6 +1679,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react@19.2.13': + resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -1893,6 +1974,10 @@ packages: resolution: {integrity: sha512-fMMcWc2JPFcUaqHeR6+PbmEpTxCrPZyBUM95oG4w3ngJ8NfBNas/ZXA+pTHXLqJ0UlFVTcy05GC25WxKx/M20A==} hasBin: true + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + axios@0.30.2: resolution: {integrity: sha512-0pE4RQ4UQi1jKY6p7u6i1Tkzqmu+d+/tHS7Q7rKunWLB9WyilBTpHHpXzPNMDj5hTbK0B0PTLSz07yqMBiF6xg==} @@ -2072,6 +2157,9 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chevrotain@10.5.0: + resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2257,6 +2345,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2307,6 +2398,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2638,6 +2733,10 @@ packages: debug: optional: true + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fork-ts-checker-webpack-plugin@9.1.0: resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} engines: {node: '>=14.21.3'} @@ -2683,6 +2782,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2699,6 +2801,9 @@ packages: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2753,6 +2858,12 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + + graphmatch@1.1.0: + resolution: {integrity: sha512-0E62MaTW5rPZVRLyIJZG/YejmdA/Xr1QydHEw3Vt+qOKkMIOE8WDLc9ZX2bmAjtJFZcId4lEdrdmASsEy7D1QA==} + handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -2784,6 +2895,10 @@ packages: hdr-histogram-percentiles-obj@3.0.0: resolution: {integrity: sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==} + hono@4.11.4: + resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} + engines: {node: '>=16.9.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2797,6 +2912,9 @@ packages: http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + http2-wrapper@2.2.1: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} @@ -2888,6 +3006,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -3155,6 +3276,10 @@ packages: libphonenumber-js@1.12.36: resolution: {integrity: sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==} + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + limiter@1.1.5: resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} @@ -3213,6 +3338,9 @@ 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==} @@ -3224,6 +3352,9 @@ packages: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lowercase-keys@3.0.0: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3242,6 +3373,10 @@ packages: lru-memoizer@2.3.0: resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -3369,6 +3504,14 @@ packages: resolution: {integrity: sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==} engines: {node: '>=0.8.0'} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nan@2.25.0: resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==} @@ -3529,6 +3672,40 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3562,6 +3739,30 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + precond@0.2.3: resolution: {integrity: sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==} engines: {node: '>= 0.6'} @@ -3587,13 +3788,16 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - prisma@6.19.2: - resolution: {integrity: sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==} - engines: {node: '>=18.18'} + prisma@7.3.0: + resolution: {integrity: sha512-ApYSOLHfMN8WftJA+vL6XwAPOh/aZ0BgUyyKPwUFgjARmG6EBI9LzDPf6SWULQMSAxydV9qn5gLj037nPNlg2w==} + engines: {node: ^20.19 || ^22.12 || >=24.0} hasBin: true peerDependencies: - typescript: '>=5.1.0' + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: + better-sqlite3: + optional: true typescript: optional: true @@ -3605,6 +3809,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -3648,9 +3855,18 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -3665,9 +3881,15 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + reinterval@1.1.0: resolution: {integrity: sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==} + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3711,6 +3933,10 @@ packages: retimer@3.0.0: resolution: {integrity: sha512-WKE0j11Pa0ZJI5YIk0nflGI7SQsfl2ljihVy7ogh7DeQSeYAUi0ubZ/yEueGtDfUPk6GH5LRw1hBdLq4IwUBWA==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3746,6 +3972,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + schema-utils@3.3.0: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} @@ -3783,6 +4012,9 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -3857,9 +4089,17 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} @@ -3871,6 +4111,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -4188,6 +4431,14 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validator@13.15.26: resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} engines: {node: '>= 0.10'} @@ -4297,6 +4548,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + snapshots: '@angular-devkit/core@19.2.17(chokidar@4.0.3)': @@ -5049,6 +5303,21 @@ snapshots: '@borewit/text-codec@0.2.1': {} + '@chevrotain/cst-dts-gen@10.5.0': + dependencies: + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/gast@10.5.0': + dependencies: + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/types@10.5.0': {} + + '@chevrotain/utils@10.5.0': {} + '@colors/colors@1.5.0': optional: true @@ -5064,6 +5333,16 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 + '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': + dependencies: + '@electric-sql/pglite': 0.3.15 + + '@electric-sql/pglite-tools@0.2.20(@electric-sql/pglite@0.3.15)': + dependencies: + '@electric-sql/pglite': 0.3.15 + + '@electric-sql/pglite@0.3.15': {} + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -5126,6 +5405,10 @@ snapshots: dependencies: '@hapi/hoek': 11.0.7 + '@hono/node-server@1.19.9(hono@4.11.4)': + dependencies: + hono: 4.11.4 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -5492,6 +5775,11 @@ snapshots: dependencies: minimist: 1.2.8 + '@mrleebo/prisma-ast@0.13.1': + dependencies: + chevrotain: 10.5.0 + lilconfig: 2.1.0 + '@napi-rs/nice-android-arm-eabi@1.1.1': optional: true @@ -5706,12 +5994,24 @@ snapshots: '@pkgr/core@0.2.9': {} - '@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/adapter-pg@7.3.0': + dependencies: + '@prisma/driver-adapter-utils': 7.3.0 + pg: 8.18.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.3.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)': + dependencies: + '@prisma/client-runtime-utils': 7.3.0 optionalDependencies: - prisma: 6.19.2(typescript@5.9.3) + 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/config@6.19.2': + '@prisma/config@7.3.0': dependencies: c12: 3.1.0 deepmerge-ts: 7.1.5 @@ -5720,26 +6020,66 @@ snapshots: transitivePeerDependencies: - magicast - '@prisma/debug@6.19.2': {} + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.3.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) + '@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 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@5.9.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.3.0': + dependencies: + '@prisma/debug': 7.3.0 + + '@prisma/engines-version@7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735': {} - '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + '@prisma/engines@7.3.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/fetch-engine@7.3.0': + dependencies: + '@prisma/debug': 7.3.0 + '@prisma/engines-version': 7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735 + '@prisma/get-platform': 7.3.0 - '@prisma/engines@6.19.2': + '@prisma/get-platform@7.2.0': dependencies: - '@prisma/debug': 6.19.2 - '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 - '@prisma/fetch-engine': 6.19.2 - '@prisma/get-platform': 6.19.2 + '@prisma/debug': 7.2.0 - '@prisma/fetch-engine@6.19.2': + '@prisma/get-platform@7.3.0': dependencies: - '@prisma/debug': 6.19.2 - '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 - '@prisma/get-platform': 6.19.2 + '@prisma/debug': 7.3.0 + + '@prisma/query-plan-executor@7.2.0': {} - '@prisma/get-platform@6.19.2': + '@prisma/studio-core@0.13.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@prisma/debug': 6.19.2 + '@types/react': 19.2.13 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) '@scarf/scarf@1.4.0': {} @@ -6315,6 +6655,10 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react@19.2.13': + dependencies: + csstype: 3.2.3 + '@types/send@1.2.1': dependencies: '@types/node': 22.19.9 @@ -6731,6 +7075,8 @@ snapshots: semver: 7.7.4 timestring: 6.0.0 + aws-ssl-profiles@1.1.2: {} + axios@0.30.2: dependencies: follow-redirects: 1.15.11 @@ -6958,6 +7304,15 @@ snapshots: chardet@2.1.1: {} + chevrotain@10.5.0: + dependencies: + '@chevrotain/cst-dts-gen': 10.5.0 + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + '@chevrotain/utils': 10.5.0 + lodash: 4.17.21 + regexp-to-ast: 0.5.0 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -7125,6 +7480,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -7153,6 +7510,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} destr@2.0.5: {} @@ -7519,6 +7878,11 @@ snapshots: follow-redirects@1.15.11: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.11)): dependencies: '@babel/code-frame': 7.29.0 @@ -7571,6 +7935,10 @@ snapshots: function-bind@1.1.2: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -7590,6 +7958,8 @@ snapshots: get-package-type@0.1.0: {} + get-port-please@3.2.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -7662,6 +8032,10 @@ snapshots: graceful-fs@4.2.11: {} + grammex@3.1.12: {} + + graphmatch@1.1.0: {} + handlebars@4.7.8: dependencies: minimist: 1.2.8 @@ -7693,6 +8067,8 @@ snapshots: hdr-histogram-percentiles-obj@3.0.0: {} + hono@4.11.4: {} + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: {} @@ -7707,6 +8083,8 @@ snapshots: http-parser-js@0.5.10: {} + http-status-codes@2.3.0: {} + http2-wrapper@2.2.1: dependencies: quick-lru: 5.1.1 @@ -7779,6 +8157,8 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: {} + is-stream@2.0.1: {} is-unicode-supported@0.1.0: {} @@ -8245,6 +8625,8 @@ snapshots: libphonenumber-js@1.12.36: {} + lilconfig@2.1.0: {} + limiter@1.1.5: {} lines-and-columns@1.2.4: {} @@ -8285,6 +8667,8 @@ snapshots: lodash.once@4.1.1: {} + lodash@4.17.21: {} + lodash@4.17.23: {} log-symbols@4.1.0: @@ -8301,6 +8685,8 @@ snapshots: safe-stable-stringify: 2.5.0 triple-beam: 1.4.1 + long@5.3.2: {} + lowercase-keys@3.0.0: {} lru-cache@11.2.5: {} @@ -8318,6 +8704,8 @@ snapshots: lodash.clonedeep: 4.5.0 lru-cache: 6.0.0 + lru.min@1.1.4: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8423,6 +8811,22 @@ snapshots: rimraf: 2.4.5 optional: true + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + nan@2.25.0: optional: true @@ -8564,6 +8968,41 @@ snapshots: perfect-debounce@1.0.0: {} + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.11.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.11.0(pg@8.18.0): + dependencies: + pg: 8.18.0 + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.18.0: + dependencies: + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) + pg-protocol: 1.11.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -8590,6 +9029,20 @@ snapshots: pluralize@8.0.0: {} + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + precond@0.2.3: {} prelude-ls@1.2.1: {} @@ -8608,14 +9061,21 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 - prisma@6.19.2(typescript@5.9.3): + 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): dependencies: - '@prisma/config': 6.19.2 - '@prisma/engines': 6.19.2 + '@prisma/config': 7.3.0 + '@prisma/dev': 0.20.0(typescript@5.9.3) + '@prisma/engines': 7.3.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 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: + - '@types/react' - magicast + - react + - react-dom progress@2.0.3: {} @@ -8624,6 +9084,12 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -8668,8 +9134,15 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + react-is@18.3.1: {} + react@19.2.4: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -8684,8 +9157,12 @@ snapshots: reflect-metadata@0.2.2: {} + regexp-to-ast@0.5.0: {} + reinterval@1.1.0: {} + remeda@2.33.4: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -8719,6 +9196,8 @@ snapshots: retimer@3.0.0: {} + retry@0.12.0: {} + reusify@1.1.0: {} rimraf@2.4.5: @@ -8757,6 +9236,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 @@ -8802,6 +9283,8 @@ snapshots: transitivePeerDependencies: - supports-color + seq-queue@0.0.5: {} + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -8883,8 +9366,12 @@ snapshots: source-map@0.7.6: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} + sqlstring@2.3.3: {} + stack-trace@0.0.10: {} stack-utils@2.0.6: @@ -8893,6 +9380,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + streamsearch@1.1.0: {} streamx@2.23.0: @@ -9218,6 +9707,10 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + validator@13.15.26: {} vary@1.1.2: {} @@ -9348,3 +9841,8 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {} + + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.12 + graphmatch: 1.1.0 diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..6f5fc36 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,20 @@ +import { loadEnvFile } from 'node:process'; +import { defineConfig } from 'prisma/config'; + +try { + loadEnvFile(); +} catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } +} + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: process.env['DATABASE_URL'], + }, +}); diff --git a/prisma/SCHEMA_NOTES.md b/prisma/SCHEMA_NOTES.md deleted file mode 100644 index 9e91c99..0000000 --- a/prisma/SCHEMA_NOTES.md +++ /dev/null @@ -1,34 +0,0 @@ -# Prisma Schema Notes - -## Validation Summary -- `prisma validate` succeeded with Prisma CLI `6.17.1`. -- `prisma generate` succeeded and generated Prisma Client in `project-service-v6/node_modules/@prisma/client`. - -## Database Introspection Status -`prisma db pull` was attempted against the two configured tc-project-service connection targets and failed due connectivity: - -1. `postgres://coder:mysecretpassword@localhost:5432/projectsdb` -> `P1001: Can't reach database server at localhost:5432` -2. `postgres://coder:mysecretpassword@dockerhost:5432/projectsdb` -> `P1001: Can't reach database server at dockerhost:5432` - -Because the database was unreachable, exact pull-time verification of nullability/defaults/index definitions/FK actions could not be completed in this environment. - -## Basic Query Smoke Test -- Prisma client was generated successfully. -- A basic query (`prisma.project.count()`) was attempted with `DATABASE_URL=postgres://coder:mysecretpassword@localhost:5432/projectsdb`. -- Result: connection failure (`Can't reach database server at localhost:5432`), so runtime query verification is blocked pending DB availability. - -## Prisma-Specific Adaptations -- `phase_work_streams` is modeled as an explicit junction model (`PhaseWorkStream`) to preserve the existing table. Prisma implicit many-to-many cannot target this custom table name. -- `project_phase_member` soft-delete partial uniqueness (`phaseId + userId` for active rows only) cannot be expressed directly in Prisma schema. A standard unique constraint (`@@unique([phaseId, userId])`) is used in schema representation. -- `ProjectAttachment.tags` and `ProjectAttachment.allowedUsers` are represented as scalar lists. Prisma schema does not model nullable list semantics distinctly from non-null lists. -- `CopilotRequest.projectId`, `CopilotOpportunity.projectId`, and `CopilotOpportunity.copilotRequestId` are nullable so `onDelete: SetNull` can be represented correctly. -- `StatusHistory` uses a generic `reference/referenceId` pattern. The Prisma relation to `Milestone` is modeled via `referenceId`; filtering by `reference = 'milestone'` remains an application-level concern. - -## Follow-Up Needed Once DB Is Reachable -- Run `DATABASE_URL= npx prisma@6.17.1 db pull --schema prisma/schema.prisma`. -- Diff pulled schema vs. current manual schema for: - - BIGINT vs INT audit field types - - nullable arrays and nullable FK columns - - enum-backed vs string-backed status columns - - partial indexes and cascade behaviors -- Apply any required alignment updates. diff --git a/prisma/add_types.sql b/prisma/add_types.sql new file mode 100644 index 0000000..75f7b91 --- /dev/null +++ b/prisma/add_types.sql @@ -0,0 +1,963 @@ +BEGIN; + +-- Create enum types Prisma expects in schema "projects". +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'ProjectStatus' + ) THEN + CREATE TYPE projects."ProjectStatus" AS ENUM ( + 'draft', + 'in_review', + 'reviewed', + 'active', + 'completed', + 'paused', + 'cancelled' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'InviteStatus' + ) THEN + CREATE TYPE projects."InviteStatus" AS ENUM ( + 'pending', + 'accepted', + 'refused', + 'requested', + 'request_rejected', + 'request_approved', + 'canceled' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'WorkStreamStatus' + ) THEN + CREATE TYPE projects."WorkStreamStatus" AS ENUM ( + 'draft', + 'reviewed', + 'active', + 'completed', + 'paused' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'CopilotRequestStatus' + ) THEN + CREATE TYPE projects."CopilotRequestStatus" AS ENUM ( + 'new', + 'approved', + 'rejected', + 'seeking', + 'canceled', + 'fulfilled' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'CopilotApplicationStatus' + ) THEN + CREATE TYPE projects."CopilotApplicationStatus" AS ENUM ( + 'pending', + 'invited', + 'accepted', + 'canceled' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'CopilotOpportunityStatus' + ) THEN + CREATE TYPE projects."CopilotOpportunityStatus" AS ENUM ( + 'active', + 'completed', + 'canceled' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'CopilotOpportunityType' + ) THEN + CREATE TYPE projects."CopilotOpportunityType" AS ENUM ( + 'dev', + 'qa', + 'design', + 'ai', + 'datascience' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'ScopeChangeRequestStatus' + ) THEN + CREATE TYPE projects."ScopeChangeRequestStatus" AS ENUM ( + 'pending', + 'approved', + 'rejected', + 'activated', + 'canceled' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'CustomerPaymentStatus' + ) THEN + CREATE TYPE projects."CustomerPaymentStatus" AS ENUM ( + 'canceled', + 'processing', + 'requires_action', + 'requires_capture', + 'requires_confirmation', + 'requires_payment_method', + 'succeeded', + 'refunded', + 'refund_failed', + 'refund_pending' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'ProjectMemberRole' + ) THEN + CREATE TYPE projects."ProjectMemberRole" AS ENUM ( + 'manager', + 'observer', + 'customer', + 'copilot', + 'account_manager', + 'program_manager', + 'account_executive', + 'solution_architect', + 'project_manager' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'AttachmentType' + ) THEN + CREATE TYPE projects."AttachmentType" AS ENUM ('file', 'link'); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'EstimationType' + ) THEN + CREATE TYPE projects."EstimationType" AS ENUM ( + 'fee', + 'community', + 'topcoder_service' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'ValueType' + ) THEN + CREATE TYPE projects."ValueType" AS ENUM ( + 'int', + 'double', + 'string', + 'percentage' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'TimelineReference' + ) THEN + CREATE TYPE projects."TimelineReference" AS ENUM ( + 'project', + 'phase', + 'product', + 'work' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'PhaseApprovalDecision' + ) THEN + CREATE TYPE projects."PhaseApprovalDecision" AS ENUM ('approve', 'reject'); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'CustomerPaymentCurrency' + ) THEN + CREATE TYPE projects."CustomerPaymentCurrency" AS ENUM ( + 'USD', 'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', + 'BAM', 'BBD', 'BDT', 'BGN', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BWP', + 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLP', 'CNY', 'COP', 'CRC', 'CVE', 'CZK', + 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP', 'GEL', + 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK', 'HTG', 'HUF', 'IDR', + 'ILS', 'INR', 'ISK', 'JMD', 'JPY', 'KES', 'KGS', 'KHR', 'KMF', 'KRW', 'KYD', + 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', + 'MNT', 'MOP', 'MRO', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', + 'NIO', 'NOK', 'NPR', 'NZD', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', + 'QAR', 'RON', 'RSD', 'RUB', 'RWF', 'SAR', 'SBD', 'SCR', 'SEK', 'SGD', 'SHP', + 'SLL', 'SOS', 'SRD', 'STD', 'SZL', 'THB', 'TJS', 'TOP', 'TRY', 'TTD', 'TWD', + 'TZS', 'UAH', 'UGX', 'UYU', 'UZS', 'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XOF', + 'XPF', 'YER', 'ZAR', 'ZMW' + ); + END IF; +END $$; + +-- Normalize legacy and placeholder values before enum casts. +UPDATE projects.projects +SET status = lower(trim(status::text)) +WHERE status IS NOT NULL; + +UPDATE projects.project_phases +SET status = lower(trim(status::text)) +WHERE status IS NOT NULL; + +UPDATE projects.milestones +SET status = lower(trim(status::text)) +WHERE status IS NOT NULL; + +UPDATE projects.status_history +SET status = lower(trim(status::text)) +WHERE status IS NOT NULL; + +UPDATE projects.projects +SET status = 'in_review' +WHERE status::text IN ('inreview'); +UPDATE projects.project_phases +SET status = 'in_review' +WHERE status::text IN ('inreview'); +UPDATE projects.milestones +SET status = 'in_review' +WHERE status::text IN ('inreview'); +UPDATE projects.status_history +SET status = 'in_review' +WHERE status::text IN ('inreview'); + +UPDATE projects.projects +SET status = 'reviewed' +WHERE status::text IN ('planned', 'review'); +UPDATE projects.project_phases +SET status = 'reviewed' +WHERE status::text IN ('planned', 'review'); +UPDATE projects.milestones +SET status = 'reviewed' +WHERE status::text IN ('planned', 'review'); +UPDATE projects.status_history +SET status = 'reviewed' +WHERE status::text IN ('planned', 'review'); + +UPDATE projects.projects +SET status = 'active' +WHERE status::text IN ('inprogress', 'in_progress'); +UPDATE projects.project_phases +SET status = 'active' +WHERE status::text IN ('inprogress', 'in_progress'); +UPDATE projects.milestones +SET status = 'active' +WHERE status::text IN ('inprogress', 'in_progress'); +UPDATE projects.status_history +SET status = 'active' +WHERE status::text IN ('inprogress', 'in_progress'); + +UPDATE projects.projects +SET status = 'cancelled' +WHERE status::text = 'canceled'; +UPDATE projects.project_phases +SET status = 'cancelled' +WHERE status::text = 'canceled'; +UPDATE projects.milestones +SET status = 'cancelled' +WHERE status::text = 'canceled'; +UPDATE projects.status_history +SET status = 'cancelled' +WHERE status::text = 'canceled'; + +UPDATE projects.projects +SET status = 'draft' +WHERE status::text IN ('temporary', 'string', ''); +UPDATE projects.project_phases +SET status = 'draft' +WHERE status::text IN ('temporary', 'string', ''); +UPDATE projects.milestones +SET status = 'draft' +WHERE status::text IN ('temporary', 'string', ''); +UPDATE projects.status_history +SET status = 'draft' +WHERE status::text IN ('temporary', 'string', ''); + +UPDATE projects.project_member_invites +SET role = replace(replace(lower(trim(role::text)), '-', '_'), ' ', '_') +WHERE role IS NOT NULL; + +UPDATE projects.project_members +SET role = replace(replace(lower(trim(role::text)), '-', '_'), ' ', '_') +WHERE role IS NOT NULL; + +UPDATE projects.project_member_invites +SET status = replace(replace(lower(trim(status::text)), '-', '_'), ' ', '_') +WHERE status IS NOT NULL; +UPDATE projects.project_member_invites +SET status = 'canceled' +WHERE status::text = 'cancelled'; + +UPDATE projects.work_streams +SET status = replace(replace(lower(trim(status::text)), '-', '_'), ' ', '_') +WHERE status IS NOT NULL; +UPDATE projects.work_streams +SET status = 'active' +WHERE status::text IN ('inprogress', 'in_progress'); + +UPDATE projects.copilot_requests +SET status = replace(replace(lower(trim(status::text)), '-', '_'), ' ', '_') +WHERE status IS NOT NULL; +UPDATE projects.copilot_requests +SET status = 'canceled' +WHERE status::text = 'cancelled'; +UPDATE projects.copilot_requests +SET status = 'fulfilled' +WHERE status::text IN ('fulfiled', 'fullfilled'); + +UPDATE projects.copilot_applications +SET status = replace(replace(lower(trim(status::text)), '-', '_'), ' ', '_') +WHERE status IS NOT NULL; +UPDATE projects.copilot_applications +SET status = 'canceled' +WHERE status::text = 'cancelled'; + +UPDATE projects.copilot_opportunities +SET status = replace(replace(lower(trim(status::text)), '-', '_'), ' ', '_') +WHERE status IS NOT NULL; +UPDATE projects.copilot_opportunities +SET status = 'canceled' +WHERE status::text = 'cancelled'; + +UPDATE projects.copilot_opportunities +SET type = replace(replace(lower(trim(type::text)), '-', ''), '_', '') +WHERE type IS NOT NULL; +UPDATE projects.copilot_opportunities +SET type = 'datascience' +WHERE type::text IN ('data science', 'data_science'); + +UPDATE projects.scope_change_requests +SET status = replace(replace(lower(trim(status::text)), '-', '_'), ' ', '_') +WHERE status IS NOT NULL; +UPDATE projects.scope_change_requests +SET status = 'canceled' +WHERE status::text = 'cancelled'; + +UPDATE projects.customer_payments +SET status = replace(replace(lower(trim(status::text)), '-', '_'), ' ', '_') +WHERE status IS NOT NULL; +UPDATE projects.customer_payments +SET status = 'canceled' +WHERE status::text = 'cancelled'; + +UPDATE projects.customer_payments +SET currency = upper(trim(currency::text)) +WHERE currency IS NOT NULL; + +UPDATE projects.project_attachments +SET type = lower(trim(type::text)) +WHERE type IS NOT NULL; +UPDATE projects.project_attachments +SET type = 'link' +WHERE type::text = 'url'; + +UPDATE projects.timelines +SET reference = lower(trim(reference::text)) +WHERE reference IS NOT NULL; + +UPDATE projects.project_settings +SET "valueType" = lower(trim("valueType"::text)) +WHERE "valueType" IS NOT NULL; + +UPDATE projects.project_estimation_items +SET type = replace(replace(lower(trim(type::text)), '-', '_'), ' ', '_') +WHERE type IS NOT NULL; + +-- Validate remaining values so casts fail early with clear messages. +DO $$ +DECLARE + bad_values text; +BEGIN + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT status::text AS v + FROM projects.projects + WHERE status IS NOT NULL + AND status::text NOT IN ( + 'draft', 'in_review', 'reviewed', 'active', 'completed', 'paused', 'cancelled' + ) + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.projects.status: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT status::text AS v + FROM projects.project_phases + WHERE status IS NOT NULL + AND status::text NOT IN ( + 'draft', 'in_review', 'reviewed', 'active', 'completed', 'paused', 'cancelled' + ) + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.project_phases.status: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT status::text AS v + FROM projects.milestones + WHERE status IS NOT NULL + AND status::text NOT IN ( + 'draft', 'in_review', 'reviewed', 'active', 'completed', 'paused', 'cancelled' + ) + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.milestones.status: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT status::text AS v + FROM projects.status_history + WHERE status IS NOT NULL + AND status::text NOT IN ( + 'draft', 'in_review', 'reviewed', 'active', 'completed', 'paused', 'cancelled' + ) + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.status_history.status: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT role::text AS v + FROM projects.project_members + WHERE role IS NOT NULL + AND role::text NOT IN ( + 'manager', 'observer', 'customer', 'copilot', 'account_manager', + 'program_manager', 'account_executive', 'solution_architect', 'project_manager' + ) + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.project_members.role: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT role::text AS v + FROM projects.project_member_invites + WHERE role IS NOT NULL + AND role::text NOT IN ( + 'manager', 'observer', 'customer', 'copilot', 'account_manager', + 'program_manager', 'account_executive', 'solution_architect', 'project_manager' + ) + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.project_member_invites.role: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT status::text AS v + FROM projects.project_member_invites + WHERE status IS NOT NULL + AND status::text NOT IN ( + 'pending', 'accepted', 'refused', 'requested', 'request_rejected', + 'request_approved', 'canceled' + ) + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.project_member_invites.status: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT status::text AS v + FROM projects.work_streams + WHERE status IS NOT NULL + AND status::text NOT IN ('draft', 'reviewed', 'active', 'completed', 'paused') + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.work_streams.status: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT status::text AS v + FROM projects.copilot_requests + WHERE status IS NOT NULL + AND status::text NOT IN ('new', 'approved', 'rejected', 'seeking', 'canceled', 'fulfilled') + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.copilot_requests.status: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT status::text AS v + FROM projects.copilot_applications + WHERE status IS NOT NULL + AND status::text NOT IN ('pending', 'invited', 'accepted', 'canceled') + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.copilot_applications.status: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT status::text AS v + FROM projects.copilot_opportunities + WHERE status IS NOT NULL + AND status::text NOT IN ('active', 'completed', 'canceled') + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.copilot_opportunities.status: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT type::text AS v + FROM projects.copilot_opportunities + WHERE type IS NOT NULL + AND type::text NOT IN ('dev', 'qa', 'design', 'ai', 'datascience') + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.copilot_opportunities.type: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT status::text AS v + FROM projects.scope_change_requests + WHERE status IS NOT NULL + AND status::text NOT IN ('pending', 'approved', 'rejected', 'activated', 'canceled') + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.scope_change_requests.status: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT type::text AS v + FROM projects.project_attachments + WHERE type IS NOT NULL + AND type::text NOT IN ('file', 'link') + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.project_attachments.type: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT reference::text AS v + FROM projects.timelines + WHERE reference IS NOT NULL + AND reference::text NOT IN ('project', 'phase', 'product', 'work') + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.timelines.reference: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT "valueType"::text AS v + FROM projects.project_settings + WHERE "valueType" IS NOT NULL + AND "valueType"::text NOT IN ('int', 'double', 'string', 'percentage') + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.project_settings.valueType: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT type::text AS v + FROM projects.project_estimation_items + WHERE type IS NOT NULL + AND type::text NOT IN ('fee', 'community', 'topcoder_service') + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.project_estimation_items.type: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT status::text AS v + FROM projects.customer_payments + WHERE status IS NOT NULL + AND status::text NOT IN ( + 'canceled', 'processing', 'requires_action', 'requires_capture', + 'requires_confirmation', 'requires_payment_method', + 'succeeded', 'refunded', 'refund_failed', 'refund_pending' + ) + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.customer_payments.status: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT currency::text AS v + FROM projects.customer_payments + WHERE currency IS NOT NULL + AND currency::text NOT IN ( + 'USD', 'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', + 'BAM', 'BBD', 'BDT', 'BGN', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BWP', + 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLP', 'CNY', 'COP', 'CRC', 'CVE', 'CZK', + 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP', 'GEL', + 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK', 'HTG', 'HUF', 'IDR', + 'ILS', 'INR', 'ISK', 'JMD', 'JPY', 'KES', 'KGS', 'KHR', 'KMF', 'KRW', 'KYD', + 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', + 'MNT', 'MOP', 'MRO', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', + 'NIO', 'NOK', 'NPR', 'NZD', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', + 'QAR', 'RON', 'RSD', 'RUB', 'RWF', 'SAR', 'SBD', 'SCR', 'SEK', 'SGD', 'SHP', + 'SLL', 'SOS', 'SRD', 'STD', 'SZL', 'THB', 'TJS', 'TOP', 'TRY', 'TTD', 'TWD', + 'TZS', 'UAH', 'UGX', 'UYU', 'UZS', 'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XOF', + 'XPF', 'YER', 'ZAR', 'ZMW' + ) + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.customer_payments.currency: %', bad_values; + END IF; + + SELECT string_agg(v, ', ' ORDER BY v) INTO bad_values + FROM ( + SELECT DISTINCT decision::text AS v + FROM projects.project_phase_approval + WHERE decision IS NOT NULL + AND lower(trim(decision::text)) NOT IN ('approve', 'reject', 'approved', 'rejected') + ) s; + IF bad_values IS NOT NULL THEN + RAISE EXCEPTION 'Invalid values for projects.project_phase_approval.decision: %', bad_values; + END IF; +END $$; + +-- Cast every enum-backed column to the Prisma enum type. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'projects' + AND column_name = 'status' + AND NOT (udt_schema = 'projects' AND udt_name = 'ProjectStatus') + ) THEN + ALTER TABLE projects.projects + ALTER COLUMN status TYPE projects."ProjectStatus" + USING status::text::projects."ProjectStatus"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'project_phases' + AND column_name = 'status' + AND NOT (udt_schema = 'projects' AND udt_name = 'ProjectStatus') + ) THEN + ALTER TABLE projects.project_phases + ALTER COLUMN status TYPE projects."ProjectStatus" + USING status::text::projects."ProjectStatus"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'milestones' + AND column_name = 'status' + AND NOT (udt_schema = 'projects' AND udt_name = 'ProjectStatus') + ) THEN + ALTER TABLE projects.milestones + ALTER COLUMN status TYPE projects."ProjectStatus" + USING status::text::projects."ProjectStatus"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'status_history' + AND column_name = 'status' + AND NOT (udt_schema = 'projects' AND udt_name = 'ProjectStatus') + ) THEN + ALTER TABLE projects.status_history + ALTER COLUMN status TYPE projects."ProjectStatus" + USING status::text::projects."ProjectStatus"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'project_members' + AND column_name = 'role' + AND NOT (udt_schema = 'projects' AND udt_name = 'ProjectMemberRole') + ) THEN + ALTER TABLE projects.project_members + ALTER COLUMN role TYPE projects."ProjectMemberRole" + USING role::text::projects."ProjectMemberRole"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'project_member_invites' + AND column_name = 'role' + AND NOT (udt_schema = 'projects' AND udt_name = 'ProjectMemberRole') + ) THEN + ALTER TABLE projects.project_member_invites + ALTER COLUMN role TYPE projects."ProjectMemberRole" + USING role::text::projects."ProjectMemberRole"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'project_member_invites' + AND column_name = 'status' + AND NOT (udt_schema = 'projects' AND udt_name = 'InviteStatus') + ) THEN + ALTER TABLE projects.project_member_invites + ALTER COLUMN status TYPE projects."InviteStatus" + USING status::text::projects."InviteStatus"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'work_streams' + AND column_name = 'status' + AND NOT (udt_schema = 'projects' AND udt_name = 'WorkStreamStatus') + ) THEN + ALTER TABLE projects.work_streams + ALTER COLUMN status TYPE projects."WorkStreamStatus" + USING status::text::projects."WorkStreamStatus"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'copilot_requests' + AND column_name = 'status' + AND NOT (udt_schema = 'projects' AND udt_name = 'CopilotRequestStatus') + ) THEN + ALTER TABLE projects.copilot_requests + ALTER COLUMN status TYPE projects."CopilotRequestStatus" + USING status::text::projects."CopilotRequestStatus"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'copilot_applications' + AND column_name = 'status' + AND NOT (udt_schema = 'projects' AND udt_name = 'CopilotApplicationStatus') + ) THEN + ALTER TABLE projects.copilot_applications + ALTER COLUMN status TYPE projects."CopilotApplicationStatus" + USING status::text::projects."CopilotApplicationStatus"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'copilot_opportunities' + AND column_name = 'status' + AND NOT (udt_schema = 'projects' AND udt_name = 'CopilotOpportunityStatus') + ) THEN + ALTER TABLE projects.copilot_opportunities + ALTER COLUMN status TYPE projects."CopilotOpportunityStatus" + USING status::text::projects."CopilotOpportunityStatus"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'copilot_opportunities' + AND column_name = 'type' + AND NOT (udt_schema = 'projects' AND udt_name = 'CopilotOpportunityType') + ) THEN + ALTER TABLE projects.copilot_opportunities + ALTER COLUMN type TYPE projects."CopilotOpportunityType" + USING type::text::projects."CopilotOpportunityType"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'scope_change_requests' + AND column_name = 'status' + AND NOT (udt_schema = 'projects' AND udt_name = 'ScopeChangeRequestStatus') + ) THEN + ALTER TABLE projects.scope_change_requests + ALTER COLUMN status TYPE projects."ScopeChangeRequestStatus" + USING status::text::projects."ScopeChangeRequestStatus"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'project_attachments' + AND column_name = 'type' + AND NOT (udt_schema = 'projects' AND udt_name = 'AttachmentType') + ) THEN + ALTER TABLE projects.project_attachments + ALTER COLUMN type TYPE projects."AttachmentType" + USING type::text::projects."AttachmentType"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'timelines' + AND column_name = 'reference' + AND NOT (udt_schema = 'projects' AND udt_name = 'TimelineReference') + ) THEN + ALTER TABLE projects.timelines + ALTER COLUMN reference TYPE projects."TimelineReference" + USING reference::text::projects."TimelineReference"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'project_settings' + AND column_name = 'valueType' + AND NOT (udt_schema = 'projects' AND udt_name = 'ValueType') + ) THEN + ALTER TABLE projects.project_settings + ALTER COLUMN "valueType" TYPE projects."ValueType" + USING "valueType"::text::projects."ValueType"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'project_estimation_items' + AND column_name = 'type' + AND NOT (udt_schema = 'projects' AND udt_name = 'EstimationType') + ) THEN + ALTER TABLE projects.project_estimation_items + ALTER COLUMN type TYPE projects."EstimationType" + USING type::text::projects."EstimationType"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'customer_payments' + AND column_name = 'currency' + AND NOT (udt_schema = 'projects' AND udt_name = 'CustomerPaymentCurrency') + ) THEN + ALTER TABLE projects.customer_payments + ALTER COLUMN currency TYPE projects."CustomerPaymentCurrency" + USING currency::text::projects."CustomerPaymentCurrency"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'customer_payments' + AND column_name = 'status' + AND NOT (udt_schema = 'projects' AND udt_name = 'CustomerPaymentStatus') + ) THEN + ALTER TABLE projects.customer_payments + ALTER COLUMN status TYPE projects."CustomerPaymentStatus" + USING status::text::projects."CustomerPaymentStatus"; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'projects' + AND table_name = 'project_phase_approval' + AND column_name = 'decision' + AND NOT (udt_schema = 'projects' AND udt_name = 'PhaseApprovalDecision') + ) THEN + ALTER TABLE projects.project_phase_approval + ALTER COLUMN decision TYPE projects."PhaseApprovalDecision" + USING ( + CASE lower(trim(decision::text)) + WHEN 'approved' THEN 'approve' + WHEN 'rejected' THEN 'reject' + ELSE lower(trim(decision::text)) + END + )::projects."PhaseApprovalDecision"; + END IF; +END $$; + +-- Restore expected defaults on enum columns. +ALTER TABLE projects.copilot_requests + ALTER COLUMN status SET DEFAULT 'new'::projects."CopilotRequestStatus"; + +ALTER TABLE projects.copilot_opportunities + ALTER COLUMN status SET DEFAULT 'active'::projects."CopilotOpportunityStatus"; + +ALTER TABLE projects.copilot_applications + ALTER COLUMN status SET DEFAULT 'pending'::projects."CopilotApplicationStatus"; + +COMMIT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c4dcf0b..5c87015 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,7 +13,6 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") } enum ProjectStatus { @@ -1020,7 +1019,6 @@ model ProjectHistory { projectId BigInt status String cancelReason String? - deletedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt updatedBy Int diff --git a/prisma/seed.ts b/prisma/seed.ts index f9da8f7..c2b1cf5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,6 +1,30 @@ +import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; -const prisma = new PrismaClient(); +function getSchemaFromUrl(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + + try { + return new URL(url).searchParams.get('schema') ?? undefined; + } catch { + return undefined; + } +} + +function createPrismaClient(): PrismaClient { + const connectionString = process.env.DATABASE_URL; + const schema = getSchemaFromUrl(connectionString); + const adapter = new PrismaPg( + { connectionString }, + schema ? { schema } : undefined, + ); + + return new PrismaClient({ adapter }); +} + +const prisma = createPrismaClient(); async function seedProjectTypes() { console.log('Seeding project types...'); diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 13a02aa..b4f2e43 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -2,7 +2,6 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; import { CopilotModule } from './copilot/copilot.module'; -import { MilestoneModule } from './milestone/milestone.module'; import { PhaseProductModule } from './phase-product/phase-product.module'; import { ProjectAttachmentModule } from './project-attachment/project-attachment.module'; import { HealthCheckController } from './health-check/healthCheck.controller'; @@ -12,8 +11,6 @@ import { ProjectMemberModule } from './project-member/project-member.module'; import { ProjectPhaseModule } from './project-phase/project-phase.module'; import { ProjectSettingModule } from './project-setting/project-setting.module'; import { ProjectModule } from './project/project.module'; -import { TimelineModule } from './timeline/timeline.module'; -import { WorkStreamModule } from './workstream/workstream.module'; @Module({ imports: [ @@ -22,15 +19,12 @@ import { WorkStreamModule } from './workstream/workstream.module'; CopilotModule, MetadataModule, ProjectModule, - ProjectSettingModule, ProjectMemberModule, ProjectInviteModule, ProjectAttachmentModule, - WorkStreamModule, ProjectPhaseModule, PhaseProductModule, - TimelineModule, - MilestoneModule, + ProjectSettingModule, ], controllers: [HealthCheckController], providers: [], diff --git a/src/api/copilot/copilot-application.service.ts b/src/api/copilot/copilot-application.service.ts index 31675cb..c17bb79 100644 --- a/src/api/copilot/copilot-application.service.ts +++ b/src/api/copilot/copilot-application.service.ts @@ -9,7 +9,6 @@ import { CopilotApplicationStatus, CopilotOpportunity, CopilotOpportunityStatus, - CopilotRequest, ProjectMember, } from '@prisma/client'; import { Permission as NamedPermission } from 'src/shared/constants/permissions'; @@ -31,10 +30,6 @@ import { const APPLICATION_SORTS = ['createdAt asc', 'createdAt desc']; -type OpportunityWithRequest = CopilotOpportunity & { - copilotRequest?: CopilotRequest | null; -}; - type ApplicationWithRelations = CopilotApplication & { opportunity?: CopilotOpportunity | null; }; @@ -101,7 +96,7 @@ export class CopilotApplicationService { }); await this.notificationService.sendCopilotApplicationNotification( - opportunity as OpportunityWithRequest, + opportunity, created, ); diff --git a/src/api/copilot/copilot-notification.service.ts b/src/api/copilot/copilot-notification.service.ts index 166afc4..5b20397 100644 --- a/src/api/copilot/copilot-notification.service.ts +++ b/src/api/copilot/copilot-notification.service.ts @@ -7,15 +7,10 @@ import { ProjectMemberRole, } from '@prisma/client'; import { LoggerService } from 'src/shared/modules/global/logger.service'; -import { EventBusService } from 'src/shared/modules/global/eventBus.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { MemberService } from 'src/shared/services/member.service'; import { getCopilotRequestData, getCopilotTypeLabel } from './copilot.utils'; -const CONNECT_NOTIFICATION_EVENT = { - EXTERNAL_ACTION_EMAIL: 'external.action.email', -} as const; - const TEMPLATE_IDS = { APPLY_COPILOT: 'd-d7c1f48628654798a05c8e09e52db14f', COPILOT_APPLICATION_ACCEPTED: 'd-eef5e7568c644940b250e76d026ced5b', @@ -39,7 +34,6 @@ export class CopilotNotificationService { private readonly logger = LoggerService.forRoot('CopilotNotificationService'); constructor( - private readonly eventBusService: EventBusService, private readonly prisma: PrismaService, private readonly memberService: MemberService, ) {} @@ -241,30 +235,21 @@ export class CopilotNotificationService { ); } - private async publishEmail( + private publishEmail( templateId: string, recipients: string[], data: Record, ): Promise { if (recipients.length === 0) { - return; + return Promise.resolve(); } - try { - await this.eventBusService.publishProjectEvent( - CONNECT_NOTIFICATION_EVENT.EXTERNAL_ACTION_EMAIL, - { - data, - sendgrid_template_id: templateId, - recipients, - version: 'v3', - }, - ); - } catch (error) { - this.logger.warn( - `Failed to publish copilot email event: ${error instanceof Error ? error.message : String(error)}`, - ); - } + void templateId; + void data; + this.logger.warn( + `Copilot email Kafka publication is disabled. Skipped ${recipients.length} recipient(s).`, + ); + return Promise.resolve(); } private getWorkManagerUrl(): string { diff --git a/src/api/copilot/copilot-opportunity.service.ts b/src/api/copilot/copilot-opportunity.service.ts index 818e183..74db7c0 100644 --- a/src/api/copilot/copilot-opportunity.service.ts +++ b/src/api/copilot/copilot-opportunity.service.ts @@ -183,7 +183,7 @@ export class CopilotOpportunityService { : true; return this.formatOpportunity( - opportunity as OpportunityWithRelations, + opportunity, canApplyAsCopilot, members, isAdminOrManager(user), diff --git a/src/api/copilot/copilot-request.service.ts b/src/api/copilot/copilot-request.service.ts index 74b743e..fd67e25 100644 --- a/src/api/copilot/copilot-request.service.ts +++ b/src/api/copilot/copilot-request.service.ts @@ -161,10 +161,7 @@ export class CopilotRequestService { ); } - return this.formatRequest( - request as CopilotRequestWithRelations, - isAdminOrManager(user), - ); + return this.formatRequest(request, isAdminOrManager(user)); } async createRequest( @@ -238,10 +235,7 @@ export class CopilotRequestService { throw new NotFoundException('Unable to create copilot request.'); } - return this.formatRequest( - created as CopilotRequestWithRelations, - isAdminOrManager(user), - ); + return this.formatRequest(created, isAdminOrManager(user)); } async updateRequest( @@ -341,10 +335,7 @@ export class CopilotRequestService { return updatedRequest; }); - return this.formatRequest( - updated as CopilotRequestWithRelations, - isAdminOrManager(user), - ); + return this.formatRequest(updated, isAdminOrManager(user)); } async approveRequest( diff --git a/src/api/metadata/metadata.module.ts b/src/api/metadata/metadata.module.ts index 6fdbe67..31eb594 100644 --- a/src/api/metadata/metadata.module.ts +++ b/src/api/metadata/metadata.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { FormModule } from './form/form.module'; import { MetadataListController } from './metadata-list.controller'; import { MetadataListService } from './metadata-list.service'; -import { MilestoneTemplateModule } from './milestone-template/milestone-template.module'; import { OrgConfigModule } from './org-config/org-config.module'; import { PlanConfigModule } from './plan-config/plan-config.module'; import { PriceConfigModule } from './price-config/price-config.module'; @@ -22,7 +21,6 @@ import { WorkManagementPermissionModule } from './work-management-permission/wor FormModule, PlanConfigModule, PriceConfigModule, - MilestoneTemplateModule, WorkManagementPermissionModule, ], controllers: [MetadataListController], diff --git a/src/api/metadata/metadata.swagger.ts b/src/api/metadata/metadata.swagger.ts index a8dfc13..63952c6 100644 --- a/src/api/metadata/metadata.swagger.ts +++ b/src/api/metadata/metadata.swagger.ts @@ -67,7 +67,7 @@ export const EVENT_SWAGGER_EXAMPLES = { resourceInviteCreated: { summary: 'Resource event: invite created', value: { - topic: 'project.member.invite.created', + topic: 'project.member.added', originator: 'project-service-v6', timestamp: '2026-02-07T12:01:00.000Z', 'mime-type': 'application/json', @@ -87,7 +87,7 @@ export const EVENT_SWAGGER_EXAMPLES = { notificationProjectUpdated: { summary: 'Notification event: project updated', value: { - topic: 'connect.notification.project.updated', + topic: 'project.created', originator: 'project-service-v6', timestamp: '2026-02-07T12:02:00.000Z', 'mime-type': 'application/json', @@ -103,7 +103,7 @@ export const EVENT_SWAGGER_EXAMPLES = { notificationInviteSent: { summary: 'Notification event: invite sent', value: { - topic: 'connect.notification.project.member.invite.sent', + topic: 'project.member.removed', originator: 'project-service-v6', timestamp: '2026-02-07T12:03:00.000Z', 'mime-type': 'application/json', @@ -120,7 +120,7 @@ export const EVENT_SWAGGER_EXAMPLES = { notificationInviteAccepted: { summary: 'Notification event: invite accepted', value: { - topic: 'connect.notification.project.member.invite.accepted', + topic: 'project.deleted', originator: 'project-service-v6', timestamp: '2026-02-07T12:04:00.000Z', 'mime-type': 'application/json', @@ -211,7 +211,7 @@ export class NotificationEventPayloadSchema { } export class NotificationEventSchema { - @ApiProperty({ example: 'connect.notification.project.updated' }) + @ApiProperty({ example: 'project.created' }) topic: string; @ApiProperty({ example: 'project-service-v6' }) diff --git a/src/api/metadata/milestone-template/milestone-template.controller.ts b/src/api/metadata/milestone-template/milestone-template.controller.ts deleted file mode 100644 index 891fe38..0000000 --- a/src/api/metadata/milestone-template/milestone-template.controller.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - Param, - Patch, - Post, -} from '@nestjs/common'; -import { - ApiBearerAuth, - ApiOperation, - ApiParam, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; -import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; -import { AdminOnly } from 'src/shared/guards/adminOnly.guard'; -import { JwtUser } from 'src/shared/modules/global/jwt.service'; -import { getAuditUserIdBigInt } from '../utils/metadata-utils'; -import { CloneMilestoneTemplateDto } from './dto/clone-milestone-template.dto'; -import { CreateMilestoneTemplateDto } from './dto/create-milestone-template.dto'; -import { MilestoneTemplateResponseDto } from './dto/milestone-template-response.dto'; -import { UpdateMilestoneTemplateDto } from './dto/update-milestone-template.dto'; -import { MilestoneTemplateService } from './milestone-template.service'; - -@ApiTags('Metadata - Milestone Templates') -@ApiBearerAuth() -@Controller('/timelines/metadata/milestoneTemplates') -export class MilestoneTemplateController { - constructor( - private readonly milestoneTemplateService: MilestoneTemplateService, - ) {} - - @Get() - @ApiOperation({ summary: 'List milestone templates' }) - @ApiResponse({ status: 200, type: [MilestoneTemplateResponseDto] }) - async list(): Promise { - return this.milestoneTemplateService.findAll(); - } - - @Get(':milestoneTemplateId') - @ApiOperation({ summary: 'Get milestone template by id' }) - @ApiParam({ - name: 'milestoneTemplateId', - description: 'Milestone template id', - }) - @ApiResponse({ status: 200, type: MilestoneTemplateResponseDto }) - @ApiResponse({ status: 404, description: 'Not found' }) - async getOne( - @Param('milestoneTemplateId') milestoneTemplateId: string, - ): Promise { - return this.milestoneTemplateService.findOne( - this.milestoneTemplateService.parseId(milestoneTemplateId), - ); - } - - @Post() - @AdminOnly() - @ApiOperation({ summary: 'Create milestone template' }) - @ApiResponse({ status: 201, type: MilestoneTemplateResponseDto }) - @ApiResponse({ status: 403, description: 'Forbidden' }) - async create( - @Body() dto: CreateMilestoneTemplateDto, - @CurrentUser() user: JwtUser, - ): Promise { - return this.milestoneTemplateService.create( - dto, - getAuditUserIdBigInt(user), - ); - } - - @Post('clone') - @AdminOnly() - @ApiOperation({ summary: 'Clone milestone template' }) - @ApiResponse({ status: 201, type: MilestoneTemplateResponseDto }) - @ApiResponse({ status: 403, description: 'Forbidden' }) - async clone( - @Body() dto: CloneMilestoneTemplateDto, - @CurrentUser() user: JwtUser, - ): Promise { - return this.milestoneTemplateService.clone(dto, getAuditUserIdBigInt(user)); - } - - @Patch(':milestoneTemplateId') - @AdminOnly() - @ApiOperation({ summary: 'Update milestone template' }) - @ApiParam({ - name: 'milestoneTemplateId', - description: 'Milestone template id', - }) - @ApiResponse({ status: 200, type: MilestoneTemplateResponseDto }) - @ApiResponse({ status: 403, description: 'Forbidden' }) - @ApiResponse({ status: 404, description: 'Not found' }) - async update( - @Param('milestoneTemplateId') milestoneTemplateId: string, - @Body() dto: UpdateMilestoneTemplateDto, - @CurrentUser() user: JwtUser, - ): Promise { - return this.milestoneTemplateService.update( - this.milestoneTemplateService.parseId(milestoneTemplateId), - dto, - getAuditUserIdBigInt(user), - ); - } - - @Delete(':milestoneTemplateId') - @HttpCode(204) - @AdminOnly() - @ApiOperation({ summary: 'Delete milestone template (soft delete)' }) - @ApiParam({ - name: 'milestoneTemplateId', - description: 'Milestone template id', - }) - @ApiResponse({ status: 204, description: 'Deleted' }) - @ApiResponse({ status: 403, description: 'Forbidden' }) - @ApiResponse({ status: 404, description: 'Not found' }) - async delete( - @Param('milestoneTemplateId') milestoneTemplateId: string, - @CurrentUser() user: JwtUser, - ): Promise { - await this.milestoneTemplateService.delete( - this.milestoneTemplateService.parseId(milestoneTemplateId), - getAuditUserIdBigInt(user), - ); - } -} diff --git a/src/api/metadata/milestone-template/milestone-template.module.ts b/src/api/metadata/milestone-template/milestone-template.module.ts index 43b6eea..1d99270 100644 --- a/src/api/metadata/milestone-template/milestone-template.module.ts +++ b/src/api/metadata/milestone-template/milestone-template.module.ts @@ -1,11 +1,9 @@ import { Module } from '@nestjs/common'; import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; -import { MilestoneTemplateController } from './milestone-template.controller'; import { MilestoneTemplateService } from './milestone-template.service'; @Module({ imports: [GlobalProvidersModule], - controllers: [MilestoneTemplateController], providers: [MilestoneTemplateService], exports: [MilestoneTemplateService], }) diff --git a/src/api/metadata/utils/metadata-event.utils.ts b/src/api/metadata/utils/metadata-event.utils.ts index 833b383..5d0be86 100644 --- a/src/api/metadata/utils/metadata-event.utils.ts +++ b/src/api/metadata/utils/metadata-event.utils.ts @@ -1,5 +1,4 @@ import { - PROJECT_METADATA_EVENT_TOPIC, PROJECT_METADATA_RESOURCE, ProjectMetadataResource, } from 'src/shared/constants/event.constants'; @@ -10,7 +9,7 @@ export type MetadataEventAction = | 'PROJECT_METADATA_UPDATE' | 'PROJECT_METADATA_DELETE'; -export async function publishMetadataEvent( +export function publishMetadataEvent( eventBus: EventBusService, action: MetadataEventAction, resource: ProjectMetadataResource, @@ -18,15 +17,15 @@ export async function publishMetadataEvent( data: unknown, userId: bigint | number, ): Promise { - const topic = PROJECT_METADATA_EVENT_TOPIC[action]; - - await eventBus.publishProjectEvent(topic, { - resource, - id: typeof id === 'bigint' ? id.toString() : String(id), - data, - userId: typeof userId === 'bigint' ? userId.toString() : userId, - timestamp: new Date(), - }); + // 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; + void action; + void resource; + void id; + void data; + void userId; + return Promise.resolve(); } export { PROJECT_METADATA_RESOURCE }; diff --git a/src/api/milestone/dto/bulk-update-milestone.dto.ts b/src/api/milestone/dto/bulk-update-milestone.dto.ts deleted file mode 100644 index 8d0095d..0000000 --- a/src/api/milestone/dto/bulk-update-milestone.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsInt, IsOptional, Min } from 'class-validator'; -import { UpdateMilestoneDto } from './update-milestone.dto'; - -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); -} - -export class BulkUpdateMilestoneDto extends UpdateMilestoneDto { - @ApiPropertyOptional({ - description: - 'Existing milestone id. If omitted, a new milestone is created as part of the bulk operation.', - minimum: 1, - }) - @IsOptional() - @Transform(({ value }) => parseOptionalInteger(value)) - @IsInt() - @Min(1) - id?: number; -} diff --git a/src/api/milestone/dto/create-milestone.dto.ts b/src/api/milestone/dto/create-milestone.dto.ts deleted file mode 100644 index 513aba2..0000000 --- a/src/api/milestone/dto/create-milestone.dto.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { ProjectStatus } from '@prisma/client'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform, Type } from 'class-transformer'; -import { - IsBoolean, - IsDate, - IsEnum, - IsInt, - IsNotEmpty, - IsObject, - IsOptional, - IsString, - MaxLength, - Min, -} from 'class-validator'; - -function parseOptionalInteger(value: unknown): number | undefined { - if (typeof value === 'undefined' || value === null || value === '') { - return undefined; - } - - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return undefined; - } - - return Math.trunc(parsed); -} - -export class CreateMilestoneDto { - @ApiProperty() - @IsString() - @IsNotEmpty() - @MaxLength(255) - name: string; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - @MaxLength(255) - description?: string; - - @ApiProperty({ minimum: 1 }) - @Transform(({ value }) => parseOptionalInteger(value)) - @IsInt() - @Min(1) - duration: number; - - @ApiProperty() - @Type(() => Date) - @IsDate() - startDate: Date; - - @ApiPropertyOptional() - @IsOptional() - @Type(() => Date) - @IsDate() - actualStartDate?: Date | null; - - @ApiPropertyOptional() - @IsOptional() - @Type(() => Date) - @IsDate() - endDate?: Date | null; - - @ApiPropertyOptional() - @IsOptional() - @Type(() => Date) - @IsDate() - completionDate?: Date | null; - - @ApiProperty({ - enum: ProjectStatus, - enumName: 'ProjectStatus', - }) - @IsEnum(ProjectStatus) - status: ProjectStatus; - - @ApiProperty() - @IsString() - @IsNotEmpty() - @MaxLength(45) - type: string; - - @ApiPropertyOptional({ - type: 'object', - additionalProperties: true, - }) - @IsOptional() - @IsObject() - details?: Record; - - @ApiProperty({ minimum: 1 }) - @Transform(({ value }) => parseOptionalInteger(value)) - @IsInt() - @Min(1) - order: number; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - @MaxLength(512) - plannedText?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - @MaxLength(512) - activeText?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - @MaxLength(512) - completedText?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - @MaxLength(512) - blockedText?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsBoolean() - hidden?: boolean; -} diff --git a/src/api/milestone/dto/milestone-list-query.dto.ts b/src/api/milestone/dto/milestone-list-query.dto.ts deleted file mode 100644 index b729d36..0000000 --- a/src/api/milestone/dto/milestone-list-query.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional } from 'class-validator'; - -export class MilestoneListQueryDto { - @ApiPropertyOptional({ - description: 'Sort by order. Allowed values: order asc, order desc.', - default: 'order asc', - }) - @IsOptional() - @IsIn(['order asc', 'order desc']) - sort?: 'order asc' | 'order desc'; -} diff --git a/src/api/milestone/dto/milestone-response.dto.ts b/src/api/milestone/dto/milestone-response.dto.ts deleted file mode 100644 index 71dfda6..0000000 --- a/src/api/milestone/dto/milestone-response.dto.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { ProjectStatus } from '@prisma/client'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { StatusHistoryResponseDto } from './status-history-response.dto'; - -export class MilestoneResponseDto { - @ApiProperty() - id: string; - - @ApiProperty() - timelineId: string; - - @ApiProperty() - name: string; - - @ApiPropertyOptional({ nullable: true }) - description?: string | null; - - @ApiProperty() - duration: number; - - @ApiProperty() - startDate: Date; - - @ApiPropertyOptional({ nullable: true }) - actualStartDate?: Date | null; - - @ApiPropertyOptional({ nullable: true }) - endDate?: Date | null; - - @ApiPropertyOptional({ nullable: true }) - completionDate?: Date | null; - - @ApiProperty({ - enum: ProjectStatus, - enumName: 'ProjectStatus', - }) - status: ProjectStatus; - - @ApiProperty() - type: string; - - @ApiPropertyOptional({ - type: 'object', - additionalProperties: true, - nullable: true, - }) - details?: Record | null; - - @ApiProperty() - order: number; - - @ApiPropertyOptional({ nullable: true }) - plannedText?: string | null; - - @ApiPropertyOptional({ nullable: true }) - activeText?: string | null; - - @ApiPropertyOptional({ nullable: true }) - completedText?: string | null; - - @ApiPropertyOptional({ nullable: true }) - blockedText?: string | null; - - @ApiProperty() - hidden: boolean; - - @ApiProperty({ type: () => [StatusHistoryResponseDto] }) - statusHistory: StatusHistoryResponseDto[]; - - @ApiProperty() - createdAt: Date; - - @ApiProperty() - updatedAt: Date; - - @ApiProperty() - createdBy: string; - - @ApiProperty() - updatedBy: string; -} diff --git a/src/api/milestone/dto/status-history-response.dto.ts b/src/api/milestone/dto/status-history-response.dto.ts deleted file mode 100644 index d000840..0000000 --- a/src/api/milestone/dto/status-history-response.dto.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ProjectStatus } from '@prisma/client'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class StatusHistoryResponseDto { - @ApiProperty() - id: string; - - @ApiProperty() - reference: string; - - @ApiProperty() - referenceId: string; - - @ApiProperty({ - enum: ProjectStatus, - enumName: 'ProjectStatus', - }) - status: ProjectStatus; - - @ApiPropertyOptional({ nullable: true }) - comment?: string | null; - - @ApiProperty() - createdBy: number; - - @ApiProperty() - createdAt: Date; - - @ApiProperty() - updatedBy: number; - - @ApiProperty() - updatedAt: Date; -} diff --git a/src/api/milestone/dto/update-milestone.dto.ts b/src/api/milestone/dto/update-milestone.dto.ts deleted file mode 100644 index b397053..0000000 --- a/src/api/milestone/dto/update-milestone.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MaxLength } from 'class-validator'; -import { CreateMilestoneDto } from './create-milestone.dto'; - -export class UpdateMilestoneDto extends PartialType(CreateMilestoneDto) { - @ApiPropertyOptional({ - description: - 'Optional comment stored in milestone status history when status changes.', - }) - @IsOptional() - @IsString() - @MaxLength(4000) - statusComment?: string; -} diff --git a/src/api/milestone/milestone.controller.ts b/src/api/milestone/milestone.controller.ts deleted file mode 100644 index 7056dd7..0000000 --- a/src/api/milestone/milestone.controller.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - Param, - Patch, - Post, - Query, - UseGuards, -} from '@nestjs/common'; -import { - ApiBearerAuth, - ApiBody, - ApiOperation, - ApiParam, - ApiQuery, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; -import { Permission } from 'src/shared/constants/permissions'; -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'; -import { TimelineProjectContextGuard } from '../timeline/guards/timeline-project-context.guard'; -import { BulkUpdateMilestoneDto } from './dto/bulk-update-milestone.dto'; -import { CreateMilestoneDto } from './dto/create-milestone.dto'; -import { MilestoneListQueryDto } from './dto/milestone-list-query.dto'; -import { MilestoneResponseDto } from './dto/milestone-response.dto'; -import { UpdateMilestoneDto } from './dto/update-milestone.dto'; -import { MilestoneService } from './milestone.service'; - -@ApiTags('Milestones') -@ApiBearerAuth() -@Controller('/timelines/:timelineId/milestones') -export class MilestoneController { - constructor(private readonly service: MilestoneService) {} - - @Get() - @UseGuards(TimelineProjectContextGuard, PermissionGuard) - @Roles(...Object.values(UserRole)) - @Scopes( - Scope.PROJECTS_READ, - Scope.PROJECTS_WRITE, - Scope.PROJECTS_ALL, - Scope.CONNECT_PROJECT_ADMIN, - ) - @RequirePermission(Permission.VIEW_PROJECT) - @ApiOperation({ summary: 'List milestones for timeline' }) - @ApiParam({ name: 'timelineId', description: 'Timeline id' }) - @ApiQuery({ - name: 'sort', - required: false, - enum: ['order asc', 'order desc'], - }) - @ApiResponse({ status: 200, type: [MilestoneResponseDto] }) - async listMilestones( - @Param('timelineId') timelineId: string, - @Query() query: MilestoneListQueryDto, - ): Promise { - return this.service.listMilestones(timelineId, query); - } - - @Get(':milestoneId') - @UseGuards(TimelineProjectContextGuard, PermissionGuard) - @Roles(...Object.values(UserRole)) - @Scopes( - Scope.PROJECTS_READ, - Scope.PROJECTS_WRITE, - Scope.PROJECTS_ALL, - Scope.CONNECT_PROJECT_ADMIN, - ) - @RequirePermission(Permission.VIEW_PROJECT) - @ApiOperation({ summary: 'Get milestone by id' }) - @ApiParam({ name: 'timelineId', description: 'Timeline id' }) - @ApiParam({ name: 'milestoneId', description: 'Milestone id' }) - @ApiResponse({ status: 200, type: MilestoneResponseDto }) - @ApiResponse({ status: 404, description: 'Not found' }) - async getMilestone( - @Param('timelineId') timelineId: string, - @Param('milestoneId') milestoneId: string, - ): Promise { - return this.service.getMilestone(timelineId, milestoneId); - } - - @Post() - @UseGuards(TimelineProjectContextGuard, PermissionGuard) - @Roles(...Object.values(UserRole)) - @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) - @RequirePermission(Permission.EDIT_PROJECT) - @ApiOperation({ summary: 'Create milestone' }) - @ApiParam({ name: 'timelineId', description: 'Timeline id' }) - @ApiBody({ type: CreateMilestoneDto }) - @ApiResponse({ status: 201, type: MilestoneResponseDto }) - async createMilestone( - @Param('timelineId') timelineId: string, - @Body() dto: CreateMilestoneDto, - @CurrentUser() user: JwtUser, - ): Promise { - return this.service.createMilestone(timelineId, dto, user); - } - - @Patch() - @UseGuards(TimelineProjectContextGuard, PermissionGuard) - @Roles(...Object.values(UserRole)) - @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) - @RequirePermission(Permission.EDIT_PROJECT) - @ApiOperation({ summary: 'Bulk update milestones' }) - @ApiParam({ name: 'timelineId', description: 'Timeline id' }) - @ApiBody({ type: [BulkUpdateMilestoneDto] }) - @ApiResponse({ status: 200, type: [MilestoneResponseDto] }) - async bulkUpdateMilestones( - @Param('timelineId') timelineId: string, - @Body() dto: BulkUpdateMilestoneDto[], - @CurrentUser() user: JwtUser, - ): Promise { - return this.service.bulkUpdateMilestones(timelineId, dto, user); - } - - @Patch(':milestoneId') - @UseGuards(TimelineProjectContextGuard, PermissionGuard) - @Roles(...Object.values(UserRole)) - @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) - @RequirePermission(Permission.EDIT_PROJECT) - @ApiOperation({ summary: 'Update milestone' }) - @ApiParam({ name: 'timelineId', description: 'Timeline id' }) - @ApiParam({ name: 'milestoneId', description: 'Milestone id' }) - @ApiBody({ type: UpdateMilestoneDto }) - @ApiResponse({ status: 200, type: MilestoneResponseDto }) - async updateMilestone( - @Param('timelineId') timelineId: string, - @Param('milestoneId') milestoneId: string, - @Body() dto: UpdateMilestoneDto, - @CurrentUser() user: JwtUser, - ): Promise { - return this.service.updateMilestone(timelineId, milestoneId, dto, user); - } - - @Delete(':milestoneId') - @HttpCode(204) - @UseGuards(TimelineProjectContextGuard, PermissionGuard) - @Roles(...Object.values(UserRole)) - @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) - @RequirePermission(Permission.EDIT_PROJECT) - @ApiOperation({ summary: 'Delete milestone' }) - @ApiParam({ name: 'timelineId', description: 'Timeline id' }) - @ApiParam({ name: 'milestoneId', description: 'Milestone id' }) - @ApiResponse({ status: 204, description: 'Deleted' }) - async deleteMilestone( - @Param('timelineId') timelineId: string, - @Param('milestoneId') milestoneId: string, - @CurrentUser() user: JwtUser, - ): Promise { - await this.service.deleteMilestone(timelineId, milestoneId, user); - } -} diff --git a/src/api/milestone/milestone.module.ts b/src/api/milestone/milestone.module.ts deleted file mode 100644 index a0b1fa1..0000000 --- a/src/api/milestone/milestone.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; -import { TimelineModule } from '../timeline/timeline.module'; -import { MilestoneController } from './milestone.controller'; -import { MilestoneService } from './milestone.service'; - -@Module({ - imports: [GlobalProvidersModule, TimelineModule], - controllers: [MilestoneController], - providers: [MilestoneService], - exports: [MilestoneService], -}) -export class MilestoneModule {} diff --git a/src/api/milestone/milestone.service.spec.ts b/src/api/milestone/milestone.service.spec.ts deleted file mode 100644 index 93ae2ed..0000000 --- a/src/api/milestone/milestone.service.spec.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { ProjectStatus, TimelineReference } from '@prisma/client'; -import { KAFKA_TOPIC } from 'src/shared/config/kafka.config'; -import { MilestoneService } from './milestone.service'; - -jest.mock('src/shared/utils/event.utils', () => ({ - publishMilestoneEvent: jest.fn(() => Promise.resolve()), - publishNotificationEvent: jest.fn(() => Promise.resolve()), -})); - -const eventUtils = jest.requireMock('src/shared/utils/event.utils'); - -function buildTimeline(overrides: Record = {}) { - return { - id: BigInt(1), - name: 'Execution', - description: null, - startDate: new Date('2026-02-01T00:00:00.000Z'), - endDate: null, - reference: TimelineReference.project, - referenceId: BigInt(1001), - deletedAt: null, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - deletedBy: null, - createdBy: BigInt(123), - updatedBy: BigInt(123), - ...overrides, - }; -} - -function buildMilestone(overrides: Record = {}) { - return { - id: BigInt(11), - timelineId: BigInt(1), - name: 'Kickoff', - description: null, - duration: 2, - startDate: new Date('2026-02-01T00:00:00.000Z'), - actualStartDate: null, - endDate: new Date('2026-02-02T00:00:00.000Z'), - completionDate: null, - status: ProjectStatus.reviewed, - type: 'phase', - details: {}, - order: 1, - plannedText: 'planned', - activeText: 'active', - completedText: 'completed', - blockedText: 'blocked', - hidden: false, - deletedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedBy: null, - createdBy: BigInt(123), - updatedBy: BigInt(123), - statusHistory: [ - { - id: BigInt(1), - reference: 'milestone', - referenceId: BigInt(11), - status: ProjectStatus.reviewed, - comment: null, - createdBy: 123, - createdAt: new Date(), - updatedBy: 123, - updatedAt: new Date(), - }, - ], - ...overrides, - }; -} - -describe('MilestoneService', () => { - const prismaMock = { - timeline: { - findFirst: jest.fn(), - }, - milestone: { - findMany: jest.fn(), - findFirst: jest.fn(), - }, - project: { - findFirst: jest.fn(), - }, - $transaction: jest.fn(), - }; - - const timelineReferenceServiceMock = { - resolveProjectContextByTimelineId: jest.fn(), - }; - - let service: MilestoneService; - - beforeEach(() => { - jest.clearAllMocks(); - - service = new MilestoneService( - prismaMock as any, - timelineReferenceServiceMock as any, - ); - - prismaMock.timeline.findFirst.mockResolvedValue(buildTimeline()); - prismaMock.milestone.findMany.mockResolvedValue([]); - timelineReferenceServiceMock.resolveProjectContextByTimelineId.mockResolvedValue( - { - projectId: BigInt(1001), - }, - ); - prismaMock.project.findFirst.mockResolvedValue({ - id: BigInt(1001), - name: 'Demo Project', - details: {}, - }); - }); - - it('creates milestone with order shift and status history entry', async () => { - const txMilestoneCount = jest.fn().mockResolvedValue(2); - const txMilestoneUpdateMany = jest.fn().mockResolvedValue({ count: 1 }); - const txMilestoneCreate = jest.fn().mockResolvedValue( - buildMilestone({ - id: BigInt(55), - order: 1, - }), - ); - const txStatusHistoryCreate = jest.fn().mockResolvedValue({}); - - prismaMock.$transaction.mockImplementation( - async (callback: (tx: unknown) => Promise) => - callback({ - milestone: { - count: txMilestoneCount, - updateMany: txMilestoneUpdateMany, - create: txMilestoneCreate, - }, - statusHistory: { - create: txStatusHistoryCreate, - }, - }), - ); - - prismaMock.milestone.findFirst.mockResolvedValue( - buildMilestone({ - id: BigInt(55), - order: 1, - }), - ); - - const response = await service.createMilestone( - '1', - { - name: 'Kickoff', - duration: 2, - startDate: new Date('2026-02-01T00:00:00.000Z'), - status: ProjectStatus.reviewed, - type: 'phase', - order: 1, - }, - { - userId: '123', - isMachine: false, - }, - ); - - expect(response.id).toBe('55'); - expect(txMilestoneUpdateMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - order: { - gte: 1, - }, - }), - }), - ); - expect(txStatusHistoryCreate).toHaveBeenCalled(); - expect(eventUtils.publishMilestoneEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.MILESTONE_ADDED, - expect.any(Object), - ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.MILESTONE_NOTIFICATION_ADDED, - expect.any(Object), - ); - }); - - it('bulk updates milestones with create, update and delete operations', async () => { - const existingMilestones = [ - buildMilestone({ id: BigInt(11), order: 1 }), - buildMilestone({ - id: BigInt(12), - order: 2, - status: ProjectStatus.active, - statusHistory: [ - { - id: BigInt(2), - reference: 'milestone', - referenceId: BigInt(12), - status: ProjectStatus.active, - comment: null, - createdBy: 123, - createdAt: new Date(), - updatedBy: 123, - updatedAt: new Date(), - }, - ], - }), - ]; - - const txMilestoneFindMany = jest - .fn() - .mockResolvedValueOnce(existingMilestones) - .mockResolvedValueOnce([ - { - id: BigInt(11), - order: 1, - }, - { - id: BigInt(99), - order: 9, - }, - ]) - .mockResolvedValueOnce([ - buildMilestone({ - id: BigInt(11), - order: 1, - status: ProjectStatus.completed, - statusHistory: [ - { - id: BigInt(3), - reference: 'milestone', - referenceId: BigInt(11), - status: ProjectStatus.completed, - comment: 'done', - createdBy: 123, - createdAt: new Date(), - updatedBy: 123, - updatedAt: new Date(), - }, - ], - }), - buildMilestone({ - id: BigInt(99), - order: 2, - name: 'New Milestone', - }), - ]); - - const txMilestoneUpdateMany = jest.fn().mockResolvedValue({ count: 1 }); - const txMilestoneUpdate = jest.fn().mockResolvedValue( - buildMilestone({ - id: BigInt(11), - status: ProjectStatus.completed, - }), - ); - const txMilestoneCreate = jest.fn().mockResolvedValue( - buildMilestone({ - id: BigInt(99), - order: 9, - name: 'New Milestone', - }), - ); - const txMilestoneFindFirst = jest.fn().mockResolvedValueOnce( - buildMilestone({ - id: BigInt(11), - status: ProjectStatus.completed, - statusHistory: [ - { - id: BigInt(3), - reference: 'milestone', - referenceId: BigInt(11), - status: ProjectStatus.completed, - comment: 'done', - createdBy: 123, - createdAt: new Date(), - updatedBy: 123, - updatedAt: new Date(), - }, - ], - }), - ); - const txStatusHistoryCreate = jest.fn().mockResolvedValue({}); - - prismaMock.$transaction.mockImplementation( - async (callback: (tx: unknown) => Promise) => - callback({ - milestone: { - findMany: txMilestoneFindMany, - updateMany: txMilestoneUpdateMany, - update: txMilestoneUpdate, - create: txMilestoneCreate, - findFirst: txMilestoneFindFirst, - }, - statusHistory: { - create: txStatusHistoryCreate, - }, - }), - ); - - const response = await service.bulkUpdateMilestones( - '1', - [ - { - id: 11, - status: ProjectStatus.completed, - statusComment: 'done', - order: 1, - }, - { - name: 'New Milestone', - duration: 1, - startDate: new Date('2026-02-03T00:00:00.000Z'), - status: ProjectStatus.reviewed, - type: 'phase', - order: 2, - }, - ], - { - userId: '123', - isMachine: false, - }, - ); - - expect(response).toHaveLength(2); - expect(txMilestoneUpdateMany).toHaveBeenCalled(); - expect(txMilestoneCreate).toHaveBeenCalled(); - expect(txMilestoneUpdate).toHaveBeenCalled(); - expect(txStatusHistoryCreate).toHaveBeenCalled(); - expect(eventUtils.publishMilestoneEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.MILESTONE_ADDED, - expect.any(Object), - ); - expect(eventUtils.publishMilestoneEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.MILESTONE_REMOVED, - expect.any(Object), - ); - expect(eventUtils.publishMilestoneEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.MILESTONE_UPDATED, - expect.any(Object), - ); - }); -}); diff --git a/src/api/milestone/milestone.service.ts b/src/api/milestone/milestone.service.ts deleted file mode 100644 index f08db6d..0000000 --- a/src/api/milestone/milestone.service.ts +++ /dev/null @@ -1,1409 +0,0 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { - Milestone, - Prisma, - Project, - ProjectStatus, - StatusHistory, - Timeline, -} from '@prisma/client'; -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 { - publishMilestoneEvent, - publishNotificationEvent, -} from 'src/shared/utils/event.utils'; -import { toSerializable } from '../metadata/utils/metadata-utils'; -import { TimelineReferenceService } from '../timeline/timeline-reference.service'; -import { BulkUpdateMilestoneDto } from './dto/bulk-update-milestone.dto'; -import { CreateMilestoneDto } from './dto/create-milestone.dto'; -import { MilestoneListQueryDto } from './dto/milestone-list-query.dto'; -import { MilestoneResponseDto } from './dto/milestone-response.dto'; -import { StatusHistoryResponseDto } from './dto/status-history-response.dto'; -import { UpdateMilestoneDto } from './dto/update-milestone.dto'; - -type MilestoneWithStatusHistory = Milestone & { - statusHistory?: StatusHistory[]; -}; - -interface MilestoneUpdateResult { - original: MilestoneResponseDto; - updated: MilestoneResponseDto; -} - -@Injectable() -export class MilestoneService { - private readonly logger = LoggerService.forRoot('MilestoneService'); - - constructor( - private readonly prisma: PrismaService, - private readonly timelineReferenceService: TimelineReferenceService, - ) {} - - async listMilestones( - timelineId: string, - query: MilestoneListQueryDto, - ): Promise { - const parsedTimelineId = this.parseId(timelineId, 'timelineId'); - - await this.getTimelineOrFail(parsedTimelineId); - - const sort = query.sort || 'order asc'; - - const milestones = await this.prisma.milestone.findMany({ - where: { - timelineId: parsedTimelineId, - deletedAt: null, - }, - include: this.statusHistoryInclude(), - orderBy: this.toOrderBy(sort), - }); - - return milestones.map((milestone) => - this.toMilestoneDto(milestone as MilestoneWithStatusHistory), - ); - } - - async getMilestone( - timelineId: string, - milestoneId: string, - ): Promise { - const parsedTimelineId = this.parseId(timelineId, 'timelineId'); - const parsedMilestoneId = this.parseId(milestoneId, 'milestoneId'); - - const milestone = await this.prisma.milestone.findFirst({ - where: { - id: parsedMilestoneId, - timelineId: parsedTimelineId, - deletedAt: null, - }, - include: this.statusHistoryInclude(), - }); - - if (!milestone) { - throw new NotFoundException( - `Milestone not found for milestone id ${milestoneId}.`, - ); - } - - return this.toMilestoneDto(milestone as MilestoneWithStatusHistory); - } - - async createMilestone( - timelineId: string, - dto: CreateMilestoneDto, - user: JwtUser, - ): Promise { - const parsedTimelineId = this.parseId(timelineId, 'timelineId'); - const auditUserId = this.getAuditUserIdBigInt(user); - const statusAuditUserId = this.getAuditUserIdInt(user); - - const timeline = await this.getTimelineOrFail(parsedTimelineId); - - const [createdMilestone, context] = await this.prisma.$transaction( - async (tx) => { - const milestoneCount = await tx.milestone.count({ - where: { - timelineId: parsedTimelineId, - deletedAt: null, - }, - }); - - const order = this.resolveRequestedOrder(dto.order, milestoneCount + 1); - - await tx.milestone.updateMany({ - where: { - timelineId: parsedTimelineId, - deletedAt: null, - order: { - gte: order, - }, - }, - data: { - order: { - increment: 1, - }, - updatedBy: auditUserId, - }, - }); - - this.validateMilestoneDates(dto.startDate, dto.endDate ?? null); - this.validateMilestoneCompletionDates( - dto.actualStartDate ?? null, - dto.completionDate ?? null, - ); - - const endDate = - dto.endDate || this.addDaysUtc(dto.startDate, dto.duration - 1); - - const created = await tx.milestone.create({ - data: { - timelineId: parsedTimelineId, - name: dto.name, - description: dto.description || null, - duration: dto.duration, - startDate: dto.startDate, - actualStartDate: dto.actualStartDate || null, - endDate, - completionDate: dto.completionDate || null, - status: dto.status, - type: dto.type, - details: this.toJsonInput(dto.details || {}), - order, - plannedText: dto.plannedText || null, - activeText: dto.activeText || null, - completedText: dto.completedText || null, - blockedText: dto.blockedText || null, - hidden: Boolean(dto.hidden), - createdBy: auditUserId, - updatedBy: auditUserId, - }, - }); - - await tx.statusHistory.create({ - data: { - reference: 'milestone', - referenceId: created.id, - status: created.status, - comment: null, - createdBy: statusAuditUserId, - updatedBy: statusAuditUserId, - }, - }); - - const contextByTimeline = - await this.timelineReferenceService.resolveProjectContextByTimelineId( - parsedTimelineId, - ); - - return [created, contextByTimeline] as const; - }, - ); - - const createdWithHistory = await this.prisma.milestone.findFirst({ - where: { - id: createdMilestone.id, - timelineId: parsedTimelineId, - deletedAt: null, - }, - include: this.statusHistoryInclude(), - }); - - if (!createdWithHistory) { - throw new NotFoundException( - `Milestone not found for milestone id ${createdMilestone.id.toString()} after creation.`, - ); - } - - const response = this.toMilestoneDto( - createdWithHistory as MilestoneWithStatusHistory, - ); - - this.publishMilestoneAction(KAFKA_TOPIC.MILESTONE_ADDED, response); - - await this.publishMilestoneAddedNotification( - context.projectId, - response, - user, - timeline, - ); - - return response; - } - - async updateMilestone( - timelineId: string, - milestoneId: string, - dto: UpdateMilestoneDto, - user: JwtUser, - ): Promise { - const parsedTimelineId = this.parseId(timelineId, 'timelineId'); - const parsedMilestoneId = this.parseId(milestoneId, 'milestoneId'); - const auditUserId = this.getAuditUserIdBigInt(user); - const statusAuditUserId = this.getAuditUserIdInt(user); - - const timeline = await this.getTimelineOrFail(parsedTimelineId); - - const { original, updated } = await this.prisma.$transaction(async (tx) => { - const existingMilestone = await tx.milestone.findFirst({ - where: { - id: parsedMilestoneId, - timelineId: parsedTimelineId, - deletedAt: null, - }, - }); - - if (!existingMilestone) { - throw new NotFoundException( - `Milestone not found for milestone id ${milestoneId}.`, - ); - } - - const totalMilestones = await tx.milestone.count({ - where: { - timelineId: parsedTimelineId, - deletedAt: null, - }, - }); - - const resolvedOrder = this.resolveRequestedOrder( - dto.order, - totalMilestones, - existingMilestone.order, - ); - - if (resolvedOrder < existingMilestone.order) { - await tx.milestone.updateMany({ - where: { - timelineId: parsedTimelineId, - deletedAt: null, - id: { - not: parsedMilestoneId, - }, - order: { - gte: resolvedOrder, - lt: existingMilestone.order, - }, - }, - data: { - order: { - increment: 1, - }, - updatedBy: auditUserId, - }, - }); - } - - if (resolvedOrder > existingMilestone.order) { - await tx.milestone.updateMany({ - where: { - timelineId: parsedTimelineId, - deletedAt: null, - id: { - not: parsedMilestoneId, - }, - order: { - lte: resolvedOrder, - gt: existingMilestone.order, - }, - }, - data: { - order: { - decrement: 1, - }, - updatedBy: auditUserId, - }, - }); - } - - const original = existingMilestone; - - const startDate = dto.startDate || original.startDate; - const duration = - typeof dto.duration === 'number' ? dto.duration : original.duration; - const endDate = - typeof dto.endDate !== 'undefined' - ? dto.endDate - : original.endDate || this.addDaysUtc(startDate, duration - 1); - const actualStartDate = - typeof dto.actualStartDate !== 'undefined' - ? dto.actualStartDate - : original.actualStartDate; - const completionDate = - typeof dto.completionDate !== 'undefined' - ? dto.completionDate - : original.completionDate; - - this.validateMilestoneDates(startDate, endDate); - this.validateMilestoneCompletionDates(actualStartDate, completionDate); - - const mergedDetails = - typeof dto.details === 'undefined' - ? original.details - : this.mergeDetails(original.details, dto.details); - - const updatedMilestone = await tx.milestone.update({ - where: { - id: parsedMilestoneId, - }, - data: { - ...(typeof dto.name === 'undefined' ? {} : { name: dto.name }), - ...(typeof dto.description === 'undefined' - ? {} - : { description: dto.description }), - ...(typeof dto.duration === 'undefined' - ? {} - : { duration: dto.duration }), - ...(typeof dto.startDate === 'undefined' ? {} : { startDate }), - ...(typeof dto.actualStartDate === 'undefined' - ? {} - : { actualStartDate }), - ...(typeof dto.endDate === 'undefined' ? {} : { endDate }), - ...(typeof dto.completionDate === 'undefined' - ? {} - : { completionDate }), - ...(typeof dto.status === 'undefined' ? {} : { status: dto.status }), - ...(typeof dto.type === 'undefined' ? {} : { type: dto.type }), - ...(typeof dto.details === 'undefined' - ? {} - : { details: this.toJsonInput(mergedDetails) }), - order: resolvedOrder, - ...(typeof dto.plannedText === 'undefined' - ? {} - : { plannedText: dto.plannedText }), - ...(typeof dto.activeText === 'undefined' - ? {} - : { activeText: dto.activeText }), - ...(typeof dto.completedText === 'undefined' - ? {} - : { completedText: dto.completedText }), - ...(typeof dto.blockedText === 'undefined' - ? {} - : { blockedText: dto.blockedText }), - ...(typeof dto.hidden === 'undefined' ? {} : { hidden: dto.hidden }), - updatedBy: auditUserId, - }, - }); - - if (original.status !== updatedMilestone.status) { - await tx.statusHistory.create({ - data: { - reference: 'milestone', - referenceId: updatedMilestone.id, - status: updatedMilestone.status, - comment: dto.statusComment || null, - createdBy: statusAuditUserId, - updatedBy: statusAuditUserId, - }, - }); - } - - const [originalWithHistory, updatedWithHistory] = await Promise.all([ - tx.milestone.findFirst({ - where: { - id: original.id, - }, - include: this.statusHistoryInclude(), - }), - tx.milestone.findFirst({ - where: { - id: updatedMilestone.id, - }, - include: this.statusHistoryInclude(), - }), - ]); - - if (!originalWithHistory || !updatedWithHistory) { - throw new NotFoundException( - `Milestone not found for milestone id ${milestoneId} after update.`, - ); - } - - return { - original: this.toMilestoneDto( - originalWithHistory as MilestoneWithStatusHistory, - ), - updated: this.toMilestoneDto( - updatedWithHistory as MilestoneWithStatusHistory, - ), - }; - }); - - this.publishMilestoneAction(KAFKA_TOPIC.MILESTONE_UPDATED, { - original, - updated, - }); - - const context = - await this.timelineReferenceService.resolveProjectContextByTimelineId( - parsedTimelineId, - ); - - await this.publishMilestoneUpdatedNotifications( - context.projectId, - original, - updated, - user, - timeline, - ); - - return updated; - } - - async bulkUpdateMilestones( - timelineId: string, - updates: BulkUpdateMilestoneDto[], - user: JwtUser, - ): Promise { - const parsedTimelineId = this.parseId(timelineId, 'timelineId'); - const auditUserId = this.getAuditUserIdBigInt(user); - const statusAuditUserId = this.getAuditUserIdInt(user); - - const timeline = await this.getTimelineOrFail(parsedTimelineId); - - const operationResult = await this.prisma.$transaction(async (tx) => { - const existingMilestones = await tx.milestone.findMany({ - where: { - timelineId: parsedTimelineId, - deletedAt: null, - }, - include: this.statusHistoryInclude(), - orderBy: [{ order: 'asc' }, { id: 'asc' }], - }); - - const existingById = new Map( - existingMilestones.map((milestone) => [ - milestone.id.toString(), - milestone as MilestoneWithStatusHistory, - ]), - ); - - const keepIds = new Set(); - for (const update of updates) { - if (typeof update.id === 'number') { - keepIds.add(String(update.id)); - } - } - - for (const id of keepIds) { - if (!existingById.has(id)) { - throw new NotFoundException( - `Milestone not found for milestone id ${id}.`, - ); - } - } - - const toDelete = existingMilestones.filter( - (milestone) => !keepIds.has(milestone.id.toString()), - ); - - if (toDelete.length > 0) { - await tx.milestone.updateMany({ - where: { - id: { - in: toDelete.map((milestone) => milestone.id), - }, - }, - data: { - deletedAt: new Date(), - deletedBy: auditUserId, - updatedBy: auditUserId, - }, - }); - } - - const createdIds: bigint[] = []; - const updatedRecords: MilestoneUpdateResult[] = []; - - const maxExistingOrder = existingMilestones.reduce( - (maxOrder, milestone) => Math.max(maxOrder, milestone.order), - 0, - ); - let nextOrder = Math.max(maxExistingOrder, updates.length) + 1; - - for (const update of updates) { - if (typeof update.id === 'number') { - const milestone = existingById.get(String(update.id)); - if (!milestone) { - throw new NotFoundException( - `Milestone not found for milestone id ${update.id}.`, - ); - } - - const startDate = update.startDate || milestone.startDate; - const duration = - typeof update.duration === 'number' - ? update.duration - : milestone.duration; - const endDate = - typeof update.endDate !== 'undefined' - ? update.endDate - : milestone.endDate || this.addDaysUtc(startDate, duration - 1); - const actualStartDate = - typeof update.actualStartDate !== 'undefined' - ? update.actualStartDate - : milestone.actualStartDate; - const completionDate = - typeof update.completionDate !== 'undefined' - ? update.completionDate - : milestone.completionDate; - - this.validateMilestoneDates(startDate, endDate); - this.validateMilestoneCompletionDates( - actualStartDate, - completionDate, - ); - - const mergedDetails = - typeof update.details === 'undefined' - ? milestone.details - : this.mergeDetails(milestone.details, update.details); - - const updatedMilestone = await tx.milestone.update({ - where: { - id: milestone.id, - }, - data: { - ...(typeof update.name === 'undefined' - ? {} - : { name: update.name }), - ...(typeof update.description === 'undefined' - ? {} - : { description: update.description }), - ...(typeof update.duration === 'undefined' - ? {} - : { duration: update.duration }), - ...(typeof update.startDate === 'undefined' ? {} : { startDate }), - ...(typeof update.actualStartDate === 'undefined' - ? {} - : { actualStartDate }), - ...(typeof update.endDate === 'undefined' ? {} : { endDate }), - ...(typeof update.completionDate === 'undefined' - ? {} - : { completionDate }), - ...(typeof update.status === 'undefined' - ? {} - : { status: update.status }), - ...(typeof update.type === 'undefined' - ? {} - : { type: update.type }), - ...(typeof update.details === 'undefined' - ? {} - : { details: this.toJsonInput(mergedDetails) }), - ...(typeof update.order === 'undefined' - ? {} - : { order: update.order }), - ...(typeof update.plannedText === 'undefined' - ? {} - : { plannedText: update.plannedText }), - ...(typeof update.activeText === 'undefined' - ? {} - : { activeText: update.activeText }), - ...(typeof update.completedText === 'undefined' - ? {} - : { completedText: update.completedText }), - ...(typeof update.blockedText === 'undefined' - ? {} - : { blockedText: update.blockedText }), - ...(typeof update.hidden === 'undefined' - ? {} - : { hidden: update.hidden }), - updatedBy: auditUserId, - }, - }); - - if (milestone.status !== updatedMilestone.status) { - await tx.statusHistory.create({ - data: { - reference: 'milestone', - referenceId: updatedMilestone.id, - status: updatedMilestone.status, - comment: update.statusComment || null, - createdBy: statusAuditUserId, - updatedBy: statusAuditUserId, - }, - }); - } - - const updatedWithHistory = await tx.milestone.findFirst({ - where: { - id: updatedMilestone.id, - }, - include: this.statusHistoryInclude(), - }); - - if (!updatedWithHistory) { - throw new NotFoundException( - `Milestone not found for milestone id ${updatedMilestone.id.toString()} after update.`, - ); - } - - updatedRecords.push({ - original: this.toMilestoneDto(milestone), - updated: this.toMilestoneDto( - updatedWithHistory as MilestoneWithStatusHistory, - ), - }); - - continue; - } - - const createOrder = update.order || nextOrder; - nextOrder += 1; - - const startDate = update.startDate || timeline.startDate; - const duration = update.duration || 1; - const endDate = - update.endDate || this.addDaysUtc(startDate, duration - 1); - - this.validateMilestoneDates(startDate, endDate); - this.validateMilestoneCompletionDates( - update.actualStartDate || null, - update.completionDate || null, - ); - - const createdMilestone = await tx.milestone.create({ - data: { - timelineId: parsedTimelineId, - name: update.name || 'Milestone', - description: update.description || null, - duration, - startDate, - actualStartDate: update.actualStartDate || null, - endDate, - completionDate: update.completionDate || null, - status: update.status || ProjectStatus.draft, - type: update.type || 'generic', - details: this.toJsonInput(update.details || {}), - order: createOrder, - plannedText: update.plannedText || null, - activeText: update.activeText || null, - completedText: update.completedText || null, - blockedText: update.blockedText || null, - hidden: Boolean(update.hidden), - createdBy: auditUserId, - updatedBy: auditUserId, - }, - }); - - await tx.statusHistory.create({ - data: { - reference: 'milestone', - referenceId: createdMilestone.id, - status: createdMilestone.status, - comment: null, - createdBy: statusAuditUserId, - updatedBy: statusAuditUserId, - }, - }); - - createdIds.push(createdMilestone.id); - } - - await this.reindexMilestoneOrders(tx, parsedTimelineId, auditUserId); - - const milestones = await tx.milestone.findMany({ - where: { - timelineId: parsedTimelineId, - deletedAt: null, - }, - include: this.statusHistoryInclude(), - orderBy: [{ order: 'asc' }, { id: 'asc' }], - }); - - const milestonesById = new Map( - milestones.map((milestone) => [milestone.id.toString(), milestone]), - ); - const createdMilestones = createdIds - .map((id) => milestonesById.get(id.toString())) - .filter((entry): entry is NonNullable => Boolean(entry)); - - return { - created: createdMilestones.map((entry) => - this.toMilestoneDto(entry as unknown as MilestoneWithStatusHistory), - ), - updated: updatedRecords.map((entry) => ({ - original: entry.original, - updated: milestonesById.get(entry.updated.id) - ? this.toMilestoneDto( - milestonesById.get( - entry.updated.id, - ) as unknown as MilestoneWithStatusHistory, - ) - : entry.updated, - })), - deleted: toDelete.map((milestone) => ({ - id: milestone.id.toString(), - timelineId: parsedTimelineId.toString(), - })), - milestones: milestones.map((milestone) => - this.toMilestoneDto(milestone as MilestoneWithStatusHistory), - ), - }; - }); - - for (const created of operationResult.created) { - this.publishMilestoneAction(KAFKA_TOPIC.MILESTONE_ADDED, created); - } - - for (const deleted of operationResult.deleted) { - this.publishMilestoneAction(KAFKA_TOPIC.MILESTONE_REMOVED, deleted); - } - - for (const updated of operationResult.updated) { - this.publishMilestoneAction(KAFKA_TOPIC.MILESTONE_UPDATED, updated); - } - - const context = - await this.timelineReferenceService.resolveProjectContextByTimelineId( - parsedTimelineId, - ); - - for (const created of operationResult.created) { - await this.publishMilestoneAddedNotification( - context.projectId, - created, - user, - timeline, - ); - } - - for (const deleted of operationResult.deleted) { - await this.publishMilestoneRemovedNotification( - context.projectId, - deleted, - user, - timeline, - ); - } - - for (const updated of operationResult.updated) { - await this.publishMilestoneUpdatedNotifications( - context.projectId, - updated.original, - updated.updated, - user, - timeline, - ); - } - - return operationResult.milestones; - } - - async deleteMilestone( - timelineId: string, - milestoneId: string, - user: JwtUser, - ): Promise { - const parsedTimelineId = this.parseId(timelineId, 'timelineId'); - const parsedMilestoneId = this.parseId(milestoneId, 'milestoneId'); - const auditUserId = this.getAuditUserIdBigInt(user); - - const timeline = await this.getTimelineOrFail(parsedTimelineId); - - const deletedMilestone = await this.prisma.$transaction(async (tx) => { - const milestone = await tx.milestone.findFirst({ - where: { - id: parsedMilestoneId, - timelineId: parsedTimelineId, - deletedAt: null, - }, - }); - - if (!milestone) { - throw new NotFoundException( - `Milestone not found for milestone id ${milestoneId}.`, - ); - } - - await tx.milestone.update({ - where: { - id: parsedMilestoneId, - }, - data: { - deletedAt: new Date(), - deletedBy: auditUserId, - updatedBy: auditUserId, - }, - }); - - await tx.milestone.updateMany({ - where: { - timelineId: parsedTimelineId, - deletedAt: null, - order: { - gt: milestone.order, - }, - }, - data: { - order: { - decrement: 1, - }, - updatedBy: auditUserId, - }, - }); - - return { - id: milestone.id.toString(), - timelineId: milestone.timelineId.toString(), - }; - }); - - this.publishMilestoneAction( - KAFKA_TOPIC.MILESTONE_REMOVED, - deletedMilestone, - ); - - const context = - await this.timelineReferenceService.resolveProjectContextByTimelineId( - parsedTimelineId, - ); - - await this.publishMilestoneRemovedNotification( - context.projectId, - deletedMilestone, - user, - timeline, - ); - } - - private async getTimelineOrFail(timelineId: bigint): Promise { - const timeline = await this.prisma.timeline.findFirst({ - where: { - id: timelineId, - deletedAt: null, - }, - }); - - if (!timeline) { - throw new NotFoundException( - `Timeline not found for timeline id ${timelineId.toString()}.`, - ); - } - - return timeline; - } - - private statusHistoryInclude(): Prisma.MilestoneInclude { - return { - statusHistory: { - where: { - reference: 'milestone', - }, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - }, - }; - } - - private toOrderBy( - sort: string, - ): - | Prisma.MilestoneOrderByWithRelationInput - | Prisma.MilestoneOrderByWithRelationInput[] { - const normalized = sort.trim().toLowerCase(); - - if (normalized === 'order desc') { - return [{ order: 'desc' }, { id: 'desc' }]; - } - - return [{ order: 'asc' }, { id: 'asc' }]; - } - - private toMilestoneDto( - milestone: MilestoneWithStatusHistory, - ): MilestoneResponseDto { - return { - id: milestone.id.toString(), - timelineId: milestone.timelineId.toString(), - name: milestone.name, - description: milestone.description, - duration: milestone.duration, - startDate: milestone.startDate, - actualStartDate: milestone.actualStartDate, - endDate: milestone.endDate, - completionDate: milestone.completionDate, - status: milestone.status, - type: milestone.type, - details: - (toSerializable(milestone.details) as Record | null) || - null, - order: milestone.order, - plannedText: milestone.plannedText, - activeText: milestone.activeText, - completedText: milestone.completedText, - blockedText: milestone.blockedText, - hidden: milestone.hidden, - statusHistory: (milestone.statusHistory || []).map((entry) => - this.toStatusHistoryDto(entry), - ), - createdAt: milestone.createdAt, - updatedAt: milestone.updatedAt, - createdBy: milestone.createdBy.toString(), - updatedBy: milestone.updatedBy.toString(), - }; - } - - private toStatusHistoryDto(entry: StatusHistory): StatusHistoryResponseDto { - return { - id: entry.id.toString(), - reference: entry.reference, - referenceId: entry.referenceId.toString(), - status: entry.status, - comment: entry.comment, - createdBy: entry.createdBy, - createdAt: entry.createdAt, - updatedBy: entry.updatedBy, - updatedAt: entry.updatedAt, - }; - } - - private resolveRequestedOrder( - requested: number | undefined, - maxOrder: number, - fallback?: number, - ): number { - if (typeof requested === 'undefined') { - if (typeof fallback === 'number') { - return fallback; - } - - return maxOrder; - } - - if (!Number.isInteger(requested) || requested < 1) { - throw new BadRequestException('order must be a positive integer.'); - } - - if (requested > maxOrder) { - return maxOrder; - } - - return requested; - } - - private async reindexMilestoneOrders( - tx: Prisma.TransactionClient, - timelineId: bigint, - auditUserId: bigint, - ): Promise { - const milestones = await tx.milestone.findMany({ - where: { - timelineId, - deletedAt: null, - }, - orderBy: [{ order: 'asc' }, { id: 'asc' }], - select: { - id: true, - order: true, - }, - }); - - for (let index = 0; index < milestones.length; index += 1) { - const expectedOrder = index + 1; - - if (milestones[index].order !== expectedOrder) { - await tx.milestone.update({ - where: { - id: milestones[index].id, - }, - data: { - order: expectedOrder, - updatedBy: auditUserId, - }, - }); - } - } - } - - private validateMilestoneDates(startDate: Date, endDate?: Date | null): void { - if (endDate && endDate.getTime() < startDate.getTime()) { - throw new BadRequestException('Milestone endDate must be >= startDate.'); - } - } - - private validateMilestoneCompletionDates( - actualStartDate?: Date | null, - completionDate?: Date | null, - ): void { - if ( - actualStartDate && - completionDate && - completionDate.getTime() < actualStartDate.getTime() - ) { - throw new BadRequestException( - 'The milestone completionDate should be greater or equal to actualStartDate.', - ); - } - } - - private mergeDetails( - existing: Prisma.JsonValue | null, - incoming: Record, - ): Record { - const existingObject = - existing && typeof existing === 'object' && !Array.isArray(existing) - ? (existing as Record) - : {}; - - return { - ...existingObject, - ...incoming, - }; - } - - private toJsonInput( - value: unknown, - ): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput { - if (value === null) { - return Prisma.JsonNull; - } - - return value as Prisma.InputJsonValue; - } - - private addDaysUtc(date: Date, days: number): Date { - const value = new Date(date); - value.setUTCDate(value.getUTCDate() + days); - return value; - } - - private parseId(value: string, fieldName: string): bigint { - try { - const parsed = BigInt(value); - if (parsed <= BigInt(0)) { - throw new Error('invalid'); - } - return parsed; - } catch { - throw new BadRequestException(`${fieldName} must be a positive integer.`); - } - } - - private getAuditUserIdBigInt(user: JwtUser): bigint { - const rawUserId = String(user.userId || '').trim(); - if (!/^\d+$/.test(rawUserId)) { - throw new ForbiddenException('Authenticated user id must be numeric.'); - } - - return BigInt(rawUserId); - } - - private getAuditUserIdInt(user: JwtUser): number { - const rawUserId = String(user.userId || '').trim(); - if (!/^\d+$/.test(rawUserId)) { - throw new ForbiddenException('Authenticated user id must be numeric.'); - } - - const parsed = Number.parseInt(rawUserId, 10); - if (!Number.isSafeInteger(parsed) || parsed <= 0) { - throw new ForbiddenException('Authenticated user id must be numeric.'); - } - - return parsed; - } - - private publishMilestoneAction(topic: string, payload: unknown): void { - void publishMilestoneEvent(topic, toSerializable(payload)); - } - - private async publishMilestoneAddedNotification( - projectId: bigint, - createdMilestone: MilestoneResponseDto, - user: JwtUser, - timeline: Timeline, - ): Promise { - const project = await this.getProjectForNotifications(projectId); - if (!project) { - return; - } - - const timelinePayload = - await this.buildTimelineWithProgressNotificationPayload(timeline); - - await publishNotificationEvent(KAFKA_TOPIC.MILESTONE_NOTIFICATION_ADDED, { - ...this.buildNotificationBasePayload(project, user), - timeline: timelinePayload, - addedMilestone: toSerializable(createdMilestone), - }); - - await publishNotificationEvent(KAFKA_TOPIC.TIMELINE_ADJUSTED, { - ...this.buildNotificationBasePayload(project, user), - originalTimeline: this.buildTimelineNotificationPayload(timeline), - updatedTimeline: timelinePayload, - }); - } - - private async publishMilestoneRemovedNotification( - projectId: bigint, - removedMilestone: { id: string; timelineId: string }, - user: JwtUser, - timeline?: Timeline, - ): Promise { - const project = await this.getProjectForNotifications(projectId); - if (!project) { - return; - } - - await publishNotificationEvent(KAFKA_TOPIC.MILESTONE_NOTIFICATION_REMOVED, { - ...this.buildNotificationBasePayload(project, user), - removedMilestone, - }); - - if (timeline) { - await publishNotificationEvent(KAFKA_TOPIC.TIMELINE_ADJUSTED, { - ...this.buildNotificationBasePayload(project, user), - originalTimeline: this.buildTimelineNotificationPayload(timeline), - updatedTimeline: - await this.buildTimelineWithProgressNotificationPayload(timeline), - }); - } - } - - private async publishMilestoneUpdatedNotifications( - projectId: bigint, - original: MilestoneResponseDto, - updated: MilestoneResponseDto, - user: JwtUser, - timeline: Timeline, - ): Promise { - const project = await this.getProjectForNotifications(projectId); - if (!project) { - return; - } - - const timelinePayload = - await this.buildTimelineWithProgressNotificationPayload(timeline); - - const commonPayload = { - ...this.buildNotificationBasePayload(project, user), - timeline: timelinePayload, - originalMilestone: toSerializable(original), - updatedMilestone: toSerializable(updated), - }; - - await publishNotificationEvent( - KAFKA_TOPIC.MILESTONE_NOTIFICATION_UPDATED, - commonPayload, - ); - - const statusTransitionTopic = this.detectMilestoneStatusTransition( - original, - updated, - ); - - if (statusTransitionTopic) { - await publishNotificationEvent(statusTransitionTopic, commonPayload); - } - - const originalWaiting = this.getWaitingForCustomerFlag(original.details); - const updatedWaiting = this.getWaitingForCustomerFlag(updated.details); - - if (!originalWaiting && updatedWaiting) { - await publishNotificationEvent( - KAFKA_TOPIC.MILESTONE_WAITING_CUSTOMER, - commonPayload, - ); - } - - if ( - original.duration !== updated.duration || - original.order !== updated.order || - original.startDate.getTime() !== updated.startDate.getTime() || - original.endDate?.getTime() !== updated.endDate?.getTime() - ) { - await publishNotificationEvent(KAFKA_TOPIC.TIMELINE_ADJUSTED, { - ...this.buildNotificationBasePayload(project, user), - originalTimeline: this.buildTimelineNotificationPayload(timeline), - updatedTimeline: timelinePayload, - }); - } - } - - private detectMilestoneStatusTransition( - original: MilestoneResponseDto, - updated: MilestoneResponseDto, - ): string | undefined { - if (original.status === updated.status) { - return undefined; - } - - if (updated.status === ProjectStatus.active) { - return KAFKA_TOPIC.MILESTONE_TRANSITION_ACTIVE; - } - - if (updated.status === ProjectStatus.completed) { - return KAFKA_TOPIC.MILESTONE_TRANSITION_COMPLETED; - } - - if (updated.status === ProjectStatus.paused) { - return KAFKA_TOPIC.MILESTONE_TRANSITION_PAUSED; - } - - return undefined; - } - - private async getProjectForNotifications( - projectId: bigint, - ): Promise | null> { - const project = await this.prisma.project.findFirst({ - where: { - id: projectId, - deletedAt: null, - }, - select: { - id: true, - name: true, - details: true, - }, - }); - - if (!project) { - this.logger.warn( - `Skipping milestone notification because project ${projectId.toString()} was not found.`, - ); - return null; - } - - return project; - } - - private buildNotificationBasePayload( - project: Pick, - user: JwtUser, - ): Record { - const details = - project.details && - typeof project.details === 'object' && - !Array.isArray(project.details) - ? (project.details as Record) - : {}; - const utm = - details.utm && - typeof details.utm === 'object' && - !Array.isArray(details.utm) - ? (details.utm as Record) - : {}; - - return { - projectId: project.id.toString(), - projectName: project.name, - refCode: typeof utm.code === 'string' ? utm.code : undefined, - projectUrl: this.buildProjectUrl(project.id), - userId: this.getNotificationUserId(user), - initiatorUserId: this.getNotificationUserId(user), - }; - } - - private buildProjectUrl(projectId: bigint): string { - const baseUrl = - process.env.WORK_MANAGER_URL || - process.env.WORK_MANAGER_APP_URL || - 'https://platform.topcoder.com/connect/'; - - const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; - return `${normalizedBase}projects/${projectId.toString()}`; - } - - private buildTimelineNotificationPayload( - timeline: Timeline, - ): Record { - return { - id: timeline.id.toString(), - name: timeline.name, - description: timeline.description, - startDate: timeline.startDate, - endDate: timeline.endDate, - reference: timeline.reference, - referenceId: timeline.referenceId.toString(), - }; - } - - private async buildTimelineWithProgressNotificationPayload( - timeline: Timeline, - ): Promise> { - const milestones = await this.prisma.milestone.findMany({ - where: { - timelineId: timeline.id, - deletedAt: null, - hidden: false, - }, - orderBy: [{ order: 'asc' }, { id: 'asc' }], - select: { - duration: true, - startDate: true, - endDate: true, - actualStartDate: true, - completionDate: true, - }, - }); - - let duration = 0; - let progress = 0; - - if (milestones.length > 0) { - const first = milestones[0]; - const last = milestones[milestones.length - 1]; - const durationStartDate = first.actualStartDate || first.startDate; - const durationEndDate = - last.completionDate || last.endDate || last.startDate; - - duration = - Math.max( - 0, - Math.floor( - (durationEndDate.getTime() - durationStartDate.getTime()) / - (24 * 60 * 60 * 1000), - ), - ) + 1; - - let scheduledDuration = 0; - let completedDuration = 0; - - for (const milestone of milestones) { - if (milestone.completionDate) { - const completedStartDate = - milestone.actualStartDate || milestone.startDate; - const completedValue = - Math.max( - 0, - Math.floor( - (milestone.completionDate.getTime() - - completedStartDate.getTime()) / - (24 * 60 * 60 * 1000), - ), - ) + 1; - - scheduledDuration += completedValue; - completedDuration += completedValue; - } else { - scheduledDuration += milestone.duration; - } - } - - if (scheduledDuration > 0) { - progress = Math.round((completedDuration / scheduledDuration) * 100); - } - } - - return { - ...this.buildTimelineNotificationPayload(timeline), - duration, - progress, - }; - } - - private getWaitingForCustomerFlag( - details: Record | null | undefined, - ): boolean { - if (!details || typeof details !== 'object') { - return false; - } - - const metadata = details.metadata; - if (!metadata || typeof metadata !== 'object') { - return false; - } - - const waitingForCustomer = (metadata as Record) - .waitingForCustomer; - - return waitingForCustomer === true; - } - - private getNotificationUserId(user: JwtUser): string { - const rawUserId = String(user.userId || '').trim(); - - if (/^\d+$/.test(rawUserId)) { - return rawUserId; - } - - return '-1'; - } -} diff --git a/src/api/phase-product/phase-product.service.spec.ts b/src/api/phase-product/phase-product.service.spec.ts index a4da6ea..c967a01 100644 --- a/src/api/phase-product/phase-product.service.spec.ts +++ b/src/api/phase-product/phase-product.service.spec.ts @@ -1,17 +1,8 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Permission } from 'src/shared/constants/permissions'; -import { KAFKA_TOPIC } from 'src/shared/config/kafka.config'; import { PermissionService } from 'src/shared/services/permission.service'; import { PhaseProductService } from './phase-product.service'; -jest.mock('src/shared/utils/event.utils', () => ({ - publishPhaseProductEvent: jest.fn(() => Promise.resolve()), - publishWorkItemEvent: jest.fn(() => Promise.resolve()), - publishNotificationEvent: jest.fn(() => Promise.resolve()), -})); - -const eventUtils = jest.requireMock('src/shared/utils/event.utils'); - describe('PhaseProductService', () => { const prismaMock = { project: { @@ -61,7 +52,7 @@ describe('PhaseProductService', () => { }); }); - it('creates phase product with inherited project fields and publishes event', async () => { + it('creates phase product with inherited project fields', async () => { permissionServiceMock.hasNamedPermission.mockImplementation( (permission: Permission): boolean => { if (permission === Permission.ADD_PHASE_PRODUCT) { @@ -119,15 +110,6 @@ describe('PhaseProductService', () => { }), }), ); - expect(eventUtils.publishPhaseProductEvent).toHaveBeenCalled(); - expect(eventUtils.publishWorkItemEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_WORKITEM_ADDED, - expect.any(Object), - ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_PLAN_UPDATED, - expect.any(Object), - ); }); it('enforces max products per phase', async () => { @@ -159,7 +141,7 @@ describe('PhaseProductService', () => { ).rejects.toBeInstanceOf(BadRequestException); }); - it('soft deletes phase product and emits minimal payload', async () => { + it('soft deletes phase product', async () => { permissionServiceMock.hasNamedPermission.mockImplementation( (permission: Permission): boolean => { if (permission === Permission.DELETE_PHASE_PRODUCT) { @@ -202,22 +184,6 @@ describe('PhaseProductService', () => { }); expect(prismaMock.phaseProduct.update).toHaveBeenCalled(); - expect(eventUtils.publishPhaseProductEvent).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: '91', - projectId: '1001', - phaseId: '10', - }), - ); - expect(eventUtils.publishWorkItemEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_WORKITEM_REMOVED, - expect.any(Object), - ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_PLAN_UPDATED, - expect.any(Object), - ); }); it('throws forbidden when create permission is missing', async () => { diff --git a/src/api/phase-product/phase-product.service.ts b/src/api/phase-product/phase-product.service.ts index cf9674f..394badf 100644 --- a/src/api/phase-product/phase-product.service.ts +++ b/src/api/phase-product/phase-product.service.ts @@ -9,16 +9,9 @@ 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 { 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 { PermissionService } from 'src/shared/services/permission.service'; -import { - publishNotificationEvent, - publishPhaseProductEvent, - publishWorkItemEvent, -} from 'src/shared/utils/event.utils'; import { PhaseProductResponseDto } from './dto/phase-product-response.dto'; interface ProjectPermissionContext { @@ -34,8 +27,6 @@ interface ProjectPermissionContext { @Injectable() export class PhaseProductService { - private readonly logger = LoggerService.forRoot('PhaseProductService'); - constructor( private readonly prisma: PrismaService, private readonly permissionService: PermissionService, @@ -176,15 +167,6 @@ export class PhaseProductService { }); const response = this.toDto(createdProduct); - this.publishEvent(KAFKA_TOPIC.PROJECT_PHASE_PRODUCT_ADDED, response); - this.publishWorkItemEvent(KAFKA_TOPIC.PROJECT_WORKITEM_ADDED, response); - this.publishNotificationEvent(KAFKA_TOPIC.PROJECT_PLAN_UPDATED, { - projectId, - phaseId, - workItem: response, - userId: this.getNotificationUserId(user), - initiatorUserId: this.getNotificationUserId(user), - }); return response; } @@ -259,42 +241,10 @@ export class PhaseProductService { }); const response = this.toDto(updatedProduct); - this.publishEvent(KAFKA_TOPIC.PROJECT_PHASE_PRODUCT_UPDATED, response); - this.publishWorkItemEvent(KAFKA_TOPIC.PROJECT_WORKITEM_UPDATED, response); - - const specificationChanged = - typeof dto.name !== 'undefined' || - typeof dto.type !== 'undefined' || - typeof dto.templateId !== 'undefined' || - typeof dto.details !== 'undefined'; - - if (specificationChanged) { - const notificationPayload = { - projectId, - phaseId, - originalProduct: this.toDto(existingProduct), - updatedProduct: response, - userId: this.getNotificationUserId(user), - initiatorUserId: this.getNotificationUserId(user), - }; - - this.publishNotificationEvent( - KAFKA_TOPIC.PROJECT_PRODUCT_SPECIFICATION_MODIFIED, - notificationPayload, - ); - this.publishNotificationEvent( - KAFKA_TOPIC.PROJECT_WORKITEM_SPECIFICATION_MODIFIED, - notificationPayload, - ); - } else { - this.publishNotificationEvent(KAFKA_TOPIC.PROJECT_PLAN_UPDATED, { - projectId, - phaseId, - workItem: response, - userId: this.getNotificationUserId(user), - initiatorUserId: this.getNotificationUserId(user), - }); - } + void projectId; + void phaseId; + void user; + void existingProduct; return response; } @@ -349,23 +299,10 @@ export class PhaseProductService { }, }); - this.publishEvent(KAFKA_TOPIC.PROJECT_PHASE_PRODUCT_REMOVED, { - id: deletedProduct.id.toString(), - projectId: deletedProduct.projectId.toString(), - phaseId: deletedProduct.phaseId.toString(), - }); - this.publishWorkItemEvent(KAFKA_TOPIC.PROJECT_WORKITEM_REMOVED, { - id: deletedProduct.id.toString(), - projectId: deletedProduct.projectId.toString(), - phaseId: deletedProduct.phaseId.toString(), - }); - this.publishNotificationEvent(KAFKA_TOPIC.PROJECT_PLAN_UPDATED, { - projectId, - phaseId, - workItemId: deletedProduct.id.toString(), - userId: this.getNotificationUserId(user), - initiatorUserId: this.getNotificationUserId(user), - }); + void projectId; + void phaseId; + void user; + void deletedProduct; } private async ensurePhaseExists( @@ -509,41 +446,4 @@ export class PhaseProductService { return userId; } - - private publishEvent(topic: string, payload: unknown): void { - void publishPhaseProductEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish phase-product event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private publishWorkItemEvent(topic: string, payload: unknown): void { - void publishWorkItemEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish workitem event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private publishNotificationEvent(topic: string, payload: unknown): void { - void publishNotificationEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish phase-product notification topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private getNotificationUserId(user: JwtUser): string { - const rawUserId = String(user.userId || '').trim(); - - if (/^\d+$/.test(rawUserId)) { - return rawUserId; - } - - return '-1'; - } } diff --git a/src/api/project-attachment/dto/attachment-response.dto.ts b/src/api/project-attachment/dto/attachment-response.dto.ts index 6bf8215..7c576db 100644 --- a/src/api/project-attachment/dto/attachment-response.dto.ts +++ b/src/api/project-attachment/dto/attachment-response.dto.ts @@ -45,7 +45,7 @@ export class AttachmentResponseDto { updatedAt: Date; @ApiProperty() - createdBy: number; + createdBy: string; @ApiProperty() updatedBy: number; diff --git a/src/api/project-attachment/project-attachment.service.spec.ts b/src/api/project-attachment/project-attachment.service.spec.ts index e359923..163f517 100644 --- a/src/api/project-attachment/project-attachment.service.spec.ts +++ b/src/api/project-attachment/project-attachment.service.spec.ts @@ -1,18 +1,11 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { AttachmentType } from '@prisma/client'; import { Permission } from 'src/shared/constants/permissions'; -import { KAFKA_TOPIC } from 'src/shared/config/kafka.config'; 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 { ProjectAttachmentService } from './project-attachment.service'; -jest.mock('src/shared/utils/event.utils', () => ({ - publishAttachmentEvent: jest.fn(() => Promise.resolve()), - publishNotificationEvent: jest.fn(() => Promise.resolve()), -})); - -const eventUtils = jest.requireMock('src/shared/utils/event.utils'); - describe('ProjectAttachmentService', () => { const prismaMock = { project: { @@ -36,6 +29,10 @@ describe('ProjectAttachmentService', () => { deleteFile: jest.fn(), }; + const memberServiceMock = { + getMemberDetailsByUserIds: jest.fn(), + }; + let service: ProjectAttachmentService; beforeEach(() => { @@ -45,6 +42,7 @@ describe('ProjectAttachmentService', () => { prismaMock as any, permissionServiceMock as unknown as PermissionService, fileServiceMock as unknown as FileService, + memberServiceMock as unknown as MemberService, ); fileServiceMock.getPresignedDownloadUrl.mockResolvedValue( @@ -52,6 +50,12 @@ describe('ProjectAttachmentService', () => { ); fileServiceMock.transferFile.mockResolvedValue(undefined); fileServiceMock.deleteFile.mockResolvedValue(undefined); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([ + { + userId: '123', + handle: 'member123', + }, + ]); prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), @@ -65,7 +69,7 @@ describe('ProjectAttachmentService', () => { }); }); - it('creates link attachment and publishes event', async () => { + it('creates link attachment', async () => { permissionServiceMock.hasNamedPermission.mockImplementation( (permission: Permission): boolean => { if (permission === Permission.CREATE_PROJECT_ATTACHMENT) { @@ -110,11 +114,14 @@ describe('ProjectAttachmentService', () => { ); expect(response.id).toBe('77'); + expect(response.createdBy).toBe('member123'); expect(prismaMock.projectAttachment.create).toHaveBeenCalled(); - expect(eventUtils.publishAttachmentEvent).toHaveBeenCalled(); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_LINK_CREATED, - expect.any(Object), + expect(prismaMock.projectAttachment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + createdAt: expect.any(Date), + }), + }), ); }); @@ -170,6 +177,7 @@ describe('ProjectAttachmentService', () => { ); expect(response.downloadUrl).toBe('https://download.example.com'); + expect(response.createdBy).toBe('member123'); expect(fileServiceMock.getPresignedDownloadUrl).toHaveBeenCalled(); expect(fileServiceMock.transferFile).toHaveBeenCalledWith( 'incoming-bucket', @@ -177,11 +185,55 @@ describe('ProjectAttachmentService', () => { expect.any(String), expect.any(String), ); - expect(eventUtils.publishAttachmentEvent).toHaveBeenCalled(); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_FILE_UPLOADED, - expect.any(Object), + expect(prismaMock.projectAttachment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + createdAt: expect.any(Date), + }), + }), + ); + }); + + it('lists attachments with createdBy resolved to handle', async () => { + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => { + if (permission === Permission.VIEW_PROJECT_ATTACHMENT) { + return true; + } + + return false; + }, ); + + prismaMock.projectAttachment.findMany.mockResolvedValue([ + { + id: BigInt(41), + projectId: BigInt(1001), + title: 'List Item', + type: AttachmentType.link, + path: 'https://example.com', + size: null, + category: null, + description: null, + contentType: null, + tags: [], + allowedUsers: [], + deletedAt: null, + deletedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 123, + updatedBy: 123, + }, + ]); + + const response = await service.listAttachments('1001', { + userId: '456', + isMachine: false, + }); + + expect(response).toHaveLength(1); + expect(response[0].createdBy).toBe('member123'); }); it('returns not found when attachment is restricted for current user', async () => { @@ -227,6 +279,50 @@ describe('ProjectAttachmentService', () => { ).rejects.toBeInstanceOf(NotFoundException); }); + it('returns empty createdBy when handle is unavailable for non-owner', async () => { + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => { + if (permission === Permission.VIEW_PROJECT_ATTACHMENT) { + return true; + } + + if (permission === Permission.READ_PROJECT_ANY) { + return false; + } + + return false; + }, + ); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); + + prismaMock.projectAttachment.findFirst.mockResolvedValue({ + id: BigInt(29), + projectId: BigInt(1001), + title: 'Unknown Creator', + type: AttachmentType.link, + path: 'https://example.com', + size: null, + category: null, + description: null, + contentType: null, + tags: [], + allowedUsers: [], + deletedAt: null, + deletedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 999, + updatedBy: 999, + }); + + const response = await service.getAttachment('1001', '29', { + userId: '123', + isMachine: false, + }); + + expect(response.createdBy).toBe(''); + }); + it('updates attachment fields without requiring title on patch payload', async () => { permissionServiceMock.hasNamedPermission.mockImplementation( (permission: Permission): boolean => { @@ -300,10 +396,6 @@ describe('ProjectAttachmentService', () => { }), }), ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_ATTACHMENT_UPDATED_NOTIFICATION, - expect.any(Object), - ); }); it('deletes file attachment and triggers async file removal', async () => { @@ -363,7 +455,6 @@ describe('ProjectAttachmentService', () => { }); expect(fileServiceMock.deleteFile).toHaveBeenCalled(); - expect(eventUtils.publishAttachmentEvent).toHaveBeenCalled(); }); it('throws forbidden when create permission is missing', async () => { diff --git a/src/api/project-attachment/project-attachment.service.ts b/src/api/project-attachment/project-attachment.service.ts index 12be3e3..7b60dca 100644 --- a/src/api/project-attachment/project-attachment.service.ts +++ b/src/api/project-attachment/project-attachment.service.ts @@ -10,17 +10,13 @@ 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 { 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 { 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 { - publishAttachmentEvent, - publishNotificationEvent, -} from 'src/shared/utils/event.utils'; import { AttachmentResponseDto } from './dto/attachment-response.dto'; interface ProjectPermissionContext { @@ -40,6 +36,7 @@ export class ProjectAttachmentService { private readonly prisma: PrismaService, private readonly permissionService: PermissionService, private readonly fileService: FileService, + private readonly memberService: MemberService, ) {} async listAttachments( @@ -67,11 +64,17 @@ export class ProjectAttachmentService { }, }); - return attachments - .filter((attachment) => - this.hasReadAccessToAttachment(attachment, user, isAdmin), - ) - .map((attachment) => this.toDto(attachment)); + const visibleAttachments = attachments.filter((attachment) => + this.hasReadAccessToAttachment(attachment, user, isAdmin), + ); + const creatorHandleMap = await this.getCreatorHandleMap(visibleAttachments); + + return visibleAttachments.map((attachment) => + this.toDto(attachment, { + creatorHandleMap, + currentUser: user, + }), + ); } async getAttachment( @@ -109,7 +112,11 @@ export class ProjectAttachmentService { throw new NotFoundException('Record not found'); } - const response = this.toDto(attachment); + const creatorHandleMap = await this.getCreatorHandleMap([attachment]); + const response = this.toDto(attachment, { + creatorHandleMap, + currentUser: user, + }); if (attachment.type === AttachmentType.file) { response.url = await this.resolveDownloadUrl(attachment.path); @@ -132,6 +139,7 @@ export class ProjectAttachmentService { user, project.members, ); + const createdAt = new Date(); if (dto.type === AttachmentType.link) { const createdLink = await this.prisma.projectAttachment.create({ @@ -146,17 +154,17 @@ export class ProjectAttachmentService { tags: dto.tags || [], contentType: dto.contentType || null, allowedUsers: dto.allowedUsers || [], + createdAt, createdBy: auditUserId, updatedBy: auditUserId, }, }); - const response = this.toDto(createdLink); - this.publishEvent(KAFKA_TOPIC.PROJECT_ATTACHMENT_ADDED, response); - this.publishNotification( - KAFKA_TOPIC.PROJECT_LINK_CREATED, - this.buildAttachmentNotificationPayload(response, user), - ); + const creatorHandleMap = await this.getCreatorHandleMap([createdLink]); + const response = this.toDto(createdLink, { + creatorHandleMap, + currentUser: user, + }); return response; } @@ -189,22 +197,22 @@ export class ProjectAttachmentService { tags: dto.tags || [], contentType: dto.contentType, allowedUsers: dto.allowedUsers || [], + createdAt, createdBy: auditUserId, updatedBy: auditUserId, }, }); const downloadUrl = await this.resolveDownloadUrl(destinationPath); + const creatorHandleMap = await this.getCreatorHandleMap([ + createdFileAttachment, + ]); const response = this.toDto(createdFileAttachment, { + creatorHandleMap, + currentUser: user, downloadUrl, }); - this.publishEvent(KAFKA_TOPIC.PROJECT_ATTACHMENT_ADDED, response); - this.publishNotification( - KAFKA_TOPIC.PROJECT_FILE_UPLOADED, - this.buildAttachmentNotificationPayload(response, user), - ); - const shouldTransfer = process.env.NODE_ENV !== 'development' || APP_CONFIG.enableFileUpload; @@ -290,12 +298,13 @@ export class ProjectAttachmentService { }, }); - const response = this.toDto(updatedAttachment); - this.publishEvent(KAFKA_TOPIC.PROJECT_ATTACHMENT_UPDATED, response); - this.publishNotification( - KAFKA_TOPIC.PROJECT_ATTACHMENT_UPDATED_NOTIFICATION, - this.buildAttachmentNotificationPayload(response, user), - ); + const creatorHandleMap = await this.getCreatorHandleMap([ + updatedAttachment, + ]); + const response = this.toDto(updatedAttachment, { + creatorHandleMap, + currentUser: user, + }); return response; } @@ -330,7 +339,7 @@ export class ProjectAttachmentService { ); } - const deletedAttachment = await this.prisma.projectAttachment.update({ + await this.prisma.projectAttachment.update({ where: { id: parsedAttachmentId, }, @@ -341,11 +350,6 @@ export class ProjectAttachmentService { }, }); - this.publishEvent( - KAFKA_TOPIC.PROJECT_ATTACHMENT_REMOVED, - this.toDto(deletedAttachment), - ); - const shouldDeleteFile = attachment.type === AttachmentType.file && (process.env.NODE_ENV !== 'development' || APP_CONFIG.enableFileUpload); @@ -478,9 +482,16 @@ export class ProjectAttachmentService { private toDto( attachment: ProjectAttachment, - extra: Partial> = {}, + extra: { + url?: string; + downloadUrl?: string; + creatorHandleMap?: Map; + currentUser?: JwtUser; + } = {}, ): AttachmentResponseDto { - return { + const creatorHandleMap = + extra.creatorHandleMap || new Map(); + const response: AttachmentResponseDto = { id: attachment.id.toString(), projectId: attachment.projectId.toString(), title: attachment.title, @@ -494,10 +505,76 @@ export class ProjectAttachmentService { allowedUsers: attachment.allowedUsers, createdAt: attachment.createdAt, updatedAt: attachment.updatedAt, - createdBy: attachment.createdBy, + createdBy: this.resolveCreatedByHandle( + attachment, + creatorHandleMap, + extra.currentUser, + ), updatedBy: attachment.updatedBy, - ...extra, }; + + if (typeof extra.url !== 'undefined') { + response.url = extra.url; + } + + if (typeof extra.downloadUrl !== 'undefined') { + response.downloadUrl = extra.downloadUrl; + } + + return response; + } + + private async getCreatorHandleMap( + attachments: ProjectAttachment[], + ): Promise> { + const creatorIds = Array.from( + new Set(attachments.map((attachment) => attachment.createdBy)), + ); + + if (creatorIds.length === 0) { + return new Map(); + } + + const details = + await this.memberService.getMemberDetailsByUserIds(creatorIds); + const map = new Map(); + + for (const detail of details) { + const userId = Number.parseInt(String(detail.userId || ''), 10); + const handle = String(detail.handle || '').trim(); + + if (Number.isNaN(userId) || !handle) { + continue; + } + + map.set(userId, handle); + } + + return map; + } + + private resolveCreatedByHandle( + attachment: ProjectAttachment, + creatorHandleMap: Map, + user?: JwtUser, + ): string { + const handle = creatorHandleMap.get(attachment.createdBy); + if (handle) { + return handle; + } + + const currentUserId = Number.parseInt(String(user?.userId || ''), 10); + const currentUserHandle = String(user?.handle || '').trim(); + + if ( + !Number.isNaN(currentUserId) && + currentUserId === attachment.createdBy && + currentUserHandle.length > 0 + ) { + return currentUserHandle; + } + + return ''; } private parseId(value: string, entityName: string): bigint { @@ -517,49 +594,4 @@ export class ProjectAttachmentService { return userId; } - - private publishEvent(topic: string, payload: unknown): void { - void publishAttachmentEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish attachment event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private publishNotification(topic: string, payload: unknown): void { - void publishNotificationEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish attachment notification topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private buildAttachmentNotificationPayload( - attachment: AttachmentResponseDto, - user: JwtUser, - ): Record { - const userId = this.getNotificationUserId(user); - - return { - projectId: attachment.projectId, - attachmentId: attachment.id, - type: attachment.type, - title: attachment.title, - path: attachment.path, - userId, - initiatorUserId: userId, - }; - } - - private getNotificationUserId(user: JwtUser): string { - const rawUserId = String(user.userId || '').trim(); - - if (/^\d+$/.test(rawUserId)) { - return rawUserId; - } - - return '-1'; - } } diff --git a/src/api/project-invite/project-invite.service.spec.ts b/src/api/project-invite/project-invite.service.spec.ts index 5edf4a4..f4a1a26 100644 --- a/src/api/project-invite/project-invite.service.spec.ts +++ b/src/api/project-invite/project-invite.service.spec.ts @@ -9,9 +9,7 @@ import { PermissionService } from 'src/shared/services/permission.service'; import { ProjectInviteService } from './project-invite.service'; jest.mock('src/shared/utils/event.utils', () => ({ - publishInviteEvent: jest.fn(() => Promise.resolve()), publishMemberEvent: jest.fn(() => Promise.resolve()), - publishNotificationEvent: jest.fn(() => Promise.resolve()), })); const eventUtils = jest.requireMock('src/shared/utils/event.utils'); @@ -60,7 +58,7 @@ describe('ProjectInviteService', () => { ); }); - it('creates invite and publishes event', async () => { + it('creates invite', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), name: 'Demo', @@ -130,21 +128,9 @@ describe('ProjectInviteService', () => { ); expect(response.success).toHaveLength(1); - expect(eventUtils.publishInviteEvent).toHaveBeenCalled(); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_MEMBER_INVITE_SENT, - expect.objectContaining({ - projectId: '1001', - inviteId: '1', - role: ProjectMemberRole.customer, - email: 'member@topcoder.com', - userId: '123', - initiatorUserId: '99', - }), - ); }); - it('publishes invite accepted notification when invite is accepted', async () => { + it('publishes member.added when invite is accepted', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), members: [], @@ -220,16 +206,13 @@ describe('ProjectInviteService', () => { undefined, ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_MEMBER_INVITE_ACCEPTED, + expect(eventUtils.publishMemberEvent).toHaveBeenCalledWith( + KAFKA_TOPIC.PROJECT_MEMBER_ADDED, expect.objectContaining({ projectId: '1001', - inviteId: '10', + id: '777', role: ProjectMemberRole.customer, - email: 'member@topcoder.com', userId: '123', - initiatorUserId: '123', - memberId: '777', }), ); }); diff --git a/src/api/project-invite/project-invite.service.ts b/src/api/project-invite/project-invite.service.ts index 5af19b9..4ce4063 100644 --- a/src/api/project-invite/project-invite.service.ts +++ b/src/api/project-invite/project-invite.service.ts @@ -39,11 +39,7 @@ import { enrichInvitesWithUserDetails, validateUserHasProjectRole, } from 'src/shared/utils/member.utils'; -import { - publishInviteEvent, - publishMemberEvent, - publishNotificationEvent, -} from 'src/shared/utils/event.utils'; +import { publishMemberEvent } from 'src/shared/utils/event.utils'; interface InviteTargetByUser { userId: bigint; @@ -75,7 +71,6 @@ export class ProjectInviteService { ): Promise { const parsedProjectId = this.parseId(projectId, 'Project'); const auditUserId = this.getAuditUserId(user); - const initiatorUserId = this.getActorUserId(user); const project = await this.prisma.project.findFirst({ where: { @@ -171,14 +166,6 @@ export class ProjectInviteService { handleTargets.concat(emailTargets.userTargets), failed, ); - const inviteHandleByUserId = new Map( - validatedUserTargets - .filter( - (target): target is InviteTargetByUser & { handle: string } => - typeof target.handle === 'string' && target.handle.length > 0, - ) - .map((target) => [String(target.userId), target.handle]), - ); const emailOnlyTargets = emailTargets.emailOnlyTargets; @@ -226,22 +213,6 @@ export class ProjectInviteService { for (const invite of success) { const normalizedInvite = this.normalizeEntity(invite); - this.publishInvite(KAFKA_TOPIC.PROJECT_MEMBER_INVITE_CREATED, { - ...normalizedInvite, - source: 'work_manager', - }); - this.publishInviteNotification( - KAFKA_TOPIC.PROJECT_MEMBER_INVITE_SENT, - this.buildInviteNotificationPayload({ - projectId: parsedProjectId, - invite, - handle: invite.userId - ? inviteHandleByUserId.get(String(invite.userId)) - : undefined, - initiatorUserId, - }), - ); - if ( invite.email && !invite.userId && @@ -286,7 +257,6 @@ export class ProjectInviteService { const parsedProjectId = this.parseId(projectId, 'Project'); const parsedInviteId = this.parseId(inviteId, 'Invite'); const auditUserId = this.getAuditUserId(user); - const initiatorUserId = this.getActorUserId(user); const project = await this.prisma.project.findFirst({ where: { @@ -434,11 +404,6 @@ export class ProjectInviteService { }, ); - this.publishInvite( - KAFKA_TOPIC.PROJECT_MEMBER_INVITE_UPDATED, - this.normalizeEntity(updatedInvite), - ); - if (projectMember) { this.publishMember( KAFKA_TOPIC.PROJECT_MEMBER_ADDED, @@ -446,22 +411,6 @@ export class ProjectInviteService { ); } - if ( - invite.status !== updatedInvite.status && - (updatedInvite.status === InviteStatus.accepted || - updatedInvite.status === InviteStatus.request_approved) - ) { - this.publishInviteNotification( - KAFKA_TOPIC.PROJECT_MEMBER_INVITE_ACCEPTED, - this.buildInviteNotificationPayload({ - projectId: parsedProjectId, - invite: updatedInvite, - initiatorUserId, - memberId: projectMember ? projectMember.id : undefined, - }), - ); - } - return this.hydrateInviteResponse(updatedInvite, fields); } @@ -530,7 +479,7 @@ export class ProjectInviteService { this.ensureDeleteInvitePermission(invite.role, user, project.members); } - const updatedInvite = await this.prisma.$transaction(async (tx) => { + await this.prisma.$transaction(async (tx) => { const updated = await tx.projectMemberInvite.update({ where: { id: parsedInviteId, @@ -550,11 +499,6 @@ export class ProjectInviteService { return updated; }); - - this.publishInvite( - KAFKA_TOPIC.PROJECT_MEMBER_INVITE_REMOVED, - this.normalizeEntity(updatedInvite), - ); } async listInvites( @@ -1345,48 +1289,6 @@ export class ProjectInviteService { return walk(payload) as T; } - private buildInviteNotificationPayload(params: { - projectId: bigint; - invite: ProjectMemberInvite; - initiatorUserId: string; - handle?: string; - memberId?: bigint; - }): Record { - const payload: Record = { - projectId: params.projectId.toString(), - inviteId: params.invite.id.toString(), - role: params.invite.role, - initiatorUserId: params.initiatorUserId, - }; - - if (params.invite.email) { - payload.email = params.invite.email; - } - - if (params.handle) { - payload.handle = params.handle; - } - - if (params.invite.userId) { - payload.userId = params.invite.userId.toString(); - } - - if (params.memberId) { - payload.memberId = params.memberId.toString(); - } - - return payload; - } - - private publishInvite(topic: string, payload: unknown): void { - void publishInviteEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish invite event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - private publishMember(topic: string, payload: unknown): void { void publishMemberEvent(topic, payload).catch((error) => { this.logger.error( @@ -1395,13 +1297,4 @@ export class ProjectInviteService { ); }); } - - private publishInviteNotification(topic: string, payload: unknown): void { - void publishNotificationEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish invite notification topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } } diff --git a/src/api/project-member/project-member.service.spec.ts b/src/api/project-member/project-member.service.spec.ts index 6b969af..545d1cb 100644 --- a/src/api/project-member/project-member.service.spec.ts +++ b/src/api/project-member/project-member.service.spec.ts @@ -8,7 +8,6 @@ import { ProjectMemberService } from './project-member.service'; jest.mock('src/shared/utils/event.utils', () => ({ publishMemberEvent: jest.fn(() => Promise.resolve()), - publishNotificationEvent: jest.fn(() => Promise.resolve()), })); const eventUtils = jest.requireMock('src/shared/utils/event.utils'); @@ -45,7 +44,7 @@ describe('ProjectMemberService', () => { ); }); - it('adds member and publishes event', async () => { + it('adds member and publishes member.added event', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), members: [], @@ -102,13 +101,8 @@ describe('ProjectMemberService', () => { ); expect(txMock.projectMember.create).toHaveBeenCalled(); - expect(eventUtils.publishMemberEvent).toHaveBeenCalled(); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.MEMBER_JOINED, - expect.any(Object), - ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_TEAM_UPDATED, + expect(eventUtils.publishMemberEvent).toHaveBeenCalledWith( + KAFKA_TOPIC.PROJECT_MEMBER_ADDED, expect.any(Object), ); expect((result as any).id).toBe('1'); diff --git a/src/api/project-member/project-member.service.ts b/src/api/project-member/project-member.service.ts index c3bf1c0..8e071e5 100644 --- a/src/api/project-member/project-member.service.ts +++ b/src/api/project-member/project-member.service.ts @@ -32,10 +32,7 @@ import { getDefaultProjectRole, validateUserHasProjectRole, } from 'src/shared/utils/member.utils'; -import { - publishMemberEvent, - publishNotificationEvent, -} from 'src/shared/utils/event.utils'; +import { publishMemberEvent } from 'src/shared/utils/event.utils'; @Injectable() export class ProjectMemberService { @@ -170,14 +167,6 @@ export class ProjectMemberService { KAFKA_TOPIC.PROJECT_MEMBER_ADDED, this.normalizeEntity(createdMember), ); - this.publishMemberNotifications( - this.resolveMemberJoinedNotificationTopic(createdMember.role), - this.normalizeEntity(createdMember), - ); - this.publishMemberNotifications( - KAFKA_TOPIC.PROJECT_TEAM_UPDATED, - this.normalizeEntity(createdMember), - ); return this.hydrateMemberResponse(createdMember, fields); } @@ -296,21 +285,6 @@ export class ProjectMemberService { return updated; }); - this.publishEvent( - KAFKA_TOPIC.PROJECT_MEMBER_UPDATED, - this.normalizeEntity(updatedMember), - ); - if (!existingMember.isPrimary && updatedMember.isPrimary) { - this.publishMemberNotifications( - KAFKA_TOPIC.MEMBER_ASSIGNED_AS_OWNER, - this.normalizeEntity(updatedMember), - ); - } - this.publishMemberNotifications( - KAFKA_TOPIC.PROJECT_TEAM_UPDATED, - this.normalizeEntity(updatedMember), - ); - return this.hydrateMemberResponse(updatedMember, fields); } @@ -411,14 +385,6 @@ export class ProjectMemberService { KAFKA_TOPIC.PROJECT_MEMBER_REMOVED, this.normalizeEntity(deletedMember), ); - this.publishMemberNotifications( - isOwnMember ? KAFKA_TOPIC.MEMBER_LEFT : KAFKA_TOPIC.MEMBER_REMOVED, - this.normalizeEntity(deletedMember), - ); - this.publishMemberNotifications( - KAFKA_TOPIC.PROJECT_TEAM_UPDATED, - this.normalizeEntity(deletedMember), - ); } async listMembers( @@ -818,34 +784,4 @@ export class ProjectMemberService { ); }); } - - private publishMemberNotifications(topic: string, payload: unknown): void { - void publishNotificationEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish member notification topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private resolveMemberJoinedNotificationTopic( - role: ProjectMemberRole, - ): string { - if (role === ProjectMemberRole.copilot) { - return KAFKA_TOPIC.MEMBER_JOINED_COPILOT; - } - - const managerRoles = new Set([ - ProjectMemberRole.manager, - ProjectMemberRole.project_manager, - ProjectMemberRole.program_manager, - ProjectMemberRole.solution_architect, - ]); - - if (managerRoles.has(role)) { - return KAFKA_TOPIC.MEMBER_JOINED_MANAGER; - } - - return KAFKA_TOPIC.MEMBER_JOINED; - } } diff --git a/src/api/project-phase/project-phase.service.spec.ts b/src/api/project-phase/project-phase.service.spec.ts index 3dfdfe0..7620323 100644 --- a/src/api/project-phase/project-phase.service.spec.ts +++ b/src/api/project-phase/project-phase.service.spec.ts @@ -1,17 +1,8 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Permission } from 'src/shared/constants/permissions'; -import { KAFKA_TOPIC } from 'src/shared/config/kafka.config'; import { PermissionService } from 'src/shared/services/permission.service'; import { ProjectPhaseService } from './project-phase.service'; -jest.mock('src/shared/utils/event.utils', () => ({ - publishPhaseEvent: jest.fn(() => Promise.resolve()), - publishWorkEvent: jest.fn(() => Promise.resolve()), - publishNotificationEvent: jest.fn(() => Promise.resolve()), -})); - -const eventUtils = jest.requireMock('src/shared/utils/event.utils'); - type TxPhaseOrder = { id: bigint; order: number | null; @@ -277,15 +268,6 @@ describe('ProjectPhaseService', () => { }), }), ); - expect(eventUtils.publishPhaseEvent).toHaveBeenCalled(); - expect(eventUtils.publishWorkEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_WORK_ADDED, - expect.any(Object), - ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_PLAN_UPDATED, - expect.any(Object), - ); }); it('reorders sibling phases when updating order', async () => { @@ -380,10 +362,6 @@ describe('ProjectPhaseService', () => { }), }), ); - expect(eventUtils.publishWorkEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_WORK_UPDATED, - expect.any(Object), - ); }); it('fails update when startDate is after endDate', async () => { @@ -520,15 +498,6 @@ describe('ProjectPhaseService', () => { }), }), ); - expect(eventUtils.publishPhaseEvent).toHaveBeenCalled(); - expect(eventUtils.publishWorkEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_WORK_REMOVED, - expect.any(Object), - ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_PLAN_UPDATED, - expect.any(Object), - ); }); it('throws forbidden when user cannot create phase', async () => { diff --git a/src/api/project-phase/project-phase.service.ts b/src/api/project-phase/project-phase.service.ts index 44efe93..be47e9f 100644 --- a/src/api/project-phase/project-phase.service.ts +++ b/src/api/project-phase/project-phase.service.ts @@ -22,17 +22,10 @@ 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 { 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 { PermissionService } from 'src/shared/services/permission.service'; import { hasAdminRole } from 'src/shared/utils/permission.utils'; -import { - publishNotificationEvent, - publishPhaseEvent, - publishWorkEvent, -} from 'src/shared/utils/event.utils'; interface ProjectPermissionContext { id: bigint; @@ -86,8 +79,6 @@ const TERMINAL_PHASE_STATUSES = new Set([ @Injectable() export class ProjectPhaseService { - private readonly logger = LoggerService.forRoot('ProjectPhaseService'); - constructor( private readonly prisma: PrismaService, private readonly permissionService: PermissionService, @@ -237,7 +228,7 @@ export class ProjectPhaseService { ); } - return this.toDto(phase as PhaseWithRelations); + return this.toDto(phase); } async createPhase( @@ -392,15 +383,7 @@ export class ProjectPhaseService { ); } - const response = this.toDto(createdPhase as PhaseWithRelations); - this.publishEvent(KAFKA_TOPIC.PROJECT_PHASE_ADDED, response); - this.publishWorkResourceEvent(KAFKA_TOPIC.PROJECT_WORK_ADDED, response); - this.publishNotification(KAFKA_TOPIC.PROJECT_PLAN_UPDATED, { - projectId, - phase: response, - userId: this.getNotificationUserId(user), - initiatorUserId: this.getNotificationUserId(user), - }); + const response = this.toDto(createdPhase); return response; } @@ -532,30 +515,7 @@ export class ProjectPhaseService { }); }); - const response = this.toDto(updatedPhase as PhaseWithRelations); - this.publishEvent(KAFKA_TOPIC.PROJECT_PHASE_UPDATED, response); - this.publishWorkResourceEvent(KAFKA_TOPIC.PROJECT_WORK_UPDATED, response); - - const changeTopics = this.detectPhaseChangeType( - existingPhase, - updatedPhase, - ); - for (const topic of changeTopics) { - this.publishNotification(topic, { - projectId, - originalPhase: this.toDto(existingPhase as PhaseWithRelations), - updatedPhase: response, - userId: this.getNotificationUserId(user), - initiatorUserId: this.getNotificationUserId(user), - }); - } - - this.publishNotification(KAFKA_TOPIC.PROJECT_PLAN_UPDATED, { - projectId, - phase: response, - userId: this.getNotificationUserId(user), - initiatorUserId: this.getNotificationUserId(user), - }); + const response = this.toDto(updatedPhase); return response; } @@ -620,70 +580,10 @@ export class ProjectPhaseService { return deleted; }); - this.publishEvent( - KAFKA_TOPIC.PROJECT_PHASE_REMOVED, - this.toDto(deletedPhase), - ); - this.publishWorkResourceEvent( - KAFKA_TOPIC.PROJECT_WORK_REMOVED, - this.toDto(deletedPhase), - ); - this.publishNotification(KAFKA_TOPIC.PROJECT_PLAN_UPDATED, { - projectId, - phaseId, - userId: this.getNotificationUserId(user), - initiatorUserId: this.getNotificationUserId(user), - }); - } - - private detectPhaseChangeType( - original: ProjectPhase, - updated: ProjectPhase, - ): string[] { - const topics: string[] = []; - - if (original.status !== updated.status) { - if (updated.status === ProjectStatus.active) { - topics.push(KAFKA_TOPIC.PROJECT_PHASE_TRANSITION_ACTIVE); - topics.push(KAFKA_TOPIC.PROJECT_WORK_TRANSITION_ACTIVE); - } - - if (updated.status === ProjectStatus.completed) { - topics.push(KAFKA_TOPIC.PROJECT_PHASE_TRANSITION_COMPLETED); - topics.push(KAFKA_TOPIC.PROJECT_WORK_TRANSITION_COMPLETED); - } - } - - if ( - original.budget !== updated.budget || - original.spentBudget !== updated.spentBudget - ) { - topics.push(KAFKA_TOPIC.PROJECT_PHASE_UPDATE_PAYMENT); - topics.push(KAFKA_TOPIC.PROJECT_WORK_UPDATE_PAYMENT); - } - - if ( - original.progress !== updated.progress || - original.duration !== updated.duration || - original.startDate?.getTime() !== updated.startDate?.getTime() || - original.endDate?.getTime() !== updated.endDate?.getTime() - ) { - topics.push(KAFKA_TOPIC.PROJECT_PHASE_UPDATE_PROGRESS); - topics.push(KAFKA_TOPIC.PROJECT_WORK_UPDATE_PROGRESS); - } - - if ( - original.name !== updated.name || - original.description !== updated.description || - original.requirements !== updated.requirements || - JSON.stringify(original.details || null) !== - JSON.stringify(updated.details || null) - ) { - topics.push(KAFKA_TOPIC.PROJECT_PHASE_UPDATE_SCOPE); - topics.push(KAFKA_TOPIC.PROJECT_WORK_UPDATE_SCOPE); - } - - return [...new Set(topics)]; + void projectId; + void phaseId; + void user; + void deletedPhase; } private parseFieldSelection(fields?: string): { @@ -1156,41 +1056,4 @@ export class ProjectPhaseService { return userId; } - - private publishEvent(topic: string, payload: unknown): void { - void publishPhaseEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish phase event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private publishNotification(topic: string, payload: unknown): void { - void publishNotificationEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish phase notification topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private publishWorkResourceEvent(topic: string, payload: unknown): void { - void publishWorkEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish work event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private getNotificationUserId(user: JwtUser): string { - const rawUserId = String(user.userId || '').trim(); - - if (/^\d+$/.test(rawUserId)) { - return rawUserId; - } - - return '-1'; - } } diff --git a/src/api/project-setting/project-setting.service.spec.ts b/src/api/project-setting/project-setting.service.spec.ts index 3cd5364..c37f04f 100644 --- a/src/api/project-setting/project-setting.service.spec.ts +++ b/src/api/project-setting/project-setting.service.spec.ts @@ -1,11 +1,5 @@ import { ProjectSettingService } from './project-setting.service'; -jest.mock('src/shared/utils/event.utils', () => ({ - publishSettingEvent: jest.fn(() => Promise.resolve()), -})); - -const eventUtils = jest.requireMock('src/shared/utils/event.utils'); - describe('ProjectSettingService', () => { const prismaMock = { project: { @@ -38,7 +32,7 @@ describe('ProjectSettingService', () => { }); }); - it('publishes setting create event', async () => { + it('creates setting', async () => { prismaMock.projectSetting.findFirst.mockResolvedValue(null); prismaMock.projectSetting.create.mockResolvedValue({ id: BigInt(11), @@ -70,13 +64,10 @@ describe('ProjectSettingService', () => { [], ); - expect(eventUtils.publishSettingEvent).toHaveBeenCalledWith( - 'project.setting.created', - expect.any(Object), - ); + expect(prismaMock.projectSetting.create).toHaveBeenCalled(); }); - it('publishes setting update and delete events', async () => { + it('updates and deletes setting', async () => { prismaMock.projectSetting.findFirst.mockResolvedValue({ id: BigInt(11), projectId: BigInt(1001), @@ -139,20 +130,10 @@ describe('ProjectSettingService', () => { await service.delete('1001', '11', { userId: '123', isMachine: false }, []); - expect(eventUtils.publishSettingEvent).toHaveBeenCalledWith( - 'project.setting.updated', - expect.any(Object), - ); - expect(eventUtils.publishSettingEvent).toHaveBeenCalledWith( - 'project.setting.deleted', - expect.any(Object), - ); + expect(prismaMock.projectSetting.update).toHaveBeenCalledTimes(2); }); - it('returns response even when event publishing fails', async () => { - eventUtils.publishSettingEvent.mockRejectedValueOnce( - new Error('event error'), - ); + it('returns created setting response', async () => { prismaMock.projectSetting.findFirst.mockResolvedValue(null); prismaMock.projectSetting.create.mockResolvedValue({ id: BigInt(12), diff --git a/src/api/project-setting/project-setting.service.ts b/src/api/project-setting/project-setting.service.ts index c0b7eea..0fa2bc4 100644 --- a/src/api/project-setting/project-setting.service.ts +++ b/src/api/project-setting/project-setting.service.ts @@ -16,17 +16,12 @@ import { ProjectMember, Permission, } from 'src/shared/interfaces/permission.interface'; -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 { PermissionService } from 'src/shared/services/permission.service'; -import { publishSettingEvent } from 'src/shared/utils/event.utils'; @Injectable() export class ProjectSettingService { - private readonly logger = LoggerService.forRoot('ProjectSettingService'); - constructor( private readonly prisma: PrismaService, private readonly permissionService: PermissionService, @@ -124,7 +119,6 @@ export class ProjectSettingService { }); const response = this.toDto(created); - this.publishEvent(KAFKA_TOPIC.PROJECT_SETTING_CREATED, response); return response; } catch (error) { @@ -230,7 +224,6 @@ export class ProjectSettingService { }); const response = this.toDto(updated); - this.publishEvent(KAFKA_TOPIC.PROJECT_SETTING_UPDATED, response); return response; } @@ -287,7 +280,7 @@ export class ProjectSettingService { }, }); - this.publishEvent(KAFKA_TOPIC.PROJECT_SETTING_DELETED, this.toDto(deleted)); + void deleted; } private parseBigIntParam(value: string, name: string): bigint { @@ -346,13 +339,4 @@ export class ProjectSettingService { updatedBy: setting.updatedBy, }; } - - private publishEvent(topic: string, payload: unknown): void { - void publishSettingEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish setting event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } } diff --git a/src/api/project/dto/project-list-query.dto.ts b/src/api/project/dto/project-list-query.dto.ts index 67baa06..f4f6010 100644 --- a/src/api/project/dto/project-list-query.dto.ts +++ b/src/api/project/dto/project-list-query.dto.ts @@ -58,8 +58,7 @@ export class ProjectListQueryDto extends PaginationDto { sort?: string; @ApiPropertyOptional({ - description: - 'CSV fields list. Supported: members, invites, attachments, phases', + description: 'CSV fields list. Supported: members, invites, attachments', }) @IsOptional() @IsString() @@ -79,6 +78,13 @@ export class ProjectListQueryDto extends PaginationDto { @Transform(({ value }) => parseFilterInput(value)) status?: string | string[] | Record; + @ApiPropertyOptional({ + description: 'Filter by billing account id (exact or $in pattern)', + }) + @IsOptional() + @Transform(({ value }) => parseFilterInput(value)) + billingAccountId?: string | string[] | Record; + @ApiPropertyOptional({ description: 'When true, return projects where current user is member/invitee', @@ -136,8 +142,7 @@ export class ProjectListQueryDto extends PaginationDto { export class GetProjectQueryDto { @ApiPropertyOptional({ - description: - 'CSV fields list. Supported: members, invites, attachments, phases', + description: 'CSV fields list. Supported: members, invites, attachments', }) @IsOptional() @IsString() diff --git a/src/api/project/dto/project-response.dto.ts b/src/api/project/dto/project-response.dto.ts index 032dd55..24555ed 100644 --- a/src/api/project/dto/project-response.dto.ts +++ b/src/api/project/dto/project-response.dto.ts @@ -14,6 +14,9 @@ export class ProjectMemberDto { @ApiProperty() role: string; + @ApiPropertyOptional() + handle?: string | null; + @ApiProperty() isPrimary: boolean; @@ -46,6 +49,9 @@ export class ProjectInviteDto { @ApiProperty() role: string; + @ApiPropertyOptional() + handle?: string | null; + @ApiProperty() createdAt: Date; @@ -91,35 +97,6 @@ export class ProjectAttachmentDto { updatedAt: Date; } -export class ProjectPhaseDto { - @ApiProperty() - id: string; - - @ApiProperty() - projectId: string; - - @ApiPropertyOptional() - name?: string | null; - - @ApiPropertyOptional() - description?: string | null; - - @ApiPropertyOptional({ - enum: ProjectStatus, - enumName: 'ProjectStatus', - }) - status?: ProjectStatus | null; - - @ApiPropertyOptional() - order?: number | null; - - @ApiProperty() - createdAt: Date; - - @ApiProperty() - updatedAt: Date; -} - export class ProjectResponseDto { @ApiProperty() id: string; @@ -142,6 +119,9 @@ export class ProjectResponseDto { @ApiPropertyOptional() billingAccountId?: string | null; + @ApiPropertyOptional() + billingAccountName?: string | null; + @ApiPropertyOptional() directProjectId?: string | null; @@ -215,7 +195,4 @@ export class ProjectWithRelationsDto extends ProjectResponseDto { @ApiPropertyOptional({ type: () => [ProjectAttachmentDto] }) attachments?: ProjectAttachmentDto[]; - - @ApiPropertyOptional({ type: () => [ProjectPhaseDto] }) - phases?: ProjectPhaseDto[]; } diff --git a/src/api/project/project.controller.spec.ts b/src/api/project/project.controller.spec.ts index bb809cc..3f92876 100644 --- a/src/api/project/project.controller.spec.ts +++ b/src/api/project/project.controller.spec.ts @@ -6,6 +6,8 @@ describe('ProjectController', () => { const serviceMock = { listProjects: jest.fn(), getProject: jest.fn(), + listProjectBillingAccounts: jest.fn(), + getProjectBillingAccount: jest.fn(), createProject: jest.fn(), updateProject: jest.fn(), deleteProject: jest.fn(), @@ -87,6 +89,50 @@ describe('ProjectController', () => { ); }); + it('lists billing accounts for project', async () => { + serviceMock.listProjectBillingAccounts.mockResolvedValue([ + { + tcBillingAccountId: '1010', + }, + ]); + + const result = await controller.listProjectBillingAccounts('101', { + userId: '123', + isMachine: false, + }); + + expect(result).toEqual([ + { + tcBillingAccountId: '1010', + }, + ]); + expect(serviceMock.listProjectBillingAccounts).toHaveBeenCalledWith( + '101', + expect.objectContaining({ userId: '123' }), + ); + }); + + it('gets default billing account for project', async () => { + serviceMock.getProjectBillingAccount.mockResolvedValue({ + tcBillingAccountId: '2020', + active: true, + }); + + const result = await controller.getProjectBillingAccount('202', { + userId: '123', + isMachine: false, + }); + + expect(result).toEqual({ + tcBillingAccountId: '2020', + active: true, + }); + expect(serviceMock.getProjectBillingAccount).toHaveBeenCalledWith( + '202', + expect.objectContaining({ userId: '123' }), + ); + }); + it('creates project', async () => { serviceMock.createProject.mockResolvedValue({ id: '202' }); diff --git a/src/api/project/project.controller.ts b/src/api/project/project.controller.ts index 568fe03..43f841f 100644 --- a/src/api/project/project.controller.ts +++ b/src/api/project/project.controller.ts @@ -32,6 +32,7 @@ import { AdminOnly } from 'src/shared/guards/adminOnly.guard'; 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 { BillingAccount } from 'src/shared/services/billingAccount.service'; import { setProjectPaginationHeaders } from 'src/shared/utils/pagination.utils'; import { CreateProjectDto } from './dto/create-project.dto'; import { @@ -70,6 +71,7 @@ export class ProjectController { @ApiQuery({ name: 'fields', required: false, type: String }) @ApiQuery({ name: 'id', required: false, type: String }) @ApiQuery({ name: 'status', required: false, type: String }) + @ApiQuery({ name: 'billingAccountId', required: false, type: String }) @ApiQuery({ name: 'memberOnly', required: false, type: Boolean }) @ApiQuery({ name: 'keyword', required: false, type: String }) @ApiQuery({ name: 'type', required: false, type: String }) @@ -159,8 +161,7 @@ export class ProjectController { name: 'fields', required: false, type: String, - description: - 'CSV fields list. Supported: members, invites, attachments, phases', + description: 'CSV fields list. Supported: members, invites, attachments', }) @ApiResponse({ status: 200, @@ -180,6 +181,79 @@ export class ProjectController { return this.service.getProject(projectId, query.fields, user); } + @Get(':projectId/billingAccounts') + @UseGuards(PermissionGuard) + @Roles(...Object.values(UserRole)) + @Scopes( + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECTS_READ_USER_BILLING_ACCOUNTS, + ) + @RequirePermission(Permission.READ_AVL_PROJECT_BILLING_ACCOUNTS) + @ApiOperation({ + summary: 'List available billing accounts for project', + description: + 'Returns billing accounts available to the caller in the context of a project.', + }) + @ApiParam({ + name: 'projectId', + required: true, + description: 'Project numeric id', + }) + @ApiResponse({ + status: 200, + description: 'Billing accounts list', + schema: { + type: 'array', + items: { + type: 'object', + }, + }, + }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async listProjectBillingAccounts( + @Param('projectId') projectId: string, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.listProjectBillingAccounts(projectId, user); + } + + @Get(':projectId/billingAccount') + @UseGuards(PermissionGuard) + @Roles(...Object.values(UserRole)) + @Scopes(Scope.PROJECTS_READ_PROJECT_BILLING_ACCOUNT_DETAILS) + @RequirePermission(Permission.READ_PROJECT_BILLING_ACCOUNT_DETAILS) + @ApiOperation({ + summary: 'Get default billing account for project', + description: + 'Returns billing account details for the project-level default billing account.', + }) + @ApiParam({ + name: 'projectId', + required: true, + description: 'Project numeric id', + }) + @ApiResponse({ + status: 200, + description: 'Billing account details', + schema: { + type: 'object', + }, + }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Not Found' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async getProjectBillingAccount( + @Param('projectId') projectId: string, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.getProjectBillingAccount(projectId, user); + } + @Get(':projectId/permissions') @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index 1c2e3c3..ac8e899 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -6,7 +6,6 @@ import { ProjectService } from './project.service'; jest.mock('src/shared/utils/event.utils', () => ({ publishProjectEvent: jest.fn(() => Promise.resolve()), - publishNotificationEvent: jest.fn(() => Promise.resolve()), })); const eventUtils = jest.requireMock('src/shared/utils/event.utils'); @@ -28,6 +27,7 @@ describe('ProjectService', () => { projectMemberInvite: { findMany: jest.fn(), }, + $queryRaw: jest.fn(), $transaction: jest.fn(), }; @@ -35,13 +35,21 @@ describe('ProjectService', () => { hasNamedPermission: jest.fn(), }; + const billingAccountServiceMock = { + getBillingAccountsForProject: jest.fn(), + getBillingAccountsByIds: jest.fn(), + getDefaultBillingAccount: jest.fn(), + }; + let service: ProjectService; beforeEach(() => { jest.clearAllMocks(); + prismaMock.$queryRaw.mockResolvedValue([]); service = new ProjectService( prismaMock as any, permissionServiceMock as unknown as PermissionService, + billingAccountServiceMock as any, ); }); @@ -121,6 +129,7 @@ describe('ProjectService', () => { { page: 1, perPage: 20, + fields: 'invites', }, { userId: '100', @@ -132,6 +141,195 @@ describe('ProjectService', () => { expect(result.data).toHaveLength(1); expect(result.data[0].invites).toHaveLength(1); expect(result.data[0].invites?.[0].userId).toBe('100'); + expect(prismaMock.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.objectContaining({ + members: expect.any(Object), + invites: expect.any(Object), + }), + }), + ); + }); + + it('does not load relation payloads by default in project listing', async () => { + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.READ_PROJECT_ANY || + permission === Permission.READ_PROJECT_MEMBER, + ); + + const now = new Date(); + + prismaMock.project.count.mockResolvedValue(1); + prismaMock.project.findMany.mockResolvedValue([ + { + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'active', + billingAccountId: null, + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + utm: null, + details: null, + challengeEligibility: null, + cancelReason: null, + templateId: null, + version: 'v3', + lastActivityAt: now, + lastActivityUserId: '100', + createdAt: now, + updatedAt: now, + createdBy: 100, + updatedBy: 100, + }, + ]); + + const result = await service.listProjects( + { + page: 1, + perPage: 20, + }, + { + userId: '100', + roles: ['administrator'], + isMachine: false, + }, + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].members).toBeUndefined(); + expect(result.data[0].invites).toBeUndefined(); + expect(result.data[0].attachments).toBeUndefined(); + expect(prismaMock.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: {}, + }), + ); + }); + + it('adds billing account name to project listing when available', async () => { + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.READ_PROJECT_ANY || + permission === Permission.READ_PROJECT_MEMBER, + ); + + const now = new Date(); + + prismaMock.project.count.mockResolvedValue(1); + prismaMock.project.findMany.mockResolvedValue([ + { + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'active', + billingAccountId: BigInt(80001063), + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + utm: null, + details: null, + challengeEligibility: null, + cancelReason: null, + templateId: null, + version: 'v3', + lastActivityAt: now, + lastActivityUserId: '100', + createdAt: now, + updatedAt: now, + createdBy: 100, + updatedBy: 100, + }, + ]); + billingAccountServiceMock.getBillingAccountsByIds.mockResolvedValue({ + '80001063': { + name: 'Acme BA', + tcBillingAccountId: '80001063', + }, + }); + + const result = await service.listProjects( + { + page: 1, + perPage: 20, + }, + { + userId: '100', + roles: ['administrator'], + isMachine: false, + }, + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].billingAccountName).toBe('Acme BA'); + expect( + billingAccountServiceMock.getBillingAccountsByIds, + ).toHaveBeenCalledWith(['80001063']); + }); + + it('adds billing account name to project details when available', async () => { + const now = new Date(); + + permissionServiceMock.hasNamedPermission.mockReturnValue(true); + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'active', + billingAccountId: BigInt(80001063), + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + utm: null, + details: null, + challengeEligibility: null, + cancelReason: null, + templateId: null, + version: 'v3', + lastActivityAt: now, + lastActivityUserId: '100', + createdAt: now, + updatedAt: now, + createdBy: 100, + updatedBy: 100, + members: [], + invites: [], + attachments: [], + }); + billingAccountServiceMock.getBillingAccountsByIds.mockResolvedValue({ + '80001063': { + name: 'Acme BA', + tcBillingAccountId: '80001063', + }, + }); + + const result = await service.getProject('1001', undefined, { + userId: '100', + roles: ['administrator'], + isMachine: false, + }); + + expect(result.billingAccountName).toBe('Acme BA'); + expect( + billingAccountServiceMock.getBillingAccountsByIds, + ).toHaveBeenCalledWith(['80001063']); }); it('throws NotFoundException when project is missing', async () => { @@ -145,6 +343,90 @@ describe('ProjectService', () => { ).rejects.toBeInstanceOf(NotFoundException); }); + it('lists billing accounts for project id', async () => { + billingAccountServiceMock.getBillingAccountsForProject.mockResolvedValue([ + { + tcBillingAccountId: '123123', + }, + ]); + + const result = await service.listProjectBillingAccounts('001001', { + userId: '123', + isMachine: false, + }); + + expect(result).toEqual([ + { + tcBillingAccountId: '123123', + }, + ]); + expect( + billingAccountServiceMock.getBillingAccountsForProject, + ).toHaveBeenCalledWith('1001', '123'); + }); + + it('returns project billing account and strips markup for user tokens', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + billingAccountId: BigInt(12), + }); + billingAccountServiceMock.getDefaultBillingAccount.mockResolvedValue({ + tcBillingAccountId: '12', + markup: 50, + active: true, + }); + + const result = await service.getProjectBillingAccount('1001', { + userId: '123', + isMachine: false, + }); + + expect(result).toEqual({ + tcBillingAccountId: '12', + active: true, + }); + expect( + billingAccountServiceMock.getDefaultBillingAccount, + ).toHaveBeenCalledWith('12'); + }); + + it('returns project billing account markup for m2m tokens', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + billingAccountId: BigInt(12), + }); + billingAccountServiceMock.getDefaultBillingAccount.mockResolvedValue({ + tcBillingAccountId: '12', + markup: 50, + active: true, + }); + + const result = await service.getProjectBillingAccount('1001', { + scopes: ['read:project-billing-account-details'], + isMachine: true, + }); + + expect(result).toEqual({ + tcBillingAccountId: '12', + markup: 50, + active: true, + }); + }); + + it('throws when billing account is not attached to the project', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + billingAccountId: null, + }); + + await expect( + service.getProjectBillingAccount('1001', { + userId: '123', + isMachine: false, + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + it('loads members and invites for permission checks when fields omit them', async () => { const now = new Date(); @@ -344,7 +626,7 @@ describe('ProjectService', () => { expect(eventUtils.publishProjectEvent).toHaveBeenCalled(); }); - it('creates project and publishes created notification', async () => { + it('creates project and publishes project.created event', async () => { prismaMock.projectType.findFirst.mockResolvedValue({ key: 'app', }); @@ -415,19 +697,12 @@ describe('ProjectService', () => { ); expect(eventUtils.publishProjectEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_DRAFT_CREATED, - expect.any(Object), - ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( KAFKA_TOPIC.PROJECT_CREATED, - expect.objectContaining({ - projectId: '1001', - projectName: 'Demo Project', - }), + expect.any(Object), ); }); - it('publishes status and billing notifications during update', async () => { + it('publishes project.updated event during update', async () => { prismaMock.project.findFirst .mockResolvedValueOnce({ id: BigInt(1001), @@ -518,19 +793,8 @@ describe('ProjectService', () => { }, ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_ACTIVE, - expect.any(Object), - ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_BILLING_ACCOUNT_UPDATED, - expect.objectContaining({ - oldBillingAccountId: '11', - newBillingAccountId: '22', - }), - ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_UPDATED_NOTIFICATION, + expect(eventUtils.publishProjectEvent).toHaveBeenCalledWith( + KAFKA_TOPIC.PROJECT_UPDATED, expect.any(Object), ); }); diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index a05b93c..2476248 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -12,7 +12,6 @@ import { ProjectMember, ProjectMemberInvite, ProjectMemberRole, - ProjectPhase, ProjectStatus, } from '@prisma/client'; import { Permission } from 'src/shared/constants/permissions'; @@ -23,11 +22,12 @@ import { Permission as JsonPermission } from 'src/shared/interfaces/permission.i 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 { PermissionService } from 'src/shared/services/permission.service'; import { - publishNotificationEvent, - publishProjectEvent, -} from 'src/shared/utils/event.utils'; + BillingAccount, + BillingAccountService, +} from 'src/shared/services/billingAccount.service'; +import { PermissionService } from 'src/shared/services/permission.service'; +import { publishProjectEvent } from 'src/shared/utils/event.utils'; import { ParsedProjectFields, buildProjectIncludeClause, @@ -49,11 +49,19 @@ interface PaginatedProjectResponse { total: number; } +type ProjectMemberWithHandle = ProjectMember & { + handle?: string | null; +}; + +type ProjectInviteWithHandle = ProjectMemberInvite & { + handle?: string | null; +}; + type ProjectWithRawRelations = Project & { - members?: ProjectMember[]; - invites?: ProjectMemberInvite[]; + billingAccountName?: string; + members?: ProjectMemberWithHandle[]; + invites?: ProjectInviteWithHandle[]; attachments?: ProjectAttachment[]; - phases?: ProjectPhase[]; }; @Injectable() @@ -63,6 +71,7 @@ export class ProjectService { constructor( private readonly prisma: PrismaService, private readonly permissionService: PermissionService, + private readonly billingAccountService: BillingAccountService, ) {} async listProjects( @@ -79,8 +88,9 @@ export class ProjectService { ); const where = buildProjectWhereClause(criteria, user, isAdmin); - const fields = parseFieldsParameter(criteria.fields); - const include = buildProjectIncludeClause(fields); + const requestedFields = this.resolveListFields(criteria.fields); + const includeFields = this.resolveListIncludeFields(requestedFields); + const include = buildProjectIncludeClause(includeFields); const orderBy = this.resolveSort(criteria.sort); const [total, projects] = await Promise.all([ @@ -93,11 +103,35 @@ export class ProjectService { take: perPage, }), ]); - - const data = projects.map((project) => - this.toDto(this.filterProjectRelations(project, user, isAdmin)), + const projectsWithMemberHandles = + await this.enrichProjectsWithMemberHandles(projects); + const billingAccountNamesById = await this.getBillingAccountNamesById( + projectsWithMemberHandles, ); + const data = projectsWithMemberHandles.map((project) => { + const billingAccountId = this.toOptionalBigintString( + project.billingAccountId, + ); + const filteredProject = this.filterProjectRelations( + project, + user, + isAdmin, + ); + const projectWithRequestedFields = this.filterProjectFields( + filteredProject, + requestedFields, + ); + + return this.toDto({ + ...projectWithRequestedFields, + billingAccountName: + billingAccountId && billingAccountNamesById.has(billingAccountId) + ? billingAccountNamesById.get(billingAccountId) + : undefined, + }); + }); + return { data, page, @@ -133,11 +167,15 @@ export class ProjectService { ); } + const [projectWithMemberHandles] = + await this.enrichProjectsWithMemberHandles([project]); + const projectWithRelations = projectWithMemberHandles || project; + const canViewProject = this.permissionService.hasNamedPermission( Permission.VIEW_PROJECT, user, - project.members || [], - (project.invites || []).map((invite) => ({ + projectWithRelations.members || [], + (projectWithRelations.invites || []).map((invite) => ({ ...invite, status: String(invite.status), })), @@ -150,16 +188,32 @@ export class ProjectService { const isAdmin = this.permissionService.hasNamedPermission( Permission.READ_PROJECT_ANY, user, - project.members || [], + projectWithRelations.members || [], ); - const filteredProject = this.filterProjectRelations(project, user, isAdmin); + const filteredProject = this.filterProjectRelations( + projectWithRelations, + user, + isAdmin, + ); const projectWithRequestedFields = this.filterProjectFields( filteredProject, fields, ); - - return this.toDto(projectWithRequestedFields); + const billingAccountId = this.toOptionalBigintString( + projectWithRelations.billingAccountId, + ); + const billingAccountNamesById = billingAccountId + ? await this.getBillingAccountNamesById([projectWithRelations]) + : new Map(); + + return this.toDto({ + ...projectWithRequestedFields, + billingAccountName: + billingAccountId && billingAccountNamesById.has(billingAccountId) + ? billingAccountNamesById.get(billingAccountId) + : undefined, + }); } async createProject( @@ -404,7 +458,7 @@ export class ProjectService { deletedAt: null, }, include: buildProjectIncludeClause( - parseFieldsParameter('members,invites,attachments,phases'), + parseFieldsParameter('members,invites,attachments'), ), }); @@ -412,13 +466,12 @@ export class ProjectService { throw new ConflictException('Failed to create project.'); } - const response = this.toDto(createdProject); - - this.publishEvent(KAFKA_TOPIC.PROJECT_DRAFT_CREATED, response); - this.publishNotificationEvent( - KAFKA_TOPIC.PROJECT_CREATED, - this.buildNotificationPayload(response, user), + const [createdProjectWithMemberHandles] = + await this.enrichProjectsWithMemberHandles([createdProject]); + const response = this.toDto( + createdProjectWithMemberHandles || createdProject, ); + this.publishEvent(KAFKA_TOPIC.PROJECT_CREATED, response); return response; } @@ -510,24 +563,6 @@ export class ProjectService { const statusChanged = typeof dto.status !== 'undefined' && dto.status !== existingProject.status; - const nameChanged = - typeof dto.name !== 'undefined' && dto.name !== existingProject.name; - const descriptionChanged = - typeof dto.description !== 'undefined' && - dto.description !== existingProject.description; - const detailsChanged = - typeof dto.details !== 'undefined' && - JSON.stringify(dto.details ?? null) !== - JSON.stringify(existingProject.details ?? null); - const bookmarksChanged = - typeof dto.bookmarks !== 'undefined' && - JSON.stringify(dto.bookmarks ?? null) !== - JSON.stringify(existingProject.bookmarks ?? null); - const billingAccountChanged = - typeof dto.billingAccountId !== 'undefined' && - String(existingProject.billingAccountId ?? '') !== - String(dto.billingAccountId ?? ''); - const updatedProject = await this.prisma.$transaction(async (tx) => { const updated = await tx.project.update({ where: { @@ -606,7 +641,7 @@ export class ProjectService { deletedAt: null, }, include: buildProjectIncludeClause( - parseFieldsParameter('members,invites,attachments,phases'), + parseFieldsParameter('members,invites,attachments'), ), }); @@ -616,82 +651,22 @@ export class ProjectService { ); } + const [projectWithMemberHandles] = + await this.enrichProjectsWithMemberHandles([project]); + const projectWithRelations = projectWithMemberHandles || project; + const isAdmin = this.permissionService.hasNamedPermission( Permission.READ_PROJECT_ANY, user, - project.members || [], + projectWithRelations.members || [], ); const response = this.toDto( - this.filterProjectRelations(project, user, isAdmin), + this.filterProjectRelations(projectWithRelations, user, isAdmin), ); this.publishEvent(KAFKA_TOPIC.PROJECT_UPDATED, response); - if (statusChanged) { - this.publishEvent(KAFKA_TOPIC.PROJECT_STATUS_CHANGED, response); - } - - const notificationPayload = this.buildNotificationPayload(response, user); - let hasNotificationChange = false; - - if (statusChanged && dto.status) { - const statusNotificationTopic = this.resolveStatusNotificationTopic( - dto.status, - ); - - if (statusNotificationTopic) { - this.publishNotificationEvent( - statusNotificationTopic, - notificationPayload, - ); - hasNotificationChange = true; - } - } - - if ( - !statusChanged && - (nameChanged || descriptionChanged || detailsChanged) - ) { - this.publishNotificationEvent( - KAFKA_TOPIC.PROJECT_SPECIFICATION_MODIFIED, - notificationPayload, - ); - hasNotificationChange = true; - } - - if (bookmarksChanged) { - this.publishNotificationEvent( - KAFKA_TOPIC.PROJECT_LINK_CREATED, - notificationPayload, - ); - hasNotificationChange = true; - } - - if (billingAccountChanged) { - this.publishNotificationEvent( - KAFKA_TOPIC.PROJECT_BILLING_ACCOUNT_UPDATED, - { - ...notificationPayload, - oldBillingAccountId: existingProject.billingAccountId - ? existingProject.billingAccountId.toString() - : null, - newBillingAccountId: - typeof dto.billingAccountId === 'number' - ? String(Math.trunc(dto.billingAccountId)) - : null, - }, - ); - hasNotificationChange = true; - } - - if (hasNotificationChange) { - this.publishNotificationEvent( - KAFKA_TOPIC.PROJECT_UPDATED_NOTIFICATION, - notificationPayload, - ); - } - return response; } @@ -816,6 +791,70 @@ export class ProjectService { return policyMap; } + async listProjectBillingAccounts( + projectId: string, + user: JwtUser, + ): Promise { + const normalizedProjectId = this.parseProjectId(projectId).toString(); + const userId = user.userId ? String(user.userId).trim() : ''; + + if (!userId) { + this.logger.warn( + `Missing userId while listing billing accounts for projectId=${normalizedProjectId}.`, + ); + return []; + } + + return this.billingAccountService.getBillingAccountsForProject( + normalizedProjectId, + userId, + ); + } + + async getProjectBillingAccount( + projectId: string, + user: JwtUser, + ): Promise { + const parsedProjectId = this.parseProjectId(projectId); + + const project = await this.prisma.project.findFirst({ + where: { + id: parsedProjectId, + deletedAt: null, + }, + select: { + id: true, + billingAccountId: true, + }, + }); + + if (!project) { + throw new NotFoundException( + `Project with id ${projectId} was not found.`, + ); + } + + if (!project.billingAccountId) { + throw new NotFoundException('Billing Account not found'); + } + + const billingAccount = + (await this.billingAccountService.getDefaultBillingAccount( + project.billingAccountId.toString(), + )) || {}; + + if (user.isMachine) { + return billingAccount; + } + + const sanitizedBillingAccount = { + ...billingAccount, + }; + delete sanitizedBillingAccount.markup; + + return sanitizedBillingAccount; + } + async upgradeProject( projectId: string, dto: UpgradeProjectDto, @@ -883,6 +922,154 @@ export class ProjectService { }; } + private async enrichProjectsWithMemberHandles( + projects: ProjectWithRawRelations[], + ): Promise { + if (!projects.length) { + return projects; + } + + const userIds = this.collectProjectUserIds(projects); + + if (!userIds.length) { + return projects; + } + + const handlesByUserId = await this.fetchMemberHandlesByUserId(userIds); + + if (!handlesByUserId.size) { + return projects; + } + + return projects.map((project) => ({ + ...project, + members: Array.isArray(project.members) + ? project.members.map((member) => ({ + ...member, + handle: + this.toOptionalHandle(member.handle) || + this.getHandleByUserId(member.userId, handlesByUserId), + })) + : project.members, + invites: Array.isArray(project.invites) + ? project.invites.map((invite) => ({ + ...invite, + handle: + this.toOptionalHandle(invite.handle) || + this.getHandleByUserId(invite.userId, handlesByUserId), + })) + : project.invites, + })); + } + + private collectProjectUserIds(projects: ProjectWithRawRelations[]): bigint[] { + const userIds = new Set(); + + for (const project of projects) { + for (const member of project.members || []) { + const parsedUserId = this.parseUserIdValue(member.userId); + + if (parsedUserId) { + userIds.add(parsedUserId.toString()); + } + } + + for (const invite of project.invites || []) { + const parsedUserId = this.parseUserIdValue(invite.userId); + + if (parsedUserId) { + userIds.add(parsedUserId.toString()); + } + } + } + + return Array.from(userIds).map((userId) => BigInt(userId)); + } + + private async fetchMemberHandlesByUserId( + userIds: bigint[], + ): Promise> { + if (!userIds.length) { + return new Map(); + } + + try { + const rows = await this.prisma.$queryRaw< + Array<{ + userId: bigint | number | string; + handle: string | null; + }> + >(Prisma.sql` + SELECT + m."userId" AS "userId", + m.handle AS "handle" + FROM members.member m + WHERE m."userId" IN (${Prisma.join(userIds)}) + `); + + return rows.reduce>((acc, row) => { + const parsedUserId = this.parseUserIdValue(row.userId); + const handle = this.toOptionalHandle(row.handle); + + if (parsedUserId && handle) { + acc.set(parsedUserId.toString(), handle); + } + + return acc; + }, new Map()); + } catch (error) { + this.logger.warn( + `Failed to fetch member handles from members.member: ${error instanceof Error ? error.message : String(error)}`, + ); + return new Map(); + } + } + + private getHandleByUserId( + userId: bigint | number | string | null | undefined, + handlesByUserId: Map, + ): string | null { + const parsedUserId = this.parseUserIdValue(userId); + + if (!parsedUserId) { + return null; + } + + return handlesByUserId.get(parsedUserId.toString()) || null; + } + + private parseUserIdValue( + userId: bigint | number | string | null | undefined, + ): bigint | undefined { + if (typeof userId === 'bigint') { + return userId; + } + + if (typeof userId === 'number' && Number.isFinite(userId)) { + return BigInt(Math.trunc(userId)); + } + + if (typeof userId === 'string') { + const normalizedUserId = userId.trim(); + + if (/^\d+$/.test(normalizedUserId)) { + return BigInt(normalizedUserId); + } + } + + return undefined; + } + + private toOptionalHandle(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalizedHandle = value.trim(); + + return normalizedHandle || undefined; + } + private resolveSort(sort?: string): Prisma.ProjectOrderByWithRelationInput { const defaultOrderBy: Prisma.ProjectOrderByWithRelationInput = { createdAt: 'asc', @@ -926,6 +1113,38 @@ export class ProjectService { } as Prisma.ProjectOrderByWithRelationInput; } + private resolveListFields(fieldsParam?: string): ParsedProjectFields { + const parsedFields = parseFieldsParameter(fieldsParam); + + if (fieldsParam && fieldsParam.trim().length > 0) { + return parsedFields; + } + + return { + ...parsedFields, + project_members: false, + project_member_invites: false, + attachments: false, + }; + } + + private resolveListIncludeFields( + requestedFields: ParsedProjectFields, + ): ParsedProjectFields { + if (requestedFields.project_members) { + return requestedFields; + } + + if (requestedFields.project_member_invites || requestedFields.attachments) { + return { + ...requestedFields, + project_members: true, + }; + } + + return requestedFields; + } + private filterProjectRelations( project: ProjectWithRawRelations, user: JwtUser, @@ -996,10 +1215,6 @@ export class ProjectService { delete clone.attachments; } - if (!fields.project_phases) { - delete clone.phases; - } - return clone; } @@ -1107,80 +1322,6 @@ export class ProjectService { }); } - private publishNotificationEvent(topic: string, payload: unknown): void { - void publishNotificationEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish notification event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private resolveStatusNotificationTopic( - status: ProjectStatus, - ): string | undefined { - const topicByStatus: Partial> = { - [ProjectStatus.in_review]: KAFKA_TOPIC.PROJECT_SUBMITTED_FOR_REVIEW, - [ProjectStatus.reviewed]: KAFKA_TOPIC.PROJECT_APPROVED, - [ProjectStatus.active]: KAFKA_TOPIC.PROJECT_ACTIVE, - [ProjectStatus.paused]: KAFKA_TOPIC.PROJECT_PAUSED, - [ProjectStatus.completed]: KAFKA_TOPIC.PROJECT_COMPLETED, - [ProjectStatus.cancelled]: KAFKA_TOPIC.PROJECT_CANCELED, - }; - - return topicByStatus[status]; - } - - private buildNotificationPayload( - project: ProjectWithRelationsDto, - user: JwtUser, - ): Record { - const details = - project.details && - typeof project.details === 'object' && - !Array.isArray(project.details) - ? project.details - : {}; - const detailsUtm = - details.utm && - typeof details.utm === 'object' && - !Array.isArray(details.utm) - ? (details.utm as Record) - : {}; - - const userId = this.getNotificationUserId(user); - - return { - projectId: project.id, - projectName: project.name, - projectUrl: this.buildProjectUrl(project.id), - userId, - initiatorUserId: userId, - refCode: - typeof detailsUtm.code === 'string' ? detailsUtm.code : undefined, - }; - } - - private buildProjectUrl(projectId: string): string { - const baseUrl = - process.env.WORK_MANAGER_URL || - process.env.WORK_MANAGER_APP_URL || - 'https://platform.topcoder.com/connect/'; - const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; - - return `${normalizedBase}projects/${projectId}`; - } - - private getNotificationUserId(user: JwtUser): string { - const rawUserId = String(user.userId || '').trim(); - - if (/^\d+$/.test(rawUserId)) { - return rawUserId; - } - - return '-1'; - } - private toDto(project: ProjectWithRawRelations): ProjectWithRelationsDto { return this.normalizeProjectEntity( project, @@ -1248,4 +1389,55 @@ export class ProjectService { return walk(payload) as T; } + + private toOptionalBigintString( + value: bigint | null | undefined, + ): string | undefined { + if (typeof value !== 'bigint') { + return undefined; + } + + return value.toString(); + } + + private async getBillingAccountNamesById( + projects: Project[], + ): Promise> { + const billingAccountIds = Array.from( + new Set( + projects + .map((project) => + this.toOptionalBigintString(project.billingAccountId), + ) + .filter((billingAccountId): billingAccountId is string => + Boolean(billingAccountId), + ), + ), + ); + + if (billingAccountIds.length === 0) { + return new Map(); + } + + const billingAccountsById = + await this.billingAccountService.getBillingAccountsByIds( + billingAccountIds, + ); + + return Object.entries(billingAccountsById).reduce>( + (acc, [billingAccountId, billingAccount]) => { + const billingAccountName = + typeof billingAccount?.name === 'string' + ? billingAccount.name.trim() + : ''; + + if (billingAccountName) { + acc.set(billingAccountId, billingAccountName); + } + + return acc; + }, + new Map(), + ); + } } diff --git a/src/api/timeline/dto/create-timeline.dto.ts b/src/api/timeline/dto/create-timeline.dto.ts deleted file mode 100644 index 0423b50..0000000 --- a/src/api/timeline/dto/create-timeline.dto.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { TimelineReference } from '@prisma/client'; -import { Transform, Type } from 'class-transformer'; -import { - IsDate, - IsEnum, - IsInt, - IsNotEmpty, - IsOptional, - IsString, - MaxLength, - Min, -} from 'class-validator'; - -function parseOptionalInteger(value: unknown): number | undefined { - if (typeof value === 'undefined' || value === null || value === '') { - return undefined; - } - - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return undefined; - } - - return Math.trunc(parsed); -} - -export class CreateTimelineDto { - @ApiProperty() - @IsString() - @IsNotEmpty() - @MaxLength(255) - name: string; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - @MaxLength(255) - description?: string; - - @ApiProperty() - @Type(() => Date) - @IsDate() - startDate: Date; - - @ApiPropertyOptional() - @IsOptional() - @Type(() => Date) - @IsDate() - endDate?: Date | null; - - @ApiProperty({ - enum: TimelineReference, - enumName: 'TimelineReference', - }) - @IsEnum(TimelineReference) - reference: TimelineReference; - - @ApiProperty({ minimum: 1 }) - @Transform(({ value }) => parseOptionalInteger(value)) - @IsInt() - @Min(1) - referenceId: number; - - @ApiPropertyOptional({ - minimum: 1, - description: - 'Optional product template id used to create default milestones from milestone templates.', - }) - @IsOptional() - @Transform(({ value }) => parseOptionalInteger(value)) - @IsInt() - @Min(1) - templateId?: number; -} diff --git a/src/api/timeline/dto/timeline-list-query.dto.ts b/src/api/timeline/dto/timeline-list-query.dto.ts deleted file mode 100644 index 6b130e7..0000000 --- a/src/api/timeline/dto/timeline-list-query.dto.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { TimelineReference } from '@prisma/client'; -import { Transform } from 'class-transformer'; -import { IsEnum, IsInt, Min } from 'class-validator'; - -function parseInteger(value: unknown): number { - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return 0; - } - - return Math.trunc(parsed); -} - -export class TimelineListQueryDto { - @ApiProperty({ - enum: TimelineReference, - enumName: 'TimelineReference', - description: 'Timeline parent reference type.', - }) - @IsEnum(TimelineReference) - reference: TimelineReference; - - @ApiProperty({ - description: 'Timeline parent reference id.', - minimum: 1, - }) - @Transform(({ value }) => parseInteger(value)) - @IsInt() - @Min(1) - referenceId: number; -} diff --git a/src/api/timeline/dto/timeline-response.dto.ts b/src/api/timeline/dto/timeline-response.dto.ts deleted file mode 100644 index 2dfc844..0000000 --- a/src/api/timeline/dto/timeline-response.dto.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { TimelineReference } from '@prisma/client'; -import { MilestoneResponseDto } from 'src/api/milestone/dto/milestone-response.dto'; - -export class TimelineResponseDto { - @ApiProperty() - id: string; - - @ApiProperty() - name: string; - - @ApiPropertyOptional({ nullable: true }) - description?: string | null; - - @ApiProperty() - startDate: Date; - - @ApiPropertyOptional({ nullable: true }) - endDate?: Date | null; - - @ApiProperty({ - enum: TimelineReference, - enumName: 'TimelineReference', - }) - reference: TimelineReference; - - @ApiProperty() - referenceId: string; - - @ApiPropertyOptional({ type: () => [MilestoneResponseDto] }) - milestones?: MilestoneResponseDto[]; - - @ApiProperty() - createdAt: Date; - - @ApiProperty() - updatedAt: Date; - - @ApiProperty() - createdBy: string; - - @ApiProperty() - updatedBy: string; -} diff --git a/src/api/timeline/dto/update-timeline.dto.ts b/src/api/timeline/dto/update-timeline.dto.ts deleted file mode 100644 index 39eeb42..0000000 --- a/src/api/timeline/dto/update-timeline.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateTimelineDto } from './create-timeline.dto'; - -export class UpdateTimelineDto extends PartialType(CreateTimelineDto) {} diff --git a/src/api/timeline/guards/timeline-project-context.guard.spec.ts b/src/api/timeline/guards/timeline-project-context.guard.spec.ts deleted file mode 100644 index 88d0b91..0000000 --- a/src/api/timeline/guards/timeline-project-context.guard.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; -import { TimelineReference } from '@prisma/client'; -import { ExecutionContext } from '@nestjs/common'; -import { TimelineProjectContextGuard } from './timeline-project-context.guard'; - -function createExecutionContext( - request: Record, -): ExecutionContext { - return { - switchToHttp: () => ({ - getRequest: () => request, - }), - } as unknown as ExecutionContext; -} - -describe('TimelineProjectContextGuard', () => { - const timelineReferenceServiceMock = { - parsePositiveBigInt: jest.fn(), - resolveProjectContextByTimelineId: jest.fn(), - parseTimelineReference: jest.fn(), - resolveProjectContextByReference: jest.fn(), - }; - - let guard: TimelineProjectContextGuard; - - beforeEach(() => { - jest.clearAllMocks(); - - guard = new TimelineProjectContextGuard( - timelineReferenceServiceMock as any, - ); - }); - - it('attaches project context from timeline id param', async () => { - timelineReferenceServiceMock.parsePositiveBigInt.mockReturnValue(BigInt(7)); - timelineReferenceServiceMock.resolveProjectContextByTimelineId.mockResolvedValue( - { - timeline: { - id: BigInt(7), - reference: TimelineReference.project, - referenceId: BigInt(1001), - }, - reference: TimelineReference.project, - referenceId: BigInt(1001), - projectId: BigInt(1001), - }, - ); - - const request: any = { - params: { - timelineId: '7', - }, - body: {}, - }; - - const result = await guard.canActivate(createExecutionContext(request)); - - expect(result).toBe(true); - expect(request.params.projectId).toBe('1001'); - }); - - it('attaches project context from query reference/referenceId', async () => { - timelineReferenceServiceMock.parseTimelineReference.mockReturnValue( - TimelineReference.phase, - ); - timelineReferenceServiceMock.parsePositiveBigInt.mockReturnValue( - BigInt(2001), - ); - timelineReferenceServiceMock.resolveProjectContextByReference.mockResolvedValue( - { - reference: TimelineReference.phase, - referenceId: BigInt(2001), - projectId: BigInt(1002), - }, - ); - - const request: any = { - params: {}, - query: { - reference: 'phase', - referenceId: '2001', - }, - body: {}, - }; - - const result = await guard.canActivate(createExecutionContext(request)); - - expect(result).toBe(true); - expect(request.params.projectId).toBe('1002'); - }); - - it('throws bad request when only reference is provided', async () => { - const request: any = { - params: {}, - query: { - reference: 'project', - }, - body: {}, - }; - - await expect( - guard.canActivate(createExecutionContext(request)), - ).rejects.toBeInstanceOf(BadRequestException); - }); -}); diff --git a/src/api/timeline/guards/timeline-project-context.guard.ts b/src/api/timeline/guards/timeline-project-context.guard.ts deleted file mode 100644 index acf86d9..0000000 --- a/src/api/timeline/guards/timeline-project-context.guard.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { - BadRequestException, - CanActivate, - ExecutionContext, - Injectable, -} from '@nestjs/common'; -import { AuthenticatedRequest } from 'src/shared/interfaces/request.interface'; -import { - ResolvedTimelineProjectContext, - TimelineReferenceService, -} from '../timeline-reference.service'; - -type TimelineAwareRequest = AuthenticatedRequest & { - timelineContext?: ResolvedTimelineProjectContext; -}; - -@Injectable() -export class TimelineProjectContextGuard implements CanActivate { - constructor( - private readonly timelineReferenceService: TimelineReferenceService, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - - const timelineId = this.extractValue(request.params?.timelineId); - if (timelineId) { - const parsedTimelineId = - this.timelineReferenceService.parsePositiveBigInt( - timelineId, - 'timelineId', - ); - - const timelineContext = - await this.timelineReferenceService.resolveProjectContextByTimelineId( - parsedTimelineId, - ); - - const bodyReference = this.extractValue(request.body?.reference); - const bodyReferenceId = this.extractValue(request.body?.referenceId); - - if (bodyReference || bodyReferenceId) { - if (!bodyReference || !bodyReferenceId) { - throw new BadRequestException( - 'reference and referenceId must be provided together.', - ); - } - - const resolvedReference = - this.timelineReferenceService.parseTimelineReference(bodyReference); - const resolvedReferenceId = - this.timelineReferenceService.parsePositiveBigInt( - bodyReferenceId, - 'referenceId', - ); - - const bodyContext = - await this.timelineReferenceService.resolveProjectContextByReference( - resolvedReference, - resolvedReferenceId, - ); - - this.attachProjectContext(request, { - ...timelineContext, - reference: bodyContext.reference, - referenceId: bodyContext.referenceId, - projectId: bodyContext.projectId, - }); - - return true; - } - - this.attachProjectContext(request, timelineContext); - return true; - } - - const queryReference = this.extractValue(request.query?.reference); - const queryReferenceId = this.extractValue(request.query?.referenceId); - const bodyReference = this.extractValue(request.body?.reference); - const bodyReferenceId = this.extractValue(request.body?.referenceId); - - const referenceSource = queryReference - ? { - reference: queryReference, - referenceId: queryReferenceId, - } - : { - reference: bodyReference, - referenceId: bodyReferenceId, - }; - - if (!referenceSource.reference && !referenceSource.referenceId) { - return true; - } - - if (!referenceSource.reference || !referenceSource.referenceId) { - throw new BadRequestException( - 'reference and referenceId must be provided together.', - ); - } - - const reference = this.timelineReferenceService.parseTimelineReference( - referenceSource.reference, - ); - const referenceId = this.timelineReferenceService.parsePositiveBigInt( - referenceSource.referenceId, - 'referenceId', - ); - - const resolvedContext = - await this.timelineReferenceService.resolveProjectContextByReference( - reference, - referenceId, - ); - - this.attachProjectContext(request, resolvedContext); - - return true; - } - - private attachProjectContext( - request: TimelineAwareRequest, - context: ResolvedTimelineProjectContext, - ): void { - request.timelineContext = context; - - if (!request.params) { - request.params = {}; - } - - request.params.projectId = context.projectId.toString(); - request.params.timelineReference = context.reference; - request.params.timelineReferenceId = context.referenceId.toString(); - } - - private extractValue(value: unknown): string | undefined { - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim(); - } - - if (typeof value === 'number' && Number.isFinite(value)) { - return String(Math.trunc(value)); - } - - return undefined; - } -} diff --git a/src/api/timeline/timeline-reference.service.spec.ts b/src/api/timeline/timeline-reference.service.spec.ts deleted file mode 100644 index 0bea694..0000000 --- a/src/api/timeline/timeline-reference.service.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { TimelineReference } from '@prisma/client'; -import { TimelineReferenceService } from './timeline-reference.service'; - -describe('TimelineReferenceService', () => { - const prismaMock = { - project: { - findFirst: jest.fn(), - }, - projectPhase: { - findFirst: jest.fn(), - }, - phaseProduct: { - findFirst: jest.fn(), - }, - workStream: { - findFirst: jest.fn(), - }, - timeline: { - findFirst: jest.fn(), - }, - }; - - let service: TimelineReferenceService; - - beforeEach(() => { - jest.clearAllMocks(); - service = new TimelineReferenceService(prismaMock as any); - }); - - it('resolves project reference directly', async () => { - prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001) }); - - const result = await service.resolveProjectContextByReference( - TimelineReference.project, - BigInt(1001), - ); - - expect(result.projectId).toBe(BigInt(1001)); - }); - - it('resolves project id from phase reference', async () => { - prismaMock.projectPhase.findFirst.mockResolvedValue({ - projectId: BigInt(1002), - }); - - const result = await service.resolveProjectContextByReference( - TimelineReference.phase, - BigInt(77), - ); - - expect(result.projectId).toBe(BigInt(1002)); - }); - - it('resolves project id from product reference', async () => { - prismaMock.phaseProduct.findFirst.mockResolvedValue({ - projectId: BigInt(1003), - }); - - const result = await service.resolveProjectContextByReference( - TimelineReference.product, - BigInt(88), - ); - - expect(result.projectId).toBe(BigInt(1003)); - }); - - it('resolves project id from work reference', async () => { - prismaMock.workStream.findFirst.mockResolvedValue({ - projectId: BigInt(1004), - }); - - const result = await service.resolveProjectContextByReference( - TimelineReference.work, - BigInt(99), - ); - - expect(result.projectId).toBe(BigInt(1004)); - }); - - it('throws bad request when reference target does not exist', async () => { - prismaMock.project.findFirst.mockResolvedValue(null); - - await expect( - service.resolveProjectContextByReference( - TimelineReference.project, - BigInt(1), - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('resolves by timeline id', async () => { - prismaMock.timeline.findFirst.mockResolvedValue({ - id: BigInt(9), - reference: TimelineReference.project, - referenceId: BigInt(1001), - }); - prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001) }); - - const result = await service.resolveProjectContextByTimelineId(BigInt(9)); - - expect(result.timeline?.id).toBe(BigInt(9)); - expect(result.projectId).toBe(BigInt(1001)); - }); - - it('throws not found when timeline id does not exist', async () => { - prismaMock.timeline.findFirst.mockResolvedValue(null); - - await expect( - service.resolveProjectContextByTimelineId(BigInt(100)), - ).rejects.toBeInstanceOf(NotFoundException); - }); - - it('parses timeline references', () => { - expect(service.parseTimelineReference('project')).toBe( - TimelineReference.project, - ); - - expect(() => service.parseTimelineReference('invalid')).toThrow( - BadRequestException, - ); - }); -}); diff --git a/src/api/timeline/timeline-reference.service.ts b/src/api/timeline/timeline-reference.service.ts deleted file mode 100644 index d39fa71..0000000 --- a/src/api/timeline/timeline-reference.service.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { Timeline, TimelineReference } from '@prisma/client'; -import { PrismaService } from 'src/shared/modules/global/prisma.service'; - -export interface ResolvedTimelineProjectContext { - timeline?: Pick; - reference: TimelineReference; - referenceId: bigint; - projectId: bigint; -} - -@Injectable() -export class TimelineReferenceService { - constructor(private readonly prisma: PrismaService) {} - - async resolveProjectContextByTimelineId( - timelineId: bigint, - ): Promise { - const timeline = await this.prisma.timeline.findFirst({ - where: { - id: timelineId, - deletedAt: null, - }, - select: { - id: true, - reference: true, - referenceId: true, - }, - }); - - if (!timeline) { - throw new NotFoundException( - `Timeline not found for timeline id ${timelineId.toString()}.`, - ); - } - - const projectId = await this.resolveProjectId( - timeline.reference, - timeline.referenceId, - ); - - return { - timeline, - reference: timeline.reference, - referenceId: timeline.referenceId, - projectId, - }; - } - - async resolveProjectContextByReference( - reference: TimelineReference, - referenceId: bigint, - ): Promise { - const projectId = await this.resolveProjectId(reference, referenceId); - - return { - reference, - referenceId, - projectId, - }; - } - - parseTimelineReference(value: unknown): TimelineReference { - const rawValue = - typeof value === 'string' || typeof value === 'number' - ? String(value) - : ''; - const normalized = rawValue.trim().toLowerCase(); - - if (normalized.length === 0) { - throw new BadRequestException('reference is required.'); - } - - if ( - !Object.values(TimelineReference).includes( - normalized as TimelineReference, - ) - ) { - throw new BadRequestException( - `reference must be one of: ${Object.values(TimelineReference).join(', ')}.`, - ); - } - - return normalized as TimelineReference; - } - - parsePositiveBigInt(value: unknown, fieldName: string): bigint { - const rawValue = - typeof value === 'string' || typeof value === 'number' - ? String(value) - : ''; - const normalized = rawValue.trim(); - - if (!/^\d+$/.test(normalized)) { - throw new BadRequestException(`${fieldName} must be a positive integer.`); - } - - const parsed = BigInt(normalized); - if (parsed <= BigInt(0)) { - throw new BadRequestException(`${fieldName} must be a positive integer.`); - } - - return parsed; - } - - private async resolveProjectId( - reference: TimelineReference, - referenceId: bigint, - ): Promise { - if (reference === TimelineReference.project) { - const project = await this.prisma.project.findFirst({ - where: { - id: referenceId, - deletedAt: null, - }, - select: { - id: true, - }, - }); - - if (!project) { - throw new BadRequestException( - `Project not found for project id ${referenceId.toString()}.`, - ); - } - - return project.id; - } - - if (reference === TimelineReference.phase) { - const phase = await this.prisma.projectPhase.findFirst({ - where: { - id: referenceId, - deletedAt: null, - }, - select: { - projectId: true, - }, - }); - - if (!phase) { - throw new BadRequestException( - `Phase not found for phase id ${referenceId.toString()}.`, - ); - } - - return phase.projectId; - } - - if (reference === TimelineReference.product) { - const product = await this.prisma.phaseProduct.findFirst({ - where: { - id: referenceId, - deletedAt: null, - }, - select: { - projectId: true, - }, - }); - - if (!product) { - throw new BadRequestException( - `Product not found for product id ${referenceId.toString()}.`, - ); - } - - return product.projectId; - } - - const work = await this.prisma.workStream.findFirst({ - where: { - id: referenceId, - deletedAt: null, - }, - select: { - projectId: true, - }, - }); - - if (!work) { - throw new BadRequestException( - `Work stream not found for work id ${referenceId.toString()}.`, - ); - } - - return work.projectId; - } -} diff --git a/src/api/timeline/timeline.controller.ts b/src/api/timeline/timeline.controller.ts deleted file mode 100644 index 9cc534e..0000000 --- a/src/api/timeline/timeline.controller.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - Param, - Patch, - Post, - Query, - UseGuards, -} from '@nestjs/common'; -import { - ApiBearerAuth, - ApiBody, - ApiOperation, - ApiParam, - ApiQuery, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; -import { Permission } from 'src/shared/constants/permissions'; -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'; -import { CreateTimelineDto } from './dto/create-timeline.dto'; -import { TimelineListQueryDto } from './dto/timeline-list-query.dto'; -import { TimelineResponseDto } from './dto/timeline-response.dto'; -import { UpdateTimelineDto } from './dto/update-timeline.dto'; -import { TimelineProjectContextGuard } from './guards/timeline-project-context.guard'; -import { TimelineService } from './timeline.service'; - -@ApiTags('Timelines') -@ApiBearerAuth() -@Controller('/timelines') -export class TimelineController { - constructor(private readonly service: TimelineService) {} - - @Get() - @UseGuards(TimelineProjectContextGuard, PermissionGuard) - @Roles(...Object.values(UserRole)) - @Scopes( - Scope.PROJECTS_READ, - Scope.PROJECTS_WRITE, - Scope.PROJECTS_ALL, - Scope.CONNECT_PROJECT_ADMIN, - ) - @RequirePermission(Permission.VIEW_PROJECT) - @ApiOperation({ summary: 'List timelines by reference and referenceId' }) - @ApiQuery({ name: 'reference', required: true }) - @ApiQuery({ name: 'referenceId', required: true, type: Number }) - @ApiResponse({ status: 200, type: [TimelineResponseDto] }) - async listTimelines( - @Query() query: TimelineListQueryDto, - ): Promise { - return this.service.listTimelines(query); - } - - @Get(':timelineId') - @UseGuards(TimelineProjectContextGuard, PermissionGuard) - @Roles(...Object.values(UserRole)) - @Scopes( - Scope.PROJECTS_READ, - Scope.PROJECTS_WRITE, - Scope.PROJECTS_ALL, - Scope.CONNECT_PROJECT_ADMIN, - ) - @RequirePermission(Permission.VIEW_PROJECT) - @ApiOperation({ summary: 'Get timeline by id' }) - @ApiParam({ name: 'timelineId', description: 'Timeline id' }) - @ApiResponse({ status: 200, type: TimelineResponseDto }) - @ApiResponse({ status: 404, description: 'Not found' }) - async getTimeline( - @Param('timelineId') timelineId: string, - ): Promise { - return this.service.getTimeline(timelineId); - } - - @Post() - @UseGuards(TimelineProjectContextGuard, PermissionGuard) - @Roles(...Object.values(UserRole)) - @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) - @RequirePermission(Permission.EDIT_PROJECT) - @ApiOperation({ summary: 'Create timeline' }) - @ApiBody({ type: CreateTimelineDto }) - @ApiResponse({ status: 201, type: TimelineResponseDto }) - async createTimeline( - @Body() dto: CreateTimelineDto, - @CurrentUser() user: JwtUser, - ): Promise { - return this.service.createTimeline(dto, user); - } - - @Patch(':timelineId') - @UseGuards(TimelineProjectContextGuard, PermissionGuard) - @Roles(...Object.values(UserRole)) - @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) - @RequirePermission(Permission.EDIT_PROJECT) - @ApiOperation({ summary: 'Update timeline' }) - @ApiParam({ name: 'timelineId', description: 'Timeline id' }) - @ApiBody({ type: UpdateTimelineDto }) - @ApiResponse({ status: 200, type: TimelineResponseDto }) - async updateTimeline( - @Param('timelineId') timelineId: string, - @Body() dto: UpdateTimelineDto, - @CurrentUser() user: JwtUser, - ): Promise { - return this.service.updateTimeline(timelineId, dto, user); - } - - @Delete(':timelineId') - @HttpCode(204) - @UseGuards(TimelineProjectContextGuard, PermissionGuard) - @Roles(...Object.values(UserRole)) - @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) - @RequirePermission(Permission.EDIT_PROJECT) - @ApiOperation({ summary: 'Delete timeline' }) - @ApiParam({ name: 'timelineId', description: 'Timeline id' }) - @ApiResponse({ status: 204, description: 'Deleted' }) - async deleteTimeline( - @Param('timelineId') timelineId: string, - @CurrentUser() user: JwtUser, - ): Promise { - await this.service.deleteTimeline(timelineId, user); - } -} diff --git a/src/api/timeline/timeline.module.ts b/src/api/timeline/timeline.module.ts deleted file mode 100644 index a70a292..0000000 --- a/src/api/timeline/timeline.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Module } from '@nestjs/common'; -import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; -import { TimelineController } from './timeline.controller'; -import { TimelineProjectContextGuard } from './guards/timeline-project-context.guard'; -import { TimelineReferenceService } from './timeline-reference.service'; -import { TimelineService } from './timeline.service'; - -@Module({ - imports: [GlobalProvidersModule], - controllers: [TimelineController], - providers: [ - TimelineService, - TimelineReferenceService, - TimelineProjectContextGuard, - ], - exports: [ - TimelineService, - TimelineReferenceService, - TimelineProjectContextGuard, - ], -}) -export class TimelineModule {} diff --git a/src/api/timeline/timeline.service.spec.ts b/src/api/timeline/timeline.service.spec.ts deleted file mode 100644 index dd0e938..0000000 --- a/src/api/timeline/timeline.service.spec.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { ProjectStatus, TimelineReference } from '@prisma/client'; -import { KAFKA_TOPIC } from 'src/shared/config/kafka.config'; -import { TimelineService } from './timeline.service'; - -jest.mock('src/shared/utils/event.utils', () => ({ - publishMilestoneEvent: jest.fn(() => Promise.resolve()), - publishNotificationEvent: jest.fn(() => Promise.resolve()), - publishTimelineEvent: jest.fn(() => Promise.resolve()), -})); - -const eventUtils = jest.requireMock('src/shared/utils/event.utils'); - -function buildMilestone(overrides: Record = {}) { - return { - id: BigInt(11), - timelineId: BigInt(1), - name: 'Kickoff', - description: null, - duration: 2, - startDate: new Date('2026-02-01T00:00:00.000Z'), - actualStartDate: null, - endDate: new Date('2026-02-02T00:00:00.000Z'), - completionDate: null, - status: ProjectStatus.reviewed, - type: 'phase', - details: {}, - order: 1, - plannedText: 'planned', - activeText: 'active', - completedText: 'completed', - blockedText: 'blocked', - hidden: false, - deletedAt: null, - deletedBy: null, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - createdBy: BigInt(123), - updatedBy: BigInt(123), - statusHistory: [], - ...overrides, - }; -} - -function buildTimeline(overrides: Record = {}) { - return { - id: BigInt(1), - name: 'Execution', - description: null, - startDate: new Date('2026-02-01T00:00:00.000Z'), - endDate: new Date('2026-02-28T00:00:00.000Z'), - reference: TimelineReference.project, - referenceId: BigInt(1001), - deletedAt: null, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - deletedBy: null, - createdBy: BigInt(123), - updatedBy: BigInt(123), - milestones: [buildMilestone()], - ...overrides, - }; -} - -describe('TimelineService', () => { - const prismaMock = { - timeline: { - findFirst: jest.fn(), - }, - project: { - findFirst: jest.fn(), - }, - $transaction: jest.fn(), - }; - - const referenceServiceMock = { - resolveProjectContextByReference: jest.fn(), - }; - - let service: TimelineService; - - beforeEach(() => { - jest.clearAllMocks(); - - service = new TimelineService( - prismaMock as any, - referenceServiceMock as any, - ); - }); - - it('creates timeline and bootstraps milestones from template with status history', async () => { - const txTimelineCreate = jest.fn().mockResolvedValue({ - id: BigInt(1), - startDate: new Date('2026-02-01T00:00:00.000Z'), - name: 'Execution', - description: null, - endDate: null, - reference: TimelineReference.project, - referenceId: BigInt(1001), - deletedAt: null, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - deletedBy: null, - createdBy: BigInt(123), - updatedBy: BigInt(123), - }); - const txTemplateFindMany = jest.fn().mockResolvedValue([ - { - id: BigInt(9), - name: 'Template A', - description: 'A', - duration: 2, - type: 'phase', - order: 1, - plannedText: 'planned', - activeText: 'active', - completedText: 'completed', - blockedText: 'blocked', - hidden: false, - metadata: { key: 'value' }, - }, - { - id: BigInt(10), - name: 'Template B', - description: 'B', - duration: 1, - type: 'phase', - order: 2, - plannedText: 'planned2', - activeText: 'active2', - completedText: 'completed2', - blockedText: 'blocked2', - hidden: false, - metadata: { key: 'value-2' }, - }, - ]); - const txMilestoneCreate = jest - .fn() - .mockResolvedValueOnce( - buildMilestone({ - id: BigInt(200), - name: 'Template A', - startDate: new Date('2026-02-01T00:00:00.000Z'), - endDate: new Date('2026-02-02T00:00:00.000Z'), - }), - ) - .mockResolvedValueOnce( - buildMilestone({ - id: BigInt(201), - name: 'Template B', - order: 2, - startDate: new Date('2026-02-03T00:00:00.000Z'), - endDate: new Date('2026-02-03T00:00:00.000Z'), - }), - ); - const txStatusHistoryCreate = jest.fn().mockResolvedValue({}); - - prismaMock.$transaction.mockImplementation( - async (callback: (tx: unknown) => Promise) => - callback({ - timeline: { - create: txTimelineCreate, - }, - milestoneTemplate: { - findMany: txTemplateFindMany, - }, - milestone: { - create: txMilestoneCreate, - }, - statusHistory: { - create: txStatusHistoryCreate, - }, - }), - ); - - prismaMock.timeline.findFirst.mockResolvedValue( - buildTimeline({ - milestones: [ - buildMilestone({ - id: BigInt(200), - name: 'Template A', - statusHistory: [ - { - id: BigInt(1), - reference: 'milestone', - referenceId: BigInt(200), - status: ProjectStatus.reviewed, - comment: null, - createdBy: 123, - createdAt: new Date(), - updatedBy: 123, - updatedAt: new Date(), - }, - ], - }), - buildMilestone({ - id: BigInt(201), - name: 'Template B', - order: 2, - statusHistory: [ - { - id: BigInt(2), - reference: 'milestone', - referenceId: BigInt(201), - status: ProjectStatus.reviewed, - comment: null, - createdBy: 123, - createdAt: new Date(), - updatedBy: 123, - updatedAt: new Date(), - }, - ], - }), - ], - }), - ); - - referenceServiceMock.resolveProjectContextByReference.mockResolvedValue({ - projectId: BigInt(1001), - }); - - prismaMock.project.findFirst.mockResolvedValue({ - id: BigInt(1001), - name: 'Demo Project', - details: { - utm: { - code: 'ABC', - }, - }, - }); - - const response = await service.createTimeline( - { - name: 'Execution', - startDate: new Date('2026-02-01T00:00:00.000Z'), - reference: TimelineReference.project, - referenceId: 1001, - templateId: 67, - }, - { - userId: '123', - isMachine: false, - }, - ); - - expect(response.id).toBe('1'); - expect(txMilestoneCreate).toHaveBeenCalledTimes(2); - expect(txStatusHistoryCreate).toHaveBeenCalledTimes(2); - expect(eventUtils.publishTimelineEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.TIMELINE_ADDED, - expect.any(Object), - ); - expect(eventUtils.publishMilestoneEvent).toHaveBeenCalledTimes(2); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.TIMELINE_ADJUSTED, - expect.any(Object), - ); - }); - - it('updates timeline startDate and cascades milestone schedule updates', async () => { - prismaMock.timeline.findFirst - .mockResolvedValueOnce( - buildTimeline({ - startDate: new Date('2026-02-01T00:00:00.000Z'), - milestones: [ - buildMilestone({ - id: BigInt(11), - duration: 2, - startDate: new Date('2026-02-01T00:00:00.000Z'), - endDate: new Date('2026-02-02T00:00:00.000Z'), - }), - buildMilestone({ - id: BigInt(12), - order: 2, - duration: 3, - startDate: new Date('2026-02-03T00:00:00.000Z'), - endDate: new Date('2026-02-05T00:00:00.000Z'), - }), - ], - }), - ) - .mockResolvedValueOnce( - buildTimeline({ - startDate: new Date('2026-02-10T00:00:00.000Z'), - milestones: [ - buildMilestone({ - id: BigInt(11), - duration: 2, - startDate: new Date('2026-02-10T00:00:00.000Z'), - endDate: new Date('2026-02-11T00:00:00.000Z'), - }), - buildMilestone({ - id: BigInt(12), - order: 2, - duration: 3, - startDate: new Date('2026-02-12T00:00:00.000Z'), - endDate: new Date('2026-02-14T00:00:00.000Z'), - }), - ], - }), - ); - - const txTimelineUpdate = jest.fn().mockResolvedValue({}); - const txMilestoneFindMany = jest.fn().mockResolvedValue([ - buildMilestone({ - id: BigInt(11), - duration: 2, - startDate: new Date('2026-02-01T00:00:00.000Z'), - endDate: new Date('2026-02-02T00:00:00.000Z'), - }), - buildMilestone({ - id: BigInt(12), - order: 2, - duration: 3, - startDate: new Date('2026-02-03T00:00:00.000Z'), - endDate: new Date('2026-02-05T00:00:00.000Z'), - }), - ]); - const txMilestoneUpdate = jest.fn().mockResolvedValue({}); - - prismaMock.$transaction.mockImplementation( - async (callback: (tx: unknown) => Promise) => - callback({ - timeline: { - update: txTimelineUpdate, - }, - milestone: { - findMany: txMilestoneFindMany, - update: txMilestoneUpdate, - }, - }), - ); - - referenceServiceMock.resolveProjectContextByReference.mockResolvedValue({ - projectId: BigInt(1001), - }); - - prismaMock.project.findFirst.mockResolvedValue({ - id: BigInt(1001), - name: 'Demo Project', - details: {}, - }); - - const response = await service.updateTimeline( - '1', - { - startDate: new Date('2026-02-10T00:00:00.000Z'), - }, - { - userId: '123', - isMachine: false, - }, - ); - - expect(response.startDate.toISOString()).toBe('2026-02-10T00:00:00.000Z'); - expect(txMilestoneUpdate).toHaveBeenCalledTimes(2); - expect(eventUtils.publishTimelineEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.TIMELINE_UPDATED, - expect.any(Object), - ); - expect(eventUtils.publishNotificationEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.TIMELINE_ADJUSTED, - expect.any(Object), - ); - }); -}); diff --git a/src/api/timeline/timeline.service.ts b/src/api/timeline/timeline.service.ts deleted file mode 100644 index d688730..0000000 --- a/src/api/timeline/timeline.service.ts +++ /dev/null @@ -1,715 +0,0 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { - Milestone, - MilestoneTemplate, - Prisma, - ProjectStatus, - StatusHistory, - Timeline, -} from '@prisma/client'; -import { MilestoneResponseDto } from 'src/api/milestone/dto/milestone-response.dto'; -import { StatusHistoryResponseDto } from 'src/api/milestone/dto/status-history-response.dto'; -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 { - publishMilestoneEvent, - publishNotificationEvent, - publishTimelineEvent, -} from 'src/shared/utils/event.utils'; -import { toSerializable } from '../metadata/utils/metadata-utils'; -import { CreateTimelineDto } from './dto/create-timeline.dto'; -import { TimelineListQueryDto } from './dto/timeline-list-query.dto'; -import { TimelineResponseDto } from './dto/timeline-response.dto'; -import { UpdateTimelineDto } from './dto/update-timeline.dto'; -import { TimelineReferenceService } from './timeline-reference.service'; - -type MilestoneWithStatusHistory = Milestone & { - statusHistory?: StatusHistory[]; -}; - -type TimelineWithMilestones = Timeline & { - milestones: MilestoneWithStatusHistory[]; -}; - -@Injectable() -export class TimelineService { - private readonly logger = LoggerService.forRoot('TimelineService'); - - constructor( - private readonly prisma: PrismaService, - private readonly timelineReferenceService: TimelineReferenceService, - ) {} - - async listTimelines( - query: TimelineListQueryDto, - ): Promise { - const timelines = await this.prisma.timeline.findMany({ - where: { - reference: query.reference, - referenceId: BigInt(query.referenceId), - deletedAt: null, - }, - include: this.getMilestoneInclude(), - orderBy: [{ id: 'asc' }], - }); - - return timelines.map((timeline) => - this.toTimelineDto(timeline as TimelineWithMilestones), - ); - } - - async getTimeline(timelineId: string): Promise { - const parsedTimelineId = this.parseId(timelineId, 'timelineId'); - - const timeline = await this.findTimelineWithMilestones(parsedTimelineId); - - if (!timeline) { - throw new NotFoundException( - `Timeline not found for timeline id ${timelineId}.`, - ); - } - - return this.toTimelineDto(timeline); - } - - async createTimeline( - dto: CreateTimelineDto, - user: JwtUser, - ): Promise { - const auditUserId = this.getAuditUserIdBigInt(user); - const statusAuditUserId = this.getAuditUserIdInt(user); - - this.validateTimelineDateRange(dto.startDate, dto.endDate ?? null); - - const referenceId = BigInt(dto.referenceId); - const templateId = - typeof dto.templateId === 'number' ? BigInt(dto.templateId) : null; - - const resolvedContext = - await this.timelineReferenceService.resolveProjectContextByReference( - dto.reference, - referenceId, - ); - - let createdTimelineId = BigInt(0); - let createdMilestones: Milestone[] = []; - - await this.prisma.$transaction(async (tx) => { - const createdTimeline = await tx.timeline.create({ - data: { - name: dto.name, - description: dto.description || null, - startDate: dto.startDate, - endDate: dto.endDate || null, - reference: dto.reference, - referenceId, - createdBy: auditUserId, - updatedBy: auditUserId, - }, - }); - - createdTimelineId = createdTimeline.id; - - if (templateId) { - createdMilestones = await this.createTemplateMilestones( - tx, - createdTimeline, - templateId, - auditUserId, - statusAuditUserId, - ); - } - }); - - const createdTimeline = - await this.findTimelineWithMilestones(createdTimelineId); - - if (!createdTimeline) { - throw new NotFoundException( - `Timeline not found for timeline id ${createdTimelineId.toString()} after creation.`, - ); - } - - const response = this.toTimelineDto(createdTimeline); - - this.publishTimelineAction(KAFKA_TOPIC.TIMELINE_ADDED, response); - - for (const milestone of createdMilestones) { - this.publishMilestoneAction( - KAFKA_TOPIC.MILESTONE_ADDED, - this.toMilestoneDto({ - ...milestone, - statusHistory: [], - }), - ); - } - - if (createdMilestones.length > 0) { - await this.publishTimelineAdjustedNotification( - resolvedContext.projectId, - { - ...response, - milestones: [], - }, - response, - user, - ); - } - - return response; - } - - async updateTimeline( - timelineId: string, - dto: UpdateTimelineDto, - user: JwtUser, - ): Promise { - const parsedTimelineId = this.parseId(timelineId, 'timelineId'); - const auditUserId = this.getAuditUserIdBigInt(user); - - const existingTimeline = - await this.findTimelineWithMilestones(parsedTimelineId); - - if (!existingTimeline) { - throw new NotFoundException( - `Timeline not found for timeline id ${timelineId}.`, - ); - } - - if ( - (typeof dto.reference !== 'undefined' && - typeof dto.referenceId === 'undefined') || - (typeof dto.reference === 'undefined' && - typeof dto.referenceId !== 'undefined') - ) { - throw new BadRequestException( - 'reference and referenceId must be provided together.', - ); - } - - const resolvedReference = - typeof dto.reference === 'undefined' - ? existingTimeline.reference - : dto.reference; - const resolvedReferenceId = - typeof dto.referenceId === 'undefined' - ? existingTimeline.referenceId - : BigInt(dto.referenceId); - - await this.timelineReferenceService.resolveProjectContextByReference( - resolvedReference, - resolvedReferenceId, - ); - - const updatedStartDate = dto.startDate ?? existingTimeline.startDate; - const updatedEndDate = - typeof dto.endDate === 'undefined' - ? existingTimeline.endDate - : dto.endDate; - - this.validateTimelineDateRange(updatedStartDate, updatedEndDate); - - await this.prisma.$transaction(async (tx) => { - await tx.timeline.update({ - where: { - id: parsedTimelineId, - }, - data: { - ...(typeof dto.name === 'undefined' ? {} : { name: dto.name }), - ...(typeof dto.description === 'undefined' - ? {} - : { description: dto.description }), - ...(typeof dto.startDate === 'undefined' - ? {} - : { startDate: dto.startDate }), - ...(typeof dto.endDate === 'undefined' - ? {} - : { endDate: dto.endDate }), - ...(typeof dto.reference === 'undefined' - ? {} - : { reference: dto.reference }), - ...(typeof dto.referenceId === 'undefined' - ? {} - : { referenceId: BigInt(dto.referenceId) }), - updatedBy: auditUserId, - }, - }); - - const startDateChanged = - existingTimeline.startDate.getTime() !== updatedStartDate.getTime(); - - if (startDateChanged) { - await this.rescheduleMilestonesForTimeline( - tx, - parsedTimelineId, - updatedStartDate, - auditUserId, - ); - } - }); - - const updatedTimeline = - await this.findTimelineWithMilestones(parsedTimelineId); - - if (!updatedTimeline) { - throw new NotFoundException( - `Timeline not found for timeline id ${timelineId} after update.`, - ); - } - - const updatedResponse = this.toTimelineDto(updatedTimeline); - const originalResponse = this.toTimelineDto(existingTimeline); - - this.publishTimelineAction(KAFKA_TOPIC.TIMELINE_UPDATED, { - updated: updatedResponse, - original: originalResponse, - }); - - const context = - await this.timelineReferenceService.resolveProjectContextByReference( - updatedTimeline.reference, - updatedTimeline.referenceId, - ); - - await this.publishTimelineAdjustedNotification( - context.projectId, - originalResponse, - updatedResponse, - user, - ); - - return updatedResponse; - } - - async deleteTimeline(timelineId: string, user: JwtUser): Promise { - const parsedTimelineId = this.parseId(timelineId, 'timelineId'); - const auditUserId = this.getAuditUserIdBigInt(user); - - const existingTimeline = - await this.findTimelineWithMilestones(parsedTimelineId); - - if (!existingTimeline) { - throw new NotFoundException( - `Timeline not found for timeline id ${timelineId}.`, - ); - } - - const deletedMilestones = await this.prisma.$transaction(async (tx) => { - const milestones = await tx.milestone.findMany({ - where: { - timelineId: parsedTimelineId, - deletedAt: null, - }, - select: { - id: true, - timelineId: true, - }, - }); - - await tx.timeline.update({ - where: { - id: parsedTimelineId, - }, - data: { - deletedAt: new Date(), - deletedBy: auditUserId, - updatedBy: auditUserId, - }, - }); - - await tx.milestone.updateMany({ - where: { - timelineId: parsedTimelineId, - deletedAt: null, - }, - data: { - deletedAt: new Date(), - deletedBy: auditUserId, - updatedBy: auditUserId, - }, - }); - - return milestones; - }); - - this.publishTimelineAction(KAFKA_TOPIC.TIMELINE_REMOVED, { - id: timelineId, - }); - - for (const milestone of deletedMilestones) { - this.publishMilestoneAction(KAFKA_TOPIC.MILESTONE_REMOVED, { - id: milestone.id.toString(), - timelineId: milestone.timelineId.toString(), - }); - } - } - - private async createTemplateMilestones( - tx: Prisma.TransactionClient, - timeline: Timeline, - templateId: bigint, - auditUserId: bigint, - statusAuditUserId: number, - ): Promise { - const templates = await tx.milestoneTemplate.findMany({ - where: { - reference: 'productTemplate', - referenceId: templateId, - deletedAt: null, - }, - orderBy: [{ order: 'asc' }, { id: 'asc' }], - }); - - const createdMilestones: Milestone[] = []; - let nextStartDate = new Date(timeline.startDate); - - for (const template of templates) { - const milestone = await this.createMilestoneFromTemplate( - tx, - timeline, - template, - nextStartDate, - auditUserId, - ); - - createdMilestones.push(milestone); - - await tx.statusHistory.create({ - data: { - reference: 'milestone', - referenceId: milestone.id, - status: milestone.status, - comment: null, - createdBy: statusAuditUserId, - updatedBy: statusAuditUserId, - }, - }); - - if (!template.hidden) { - nextStartDate = this.addDaysUtc( - milestone.endDate || milestone.startDate, - 1, - ); - } - } - - return createdMilestones; - } - - private async createMilestoneFromTemplate( - tx: Prisma.TransactionClient, - timeline: Timeline, - template: MilestoneTemplate, - startDate: Date, - auditUserId: bigint, - ): Promise { - const duration = Math.max(1, template.duration); - const computedEndDate = this.addDaysUtc(startDate, duration - 1); - - return tx.milestone.create({ - data: { - timelineId: timeline.id, - name: template.name, - description: template.description, - duration, - startDate, - endDate: computedEndDate, - status: ProjectStatus.reviewed, - type: template.type, - details: { - metadata: template.metadata, - } as Prisma.InputJsonValue, - order: template.order, - plannedText: template.plannedText, - activeText: template.activeText, - completedText: template.completedText, - blockedText: template.blockedText, - hidden: template.hidden, - createdBy: auditUserId, - updatedBy: auditUserId, - }, - }); - } - - private async rescheduleMilestonesForTimeline( - tx: Prisma.TransactionClient, - timelineId: bigint, - timelineStartDate: Date, - auditUserId: bigint, - ): Promise { - const milestones = await tx.milestone.findMany({ - where: { - timelineId, - deletedAt: null, - }, - orderBy: [{ order: 'asc' }, { id: 'asc' }], - }); - - let nextStartDate = new Date(timelineStartDate); - - for (const milestone of milestones) { - const duration = Math.max(1, milestone.duration); - const expectedEndDate = this.addDaysUtc(nextStartDate, duration - 1); - - const shouldUpdate = - milestone.startDate.getTime() !== nextStartDate.getTime() || - milestone.endDate?.getTime() !== expectedEndDate.getTime(); - - if (shouldUpdate) { - await tx.milestone.update({ - where: { - id: milestone.id, - }, - data: { - startDate: nextStartDate, - endDate: expectedEndDate, - updatedBy: auditUserId, - }, - }); - } - - nextStartDate = this.addDaysUtc(expectedEndDate, 1); - } - } - - private async findTimelineWithMilestones( - timelineId: bigint, - ): Promise { - const timeline = await this.prisma.timeline.findFirst({ - where: { - id: timelineId, - deletedAt: null, - }, - include: this.getMilestoneInclude(), - }); - - return timeline as TimelineWithMilestones | null; - } - - private getMilestoneInclude(): Prisma.TimelineInclude { - return { - milestones: { - where: { - deletedAt: null, - }, - orderBy: [{ order: 'asc' }, { id: 'asc' }], - include: { - statusHistory: { - where: { - reference: 'milestone', - }, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - }, - }, - }, - }; - } - - private toTimelineDto(timeline: TimelineWithMilestones): TimelineResponseDto { - return { - id: timeline.id.toString(), - name: timeline.name, - description: timeline.description, - startDate: timeline.startDate, - endDate: timeline.endDate, - reference: timeline.reference, - referenceId: timeline.referenceId.toString(), - milestones: timeline.milestones.map((milestone) => - this.toMilestoneDto(milestone), - ), - createdAt: timeline.createdAt, - updatedAt: timeline.updatedAt, - createdBy: timeline.createdBy.toString(), - updatedBy: timeline.updatedBy.toString(), - }; - } - - private toMilestoneDto( - milestone: MilestoneWithStatusHistory, - ): MilestoneResponseDto { - return { - id: milestone.id.toString(), - timelineId: milestone.timelineId.toString(), - name: milestone.name, - description: milestone.description, - duration: milestone.duration, - startDate: milestone.startDate, - actualStartDate: milestone.actualStartDate, - endDate: milestone.endDate, - completionDate: milestone.completionDate, - status: milestone.status, - type: milestone.type, - details: - (toSerializable(milestone.details) as Record | null) || - null, - order: milestone.order, - plannedText: milestone.plannedText, - activeText: milestone.activeText, - completedText: milestone.completedText, - blockedText: milestone.blockedText, - hidden: milestone.hidden, - statusHistory: (milestone.statusHistory || []).map((entry) => - this.toStatusHistoryDto(entry), - ), - createdAt: milestone.createdAt, - updatedAt: milestone.updatedAt, - createdBy: milestone.createdBy.toString(), - updatedBy: milestone.updatedBy.toString(), - }; - } - - private toStatusHistoryDto( - statusHistory: StatusHistory, - ): StatusHistoryResponseDto { - return { - id: statusHistory.id.toString(), - reference: statusHistory.reference, - referenceId: statusHistory.referenceId.toString(), - status: statusHistory.status, - comment: statusHistory.comment, - createdBy: statusHistory.createdBy, - createdAt: statusHistory.createdAt, - updatedBy: statusHistory.updatedBy, - updatedAt: statusHistory.updatedAt, - }; - } - - private validateTimelineDateRange( - startDate: Date, - endDate?: Date | null, - ): void { - if (endDate && endDate.getTime() < startDate.getTime()) { - throw new BadRequestException('Timeline endDate must be >= startDate.'); - } - } - - private addDaysUtc(date: Date, days: number): Date { - const value = new Date(date); - value.setUTCDate(value.getUTCDate() + days); - return value; - } - - private parseId(value: string, fieldName: string): bigint { - try { - const parsed = BigInt(value); - if (parsed <= BigInt(0)) { - throw new Error('invalid'); - } - return parsed; - } catch { - throw new BadRequestException(`${fieldName} must be a positive integer.`); - } - } - - private getAuditUserIdBigInt(user: JwtUser): bigint { - const rawUserId = String(user.userId || '').trim(); - if (!/^\d+$/.test(rawUserId)) { - throw new ForbiddenException('Authenticated user id must be numeric.'); - } - - return BigInt(rawUserId); - } - - private getAuditUserIdInt(user: JwtUser): number { - const rawUserId = String(user.userId || '').trim(); - if (!/^\d+$/.test(rawUserId)) { - throw new ForbiddenException('Authenticated user id must be numeric.'); - } - - const parsed = Number.parseInt(rawUserId, 10); - if (!Number.isSafeInteger(parsed) || parsed <= 0) { - throw new ForbiddenException('Authenticated user id must be numeric.'); - } - - return parsed; - } - - private publishTimelineAction(topic: string, payload: unknown): void { - void publishTimelineEvent(topic, toSerializable(payload)); - } - - private publishMilestoneAction(topic: string, payload: unknown): void { - void publishMilestoneEvent(topic, toSerializable(payload)); - } - - private async publishTimelineAdjustedNotification( - projectId: bigint, - originalTimeline: TimelineResponseDto, - updatedTimeline: TimelineResponseDto, - user: JwtUser, - ): Promise { - const project = await this.prisma.project.findFirst({ - where: { - id: projectId, - deletedAt: null, - }, - select: { - id: true, - name: true, - details: true, - }, - }); - - if (!project) { - this.logger.warn( - `Skipping timeline adjustment notification. Project ${projectId.toString()} was not found.`, - ); - return; - } - - const userId = this.getNotificationUserId(user); - - const details = - project.details && - typeof project.details === 'object' && - !Array.isArray(project.details) - ? (project.details as Record) - : {}; - const utm = - details.utm && - typeof details.utm === 'object' && - !Array.isArray(details.utm) - ? (details.utm as Record) - : {}; - - const payload = { - projectId: project.id.toString(), - projectName: project.name, - refCode: typeof utm.code === 'string' ? utm.code : undefined, - projectUrl: this.buildProjectUrl(project.id), - originalTimeline: toSerializable(originalTimeline), - updatedTimeline: toSerializable(updatedTimeline), - userId, - initiatorUserId: userId, - }; - - await publishNotificationEvent(KAFKA_TOPIC.TIMELINE_ADJUSTED, payload); - } - - private buildProjectUrl(projectId: bigint): string { - const baseUrl = - process.env.WORK_MANAGER_URL || - process.env.WORK_MANAGER_APP_URL || - 'https://platform.topcoder.com/connect/'; - - const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; - return `${normalizedBase}projects/${projectId.toString()}`; - } - - private getNotificationUserId(user: JwtUser): string { - const rawUserId = String(user.userId || '').trim(); - - if (/^\d+$/.test(rawUserId)) { - return rawUserId; - } - - return '-1'; - } -} diff --git a/src/api/workstream/workstream.service.spec.ts b/src/api/workstream/workstream.service.spec.ts index 00491cf..a07125c 100644 --- a/src/api/workstream/workstream.service.spec.ts +++ b/src/api/workstream/workstream.service.spec.ts @@ -1,15 +1,7 @@ import { NotFoundException } from '@nestjs/common'; import { WorkStreamStatus } from '@prisma/client'; -import { KAFKA_TOPIC } from 'src/shared/config/kafka.config'; import { WorkStreamService } from './workstream.service'; -jest.mock('src/shared/utils/event.utils', () => ({ - publishWorkstreamEvent: jest.fn(() => Promise.resolve()), - publishNotificationEvent: jest.fn(() => Promise.resolve()), -})); - -const eventUtils = jest.requireMock('src/shared/utils/event.utils'); - function buildWorkStreamRecord(overrides: Record = {}) { return { id: BigInt(11), @@ -83,10 +75,6 @@ describe('WorkStreamService', () => { }), }), ); - expect(eventUtils.publishWorkstreamEvent).toHaveBeenCalledWith( - KAFKA_TOPIC.PROJECT_WORKSTREAM_ADDED, - expect.any(Object), - ); }); it('throws not found when loading missing work stream', async () => { diff --git a/src/api/workstream/workstream.service.ts b/src/api/workstream/workstream.service.ts index 317b9e8..a88f9d5 100644 --- a/src/api/workstream/workstream.service.ts +++ b/src/api/workstream/workstream.service.ts @@ -4,13 +4,7 @@ import { NotFoundException, } from '@nestjs/common'; import { Prisma, WorkStream } from '@prisma/client'; -import { KAFKA_TOPIC } from 'src/shared/config/kafka.config'; -import { LoggerService } from 'src/shared/modules/global/logger.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; -import { - publishNotificationEvent, - publishWorkstreamEvent, -} from 'src/shared/utils/event.utils'; import { CreateWorkStreamDto, UpdateWorkStreamDto, @@ -33,8 +27,6 @@ const WORK_STREAM_SORT_FIELDS = ['name', 'status', 'createdAt', 'updatedAt']; @Injectable() export class WorkStreamService { - private readonly logger = LoggerService.forRoot('WorkStreamService'); - constructor(private readonly prisma: PrismaService) {} async create( @@ -58,28 +50,8 @@ export class WorkStreamService { }); const response = this.toDto(created); - this.publishWorkstreamResourceEvent( - KAFKA_TOPIC.PROJECT_WORKSTREAM_ADDED, - response, - ); - - if (response.status === 'active') { - this.publishNotification(KAFKA_TOPIC.PROJECT_WORK_TRANSITION_ACTIVE, { - projectId, - workstream: response, - userId: this.getNotificationUserId(userId), - initiatorUserId: this.getNotificationUserId(userId), - }); - } - - if (response.status === 'completed') { - this.publishNotification(KAFKA_TOPIC.PROJECT_WORK_TRANSITION_COMPLETED, { - projectId, - workstream: response, - userId: this.getNotificationUserId(userId), - initiatorUserId: this.getNotificationUserId(userId), - }); - } + void projectId; + void userId; return response; } @@ -150,7 +122,7 @@ export class WorkStreamService { ); } - return this.toDto(row as WorkStreamWithRelations); + return this.toDto(row); } async update( @@ -197,48 +169,9 @@ export class WorkStreamService { }); const response = this.toDto(updated); - this.publishWorkstreamResourceEvent( - KAFKA_TOPIC.PROJECT_WORKSTREAM_UPDATED, - response, - ); - - if (existing.status !== updated.status) { - if (updated.status === 'active') { - this.publishNotification(KAFKA_TOPIC.PROJECT_WORK_TRANSITION_ACTIVE, { - projectId, - workstream: response, - userId: this.getNotificationUserId(userId), - initiatorUserId: this.getNotificationUserId(userId), - }); - } - - if (updated.status === 'completed') { - this.publishNotification( - KAFKA_TOPIC.PROJECT_WORK_TRANSITION_COMPLETED, - { - projectId, - workstream: response, - userId: this.getNotificationUserId(userId), - initiatorUserId: this.getNotificationUserId(userId), - }, - ); - } - } - - if (existing.name !== updated.name || existing.type !== updated.type) { - this.publishNotification(KAFKA_TOPIC.PROJECT_WORK_UPDATE_SCOPE, { - projectId, - originalWorkstream: { - id: existing.id.toString(), - name: existing.name, - type: existing.type, - status: existing.status, - }, - updatedWorkstream: response, - userId: this.getNotificationUserId(userId), - initiatorUserId: this.getNotificationUserId(userId), - }); - } + void projectId; + void userId; + void existing; return response; } @@ -282,16 +215,7 @@ export class WorkStreamService { }, }); - this.publishWorkstreamResourceEvent( - KAFKA_TOPIC.PROJECT_WORKSTREAM_REMOVED, - { - id: deleted.id.toString(), - projectId: deleted.projectId.toString(), - name: deleted.name, - type: deleted.type, - status: deleted.status, - }, - ); + void deleted; } async ensureWorkStreamExists( @@ -492,38 +416,4 @@ export class WorkStreamService { return -1; } - - private publishWorkstreamResourceEvent( - topic: string, - payload: unknown, - ): void { - void publishWorkstreamEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish workstream event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private publishNotification(topic: string, payload: unknown): void { - void publishNotificationEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish workstream notification topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); - }); - } - - private getNotificationUserId(userId: string | number | undefined): string { - if (typeof userId === 'number' && Number.isFinite(userId)) { - return String(Math.trunc(userId)); - } - - const normalized = String(userId || '').trim(); - if (/^\d+$/.test(normalized)) { - return normalized; - } - - return '-1'; - } } diff --git a/src/main.ts b/src/main.ts index 1002691..4f924d6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,9 @@ import { EVENT_SWAGGER_EXAMPLES, EVENT_SWAGGER_MODELS, } from './api/metadata/metadata.swagger'; +import { WorkStreamModule } from './api/workstream/workstream.module'; import { AppModule } from './app.module'; +import { enrichSwaggerAuthDocumentation } from './shared/utils/swagger.utils'; import { LoggerService } from './shared/modules/global/logger.service'; function serializeBigInt(value: unknown): unknown { @@ -38,6 +40,7 @@ async function bootstrap() { rawBody: true, logger: ['error', 'warn', 'log', 'debug', 'verbose'], }); + app.set('query parser', 'extended'); const logger = LoggerService.forRoot('Bootstrap'); const apiPrefix = process.env.API_PREFIX || 'v6'; @@ -88,7 +91,9 @@ async function bootstrap() { methods: 'GET, POST, OPTIONS, PUT, DELETE, PATCH', origin: (requestOrigin, callback) => { if (!requestOrigin) { - callback(null, false); + // Keep a permissive fallback for non-browser requests so cached variants + // do not drop CORS headers for subsequent browser calls. + callback(null, '*'); return; } @@ -202,7 +207,8 @@ curl --request POST \\ .build(); const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig, { - include: [ApiModule], + include: [ApiModule, WorkStreamModule], + deepScanRoutes: true, extraModels: [...EVENT_SWAGGER_MODELS], }); @@ -212,7 +218,10 @@ curl --request POST \\ ...EVENT_SWAGGER_EXAMPLES, }; + enrichSwaggerAuthDocumentation(swaggerDocument); + SwaggerModule.setup(`/${apiPrefix}/projects/api-docs`, app, swaggerDocument); + SwaggerModule.setup(`/${apiPrefix}/projects-api-docs`, app, swaggerDocument); process.on('unhandledRejection', (reason, promise) => { logger.error( diff --git a/src/shared/config/kafka.config.ts b/src/shared/config/kafka.config.ts index 8dc7836..2302ecb 100644 --- a/src/shared/config/kafka.config.ts +++ b/src/shared/config/kafka.config.ts @@ -1,252 +1,11 @@ export const KAFKA_TOPIC = { - // Core project resource events - PROJECT_DRAFT_CREATED: - process.env.KAFKA_PROJECT_DRAFT_CREATED_TOPIC || 'project.draft.created', + PROJECT_CREATED: process.env.KAFKA_PROJECT_CREATED_TOPIC || 'project.created', PROJECT_UPDATED: process.env.KAFKA_PROJECT_UPDATED_TOPIC || 'project.updated', PROJECT_DELETED: process.env.KAFKA_PROJECT_DELETED_TOPIC || 'project.deleted', - PROJECT_STATUS_CHANGED: - process.env.KAFKA_PROJECT_STATUS_CHANGED_TOPIC || 'project.status.changed', - - // Project member resource events PROJECT_MEMBER_ADDED: process.env.KAFKA_PROJECT_MEMBER_ADDED_TOPIC || 'project.member.added', - PROJECT_MEMBER_UPDATED: - process.env.KAFKA_PROJECT_MEMBER_UPDATED_TOPIC || 'project.member.updated', PROJECT_MEMBER_REMOVED: process.env.KAFKA_PROJECT_MEMBER_REMOVED_TOPIC || 'project.member.removed', - - // Project invite resource events - PROJECT_MEMBER_INVITE_CREATED: - process.env.KAFKA_PROJECT_MEMBER_INVITE_CREATED_TOPIC || - 'project.member.invite.created', - PROJECT_MEMBER_INVITE_UPDATED: - process.env.KAFKA_PROJECT_MEMBER_INVITE_UPDATED_TOPIC || - 'project.member.invite.updated', - PROJECT_MEMBER_INVITE_REMOVED: - process.env.KAFKA_PROJECT_MEMBER_INVITE_REMOVED_TOPIC || - 'project.member.invite.deleted', - - // Project attachment resource events - PROJECT_ATTACHMENT_ADDED: - process.env.KAFKA_PROJECT_ATTACHMENT_ADDED_TOPIC || - 'project.attachment.added', - PROJECT_ATTACHMENT_UPDATED: - process.env.KAFKA_PROJECT_ATTACHMENT_UPDATED_TOPIC || - 'project.attachment.updated', - PROJECT_ATTACHMENT_REMOVED: - process.env.KAFKA_PROJECT_ATTACHMENT_REMOVED_TOPIC || - 'project.attachment.removed', - - // Project phase resource events - PROJECT_PHASE_ADDED: - process.env.KAFKA_PROJECT_PHASE_ADDED_TOPIC || 'project.phase.added', - PROJECT_PHASE_UPDATED: - process.env.KAFKA_PROJECT_PHASE_UPDATED_TOPIC || 'project.phase.updated', - PROJECT_PHASE_REMOVED: - process.env.KAFKA_PROJECT_PHASE_REMOVED_TOPIC || 'project.phase.removed', - - // Project phase-product resource events - PROJECT_PHASE_PRODUCT_ADDED: - process.env.KAFKA_PROJECT_PHASE_PRODUCT_ADDED_TOPIC || - 'project.phase.product.added', - PROJECT_PHASE_PRODUCT_UPDATED: - process.env.KAFKA_PROJECT_PHASE_PRODUCT_UPDATED_TOPIC || - 'project.phase.product.updated', - PROJECT_PHASE_PRODUCT_REMOVED: - process.env.KAFKA_PROJECT_PHASE_PRODUCT_REMOVED_TOPIC || - 'project.phase.product.removed', - - // Timeline and milestone resource events - TIMELINE_ADDED: process.env.KAFKA_TIMELINE_ADDED_TOPIC || 'timeline.added', - TIMELINE_UPDATED: - process.env.KAFKA_TIMELINE_UPDATED_TOPIC || 'timeline.updated', - TIMELINE_REMOVED: - process.env.KAFKA_TIMELINE_REMOVED_TOPIC || 'timeline.removed', - MILESTONE_ADDED: process.env.KAFKA_MILESTONE_ADDED_TOPIC || 'milestone.added', - MILESTONE_UPDATED: - process.env.KAFKA_MILESTONE_UPDATED_TOPIC || 'milestone.updated', - MILESTONE_REMOVED: - process.env.KAFKA_MILESTONE_REMOVED_TOPIC || 'milestone.removed', - - // Workstream/work/workitem resource events - PROJECT_WORKSTREAM_ADDED: - process.env.KAFKA_PROJECT_WORKSTREAM_ADDED_TOPIC || - 'project.workstream.added', - PROJECT_WORKSTREAM_UPDATED: - process.env.KAFKA_PROJECT_WORKSTREAM_UPDATED_TOPIC || - 'project.workstream.updated', - PROJECT_WORKSTREAM_REMOVED: - process.env.KAFKA_PROJECT_WORKSTREAM_REMOVED_TOPIC || - 'project.workstream.removed', - PROJECT_WORK_ADDED: - process.env.KAFKA_PROJECT_WORK_ADDED_TOPIC || 'project.work.added', - PROJECT_WORK_UPDATED: - process.env.KAFKA_PROJECT_WORK_UPDATED_TOPIC || 'project.work.updated', - PROJECT_WORK_REMOVED: - process.env.KAFKA_PROJECT_WORK_REMOVED_TOPIC || 'project.work.removed', - PROJECT_WORKITEM_ADDED: - process.env.KAFKA_PROJECT_WORKITEM_ADDED_TOPIC || 'project.workitem.added', - PROJECT_WORKITEM_UPDATED: - process.env.KAFKA_PROJECT_WORKITEM_UPDATED_TOPIC || - 'project.workitem.updated', - PROJECT_WORKITEM_REMOVED: - process.env.KAFKA_PROJECT_WORKITEM_REMOVED_TOPIC || - 'project.workitem.removed', - - // Project setting resource events - PROJECT_SETTING_CREATED: - process.env.KAFKA_PROJECT_SETTING_CREATED_TOPIC || - 'project.setting.created', - PROJECT_SETTING_UPDATED: - process.env.KAFKA_PROJECT_SETTING_UPDATED_TOPIC || - 'project.setting.updated', - PROJECT_SETTING_DELETED: - process.env.KAFKA_PROJECT_SETTING_DELETED_TOPIC || - 'project.setting.deleted', - - // Project lifecycle notifications - PROJECT_CREATED: - process.env.KAFKA_PROJECT_CREATED_TOPIC || - 'connect.notification.project.created', - PROJECT_UPDATED_NOTIFICATION: - process.env.KAFKA_PROJECT_UPDATED_NOTIFICATION_TOPIC || - 'connect.notification.project.updated', - PROJECT_SUBMITTED_FOR_REVIEW: - process.env.KAFKA_PROJECT_SUBMITTED_FOR_REVIEW_TOPIC || - 'connect.notification.project.submittedForReview', - PROJECT_APPROVED: - process.env.KAFKA_PROJECT_APPROVED_TOPIC || - 'connect.notification.project.approved', - PROJECT_PAUSED: - process.env.KAFKA_PROJECT_PAUSED_TOPIC || - 'connect.notification.project.paused', - PROJECT_COMPLETED: - process.env.KAFKA_PROJECT_COMPLETED_TOPIC || - 'connect.notification.project.completed', - PROJECT_CANCELED: - process.env.KAFKA_PROJECT_CANCELED_TOPIC || - 'connect.notification.project.canceled', - PROJECT_ACTIVE: - process.env.KAFKA_PROJECT_ACTIVE_TOPIC || - 'connect.notification.project.active', - PROJECT_SPECIFICATION_MODIFIED: - process.env.KAFKA_PROJECT_SPECIFICATION_MODIFIED_TOPIC || - 'connect.notification.project.updated.spec', - PROJECT_LINK_CREATED: - process.env.KAFKA_PROJECT_LINK_CREATED_TOPIC || - 'connect.notification.project.linkCreated', - PROJECT_PLAN_UPDATED: - process.env.KAFKA_PROJECT_PLAN_UPDATED_TOPIC || - 'connect.notification.project.plan.updated', - PROJECT_PLAN_READY: - process.env.KAFKA_PROJECT_PLAN_READY_TOPIC || - 'connect.notification.project.plan.ready', - PROJECT_BILLING_ACCOUNT_UPDATED: - process.env.KAFKA_PROJECT_BILLING_ACCOUNT_UPDATED_TOPIC || - 'connect.notification.project.billingAccount.updated', - - // Project member notifications - MEMBER_JOINED: - process.env.KAFKA_MEMBER_JOINED_TOPIC || - 'connect.notification.project.member.joined', - MEMBER_JOINED_COPILOT: - process.env.KAFKA_MEMBER_JOINED_COPILOT_TOPIC || - 'connect.notification.project.member.copilotJoined', - MEMBER_JOINED_MANAGER: - process.env.KAFKA_MEMBER_JOINED_MANAGER_TOPIC || - 'connect.notification.project.member.managerJoined', - MEMBER_LEFT: - process.env.KAFKA_MEMBER_LEFT_TOPIC || - 'connect.notification.project.member.left', - MEMBER_REMOVED: - process.env.KAFKA_MEMBER_REMOVED_TOPIC || - 'connect.notification.project.member.removed', - MEMBER_ASSIGNED_AS_OWNER: - process.env.KAFKA_MEMBER_ASSIGNED_AS_OWNER_TOPIC || - 'connect.notification.project.member.assignedAsOwner', - PROJECT_TEAM_UPDATED: - process.env.KAFKA_PROJECT_TEAM_UPDATED_TOPIC || - 'connect.notification.project.team.updated', - PROJECT_MEMBER_INVITE_SENT: - process.env.KAFKA_PROJECT_MEMBER_INVITE_SENT_TOPIC || - 'connect.notification.project.member.invite.sent', - PROJECT_MEMBER_INVITE_ACCEPTED: - process.env.KAFKA_PROJECT_MEMBER_INVITE_ACCEPTED_TOPIC || - 'connect.notification.project.member.invite.accepted', - - // Project attachment notifications - PROJECT_FILE_UPLOADED: - process.env.KAFKA_PROJECT_FILE_UPLOADED_TOPIC || - 'connect.notification.project.fileUploaded', - PROJECT_ATTACHMENT_UPDATED_NOTIFICATION: - process.env.KAFKA_PROJECT_ATTACHMENT_UPDATED_NOTIFICATION_TOPIC || - 'connect.notification.project.attachment.updated', - - // Project phase notifications - PROJECT_PHASE_TRANSITION_ACTIVE: - process.env.KAFKA_PROJECT_PHASE_TRANSITION_ACTIVE_TOPIC || - 'connect.notification.project.phase.transition.active', - PROJECT_PHASE_TRANSITION_COMPLETED: - process.env.KAFKA_PROJECT_PHASE_TRANSITION_COMPLETED_TOPIC || - 'connect.notification.project.phase.transition.completed', - PROJECT_PHASE_UPDATE_PAYMENT: - process.env.KAFKA_PROJECT_PHASE_UPDATE_PAYMENT_TOPIC || - 'connect.notification.project.phase.update.payment', - PROJECT_PHASE_UPDATE_PROGRESS: - process.env.KAFKA_PROJECT_PHASE_UPDATE_PROGRESS_TOPIC || - 'connect.notification.project.phase.update.progress', - PROJECT_PHASE_UPDATE_SCOPE: - process.env.KAFKA_PROJECT_PHASE_UPDATE_SCOPE_TOPIC || - 'connect.notification.project.phase.update.scope', - PROJECT_PRODUCT_SPECIFICATION_MODIFIED: - process.env.KAFKA_PROJECT_PRODUCT_SPECIFICATION_MODIFIED_TOPIC || - 'connect.notification.project.product.update.spec', - - // Project work notifications - PROJECT_WORK_TRANSITION_ACTIVE: - process.env.KAFKA_PROJECT_WORK_TRANSITION_ACTIVE_TOPIC || - 'connect.notification.project.work.transition.active', - PROJECT_WORK_TRANSITION_COMPLETED: - process.env.KAFKA_PROJECT_WORK_TRANSITION_COMPLETED_TOPIC || - 'connect.notification.project.work.transition.completed', - PROJECT_WORK_UPDATE_PAYMENT: - process.env.KAFKA_PROJECT_WORK_UPDATE_PAYMENT_TOPIC || - 'connect.notification.project.work.update.payment', - PROJECT_WORK_UPDATE_PROGRESS: - process.env.KAFKA_PROJECT_WORK_UPDATE_PROGRESS_TOPIC || - 'connect.notification.project.work.update.progress', - PROJECT_WORK_UPDATE_SCOPE: - process.env.KAFKA_PROJECT_WORK_UPDATE_SCOPE_TOPIC || - 'connect.notification.project.work.update.scope', - PROJECT_WORKITEM_SPECIFICATION_MODIFIED: - process.env.KAFKA_PROJECT_WORKITEM_SPECIFICATION_MODIFIED_TOPIC || - 'connect.notification.project.workitem.update.spec', - - // Timeline and milestone notifications - TIMELINE_ADJUSTED: - process.env.KAFKA_TIMELINE_ADJUSTED_TOPIC || - 'connect.notification.project.timeline.adjusted', - MILESTONE_NOTIFICATION_ADDED: - process.env.KAFKA_MILESTONE_NOTIFICATION_ADDED_TOPIC || - 'connect.notification.project.timeline.milestone.added', - MILESTONE_NOTIFICATION_UPDATED: - process.env.KAFKA_MILESTONE_NOTIFICATION_UPDATED_TOPIC || - 'connect.notification.project.timeline.milestone.updated', - MILESTONE_NOTIFICATION_REMOVED: - process.env.KAFKA_MILESTONE_NOTIFICATION_REMOVED_TOPIC || - 'connect.notification.project.timeline.milestone.removed', - MILESTONE_TRANSITION_ACTIVE: - process.env.KAFKA_MILESTONE_TRANSITION_ACTIVE_TOPIC || - 'connect.notification.project.timeline.milestone.transition.active', - MILESTONE_TRANSITION_COMPLETED: - process.env.KAFKA_MILESTONE_TRANSITION_COMPLETED_TOPIC || - 'connect.notification.project.timeline.milestone.transition.completed', - MILESTONE_TRANSITION_PAUSED: - process.env.KAFKA_MILESTONE_TRANSITION_PAUSED_TOPIC || - 'connect.notification.project.timeline.milestone.transition.paused', - MILESTONE_WAITING_CUSTOMER: - process.env.KAFKA_MILESTONE_WAITING_CUSTOMER_TOPIC || - 'connect.notification.project.timeline.milestone.waiting.customer', } as const; export type KafkaTopic = (typeof KAFKA_TOPIC)[keyof typeof KAFKA_TOPIC]; diff --git a/src/shared/constants/event.constants.ts b/src/shared/constants/event.constants.ts index dbb7cbd..961ecdc 100644 --- a/src/shared/constants/event.constants.ts +++ b/src/shared/constants/event.constants.ts @@ -1,15 +1,3 @@ -export const PROJECT_METADATA_EVENT_TOPIC = { - PROJECT_METADATA_CREATE: - process.env.KAFKA_PROJECT_METADATA_CREATE_TOPIC || 'project.action.create', - PROJECT_METADATA_UPDATE: - process.env.KAFKA_PROJECT_METADATA_UPDATE_TOPIC || 'project.action.update', - PROJECT_METADATA_DELETE: - process.env.KAFKA_PROJECT_METADATA_DELETE_TOPIC || 'project.action.delete', -} as const; - -export type ProjectMetadataEventTopic = - (typeof PROJECT_METADATA_EVENT_TOPIC)[keyof typeof PROJECT_METADATA_EVENT_TOPIC]; - export const PROJECT_METADATA_RESOURCE = { PROJECT_TEMPLATE: 'project.template', PRODUCT_TEMPLATE: 'product.template', diff --git a/src/shared/constants/permissions.ts b/src/shared/constants/permissions.ts index 6fd0dd1..c13941b 100644 --- a/src/shared/constants/permissions.ts +++ b/src/shared/constants/permissions.ts @@ -26,6 +26,8 @@ export enum Permission { DELETE_PROJECT_INVITE_NOT_OWN_COPILOT = 'DELETE_PROJECT_INVITE_NOT_OWN_COPILOT', MANAGE_PROJECT_BILLING_ACCOUNT_ID = 'MANAGE_PROJECT_BILLING_ACCOUNT_ID', MANAGE_PROJECT_DIRECT_PROJECT_ID = 'MANAGE_PROJECT_DIRECT_PROJECT_ID', + READ_AVL_PROJECT_BILLING_ACCOUNTS = 'READ_AVL_PROJECT_BILLING_ACCOUNTS', + READ_PROJECT_BILLING_ACCOUNT_DETAILS = 'READ_PROJECT_BILLING_ACCOUNT_DETAILS', MANAGE_COPILOT_REQUEST = 'MANAGE_COPILOT_REQUEST', APPLY_COPILOT_OPPORTUNITY = 'APPLY_COPILOT_OPPORTUNITY', ASSIGN_COPILOT_OPPORTUNITY = 'ASSIGN_COPILOT_OPPORTUNITY', diff --git a/src/shared/decorators/requirePermission.decorator.ts b/src/shared/decorators/requirePermission.decorator.ts index 4456651..f35ab21 100644 --- a/src/shared/decorators/requirePermission.decorator.ts +++ b/src/shared/decorators/requirePermission.decorator.ts @@ -1,11 +1,19 @@ -import { SetMetadata } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiExtension } from '@nestjs/swagger'; import { Permission as NamedPermission } from '../constants/permissions'; import { Permission } from '../interfaces/permission.interface'; export const PERMISSION_KEY = 'required_permissions'; +export const SWAGGER_REQUIRED_PERMISSIONS_KEY = 'x-required-permissions'; export type RequiredPermission = Permission | NamedPermission; export const RequirePermission = ( ...permissions: (RequiredPermission | RequiredPermission[])[] -) => SetMetadata(PERMISSION_KEY, permissions.flat()); +) => { + const flattenedPermissions = permissions.flat(); + return applyDecorators( + SetMetadata(PERMISSION_KEY, flattenedPermissions), + ApiExtension(SWAGGER_REQUIRED_PERMISSIONS_KEY, flattenedPermissions), + ); +}; diff --git a/src/shared/decorators/scopes.decorator.ts b/src/shared/decorators/scopes.decorator.ts index c8b352b..3186107 100644 --- a/src/shared/decorators/scopes.decorator.ts +++ b/src/shared/decorators/scopes.decorator.ts @@ -1,5 +1,11 @@ -import { SetMetadata } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiExtension } from '@nestjs/swagger'; export const SCOPES_KEY = 'scopes'; +export const SWAGGER_REQUIRED_SCOPES_KEY = 'x-required-scopes'; -export const Scopes = (...scopes: string[]) => SetMetadata(SCOPES_KEY, scopes); +export const Scopes = (...scopes: string[]) => + applyDecorators( + SetMetadata(SCOPES_KEY, scopes), + ApiExtension(SWAGGER_REQUIRED_SCOPES_KEY, scopes), + ); diff --git a/src/shared/guards/adminOnly.guard.ts b/src/shared/guards/adminOnly.guard.ts index a948e3e..d41bb2f 100644 --- a/src/shared/guards/adminOnly.guard.ts +++ b/src/shared/guards/adminOnly.guard.ts @@ -7,6 +7,7 @@ import { 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 { AuthenticatedRequest } from '../interfaces/request.interface'; @@ -14,6 +15,10 @@ import { M2MService } from '../modules/global/m2m.service'; import { PermissionService } from '../services/permission.service'; import { Roles } from './tokenRoles.guard'; +export const SWAGGER_ADMIN_ONLY_KEY = 'x-admin-only'; +export const SWAGGER_ADMIN_ALLOWED_ROLES_KEY = 'x-admin-only-roles'; +export const SWAGGER_ADMIN_ALLOWED_SCOPES_KEY = 'x-admin-only-scopes'; + @Injectable() export class AdminOnlyGuard implements CanActivate { constructor( @@ -50,6 +55,14 @@ export class AdminOnlyGuard implements CanActivate { } } -export const AdminOnly = () => applyDecorators(UseGuards(AdminOnlyGuard)); +export const AdminOnly = () => + applyDecorators( + UseGuards(AdminOnlyGuard), + ApiExtension(SWAGGER_ADMIN_ONLY_KEY, true), + ApiExtension(SWAGGER_ADMIN_ALLOWED_ROLES_KEY, ADMIN_ROLES), + ApiExtension(SWAGGER_ADMIN_ALLOWED_SCOPES_KEY, [ + Scope.CONNECT_PROJECT_ADMIN, + ]), + ); export const ManagerOnly = () => applyDecorators(Roles(...MANAGER_ROLES)); diff --git a/src/shared/guards/tokenRoles.guard.ts b/src/shared/guards/tokenRoles.guard.ts index 0bf8879..90a8920 100644 --- a/src/shared/guards/tokenRoles.guard.ts +++ b/src/shared/guards/tokenRoles.guard.ts @@ -1,4 +1,5 @@ import { + applyDecorators, CanActivate, ExecutionContext, ForbiddenException, @@ -7,6 +8,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { ApiExtension } from '@nestjs/swagger'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { SCOPES_KEY } from '../decorators/scopes.decorator'; import { AuthenticatedRequest } from '../interfaces/request.interface'; @@ -14,7 +16,12 @@ import { JwtService } from '../modules/global/jwt.service'; import { M2MService } from '../modules/global/m2m.service'; export const ROLES_KEY = 'roles'; -export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); +export const SWAGGER_REQUIRED_ROLES_KEY = 'x-required-roles'; +export const Roles = (...roles: string[]) => + applyDecorators( + SetMetadata(ROLES_KEY, roles), + ApiExtension(SWAGGER_REQUIRED_ROLES_KEY, roles), + ); @Injectable() export class TokenRolesGuard implements CanActivate { diff --git a/src/shared/modules/global/eventBus.service.ts b/src/shared/modules/global/eventBus.service.ts index 6d4205d..a279ef0 100644 --- a/src/shared/modules/global/eventBus.service.ts +++ b/src/shared/modules/global/eventBus.service.ts @@ -52,20 +52,6 @@ export class EventBusService { } } - async publishProjectCreated(payload: unknown): Promise { - await this.publishProjectEvent( - process.env.KAFKA_PROJECT_CREATED_TOPIC || 'project.action.create', - payload, - ); - } - - async publishProjectUpdated(payload: unknown): Promise { - await this.publishProjectEvent( - process.env.KAFKA_PROJECT_UPDATED_TOPIC || 'project.action.update', - payload, - ); - } - private createClient(): EventBusClient | null { const busApiFactory = busApi as unknown as ( config: Record, diff --git a/src/shared/modules/global/jwt.service.spec.ts b/src/shared/modules/global/jwt.service.spec.ts new file mode 100644 index 0000000..89ef867 --- /dev/null +++ b/src/shared/modules/global/jwt.service.spec.ts @@ -0,0 +1,46 @@ +import * as jwt from 'jsonwebtoken'; +import { JwtService } from './jwt.service'; + +function signToken(payload: Record): string { + return jwt.sign(payload, 'test-secret'); +} + +describe('JwtService', () => { + let service: JwtService; + + beforeEach(() => { + service = new JwtService(); + }); + + it('prefers numeric userId claim over non-numeric sub', async () => { + const token = signToken({ + userId: 12345, + sub: 'auth0|abcd', + }); + + const user = await service.validateToken(token); + + expect(user.userId).toBe('12345'); + }); + + it('uses namespaced userId claim when sub is non-numeric', async () => { + const token = signToken({ + sub: 'auth0|abcd', + 'https://topcoder.com/userId': 67890, + }); + + const user = await service.validateToken(token); + + expect(user.userId).toBe('67890'); + }); + + it('falls back to sub when user id claim is unavailable', async () => { + const token = signToken({ + sub: 'auth0|abcd', + }); + + const user = await service.validateToken(token); + + expect(user.userId).toBe('auth0|abcd'); + }); +}); diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index ce26e90..fa3e7de 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -227,7 +227,7 @@ export class JwtService implements OnModuleInit { user.isMachine = true; } - const userId = this.extractString(payload, ['userId', 'sub']); + const userId = this.extractUserId(payload); if (userId) { user.userId = userId; } @@ -245,9 +245,14 @@ export class JwtService implements OnModuleInit { for (const key of Object.keys(payload)) { const lowerKey = key.toLowerCase(); - if (!user.userId && lowerKey.endsWith('userid')) { - const value = payload[key]; - if (typeof value === 'string' && value.trim().length > 0) { + if (lowerKey.endsWith('userid')) { + const value = this.extractIdentifier(payload[key]); + if ( + value && + (!user.userId || + (!this.isNumericIdentifier(user.userId) && + this.isNumericIdentifier(value))) + ) { user.userId = value; } } @@ -320,6 +325,35 @@ export class JwtService implements OnModuleInit { return undefined; } + private extractUserId(payload: JwtPayloadRecord): string | undefined { + const userId = this.extractIdentifier(payload.userId); + if (userId) { + return userId; + } + + return this.extractIdentifier(payload.sub); + } + + private extractIdentifier(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + + if (typeof value === 'number' && Number.isSafeInteger(value)) { + return String(value); + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + return undefined; + } + + private isNumericIdentifier(value: string): boolean { + return /^\d+$/.test(value.trim()); + } + private getValidIssuers(): string[] { const validIssuers = process.env.VALID_ISSUERS; diff --git a/src/shared/modules/global/prisma.service.ts b/src/shared/modules/global/prisma.service.ts index 7a4667b..e7deec7 100644 --- a/src/shared/modules/global/prisma.service.ts +++ b/src/shared/modules/global/prisma.service.ts @@ -1,4 +1,5 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaPg } from '@prisma/adapter-pg'; import { Prisma, PrismaClient } from '@prisma/client'; import { LoggerService } from './logger.service'; import { PrismaErrorService } from './prisma-error.service'; @@ -35,6 +36,20 @@ function getDatasourceUrl(): string | undefined { } } +function getSchemaFromDatasourceUrl( + datasourceUrl: string | undefined, +): string | undefined { + if (!datasourceUrl) { + return undefined; + } + + try { + return new URL(datasourceUrl).searchParams.get('schema') ?? undefined; + } catch { + return undefined; + } +} + @Injectable() export class PrismaService extends PrismaClient @@ -43,7 +58,15 @@ export class PrismaService private readonly logger = LoggerService.forRoot('PrismaService'); constructor(private readonly prismaErrorService: PrismaErrorService) { + const datasourceUrl = getDatasourceUrl(); + const schema = getSchemaFromDatasourceUrl(datasourceUrl); + const adapter = new PrismaPg( + { connectionString: datasourceUrl }, + schema ? { schema } : undefined, + ); + super({ + adapter, transactionOptions: { timeout: getTransactionTimeout(), }, @@ -53,11 +76,6 @@ export class PrismaService { level: 'warn', emit: 'event' }, { level: 'error', emit: 'event' }, ], - datasources: { - db: { - url: getDatasourceUrl(), - }, - }, }); this.$on('query' as never, (event: Prisma.QueryEvent) => { diff --git a/src/shared/services/billingAccount.service.ts b/src/shared/services/billingAccount.service.ts index 98d9d3f..aaf6738 100644 --- a/src/shared/services/billingAccount.service.ts +++ b/src/shared/services/billingAccount.service.ts @@ -1,7 +1,8 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; +import { createPrivateKey, KeyObject } from 'crypto'; import { firstValueFrom } from 'rxjs'; -import { M2MService } from 'src/shared/modules/global/m2m.service'; +import * as jwt from 'jsonwebtoken'; import { LoggerService } from 'src/shared/modules/global/logger.service'; export interface BillingAccount { @@ -17,83 +18,404 @@ export interface BillingAccount { @Injectable() export class BillingAccountService { private readonly logger = LoggerService.forRoot('BillingAccountService'); - private readonly billingAccountBaseUrl = - process.env.BILLING_ACCOUNT_SERVICE_URL || - process.env.SALESFORCE_API_URL || + private readonly salesforceAudience = + process.env.SALESFORCE_CLIENT_AUDIENCE || + process.env.SALESFORCE_AUDIENCE || ''; + private readonly salesforceLoginBaseUrl = + process.env.SALESFORCE_LOGIN_BASE_URL || + this.salesforceAudience || + 'https://login.salesforce.com'; + private readonly salesforceApiVersion = + process.env.SALESFORCE_API_VERSION || 'v37.0'; + private readonly sfdcBillingAccountNameField = + process.env.SFDC_BILLING_ACCOUNT_NAME_FIELD || 'Billing_Account_name__c'; + private readonly sfdcBillingAccountMarkupField = + process.env.SFDC_BILLING_ACCOUNT_MARKUP_FIELD || 'Mark_Up__c'; + private readonly sfdcBillingAccountActiveField = + process.env.SFDC_BILLING_ACCOUNT_ACTIVE_FIELD || 'Active__c'; - constructor( - private readonly httpService: HttpService, - private readonly m2mService: M2MService, - ) {} + constructor(private readonly httpService: HttpService) {} async getBillingAccountsForProject( projectId: string, + userId: string, ): Promise { - if (!this.billingAccountBaseUrl) { - this.logger.warn('Billing account base URL is not configured.'); + if (!this.isSalesforceConfigured()) { + this.logger.warn('Salesforce integration is not configured.'); return []; } try { - const token = await this.m2mService.getM2MToken(); - - const response = await firstValueFrom( - this.httpService.get( - `${this.billingAccountBaseUrl}/projects/${projectId}/billingAccounts`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ), - ); + 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}'`; + this.logger.debug(sql); - if (!Array.isArray(response.data)) { - return []; - } + const records = await this.queryBillingAccountRecords( + sql, + accessToken, + instanceUrl, + ); - return response.data as BillingAccount[]; + return records.map((record) => ({ + sfBillingAccountId: this.readAsString( + record?.Topcoder_Billing_Account__r?.Id, + ), + tcBillingAccountId: this.parseIntStrictly( + this.readAsString( + record?.Topcoder_Billing_Account__r?.TopCoder_Billing_Account_Id__c, + ), + ), + name: this.readAsString( + record?.Topcoder_Billing_Account__r?.[ + this.sfdcBillingAccountNameField + ], + ), + startDate: this.readAsString( + record?.Topcoder_Billing_Account__r?.Start_Date__c, + ), + endDate: this.readAsString( + record?.Topcoder_Billing_Account__r?.End_Date__c, + ), + })); } catch (error) { this.logger.warn( - `Unable to fetch billing accounts for projectId=${projectId}: ${error instanceof Error ? error.message : String(error)}`, + `Unable to fetch billing accounts for projectId=${projectId}: ${ + error instanceof Error ? error.message : String(error) + }`, ); return []; } } async getDefaultBillingAccount( - projectId: string, + billingAccountId: string, ): Promise { - if (!this.billingAccountBaseUrl) { - this.logger.warn('Billing account base URL is not configured.'); + if (!this.isSalesforceConfigured()) { + this.logger.warn('Salesforce integration is not configured.'); return null; } try { - const token = await this.m2mService.getM2MToken(); - - const response = await firstValueFrom( - this.httpService.get( - `${this.billingAccountBaseUrl}/projects/${projectId}/billingAccount`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ), + 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}'`; + this.logger.debug(sql); + + const records = await this.queryBillingAccountRecords( + sql, + accessToken, + instanceUrl, ); - if (!response.data || typeof response.data !== 'object') { + const firstRecord = records[0]; + if (!firstRecord || typeof firstRecord !== 'object') { return null; } - return response.data as BillingAccount; + return { + tcBillingAccountId: this.parseIntStrictly( + this.readAsString(firstRecord.TopCoder_Billing_Account_Id__c), + ), + markup: this.readAsNumber( + firstRecord[this.sfdcBillingAccountMarkupField], + ), + active: this.readAsBoolean( + firstRecord[this.sfdcBillingAccountActiveField], + ), + startDate: this.readAsString(firstRecord.Start_Date__c), + endDate: this.readAsString(firstRecord.End_Date__c), + }; } catch (error) { this.logger.warn( - `Unable to fetch default billing account for projectId=${projectId}: ${error instanceof Error ? error.message : String(error)}`, + `Unable to fetch default billing account for billingAccountId=${billingAccountId}: ${ + error instanceof Error ? error.message : String(error) + }`, ); return null; } } + + async getBillingAccountsByIds( + billingAccountIds: string[], + ): Promise> { + const normalizedBillingAccountIds = Array.from( + new Set( + billingAccountIds + .map((billingAccountId) => this.parseIntStrictly(billingAccountId)) + .filter((billingAccountId): billingAccountId is string => + Boolean(billingAccountId), + ), + ), + ); + + if (normalizedBillingAccountIds.length === 0) { + return {}; + } + + if (!this.isSalesforceConfigured()) { + this.logger.warn('Salesforce integration is not configured.'); + return {}; + } + + try { + const { accessToken, instanceUrl } = await this.authenticate(); + const inClause = normalizedBillingAccountIds + .map( + (billingAccountId) => `'${this.escapeSoqlLiteral(billingAccountId)}'`, + ) + .join(','); + 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); + + const records = await this.queryBillingAccountRecords( + sql, + accessToken, + instanceUrl, + ); + + return records.reduce>((acc, record) => { + const normalizedBillingAccountId = this.parseIntStrictly( + this.readAsString(record?.TopCoder_Billing_Account_Id__c), + ); + + if (!normalizedBillingAccountId) { + return acc; + } + + acc[normalizedBillingAccountId] = { + tcBillingAccountId: normalizedBillingAccountId, + name: this.readAsString(record?.[this.sfdcBillingAccountNameField]), + startDate: this.readAsString(record?.Start_Date__c), + endDate: this.readAsString(record?.End_Date__c), + active: this.readAsBoolean( + record?.[this.sfdcBillingAccountActiveField], + ), + }; + + return acc; + }, {}); + } catch (error) { + this.logger.warn( + `Unable to fetch billing accounts by ids: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return {}; + } + } + + private isSalesforceConfigured(): boolean { + return Boolean( + process.env.SALESFORCE_CLIENT_ID && + this.salesforceAudience && + process.env.SALESFORCE_SUBJECT && + process.env.SALESFORCE_CLIENT_KEY, + ); + } + + private async authenticate(): Promise<{ + accessToken: string; + instanceUrl: string; + }> { + const clientId = process.env.SALESFORCE_CLIENT_ID || ''; + const audience = this.salesforceAudience; + const subject = process.env.SALESFORCE_SUBJECT || ''; + const privateKey = this.normalizePrivateKey( + process.env.SALESFORCE_CLIENT_KEY || '', + ); + const privateKeyObject = this.toPrivateKeyObject(privateKey); + + const assertion = jwt.sign({}, privateKeyObject, { + expiresIn: '1h', + issuer: clientId, + audience, + subject, + algorithm: 'RS256', + }); + + const response = await firstValueFrom( + this.httpService.post( + `${this.salesforceLoginBaseUrl}/services/oauth2/token`, + new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion, + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ), + ); + + const accessToken = this.readAsString(response?.data?.access_token); + const instanceUrl = this.readAsString(response?.data?.instance_url); + + if (!accessToken || !instanceUrl) { + throw new Error('Salesforce authentication response is invalid.'); + } + + return { + accessToken, + instanceUrl, + }; + } + + private async queryBillingAccountRecords( + sql: string, + accessToken: string, + instanceUrl: string, + ): Promise[]> { + const response = await firstValueFrom( + this.httpService.get( + `${instanceUrl}/services/data/${this.salesforceApiVersion}/query`, + { + params: { + q: sql, + }, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ), + ); + + const records = response?.data?.records; + if (!Array.isArray(records)) { + return []; + } + + return records as Record[]; + } + + private parseIntStrictly(rawValue: string | undefined): string | undefined { + if (!rawValue) { + return undefined; + } + + const parsed = Number.parseInt(rawValue, 10); + if (Number.isNaN(parsed)) { + return undefined; + } + + if (String(parsed) !== rawValue) { + return undefined; + } + + return String(parsed); + } + + private readAsString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + private readAsNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + + return undefined; + } + + private readAsBoolean(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; + } + + private escapeSoqlLiteral(value: string): string { + return String(value).replace(/'/g, "\\'"); + } + + private normalizePrivateKey(rawKey: string): string { + let normalized = String(rawKey || '').trim(); + + // Some secret stores keep the whole PEM wrapped in quotes. + if ( + (normalized.startsWith('"') && normalized.endsWith('"')) || + (normalized.startsWith("'") && normalized.endsWith("'")) + ) { + normalized = normalized.slice(1, -1); + } + + // Support escaped newlines, including double-escaped forms. + while (normalized.includes('\\n')) { + normalized = normalized.replace(/\\n/g, '\n'); + } + + // Support one-line PEM values where body chunks are separated by spaces. + // This is used for local dev reading from AWS parameter store via the SSM python script + // we use for loading configs from parameter store into the local env. + // Example: + // -----BEGIN PRIVATE KEY----- ABC... XYZ... -----END PRIVATE KEY----- + const pemMatch = normalized.match( + /-----BEGIN ([A-Z0-9 ]+)-----\s*([A-Za-z0-9+/=\s]+)\s*-----END \1-----/s, + ); + if (pemMatch) { + const [, keyType, keyBody] = pemMatch; + const compactBody = keyBody.replace(/\s+/g, ''); + if (compactBody.length > 0) { + const wrappedBody = compactBody.match(/.{1,64}/g)?.join('\n') || ''; + normalized = [ + `-----BEGIN ${keyType}-----`, + wrappedBody, + `-----END ${keyType}-----`, + ].join('\n'); + } + } + + if (!normalized.includes('-----BEGIN')) { + try { + const decoded = Buffer.from(normalized, 'base64').toString('utf8'); + if (decoded.includes('-----BEGIN')) { + normalized = decoded; + } + } catch { + // keep original value; validation below will produce clear error + } + } + + return normalized.trim(); + } + + private toPrivateKeyObject(privateKey: string): KeyObject { + try { + return createPrivateKey({ + key: privateKey, + format: 'pem', + }); + } catch (error) { + throw new Error( + `Invalid SALESFORCE_CLIENT_KEY format: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } } diff --git a/src/shared/services/email.service.ts b/src/shared/services/email.service.ts index 80390c3..27cb5c9 100644 --- a/src/shared/services/email.service.ts +++ b/src/shared/services/email.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { getBusApiClient } from 'src/shared/utils/event.utils'; +import { EventBusService } from 'src/shared/modules/global/eventBus.service'; import { LoggerService } from 'src/shared/modules/global/logger.service'; export interface InviteEmailPayload { @@ -18,17 +18,25 @@ export interface InviteEmailInitiator { email?: string; } +const EXTERNAL_ACTION_EMAIL_TOPIC = 'external.action.email'; +const DEFAULT_INVITE_EMAIL_SUBJECT = 'You are invited to Topcoder'; +const DEFAULT_INVITE_EMAIL_SECTION_TITLE = 'Project Invitation'; + @Injectable() export class EmailService { private readonly logger = LoggerService.forRoot('EmailService'); + constructor(private readonly eventBusService: EventBusService) {} + async sendInviteEmail( projectId: string, invite: InviteEmailPayload, initiator: InviteEmailInitiator, projectName?: string, ): Promise { - if (!invite.email) { + const recipient = invite.email?.trim().toLowerCase(); + + if (!recipient) { return; } @@ -40,50 +48,59 @@ export class EmailService { return; } - const workManagerUrl = process.env.WORK_MANAGER_URL || ''; - const accountsAppUrl = process.env.ACCOUNTS_APP_URL || ''; - const invitePath = `${workManagerUrl.replace(/\/$/, '')}/projects/${projectId}`; - - try { - const client = await getBusApiClient(); - - await client.postEvent({ - topic: 'external.action.email', - originator: 'project-service-v6', - timestamp: new Date().toISOString(), - 'mime-type': 'application/json', - payload: { - data: { - workManagerUrl, - accountsAppURL: accountsAppUrl, - subject: process.env.INVITE_EMAIL_SUBJECT, - projects: [ + const normalizedProjectName = projectName?.trim() || `Project ${projectId}`; + 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: [ { - name: projectName || `Project ${projectId}`, + EMAIL_INVITES: true, + title: + process.env.INVITE_EMAIL_SECTION_TITLE || + DEFAULT_INVITE_EMAIL_SECTION_TITLE, + projectName: normalizedProjectName, projectId, - sections: [ - { - EMAIL_INVITES: true, - title: process.env.INVITE_EMAIL_SECTION_TITLE, - projectName: projectName || `Project ${projectId}`, - projectId, - inviteLink: invitePath, - role: invite.role, - initiator, - }, - ], + initiator: this.normalizeInitiator(initiator), + isSSO: false, }, ], }, - sendgrid_template_id: templateId, - recipients: [invite.email], - version: 'v3', - }, - }); + ], + }, + sendgrid_template_id: templateId, + recipients: [recipient], + version: 'v3', + }; + + try { + await this.eventBusService.publishProjectEvent( + EXTERNAL_ACTION_EMAIL_TOPIC, + payload, + ); } catch (error) { - this.logger.warn( - `Failed to send invite email for projectId=${projectId}: ${error instanceof Error ? error.message : String(error)}`, + this.logger.error( + `Failed to publish invite email event to ${EXTERNAL_ACTION_EMAIL_TOPIC} for projectId=${projectId} recipient=${recipient}: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, ); } } + + private normalizeInitiator( + initiator: InviteEmailInitiator, + ): InviteEmailInitiator { + return { + userId: initiator.userId, + handle: initiator.handle, + firstName: initiator.firstName || 'Connect', + lastName: initiator.lastName || 'User', + email: initiator.email, + }; + } } diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index 41c5de7..bcdf3d7 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -1,6 +1,7 @@ import { ProjectMemberRole } from '../enums/projectMemberRole.enum'; import { Scope } from '../enums/scopes.enum'; import { UserRole } from '../enums/userRole.enum'; +import { Permission } from '../constants/permissions'; import { PermissionService } from './permission.service'; describe('PermissionService', () => { @@ -171,4 +172,48 @@ describe('PermissionService', () => { expect(role).toBe(ProjectMemberRole.MANAGER); }); + + it('allows available billing accounts permission for copilot project member', () => { + const allowed = service.hasNamedPermission( + Permission.READ_AVL_PROJECT_BILLING_ACCOUNTS, + { + userId: '123', + roles: [UserRole.TOPCODER_USER], + isMachine: false, + }, + [ + { + userId: '123', + role: ProjectMemberRole.COPILOT, + }, + ], + ); + + expect(allowed).toBe(true); + }); + + it('allows billing account details permission for matching m2m scope', () => { + const allowed = service.hasNamedPermission( + Permission.READ_PROJECT_BILLING_ACCOUNT_DETAILS, + { + scopes: [Scope.PROJECTS_READ_PROJECT_BILLING_ACCOUNT_DETAILS], + isMachine: true, + }, + ); + + expect(allowed).toBe(true); + }); + + it('marks billing account permissions as requiring project member context', () => { + expect( + service.isNamedPermissionRequireProjectMembers( + Permission.READ_AVL_PROJECT_BILLING_ACCOUNTS, + ), + ).toBe(true); + expect( + service.isNamedPermissionRequireProjectMembers( + Permission.READ_PROJECT_BILLING_ACCOUNT_DETAILS, + ), + ).toBe(true); + }); }); diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index ebeb214..072b7d0 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -233,6 +233,27 @@ export class PermissionService { case NamedPermission.MANAGE_PROJECT_DIRECT_PROJECT_ID: return isAdmin; + case NamedPermission.READ_AVL_PROJECT_BILLING_ACCOUNTS: + return ( + isManagementMember || + this.isCopilot(member?.role) || + this.hasProjectBillingTopcoderRole(user) || + this.m2mService.hasRequiredScopes(user.scopes || [], [ + Scope.CONNECT_PROJECT_ADMIN, + Scope.PROJECTS_READ_USER_BILLING_ACCOUNTS, + ]) + ); + + case NamedPermission.READ_PROJECT_BILLING_ACCOUNT_DETAILS: + return ( + isManagementMember || + this.isCopilot(member?.role) || + this.hasProjectBillingTopcoderRole(user) || + this.m2mService.hasRequiredScopes(user.scopes || [], [ + Scope.PROJECTS_READ_PROJECT_BILLING_ACCOUNT_DETAILS, + ]) + ); + case NamedPermission.MANAGE_COPILOT_REQUEST: case NamedPermission.ASSIGN_COPILOT_OPPORTUNITY: case NamedPermission.CANCEL_COPILOT_OPPORTUNITY: @@ -337,6 +358,8 @@ export class PermissionService { NamedPermission.DELETE_PROJECT_INVITE_NOT_OWN_COPILOT, NamedPermission.MANAGE_PROJECT_BILLING_ACCOUNT_ID, NamedPermission.MANAGE_PROJECT_DIRECT_PROJECT_ID, + NamedPermission.READ_AVL_PROJECT_BILLING_ACCOUNTS, + NamedPermission.READ_PROJECT_BILLING_ACCOUNT_DETAILS, NamedPermission.VIEW_PROJECT_ATTACHMENT, NamedPermission.CREATE_PROJECT_ATTACHMENT, NamedPermission.EDIT_PROJECT_ATTACHMENT, @@ -452,4 +475,15 @@ export class PermissionService { private hasCopilotManagerRole(user: JwtUser): boolean { return this.hasIntersection(user.roles || [], [UserRole.COPILOT_MANAGER]); } + + private hasProjectBillingTopcoderRole(user: JwtUser): boolean { + return this.hasIntersection(user.roles || [], [ + ...ADMIN_ROLES, + UserRole.PROJECT_MANAGER, + UserRole.TASK_MANAGER, + UserRole.TOPCODER_TASK_MANAGER, + UserRole.TALENT_MANAGER, + UserRole.TOPCODER_TALENT_MANAGER, + ]); + } } diff --git a/src/shared/utils/project.utils.ts b/src/shared/utils/project.utils.ts index 29bd6f7..be09c7f 100644 --- a/src/shared/utils/project.utils.ts +++ b/src/shared/utils/project.utils.ts @@ -8,7 +8,6 @@ export interface ParsedProjectFields { project_members: boolean; project_member_invites: boolean; attachments: boolean; - project_phases: boolean; raw: string[]; } @@ -17,7 +16,6 @@ const DEFAULT_FIELDS: ParsedProjectFields = { project_members: true, project_member_invites: true, attachments: true, - project_phases: false, raw: [], }; @@ -144,8 +142,6 @@ export function parseFieldsParameter(fields?: string): ParsedProjectFields { tokens.has('invites') || tokens.has('all'), attachments: tokens.has('attachments') || tokens.has('all'), - project_phases: - tokens.has('project_phases') || tokens.has('phases') || tokens.has('all'), raw: parsed, }; } @@ -185,6 +181,19 @@ export function buildProjectWhereClause( } } + const billingAccountIdFilter = parseFilterValue(criteria.billingAccountId); + if (billingAccountIdFilter.length > 0) { + const billingAccountIds = toBigIntList(billingAccountIdFilter); + + if (billingAccountIds.length === 1) { + where.billingAccountId = billingAccountIds[0]; + } else if (billingAccountIds.length > 1) { + where.billingAccountId = { + in: billingAccountIds, + }; + } + } + const typeFilter = parseFilterValue(criteria.type); if (typeFilter.length > 0) { if (typeFilter.length === 1) { @@ -358,24 +367,6 @@ export function buildProjectIncludeClause( }; } - if (fields.project_phases) { - include.phases = { - where: { - deletedAt: null, - }, - include: { - products: { - where: { - deletedAt: null, - }, - }, - }, - orderBy: { - order: 'asc', - }, - }; - } - return include; } diff --git a/src/shared/utils/swagger.utils.ts b/src/shared/utils/swagger.utils.ts new file mode 100644 index 0000000..d57b9c4 --- /dev/null +++ b/src/shared/utils/swagger.utils.ts @@ -0,0 +1,167 @@ +import { OpenAPIObject } from '@nestjs/swagger'; +import { + SWAGGER_REQUIRED_PERMISSIONS_KEY, + RequiredPermission, +} from '../decorators/requirePermission.decorator'; +import { SWAGGER_REQUIRED_SCOPES_KEY } from '../decorators/scopes.decorator'; +import { + SWAGGER_ADMIN_ALLOWED_ROLES_KEY, + SWAGGER_ADMIN_ALLOWED_SCOPES_KEY, + SWAGGER_ADMIN_ONLY_KEY, +} from '../guards/adminOnly.guard'; +import { SWAGGER_REQUIRED_ROLES_KEY } from '../guards/tokenRoles.guard'; + +type SwaggerOperation = { + description?: string; + responses?: Record; + [key: string]: unknown; +}; + +const HTTP_METHODS = [ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +] as const; + +function parseStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => String(entry).trim()) + .filter((entry) => entry.length > 0); +} + +function parsePermissionArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => stringifyPermission(entry as RequiredPermission)) + .filter((entry) => entry.length > 0); +} + +function stringifyPermission(permission: RequiredPermission): string { + if (typeof permission === 'string') { + return permission; + } + + return JSON.stringify(permission); +} + +function addAuthSection( + description: string | undefined, + authorizationLines: string[], +): string { + if (authorizationLines.length === 0) { + return description || ''; + } + + const authSection = [ + 'Authorization:', + ...authorizationLines.map((line) => `- ${line}`), + ].join('\n'); + + if (!description || description.trim().length === 0) { + return authSection; + } + + if (description.includes('Authorization:')) { + return description; + } + + return `${description}\n\n${authSection}`; +} + +function ensureErrorResponses(operation: SwaggerOperation): void { + operation.responses = operation.responses || {}; + + if (!operation.responses['401']) { + operation.responses['401'] = { description: 'Unauthorized' }; + } + + if (!operation.responses['403']) { + operation.responses['403'] = { description: 'Forbidden' }; + } +} + +function getAuthorizationLines(operation: SwaggerOperation): string[] { + const roles = parseStringArray(operation[SWAGGER_REQUIRED_ROLES_KEY]); + const scopes = parseStringArray(operation[SWAGGER_REQUIRED_SCOPES_KEY]); + const permissions = parsePermissionArray( + operation[SWAGGER_REQUIRED_PERMISSIONS_KEY], + ); + const isAdminOnly = Boolean(operation[SWAGGER_ADMIN_ONLY_KEY]); + const adminRoles = parseStringArray( + operation[SWAGGER_ADMIN_ALLOWED_ROLES_KEY], + ); + const adminScopes = parseStringArray( + operation[SWAGGER_ADMIN_ALLOWED_SCOPES_KEY], + ); + + const authorizationLines: string[] = []; + + if (roles.length > 0) { + authorizationLines.push(`Allowed user roles (any): ${roles.join(', ')}`); + } + + if (scopes.length > 0) { + authorizationLines.push(`Allowed token scopes (any): ${scopes.join(', ')}`); + } + + if (permissions.length > 0) { + authorizationLines.push( + `Required policy permissions (any): ${permissions.join(', ')}`, + ); + } + + if (isAdminOnly) { + const details = [ + adminRoles.length > 0 ? `roles (${adminRoles.join(', ')})` : undefined, + adminScopes.length > 0 ? `scopes (${adminScopes.join(', ')})` : undefined, + ] + .filter((entry): entry is string => Boolean(entry)) + .join(' or '); + + if (details.length > 0) { + authorizationLines.push(`Admin-only endpoint: requires ${details}.`); + } else { + authorizationLines.push('Admin-only endpoint.'); + } + } + + return authorizationLines; +} + +export function enrichSwaggerAuthDocumentation( + document: OpenAPIObject, +): OpenAPIObject { + for (const path of Object.values(document.paths || {})) { + for (const method of HTTP_METHODS) { + const operation = path[method] as SwaggerOperation | undefined; + if (!operation) { + continue; + } + + const authorizationLines = getAuthorizationLines(operation); + if (authorizationLines.length === 0) { + continue; + } + + operation.description = addAuthSection( + operation.description, + authorizationLines, + ); + ensureErrorResponses(operation); + } + } + + return document; +} diff --git a/test/database-compatibility.e2e-spec.ts b/test/database-compatibility.e2e-spec.ts index e602da6..45ad0b3 100644 --- a/test/database-compatibility.e2e-spec.ts +++ b/test/database-compatibility.e2e-spec.ts @@ -1,6 +1,7 @@ import { execSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import * as path from 'node:path'; +import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; const DB_COMPAT_ENABLED = process.env.DB_COMPAT_TESTS_ENABLED === 'true'; @@ -10,8 +11,31 @@ const DB_MIGRATION_SAFETY_ENABLED = const describeIfDbCompat = DB_COMPAT_ENABLED ? describe : describe.skip; const projectRoot = path.resolve(__dirname, '..'); +function getSchemaFromUrl(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + + try { + return new URL(url).searchParams.get('schema') ?? undefined; + } catch { + return undefined; + } +} + +function createPrismaClient(): PrismaClient { + const connectionString = process.env.DATABASE_URL; + const schema = getSchemaFromUrl(connectionString); + const adapter = new PrismaPg( + { connectionString }, + schema ? { schema } : undefined, + ); + + return new PrismaClient({ adapter }); +} + describeIfDbCompat('Database compatibility validation', () => { - const prisma = new PrismaClient(); + const prisma = createPrismaClient(); afterAll(async () => { await prisma.$disconnect(); diff --git a/test/event-payload-validation.e2e-spec.ts b/test/event-payload-validation.e2e-spec.ts index cf1ffaa..ad74ff7 100644 --- a/test/event-payload-validation.e2e-spec.ts +++ b/test/event-payload-validation.e2e-spec.ts @@ -1143,7 +1143,7 @@ describe('Event payload parity against v5 fixture snapshots', () => { .send(requestFixtures.createProject) .expect(201); - await waitForEventCount(2); + await waitForEventCount(1); assertSnapshot('project-create.json'); }); @@ -1156,7 +1156,7 @@ describe('Event payload parity against v5 fixture snapshots', () => { const createdProjectId = readId(createResponse.body); - await waitForEventCount(2); + await waitForEventCount(1); capturedEvents.length = 0; postEventMock.mockClear(); @@ -1166,7 +1166,7 @@ describe('Event payload parity against v5 fixture snapshots', () => { .send(requestFixtures.updateProject) .expect(200); - await waitForEventCount(4); + await waitForEventCount(1); assertSnapshot('project-update.json'); }); @@ -1177,7 +1177,7 @@ describe('Event payload parity against v5 fixture snapshots', () => { .send(requestFixtures.acceptInvite) .expect(200); - await waitForEventCount(3); + await waitForEventCount(1); assertSnapshot('invite-accept.json'); }); @@ -1190,7 +1190,7 @@ describe('Event payload parity against v5 fixture snapshots', () => { }) .expect(200); - await waitForEventCount(5); + await waitForEventCount(0); assertSnapshot('phase-transition.json'); }); diff --git a/test/fixtures/database-seed.ts b/test/fixtures/database-seed.ts index 220257a..eb86f9b 100644 --- a/test/fixtures/database-seed.ts +++ b/test/fixtures/database-seed.ts @@ -1,3 +1,4 @@ +import { PrismaPg } from '@prisma/adapter-pg'; import { AttachmentType, CopilotOpportunityStatus, @@ -23,6 +24,29 @@ export interface DatabaseSeedSummary { copilotOpportunityIds: string[]; } +function getSchemaFromUrl(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + + try { + return new URL(url).searchParams.get('schema') ?? undefined; + } catch { + return undefined; + } +} + +function createPrismaClient(): PrismaClient { + const connectionString = process.env.DATABASE_URL; + const schema = getSchemaFromUrl(connectionString); + const adapter = new PrismaPg( + { connectionString }, + schema ? { schema } : undefined, + ); + + return new PrismaClient({ adapter }); +} + async function ensureReferenceMetadata(prisma: PrismaClient): Promise<{ templateId: bigint; }> { @@ -686,7 +710,7 @@ export async function seedDatabaseFixtures( } if (require.main === module) { - const prisma = new PrismaClient(); + const prisma = createPrismaClient(); void seedDatabaseFixtures(prisma) .then((summary) => { diff --git a/test/fixtures/event-payload-snapshots/invite-accept.json b/test/fixtures/event-payload-snapshots/invite-accept.json index a5138c1..47565db 100644 --- a/test/fixtures/event-payload-snapshots/invite-accept.json +++ b/test/fixtures/event-payload-snapshots/invite-accept.json @@ -1,18 +1,4 @@ [ - { - "topic": "project.member.invite.updated", - "originator": "project-service-v6", - "payload": { - "resource": "project.member.invite", - "data": { - "id": "7001", - "projectId": "1001", - "status": "accepted", - "role": "copilot", - "userId": "2001" - } - } - }, { "topic": "project.member.added", "originator": "project-service-v6", @@ -25,17 +11,5 @@ "userId": "2001" } } - }, - { - "topic": "connect.notification.project.member.invite.accepted", - "originator": "project-service-v6", - "payload": { - "projectId": "1001", - "userId": "2001", - "initiatorUserId": "1001", - "inviteId": "7001", - "memberId": "5002", - "role": "copilot" - } } ] diff --git a/test/fixtures/event-payload-snapshots/phase-transition.json b/test/fixtures/event-payload-snapshots/phase-transition.json index 6449839..fe51488 100644 --- a/test/fixtures/event-payload-snapshots/phase-transition.json +++ b/test/fixtures/event-payload-snapshots/phase-transition.json @@ -1,85 +1 @@ -[ - { - "topic": "project.phase.updated", - "originator": "project-service-v6", - "payload": { - "resource": "project.phase", - "data": { - "id": "5001", - "projectId": "1001", - "status": "completed", - "name": "Discovery" - } - } - }, - { - "topic": "project.work.updated", - "originator": "project-service-v6", - "payload": { - "resource": "project.work", - "data": { - "id": "5001", - "projectId": "1001", - "status": "completed", - "name": "Discovery" - } - } - }, - { - "topic": "connect.notification.project.phase.transition.completed", - "originator": "project-service-v6", - "payload": { - "projectId": "1001", - "userId": "1001", - "initiatorUserId": "1001", - "originalPhase": { - "id": "5001", - "projectId": "1001", - "status": "active", - "name": "Discovery" - }, - "updatedPhase": { - "id": "5001", - "projectId": "1001", - "status": "completed", - "name": "Discovery" - } - } - }, - { - "topic": "connect.notification.project.work.transition.completed", - "originator": "project-service-v6", - "payload": { - "projectId": "1001", - "userId": "1001", - "initiatorUserId": "1001", - "originalPhase": { - "id": "5001", - "projectId": "1001", - "status": "active", - "name": "Discovery" - }, - "updatedPhase": { - "id": "5001", - "projectId": "1001", - "status": "completed", - "name": "Discovery" - } - } - }, - { - "topic": "connect.notification.project.plan.updated", - "originator": "project-service-v6", - "payload": { - "projectId": "1001", - "userId": "1001", - "initiatorUserId": "1001", - "phase": { - "id": "5001", - "projectId": "1001", - "status": "completed", - "name": "Discovery" - } - } - } -] +[] diff --git a/test/fixtures/event-payload-snapshots/project-create.json b/test/fixtures/event-payload-snapshots/project-create.json index dec11a5..0f99d33 100644 --- a/test/fixtures/event-payload-snapshots/project-create.json +++ b/test/fixtures/event-payload-snapshots/project-create.json @@ -1,6 +1,6 @@ [ { - "topic": "project.draft.created", + "topic": "project.created", "originator": "project-service-v6", "payload": { "resource": "project", @@ -10,16 +10,5 @@ "name": "fixture-v6-work-manager-project" } } - }, - { - "topic": "connect.notification.project.created", - "originator": "project-service-v6", - "payload": { - "projectId": "2001", - "projectName": "fixture-v6-work-manager-project", - "projectUrl": "https://platform.topcoder.com/connect/projects/2001", - "userId": "1001", - "initiatorUserId": "1001" - } } ] diff --git a/test/fixtures/event-payload-snapshots/project-update.json b/test/fixtures/event-payload-snapshots/project-update.json index 208087e..92a3c6e 100644 --- a/test/fixtures/event-payload-snapshots/project-update.json +++ b/test/fixtures/event-payload-snapshots/project-update.json @@ -10,39 +10,5 @@ "name": "fixture-v6-work-manager-project-updated" } } - }, - { - "topic": "project.status.changed", - "originator": "project-service-v6", - "payload": { - "resource": "project", - "data": { - "id": "2001", - "status": "active", - "name": "fixture-v6-work-manager-project-updated" - } - } - }, - { - "topic": "connect.notification.project.active", - "originator": "project-service-v6", - "payload": { - "projectId": "2001", - "projectName": "fixture-v6-work-manager-project-updated", - "projectUrl": "https://platform.topcoder.com/connect/projects/2001", - "userId": "1001", - "initiatorUserId": "1001" - } - }, - { - "topic": "connect.notification.project.updated", - "originator": "project-service-v6", - "payload": { - "projectId": "2001", - "projectName": "fixture-v6-work-manager-project-updated", - "projectUrl": "https://platform.topcoder.com/connect/projects/2001", - "userId": "1001", - "initiatorUserId": "1001" - } } ]