diff --git a/.circleci/config.yml b/.circleci/config.yml index 3f19e2c..f1b6957 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,7 @@ workflows: only: - develop - PM-4669_payment-cycles - - v9-engagements + - PM-5906 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/src/applications/applications.service.spec.ts b/src/applications/applications.service.spec.ts index 4beeafe..c745635 100644 --- a/src/applications/applications.service.spec.ts +++ b/src/applications/applications.service.spec.ts @@ -293,6 +293,7 @@ describe("ApplicationsService", () => { }; const engagement = { id: "eng-1", + createdBy: "654321", requiredMemberCount: 3, requiredSkills: ["skill-1"], }; @@ -337,6 +338,15 @@ describe("ApplicationsService", () => { skills: [{ id: "skill-1" }], }, ); + expect( + assignmentOfferEmailService.sendAssignmentOfferEmail, + ).toHaveBeenCalledWith( + expect.objectContaining({ + assignmentId: "assign-1", + createdBy: "654321", + memberId: "123", + }), + ); }); it("terminates active assignment when selected application is moved to submitted", async () => { diff --git a/src/applications/applications.service.ts b/src/applications/applications.service.ts index 35958b2..9812f65 100644 --- a/src/applications/applications.service.ts +++ b/src/applications/applications.service.ts @@ -689,6 +689,7 @@ export class ApplicationsService { assignmentId, engagementId: engagement.id, engagementTitle: engagement.title, + createdBy: engagement.createdBy, assignmentStartDate: assignmentResult.assignment?.startDate ?? null, durationMonths: assignmentResult.assignment?.durationMonths ?? null, paymentCycle: assignmentResult.assignment?.paymentCycle ?? null, diff --git a/src/engagements/engagements.service.spec.ts b/src/engagements/engagements.service.spec.ts index 898b012..a67adac 100644 --- a/src/engagements/engagements.service.spec.ts +++ b/src/engagements/engagements.service.spec.ts @@ -534,6 +534,7 @@ describe("EngagementsService", () => { id: "eng-1", title: "Original engagement", isPrivate: true, + createdBy: "654321", requiredMemberCount: 1, requiredSkills: ["skill-1", "skill-2"], assignments: [ @@ -628,6 +629,7 @@ describe("EngagementsService", () => { ).toHaveBeenCalledWith([ expect.objectContaining({ assignmentId: "assignment-selected", + createdBy: "654321", memberId: "123456", }), ]); @@ -657,6 +659,7 @@ describe("EngagementsService", () => { id: "eng-1", title: "Original engagement", isPrivate: true, + createdBy: "654321", requiredMemberCount: 1, requiredSkills: ["skill-1"], assignments: [existingAssignment], @@ -734,6 +737,7 @@ describe("EngagementsService", () => { ).toHaveBeenCalledWith([ expect.objectContaining({ assignmentId: "assignment-selected", + createdBy: "654321", memberId: "123456", }), ]); diff --git a/src/engagements/engagements.service.ts b/src/engagements/engagements.service.ts index f80b111..69de7f1 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -560,6 +560,7 @@ export class EngagementsService { assignmentId: assignment.id, engagementId: assignment.engagementId, engagementTitle: engagement.title, + createdBy: engagement.createdBy, assignmentStartDate: assignment.startDate ?? null, durationMonths: assignment.durationMonths ?? null, paymentCycle: assignment.paymentCycle ?? DEFAULT_PAYMENT_CYCLE, diff --git a/src/integrations/assignment-offer-email.service.spec.ts b/src/integrations/assignment-offer-email.service.spec.ts index f090404..1015930 100644 --- a/src/integrations/assignment-offer-email.service.spec.ts +++ b/src/integrations/assignment-offer-email.service.spec.ts @@ -111,4 +111,134 @@ describe("AssignmentOfferEmailService", () => { version: "v3", }); }); + + it("CCs the engagement creator on offer emails", async () => { + memberService.getMemberByUserId.mockImplementation( + async (userId: string) => { + if (userId === "99999") { + return { + email: "creator@example.com", + firstName: "Chris", + lastName: "Creator", + }; + } + + return { + email: "member@example.com", + firstName: "Jane", + lastName: "Doe", + }; + }, + ); + + await service.sendAssignmentOfferEmail({ + memberId: "12345", + createdBy: "99999", + engagementTitle: "Senior Designer", + }); + + expect(memberService.getMemberByUserId).toHaveBeenCalledWith("99999"); + expect(eventBusService.postEvent).toHaveBeenCalledWith( + "external.action.email", + expect.objectContaining({ + recipients: ["member@example.com"], + cc: ["creator@example.com"], + sendgrid_template_id: "offer-template", + }), + ); + }); + + it("does not CC the engagement creator on assignment update emails", async () => { + memberService.getMemberByUserId.mockImplementation( + async (userId: string) => { + if (userId === "99999") { + return { + email: "creator@example.com", + firstName: "Chris", + lastName: "Creator", + }; + } + + return { + email: "member@example.com", + firstName: "Jane", + lastName: "Doe", + }; + }, + ); + + await service.sendAssignmentUpdatedEmail({ + memberId: "12345", + createdBy: "99999", + engagementTitle: "Senior Designer", + }); + + const payload = eventBusService.postEvent.mock.calls[0][1]; + expect(payload.cc).toBeUndefined(); + expect(memberService.getMemberByUserId).toHaveBeenCalledTimes(1); + expect(memberService.getMemberByUserId).toHaveBeenCalledWith("12345"); + }); + + it("omits CC when the creator user ID is not numeric", async () => { + await service.sendAssignmentOfferEmail({ + memberId: "12345", + createdBy: "system", + engagementTitle: "Senior Designer", + }); + + const payload = eventBusService.postEvent.mock.calls[0][1]; + expect(payload.cc).toBeUndefined(); + expect(memberService.getMemberByUserId).toHaveBeenCalledTimes(1); + expect(memberService.getMemberByUserId).toHaveBeenCalledWith("12345"); + }); + + it("omits CC when the creator email matches the member email", async () => { + memberService.getMemberByUserId.mockResolvedValue({ + email: "member@example.com", + firstName: "Jane", + lastName: "Doe", + }); + + await service.sendAssignmentOfferEmail({ + memberId: "12345", + createdBy: "99999", + engagementTitle: "Senior Designer", + }); + + const payload = eventBusService.postEvent.mock.calls[0][1]; + expect(payload.recipients).toEqual(["member@example.com"]); + expect(payload.cc).toBeUndefined(); + }); + + it("still sends the offer email when creator lookup fails", async () => { + memberService.getMemberByUserId.mockImplementation( + async (userId: string) => { + if (userId === "99999") { + throw new Error("member api unavailable"); + } + + return { + email: "member@example.com", + firstName: "Jane", + lastName: "Doe", + }; + }, + ); + + await service.sendAssignmentOfferEmail({ + memberId: "12345", + createdBy: "99999", + engagementTitle: "Senior Designer", + }); + + expect(eventBusService.postEvent).toHaveBeenCalledWith( + "external.action.email", + expect.objectContaining({ + recipients: ["member@example.com"], + sendgrid_template_id: "offer-template", + }), + ); + const payload = eventBusService.postEvent.mock.calls[0][1]; + expect(payload.cc).toBeUndefined(); + }); }); diff --git a/src/integrations/assignment-offer-email.service.ts b/src/integrations/assignment-offer-email.service.ts index d3309e4..97ce9cd 100644 --- a/src/integrations/assignment-offer-email.service.ts +++ b/src/integrations/assignment-offer-email.service.ts @@ -10,6 +10,7 @@ export type AssignmentOfferRecipient = { assignmentId?: string | null; engagementId?: string | null; engagementTitle?: string | null; + createdBy?: string | null; assignmentStartDate?: Date | string | null; assignmentEndDate?: Date | string | null; durationMonths?: number | null; @@ -177,6 +178,11 @@ export class AssignmentOfferEmailService { } } + const cc = + payloadType === "offer" + ? await this.resolveCreatorCcEmails(recipient.createdBy, email) + : []; + const payload = { data: payloadType === "offer" @@ -188,6 +194,7 @@ export class AssignmentOfferEmailService { handle, ), recipients: [email], + ...(cc.length ? { cc } : {}), sendgrid_template_id: templateId, version: "v3", }; @@ -205,6 +212,52 @@ export class AssignmentOfferEmailService { } } + /** + * Resolves the engagement creator's email for the offer-email CC field. + * + * @param createdBy - Engagement creator user ID stored on the engagement. + * @param memberEmail - Member recipient email, used to avoid duplicating the + * To address in CC. + * @returns A one-item CC list when the creator email can be resolved, + * otherwise an empty list. + */ + private async resolveCreatorCcEmails( + createdBy?: string | null, + memberEmail?: string | null, + ): Promise { + const creatorUserId = String(createdBy ?? "").trim(); + if (!creatorUserId || !/^\d+$/.test(creatorUserId)) { + return []; + } + + try { + const creatorDetails = + await this.memberService.getMemberByUserId(creatorUserId); + const creatorEmail = creatorDetails?.email?.trim() ?? ""; + if (!creatorEmail) { + this.logger.warn( + `Engagement creator CC skipped: no email found for user ${creatorUserId}.`, + ); + return []; + } + + if ( + memberEmail && + creatorEmail.toLowerCase() === memberEmail.trim().toLowerCase() + ) { + return []; + } + + return [creatorEmail]; + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + this.logger.error( + `Failed to resolve engagement creator email for offer CC (createdBy=${creatorUserId}): ${message}`, + ); + return []; + } + } + private buildEngagementUrl(): string { const baseUrl = this.configService.get("TOPCODER_API_URL_BASE") ??