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
18 changes: 17 additions & 1 deletion apps/api/src/email/templates/invite-member.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ interface Props {
organizationName: string;
inviteLink: string;
email?: string;
portalLink?: string;
}

export const InviteEmail = ({ organizationName, inviteLink, email }: Props) => {
export const InviteEmail = ({ organizationName, inviteLink, email, portalLink }: Props) => {
return (
<Html>
<Tailwind>
Expand Down Expand Up @@ -69,6 +70,21 @@ export const InviteEmail = ({ organizationName, inviteLink, email }: Props) => {
</Link>
</Text>

{portalLink && (
<>
<Text className="text-[14px] leading-[24px] text-[#121212] mt-[24px]">
You also have access to the <strong>{organizationName} Employee Portal</strong> for
completing compliance tasks like signing policies and security training.
Once you've accepted your invite above, you can access the portal at:
</Text>
<Text className="text-[14px] leading-[24px] break-all text-[#707070]">
<Link href={portalLink} className="text-[#707070] underline">
{portalLink}
</Link>
</Text>
</>
)}

<br />
{email && (
<Section>
Expand Down
254 changes: 228 additions & 26 deletions apps/api/src/people/people-invite.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { PeopleInviteService } from './people-invite.service';
import { TimelinesService } from '../timelines/timelines.service';

jest.mock('@db', () => ({
BackgroundCheckStatus: {
invited: 'invited',
in_progress: 'in_progress',
in_review: 'in_review',
completed: 'completed',
completed_with_flags: 'completed_with_flags',
failed: 'failed',
cancelled: 'cancelled',
},
db: {
organization: {
findUnique: jest.fn(),
Expand All @@ -23,15 +31,85 @@ jest.mock('@db', () => ({
employeeTrainingVideoCompletion: {
createMany: jest.fn(),
},
frameworkInstance: {
findFirst: jest.fn(),
},
organizationRole: {
findMany: jest.fn().mockResolvedValue([]),
},
},
}));

jest.mock('@trycompai/auth', () => ({
BUILT_IN_ROLE_PERMISSIONS: {
owner: {
organization: ['read', 'update', 'delete'],
member: ['create', 'read', 'update', 'delete'],
app: ['read'],
},
admin: {
organization: ['read', 'update'],
member: ['create', 'read', 'update', 'delete'],
app: ['read'],
},
auditor: {
member: ['create', 'read'],
app: ['read'],
},
employee: {
policy: ['read'],
portal: ['read', 'update'],
},
contractor: {
policy: ['read'],
portal: ['read', 'update'],
},
},
BUILT_IN_ROLE_OBLIGATIONS: {
owner: { compliance: true },
admin: {},
auditor: {},
employee: { compliance: true },
contractor: { compliance: true },
},
isRestrictedRole: (role: string) =>
role === 'employee' || role === 'contractor',
parseRoleObligations: (value: unknown) => {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
},
parseRolePermissions: (value: unknown) => {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
},
}));

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

jest.mock('../frameworks/frameworks-timeline.helper', () => ({
checkAutoCompletePhases: jest.fn().mockResolvedValue(undefined),
}));

const mockInviteEmail = jest.fn().mockReturnValue('mocked-app-element');
jest.mock('../email/templates/invite-member', () => ({
InviteEmail: jest.fn().mockReturnValue('mocked-react-element'),
InviteEmail: (...args: unknown[]) => mockInviteEmail(...args),
}));

const mockInvitePortalEmail = jest
.fn()
.mockReturnValue('mocked-portal-element');
jest.mock('@trycompai/email', () => ({
InvitePortalEmail: (...args: unknown[]) => mockInvitePortalEmail(...args),
}));

import { db } from '@db';
Expand Down Expand Up @@ -68,52 +146,51 @@ describe('PeopleInviteService', () => {
callerRole: 'admin,owner',
};

it('should throw ForbiddenException for unauthorized roles', async () => {
await expect(
service.inviteMembers({
...baseParams,
callerRole: 'employee',
invites: [{ email: 'test@example.com', roles: ['employee'] }],
}),
).rejects.toThrow(ForbiddenException);
it('should return error for employee caller trying to invite', async () => {
const results = await service.inviteMembers({
...baseParams,
callerRole: 'employee',
invites: [{ email: 'test@example.com', roles: ['employee'] }],
});

expect(results[0].success).toBe(false);
});

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

expect(results[0].success).toBe(false);
expect(results[0].error).toContain('Auditors can only invite');
expect(results[0].error).toContain('privileged roles');
});

it('should allow auditors to invite other auditors', async () => {
// inviteWithCheck path: user doesn't exist → create invitation
(mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
it('should allow auditors to invite restricted roles', async () => {
(mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
name: 'Test Org',
});
(mockDb.invitation.create as jest.Mock).mockResolvedValue({
id: 'inv_auditor',
(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,
callerRole: 'auditor',
invites: [{ email: 'auditor@example.com', roles: ['auditor'] }],
invites: [{ email: 'emp@example.com', roles: ['employee'] }],
});

expect(results[0].success).toBe(true);
expect(mockDb.invitation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
email: 'auditor@example.com',
role: 'auditor',
}),
}),
);
});

it('should add employee without invitation for employee/contractor roles', async () => {
Expand Down Expand Up @@ -301,5 +378,130 @@ describe('PeopleInviteService', () => {
}),
);
});

describe('email flow by role combination', () => {
function setupNewUserInvite() {
(mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
(mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
name: 'Test Org',
});
(mockDb.invitation.create as jest.Mock).mockResolvedValue({
id: 'inv_new',
});
}

it('admin + employee with portal checked: sends single app email with portal link', async () => {
setupNewUserInvite();

const results = await service.inviteMembers({
...baseParams,
invites: [
{
email: 'both@example.com',
roles: ['admin', 'employee'],
sendPortalEmail: true,
},
],
});

expect(results[0].success).toBe(true);
expect(mockTriggerEmail).toHaveBeenCalledTimes(1);
expect(mockInviteEmail).toHaveBeenCalledWith(
expect.objectContaining({
organizationName: 'Test Org',
portalLink: expect.stringContaining('org_123'),
}),
);
expect(mockInvitePortalEmail).not.toHaveBeenCalled();
});

it('employee only with portal checked: sends portal-only 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: true,
},
],
});

expect(results[0].success).toBe(true);
expect(mockTriggerEmail).toHaveBeenCalledTimes(1);
expect(mockInvitePortalEmail).toHaveBeenCalledWith(
expect.objectContaining({
organizationName: 'Test Org',
email: 'emp@example.com',
}),
);
expect(mockInviteEmail).not.toHaveBeenCalled();
});

it('admin only (no portal): sends app email without portal link', async () => {
setupNewUserInvite();

const results = await service.inviteMembers({
...baseParams,
invites: [
{
email: 'admin@example.com',
roles: ['admin'],
sendPortalEmail: false,
},
],
});

expect(results[0].success).toBe(true);
expect(mockTriggerEmail).toHaveBeenCalledTimes(1);
expect(mockInviteEmail).toHaveBeenCalledWith(
expect.objectContaining({ organizationName: 'Test Org' }),
);
expect(mockInviteEmail).toHaveBeenCalledWith(
expect.not.objectContaining({
portalLink: expect.anything(),
}),
);
expect(mockInvitePortalEmail).not.toHaveBeenCalled();
});

it('admin with portal checked but no compliance obligation: sends app email without portal', async () => {
setupNewUserInvite();

const results = await service.inviteMembers({
...baseParams,
invites: [
{
email: 'admin2@example.com',
roles: ['admin'],
sendPortalEmail: true,
},
],
});

expect(results[0].success).toBe(true);
expect(mockTriggerEmail).toHaveBeenCalledTimes(1);
expect(mockInviteEmail).toHaveBeenCalledWith(
expect.objectContaining({ organizationName: 'Test Org' }),
);
expect(mockInvitePortalEmail).not.toHaveBeenCalled();
});
});
});
});
Loading
Loading