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
104 changes: 104 additions & 0 deletions src/integrations/assignment-offer-response-email.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {
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<string, string>([
["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",
}),
],
]);
});
});
75 changes: 53 additions & 22 deletions src/integrations/assignment-offer-response-email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ type AssignmentOfferResponseParams = {
type ProjectUser = {
userId?: string | number | null;
email?: string | null;
role?: string | null;
};

type ProjectUsers = {
members?: ProjectUser[] | null;
invites?: ProjectUser[] | null;
};

const TALENT_MANAGER_PROJECT_ROLE = "manager";

@Injectable()
export class AssignmentOfferResponseEmailService {
private readonly logger = new Logger(
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand All @@ -133,10 +153,12 @@ export class AssignmentOfferResponseEmailService {
private async resolveRecipientEmails(
projectUsers: ProjectUsers,
): Promise<string[]> {
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)
Expand All @@ -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)
Expand All @@ -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;
Expand All @@ -191,24 +222,24 @@ 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",
};

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}`,
);
}
}
Expand Down
Loading