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
140 changes: 140 additions & 0 deletions apps/api/src/email/templates/evidence-access-request-submitted.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import * as React from 'react';
import {
Body,
Button,
Container,
Font,
Heading,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
import { Footer } from '../components/footer';
import { Logo } from '../components/logo';
import { getUnsubscribeUrl } from '@trycompai/email';

interface Props {
toName: string;
toEmail: string;
organizationName: string;
requesterName: string;
accountsNeeded: string;
permissionsNeeded: string;
reasonForRequest: string;
reviewUrl: string;
}

export const EvidenceAccessRequestSubmittedEmail = ({
toName,
toEmail,
organizationName,
requesterName,
accountsNeeded,
permissionsNeeded,
reasonForRequest,
reviewUrl,
}: Props) => {
const unsubscribeUrl = getUnsubscribeUrl(toEmail);

return (
<Html>
<Tailwind>
<head>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={400}
fontStyle="normal"
/>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={500}
fontStyle="normal"
/>
</head>
<Preview>New access request from {requesterName}</Preview>

<Body className="mx-auto my-auto bg-[#fff] font-sans">
<Container
className="mx-auto my-[40px] max-w-[600px] border-transparent p-[20px] md:border-[#E8E7E1]"
style={{ borderStyle: 'solid', borderWidth: 1 }}
>
<Logo />
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-[#121212]">
New Access Request
</Heading>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Hello {toName},
</Text>

<Text className="text-[14px] leading-[24px] text-[#121212]">
<strong>{requesterName}</strong> submitted an access request in{' '}
<strong>{organizationName}</strong>.
</Text>

<Section
className="mt-[24px] mb-[24px] rounded-[8px] bg-[#f5f5f5] p-[16px]"
style={{ border: '1px solid #e0e0e0' }}
>
<Text className="m-0 text-[12px] font-medium uppercase tracking-wide text-[#666666]">
Request Details
</Text>

<Text className="mt-[8px] mb-[4px] text-[14px] font-medium text-[#121212]">
Accounts Needed: {accountsNeeded}
</Text>

<Text className="mt-[4px] mb-[4px] text-[14px] font-medium text-[#121212]">
Permissions Needed: {permissionsNeeded}
</Text>

<Text className="mt-[12px] mb-0 text-[13px] italic text-[#444444]">
"{reasonForRequest}"
</Text>
</Section>

<Section className="mt-[32px] mb-[32px] text-center">
<Button
className="rounded-[3px] bg-[#121212] px-[20px] py-[12px] text-center text-[14px] font-semibold text-white no-underline"
href={reviewUrl}
>
Review Request
</Button>
</Section>

<Text className="text-[14px] leading-[24px] text-[#121212]">
or copy and paste this URL into your browser:{' '}
<a href={reviewUrl} className="text-[#121212] underline">
{reviewUrl}
</a>
</Text>

<Section className="mt-[30px] mb-[20px]">
<Text className="text-[12px] leading-[20px] text-[#666666]">
Don't want to receive access request notifications?{' '}
<Link
href={unsubscribeUrl}
className="text-[#121212] underline"
>
Manage your email preferences
</Link>
.
</Text>
</Section>

<br />

<Footer />
</Container>
</Body>
</Tailwind>
</Html>
);
};

export default EvidenceAccessRequestSubmittedEmail;
201 changes: 201 additions & 0 deletions apps/api/src/evidence-forms/evidence-forms-notifier.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
const mockDb = {
organization: { findUnique: jest.fn() },
member: { findMany: jest.fn() },
};

jest.mock('@db', () => ({ db: mockDb }));

jest.mock('@trycompai/email', () => ({
isUserUnsubscribed: jest.fn().mockResolvedValue(false),
getUnsubscribeUrl: jest
.fn()
.mockReturnValue('https://app.trycomp.ai/unsubscribe'),
}));

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

jest.mock('../email/templates/evidence-access-request-submitted', () => ({
EvidenceAccessRequestSubmittedEmail: () => null,
}));

import { isUserUnsubscribed } from '@trycompai/email';
import { triggerEmail } from '../email/trigger-email';
import { EvidenceFormsNotifierService } from './evidence-forms-notifier.service';

describe('EvidenceFormsNotifierService', () => {
let service: EvidenceFormsNotifierService;

beforeEach(() => {
jest.clearAllMocks();
(isUserUnsubscribed as jest.Mock).mockResolvedValue(false);
service = new EvidenceFormsNotifierService();
});

it('emails every active owner/admin, excluding the submitter', async () => {
mockDb.organization.findUnique.mockResolvedValue({ name: 'Acme' });
mockDb.member.findMany.mockResolvedValue([
{
role: 'admin',
user: { id: 'usr_admin', email: 'admin@acme.com', name: 'Admin' },
},
{
role: 'owner',
user: { id: 'usr_owner', email: 'owner@acme.com', name: 'Owner' },
},
{
role: 'employee',
user: {
id: 'usr_submitter',
email: 'submitter@acme.com',
name: 'Submitter',
},
},
]);

await service.notifyAccessRequestSubmitted({
organizationId: 'org_1',
submitterUserId: 'usr_submitter',
submitterName: 'Submitter',
submissionId: 'sub_1',
data: {
accountsNeeded: 'GitHub',
permissionsNeeded: 'write',
reasonForRequest: 'New hire',
},
});

expect(triggerEmail).toHaveBeenCalledTimes(2);
const recipients = (triggerEmail as jest.Mock).mock.calls.map(
(call) => call[0].to,
);
expect(recipients.sort()).toEqual(['admin@acme.com', 'owner@acme.com']);
expect(mockDb.member.findMany).toHaveBeenCalledWith({
where: { organizationId: 'org_1', deactivated: false, isActive: true },
select: {
role: true,
user: { select: { id: true, email: true, name: true } },
},
});
});

it('does nothing when there are no owner/admin recipients', async () => {
mockDb.member.findMany.mockResolvedValue([
{
role: 'employee',
user: {
id: 'usr_submitter',
email: 'submitter@acme.com',
name: 'Submitter',
},
},
]);

await service.notifyAccessRequestSubmitted({
organizationId: 'org_1',
submitterUserId: 'usr_submitter',
submitterName: 'Submitter',
submissionId: 'sub_1',
data: {},
});

expect(triggerEmail).not.toHaveBeenCalled();
expect(mockDb.organization.findUnique).not.toHaveBeenCalled();
});

it('excludes deactivated and inactive members from the recipient query', async () => {
mockDb.organization.findUnique.mockResolvedValue({ name: 'Acme' });

const allMembers = [
{
role: 'admin',
deactivated: false,
isActive: true,
user: { id: 'usr_admin', email: 'admin@acme.com', name: 'Admin' },
},
{
role: 'owner',
deactivated: false,
isActive: false,
user: {
id: 'usr_inactive_owner',
email: 'inactive-owner@acme.com',
name: 'Inactive Owner',
},
},
{
role: 'admin',
deactivated: true,
isActive: true,
user: {
id: 'usr_deactivated_admin',
email: 'deactivated-admin@acme.com',
name: 'Deactivated Admin',
},
},
];

mockDb.member.findMany.mockImplementation(
(args: { where: { deactivated: boolean; isActive: boolean } }) =>
Promise.resolve(
allMembers.filter(
(m) =>
m.deactivated === args.where.deactivated &&
m.isActive === args.where.isActive,
),
),
);

await service.notifyAccessRequestSubmitted({
organizationId: 'org_1',
submitterUserId: 'usr_other',
submitterName: 'Requester',
submissionId: 'sub_1',
data: {},
});

expect(mockDb.member.findMany).toHaveBeenCalledWith({
where: { organizationId: 'org_1', deactivated: false, isActive: true },
select: {
role: true,
user: { select: { id: true, email: true, name: true } },
},
});
expect(triggerEmail).toHaveBeenCalledTimes(1);
expect((triggerEmail as jest.Mock).mock.calls[0][0].to).toBe(
'admin@acme.com',
);
});

it('excludes admins who unsubscribed from emails', async () => {
mockDb.organization.findUnique.mockResolvedValue({ name: 'Acme' });
mockDb.member.findMany.mockResolvedValue([
{
role: 'admin',
user: { id: 'usr_admin', email: 'admin@acme.com', name: 'Admin' },
},
{
role: 'owner',
user: { id: 'usr_owner', email: 'owner@acme.com', name: 'Owner' },
},
]);
(isUserUnsubscribed as jest.Mock).mockImplementation(
(_db: unknown, email: string) =>
Promise.resolve(email === 'admin@acme.com'),
);

await service.notifyAccessRequestSubmitted({
organizationId: 'org_1',
submitterUserId: 'usr_other',
submitterName: 'Requester',
submissionId: 'sub_1',
data: {},
});

expect(triggerEmail).toHaveBeenCalledTimes(1);
expect((triggerEmail as jest.Mock).mock.calls[0][0].to).toBe(
'owner@acme.com',
);
});
});
Loading
Loading