From 635c91714dc8229c917ba2c026d01ab100a22f7d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 20 Feb 2026 15:54:09 +1100 Subject: [PATCH 01/13] Add additional details to the responses for engagements to help the work-manager UI --- README.md | 6 + .../dto/engagement-response.dto.ts | 26 ++ src/engagements/engagements.service.spec.ts | 52 +++- src/engagements/engagements.service.ts | 126 ++++++++- src/integrations/project.service.ts | 245 ++++++++++++++---- 5 files changed, 399 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 578af7a..e302b4d 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,9 @@ M2M access uses Auth0 client credentials. Ensure the client is configured with t - Administrators, Topcoder Project Managers, Topcoder Task Managers, and Topcoder Talent Managers can bypass scope checks for most management operations. - Regular members can view engagements and manage their own applications. - Project Managers can view and update application statuses for engagements they created, while Task Managers and Talent Managers can do so across engagements. + +## Response Notes + +- `GET /engagements`, `GET /engagements/active`, and `GET /engagements/my-assignments` include project metadata on each engagement record: + - `projectName` (if available) + - `project` object with `id` and optional `name` diff --git a/src/engagements/dto/engagement-response.dto.ts b/src/engagements/dto/engagement-response.dto.ts index a4bca73..ccea73b 100644 --- a/src/engagements/dto/engagement-response.dto.ts +++ b/src/engagements/dto/engagement-response.dto.ts @@ -8,6 +8,20 @@ import { import { Transform } from "class-transformer"; import { AssignmentResponseDto } from "./assignment-response.dto"; +class EngagementProjectResponseDto { + @ApiProperty({ + description: "Project ID", + example: "3d9b37b5-1a5d-4c48-a60f-5f73c2f7f1b6", + }) + id: string; + + @ApiPropertyOptional({ + description: "Project name", + example: "Platform Modernization", + }) + name?: string; +} + export class EngagementResponseDto { @ApiProperty({ description: "Engagement ID", @@ -21,6 +35,18 @@ export class EngagementResponseDto { }) projectId: string; + @ApiPropertyOptional({ + description: "Project name", + example: "Platform Modernization", + }) + projectName?: string; + + @ApiPropertyOptional({ + description: "Project summary object", + type: EngagementProjectResponseDto, + }) + project?: EngagementProjectResponseDto; + @ApiProperty({ description: "Engagement title", example: "Senior Frontend Engineer", diff --git a/src/engagements/engagements.service.spec.ts b/src/engagements/engagements.service.spec.ts index 19477ee..c08c6a1 100644 --- a/src/engagements/engagements.service.spec.ts +++ b/src/engagements/engagements.service.spec.ts @@ -13,7 +13,10 @@ describe("EngagementsService", () => { count: jest.Mock; }; }; - let projectService: { validateProjectExists: jest.Mock }; + let projectService: { + getProjectNamesByIds: jest.Mock; + validateProjectExists: jest.Mock; + }; let skillsService: { validateSkillsExist: jest.Mock }; let memberService: { getMemberHandleByUserId: jest.Mock; @@ -50,6 +53,7 @@ describe("EngagementsService", () => { }, }; projectService = { + getProjectNamesByIds: jest.fn().mockResolvedValue(new Map()), validateProjectExists: jest.fn().mockResolvedValue(true), }; skillsService = { @@ -254,6 +258,52 @@ describe("EngagementsService", () => { expect(result.data[0]).toHaveProperty("assignedMemberHandles", ["member1"]); }); + it("hydrates project details in engagement listings", async () => { + db.engagement.findMany.mockResolvedValue([ + { + id: "eng-1", + projectId: "project-1", + title: "Public engagement", + description: "Public description", + timeZones: ["UTC"], + countries: ["US"], + requiredSkills: ["skill-1"], + anticipatedStart: "IMMEDIATE", + status: "OPEN", + createdAt: new Date("2026-02-11T10:00:00.000Z"), + updatedAt: new Date("2026-02-11T10:00:00.000Z"), + createdBy: "123456", + isPrivate: false, + requiredMemberCount: 2, + _count: { + applications: 3, + }, + }, + ]); + db.engagement.count.mockResolvedValue(1); + projectService.getProjectNamesByIds.mockResolvedValue( + new Map([["project-1", "Platform UI Refresh"]]), + ); + + const result = await service.findAll({ + page: 1, + perPage: 20, + sortBy: "createdAt", + sortOrder: "desc", + } as any); + + expect(projectService.getProjectNamesByIds).toHaveBeenCalledWith([ + "project-1", + ]); + expect(result.data[0]).toMatchObject({ + project: { + id: "project-1", + name: "Platform UI Refresh", + }, + projectName: "Platform UI Refresh", + }); + }); + it("sets assignment endDate to now when status is terminated", async () => { const now = new Date("2026-02-11T12:00:00.000Z"); jest.useFakeTimers().setSystemTime(now); diff --git a/src/engagements/engagements.service.ts b/src/engagements/engagements.service.ts index 480c0c4..1826edd 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -51,6 +51,11 @@ type ResolvedAssignmentDetails = { otherRemarks?: string; }; +type EngagementProjectReference = { + id: string; + name?: string; +}; + @Injectable() export class EngagementsService { private readonly logger = new Logger(EngagementsService.name); @@ -432,9 +437,11 @@ export class EngagementsService { : engagementWithCount; }); const hydratedEngagements = await this.hydrateCreatorEmails(engagements); + const hydratedEngagementsWithProjectDetails = + await this.hydrateProjectDetails(hydratedEngagements); return { - data: hydratedEngagements, + data: hydratedEngagementsWithProjectDetails, meta: { page, perPage, @@ -555,9 +562,11 @@ export class EngagementsService { }), ); const hydratedEngagements = await this.hydrateCreatorEmails(engagements); + const hydratedEngagementsWithProjectDetails = + await this.hydrateProjectDetails(hydratedEngagements); return { - data: hydratedEngagements, + data: hydratedEngagementsWithProjectDetails, meta: { page, perPage, @@ -1190,7 +1199,9 @@ export class EngagementsService { }, orderBy: { createdAt: "desc" }, }); - return this.hydrateCreatorEmails(engagements); + const engagementsWithCreatorEmails = + await this.hydrateCreatorEmails(engagements); + return this.hydrateProjectDetails(engagementsWithCreatorEmails); } private normalizeAssignmentOfferDetails(details?: AssignmentDetailsDto): { @@ -1529,6 +1540,115 @@ export class EngagementsService { }); } + private normalizeProjectId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const normalizedProjectId = value.trim(); + return normalizedProjectId || undefined; + } + + private normalizeProjectName(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const normalizedProjectName = value.trim(); + return normalizedProjectName || undefined; + } + + private async hydrateProjectDetails< + T extends { + project?: { + id?: string | null; + name?: string | null; + }; + projectId: string; + projectName?: string | null; + }, + >( + engagements: T[], + ): Promise< + Array< + T & { + project: EngagementProjectReference; + projectName?: string; + } + > + > { + if (!engagements.length) { + return engagements as Array< + T & { + project: EngagementProjectReference; + projectName?: string; + } + >; + } + + const projectIds = Array.from( + new Set( + engagements + .map((engagement) => this.normalizeProjectId(engagement.projectId)) + .filter((value): value is string => Boolean(value)), + ), + ); + + if (!projectIds.length) { + return engagements as Array< + T & { + project: EngagementProjectReference; + projectName?: string; + } + >; + } + + let projectNameByProjectId = new Map(); + + try { + projectNameByProjectId = + await this.projectService.getProjectNamesByIds(projectIds); + } catch (error) { + this.logger.warn("Failed to hydrate engagement project names.", { + error: error instanceof Error ? error.message : error, + }); + } + + return engagements.map((engagement) => { + const normalizedProjectId = this.normalizeProjectId(engagement.projectId); + if (!normalizedProjectId) { + return engagement as T & { + project: EngagementProjectReference; + projectName?: string; + }; + } + + const existingProject = engagement.project; + const existingProjectName = + this.normalizeProjectName(engagement.projectName) ?? + this.normalizeProjectName(existingProject?.name); + const resolvedProjectName = + existingProjectName ?? + this.normalizeProjectName( + projectNameByProjectId.get(normalizedProjectId), + ); + + const project: EngagementProjectReference = { + id: this.normalizeProjectId(existingProject?.id) ?? normalizedProjectId, + }; + + if (resolvedProjectName) { + project.name = resolvedProjectName; + } + + return { + ...engagement, + project, + ...(resolvedProjectName ? { projectName: resolvedProjectName } : {}), + }; + }); + } + private assertNonBlankField(value: unknown, fieldName: string): void { if (typeof value !== "string" || value.trim().length === 0) { throw new BadRequestException( diff --git a/src/integrations/project.service.ts b/src/integrations/project.service.ts index 8c30e38..993d386 100644 --- a/src/integrations/project.service.ts +++ b/src/integrations/project.service.ts @@ -15,10 +15,30 @@ type ProjectUsers = { invites: ProjectUser[]; }; +type ProjectSummary = { + id: string; + name: string; +}; + +type CachedProjectName = { + expiresAt: number; + name: string; +}; + +type ProjectResponse = { + id?: string | number | null; + invites?: ProjectUser[] | null; + members?: ProjectUser[] | null; + name?: string | null; +}; + @Injectable() export class ProjectService { private readonly logger = new Logger(ProjectService.name); private readonly m2m; + private readonly projectNameCache = new Map(); + private readonly projectNameCacheTtlMs = 5 * 60 * 1000; + private readonly projectLookupBatchSize = 10; constructor( private readonly httpService: HttpService, @@ -40,47 +60,172 @@ export class ProjectService { } async validateProjectExists(projectId: string): Promise { - const apiBaseUrl = this.configService.get( - "TOPCODER_API_URL_BASE", - "https://api.topcoder-dev.com", + const token = await this.getM2MToken(); + const project = await this.fetchProjectById(projectId, token); + return Boolean(project); + } + + async getProjectUsers(projectId: string): Promise { + const token = await this.getM2MToken(); + const project = await this.fetchProjectById(projectId, token); + + if (!project) { + return null; + } + + return { + members: Array.isArray(project.members) ? project.members : [], + invites: Array.isArray(project.invites) ? project.invites : [], + }; + } + + async getProjectNamesByIds( + projectIds: string[], + ): Promise> { + const normalizedProjectIds = Array.from( + new Set( + projectIds + .map((value) => this.normalizeProjectId(value)) + .filter((value): value is string => Boolean(value)), + ), ); + + if (!normalizedProjectIds.length) { + return new Map(); + } + + const projectNamesById = new Map(); + const uncachedProjectIds: string[] = []; + + normalizedProjectIds.forEach((projectId) => { + const cachedProjectName = this.getCachedProjectName(projectId); + + if (cachedProjectName) { + projectNamesById.set(projectId, cachedProjectName); + return; + } + + uncachedProjectIds.push(projectId); + }); + + if (!uncachedProjectIds.length) { + return projectNamesById; + } + const token = await this.getM2MToken(); - const normalizedBaseUrl = apiBaseUrl.replace(/\/$/, ""); - const url = `${normalizedBaseUrl}/v5/projects/${projectId}`; - try { - const response = await firstValueFrom( - this.httpService.get(url, { - headers: { Authorization: `Bearer ${token}` }, - }), + for ( + let batchStartIndex = 0; + batchStartIndex < uncachedProjectIds.length; + batchStartIndex += this.projectLookupBatchSize + ) { + const batchProjectIds = uncachedProjectIds.slice( + batchStartIndex, + batchStartIndex + this.projectLookupBatchSize, ); - return response.status === 200; - } catch (error) { - if (isAxiosError(error)) { - if (error.response?.status === 404) { - return false; + + const batchProjectSummaries = await Promise.all( + batchProjectIds.map((projectId) => + this.fetchProjectSummary(projectId, token).catch(() => null), + ), + ); + + batchProjectSummaries.forEach((projectSummary) => { + if (!projectSummary) { + return; } - this.logger.error("Project validation failed.", { - status: error.response?.status, - data: error.response?.data, - }); - throw error; - } + projectNamesById.set(projectSummary.id, projectSummary.name); + this.setCachedProjectName(projectSummary.id, projectSummary.name); + }); + } - this.logger.error("Project validation failed.", error); - throw error; + return projectNamesById; + } + + private async getM2MToken(): Promise { + const clientId = this.configService.get("M2M_CLIENT_ID"); + const clientSecret = this.configService.get("M2M_CLIENT_SECRET"); + + if (!clientId || !clientSecret) { + this.logger.error( + "M2M credentials are not configured. Set M2M_CLIENT_ID and M2M_CLIENT_SECRET.", + ); + throw new Error("M2M credentials are not configured."); } + + return (await this.m2m.getMachineToken(clientId, clientSecret)) as string; } - async getProjectUsers(projectId: string): Promise { - const apiBaseUrl = this.configService.get( - "TOPCODER_API_URL_BASE", - "https://api.topcoder-dev.com", - ); - const token = await this.getM2MToken(); - const normalizedBaseUrl = apiBaseUrl.replace(/\/$/, ""); - const url = `${normalizedBaseUrl}/v5/projects/${projectId}`; + private normalizeProjectId(projectId: string): string | undefined { + const normalizedProjectId = String(projectId || "").trim(); + return normalizedProjectId || undefined; + } + + private normalizeProjectName(projectName: unknown): string | undefined { + if (typeof projectName !== "string") { + return undefined; + } + + const normalizedProjectName = projectName.trim(); + return normalizedProjectName || undefined; + } + + private getCachedProjectName(projectId: string): string | undefined { + const cacheEntry = this.projectNameCache.get(projectId); + if (!cacheEntry) { + return undefined; + } + + if (cacheEntry.expiresAt <= Date.now()) { + this.projectNameCache.delete(projectId); + return undefined; + } + + return cacheEntry.name; + } + + private setCachedProjectName(projectId: string, projectName: string): void { + this.projectNameCache.set(projectId, { + expiresAt: Date.now() + this.projectNameCacheTtlMs, + name: projectName, + }); + } + + private async fetchProjectSummary( + projectId: string, + token: string, + ): Promise { + const project = await this.fetchProjectById(projectId, token, [ + "id", + "name", + ]); + if (!project) { + return null; + } + + const normalizedProjectName = this.normalizeProjectName(project.name); + if (!normalizedProjectName) { + return null; + } + + return { + id: projectId, + name: normalizedProjectName, + }; + } + + private async fetchProjectById( + projectId: string, + token: string, + fields?: string[], + ): Promise { + const normalizedProjectId = this.normalizeProjectId(projectId); + if (!normalizedProjectId) { + return null; + } + + const url = this.getProjectUrl(normalizedProjectId, fields); try { const response = await firstValueFrom( @@ -89,43 +234,39 @@ export class ProjectService { }), ); - return { - members: Array.isArray(response.data?.members) - ? response.data.members - : [], - invites: Array.isArray(response.data?.invites) - ? response.data.invites - : [], - }; + return response.data as ProjectResponse; } catch (error) { if (isAxiosError(error)) { if (error.response?.status === 404) { return null; } - this.logger.error("Project users lookup failed.", { + this.logger.error("Project lookup failed.", { + projectId: normalizedProjectId, status: error.response?.status, data: error.response?.data, }); throw error; } - this.logger.error("Project users lookup failed.", error); + this.logger.error("Project lookup failed.", { + projectId: normalizedProjectId, + error, + }); throw error; } } - private async getM2MToken(): Promise { - const clientId = this.configService.get("M2M_CLIENT_ID"); - const clientSecret = this.configService.get("M2M_CLIENT_SECRET"); - - if (!clientId || !clientSecret) { - this.logger.error( - "M2M credentials are not configured. Set M2M_CLIENT_ID and M2M_CLIENT_SECRET.", - ); - throw new Error("M2M credentials are not configured."); - } + private getProjectUrl(projectId: string, fields?: string[]): string { + const apiBaseUrl = this.configService.get( + "TOPCODER_API_URL_BASE", + "https://api.topcoder-dev.com", + ); + const normalizedBaseUrl = apiBaseUrl.replace(/\/$/, ""); + const query = fields?.length + ? `?fields=${encodeURIComponent(fields.join(","))}` + : ""; - return (await this.m2m.getMachineToken(clientId, clientSecret)) as string; + return `${normalizedBaseUrl}/v5/projects/${projectId}${query}`; } } From de1c48fe90e33c28854215d3cae675d33d8d8a13 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 3 Mar 2026 16:11:21 +1100 Subject: [PATCH 02/13] Point at v6 projects API --- .env.example | 2 +- src/integrations/project.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 5234253..0a2f795 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ VALID_ISSUERS='["https://api.topcoder-dev.com"]' # ======================================== TOPCODER_API_URL_BASE=https://api.topcoder-dev.com # Derived endpoints: -# - TC_PROJECT_SERVICE_URL: ${TOPCODER_API_URL_BASE}/v5/projects +# - TC_PROJECT_SERVICE_URL: ${TOPCODER_API_URL_BASE}/v6/projects # - STANDARDIZED_SKILLS_API_URL: ${TOPCODER_API_URL_BASE}/v5/standardized-skills # - MEMBER_API_V6_URL: ${TOPCODER_API_URL_BASE}/v6/members # PLATFORM UI BASE URL (used to generate anonymous feedback links) diff --git a/src/integrations/project.service.ts b/src/integrations/project.service.ts index 993d386..e81c007 100644 --- a/src/integrations/project.service.ts +++ b/src/integrations/project.service.ts @@ -267,6 +267,6 @@ export class ProjectService { ? `?fields=${encodeURIComponent(fields.join(","))}` : ""; - return `${normalizedBaseUrl}/v5/projects/${projectId}${query}`; + return `${normalizedBaseUrl}/v6/projects/${projectId}${query}`; } } From a653378d681727b5693f8673649ba08862bbfd6e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 6 Mar 2026 10:39:29 +1100 Subject: [PATCH 03/13] New features for ON-HOLD, emails for under review / rejected --- .env.example | 4 + README.md | 2 + packages/engagements-prisma-client/edge.js | 5 +- .../index-browser.js | 3 +- packages/engagements-prisma-client/index.d.ts | 3 +- packages/engagements-prisma-client/index.js | 5 +- .../engagements-prisma-client/package.json | 2 +- .../engagements-prisma-client/schema.prisma | 1 + .../migration.sql | 2 + prisma/schema.prisma | 1 + src/applications/applications.service.spec.ts | 108 +++++++++++++++ src/applications/applications.service.ts | 52 +++++++- src/common/constants.ts | 2 + src/engagements/dto/create-engagement.dto.ts | 2 +- src/engagements/dto/engagement-query.dto.ts | 3 +- src/engagements/engagements.controller.ts | 42 +++++- src/engagements/engagements.service.spec.ts | 124 +++++++++++++++++- src/engagements/engagements.service.ts | 35 ++++- .../application-status-email.service.ts | 118 +++++++++++++++++ src/integrations/integrations.module.ts | 3 + test/auth.e2e-spec.ts | 69 ++++++++++ 21 files changed, 568 insertions(+), 18 deletions(-) create mode 100644 prisma/migrations/20260306085643_add_on_hold_engagement_status/migration.sql create mode 100644 src/integrations/application-status-email.service.ts diff --git a/.env.example b/.env.example index 0a2f795..064d2c6 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,10 @@ KAFKA_ERROR_TOPIC=common.error.reporting SENDGRID_ASSIGNMENT_OFFER_TEMPLATE_ID= SENDGRID_ASSIGNMENT_OFFER_ACCEPTED_TEMPLATE_ID= SENDGRID_ASSIGNMENT_OFFER_REJECTED_TEMPLATE_ID= +# SendGrid template for notifying applicants their application is under review. +SENDGRID_UNDER_REVIEW_TEMPLATE_ID= +# SendGrid template for notifying applicants their application was not selected. +SENDGRID_REJECTED_TEMPLATE_ID= # ======================================== # M2M AUTHENTICATION diff --git a/README.md b/README.md index e302b4d..b7ea774 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ Set the following environment variables (see `.env.example` for defaults): | `SENDGRID_ASSIGNMENT_OFFER_TEMPLATE_ID` | SendGrid template ID for assignment offer emails. | | `SENDGRID_ASSIGNMENT_OFFER_ACCEPTED_TEMPLATE_ID` | SendGrid template ID for assignment offer accepted emails. | | `SENDGRID_ASSIGNMENT_OFFER_REJECTED_TEMPLATE_ID` | SendGrid template ID for assignment offer rejected emails. | +| `SENDGRID_UNDER_REVIEW_TEMPLATE_ID` | SendGrid template ID for notifying applicants their application is under review. | +| `SENDGRID_REJECTED_TEMPLATE_ID` | SendGrid template ID for notifying applicants their application was not selected. | ## Authentication diff --git a/packages/engagements-prisma-client/edge.js b/packages/engagements-prisma-client/edge.js index d57be11..4729640 100644 --- a/packages/engagements-prisma-client/edge.js +++ b/packages/engagements-prisma-client/edge.js @@ -192,7 +192,8 @@ exports.EngagementStatus = exports.$Enums.EngagementStatus = { OPEN: 'OPEN', ACTIVE: 'ACTIVE', CANCELLED: 'CANCELLED', - CLOSED: 'CLOSED' + CLOSED: 'CLOSED', + ON_HOLD: 'ON_HOLD' }; exports.ApplicationStatus = exports.$Enums.ApplicationStatus = { @@ -244,7 +245,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}\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 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 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" } 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\":\"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-browser.js b/packages/engagements-prisma-client/index-browser.js index 6a95a0e..80f2bd1 100644 --- a/packages/engagements-prisma-client/index-browser.js +++ b/packages/engagements-prisma-client/index-browser.js @@ -226,7 +226,8 @@ exports.EngagementStatus = exports.$Enums.EngagementStatus = { OPEN: 'OPEN', ACTIVE: 'ACTIVE', CANCELLED: 'CANCELLED', - CLOSED: 'CLOSED' + CLOSED: 'CLOSED', + ON_HOLD: 'ON_HOLD' }; exports.Role = exports.$Enums.Role = { diff --git a/packages/engagements-prisma-client/index.d.ts b/packages/engagements-prisma-client/index.d.ts index dfa99e6..9969d0d 100644 --- a/packages/engagements-prisma-client/index.d.ts +++ b/packages/engagements-prisma-client/index.d.ts @@ -47,7 +47,8 @@ export namespace $Enums { OPEN: 'OPEN', ACTIVE: 'ACTIVE', CANCELLED: 'CANCELLED', - CLOSED: 'CLOSED' + CLOSED: 'CLOSED', + ON_HOLD: 'ON_HOLD' }; export type EngagementStatus = (typeof EngagementStatus)[keyof typeof EngagementStatus] diff --git a/packages/engagements-prisma-client/index.js b/packages/engagements-prisma-client/index.js index 7f15bd7..05ddace 100644 --- a/packages/engagements-prisma-client/index.js +++ b/packages/engagements-prisma-client/index.js @@ -193,7 +193,8 @@ exports.EngagementStatus = exports.$Enums.EngagementStatus = { OPEN: 'OPEN', ACTIVE: 'ACTIVE', CANCELLED: 'CANCELLED', - CLOSED: 'CLOSED' + CLOSED: 'CLOSED', + ON_HOLD: 'ON_HOLD' }; exports.ApplicationStatus = exports.$Enums.ApplicationStatus = { @@ -245,7 +246,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}\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 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 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" } 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\":\"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 1df16f9..3442491 100644 --- a/packages/engagements-prisma-client/package.json +++ b/packages/engagements-prisma-client/package.json @@ -1,5 +1,5 @@ { - "name": "prisma-client-7cc2936d14885ccd3be9654ca092a2ab99d72bcd4ebce0554d3be2bd63cf3888", + "name": "prisma-client-b06bde78a0b245129b70bff45b46abe8ec245e660969cab872795a34320c553f", "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 186d122..fe72f0d 100644 --- a/packages/engagements-prisma-client/schema.prisma +++ b/packages/engagements-prisma-client/schema.prisma @@ -17,6 +17,7 @@ enum EngagementStatus { ACTIVE CANCELLED CLOSED + ON_HOLD } enum ApplicationStatus { diff --git a/prisma/migrations/20260306085643_add_on_hold_engagement_status/migration.sql b/prisma/migrations/20260306085643_add_on_hold_engagement_status/migration.sql new file mode 100644 index 0000000..f7ad72f --- /dev/null +++ b/prisma/migrations/20260306085643_add_on_hold_engagement_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "EngagementStatus" ADD VALUE 'ON_HOLD'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8845f97..968f223 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,7 @@ enum EngagementStatus { ACTIVE CANCELLED CLOSED + ON_HOLD } enum ApplicationStatus { diff --git a/src/applications/applications.service.spec.ts b/src/applications/applications.service.spec.ts index e6037f5..a5976b7 100644 --- a/src/applications/applications.service.spec.ts +++ b/src/applications/applications.service.spec.ts @@ -34,6 +34,9 @@ describe("ApplicationsService", () => { let assignmentOfferEmailService: { sendAssignmentOfferEmail: jest.Mock; }; + let applicationStatusEmailService: { + sendApplicationStatusEmail: jest.Mock; + }; const createDto = { coverLetter: "I am excited to apply for this engagement.", @@ -72,12 +75,16 @@ describe("ApplicationsService", () => { assignmentOfferEmailService = { sendAssignmentOfferEmail: jest.fn().mockResolvedValue(undefined), }; + applicationStatusEmailService = { + sendApplicationStatusEmail: jest.fn().mockResolvedValue(undefined), + }; service = new ApplicationsService( db as any, memberService as any, engagementsService as any, eventBusService as any, assignmentOfferEmailService as any, + applicationStatusEmailService as any, ); }); @@ -117,6 +124,107 @@ describe("ApplicationsService", () => { ); }); + it.each([ + { + status: ApplicationStatus.UNDER_REVIEW, + emailStatus: "UNDER_REVIEW", + }, + { + status: ApplicationStatus.REJECTED, + emailStatus: "REJECTED", + }, + ] as const)( + "sends application status email when status is $status", + async ({ status, emailStatus }) => { + const application = { + id: "app-1", + engagementId: "eng-1", + userId: "user-1", + status: ApplicationStatus.SUBMITTED, + engagement: { title: "Senior Product Designer" }, + }; + const updatedApplication = { + ...application, + status, + }; + jest.spyOn(service, "findOne").mockResolvedValue(application as any); + db.engagementApplication.update.mockResolvedValue(updatedApplication); + + await service.updateStatus("app-1", status, { userId: "manager-1" }); + + expect( + applicationStatusEmailService.sendApplicationStatusEmail, + ).toHaveBeenCalledWith({ + memberId: "user-1", + status: emailStatus, + engagementTitle: "Senior Product Designer", + }); + expect( + db.engagementApplication.update.mock.invocationCallOrder[0], + ).toBeLessThan( + applicationStatusEmailService.sendApplicationStatusEmail.mock + .invocationCallOrder[0], + ); + }, + ); + + it("does not send application status email for statuses without notification side effects", async () => { + const application = { + id: "app-1", + engagementId: "eng-1", + userId: "user-1", + status: ApplicationStatus.SUBMITTED, + engagement: { title: "Senior Product Designer" }, + }; + const updatedApplication = { + ...application, + status: ApplicationStatus.SUBMITTED, + }; + jest.spyOn(service, "findOne").mockResolvedValue(application as any); + db.engagementApplication.update.mockResolvedValue(updatedApplication); + + await service.updateStatus("app-1", ApplicationStatus.SUBMITTED, { + userId: "manager-1", + }); + + expect( + applicationStatusEmailService.sendApplicationStatusEmail, + ).not.toHaveBeenCalled(); + }); + + it("returns updated application when application status email dispatch rejects", async () => { + const application = { + id: "app-1", + engagementId: "eng-1", + userId: "user-1", + status: ApplicationStatus.SUBMITTED, + engagement: { title: "Senior Product Designer" }, + }; + const updatedApplication = { + ...application, + status: ApplicationStatus.REJECTED, + }; + jest.spyOn(service, "findOne").mockResolvedValue(application as any); + db.engagementApplication.update.mockResolvedValue(updatedApplication); + applicationStatusEmailService.sendApplicationStatusEmail.mockRejectedValue( + new Error("send failed"), + ); + + await expect( + service.updateStatus("app-1", ApplicationStatus.REJECTED, { + userId: "manager-1", + }), + ).resolves.toEqual(updatedApplication); + + expect( + applicationStatusEmailService.sendApplicationStatusEmail, + ).toHaveBeenCalledWith({ + memberId: "user-1", + status: "REJECTED", + engagementTitle: "Senior Product Designer", + }); + }); + it("does not activate engagement when accepting an application", async () => { const application = { id: "app-1", diff --git a/src/applications/applications.service.ts b/src/applications/applications.service.ts index a8bb685..d5267e6 100644 --- a/src/applications/applications.service.ts +++ b/src/applications/applications.service.ts @@ -18,6 +18,7 @@ import { DbService } from "../db/db.service"; import { MemberService } from "../integrations/member.service"; import { EventBusService } from "../integrations/event-bus.service"; import { AssignmentOfferEmailService } from "../integrations/assignment-offer-email.service"; +import { ApplicationStatusEmailService } from "../integrations/application-status-email.service"; import { EngagementMemberAssignedPayload } from "../integrations/types/event-bus.types"; import { EngagementsService } from "../engagements/engagements.service"; import { @@ -75,6 +76,7 @@ export class ApplicationsService { private readonly engagementsService: EngagementsService, private readonly eventBusService: EventBusService, private readonly assignmentOfferEmailService: AssignmentOfferEmailService, + private readonly applicationStatusEmailService: ApplicationStatusEmailService, ) {} async create( @@ -272,6 +274,21 @@ export class ApplicationsService { ); } + /** + * Updates an application's status and applies related side-effects. + * + * Besides assignment and unassignment workflows, transitions to + * `UNDER_REVIEW` and `REJECTED` trigger a non-blocking applicant email + * notification. + * + * @param id - Application ID. + * @param status - New application status. + * @param authUser - Authenticated user context used for authorization and + * `updatedBy`. + * @param assignmentDetails - Optional assignment details used when selecting a + * member. + * @returns The updated engagement application row. + */ async updateStatus( id: string, status: ApplicationStatus, @@ -292,13 +309,46 @@ export class ApplicationsService { await this.handleMemberUnassignment(application); } - return this.db.engagementApplication.update({ + const updatedApplication = await this.db.engagementApplication.update({ where: { id }, data: { status, updatedBy: authUserId, }, }); + + const emailStatus = + status === ApplicationStatus.UNDER_REVIEW + ? "UNDER_REVIEW" + : status === ApplicationStatus.REJECTED + ? "REJECTED" + : null; + + if (emailStatus) { + try { + void this.applicationStatusEmailService + .sendApplicationStatusEmail({ + memberId: application.userId, + status: emailStatus, + engagementTitle: application.engagement.title, + }) + .catch((error: unknown) => { + const message = + error instanceof Error ? error.message : "unknown error"; + this.logger.error( + `Failed to send application status email for application ${application.id}: ${message}`, + ); + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "unknown error"; + this.logger.error( + `Failed to queue application status email for application ${application.id}: ${message}`, + ); + } + } + + return updatedApplication; } private normalizeAssignmentDetails(details?: ApproveApplicationDto): { diff --git a/src/common/constants.ts b/src/common/constants.ts index 457fc30..b9375e7 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -31,4 +31,6 @@ export const ERROR_MESSAGES = { MemberExperienceNotFound: "Member experience record not found", 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.", }; diff --git a/src/engagements/dto/create-engagement.dto.ts b/src/engagements/dto/create-engagement.dto.ts index 8e7468f..ce99cb7 100644 --- a/src/engagements/dto/create-engagement.dto.ts +++ b/src/engagements/dto/create-engagement.dto.ts @@ -229,7 +229,7 @@ export class CreateEngagementDto { anticipatedStart: AnticipatedStart; @ApiPropertyOptional({ - description: "Engagement status", + description: "Engagement status, including ON_HOLD when applicable", enum: EngagementStatus, example: EngagementStatus.OPEN, }) diff --git a/src/engagements/dto/engagement-query.dto.ts b/src/engagements/dto/engagement-query.dto.ts index 63608bc..09bfb9b 100644 --- a/src/engagements/dto/engagement-query.dto.ts +++ b/src/engagements/dto/engagement-query.dto.ts @@ -38,7 +38,8 @@ export class EngagementQueryDto extends PaginationDto { projectId?: string; @ApiPropertyOptional({ - description: "Filter by status", + description: + "Filter by status. ON_HOLD requires the same authorization as includePrivate=true (admin, talent manager, or M2M token).", enum: EngagementStatus, example: EngagementStatus.OPEN, }) diff --git a/src/engagements/engagements.controller.ts b/src/engagements/engagements.controller.ts index d9f5085..f53e04a 100644 --- a/src/engagements/engagements.controller.ts +++ b/src/engagements/engagements.controller.ts @@ -49,7 +49,7 @@ import { UpdateEngagementDto, } from "./dto"; import { EngagementsService } from "./engagements.service"; -import { Engagement } from "@prisma/client"; +import { Engagement, EngagementStatus } from "@prisma/client"; import { getUserRoles } from "../common/user.util"; @ApiTags("Engagements") @@ -135,7 +135,7 @@ export class EngagementsController { @Query() query: EngagementQueryDto, @Req() req: Request & { authUser?: Record }, ): Promise> { - if (query.includePrivate) { + if (query.includePrivate || query.status === EngagementStatus.ON_HOLD) { this.assertCanIncludePrivate(req.authUser); } return this.engagementsService.findAll(query); @@ -399,27 +399,57 @@ export class EngagementsController { @ApiOperation({ summary: "Delete engagement", description: - "Deletes an engagement. Requires admin, PM, Task Manager, or Talent Manager role for user tokens, " + - "or manage:engagements scope for M2M clients.", + "Deletes an engagement. Requires Administrator role for user tokens, or manage:engagements scope for M2M clients. " + + "The engagement must have no active member assignments.", }) @ApiResponse({ status: 204, description: "Engagement deleted." }) + @ApiBadRequestResponse({ + description: + "Engagement has active member assignments and cannot be deleted.", + }) @ApiUnauthorizedResponse({ description: "Missing or invalid authentication token.", }) @ApiForbiddenResponse({ description: - "Insufficient permissions. Requires admin/PM/Task Manager/Talent Manager role or manage:engagements scope.", + "Insufficient permissions. Requires Administrator role or manage:engagements scope.", }) @ApiNotFoundResponse({ description: "Engagement not found." }) @HttpCode(HttpStatus.NO_CONTENT) + /** + * Deletes an engagement by ID. + * + * 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. + */ async remove( @Param("id") id: string, @Req() req: Request & { authUser?: Record }, ): Promise { - this.assertAdminOrPm(req.authUser); + this.assertAdminOnly(req.authUser); await this.engagementsService.remove(id); } + private assertAdminOnly(authUser?: Record) { + if (authUser?.isMachine) { + return; + } + + const roles = getUserRoles(authUser); + const isAdmin = roles.some( + (role) => role?.toLowerCase() === UserRoles.Admin.toLowerCase(), + ); + + if (!isAdmin) { + throw new ForbiddenException( + "Only Administrator users can delete engagements.", + ); + } + } + private assertAdminOrPm(authUser?: Record) { if (authUser?.isMachine) { return; diff --git a/src/engagements/engagements.service.spec.ts b/src/engagements/engagements.service.spec.ts index c08c6a1..b01e07a 100644 --- a/src/engagements/engagements.service.spec.ts +++ b/src/engagements/engagements.service.spec.ts @@ -1,6 +1,11 @@ -import { AssignmentStatus } from "@prisma/client"; +import { BadRequestException } from "@nestjs/common"; +import { AssignmentStatus, EngagementStatus } from "@prisma/client"; import { EngagementsService } from "./engagements.service"; +jest.mock("nanoid", () => ({ + nanoid: () => "test-id", +})); + describe("EngagementsService", () => { let service: EngagementsService; let db: { @@ -11,6 +16,10 @@ describe("EngagementsService", () => { update: jest.Mock; findMany: jest.Mock; count: jest.Mock; + delete: jest.Mock; + }; + engagementAssignment: { + count: jest.Mock; }; }; let projectService: { @@ -50,6 +59,10 @@ describe("EngagementsService", () => { update: jest.fn(), findMany: jest.fn(), count: jest.fn(), + delete: jest.fn(), + }, + engagementAssignment: { + count: jest.fn(), }, }; projectService = { @@ -304,6 +317,93 @@ describe("EngagementsService", () => { }); }); + it("excludes ON_HOLD from default public engagement listings", async () => { + db.engagement.findMany.mockResolvedValue([]); + db.engagement.count.mockResolvedValue(0); + + await service.findAll({ + page: 1, + perPage: 20, + sortBy: "createdAt", + sortOrder: "desc", + } as any); + + expect(db.engagement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + isPrivate: false, + AND: expect.arrayContaining([ + { status: { notIn: [EngagementStatus.ON_HOLD] } }, + ]), + }), + }), + ); + }); + + it("does not allow public status=ON_HOLD listings to return ON_HOLD engagements", async () => { + db.engagement.findMany.mockResolvedValue([]); + db.engagement.count.mockResolvedValue(0); + + const result = await service.findAll({ + status: EngagementStatus.ON_HOLD, + page: 1, + perPage: 20, + sortBy: "createdAt", + sortOrder: "desc", + } as any); + + const findManyArg = db.engagement.findMany.mock.calls[0][0]; + expect(findManyArg.where).toMatchObject({ + isPrivate: false, + }); + expect(findManyArg.where.AND).toEqual( + expect.arrayContaining([ + { status: EngagementStatus.ON_HOLD }, + { status: { notIn: [EngagementStatus.ON_HOLD] } }, + ]), + ); + expect(result.data).toEqual([]); + }); + + it("allows includePrivate status=ON_HOLD listings for privileged queries", async () => { + db.engagement.findMany.mockResolvedValue([]); + db.engagement.count.mockResolvedValue(0); + + await service.findAll({ + includePrivate: true, + status: EngagementStatus.ON_HOLD, + page: 1, + perPage: 20, + sortBy: "createdAt", + sortOrder: "desc", + } as any); + + const findManyArg = db.engagement.findMany.mock.calls[0][0]; + expect(findManyArg.where).not.toHaveProperty("isPrivate"); + expect(findManyArg.where.AND).toEqual( + expect.arrayContaining([{ status: EngagementStatus.ON_HOLD }]), + ); + expect(findManyArg.where.AND).not.toEqual( + expect.arrayContaining([ + { status: { notIn: [EngagementStatus.ON_HOLD] } }, + ]), + ); + }); + + it("findAllActive always uses public OPEN-only filtering", async () => { + db.engagement.findMany.mockResolvedValue([]); + + await service.findAllActive(); + + expect(db.engagement.findMany).toHaveBeenCalledWith({ + where: { + isPrivate: false, + status: EngagementStatus.OPEN, + }, + orderBy: { createdAt: "desc" }, + }); + }); + it("sets assignment endDate to now when status is terminated", async () => { const now = new Date("2026-02-11T12:00:00.000Z"); jest.useFakeTimers().setSystemTime(now); @@ -439,4 +539,26 @@ describe("EngagementsService", () => { }); expect(updateArgs.data).not.toHaveProperty("endDate"); }); + + it("throws BadRequestException when removing an engagement with active assignments", async () => { + jest.spyOn(service, "findOne").mockResolvedValue({ id: "eng-1" } as any); + db.engagementAssignment.count.mockResolvedValue(1); + + await expect(service.remove("eng-1")).rejects.toBeInstanceOf( + BadRequestException, + ); + + expect(db.engagement.delete).not.toHaveBeenCalled(); + }); + + it("deletes an engagement when there are no active assignments", async () => { + jest.spyOn(service, "findOne").mockResolvedValue({ id: "eng-1" } as any); + db.engagementAssignment.count.mockResolvedValue(0); + + await service.remove("eng-1"); + + expect(db.engagement.delete).toHaveBeenCalledWith({ + where: { id: "eng-1" }, + }); + }); }); diff --git a/src/engagements/engagements.service.ts b/src/engagements/engagements.service.ts index 1826edd..d15b12d 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -313,6 +313,10 @@ export class EngagementsService { ); } + /** + * Lists engagements with pagination and filters. + * Public/non-includePrivate feeds always exclude ON_HOLD, including explicit status filters. + */ async findAll( query: EngagementQueryDto, ): Promise> { @@ -322,6 +326,7 @@ export class EngagementsService { search: query.search, }); + const isPublicFeed = query.includePrivate !== true; const where: Prisma.EngagementWhereInput = query.includePrivate ? {} : { isPrivate: false }; @@ -332,7 +337,10 @@ export class EngagementsService { } if (query.status) { - where.status = query.status; + andFilters.push({ status: query.status }); + } + if (isPublicFeed) { + andFilters.push({ status: { notIn: [EngagementStatus.ON_HOLD] } }); } if (query.search) { @@ -941,9 +949,31 @@ 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. + * + * @param id Engagement UUID. + * @throws {NotFoundException} If the engagement does not exist. + * @throws {BadRequestException} If the engagement has one or more active assignments. + */ async remove(id: string): Promise { this.logger.debug("Removing engagement", { id }); await this.findOne(id); + + const activeAssignmentCount = await this.db.engagementAssignment.count({ + where: { + engagementId: id, + status: { notIn: ASSIGNMENT_COMPLETION_STATUSES }, + }, + }); + + if (activeAssignmentCount > 0) { + throw new BadRequestException(ERROR_MESSAGES.EngagementHasMembers); + } + await this.db.engagement.delete({ where: { id } }); } @@ -1190,6 +1220,9 @@ export class EngagementsService { } } + /** + * Lists public engagements that are currently OPEN. + */ async findAllActive(): Promise { this.logger.debug("Listing active engagements"); const engagements = await this.db.engagement.findMany({ diff --git a/src/integrations/application-status-email.service.ts b/src/integrations/application-status-email.service.ts new file mode 100644 index 0000000..27e77e7 --- /dev/null +++ b/src/integrations/application-status-email.service.ts @@ -0,0 +1,118 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { EventBusService } from "./event-bus.service"; +import { MemberService } from "./member.service"; + +/** + * Publishes applicant status notification emails to the external email pipeline. + * + * This service consumes `SENDGRID_UNDER_REVIEW_TEMPLATE_ID` and + * `SENDGRID_REJECTED_TEMPLATE_ID` to select the SendGrid template used for each + * status notification. + */ +@Injectable() +export class ApplicationStatusEmailService { + private readonly logger = new Logger(ApplicationStatusEmailService.name); + + constructor( + private readonly memberService: MemberService, + private readonly eventBusService: EventBusService, + private readonly configService: ConfigService, + ) {} + + /** + * Sends an email notification when an application transitions to an + * externally visible status. + * + * @param params - Email parameters including applicant user ID, target status, + * and optional engagement title used in the template payload. + * @returns A promise that resolves after the event bus publish attempt + * completes. + * @throws This method does not intentionally throw; operational failures are + * logged and the method returns early. + */ + async sendApplicationStatusEmail(params: { + memberId: string; + status: "UNDER_REVIEW" | "REJECTED"; + engagementTitle?: string | null; + }): Promise { + const templateKey = + params.status === "UNDER_REVIEW" + ? "SENDGRID_UNDER_REVIEW_TEMPLATE_ID" + : "SENDGRID_REJECTED_TEMPLATE_ID"; + const templateId = this.configService.get(templateKey); + + if (!templateId) { + this.logger.warn( + `SendGrid template ID not configured (${templateKey}). Application status emails are disabled.`, + ); + return; + } + + const memberId = String(params.memberId ?? "").trim(); + if (!memberId) { + this.logger.warn("Application status email skipped: missing member ID."); + return; + } + + let memberDetails: { + email: string | null; + firstName: string | null; + lastName: string | null; + } | null = null; + + try { + memberDetails = await this.memberService.getMemberByUserId(memberId); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + this.logger.error( + `Failed to fetch member details for application status email (memberId=${memberId}): ${message}`, + ); + return; + } + + const email = memberDetails?.email?.trim() ?? ""; + if (!email) { + this.logger.warn( + `Application status email skipped: no email found for member ${memberId}.`, + ); + return; + } + + let handle = ""; + try { + handle = + (await this.memberService.getMemberHandleByUserId(memberId)) ?? ""; + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + this.logger.error( + `Failed to resolve handle for application status email (memberId=${memberId}): ${message}`, + ); + } + + const payload = { + data: { + firstName: memberDetails?.firstName ?? "", + lastName: memberDetails?.lastName ?? "", + handle, + email, + engagementTitle: params.engagementTitle ?? "", + }, + recipients: [email], + sendgrid_template_id: templateId, + version: "v3", + }; + + try { + await this.eventBusService.postEvent("external.action.email", payload); + this.logger.log( + `Published 'external.action.email' (application ${params.status.toLowerCase()}) for member ${memberId} to ${email}.`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + this.logger.error( + `Failed to publish application ${params.status.toLowerCase()} email for member ${memberId}: ${message}`, + ); + } + } +} diff --git a/src/integrations/integrations.module.ts b/src/integrations/integrations.module.ts index b34d803..1a35c8a 100644 --- a/src/integrations/integrations.module.ts +++ b/src/integrations/integrations.module.ts @@ -7,6 +7,7 @@ import { MemberService } from "./member.service"; import { EventBusService } from "./event-bus.service"; import { AssignmentOfferEmailService } from "./assignment-offer-email.service"; import { AssignmentOfferResponseEmailService } from "./assignment-offer-response-email.service"; +import { ApplicationStatusEmailService } from "./application-status-email.service"; @Global() @Module({ @@ -18,6 +19,7 @@ import { AssignmentOfferResponseEmailService } from "./assignment-offer-response EventBusService, AssignmentOfferEmailService, AssignmentOfferResponseEmailService, + ApplicationStatusEmailService, ], exports: [ ProjectService, @@ -26,6 +28,7 @@ import { AssignmentOfferResponseEmailService } from "./assignment-offer-response EventBusService, AssignmentOfferEmailService, AssignmentOfferResponseEmailService, + ApplicationStatusEmailService, ], }) export class IntegrationsModule {} diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts index c62ef20..8fe7d48 100644 --- a/test/auth.e2e-spec.ts +++ b/test/auth.e2e-spec.ts @@ -19,6 +19,10 @@ const tokenFixtures: Record> = { isMachine: true, scopes: ["write:engagements"], }, + "m2m-manage": { + isMachine: true, + scopes: ["manage:engagements"], + }, "m2m-invalid": { isMachine: true, scopes: ["write:engagements"], @@ -534,6 +538,71 @@ describe("Authentication & Authorization (e2e)", () => { }); }); + describe("Delete Engagement Authorization", () => { + it("allows administrator user to delete an engagement", async () => { + engagementsServiceMock.remove.mockClear(); + engagementsServiceMock.remove.mockResolvedValueOnce(undefined); + + await request(app.getHttpServer()) + .delete("/engagements/eng-1") + .set("Authorization", "Bearer admin-user") + .expect(204); + + expect(engagementsServiceMock.remove).toHaveBeenCalledWith("eng-1"); + }); + + it("returns 403 for project manager user", async () => { + engagementsServiceMock.remove.mockClear(); + + await request(app.getHttpServer()) + .delete("/engagements/eng-1") + .set("Authorization", "Bearer project-manager-user") + .expect(403); + + expect(engagementsServiceMock.remove).not.toHaveBeenCalled(); + }); + + it("returns 403 for task manager user", async () => { + engagementsServiceMock.remove.mockClear(); + + await request(app.getHttpServer()) + .delete("/engagements/eng-1") + .set("Authorization", "Bearer task-manager-user") + .expect(403); + + expect(engagementsServiceMock.remove).not.toHaveBeenCalled(); + }); + + it("returns 403 for talent manager user", async () => { + engagementsServiceMock.remove.mockClear(); + + await request(app.getHttpServer()) + .delete("/engagements/eng-1") + .set("Authorization", "Bearer talent-manager-user") + .expect(403); + + expect(engagementsServiceMock.remove).not.toHaveBeenCalled(); + }); + + it("requires manage:engagements for M2M tokens", async () => { + engagementsServiceMock.remove.mockClear(); + + await request(app.getHttpServer()) + .delete("/engagements/eng-1") + .set("Authorization", "Bearer m2m-write") + .expect(403); + + expect(engagementsServiceMock.remove).not.toHaveBeenCalled(); + + await request(app.getHttpServer()) + .delete("/engagements/eng-1") + .set("Authorization", "Bearer m2m-manage") + .expect(204); + + expect(engagementsServiceMock.remove).toHaveBeenCalledWith("eng-1"); + }); + }); + describe("Application Create Authentication", () => { it("returns 401 when token is missing", async () => { await request(app.getHttpServer()) From 79538c4321b9222217ad0ecc86a2cd812de425a0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 6 Mar 2026 12:02:03 +1100 Subject: [PATCH 04/13] Project ID handling for listing - used to limit what TMs can see --- src/engagements/dto/engagement-query.dto.ts | 20 ++++++++++++++++++++ src/engagements/engagements.service.ts | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/src/engagements/dto/engagement-query.dto.ts b/src/engagements/dto/engagement-query.dto.ts index 09bfb9b..d3cbbb3 100644 --- a/src/engagements/dto/engagement-query.dto.ts +++ b/src/engagements/dto/engagement-query.dto.ts @@ -28,6 +28,13 @@ export const ENGAGEMENT_SORT_FIELDS: EngagementSortBy[] = [ EngagementSortBy.Title, ]; +/** + * Query parameters for listing engagements. + * + * `projectId` filters by a single project. + * `projectIds` filters by multiple projects using an `IN` query. + * When both are provided, `projectIds` takes precedence. + */ export class EngagementQueryDto extends PaginationDto { @ApiPropertyOptional({ description: "Filter by project ID", @@ -37,6 +44,19 @@ export class EngagementQueryDto extends PaginationDto { @IsString() projectId?: string; + @ApiPropertyOptional({ + description: "Filter by project IDs", + example: [ + "3d9b37b5-1a5d-4c48-a60f-5f73c2f7f1b6", + "4f5e9f5a-19b6-41e2-9dfe-8ce7adfce54b", + ], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(transformArray) + projectIds?: string[]; + @ApiPropertyOptional({ description: "Filter by status. ON_HOLD requires the same authorization as includePrivate=true (admin, talent manager, or M2M token).", diff --git a/src/engagements/engagements.service.ts b/src/engagements/engagements.service.ts index d15b12d..d624d0a 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -316,12 +316,15 @@ export class EngagementsService { /** * Lists engagements with pagination and filters. * Public/non-includePrivate feeds always exclude ON_HOLD, including explicit status filters. + * Supports `projectId` and `projectIds` project filtering. + * When both are provided, `projectIds` takes precedence. */ async findAll( query: EngagementQueryDto, ): Promise> { this.logger.debug("Listing engagements", { projectId: query.projectId, + projectIds: query.projectIds, status: query.status, search: query.search, }); @@ -335,6 +338,9 @@ export class EngagementsService { if (query.projectId) { where.projectId = query.projectId; } + if (query.projectIds?.length) { + where.projectId = { in: query.projectIds }; + } if (query.status) { andFilters.push({ status: query.status }); From 61c1a38194d16b832d889639bb23c6a2ee0dd340 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 6 Mar 2026 16:53:53 +1100 Subject: [PATCH 05/13] Add project details to avoid lots of extra calls in WM --- README.md | 1 + src/common/constants.ts | 2 + src/engagements/engagements.controller.ts | 3 +- src/engagements/engagements.service.spec.ts | 96 +++++++++++++++++++++ src/engagements/engagements.service.ts | 39 +++++++++ src/integrations/project.service.ts | 33 +++++++ 6 files changed, 173 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b7ea774..c4d32bb 100644 --- a/README.md +++ b/README.md @@ -90,3 +90,4 @@ M2M access uses Auth0 client credentials. Ensure the client is configured with t - `GET /engagements`, `GET /engagements/active`, and `GET /engagements/my-assignments` include project metadata on each engagement record: - `projectName` (if available) - `project` object with `id` and optional `name` +- `PUT /engagements/:id` rejects project reassignment when the engagement's current project already has a `billingAccountId`. diff --git a/src/common/constants.ts b/src/common/constants.ts index b9375e7..0e8add3 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -13,6 +13,8 @@ export const ERROR_MESSAGES = { MissingDuration: "Provide durationStartDate and durationEndDate, or durationWeeks, or durationMonths.", ProjectNotFound: "Project not found.", + ProjectChangeBlockedByBillingAccount: + "Cannot change engagement project because the current project has a billing account assigned.", InvalidSkills: "One or more required skills are invalid.", DuplicateApplication: "You have already applied to this engagement", MemberNotFound: "Member profile not found", diff --git a/src/engagements/engagements.controller.ts b/src/engagements/engagements.controller.ts index f53e04a..0661820 100644 --- a/src/engagements/engagements.controller.ts +++ b/src/engagements/engagements.controller.ts @@ -236,7 +236,8 @@ export class EngagementsController { type: EngagementResponseDto, }) @ApiBadRequestResponse({ - description: "Invalid request payload.", + description: + "Invalid request payload, or project reassignment is blocked because the current project has a billing account.", }) @ApiUnauthorizedResponse({ description: "Missing or invalid authentication token.", diff --git a/src/engagements/engagements.service.spec.ts b/src/engagements/engagements.service.spec.ts index b01e07a..be56a94 100644 --- a/src/engagements/engagements.service.spec.ts +++ b/src/engagements/engagements.service.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException } from "@nestjs/common"; import { AssignmentStatus, EngagementStatus } from "@prisma/client"; +import { ERROR_MESSAGES } from "../common/constants"; import { EngagementsService } from "./engagements.service"; jest.mock("nanoid", () => ({ @@ -24,6 +25,7 @@ describe("EngagementsService", () => { }; let projectService: { getProjectNamesByIds: jest.Mock; + hasBillingAccountAssigned: jest.Mock; validateProjectExists: jest.Mock; }; let skillsService: { validateSkillsExist: jest.Mock }; @@ -67,6 +69,7 @@ describe("EngagementsService", () => { }; projectService = { getProjectNamesByIds: jest.fn().mockResolvedValue(new Map()), + hasBillingAccountAssigned: jest.fn().mockResolvedValue(false), validateProjectExists: jest.fn().mockResolvedValue(true), }; skillsService = { @@ -154,6 +157,99 @@ describe("EngagementsService", () => { ); }); + it("blocks changing project when current project has a billing account", async () => { + const existingEngagement = { + id: "eng-1", + projectId: "project-1", + isPrivate: false, + requiredMemberCount: undefined, + assignments: [], + }; + jest.spyOn(service, "findOne").mockResolvedValue(existingEngagement as any); + projectService.hasBillingAccountAssigned.mockResolvedValue(true); + + await expect( + service.update("eng-1", { projectId: "project-2" } as any, { + sub: "123456", + }), + ).rejects.toThrow(ERROR_MESSAGES.ProjectChangeBlockedByBillingAccount); + + expect(projectService.hasBillingAccountAssigned).toHaveBeenCalledWith( + "project-1", + ); + expect(projectService.validateProjectExists).not.toHaveBeenCalled(); + expect(db.$transaction).not.toHaveBeenCalled(); + }); + + it("allows project updates when the current project has no billing account", async () => { + const existingEngagement = { + id: "eng-1", + projectId: "project-1", + isPrivate: false, + requiredMemberCount: undefined, + assignments: [], + }; + jest.spyOn(service, "findOne").mockResolvedValue(existingEngagement as any); + projectService.hasBillingAccountAssigned.mockResolvedValue(false); + + const tx = { + engagement: { + update: jest.fn().mockResolvedValue({ + ...existingEngagement, + projectId: "project-2", + }), + }, + }; + db.$transaction.mockImplementation((callback: any) => callback(tx)); + + await service.update("eng-1", { projectId: "project-2" } as any, { + sub: "123456", + }); + + expect(projectService.hasBillingAccountAssigned).toHaveBeenCalledWith( + "project-1", + ); + expect(projectService.validateProjectExists).toHaveBeenCalledWith( + "project-2", + ); + expect(tx.engagement.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + projectId: "project-2", + }), + }), + ); + }); + + it("does not run billing-account guard when projectId is unchanged", async () => { + const existingEngagement = { + id: "eng-1", + projectId: "project-1", + isPrivate: false, + requiredMemberCount: undefined, + assignments: [], + }; + jest.spyOn(service, "findOne").mockResolvedValue(existingEngagement as any); + projectService.hasBillingAccountAssigned.mockResolvedValue(true); + + const tx = { + engagement: { + update: jest.fn().mockResolvedValue(existingEngagement), + }, + }; + db.$transaction.mockImplementation((callback: any) => callback(tx)); + + await service.update("eng-1", { projectId: "project-1" } as any, { + sub: "123456", + }); + + expect(projectService.hasBillingAccountAssigned).not.toHaveBeenCalled(); + expect(projectService.validateProjectExists).toHaveBeenCalledWith( + "project-1", + ); + expect(tx.engagement.update).toHaveBeenCalled(); + }); + it("does not include assignment details for public engagement listings", async () => { db.engagement.findMany.mockResolvedValue([ { diff --git a/src/engagements/engagements.service.ts b/src/engagements/engagements.service.ts index d624d0a..c724dd4 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -670,6 +670,22 @@ export class EngagementsService { const existingEngagement = await this.findOne(id); if (updateDto.projectId) { + const normalizedCurrentProjectId = this.normalizeProjectId( + existingEngagement.projectId, + ); + const normalizedUpdatedProjectId = this.normalizeProjectId( + updateDto.projectId, + ); + + if ( + normalizedUpdatedProjectId && + normalizedUpdatedProjectId !== normalizedCurrentProjectId + ) { + await this.assertProjectReassignmentAllowed( + existingEngagement.projectId, + ); + } + await this.assertProjectExists(updateDto.projectId); } @@ -1711,6 +1727,29 @@ export class EngagementsService { } } + /** + * Ensures engagement project reassignment is allowed for the current project. + * + * Project reassignment is blocked when the current project already has a + * billing account, because that project is financially bound. + * + * @param currentProjectId Existing project id on the engagement. + * @returns Resolves when the engagement project can be changed. + * @throws BadRequestException When the current project has a billing account. + */ + private async assertProjectReassignmentAllowed( + currentProjectId: string, + ): Promise { + const hasBillingAccount = + await this.projectService.hasBillingAccountAssigned(currentProjectId); + + if (hasBillingAccount) { + throw new BadRequestException( + ERROR_MESSAGES.ProjectChangeBlockedByBillingAccount, + ); + } + } + private async assertSkillsValid(skillIds: string[]): Promise { const { invalid } = await this.skillsService.validateSkillsExist(skillIds); if (invalid.length) { diff --git a/src/integrations/project.service.ts b/src/integrations/project.service.ts index e81c007..37be4d0 100644 --- a/src/integrations/project.service.ts +++ b/src/integrations/project.service.ts @@ -26,6 +26,7 @@ type CachedProjectName = { }; type ProjectResponse = { + billingAccountId?: unknown; id?: string | number | null; invites?: ProjectUser[] | null; members?: ProjectUser[] | null; @@ -79,6 +80,38 @@ export class ProjectService { }; } + /** + * Checks whether a project currently has a billing account assigned. + * + * This is used by engagement updates to block reassignment to a different + * project when the existing project is already linked to billing. + * + * @param projectId Project id being inspected. + * @returns `true` when a non-empty `billingAccountId` exists, otherwise `false`. + * @throws Error Propagates token lookup and project lookup errors (except 404). + */ + async hasBillingAccountAssigned(projectId: string): Promise { + const token = await this.getM2MToken(); + const project = await this.fetchProjectById(projectId, token, [ + "id", + "billingAccountId", + ]); + + if (!project) { + return false; + } + + if (typeof project.billingAccountId === "string") { + return project.billingAccountId.trim().length > 0; + } + + if (typeof project.billingAccountId === "number") { + return Number.isFinite(project.billingAccountId); + } + + return false; + } + async getProjectNamesByIds( projectIds: string[], ): Promise> { From 6be3ac6318eca10cca0517d605414940558a75e3 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 6 Mar 2026 17:11:52 +1100 Subject: [PATCH 06/13] Updates for project filtering --- README.md | 1 + src/engagements/engagements.controller.ts | 6 +- src/engagements/engagements.service.spec.ts | 89 ++++++++++++++ src/engagements/engagements.service.ts | 128 +++++++++++++++++++- src/integrations/project.service.ts | 126 ++++++++++++++++++- 5 files changed, 342 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c4d32bb..a65ca32 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ M2M access uses Auth0 client credentials. Ensure the client is configured with t - Administrators, Topcoder Project Managers, Topcoder Task Managers, and Topcoder Talent Managers can bypass scope checks for most management operations. - Regular members can view engagements and manage their own applications. - Project Managers can view and update application statuses for engagements they created, while Task Managers and Talent Managers can do so across engagements. +- Talent Managers are server-scoped to engagements from projects where they are members when listing engagements. ## Response Notes diff --git a/src/engagements/engagements.controller.ts b/src/engagements/engagements.controller.ts index 0661820..1b64f4d 100644 --- a/src/engagements/engagements.controller.ts +++ b/src/engagements/engagements.controller.ts @@ -138,7 +138,11 @@ export class EngagementsController { if (query.includePrivate || query.status === EngagementStatus.ON_HOLD) { this.assertCanIncludePrivate(req.authUser); } - return this.engagementsService.findAll(query); + return this.engagementsService.findAll( + query, + req.authUser, + req.headers.authorization, + ); } @Get("active") diff --git a/src/engagements/engagements.service.spec.ts b/src/engagements/engagements.service.spec.ts index be56a94..a83b4e8 100644 --- a/src/engagements/engagements.service.spec.ts +++ b/src/engagements/engagements.service.spec.ts @@ -24,6 +24,7 @@ describe("EngagementsService", () => { }; }; let projectService: { + getMemberProjectIdsForUser: jest.Mock; getProjectNamesByIds: jest.Mock; hasBillingAccountAssigned: jest.Mock; validateProjectExists: jest.Mock; @@ -68,6 +69,7 @@ describe("EngagementsService", () => { }, }; projectService = { + getMemberProjectIdsForUser: jest.fn().mockResolvedValue([]), getProjectNamesByIds: jest.fn().mockResolvedValue(new Map()), hasBillingAccountAssigned: jest.fn().mockResolvedValue(false), validateProjectExists: jest.fn().mockResolvedValue(true), @@ -367,6 +369,93 @@ describe("EngagementsService", () => { expect(result.data[0]).toHaveProperty("assignedMemberHandles", ["member1"]); }); + it("scopes TM listings to member projects when no project filter is provided", async () => { + projectService.getMemberProjectIdsForUser.mockResolvedValue([ + "project-2", + "project-3", + ]); + db.engagement.findMany.mockResolvedValue([]); + db.engagement.count.mockResolvedValue(0); + + await service.findAll( + { + includePrivate: true, + page: 1, + perPage: 20, + sortBy: "createdAt", + sortOrder: "desc", + } as any, + { roles: ["Talent Manager"] }, + "Bearer tm-token", + ); + + expect(projectService.getMemberProjectIdsForUser).toHaveBeenCalledWith( + "Bearer tm-token", + ); + expect(db.engagement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + projectId: { in: ["project-2", "project-3"] }, + }), + }), + ); + }); + + it("returns empty listing when TM user has no member projects", async () => { + projectService.getMemberProjectIdsForUser.mockResolvedValue([]); + + const result = await service.findAll( + { + includePrivate: true, + page: 2, + perPage: 10, + sortBy: "createdAt", + sortOrder: "desc", + } as any, + { roles: ["Topcoder Talent Manager"] }, + "Bearer tm-token", + ); + + expect(db.engagement.findMany).not.toHaveBeenCalled(); + expect(db.engagement.count).not.toHaveBeenCalled(); + expect(result).toEqual({ + data: [], + meta: { + page: 2, + perPage: 10, + totalCount: 0, + totalPages: 0, + }, + }); + }); + + it("intersects requested projectIds with TM member projects", async () => { + projectService.getMemberProjectIdsForUser.mockResolvedValue(["project-2"]); + db.engagement.findMany.mockResolvedValue([]); + db.engagement.count.mockResolvedValue(0); + + await service.findAll( + { + includePrivate: true, + projectIds: ["project-1", "project-2", "project-3"], + page: 1, + perPage: 20, + sortBy: "createdAt", + sortOrder: "desc", + } as any, + { roles: ["Talent Manager"] }, + "Bearer tm-token", + ); + + expect(db.engagement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + projectId: { in: ["project-2"] }, + }), + }), + ); + }); + it("hydrates project details in engagement listings", async () => { db.engagement.findMany.mockResolvedValue([ { diff --git a/src/engagements/engagements.service.ts b/src/engagements/engagements.service.ts index c724dd4..f401f5a 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -15,7 +15,11 @@ import { Workload, } from "@prisma/client"; import { nanoid } from "nanoid"; -import { PrivilegedUserRoles } from "../app-constants"; +import { + PrivilegedUserRoles, + TalentManagerRoles, + UserRoles, +} from "../app-constants"; import { DbService } from "../db/db.service"; import { EventBusService } from "../integrations/event-bus.service"; import { MemberService } from "../integrations/member.service"; @@ -62,6 +66,10 @@ export class EngagementsService { private readonly privilegedRoles = new Set( PrivilegedUserRoles.map((role) => role.toLowerCase()), ); + private readonly adminRoles = new Set([UserRoles.Admin.toLowerCase()]); + private readonly talentManagerRoles = new Set( + TalentManagerRoles.map((role) => role.toLowerCase()), + ); constructor( private readonly db: DbService, @@ -318,28 +326,43 @@ export class EngagementsService { * Public/non-includePrivate feeds always exclude ON_HOLD, including explicit status filters. * Supports `projectId` and `projectIds` project filtering. * When both are provided, `projectIds` takes precedence. + * TM users are server-scoped to engagements from projects where they are members. */ async findAll( query: EngagementQueryDto, + authUser?: Record, + authorizationHeader?: string | string[], ): Promise> { + const projectScope = await this.resolveProjectScope( + query, + authUser, + authorizationHeader, + ); + this.logger.debug("Listing engagements", { projectId: query.projectId, projectIds: query.projectIds, + scopedProjectId: projectScope.projectId, + scopedProjectIds: projectScope.projectIds, status: query.status, search: query.search, }); + if (projectScope.isEmpty) { + return this.emptyPaginatedResponse(query.page, query.perPage); + } + const isPublicFeed = query.includePrivate !== true; const where: Prisma.EngagementWhereInput = query.includePrivate ? {} : { isPrivate: false }; const andFilters: Prisma.EngagementWhereInput[] = []; - if (query.projectId) { - where.projectId = query.projectId; + if (projectScope.projectId) { + where.projectId = projectScope.projectId; } - if (query.projectIds?.length) { - where.projectId = { in: query.projectIds }; + if (projectScope.projectIds?.length) { + where.projectId = { in: projectScope.projectIds }; } if (query.status) { @@ -1766,4 +1789,99 @@ export class EngagementsService { return new Date(dateValue); } + + private emptyPaginatedResponse( + page: number, + perPage: number, + ): PaginatedResponse { + return { + data: [], + meta: { + page, + perPage, + totalCount: 0, + totalPages: 0, + }, + }; + } + + private async resolveProjectScope( + query: EngagementQueryDto, + authUser?: Record, + authorizationHeader?: string | string[], + ): Promise<{ + projectId?: string; + projectIds?: string[]; + isEmpty: boolean; + }> { + const normalizedProjectId = this.normalizeProjectId(query.projectId); + const normalizedProjectIds = Array.from( + new Set( + (query.projectIds ?? []) + .map((projectId) => this.normalizeProjectId(projectId)) + .filter((value): value is string => Boolean(value)), + ), + ); + + if (!this.isTalentManagerOnly(authUser)) { + return { + projectId: normalizedProjectId, + projectIds: normalizedProjectIds.length + ? normalizedProjectIds + : undefined, + isEmpty: false, + }; + } + + const memberProjectIds = + await this.projectService.getMemberProjectIdsForUser(authorizationHeader); + if (!memberProjectIds.length) { + return { isEmpty: true }; + } + + const memberProjectIdSet = new Set(memberProjectIds); + + if (normalizedProjectIds.length) { + const scopedProjectIds = normalizedProjectIds.filter((projectId) => + memberProjectIdSet.has(projectId), + ); + if (!scopedProjectIds.length) { + return { isEmpty: true }; + } + + return { projectIds: scopedProjectIds, isEmpty: false }; + } + + if (normalizedProjectId) { + if (!memberProjectIdSet.has(normalizedProjectId)) { + return { isEmpty: true }; + } + + return { projectId: normalizedProjectId, isEmpty: false }; + } + + return { projectIds: memberProjectIds, isEmpty: false }; + } + + private isTalentManagerOnly(authUser?: Record): boolean { + if (!authUser || authUser.isMachine) { + return false; + } + + const normalizedRoles = getUserRoles(authUser).map((role) => + role?.toLowerCase(), + ); + + const hasTalentManagerRole = normalizedRoles.some((role) => + this.talentManagerRoles.has(role), + ); + if (!hasTalentManagerRole) { + return false; + } + + const hasAdminRole = normalizedRoles.some((role) => + this.adminRoles.has(role), + ); + return !hasAdminRole; + } } diff --git a/src/integrations/project.service.ts b/src/integrations/project.service.ts index 37be4d0..26127a9 100644 --- a/src/integrations/project.service.ts +++ b/src/integrations/project.service.ts @@ -176,6 +176,76 @@ export class ProjectService { return projectNamesById; } + /** + * Resolves project IDs where the authenticated user is a member. + * + * Uses the caller's JWT bearer token against the projects API with + * `memberOnly=true` and paginates through all pages. + * + * Returns an empty list when the authorization header is missing/invalid or + * when project lookup fails, to fail closed for permission-sensitive callers. + */ + async getMemberProjectIdsForUser( + authorizationHeader?: string | string[], + ): Promise { + const normalizedAuthorizationHeader = + this.normalizeAuthorizationHeader(authorizationHeader); + if (!normalizedAuthorizationHeader) { + return []; + } + + const projectIds = new Set(); + const perPage = 100; + let page = 1; + let hasMore = true; + + while (hasMore) { + const url = this.getMemberProjectsUrl(page, perPage); + + try { + const response = await firstValueFrom( + this.httpService.get(url, { + headers: { Authorization: normalizedAuthorizationHeader }, + }), + ); + + const projects = Array.isArray(response.data) ? response.data : []; + projects.forEach((project) => { + const projectId = this.normalizeProjectId( + (project as ProjectResponse).id, + ); + if (projectId) { + projectIds.add(projectId); + } + }); + + const totalPages = this.parseNumericHeader( + response.headers?.["x-total-pages"], + ); + if (totalPages && totalPages > 0) { + hasMore = page < totalPages; + } else { + hasMore = projects.length === perPage; + } + page += 1; + } catch (error) { + if (isAxiosError(error)) { + this.logger.warn( + `Failed to fetch member projects for user-scoped engagement filtering (status=${error.response?.status ?? "unknown"}).`, + ); + return []; + } + + this.logger.warn( + "Failed to fetch member projects for user-scoped engagement filtering.", + ); + return []; + } + } + + return Array.from(projectIds); + } + private async getM2MToken(): Promise { const clientId = this.configService.get("M2M_CLIENT_ID"); const clientSecret = this.configService.get("M2M_CLIENT_SECRET"); @@ -190,11 +260,47 @@ export class ProjectService { return (await this.m2m.getMachineToken(clientId, clientSecret)) as string; } - private normalizeProjectId(projectId: string): string | undefined { - const normalizedProjectId = String(projectId || "").trim(); + private normalizeProjectId(projectId: unknown): string | undefined { + if (projectId === undefined || projectId === null) { + return undefined; + } + + if (typeof projectId !== "string" && typeof projectId !== "number") { + return undefined; + } + + const normalizedProjectId = String(projectId).trim(); return normalizedProjectId || undefined; } + private normalizeAuthorizationHeader( + authorizationHeader?: string | string[], + ): string | undefined { + const rawValue = Array.isArray(authorizationHeader) + ? authorizationHeader.find( + (value) => typeof value === "string" && value.trim().length > 0, + ) + : authorizationHeader; + + if (!rawValue || typeof rawValue !== "string") { + return undefined; + } + + const normalized = rawValue.trim(); + if (!normalized) { + return undefined; + } + + return /^Bearer\s+/i.test(normalized) ? normalized : `Bearer ${normalized}`; + } + + private parseNumericHeader(headerValue: unknown): number | undefined { + const rawValue = Array.isArray(headerValue) ? headerValue[0] : headerValue; + const numericValue = + typeof rawValue === "number" ? rawValue : Number(rawValue); + return Number.isFinite(numericValue) ? numericValue : undefined; + } + private normalizeProjectName(projectName: unknown): string | undefined { if (typeof projectName !== "string") { return undefined; @@ -302,4 +408,20 @@ export class ProjectService { return `${normalizedBaseUrl}/v6/projects/${projectId}${query}`; } + + private getMemberProjectsUrl(page: number, perPage: number): string { + const apiBaseUrl = this.configService.get( + "TOPCODER_API_URL_BASE", + "https://api.topcoder-dev.com", + ); + const normalizedBaseUrl = apiBaseUrl.replace(/\/$/, ""); + const query = new URLSearchParams({ + memberOnly: "true", + fields: "id", + page: String(page), + perPage: String(perPage), + }); + + return `${normalizedBaseUrl}/v6/projects?${query.toString()}`; + } } From 52a93cabf25d69aeb51d55efdb30724cbfc0af60 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 6 Mar 2026 17:03:25 +0530 Subject: [PATCH 07/13] PM-4206 Allow applictation for only complete profiles --- .circleci/config.yml | 2 +- src/applications/applications.service.ts | 16 ++++++++++++ src/integrations/member.service.ts | 33 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2ca5f32..c439c46 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -64,7 +64,7 @@ workflows: branches: only: - develop - - pm-1127_1 + - PM-4206 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/src/applications/applications.service.ts b/src/applications/applications.service.ts index d5267e6..d0ee49d 100644 --- a/src/applications/applications.service.ts +++ b/src/applications/applications.service.ts @@ -123,6 +123,22 @@ export class ApplicationsService { throw new NotFoundException(ERROR_MESSAGES.MemberNotFound); } + const memberHandle = await this.memberService.getMemberHandleByUserId( + normalizedUserId, + ); + + if (!memberHandle) { + throw new BadRequestException("Member handle not found."); + } + + const percentComplete = await this.memberService.getMemberProfileCompleteness(memberHandle); + + if (percentComplete !== 1) { + throw new BadRequestException( + "Your profile must be 100% complete before applying.", + ); + } + const memberAddress = await this.memberService.getMemberAddress(normalizedUserId); const formattedAddress = this.formatAddress(memberAddress); diff --git a/src/integrations/member.service.ts b/src/integrations/member.service.ts index 202ca27..16c832b 100644 --- a/src/integrations/member.service.ts +++ b/src/integrations/member.service.ts @@ -173,6 +173,39 @@ export class MemberService { return this.extractAddressFromTraits(traits); } + async getMemberProfileCompleteness(handle: string): Promise { + const token = await this.getM2MToken(); + const baseUrl = this.getMemberApiBaseUrl(); + const url = `${baseUrl}/${encodeURIComponent(handle)}/profileCompleteness`; + + try { + const response = await firstValueFrom( + this.httpService.get(url, { + headers: { Authorization: `Bearer ${token}` }, + }), + ); + + const percentComplete = response.data?.data?.percentComplete; + return percentComplete == null ? null : Number(percentComplete); + } catch (error) { + if (isAxiosError(error)) { + if (error.response?.status === 404) { + return null; + } + + this.logger.error("Member profile completeness lookup failed.", { + status: error.response?.status, + data: error.response?.data, + handle, + }); + throw error; + } + + this.logger.error("Member profile completeness lookup failed.", error); + throw error; + } + } + private extractAddressFromMember(member: MemberRecord): MemberAddress | null { if (!member.addresses?.length) { return null; From ad774f78891cac3468eab798c7a447eca980d3dd Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Fri, 6 Mar 2026 23:50:22 +0100 Subject: [PATCH 08/13] PM-3841 #time 3h reproduce the issue and fix the my assignments having others assignments --- src/engagements/engagements.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/engagements/engagements.service.ts b/src/engagements/engagements.service.ts index f401f5a..2332f61 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -585,7 +585,11 @@ export class EngagementsService { applications: true, }, }, - assignments: true, + assignments: { + where: { + memberId: userIdentifier, + }, + }, }, }), this.db.engagement.count({ where }), From 5ccdafb13f1576c5e1ed2df00b16c498b357f48c Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Fri, 6 Mar 2026 23:59:11 +0100 Subject: [PATCH 09/13] deploy to dev --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2ca5f32..4e09db3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -64,7 +64,7 @@ workflows: branches: only: - develop - - pm-1127_1 + - pm-3841 # Production builds are exectuted only on tagged commits to the # master branch. From 3ff2707b032e195721fb1b29ca97b3d57d5d680a Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 9 Mar 2026 13:40:14 +0530 Subject: [PATCH 10/13] Add debugging logs --- src/applications/applications.service.ts | 10 ++++++++++ src/integrations/member.service.ts | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/applications/applications.service.ts b/src/applications/applications.service.ts index d0ee49d..7fbc1c2 100644 --- a/src/applications/applications.service.ts +++ b/src/applications/applications.service.ts @@ -130,9 +130,19 @@ export class ApplicationsService { if (!memberHandle) { throw new BadRequestException("Member handle not found."); } + + this.logger.debug("[Completeness] Checking profile completeness", { + memberHandle, + userId: normalizedUserId, + }); const percentComplete = await this.memberService.getMemberProfileCompleteness(memberHandle); + this.logger.debug("[Completeness] Profile completeness result", { + memberHandle, + percentComplete, + }); + if (percentComplete !== 1) { throw new BadRequestException( "Your profile must be 100% complete before applying.", diff --git a/src/integrations/member.service.ts b/src/integrations/member.service.ts index 16c832b..0220f79 100644 --- a/src/integrations/member.service.ts +++ b/src/integrations/member.service.ts @@ -178,6 +178,11 @@ export class MemberService { const baseUrl = this.getMemberApiBaseUrl(); const url = `${baseUrl}/${encodeURIComponent(handle)}/profileCompleteness`; + this.logger.debug("[Completeness] Calling Member API for profile completeness", { + handle, + url, + }); + try { const response = await firstValueFrom( this.httpService.get(url, { @@ -186,6 +191,12 @@ export class MemberService { ); const percentComplete = response.data?.data?.percentComplete; + + this.logger.debug("[Completeness] Member API response", { + handle, + percentComplete, + rawResponse: response.data, + }); return percentComplete == null ? null : Number(percentComplete); } catch (error) { if (isAxiosError(error)) { From cd928b669538d753327387c397db9a53a2094ab9 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 9 Mar 2026 14:25:02 +0530 Subject: [PATCH 11/13] More debug logs --- src/integrations/member.service.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/integrations/member.service.ts b/src/integrations/member.service.ts index 0220f79..76c6612 100644 --- a/src/integrations/member.service.ts +++ b/src/integrations/member.service.ts @@ -178,10 +178,9 @@ export class MemberService { const baseUrl = this.getMemberApiBaseUrl(); const url = `${baseUrl}/${encodeURIComponent(handle)}/profileCompleteness`; - this.logger.debug("[Completeness] Calling Member API for profile completeness", { - handle, - url, - }); + this.logger.debug( + `[Completeness] Calling API handle=${handle} url=${url} tokenExists=${!!token}` + ); try { const response = await firstValueFrom( @@ -192,17 +191,18 @@ export class MemberService { const percentComplete = response.data?.data?.percentComplete; - this.logger.debug("[Completeness] Member API response", { - handle, - percentComplete, - rawResponse: response.data, - }); + this.logger.debug( + `[Completeness] Success handle=${handle} percentComplete=${percentComplete}` + ); return percentComplete == null ? null : Number(percentComplete); } catch (error) { if (isAxiosError(error)) { if (error.response?.status === 404) { return null; } + this.logger.error( + `[Completeness] Axios error handle=${handle} url=${url} status=${error.response?.status} data=${JSON.stringify(error.response?.data)}` + ); this.logger.error("Member profile completeness lookup failed.", { status: error.response?.status, From af3e0d17c85daa641ba3f0219f5ce27302e4bdb9 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 9 Mar 2026 15:36:23 +0530 Subject: [PATCH 12/13] Clean up logs --- src/applications/applications.service.ts | 10 ---------- src/integrations/member.service.ts | 11 ----------- 2 files changed, 21 deletions(-) diff --git a/src/applications/applications.service.ts b/src/applications/applications.service.ts index 7fbc1c2..e3924a2 100644 --- a/src/applications/applications.service.ts +++ b/src/applications/applications.service.ts @@ -131,18 +131,8 @@ export class ApplicationsService { throw new BadRequestException("Member handle not found."); } - this.logger.debug("[Completeness] Checking profile completeness", { - memberHandle, - userId: normalizedUserId, - }); - const percentComplete = await this.memberService.getMemberProfileCompleteness(memberHandle); - this.logger.debug("[Completeness] Profile completeness result", { - memberHandle, - percentComplete, - }); - if (percentComplete !== 1) { throw new BadRequestException( "Your profile must be 100% complete before applying.", diff --git a/src/integrations/member.service.ts b/src/integrations/member.service.ts index 76c6612..16c832b 100644 --- a/src/integrations/member.service.ts +++ b/src/integrations/member.service.ts @@ -178,10 +178,6 @@ export class MemberService { const baseUrl = this.getMemberApiBaseUrl(); const url = `${baseUrl}/${encodeURIComponent(handle)}/profileCompleteness`; - this.logger.debug( - `[Completeness] Calling API handle=${handle} url=${url} tokenExists=${!!token}` - ); - try { const response = await firstValueFrom( this.httpService.get(url, { @@ -190,19 +186,12 @@ export class MemberService { ); const percentComplete = response.data?.data?.percentComplete; - - this.logger.debug( - `[Completeness] Success handle=${handle} percentComplete=${percentComplete}` - ); return percentComplete == null ? null : Number(percentComplete); } catch (error) { if (isAxiosError(error)) { if (error.response?.status === 404) { return null; } - this.logger.error( - `[Completeness] Axios error handle=${handle} url=${url} status=${error.response?.status} data=${JSON.stringify(error.response?.data)}` - ); this.logger.error("Member profile completeness lookup failed.", { status: error.response?.status, From 672fd99aad9730b071a1f8a62d15bca580633e4e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 10 Mar 2026 10:23:32 +1100 Subject: [PATCH 13/13] Fix issue with an assignee viewing a private assignment --- README.md | 3 +- docs/AUTHENTICATION.md | 3 +- .../engagement-response.e2e.spec.ts | 168 +++++++++++++++--- src/engagements/engagements.controller.ts | 43 +++-- src/engagements/engagements.service.spec.ts | 52 ++++++ src/engagements/engagements.service.ts | 17 +- 6 files changed, 251 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index a65ca32..ceeddef 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,8 @@ M2M access uses Auth0 client credentials. Ensure the client is configured with t ## Role-Based Access - Administrators, Topcoder Project Managers, Topcoder Task Managers, and Topcoder Talent Managers can bypass scope checks for most management operations. -- Regular members can view engagements and manage their own applications. +- Regular members can view public engagements and manage their own applications. +- Assigned members can view the details of their own private engagements. - Project Managers can view and update application statuses for engagements they created, while Task Managers and Talent Managers can do so across engagements. - Talent Managers are server-scoped to engagements from projects where they are members when listing engagements. diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 17de2c2..f09b5a4 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -27,7 +27,8 @@ M2M tokens are issued via the Auth0 client credentials flow. These tokens do not ## Role-Based Access - Administrators and Topcoder Project Managers can bypass scope checks for most management operations. -- Regular members can view engagements and manage their own applications. +- Regular members can view public engagements and manage their own applications. +- Assigned members can view the details of their own private engagements. - Project Managers can view and update application statuses for engagements they own. ## Code Examples diff --git a/src/engagements/engagement-response.e2e.spec.ts b/src/engagements/engagement-response.e2e.spec.ts index 9ed03f9..5188520 100644 --- a/src/engagements/engagement-response.e2e.spec.ts +++ b/src/engagements/engagement-response.e2e.spec.ts @@ -19,6 +19,16 @@ const tokenFixtures: Record> = { isMachine: true, scopes: ["read:engagements"], }, + "member-assigned": { + userId: "123456", + handle: "testaws1", + roles: [], + }, + "member-unassigned": { + userId: "999999", + handle: "someone_else", + roles: [], + }, }; describe("Engagement Response (e2e)", () => { @@ -33,26 +43,6 @@ describe("Engagement Response (e2e)", () => { }; beforeAll(async () => { - dbServiceMock.engagement.findUnique.mockResolvedValue({ - id: "eng-1", - projectId: "proj-1", - title: "Test engagement", - description: "Test description", - timeZones: ["UTC"], - countries: ["US"], - requiredSkills: ["skill-1"], - anticipatedStart: AnticipatedStart.IMMEDIATE, - status: EngagementStatus.OPEN, - createdAt: new Date("2025-01-01T00:00:00.000Z"), - updatedAt: new Date("2025-01-02T00:00:00.000Z"), - createdBy: "123456", - isPrivate: false, - role: Role.SOFTWARE_DEVELOPER, - workload: Workload.FULL_TIME, - compensationRange: null, - assignments: [], - }); - const moduleFixture = await Test.createTestingModule({ imports: [AppModule], }) @@ -83,6 +73,29 @@ describe("Engagement Response (e2e)", () => { await app.init(); }); + beforeEach(() => { + dbServiceMock.engagement.findUnique.mockReset(); + dbServiceMock.engagement.findUnique.mockResolvedValue({ + id: "eng-1", + projectId: "proj-1", + title: "Test engagement", + description: "Test description", + timeZones: ["UTC"], + countries: ["US"], + requiredSkills: ["skill-1"], + anticipatedStart: AnticipatedStart.IMMEDIATE, + status: EngagementStatus.OPEN, + createdAt: new Date("2025-01-01T00:00:00.000Z"), + updatedAt: new Date("2025-01-02T00:00:00.000Z"), + createdBy: "123456", + isPrivate: false, + role: Role.SOFTWARE_DEVELOPER, + workload: Workload.FULL_TIME, + compensationRange: null, + assignments: [], + }); + }); + afterAll(async () => { await app.close(); }); @@ -102,4 +115,119 @@ describe("Engagement Response (e2e)", () => { expect(response.body.workload).toBe(Workload.FULL_TIME); expect(response.body.compensationRange).toBeNull(); }); + + it("allows an assigned member to view a private engagement", async () => { + const privateEngagement = { + id: "eng-private", + projectId: "proj-1", + title: "Private engagement", + description: "Private description", + timeZones: ["UTC"], + countries: ["US"], + requiredSkills: ["skill-1"], + anticipatedStart: AnticipatedStart.IMMEDIATE, + status: EngagementStatus.OPEN, + createdAt: new Date("2025-01-01T00:00:00.000Z"), + updatedAt: new Date("2025-01-02T00:00:00.000Z"), + createdBy: "654321", + isPrivate: true, + role: Role.SOFTWARE_DEVELOPER, + workload: Workload.FULL_TIME, + compensationRange: null, + assignments: [ + { + id: "assignment-1", + engagementId: "eng-private", + memberId: "123456", + memberHandle: "testaws1", + status: "SELECTED", + agreementRate: "400", + otherRemarks: "remarks", + terminationReason: null, + startDate: null, + endDate: null, + createdAt: new Date("2025-01-01T00:00:00.000Z"), + updatedAt: new Date("2025-01-02T00:00:00.000Z"), + }, + ], + }; + + dbServiceMock.engagement.findUnique + .mockResolvedValueOnce(privateEngagement) + .mockResolvedValueOnce(privateEngagement); + + const response = await request(app.getHttpServer()) + .get("/v6/engagements/engagements/eng-private") + .set("Authorization", "Bearer member-assigned") + .expect(200); + + expect(response.body.isPrivate).toBe(true); + expect(response.body.assignments).toHaveLength(1); + expect(response.body.assignments[0].memberId).toBe("123456"); + expect(dbServiceMock.engagement.findUnique).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + include: { + assignments: { + where: { + memberId: "123456", + }, + }, + }, + }), + ); + }); + + it("rejects an unassigned member when viewing a private engagement", async () => { + const privateEngagement = { + id: "eng-private", + projectId: "proj-1", + title: "Private engagement", + description: "Private description", + timeZones: ["UTC"], + countries: ["US"], + requiredSkills: ["skill-1"], + anticipatedStart: AnticipatedStart.IMMEDIATE, + status: EngagementStatus.OPEN, + createdAt: new Date("2025-01-01T00:00:00.000Z"), + updatedAt: new Date("2025-01-02T00:00:00.000Z"), + createdBy: "654321", + isPrivate: true, + role: Role.SOFTWARE_DEVELOPER, + workload: Workload.FULL_TIME, + compensationRange: null, + assignments: [ + { + id: "assignment-1", + engagementId: "eng-private", + memberId: "123456", + memberHandle: "testaws1", + status: "SELECTED", + agreementRate: "400", + otherRemarks: "remarks", + terminationReason: null, + startDate: null, + endDate: null, + createdAt: new Date("2025-01-01T00:00:00.000Z"), + updatedAt: new Date("2025-01-02T00:00:00.000Z"), + }, + ], + }; + + dbServiceMock.engagement.findUnique + .mockResolvedValueOnce(privateEngagement) + .mockResolvedValueOnce({ + ...privateEngagement, + assignments: [], + }); + + const response = await request(app.getHttpServer()) + .get("/v6/engagements/engagements/eng-private") + .set("Authorization", "Bearer member-unassigned") + .expect(401); + + expect(response.body.message).toBe( + "You are not authorized to access this private engagement.", + ); + }); }); diff --git a/src/engagements/engagements.controller.ts b/src/engagements/engagements.controller.ts index 1b64f4d..bc64e3f 100644 --- a/src/engagements/engagements.controller.ts +++ b/src/engagements/engagements.controller.ts @@ -50,7 +50,7 @@ import { } from "./dto"; import { EngagementsService } from "./engagements.service"; import { Engagement, EngagementStatus } from "@prisma/client"; -import { getUserRoles } from "../common/user.util"; +import { getUserIdentifier, getUserRoles } from "../common/user.util"; @ApiTags("Engagements") @ApiExtraModels( @@ -191,7 +191,8 @@ export class EngagementsController { @ApiOperation({ summary: "Get engagement by ID", description: - "Retrieves a single engagement by ID. Authentication is optional.", + "Retrieves a single engagement by ID. Authentication is optional for public engagements. " + + "Private engagements are limited to privileged users, M2M clients, and assigned members.", }) @ApiResponse({ status: 200, @@ -200,25 +201,45 @@ export class EngagementsController { }) @ApiUnauthorizedResponse({ description: - "Private engagements require administrator, talent manager, or M2M authentication.", + "Private engagements require privileged, assigned-member, or M2M authentication.", }) @ApiNotFoundResponse({ description: "Engagement not found." }) async findOne( @Param("id") id: string, @Req() req: Request & { authUser?: Record }, ): Promise { - const canViewPrivateEngagement = this.canViewAssignmentDetails( - req.authUser, - ); - const engagement = await this.engagementsService.findOne(id, { + const canViewAllAssignments = this.canViewAssignmentDetails(req.authUser); + const viewerId = + req.authUser && !req.authUser.isMachine + ? getUserIdentifier(req.authUser) + : undefined; + + let engagement = await this.engagementsService.findOne(id, { includeCreatorEmail: true, - includeAssignments: canViewPrivateEngagement, + includeAssignments: canViewAllAssignments, }); - if (engagement.isPrivate && !canViewPrivateEngagement) { - throw new UnauthorizedException( - "You are not authorized to access this private engagement.", + if (engagement.isPrivate && !canViewAllAssignments) { + if (!req.authUser || !viewerId) { + throw new UnauthorizedException( + "You are not authorized to access this private engagement.", + ); + } + + engagement = await this.engagementsService.findOne(id, { + includeCreatorEmail: true, + includeAssignments: true, + assignmentMemberId: viewerId, + }); + + const isAssignedMember = engagement.assignments?.some( + (assignment) => assignment.memberId === viewerId, ); + if (!isAssignedMember) { + throw new UnauthorizedException( + "You are not authorized to access this private engagement.", + ); + } } return engagement; diff --git a/src/engagements/engagements.service.spec.ts b/src/engagements/engagements.service.spec.ts index a83b4e8..5ae6395 100644 --- a/src/engagements/engagements.service.spec.ts +++ b/src/engagements/engagements.service.spec.ts @@ -302,6 +302,58 @@ describe("EngagementsService", () => { expect(result.data[0]).not.toHaveProperty("assignedMemberHandles"); }); + it("filters assignments when loading an engagement for an assigned member", async () => { + db.engagement.findUnique.mockResolvedValue({ + id: "eng-1", + projectId: "project-1", + title: "Private engagement", + description: "Private description", + timeZones: ["UTC"], + countries: ["US"], + requiredSkills: ["skill-1"], + anticipatedStart: "IMMEDIATE", + status: EngagementStatus.OPEN, + createdAt: new Date("2026-02-11T10:00:00.000Z"), + updatedAt: new Date("2026-02-11T10:00:00.000Z"), + createdBy: "123456", + isPrivate: true, + assignments: [ + { + id: "assignment-1", + engagementId: "eng-1", + memberId: "123456", + memberHandle: "testaws1", + status: AssignmentStatus.SELECTED, + createdAt: new Date("2026-02-11T11:00:00.000Z"), + updatedAt: new Date("2026-02-11T11:00:00.000Z"), + agreementRate: "80", + otherRemarks: "Confidential terms", + startDate: new Date("2026-02-12T00:00:00.000Z"), + endDate: new Date("2026-03-12T00:00:00.000Z"), + terminationReason: null, + }, + ], + }); + + const result = await service.findOne("eng-1", { + includeAssignments: true, + assignmentMemberId: "123456", + }); + + expect(db.engagement.findUnique).toHaveBeenCalledWith({ + where: { id: "eng-1" }, + include: { + assignments: { + where: { + memberId: "123456", + }, + }, + }, + }); + expect(result.assignments).toHaveLength(1); + expect(result.assignments?.[0].memberId).toBe("123456"); + }); + 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 f401f5a..eda0b79 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -60,6 +60,10 @@ type EngagementProjectReference = { name?: string; }; +type EngagementDetail = Engagement & { + assignments?: EngagementAssignment[]; +}; + @Injectable() export class EngagementsService { private readonly logger = new Logger(EngagementsService.name); @@ -618,11 +622,20 @@ export class EngagementsService { options: { includeCreatorEmail?: boolean; includeAssignments?: boolean; + assignmentMemberId?: string; } = {}, - ): Promise { + ): Promise { const engagement = await this.db.engagement.findUnique({ where: { id }, - include: { assignments: true }, + include: { + assignments: options.assignmentMemberId + ? { + where: { + memberId: options.assignmentMemberId, + }, + } + : true, + }, }); if (!engagement) { throw new NotFoundException("Engagement not found.");