From de40ca80f5a4c2c822907bf37826e8211adc58d2 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 20 Apr 2026 16:17:34 +1000 Subject: [PATCH 1/2] https://topcoder.atlassian.net/browse/PM-4903 --- docs/ASSIGNMENT_STATUS_FLOW.md | 1 + packages/engagements-prisma-client/edge.js | 2 +- packages/engagements-prisma-client/index.d.ts | 8 +- packages/engagements-prisma-client/index.js | 2 +- .../engagements-prisma-client/package.json | 2 +- .../engagements-prisma-client/schema.prisma | 4 +- .../migration.sql | 17 ++ prisma/schema.prisma | 4 +- src/applications/applications.service.spec.ts | 22 ++- src/applications/applications.service.ts | 24 +-- src/common/constants.ts | 7 +- .../dto/engagement-response.dto.ts | 10 +- src/engagements/engagements.controller.ts | 17 +- src/engagements/engagements.service.spec.ts | 138 ++++++++++++- src/engagements/engagements.service.ts | 181 ++++++++++++------ 15 files changed, 335 insertions(+), 104 deletions(-) create mode 100644 prisma/migrations/20260420000000_preserve_assignment_history/migration.sql diff --git a/docs/ASSIGNMENT_STATUS_FLOW.md b/docs/ASSIGNMENT_STATUS_FLOW.md index e84faa2..5897a40 100644 --- a/docs/ASSIGNMENT_STATUS_FLOW.md +++ b/docs/ASSIGNMENT_STATUS_FLOW.md @@ -1,6 +1,7 @@ # Assignment Status Flow This diagram describes how an engagement assignment moves between statuses and the conditions for each transition. +Assignment rows are preserved after terminal transitions; unassignment and assignment removal mark active rows as `TERMINATED` instead of deleting them. ```mermaid stateDiagram-v2 diff --git a/packages/engagements-prisma-client/edge.js b/packages/engagements-prisma-client/edge.js index 4936198..798fa2d 100644 --- a/packages/engagements-prisma-client/edge.js +++ b/packages/engagements-prisma-client/edge.js @@ -248,7 +248,7 @@ const config = { "clientVersion": "7.2.0", "engineVersion": "0c8ef2ce45c83248ab3df073180d5eda9e8be7a3", "activeProvider": "postgresql", - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/engagements-prisma-client\"\n binaryTargets = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nenum EngagementStatus {\n OPEN\n ACTIVE\n CANCELLED\n CLOSED\n ON_HOLD\n}\n\nenum ApplicationStatus {\n SUBMITTED\n UNDER_REVIEW\n SELECTED\n ACCEPTED\n REJECTED\n}\n\nenum AssignmentStatus {\n SELECTED\n OFFER_REJECTED\n ASSIGNED\n COMPLETED\n TERMINATED\n}\n\nenum Role {\n DESIGNER\n SOFTWARE_DEVELOPER\n DATA_SCIENTIST\n DATA_ENGINEER\n}\n\nenum Workload {\n FULL_TIME\n FRACTIONAL\n}\n\nenum AnticipatedStart {\n IMMEDIATE\n FEW_DAYS\n FEW_WEEKS\n}\n\nmodel Engagement {\n id String @id @default(uuid())\n projectId String\n title String\n description String\n durationStartDate DateTime?\n durationEndDate DateTime?\n durationWeeks Int?\n durationMonths Int?\n timeZones String[]\n countries String[]\n requiredSkills String[]\n anticipatedStart AnticipatedStart\n status EngagementStatus @default(OPEN)\n isPrivate Boolean @default(false)\n requiredMemberCount Int?\n role Role?\n workload Workload?\n compensationRange String?\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime @updatedAt\n updatedBy String?\n\n applications EngagementApplication[]\n assignments EngagementAssignment[]\n\n @@index([projectId])\n @@index([status])\n @@index([role])\n @@index([workload])\n}\n\nmodel EngagementApplication {\n id String @id @default(uuid())\n engagementId String\n userId String\n handle String?\n email String\n name String\n address String?\n mobileNumber String?\n coverLetter String?\n resumeUrl String?\n portfolioUrls String[]\n yearsOfExperience Int?\n availability String?\n status ApplicationStatus @default(SUBMITTED)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n updatedBy String?\n\n engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Cascade)\n\n @@unique([engagementId, userId])\n @@index([userId])\n @@index([engagementId])\n @@index([status])\n}\n\nmodel EngagementAssignment {\n id String @id @default(uuid())\n engagementId String\n memberId String\n memberHandle String\n status AssignmentStatus @default(SELECTED)\n agreementRate String?\n ratePerHour String?\n standardHoursPerWeek Float?\n durationMonths Int?\n otherRemarks String?\n terminationReason String?\n startDate DateTime?\n endDate DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Cascade)\n feedback EngagementFeedback[]\n memberExperiences MemberExperience[]\n\n @@unique([engagementId, memberId])\n @@index([engagementId])\n @@index([memberId])\n}\n\nmodel EngagementFeedback {\n id String @id @default(uuid())\n engagementAssignmentId String\n feedbackText String\n rating Int?\n givenByMemberId String?\n givenByHandle String?\n givenByEmail String?\n secretToken String? @unique\n secretTokenExpiresAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n assignment EngagementAssignment @relation(fields: [engagementAssignmentId], references: [id], onDelete: Cascade)\n\n @@index([engagementAssignmentId])\n @@index([givenByMemberId])\n}\n\nmodel MemberExperience {\n id String @id @default(uuid())\n engagementAssignmentId String\n experienceText String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n assignment EngagementAssignment @relation(fields: [engagementAssignmentId], references: [id], onDelete: Cascade)\n\n @@index([engagementAssignmentId])\n}\n" + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/engagements-prisma-client\"\n binaryTargets = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nenum EngagementStatus {\n OPEN\n ACTIVE\n CANCELLED\n CLOSED\n ON_HOLD\n}\n\nenum ApplicationStatus {\n SUBMITTED\n UNDER_REVIEW\n SELECTED\n ACCEPTED\n REJECTED\n}\n\nenum AssignmentStatus {\n SELECTED\n OFFER_REJECTED\n ASSIGNED\n COMPLETED\n TERMINATED\n}\n\nenum Role {\n DESIGNER\n SOFTWARE_DEVELOPER\n DATA_SCIENTIST\n DATA_ENGINEER\n}\n\nenum Workload {\n FULL_TIME\n FRACTIONAL\n}\n\nenum AnticipatedStart {\n IMMEDIATE\n FEW_DAYS\n FEW_WEEKS\n}\n\nmodel Engagement {\n id String @id @default(uuid())\n projectId String\n title String\n description String\n durationStartDate DateTime?\n durationEndDate DateTime?\n durationWeeks Int?\n durationMonths Int?\n timeZones String[]\n countries String[]\n requiredSkills String[]\n anticipatedStart AnticipatedStart\n status EngagementStatus @default(OPEN)\n isPrivate Boolean @default(false)\n requiredMemberCount Int?\n role Role?\n workload Workload?\n compensationRange String?\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime @updatedAt\n updatedBy String?\n\n applications EngagementApplication[]\n assignments EngagementAssignment[]\n\n @@index([projectId])\n @@index([status])\n @@index([role])\n @@index([workload])\n}\n\nmodel EngagementApplication {\n id String @id @default(uuid())\n engagementId String\n userId String\n handle String?\n email String\n name String\n address String?\n mobileNumber String?\n coverLetter String?\n resumeUrl String?\n portfolioUrls String[]\n yearsOfExperience Int?\n availability String?\n status ApplicationStatus @default(SUBMITTED)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n updatedBy String?\n\n engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Cascade)\n\n @@unique([engagementId, userId])\n @@index([userId])\n @@index([engagementId])\n @@index([status])\n}\n\nmodel EngagementAssignment {\n id String @id @default(uuid())\n engagementId String\n memberId String\n memberHandle String\n status AssignmentStatus @default(SELECTED)\n agreementRate String?\n ratePerHour String?\n standardHoursPerWeek Float?\n durationMonths Int?\n otherRemarks String?\n terminationReason String?\n startDate DateTime?\n endDate DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Restrict)\n feedback EngagementFeedback[]\n memberExperiences MemberExperience[]\n\n @@index([engagementId])\n @@index([engagementId, memberId])\n @@index([memberId])\n}\n\nmodel EngagementFeedback {\n id String @id @default(uuid())\n engagementAssignmentId String\n feedbackText String\n rating Int?\n givenByMemberId String?\n givenByHandle String?\n givenByEmail String?\n secretToken String? @unique\n secretTokenExpiresAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n assignment EngagementAssignment @relation(fields: [engagementAssignmentId], references: [id], onDelete: Cascade)\n\n @@index([engagementAssignmentId])\n @@index([givenByMemberId])\n}\n\nmodel MemberExperience {\n id String @id @default(uuid())\n engagementAssignmentId String\n experienceText String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n assignment EngagementAssignment @relation(fields: [engagementAssignmentId], references: [id], onDelete: Cascade)\n\n @@index([engagementAssignmentId])\n}\n" } config.runtimeDataModel = JSON.parse("{\"models\":{\"Engagement\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"durationStartDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"durationEndDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"durationWeeks\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"durationMonths\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"timeZones\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"countries\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"requiredSkills\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"anticipatedStart\",\"kind\":\"enum\",\"type\":\"AnticipatedStart\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"EngagementStatus\"},{\"name\":\"isPrivate\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"requiredMemberCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"role\",\"kind\":\"enum\",\"type\":\"Role\"},{\"name\":\"workload\",\"kind\":\"enum\",\"type\":\"Workload\"},{\"name\":\"compensationRange\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"applications\",\"kind\":\"object\",\"type\":\"EngagementApplication\",\"relationName\":\"EngagementToEngagementApplication\"},{\"name\":\"assignments\",\"kind\":\"object\",\"type\":\"EngagementAssignment\",\"relationName\":\"EngagementToEngagementAssignment\"}],\"dbName\":null},\"EngagementApplication\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"engagementId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"handle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"address\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"mobileNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"coverLetter\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"resumeUrl\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"portfolioUrls\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"yearsOfExperience\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"availability\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"ApplicationStatus\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"engagement\",\"kind\":\"object\",\"type\":\"Engagement\",\"relationName\":\"EngagementToEngagementApplication\"}],\"dbName\":null},\"EngagementAssignment\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"engagementId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"AssignmentStatus\"},{\"name\":\"agreementRate\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ratePerHour\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"standardHoursPerWeek\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"durationMonths\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"otherRemarks\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"terminationReason\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"startDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"endDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"engagement\",\"kind\":\"object\",\"type\":\"Engagement\",\"relationName\":\"EngagementToEngagementAssignment\"},{\"name\":\"feedback\",\"kind\":\"object\",\"type\":\"EngagementFeedback\",\"relationName\":\"EngagementAssignmentToEngagementFeedback\"},{\"name\":\"memberExperiences\",\"kind\":\"object\",\"type\":\"MemberExperience\",\"relationName\":\"EngagementAssignmentToMemberExperience\"}],\"dbName\":null},\"EngagementFeedback\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"engagementAssignmentId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"feedbackText\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rating\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"givenByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"givenByHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"givenByEmail\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secretToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secretTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"assignment\",\"kind\":\"object\",\"type\":\"EngagementAssignment\",\"relationName\":\"EngagementAssignmentToEngagementFeedback\"}],\"dbName\":null},\"MemberExperience\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"engagementAssignmentId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"experienceText\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"assignment\",\"kind\":\"object\",\"type\":\"EngagementAssignment\",\"relationName\":\"EngagementAssignmentToMemberExperience\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") diff --git a/packages/engagements-prisma-client/index.d.ts b/packages/engagements-prisma-client/index.d.ts index b60359f..488bdd0 100644 --- a/packages/engagements-prisma-client/index.d.ts +++ b/packages/engagements-prisma-client/index.d.ts @@ -8102,7 +8102,6 @@ export namespace Prisma { export type EngagementAssignmentWhereUniqueInput = Prisma.AtLeast<{ id?: string - engagementId_memberId?: EngagementAssignmentEngagementIdMemberIdCompoundUniqueInput AND?: EngagementAssignmentWhereInput | EngagementAssignmentWhereInput[] OR?: EngagementAssignmentWhereInput[] NOT?: EngagementAssignmentWhereInput | EngagementAssignmentWhereInput[] @@ -8123,7 +8122,7 @@ export namespace Prisma { engagement?: XOR feedback?: EngagementFeedbackListRelationFilter memberExperiences?: MemberExperienceListRelationFilter - }, "id" | "engagementId_memberId"> + }, "id"> export type EngagementAssignmentOrderByWithAggregationInput = { id?: SortOrder @@ -9387,11 +9386,6 @@ export namespace Prisma { _count?: SortOrder } - export type EngagementAssignmentEngagementIdMemberIdCompoundUniqueInput = { - engagementId: string - memberId: string - } - export type EngagementAssignmentCountOrderByAggregateInput = { id?: SortOrder engagementId?: SortOrder diff --git a/packages/engagements-prisma-client/index.js b/packages/engagements-prisma-client/index.js index d21c096..b1ccb28 100644 --- a/packages/engagements-prisma-client/index.js +++ b/packages/engagements-prisma-client/index.js @@ -249,7 +249,7 @@ const config = { "clientVersion": "7.2.0", "engineVersion": "0c8ef2ce45c83248ab3df073180d5eda9e8be7a3", "activeProvider": "postgresql", - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/engagements-prisma-client\"\n binaryTargets = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nenum EngagementStatus {\n OPEN\n ACTIVE\n CANCELLED\n CLOSED\n ON_HOLD\n}\n\nenum ApplicationStatus {\n SUBMITTED\n UNDER_REVIEW\n SELECTED\n ACCEPTED\n REJECTED\n}\n\nenum AssignmentStatus {\n SELECTED\n OFFER_REJECTED\n ASSIGNED\n COMPLETED\n TERMINATED\n}\n\nenum Role {\n DESIGNER\n SOFTWARE_DEVELOPER\n DATA_SCIENTIST\n DATA_ENGINEER\n}\n\nenum Workload {\n FULL_TIME\n FRACTIONAL\n}\n\nenum AnticipatedStart {\n IMMEDIATE\n FEW_DAYS\n FEW_WEEKS\n}\n\nmodel Engagement {\n id String @id @default(uuid())\n projectId String\n title String\n description String\n durationStartDate DateTime?\n durationEndDate DateTime?\n durationWeeks Int?\n durationMonths Int?\n timeZones String[]\n countries String[]\n requiredSkills String[]\n anticipatedStart AnticipatedStart\n status EngagementStatus @default(OPEN)\n isPrivate Boolean @default(false)\n requiredMemberCount Int?\n role Role?\n workload Workload?\n compensationRange String?\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime @updatedAt\n updatedBy String?\n\n applications EngagementApplication[]\n assignments EngagementAssignment[]\n\n @@index([projectId])\n @@index([status])\n @@index([role])\n @@index([workload])\n}\n\nmodel EngagementApplication {\n id String @id @default(uuid())\n engagementId String\n userId String\n handle String?\n email String\n name String\n address String?\n mobileNumber String?\n coverLetter String?\n resumeUrl String?\n portfolioUrls String[]\n yearsOfExperience Int?\n availability String?\n status ApplicationStatus @default(SUBMITTED)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n updatedBy String?\n\n engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Cascade)\n\n @@unique([engagementId, userId])\n @@index([userId])\n @@index([engagementId])\n @@index([status])\n}\n\nmodel EngagementAssignment {\n id String @id @default(uuid())\n engagementId String\n memberId String\n memberHandle String\n status AssignmentStatus @default(SELECTED)\n agreementRate String?\n ratePerHour String?\n standardHoursPerWeek Float?\n durationMonths Int?\n otherRemarks String?\n terminationReason String?\n startDate DateTime?\n endDate DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Cascade)\n feedback EngagementFeedback[]\n memberExperiences MemberExperience[]\n\n @@unique([engagementId, memberId])\n @@index([engagementId])\n @@index([memberId])\n}\n\nmodel EngagementFeedback {\n id String @id @default(uuid())\n engagementAssignmentId String\n feedbackText String\n rating Int?\n givenByMemberId String?\n givenByHandle String?\n givenByEmail String?\n secretToken String? @unique\n secretTokenExpiresAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n assignment EngagementAssignment @relation(fields: [engagementAssignmentId], references: [id], onDelete: Cascade)\n\n @@index([engagementAssignmentId])\n @@index([givenByMemberId])\n}\n\nmodel MemberExperience {\n id String @id @default(uuid())\n engagementAssignmentId String\n experienceText String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n assignment EngagementAssignment @relation(fields: [engagementAssignmentId], references: [id], onDelete: Cascade)\n\n @@index([engagementAssignmentId])\n}\n" + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/engagements-prisma-client\"\n binaryTargets = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nenum EngagementStatus {\n OPEN\n ACTIVE\n CANCELLED\n CLOSED\n ON_HOLD\n}\n\nenum ApplicationStatus {\n SUBMITTED\n UNDER_REVIEW\n SELECTED\n ACCEPTED\n REJECTED\n}\n\nenum AssignmentStatus {\n SELECTED\n OFFER_REJECTED\n ASSIGNED\n COMPLETED\n TERMINATED\n}\n\nenum Role {\n DESIGNER\n SOFTWARE_DEVELOPER\n DATA_SCIENTIST\n DATA_ENGINEER\n}\n\nenum Workload {\n FULL_TIME\n FRACTIONAL\n}\n\nenum AnticipatedStart {\n IMMEDIATE\n FEW_DAYS\n FEW_WEEKS\n}\n\nmodel Engagement {\n id String @id @default(uuid())\n projectId String\n title String\n description String\n durationStartDate DateTime?\n durationEndDate DateTime?\n durationWeeks Int?\n durationMonths Int?\n timeZones String[]\n countries String[]\n requiredSkills String[]\n anticipatedStart AnticipatedStart\n status EngagementStatus @default(OPEN)\n isPrivate Boolean @default(false)\n requiredMemberCount Int?\n role Role?\n workload Workload?\n compensationRange String?\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime @updatedAt\n updatedBy String?\n\n applications EngagementApplication[]\n assignments EngagementAssignment[]\n\n @@index([projectId])\n @@index([status])\n @@index([role])\n @@index([workload])\n}\n\nmodel EngagementApplication {\n id String @id @default(uuid())\n engagementId String\n userId String\n handle String?\n email String\n name String\n address String?\n mobileNumber String?\n coverLetter String?\n resumeUrl String?\n portfolioUrls String[]\n yearsOfExperience Int?\n availability String?\n status ApplicationStatus @default(SUBMITTED)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n updatedBy String?\n\n engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Cascade)\n\n @@unique([engagementId, userId])\n @@index([userId])\n @@index([engagementId])\n @@index([status])\n}\n\nmodel EngagementAssignment {\n id String @id @default(uuid())\n engagementId String\n memberId String\n memberHandle String\n status AssignmentStatus @default(SELECTED)\n agreementRate String?\n ratePerHour String?\n standardHoursPerWeek Float?\n durationMonths Int?\n otherRemarks String?\n terminationReason String?\n startDate DateTime?\n endDate DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Restrict)\n feedback EngagementFeedback[]\n memberExperiences MemberExperience[]\n\n @@index([engagementId])\n @@index([engagementId, memberId])\n @@index([memberId])\n}\n\nmodel EngagementFeedback {\n id String @id @default(uuid())\n engagementAssignmentId String\n feedbackText String\n rating Int?\n givenByMemberId String?\n givenByHandle String?\n givenByEmail String?\n secretToken String? @unique\n secretTokenExpiresAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n assignment EngagementAssignment @relation(fields: [engagementAssignmentId], references: [id], onDelete: Cascade)\n\n @@index([engagementAssignmentId])\n @@index([givenByMemberId])\n}\n\nmodel MemberExperience {\n id String @id @default(uuid())\n engagementAssignmentId String\n experienceText String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n assignment EngagementAssignment @relation(fields: [engagementAssignmentId], references: [id], onDelete: Cascade)\n\n @@index([engagementAssignmentId])\n}\n" } config.runtimeDataModel = JSON.parse("{\"models\":{\"Engagement\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"durationStartDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"durationEndDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"durationWeeks\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"durationMonths\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"timeZones\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"countries\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"requiredSkills\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"anticipatedStart\",\"kind\":\"enum\",\"type\":\"AnticipatedStart\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"EngagementStatus\"},{\"name\":\"isPrivate\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"requiredMemberCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"role\",\"kind\":\"enum\",\"type\":\"Role\"},{\"name\":\"workload\",\"kind\":\"enum\",\"type\":\"Workload\"},{\"name\":\"compensationRange\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"applications\",\"kind\":\"object\",\"type\":\"EngagementApplication\",\"relationName\":\"EngagementToEngagementApplication\"},{\"name\":\"assignments\",\"kind\":\"object\",\"type\":\"EngagementAssignment\",\"relationName\":\"EngagementToEngagementAssignment\"}],\"dbName\":null},\"EngagementApplication\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"engagementId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"handle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"address\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"mobileNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"coverLetter\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"resumeUrl\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"portfolioUrls\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"yearsOfExperience\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"availability\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"ApplicationStatus\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"engagement\",\"kind\":\"object\",\"type\":\"Engagement\",\"relationName\":\"EngagementToEngagementApplication\"}],\"dbName\":null},\"EngagementAssignment\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"engagementId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"AssignmentStatus\"},{\"name\":\"agreementRate\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ratePerHour\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"standardHoursPerWeek\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"durationMonths\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"otherRemarks\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"terminationReason\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"startDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"endDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"engagement\",\"kind\":\"object\",\"type\":\"Engagement\",\"relationName\":\"EngagementToEngagementAssignment\"},{\"name\":\"feedback\",\"kind\":\"object\",\"type\":\"EngagementFeedback\",\"relationName\":\"EngagementAssignmentToEngagementFeedback\"},{\"name\":\"memberExperiences\",\"kind\":\"object\",\"type\":\"MemberExperience\",\"relationName\":\"EngagementAssignmentToMemberExperience\"}],\"dbName\":null},\"EngagementFeedback\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"engagementAssignmentId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"feedbackText\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rating\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"givenByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"givenByHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"givenByEmail\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secretToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secretTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"assignment\",\"kind\":\"object\",\"type\":\"EngagementAssignment\",\"relationName\":\"EngagementAssignmentToEngagementFeedback\"}],\"dbName\":null},\"MemberExperience\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"engagementAssignmentId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"experienceText\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"assignment\",\"kind\":\"object\",\"type\":\"EngagementAssignment\",\"relationName\":\"EngagementAssignmentToMemberExperience\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") diff --git a/packages/engagements-prisma-client/package.json b/packages/engagements-prisma-client/package.json index 1bb2fba..c3df20c 100644 --- a/packages/engagements-prisma-client/package.json +++ b/packages/engagements-prisma-client/package.json @@ -1,5 +1,5 @@ { - "name": "prisma-client-bf49df82ca883dbce9c97cf7770823a0d702e0b7fe85fa20f95a22f686d6216d", + "name": "prisma-client-90b70bf33765b0356bd040a7cc513dcb6f12734b0e0a56db101239dfb670cde3", "main": "index.js", "types": "index.d.ts", "browser": "default.js", diff --git a/packages/engagements-prisma-client/schema.prisma b/packages/engagements-prisma-client/schema.prisma index 5c8ae1c..4279dd5 100644 --- a/packages/engagements-prisma-client/schema.prisma +++ b/packages/engagements-prisma-client/schema.prisma @@ -131,12 +131,12 @@ model EngagementAssignment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Cascade) + engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Restrict) feedback EngagementFeedback[] memberExperiences MemberExperience[] - @@unique([engagementId, memberId]) @@index([engagementId]) + @@index([engagementId, memberId]) @@index([memberId]) } diff --git a/prisma/migrations/20260420000000_preserve_assignment_history/migration.sql b/prisma/migrations/20260420000000_preserve_assignment_history/migration.sql new file mode 100644 index 0000000..d74c308 --- /dev/null +++ b/prisma/migrations/20260420000000_preserve_assignment_history/migration.sql @@ -0,0 +1,17 @@ +DROP INDEX IF EXISTS "EngagementAssignment_engagementId_memberId_key"; + +CREATE INDEX IF NOT EXISTS "EngagementAssignment_engagementId_memberId_idx" +ON "EngagementAssignment"("engagementId", "memberId"); + +-- Preserve historical rows while preventing duplicate active assignments. +CREATE UNIQUE INDEX IF NOT EXISTS "EngagementAssignment_active_engagementId_memberId_key" +ON "EngagementAssignment"("engagementId", "memberId") +WHERE "status" IN ('SELECTED', 'ASSIGNED'); + +ALTER TABLE "EngagementAssignment" +DROP CONSTRAINT "EngagementAssignment_engagementId_fkey"; + +ALTER TABLE "EngagementAssignment" +ADD CONSTRAINT "EngagementAssignment_engagementId_fkey" +FOREIGN KEY ("engagementId") REFERENCES "Engagement"("id") +ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 361ab8e..f3af911 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -131,12 +131,12 @@ model EngagementAssignment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Cascade) + engagement Engagement @relation(fields: [engagementId], references: [id], onDelete: Restrict) feedback EngagementFeedback[] memberExperiences MemberExperience[] - @@unique([engagementId, memberId]) @@index([engagementId]) + @@index([engagementId, memberId]) @@index([memberId]) } diff --git a/src/applications/applications.service.spec.ts b/src/applications/applications.service.spec.ts index 7ce3a46..8930a28 100644 --- a/src/applications/applications.service.spec.ts +++ b/src/applications/applications.service.spec.ts @@ -1,5 +1,5 @@ import { ForbiddenException } from "@nestjs/common"; -import { ApplicationStatus } from "@prisma/client"; +import { ApplicationStatus, AssignmentStatus } from "@prisma/client"; import { ApplicationsService } from "./applications.service"; jest.mock("nanoid", () => ({ @@ -17,6 +17,7 @@ describe("ApplicationsService", () => { }; engagementAssignment: { findUnique: jest.Mock; + findFirst: jest.Mock; count: jest.Mock; create: jest.Mock; }; @@ -56,6 +57,7 @@ describe("ApplicationsService", () => { }, engagementAssignment: { findUnique: jest.fn(), + findFirst: jest.fn(), count: jest.fn(), create: jest.fn(), }, @@ -255,7 +257,7 @@ describe("ApplicationsService", () => { update: txEngagementUpdate, }, engagementAssignment: { - findUnique: jest.fn().mockResolvedValue(null), + findFirst: jest.fn().mockResolvedValue(null), count: jest.fn().mockResolvedValue(0), create: jest.fn().mockResolvedValue({ id: "assign-1" }), }, @@ -301,7 +303,7 @@ describe("ApplicationsService", () => { update: jest.fn(), }, engagementAssignment: { - findUnique: jest.fn().mockResolvedValue(null), + findFirst: jest.fn().mockResolvedValue(null), count: jest.fn().mockResolvedValue(0), create: jest.fn().mockResolvedValue({ id: "assign-1" }), }, @@ -331,7 +333,7 @@ describe("ApplicationsService", () => { ); }); - it("removes assignment when selected application is moved to submitted", async () => { + it("terminates active assignment when selected application is moved to submitted", async () => { const application = { id: "app-1", engagementId: "eng-1", @@ -339,7 +341,7 @@ describe("ApplicationsService", () => { status: ApplicationStatus.SELECTED, }; jest.spyOn(service, "findOne").mockResolvedValue(application as any); - db.engagementAssignment.findUnique.mockResolvedValue({ + db.engagementAssignment.findFirst.mockResolvedValue({ id: "assign-1", }); db.engagementApplication.update.mockResolvedValue({ @@ -351,13 +353,13 @@ describe("ApplicationsService", () => { userId: "user-2", }); - expect(db.engagementAssignment.findUnique).toHaveBeenCalledWith({ + expect(db.engagementAssignment.findFirst).toHaveBeenCalledWith({ where: { - engagementId_memberId: { - engagementId: "eng-1", - memberId: "user-1", - }, + engagementId: "eng-1", + memberId: "user-1", + status: { in: [AssignmentStatus.SELECTED, AssignmentStatus.ASSIGNED] }, }, + orderBy: { createdAt: "desc" }, select: { id: true }, }); expect(engagementsService.removeAssignment).toHaveBeenCalledWith( diff --git a/src/applications/applications.service.ts b/src/applications/applications.service.ts index 07acd8a..800ccc2 100644 --- a/src/applications/applications.service.ts +++ b/src/applications/applications.service.ts @@ -30,7 +30,7 @@ import { } from "./dto"; import { PaginatedResponse } from "../engagements/dto"; import { - ASSIGNMENT_COMPLETION_STATUSES, + ACTIVE_ASSIGNMENT_STATUSES, ERROR_MESSAGES, } from "../common/constants"; import { @@ -458,13 +458,13 @@ export class ApplicationsService { const engagementId = engagement.id; const memberId = application.userId; - const existingAssignment = await tx.engagementAssignment.findUnique({ + const existingAssignment = await tx.engagementAssignment.findFirst({ where: { - engagementId_memberId: { - engagementId, - memberId, - }, + engagementId, + memberId, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, }, + orderBy: { createdAt: "desc" }, }); if (existingAssignment) { @@ -525,7 +525,7 @@ export class ApplicationsService { const assignmentCount = await tx.engagementAssignment.count({ where: { engagementId, - status: { notIn: ASSIGNMENT_COMPLETION_STATUSES }, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, }, }); @@ -815,13 +815,13 @@ export class ApplicationsService { private async handleMemberUnassignment( application: ApplicationWithEngagement, ): Promise { - const assignment = await this.db.engagementAssignment.findUnique({ + const assignment = await this.db.engagementAssignment.findFirst({ where: { - engagementId_memberId: { - engagementId: application.engagementId, - memberId: application.userId, - }, + engagementId: application.engagementId, + memberId: application.userId, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, }, + orderBy: { createdAt: "desc" }, select: { id: true }, }); diff --git a/src/common/constants.ts b/src/common/constants.ts index 0e8add3..7dc8c2b 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -3,6 +3,11 @@ import { AssignmentStatus } from "@prisma/client"; export const DEFAULT_PAGE = 1; export const DEFAULT_PER_PAGE = 20; +export const ACTIVE_ASSIGNMENT_STATUSES: AssignmentStatus[] = [ + AssignmentStatus.SELECTED, + AssignmentStatus.ASSIGNED, +]; + export const ASSIGNMENT_COMPLETION_STATUSES: AssignmentStatus[] = [ AssignmentStatus.OFFER_REJECTED, AssignmentStatus.COMPLETED, @@ -34,5 +39,5 @@ export const ERROR_MESSAGES = { UnauthorizedExperienceAccess: "You do not have permission to access this experience record", EngagementHasMembers: - "This engagement cannot be deleted because it has members assigned to it. Cancel the engagement instead.", + "This engagement cannot be deleted because it has member assignment history. Cancel the engagement instead.", }; diff --git a/src/engagements/dto/engagement-response.dto.ts b/src/engagements/dto/engagement-response.dto.ts index ccea73b..d989791 100644 --- a/src/engagements/dto/engagement-response.dto.ts +++ b/src/engagements/dto/engagement-response.dto.ts @@ -146,14 +146,16 @@ export class EngagementResponseDto { updatedBy?: string; @ApiPropertyOptional({ - description: "Deprecated: first assigned member ID (use assignments).", + description: + "Deprecated: first active assigned member ID (use assignments).", example: "123456", deprecated: true, }) assignedMemberId?: string; @ApiPropertyOptional({ - description: "Deprecated: first assigned member handle (use assignments).", + description: + "Deprecated: first active assigned member handle (use assignments).", example: "jane_doe", deprecated: true, }) @@ -191,7 +193,7 @@ export class EngagementResponseDto { @ApiPropertyOptional({ description: - "Deprecated: array of assigned member IDs derived from assignments (use assignments).", + "Deprecated: array of active assigned member IDs derived from assignments (use assignments).", example: ["123456", "789012"], deprecated: true, }) @@ -199,7 +201,7 @@ export class EngagementResponseDto { @ApiPropertyOptional({ description: - "Deprecated: array of assigned member handles derived from assignments (use assignments).", + "Deprecated: array of active assigned member handles derived from assignments (use assignments).", example: ["john_doe", "jane_smith"], deprecated: true, }) diff --git a/src/engagements/engagements.controller.ts b/src/engagements/engagements.controller.ts index 939f6d5..966032c 100644 --- a/src/engagements/engagements.controller.ts +++ b/src/engagements/engagements.controller.ts @@ -311,12 +311,15 @@ export class EngagementsController { @ScopesDecorator(AppScopes.WriteEngagements, AppScopes.ManageEngagements) @ApiBearerAuth() @ApiOperation({ - summary: "Remove engagement assignment", + summary: "Terminate engagement assignment", description: - "Removes an assignment from an engagement. Requires admin, PM, Task Manager, or Talent Manager role for user tokens, " + + "Marks an active assignment as TERMINATED while preserving its assignment history. Requires admin, PM, Task Manager, or Talent Manager role for user tokens, " + "or write:engagements/manage:engagements scope for M2M clients.", }) - @ApiResponse({ status: 204, description: "Engagement assignment removed." }) + @ApiResponse({ + status: 204, + description: "Engagement assignment terminated.", + }) @ApiBadRequestResponse({ description: "Invalid request payload.", }) @@ -451,12 +454,12 @@ export class EngagementsController { summary: "Delete engagement", description: "Deletes an engagement. Requires Administrator role for user tokens, or manage:engagements scope for M2M clients. " + - "The engagement must have no active member assignments.", + "The engagement must have no assignment history.", }) @ApiResponse({ status: 204, description: "Engagement deleted." }) @ApiBadRequestResponse({ description: - "Engagement has active member assignments and cannot be deleted.", + "Engagement has member assignment history and cannot be deleted.", }) @ApiUnauthorizedResponse({ description: "Missing or invalid authentication token.", @@ -473,8 +476,8 @@ export class EngagementsController { * Restricted to Administrator users for user tokens. M2M clients may call this * endpoint with the manage:engagements scope. * - * Engagements with active member assignments are rejected with HTTP 400. The - * service layer enforces this member-assignment guard. + * Engagements with member assignment history are rejected with HTTP 400 so + * assignment rows are never deleted through cascading engagement deletion. */ async remove( @Param("id") id: string, diff --git a/src/engagements/engagements.service.spec.ts b/src/engagements/engagements.service.spec.ts index efeb156..a753041 100644 --- a/src/engagements/engagements.service.spec.ts +++ b/src/engagements/engagements.service.spec.ts @@ -161,6 +161,92 @@ describe("EngagementsService", () => { ); }); + it("terminates omitted active assignments when updating assignment details", async () => { + const now = new Date("2026-02-12T09:00:00.000Z"); + jest.useFakeTimers().setSystemTime(now); + + const existingAssignment = { + id: "assign-1", + engagementId: "eng-1", + memberId: "member-1", + memberHandle: "handle1", + status: AssignmentStatus.SELECTED, + createdAt: new Date("2026-02-11T10:00:00.000Z"), + updatedAt: new Date("2026-02-11T10:00:00.000Z"), + }; + const omittedAssignment = { + id: "assign-2", + engagementId: "eng-1", + memberId: "member-2", + memberHandle: "handle2", + status: AssignmentStatus.ASSIGNED, + createdAt: new Date("2026-02-11T11:00:00.000Z"), + updatedAt: new Date("2026-02-11T11:00:00.000Z"), + }; + const existingEngagement = { + id: "eng-1", + projectId: "project-1", + isPrivate: true, + requiredMemberCount: 2, + assignments: [existingAssignment, omittedAssignment], + }; + jest.spyOn(service, "findOne").mockResolvedValue(existingEngagement as any); + memberService.getMemberHandleByUserId.mockResolvedValue("handle1"); + + const tx = { + engagement: { + update: jest.fn().mockResolvedValue({ + ...existingEngagement, + assignments: [ + existingAssignment, + { + ...omittedAssignment, + status: AssignmentStatus.TERMINATED, + endDate: now, + }, + ], + }), + }, + engagementAssignment: { + findFirst: jest.fn().mockResolvedValue(existingAssignment), + update: jest.fn().mockResolvedValue(existingAssignment), + create: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + + db.$transaction.mockImplementation((callback: any) => callback(tx)); + + await service.update( + "eng-1", + { + assignmentDetails: [ + { + memberId: "member-1", + memberHandle: "handle1", + }, + ], + } as any, + { sub: "manager-1" }, + ); + + expect(tx.engagementAssignment.updateMany).toHaveBeenCalledWith({ + where: { + engagementId: "eng-1", + memberId: { + notIn: ["member-1"], + }, + status: { + in: [AssignmentStatus.SELECTED, AssignmentStatus.ASSIGNED], + }, + }, + data: { + status: AssignmentStatus.TERMINATED, + endDate: now, + }, + }); + }); + it("blocks changing project when current project has a billing account", async () => { const existingEngagement = { id: "eng-1", @@ -348,6 +434,9 @@ describe("EngagementsService", () => { assignments: { where: { memberId: "123456", + status: { + in: [AssignmentStatus.SELECTED, AssignmentStatus.ASSIGNED], + }, }, }, }, @@ -800,7 +889,7 @@ describe("EngagementsService", () => { ); }); - it("throws BadRequestException when removing an engagement with active assignments", async () => { + it("throws BadRequestException when removing an engagement with assignment history", async () => { jest.spyOn(service, "findOne").mockResolvedValue({ id: "eng-1" } as any); db.engagementAssignment.count.mockResolvedValue(1); @@ -811,7 +900,7 @@ describe("EngagementsService", () => { expect(db.engagement.delete).not.toHaveBeenCalled(); }); - it("deletes an engagement when there are no active assignments", async () => { + it("deletes an engagement when there is no assignment history", async () => { jest.spyOn(service, "findOne").mockResolvedValue({ id: "eng-1" } as any); db.engagementAssignment.count.mockResolvedValue(0); @@ -821,4 +910,49 @@ describe("EngagementsService", () => { where: { id: "eng-1" }, }); }); + + it("terminates an active assignment instead of deleting it", async () => { + const now = new Date("2026-02-12T10:00:00.000Z"); + jest.useFakeTimers().setSystemTime(now); + + const tx = { + engagement: { + findUnique: jest.fn().mockResolvedValue({ + id: "eng-1", + isPrivate: false, + assignments: [ + { + id: "assign-1", + status: AssignmentStatus.ASSIGNED, + }, + ], + }), + }, + engagementAssignment: { + findUnique: jest.fn().mockResolvedValue({ + id: "assign-1", + engagementId: "eng-1", + status: AssignmentStatus.ASSIGNED, + }), + update: jest.fn().mockResolvedValue({ + id: "assign-1", + engagementId: "eng-1", + status: AssignmentStatus.TERMINATED, + endDate: now, + }), + }, + }; + + db.$transaction.mockImplementation((callback: any) => callback(tx)); + + await service.removeAssignment("eng-1", "assign-1"); + + expect(tx.engagementAssignment.update).toHaveBeenCalledWith({ + where: { id: "assign-1" }, + data: { + status: AssignmentStatus.TERMINATED, + endDate: now, + }, + }); + }); }); diff --git a/src/engagements/engagements.service.ts b/src/engagements/engagements.service.ts index d199983..7dbfe28 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -34,7 +34,7 @@ import { UpdateEngagementDto, } from "./dto"; import { - ASSIGNMENT_COMPLETION_STATUSES, + ACTIVE_ASSIGNMENT_STATUSES, ERROR_MESSAGES, } from "../common/constants"; import { getUserIdentifier, getUserRoles } from "../common/user.util"; @@ -234,7 +234,7 @@ export class EngagementsService { const assignmentCount = await tx.engagementAssignment.count({ where: { engagementId: engagement.id, - status: { notIn: ASSIGNMENT_COMPLETION_STATUSES }, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, }, }); @@ -605,7 +605,12 @@ export class EngagementsService { }); const where: Prisma.EngagementWhereInput = { - assignments: { some: { memberId: userIdentifier } }, + assignments: { + some: { + memberId: userIdentifier, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, + }, + }, }; const andFilters: Prisma.EngagementWhereInput[] = []; @@ -692,6 +697,7 @@ export class EngagementsService { assignments: { where: { memberId: userIdentifier, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, }, }, }, @@ -736,6 +742,7 @@ export class EngagementsService { ? { where: { memberId: options.assignmentMemberId, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, }, } : true, @@ -915,10 +922,8 @@ export class EngagementsService { const existingAssignments = (existingEngagement as { assignments?: EngagementAssignment[] }) .assignments ?? []; - const totalAssignmentCount = existingAssignments.length; - const activeAssignmentCount = existingAssignments.filter( - (assignment) => - !ASSIGNMENT_COMPLETION_STATUSES.includes(assignment.status), + const activeAssignmentCount = existingAssignments.filter((assignment) => + ACTIVE_ASSIGNMENT_STATUSES.includes(assignment.status), ).length; const requiredMemberCount = payload.requiredMemberCount ?? @@ -948,7 +953,7 @@ export class EngagementsService { Boolean(assignedMemberId) || Boolean(assignedMemberHandle) || assignmentDetailsList.length > 0 || - totalAssignmentCount > 0; + activeAssignmentCount > 0; if (!hasAssignedMember) { throw new BadRequestException( @@ -1074,46 +1079,60 @@ export class EngagementsService { assignmentCreateData.otherRemarks = details.otherRemarks; assignmentUpdateData.otherRemarks = details.otherRemarks; } - return tx.engagementAssignment.upsert({ - where: { - engagementId_memberId: { + return tx.engagementAssignment + .findFirst({ + where: { engagementId: id, memberId: details.memberId, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, }, - }, - create: assignmentCreateData, - update: assignmentUpdateData, - }); + orderBy: { createdAt: "desc" }, + }) + .then((existingActiveAssignment) => + existingActiveAssignment + ? tx.engagementAssignment.update({ + where: { id: existingActiveAssignment.id }, + data: assignmentUpdateData, + }) + : tx.engagementAssignment.create({ + data: assignmentCreateData, + }), + ); }), ); const desiredMemberIds = Array.from( new Set(assignmentDetailsList.map((details) => details.memberId)), ); - await tx.engagementAssignment.deleteMany({ + await tx.engagementAssignment.updateMany({ where: { engagementId: id, memberId: { notIn: desiredMemberIds, }, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, + }, + data: { + status: AssignmentStatus.TERMINATED, + endDate: new Date(), }, }); } else if (shouldUpsertAssignment && assignmentDetailsResult) { if (requiredMemberCount !== undefined) { - const existingAssignment = await tx.engagementAssignment.findUnique({ + const existingAssignment = await tx.engagementAssignment.findFirst({ where: { - engagementId_memberId: { - engagementId: id, - memberId: assignmentDetailsResult.memberId, - }, + engagementId: id, + memberId: assignmentDetailsResult.memberId, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, }, + orderBy: { createdAt: "desc" }, }); if (!existingAssignment) { const assignmentCount = await tx.engagementAssignment.count({ where: { engagementId: id, - status: { notIn: ASSIGNMENT_COMPLETION_STATUSES }, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, }, }); if (assignmentCount >= requiredMemberCount) { @@ -1124,23 +1143,33 @@ export class EngagementsService { } } - await tx.engagementAssignment.upsert({ - where: { - engagementId_memberId: { + const existingActiveAssignment = + await tx.engagementAssignment.findFirst({ + where: { engagementId: id, memberId: assignmentDetailsResult.memberId, + status: { in: ACTIVE_ASSIGNMENT_STATUSES }, }, - }, - create: { - id: nanoid(), - engagementId: id, - memberId: assignmentDetailsResult.memberId, - memberHandle: assignmentDetailsResult.memberHandle, - }, - update: { - memberHandle: assignmentDetailsResult.memberHandle, - }, - }); + orderBy: { createdAt: "desc" }, + }); + + if (existingActiveAssignment) { + await tx.engagementAssignment.update({ + where: { id: existingActiveAssignment.id }, + data: { + memberHandle: assignmentDetailsResult.memberHandle, + }, + }); + } else { + await tx.engagementAssignment.create({ + data: { + id: nanoid(), + engagementId: id, + memberId: assignmentDetailsResult.memberId, + memberHandle: assignmentDetailsResult.memberHandle, + }, + }); + } } return tx.engagement.update({ @@ -1151,20 +1180,21 @@ export class EngagementsService { }); const updatedAssignments = updatedEngagement.assignments ?? []; - const existingAssignmentsByMemberId = new Map( + const existingAssignmentsById = new Map( existingAssignments.map((assignment) => [ - String(assignment.memberId), + String(assignment.id), assignment, ]), ); const newAssignments = updatedAssignments.filter( (assignment) => - !existingAssignmentsByMemberId.has(String(assignment.memberId)), + !existingAssignmentsById.has(String(assignment.id)) && + ACTIVE_ASSIGNMENT_STATUSES.includes(assignment.status), ); const updatedAssignmentsForEmail = updatedAssignments.filter( (assignment) => { - const existingAssignment = existingAssignmentsByMemberId.get( - String(assignment.memberId), + const existingAssignment = existingAssignmentsById.get( + String(assignment.id), ); if (!existingAssignment) { @@ -1197,35 +1227,48 @@ export class EngagementsService { * Removes an engagement by UUID. * * Designed for Administrator-only use when an engagement was created in error - * and has no active member assignments. + * and has no assignment history. * * @param id Engagement UUID. * @throws {NotFoundException} If the engagement does not exist. - * @throws {BadRequestException} If the engagement has one or more active assignments. + * @throws {BadRequestException} If the engagement has any assignments. */ async remove(id: string): Promise { this.logger.debug("Removing engagement", { id }); await this.findOne(id); - const activeAssignmentCount = await this.db.engagementAssignment.count({ + const assignmentCount = await this.db.engagementAssignment.count({ where: { engagementId: id, - status: { notIn: ASSIGNMENT_COMPLETION_STATUSES }, }, }); - if (activeAssignmentCount > 0) { + if (assignmentCount > 0) { throw new BadRequestException(ERROR_MESSAGES.EngagementHasMembers); } await this.db.engagement.delete({ where: { id } }); } + /** + * Terminates an engagement assignment without deleting its historical row. + * + * Used by the assignment-removal endpoint and application unselection flow to + * end the active assignment while preserving feedback, experience records, + * and assignment audit history. + * + * @param engagementId Engagement UUID that owns the assignment. + * @param assignmentId Assignment UUID to terminate. + * @returns Resolves when the assignment has been terminated or was already terminal. + * @throws {NotFoundException} If the engagement or assignment does not exist. + * @throws {BadRequestException} If the assignment belongs to another engagement, + * or terminating it would leave a private engagement with no active members. + */ async removeAssignment( engagementId: string, assignmentId: string, ): Promise { - this.logger.debug("Removing engagement assignment", { + this.logger.debug("Terminating engagement assignment", { engagementId, assignmentId, }); @@ -1254,13 +1297,35 @@ export class EngagementsService { ); } - if (engagement.isPrivate && engagement.assignments.length <= 1) { + const activeAssignmentCount = engagement.assignments.filter( + (currentAssignment) => + ACTIVE_ASSIGNMENT_STATUSES.includes(currentAssignment.status), + ).length; + const isActiveAssignment = ACTIVE_ASSIGNMENT_STATUSES.includes( + assignment.status, + ); + + if ( + engagement.isPrivate && + isActiveAssignment && + activeAssignmentCount <= 1 + ) { throw new BadRequestException( "Private engagements must have at least one assigned member", ); } - await tx.engagementAssignment.delete({ where: { id: assignmentId } }); + if (!isActiveAssignment) { + return; + } + + await tx.engagementAssignment.update({ + where: { id: assignmentId }, + data: { + status: AssignmentStatus.TERMINATED, + endDate: new Date(), + }, + }); }); } @@ -1806,7 +1871,15 @@ export class EngagementsService { return engagement; } - const sortedAssignments = [...engagement.assignments].sort((a, b) => { + const activeAssignments = engagement.assignments.filter((assignment) => + ACTIVE_ASSIGNMENT_STATUSES.includes(assignment.status), + ); + + if (!activeAssignments.length) { + return engagement; + } + + const sortedActiveAssignments = [...activeAssignments].sort((a, b) => { const timeA = a.createdAt.getTime(); const timeB = b.createdAt.getTime(); if (timeA !== timeB) { @@ -1817,12 +1890,12 @@ export class EngagementsService { return { ...engagement, - assignedMemberId: sortedAssignments[0]?.memberId, - assignedMemberHandle: sortedAssignments[0]?.memberHandle, - assignedMembers: sortedAssignments.map( + assignedMemberId: sortedActiveAssignments[0]?.memberId, + assignedMemberHandle: sortedActiveAssignments[0]?.memberHandle, + assignedMembers: sortedActiveAssignments.map( (assignment) => assignment.memberId, ), - assignedMemberHandles: sortedAssignments.map( + assignedMemberHandles: sortedActiveAssignments.map( (assignment) => assignment.memberHandle, ), }; From 14397cb41a3bcfd95408adc34eb3f80c95e7f03a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 22 Apr 2026 15:25:21 +1000 Subject: [PATCH 2/2] Tie engagementa ssignment to a specific billing account ID --- .../dto/assignment-context-response.dto.ts | 9 ++ src/engagements/engagements.service.spec.ts | 61 +++++++++++ src/engagements/engagements.service.ts | 5 + src/integrations/project.service.ts | 103 +++++++++++++++++- 4 files changed, 172 insertions(+), 6 deletions(-) diff --git a/src/engagements/dto/assignment-context-response.dto.ts b/src/engagements/dto/assignment-context-response.dto.ts index 9ef0907..b5e5c4b 100644 --- a/src/engagements/dto/assignment-context-response.dto.ts +++ b/src/engagements/dto/assignment-context-response.dto.ts @@ -20,6 +20,15 @@ export class AssignmentContextResponseDto { }) projectId: string; + @ApiProperty({ + description: + "Trusted billing account id assigned to the assignment's project, " + + "or null when no billing account is configured", + example: 80001063, + nullable: true, + }) + billingAccountId: number | null; + @ApiPropertyOptional({ description: "Project name", example: "Platform Modernization", diff --git a/src/engagements/engagements.service.spec.ts b/src/engagements/engagements.service.spec.ts index a753041..cd358f6 100644 --- a/src/engagements/engagements.service.spec.ts +++ b/src/engagements/engagements.service.spec.ts @@ -26,6 +26,7 @@ describe("EngagementsService", () => { }; let projectService: { getMemberProjectIdsForUser: jest.Mock; + getProjectBillingAccountId: jest.Mock; getProjectNamesByIds: jest.Mock; hasBillingAccountAssigned: jest.Mock; validateProjectExists: jest.Mock; @@ -72,6 +73,7 @@ describe("EngagementsService", () => { }; projectService = { getMemberProjectIdsForUser: jest.fn().mockResolvedValue([]), + getProjectBillingAccountId: jest.fn().mockResolvedValue(null), getProjectNamesByIds: jest.fn().mockResolvedValue(new Map()), hasBillingAccountAssigned: jest.fn().mockResolvedValue(false), validateProjectExists: jest.fn().mockResolvedValue(true), @@ -468,6 +470,7 @@ describe("EngagementsService", () => { projectService.getProjectNamesByIds.mockResolvedValue( new Map([["project-1", "Platform Modernization"]]), ); + projectService.getProjectBillingAccountId.mockResolvedValue(80001063); const result = await service.findAssignmentContext("assignment-1"); @@ -481,6 +484,7 @@ describe("EngagementsService", () => { assignmentId: "assignment-1", engagementId: "eng-1", projectId: "project-1", + billingAccountId: 80001063, projectName: "Platform Modernization", engagementTitle: "Senior Frontend Engineer", memberId: "123456", @@ -496,6 +500,63 @@ describe("EngagementsService", () => { }); }); + it("keeps assignment context available when only project-name hydration fails", async () => { + db.engagementAssignment.findUnique.mockResolvedValue({ + id: "assignment-1", + engagementId: "eng-1", + memberId: "123456", + memberHandle: "testaws1", + status: AssignmentStatus.ASSIGNED, + agreementRate: "3020", + ratePerHour: "75.50", + standardHoursPerWeek: 40, + durationMonths: 3, + otherRemarks: "Complete onboarding within the first week.", + startDate: new Date("2026-02-12T00:00:00.000Z"), + endDate: new Date("2026-05-12T00:00:00.000Z"), + engagement: { + id: "eng-1", + projectId: "project-1", + title: "Senior Frontend Engineer", + }, + }); + projectService.getProjectNamesByIds.mockRejectedValue( + new Error("projects name lookup failed"), + ); + projectService.getProjectBillingAccountId.mockResolvedValue(null); + + const result = await service.findAssignmentContext("assignment-1"); + + expect(result).toMatchObject({ + assignmentId: "assignment-1", + billingAccountId: null, + projectId: "project-1", + }); + expect(result.projectName).toBeUndefined(); + }); + + it("propagates assignment billing-account lookup failures", async () => { + const lookupError = new Error("projects billing lookup failed"); + db.engagementAssignment.findUnique.mockResolvedValue({ + id: "assignment-1", + engagementId: "eng-1", + memberId: "123456", + memberHandle: "testaws1", + status: AssignmentStatus.ASSIGNED, + engagement: { + id: "eng-1", + projectId: "project-1", + title: "Senior Frontend Engineer", + }, + }); + projectService.getProjectNamesByIds.mockResolvedValue(new Map()); + projectService.getProjectBillingAccountId.mockRejectedValue(lookupError); + + await expect(service.findAssignmentContext("assignment-1")).rejects.toThrow( + lookupError, + ); + }); + it("includes assignment details for privileged engagement listings", async () => { db.engagement.findMany.mockResolvedValue([ { diff --git a/src/engagements/engagements.service.ts b/src/engagements/engagements.service.ts index 7dbfe28..4d3193f 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -76,6 +76,7 @@ type AssignmentContextDetail = { assignmentId: string; engagementId: string; projectId: string; + billingAccountId: number | null; projectName?: string; engagementTitle: string; memberId: string; @@ -816,10 +817,14 @@ export class EngagementsService { }); } + const billingAccountId = + await this.projectService.getProjectBillingAccountId(projectId); + return { assignmentId: assignment.id, engagementId: assignment.engagementId, projectId, + billingAccountId, projectName, engagementTitle: assignment.engagement.title, memberId: assignment.memberId, diff --git a/src/integrations/project.service.ts b/src/integrations/project.service.ts index 26127a9..47b582c 100644 --- a/src/integrations/project.service.ts +++ b/src/integrations/project.service.ts @@ -101,15 +101,39 @@ export class ProjectService { return false; } - if (typeof project.billingAccountId === "string") { - return project.billingAccountId.trim().length > 0; - } + return this.normalizeBillingAccountId(project.billingAccountId) !== null; + } + + /** + * Resolves the trusted billing account assigned to a project. + * + * Assignment payment callers use this server-side project metadata instead of + * request-supplied billing account ids when validating engagement payouts. + * + * @param projectId Project id being inspected. + * @returns Positive billing account id, or `null` when the project exists and + * has no configured billing account. + * @throws Error Propagates token lookup and project lookup failures. + * @throws Error when the project is missing or returns a malformed billing + * account id. + */ + async getProjectBillingAccountId(projectId: string): Promise { + const token = await this.getM2MToken(); + const project = await this.fetchProjectById(projectId, token, [ + "id", + "billingAccountId", + ]); - if (typeof project.billingAccountId === "number") { - return Number.isFinite(project.billingAccountId); + if (!project) { + throw new Error( + `Project ${projectId} was not found while resolving billingAccountId.`, + ); } - return false; + return this.resolveConfiguredBillingAccountId( + project.billingAccountId, + projectId, + ); } async getProjectNamesByIds( @@ -273,6 +297,73 @@ export class ProjectService { return normalizedProjectId || undefined; } + /** + * Converts a raw project billing account value into a positive integer id. + * + * @param billingAccountId Raw value from the projects API. + * @returns Positive billing account id, or `null` when the value is missing + * or cannot be normalized. + */ + private normalizeBillingAccountId(billingAccountId: unknown): number | null { + if (billingAccountId === undefined || billingAccountId === null) { + return null; + } + + if ( + typeof billingAccountId !== "string" && + typeof billingAccountId !== "number" + ) { + return null; + } + + const normalizedBillingAccountId = String(billingAccountId).trim(); + + if (!/^\d+$/.test(normalizedBillingAccountId)) { + return null; + } + + const parsedBillingAccountId = Number(normalizedBillingAccountId); + + return Number.isSafeInteger(parsedBillingAccountId) && + parsedBillingAccountId > 0 + ? parsedBillingAccountId + : null; + } + + /** + * Normalizes a billing account id when `null` must only mean "not configured". + * + * @param billingAccountId Raw project billing-account value. + * @param projectId Project id used for diagnostic errors. + * @returns Positive billing account id, or `null` when the project has no + * configured billing account. + * @throws Error when the project returns a non-empty value that cannot be + * normalized into a positive integer billing account id. + */ + private resolveConfiguredBillingAccountId( + billingAccountId: unknown, + projectId: string, + ): number | null { + if (billingAccountId === undefined || billingAccountId === null) { + return null; + } + + if (typeof billingAccountId === "string" && !billingAccountId.trim()) { + return null; + } + + const normalizedBillingAccountId = + this.normalizeBillingAccountId(billingAccountId); + + if (normalizedBillingAccountId === null) { + throw new Error( + `Project ${projectId} returned an invalid billingAccountId.`, + ); + } + + return normalizedBillingAccountId; + } + private normalizeAuthorizationHeader( authorizationHeader?: string | string[], ): string | undefined {