Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/applications/applications.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ describe("ApplicationsService", () => {
};
const engagement = {
id: "eng-1",
createdBy: "654321",
requiredMemberCount: 3,
requiredSkills: ["skill-1"],
};
Expand Down Expand Up @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions src/applications/applications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/engagements/engagements.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ describe("EngagementsService", () => {
id: "eng-1",
title: "Original engagement",
isPrivate: true,
createdBy: "654321",
requiredMemberCount: 1,
requiredSkills: ["skill-1", "skill-2"],
assignments: [
Expand Down Expand Up @@ -628,6 +629,7 @@ describe("EngagementsService", () => {
).toHaveBeenCalledWith([
expect.objectContaining({
assignmentId: "assignment-selected",
createdBy: "654321",
memberId: "123456",
}),
]);
Expand Down Expand Up @@ -657,6 +659,7 @@ describe("EngagementsService", () => {
id: "eng-1",
title: "Original engagement",
isPrivate: true,
createdBy: "654321",
requiredMemberCount: 1,
requiredSkills: ["skill-1"],
assignments: [existingAssignment],
Expand Down Expand Up @@ -734,6 +737,7 @@ describe("EngagementsService", () => {
).toHaveBeenCalledWith([
expect.objectContaining({
assignmentId: "assignment-selected",
createdBy: "654321",
memberId: "123456",
}),
]);
Expand Down
1 change: 1 addition & 0 deletions src/engagements/engagements.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
130 changes: 130 additions & 0 deletions src/integrations/assignment-offer-email.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
53 changes: 53 additions & 0 deletions src/integrations/assignment-offer-email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -177,6 +178,11 @@ export class AssignmentOfferEmailService {
}
}

const cc =
payloadType === "offer"
? await this.resolveCreatorCcEmails(recipient.createdBy, email)
: [];

const payload = {
data:
payloadType === "offer"
Expand All @@ -188,6 +194,7 @@ export class AssignmentOfferEmailService {
handle,
),
recipients: [email],
...(cc.length ? { cc } : {}),
sendgrid_template_id: templateId,
version: "v3",
};
Expand All @@ -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<string[]> {
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<string>("TOPCODER_API_URL_BASE") ??
Expand Down