Skip to content

Commit 062b83f

Browse files
authored
Merge pull request #2859 from trycompai/main
[comp] Production Deploy
2 parents 1433aab + 9b052f5 commit 062b83f

6 files changed

Lines changed: 463 additions & 228 deletions

File tree

‎apps/api/src/email/templates/invite-member.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ interface Props {
1818
organizationName: string;
1919
inviteLink: string;
2020
email?: string;
21+
portalLink?: string;
2122
}
2223

23-
export const InviteEmail = ({ organizationName, inviteLink, email }: Props) => {
24+
export const InviteEmail = ({ organizationName, inviteLink, email, portalLink }: Props) => {
2425
return (
2526
<Html>
2627
<Tailwind>
@@ -69,6 +70,21 @@ export const InviteEmail = ({ organizationName, inviteLink, email }: Props) => {
6970
</Link>
7071
</Text>
7172

73+
{portalLink && (
74+
<>
75+
<Text className="text-[14px] leading-[24px] text-[#121212] mt-[24px]">
76+
You also have access to the <strong>{organizationName} Employee Portal</strong> for
77+
completing compliance tasks like signing policies and security training.
78+
Once you've accepted your invite above, you can access the portal at:
79+
</Text>
80+
<Text className="text-[14px] leading-[24px] break-all text-[#707070]">
81+
<Link href={portalLink} className="text-[#707070] underline">
82+
{portalLink}
83+
</Link>
84+
</Text>
85+
</>
86+
)}
87+
7288
<br />
7389
{email && (
7490
<Section>

‎apps/api/src/people/people-invite.service.spec.ts‎

Lines changed: 228 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import { Test, TestingModule } from '@nestjs/testing';
2-
import { BadRequestException, ForbiddenException } from '@nestjs/common';
32
import { PeopleInviteService } from './people-invite.service';
43
import { TimelinesService } from '../timelines/timelines.service';
54

65
jest.mock('@db', () => ({
6+
BackgroundCheckStatus: {
7+
invited: 'invited',
8+
in_progress: 'in_progress',
9+
in_review: 'in_review',
10+
completed: 'completed',
11+
completed_with_flags: 'completed_with_flags',
12+
failed: 'failed',
13+
cancelled: 'cancelled',
14+
},
715
db: {
816
organization: {
917
findUnique: jest.fn(),
@@ -23,15 +31,85 @@ jest.mock('@db', () => ({
2331
employeeTrainingVideoCompletion: {
2432
createMany: jest.fn(),
2533
},
34+
frameworkInstance: {
35+
findFirst: jest.fn(),
36+
},
37+
organizationRole: {
38+
findMany: jest.fn().mockResolvedValue([]),
39+
},
40+
},
41+
}));
42+
43+
jest.mock('@trycompai/auth', () => ({
44+
BUILT_IN_ROLE_PERMISSIONS: {
45+
owner: {
46+
organization: ['read', 'update', 'delete'],
47+
member: ['create', 'read', 'update', 'delete'],
48+
app: ['read'],
49+
},
50+
admin: {
51+
organization: ['read', 'update'],
52+
member: ['create', 'read', 'update', 'delete'],
53+
app: ['read'],
54+
},
55+
auditor: {
56+
member: ['create', 'read'],
57+
app: ['read'],
58+
},
59+
employee: {
60+
policy: ['read'],
61+
portal: ['read', 'update'],
62+
},
63+
contractor: {
64+
policy: ['read'],
65+
portal: ['read', 'update'],
66+
},
67+
},
68+
BUILT_IN_ROLE_OBLIGATIONS: {
69+
owner: { compliance: true },
70+
admin: {},
71+
auditor: {},
72+
employee: { compliance: true },
73+
contractor: { compliance: true },
74+
},
75+
isRestrictedRole: (role: string) =>
76+
role === 'employee' || role === 'contractor',
77+
parseRoleObligations: (value: unknown) => {
78+
try {
79+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
80+
return parsed && typeof parsed === 'object' ? parsed : {};
81+
} catch {
82+
return {};
83+
}
84+
},
85+
parseRolePermissions: (value: unknown) => {
86+
try {
87+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
88+
return parsed && typeof parsed === 'object' ? parsed : null;
89+
} catch {
90+
return null;
91+
}
2692
},
2793
}));
2894

2995
jest.mock('../email/trigger-email', () => ({
3096
triggerEmail: jest.fn().mockResolvedValue({ id: 'trigger_123' }),
3197
}));
3298

99+
jest.mock('../frameworks/frameworks-timeline.helper', () => ({
100+
checkAutoCompletePhases: jest.fn().mockResolvedValue(undefined),
101+
}));
102+
103+
const mockInviteEmail = jest.fn().mockReturnValue('mocked-app-element');
33104
jest.mock('../email/templates/invite-member', () => ({
34-
InviteEmail: jest.fn().mockReturnValue('mocked-react-element'),
105+
InviteEmail: (...args: unknown[]) => mockInviteEmail(...args),
106+
}));
107+
108+
const mockInvitePortalEmail = jest
109+
.fn()
110+
.mockReturnValue('mocked-portal-element');
111+
jest.mock('@trycompai/email', () => ({
112+
InvitePortalEmail: (...args: unknown[]) => mockInvitePortalEmail(...args),
35113
}));
36114

37115
import { db } from '@db';
@@ -68,52 +146,51 @@ describe('PeopleInviteService', () => {
68146
callerRole: 'admin,owner',
69147
};
70148

71-
it('should throw ForbiddenException for unauthorized roles', async () => {
72-
await expect(
73-
service.inviteMembers({
74-
...baseParams,
75-
callerRole: 'employee',
76-
invites: [{ email: 'test@example.com', roles: ['employee'] }],
77-
}),
78-
).rejects.toThrow(ForbiddenException);
149+
it('should return error for employee caller trying to invite', async () => {
150+
const results = await service.inviteMembers({
151+
...baseParams,
152+
callerRole: 'employee',
153+
invites: [{ email: 'test@example.com', roles: ['employee'] }],
154+
});
155+
156+
expect(results[0].success).toBe(false);
79157
});
80158

81-
it('should restrict auditors to only invite auditors', async () => {
159+
it('should restrict auditors from assigning privileged roles', async () => {
82160
const results = await service.inviteMembers({
83161
...baseParams,
84162
callerRole: 'auditor',
85163
invites: [{ email: 'test@example.com', roles: ['admin'] }],
86164
});
87165

88166
expect(results[0].success).toBe(false);
89-
expect(results[0].error).toContain('Auditors can only invite');
167+
expect(results[0].error).toContain('privileged roles');
90168
});
91169

92-
it('should allow auditors to invite other auditors', async () => {
93-
// inviteWithCheck path: user doesn't exist → create invitation
94-
(mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
170+
it('should allow auditors to invite restricted roles', async () => {
95171
(mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
96172
name: 'Test Org',
97173
});
98-
(mockDb.invitation.create as jest.Mock).mockResolvedValue({
99-
id: 'inv_auditor',
174+
(mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
175+
(mockDb.user.create as jest.Mock).mockResolvedValue({
176+
id: 'usr_emp',
177+
email: 'emp@example.com',
100178
});
179+
(mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);
180+
(mockDb.member.create as jest.Mock).mockResolvedValue({
181+
id: 'mem_emp',
182+
});
183+
(
184+
mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock
185+
).mockResolvedValue({ count: 5 });
101186

102187
const results = await service.inviteMembers({
103188
...baseParams,
104189
callerRole: 'auditor',
105-
invites: [{ email: 'auditor@example.com', roles: ['auditor'] }],
190+
invites: [{ email: 'emp@example.com', roles: ['employee'] }],
106191
});
107192

108193
expect(results[0].success).toBe(true);
109-
expect(mockDb.invitation.create).toHaveBeenCalledWith(
110-
expect.objectContaining({
111-
data: expect.objectContaining({
112-
email: 'auditor@example.com',
113-
role: 'auditor',
114-
}),
115-
}),
116-
);
117194
});
118195

119196
it('should add employee without invitation for employee/contractor roles', async () => {
@@ -301,5 +378,130 @@ describe('PeopleInviteService', () => {
301378
}),
302379
);
303380
});
381+
382+
describe('email flow by role combination', () => {
383+
function setupNewUserInvite() {
384+
(mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
385+
(mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
386+
name: 'Test Org',
387+
});
388+
(mockDb.invitation.create as jest.Mock).mockResolvedValue({
389+
id: 'inv_new',
390+
});
391+
}
392+
393+
it('admin + employee with portal checked: sends single app email with portal link', async () => {
394+
setupNewUserInvite();
395+
396+
const results = await service.inviteMembers({
397+
...baseParams,
398+
invites: [
399+
{
400+
email: 'both@example.com',
401+
roles: ['admin', 'employee'],
402+
sendPortalEmail: true,
403+
},
404+
],
405+
});
406+
407+
expect(results[0].success).toBe(true);
408+
expect(mockTriggerEmail).toHaveBeenCalledTimes(1);
409+
expect(mockInviteEmail).toHaveBeenCalledWith(
410+
expect.objectContaining({
411+
organizationName: 'Test Org',
412+
portalLink: expect.stringContaining('org_123'),
413+
}),
414+
);
415+
expect(mockInvitePortalEmail).not.toHaveBeenCalled();
416+
});
417+
418+
it('employee only with portal checked: sends portal-only email', async () => {
419+
(mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
420+
name: 'Test Org',
421+
});
422+
(mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
423+
(mockDb.user.create as jest.Mock).mockResolvedValue({
424+
id: 'usr_emp',
425+
email: 'emp@example.com',
426+
});
427+
(mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);
428+
(mockDb.member.create as jest.Mock).mockResolvedValue({
429+
id: 'mem_emp',
430+
});
431+
(
432+
mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock
433+
).mockResolvedValue({ count: 5 });
434+
435+
const results = await service.inviteMembers({
436+
...baseParams,
437+
invites: [
438+
{
439+
email: 'emp@example.com',
440+
roles: ['employee'],
441+
sendPortalEmail: true,
442+
},
443+
],
444+
});
445+
446+
expect(results[0].success).toBe(true);
447+
expect(mockTriggerEmail).toHaveBeenCalledTimes(1);
448+
expect(mockInvitePortalEmail).toHaveBeenCalledWith(
449+
expect.objectContaining({
450+
organizationName: 'Test Org',
451+
email: 'emp@example.com',
452+
}),
453+
);
454+
expect(mockInviteEmail).not.toHaveBeenCalled();
455+
});
456+
457+
it('admin only (no portal): sends app email without portal link', async () => {
458+
setupNewUserInvite();
459+
460+
const results = await service.inviteMembers({
461+
...baseParams,
462+
invites: [
463+
{
464+
email: 'admin@example.com',
465+
roles: ['admin'],
466+
sendPortalEmail: false,
467+
},
468+
],
469+
});
470+
471+
expect(results[0].success).toBe(true);
472+
expect(mockTriggerEmail).toHaveBeenCalledTimes(1);
473+
expect(mockInviteEmail).toHaveBeenCalledWith(
474+
expect.objectContaining({ organizationName: 'Test Org' }),
475+
);
476+
expect(mockInviteEmail).toHaveBeenCalledWith(
477+
expect.not.objectContaining({
478+
portalLink: expect.anything(),
479+
}),
480+
);
481+
expect(mockInvitePortalEmail).not.toHaveBeenCalled();
482+
});
483+
484+
it('admin with portal checked but no compliance obligation: sends app email without portal', async () => {
485+
setupNewUserInvite();
486+
487+
const results = await service.inviteMembers({
488+
...baseParams,
489+
invites: [
490+
{
491+
email: 'admin2@example.com',
492+
roles: ['admin'],
493+
sendPortalEmail: true,
494+
},
495+
],
496+
});
497+
498+
expect(results[0].success).toBe(true);
499+
expect(mockTriggerEmail).toHaveBeenCalledTimes(1);
500+
expect(mockInviteEmail).toHaveBeenCalledWith(
501+
expect.objectContaining({ organizationName: 'Test Org' }),
502+
);
503+
expect(mockInvitePortalEmail).not.toHaveBeenCalled();
504+
});
505+
});
304506
});
305507
});

0 commit comments

Comments
 (0)