From 18144fd7622b20a9db271b7357f3107d1ca080c7 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 9 Jun 2026 16:38:12 -0400 Subject: [PATCH] fix(people): respect "Send portal invite email" opt-out when adding users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When adding a user via "+ Add User" on the People page, unchecking "Send portal invite email" did not suppress the email. The frontend and DTO correctly passed sendPortalEmail through, but addEmployeeWithoutInvite (taken for strictly-employee roles) still emailed the user in its else branch — sending an InviteEmail with a portal link instead of nothing. Now the portal invite email is sent only when requested. When opted out, the member is added silently. The caller also stops surfacing emailSent on an intentional skip, so the UI's "invite email could not be sent" warning no longer fires when the admin deliberately suppressed the email. Updates two existing specs that asserted the old always-send behavior and adds a regression test for the employee opt-out path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/people/people-invite.service.spec.ts | 52 ++++++++++++++++++- apps/api/src/people/people-invite.service.ts | 35 ++++++------- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/apps/api/src/people/people-invite.service.spec.ts b/apps/api/src/people/people-invite.service.spec.ts index 05e5cd77c8..c35c4bf780 100644 --- a/apps/api/src/people/people-invite.service.spec.ts +++ b/apps/api/src/people/people-invite.service.spec.ts @@ -309,7 +309,11 @@ describe('PeopleInviteService', () => { }); expect(results[0].success).toBe(true); - expect(results[0].emailSent).toBe(true); + // No portal invite was requested (sendPortalEmail omitted = false), so no + // email is sent and emailSent is not surfaced (the UI only warns on an + // actual send failure, never on an intentional skip). + expect(mockTriggerEmail).not.toHaveBeenCalled(); + expect(results[0].emailSent).toBeUndefined(); expect(mockDb.member.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ @@ -437,9 +441,13 @@ describe('PeopleInviteService', () => { const results = await service.inviteMembers({ ...baseParams, - invites: [{ email: 'emp@example.com', roles: ['employee'] }], + invites: [ + { email: 'emp@example.com', roles: ['employee'], sendPortalEmail: true }, + ], }); + // A portal email was requested but the send failed — the member is still + // added and emailSent: false signals the UI to offer a resend. expect(results[0].success).toBe(true); expect(results[0].emailSent).toBe(false); }); @@ -545,6 +553,46 @@ describe('PeopleInviteService', () => { expect(mockInviteEmail).not.toHaveBeenCalled(); }); + // Regression: unchecking "Send portal invite email" when adding an + // employee via "+ Add User" must add the member WITHOUT emailing them. + // Previously the else-branch still sent an InviteEmail with a portal link. + it('employee only with portal UNchecked: adds member silently, sends no email', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + name: 'Test Org', + }); + (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.user.create as jest.Mock).mockResolvedValue({ + id: 'usr_emp', + email: 'emp@example.com', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.member.create as jest.Mock).mockResolvedValue({ id: 'mem_emp' }); + ( + mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock + ).mockResolvedValue({ count: 5 }); + + const results = await service.inviteMembers({ + ...baseParams, + invites: [ + { + email: 'emp@example.com', + roles: ['employee'], + sendPortalEmail: false, + }, + ], + }); + + // Member is still created... + expect(results[0].success).toBe(true); + expect(mockDb.member.create).toHaveBeenCalled(); + // ...but NO email of any kind goes out when the portal invite is off. + expect(mockTriggerEmail).not.toHaveBeenCalled(); + expect(mockInvitePortalEmail).not.toHaveBeenCalled(); + expect(mockInviteEmail).not.toHaveBeenCalled(); + // And no false "could not be sent" warning leaks to the UI. + expect(results[0].emailSent).toBeUndefined(); + }); + it('admin only (no portal): sends app email without portal link', async () => { setupNewUserInvite(); diff --git a/apps/api/src/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts index 094b91a1db..0ed1f2586a 100644 --- a/apps/api/src/people/people-invite.service.ts +++ b/apps/api/src/people/people-invite.service.ts @@ -106,7 +106,10 @@ export class PeopleInviteService { results.push({ email: invite.email, success: true, - emailSent: result.emailSent, + // Only surface email status when we actually attempted to send, so + // the UI's "invite email could not be sent" warning never fires for + // an intentional skip (portal invite unchecked). + ...(shouldSendPortalEmail ? { emailSent: result.emailSent } : {}), }); } else { await this.inviteWithCheck({ @@ -208,10 +211,12 @@ export class PeopleInviteService { await this.createTrainingVideoEntries(member.id, organizationId); } - // Send invite email (non-fatal) - let emailSent = true; - try { - if (sendPortalEmail) { + // Send the portal invite email only when requested (non-fatal). When the + // admin opts out ("Send portal invite email" unchecked) we add the member + // silently and send no email at all. + let emailSent = false; + if (sendPortalEmail) { + try { const inviteLink = this.buildPortalUrl(organizationId); await triggerEmail({ to: email, @@ -222,20 +227,14 @@ export class PeopleInviteService { email, }), }); - } else { - const inviteLink = this.buildPortalUrl(organizationId); - await triggerEmail({ - to: email, - subject: `You've been invited to join ${organization.name} on Comp AI`, - react: InviteEmail({ organizationName: organization.name, inviteLink }), - }); + emailSent = true; + } catch (emailErr) { + emailSent = false; + this.logger.error( + `Portal invite email failed after member was added: ${email}`, + emailErr instanceof Error ? emailErr.message : 'Unknown error', + ); } - } catch (emailErr) { - emailSent = false; - this.logger.error( - `Invite email failed after member was added: ${email}`, - emailErr instanceof Error ? emailErr.message : 'Unknown error', - ); } return { emailSent };