From 30c22d3130af4b8cc2f149828dca2d53b6a15d1b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 27 Mar 2026 08:54:51 +1100 Subject: [PATCH] Tighten up who gets accepted / rejected emails --- ...nment-offer-response-email.service.spec.ts | 104 ++++++++++++++++++ ...assignment-offer-response-email.service.ts | 75 +++++++++---- 2 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 src/integrations/assignment-offer-response-email.service.spec.ts diff --git a/src/integrations/assignment-offer-response-email.service.spec.ts b/src/integrations/assignment-offer-response-email.service.spec.ts new file mode 100644 index 0000000..3994b31 --- /dev/null +++ b/src/integrations/assignment-offer-response-email.service.spec.ts @@ -0,0 +1,104 @@ +import { AssignmentOfferResponseEmailService } from "./assignment-offer-response-email.service"; + +describe("AssignmentOfferResponseEmailService", () => { + let service: AssignmentOfferResponseEmailService; + let projectService: { + getProjectUsers: jest.Mock; + }; + let memberService: { + getMemberByUserId: jest.Mock; + getMemberEmailsByUserIds: jest.Mock; + getMemberHandleByUserId: jest.Mock; + }; + let eventBusService: { + postEvent: jest.Mock; + }; + let configService: { + get: jest.Mock; + }; + + beforeEach(() => { + projectService = { + getProjectUsers: jest.fn().mockResolvedValue({ + members: [], + invites: [], + }), + }; + memberService = { + getMemberByUserId: jest.fn().mockResolvedValue({ + email: "assigned-member@example.com", + }), + getMemberEmailsByUserIds: jest.fn().mockResolvedValue(new Map()), + getMemberHandleByUserId: jest.fn().mockResolvedValue("assigned-member"), + }; + eventBusService = { + postEvent: jest.fn().mockResolvedValue(undefined), + }; + configService = { + get: jest.fn((key: string) => { + const values: Record = { + SENDGRID_ASSIGNMENT_OFFER_ACCEPTED_TEMPLATE_ID: "accepted-template", + SENDGRID_ASSIGNMENT_OFFER_REJECTED_TEMPLATE_ID: "rejected-template", + }; + return values[key]; + }), + }; + + service = new AssignmentOfferResponseEmailService( + projectService as any, + memberService as any, + eventBusService as any, + configService as any, + ); + }); + + it("sends rejection emails only to project members with the manager role", async () => { + projectService.getProjectUsers.mockResolvedValue({ + members: [ + { userId: "manager-1", role: "manager" }, + { userId: "customer-1", role: "customer" }, + { userId: "copilot-1", role: "copilot" }, + ], + invites: [ + { userId: "manager-2", role: "manager" }, + { email: "customer-invite@example.com", role: "customer" }, + { email: "manager-invite@example.com", role: "manager" }, + ], + }); + memberService.getMemberEmailsByUserIds.mockResolvedValue( + new Map([ + ["manager-1", "manager-1@example.com"], + ["manager-2", "manager-2@example.com"], + ["customer-1", "customer-1@example.com"], + ["copilot-1", "copilot-1@example.com"], + ]), + ); + + await service.sendAssignmentOfferResponseEmails({ + projectId: "project-123", + assignmentMemberId: "member-123", + accepted: false, + engagementId: "engagement-123", + engagementTitle: "Senior Designer", + }); + + expect(memberService.getMemberEmailsByUserIds).toHaveBeenCalledWith([ + "manager-1", + ]); + expect(memberService.getMemberByUserId).toHaveBeenCalledWith("member-123"); + expect(eventBusService.postEvent).toHaveBeenCalledTimes(1); + expect(eventBusService.postEvent.mock.calls).toEqual([ + [ + "external.action.email", + expect.objectContaining({ + data: expect.objectContaining({ + handle: "assigned-member", + email: "assigned-member@example.com", + }), + recipients: ["manager-1@example.com"], + sendgrid_template_id: "rejected-template", + }), + ], + ]); + }); +}); diff --git a/src/integrations/assignment-offer-response-email.service.ts b/src/integrations/assignment-offer-response-email.service.ts index d684f9a..8a8f1f2 100644 --- a/src/integrations/assignment-offer-response-email.service.ts +++ b/src/integrations/assignment-offer-response-email.service.ts @@ -16,6 +16,7 @@ type AssignmentOfferResponseParams = { type ProjectUser = { userId?: string | number | null; email?: string | null; + role?: string | null; }; type ProjectUsers = { @@ -23,6 +24,8 @@ type ProjectUsers = { invites?: ProjectUser[] | null; }; +const TALENT_MANAGER_PROJECT_ROLE = "manager"; + @Injectable() export class AssignmentOfferResponseEmailService { private readonly logger = new Logger( @@ -60,8 +63,24 @@ export class AssignmentOfferResponseEmailService { } let handle = params.assignmentMemberHandle?.trim(); + let memberEmail = ""; + const memberId = params.assignmentMemberId?.trim(); + + if (memberId) { + try { + const memberDetails = + await this.memberService.getMemberByUserId(memberId); + memberEmail = memberDetails?.email?.trim() ?? ""; + } catch (error) { + const message = + error instanceof Error ? error.message : "unknown error"; + this.logger.error( + `Failed to resolve email for assignment offer response email (memberId=${memberId}): ${message}`, + ); + } + } + if (!handle) { - const memberId = params.assignmentMemberId?.trim(); if (memberId) { try { handle = @@ -116,9 +135,10 @@ export class AssignmentOfferResponseEmailService { const payloadHandle = handle ?? ""; await Promise.all( - recipientEmails.map((email) => + recipientEmails.map((recipientEmail) => this.sendAssignmentOfferResponseEmail({ - email, + recipientEmail, + memberEmail, handle: payloadHandle, templateId, decisionLabel, @@ -133,10 +153,12 @@ export class AssignmentOfferResponseEmailService { private async resolveRecipientEmails( projectUsers: ProjectUsers, ): Promise { - const memberUserIds = this.collectUserIds(projectUsers.members ?? []); - const inviteUserIds = this.collectUserIds(projectUsers.invites ?? []); - const inviteEmails = this.collectEmails(projectUsers.invites ?? []); - const userIds = Array.from(new Set([...memberUserIds, ...inviteUserIds])); + const talentManagerMembers = (projectUsers.members ?? []).filter((user) => + this.isTalentManagerProjectUser(user), + ); + const memberUserIds = this.collectUserIds(talentManagerMembers); + const userIds = Array.from(new Set(memberUserIds)); + const requestedUserIds = new Set(userIds); const emailByUserId = userIds.length ? await this.memberService.getMemberEmailsByUserIds(userIds) @@ -154,12 +176,28 @@ export class AssignmentOfferResponseEmailService { } }; - inviteEmails.forEach(addEmail); - emailByUserId.forEach((email) => addEmail(email)); + emailByUserId.forEach((email, userId) => { + if (requestedUserIds.has(String(userId).trim())) { + addEmail(email); + } + }); return Array.from(emailSet.values()); } + /** + * Limits notification recipients to project members that hold the manager + * role, which is how projects-api represents Talent Managers associated with + * a project. + */ + private isTalentManagerProjectUser(user: ProjectUser): boolean { + return ( + String(user.role ?? "") + .trim() + .toLowerCase() === TALENT_MANAGER_PROJECT_ROLE + ); + } + private collectUserIds(users: ProjectUser[]): string[] { return users .map((user) => user.userId) @@ -171,16 +209,9 @@ export class AssignmentOfferResponseEmailService { .filter((userId) => Boolean(userId)); } - private collectEmails(users: ProjectUser[]): string[] { - return users - .map((user) => user.email) - .filter((email): email is string => Boolean(email)) - .map((email) => email.trim()) - .filter((email) => Boolean(email)); - } - private async sendAssignmentOfferResponseEmail(params: { - email: string; + recipientEmail: string; + memberEmail: string; handle: string; templateId: string; decisionLabel: string; @@ -191,11 +222,11 @@ export class AssignmentOfferResponseEmailService { const payload = { data: { handle: params.handle, - email: params.email, + email: params.memberEmail, engagementId: params.engagementId ?? "", engagementTitle: params.engagementTitle ?? "", }, - recipients: [params.email], + recipients: [params.recipientEmail], sendgrid_template_id: params.templateId, version: "v3", }; @@ -203,12 +234,12 @@ export class AssignmentOfferResponseEmailService { try { await this.eventBusService.postEvent("external.action.email", payload); this.logger.log( - `Published 'external.action.email' (assignment offer ${params.decisionLabel}) for project ${params.projectId} to ${params.email}.`, + `Published 'external.action.email' (assignment offer ${params.decisionLabel}) for project ${params.projectId} to ${params.recipientEmail}.`, ); } catch (error) { const message = error instanceof Error ? error.message : "unknown error"; this.logger.error( - `Failed to publish assignment offer ${params.decisionLabel} email for project ${params.projectId} to ${params.email}: ${message}`, + `Failed to publish assignment offer ${params.decisionLabel} email for project ${params.projectId} to ${params.recipientEmail}: ${message}`, ); } }