diff --git a/apps/api/src/email/templates/evidence-access-request-submitted.tsx b/apps/api/src/email/templates/evidence-access-request-submitted.tsx
new file mode 100644
index 0000000000..d8907be134
--- /dev/null
+++ b/apps/api/src/email/templates/evidence-access-request-submitted.tsx
@@ -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 (
+
+
+
+
+
+
+ New access request from {requesterName}
+
+
+
+
+
+ New Access Request
+
+
+
+ Hello {toName},
+
+
+
+ {requesterName} submitted an access request in{' '}
+ {organizationName}.
+
+
+
+
+ Request Details
+
+
+
+ Accounts Needed: {accountsNeeded}
+
+
+
+ Permissions Needed: {permissionsNeeded}
+
+
+
+ "{reasonForRequest}"
+
+
+
+
+
+
+ or copy and paste this URL into your browser:{' '}
+
+ {reviewUrl}
+
+
+
+
+
+ Don't want to receive access request notifications?{' '}
+
+ Manage your email preferences
+
+ .
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default EvidenceAccessRequestSubmittedEmail;
diff --git a/apps/api/src/evidence-forms/evidence-forms-notifier.service.spec.ts b/apps/api/src/evidence-forms/evidence-forms-notifier.service.spec.ts
new file mode 100644
index 0000000000..10cb01f6d8
--- /dev/null
+++ b/apps/api/src/evidence-forms/evidence-forms-notifier.service.spec.ts
@@ -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',
+ );
+ });
+});
diff --git a/apps/api/src/evidence-forms/evidence-forms-notifier.service.ts b/apps/api/src/evidence-forms/evidence-forms-notifier.service.ts
new file mode 100644
index 0000000000..bedde398ea
--- /dev/null
+++ b/apps/api/src/evidence-forms/evidence-forms-notifier.service.ts
@@ -0,0 +1,157 @@
+import { db } from '@db';
+import { Injectable, Logger } from '@nestjs/common';
+import { isUserUnsubscribed } from '@trycompai/email';
+import { triggerEmail } from '../email/trigger-email';
+import { EvidenceAccessRequestSubmittedEmail } from '../email/templates/evidence-access-request-submitted';
+
+interface Recipient {
+ userId: string;
+ email: string;
+ name: string;
+}
+
+function getAppUrl(): string {
+ return (
+ process.env.NEXT_PUBLIC_APP_URL ??
+ process.env.BETTER_AUTH_URL ??
+ 'https://app.trycomp.ai'
+ );
+}
+
+function stringField(data: Record, key: string): string {
+ const value = data[key];
+ return typeof value === 'string' ? value : '';
+}
+
+@Injectable()
+export class EvidenceFormsNotifierService {
+ private readonly logger = new Logger(EvidenceFormsNotifierService.name);
+
+ async notifyAccessRequestSubmitted(params: {
+ organizationId: string;
+ submitterUserId: string;
+ submitterName: string;
+ submissionId: string;
+ data: Record;
+ }): Promise {
+ const {
+ organizationId,
+ submitterUserId,
+ submitterName,
+ submissionId,
+ data,
+ } = params;
+
+ const recipients = await this.getOwnersAndAdmins(
+ organizationId,
+ submitterUserId,
+ );
+ if (recipients.length === 0) {
+ this.logger.log(
+ 'No owner/admin recipients for access request notification',
+ );
+ return;
+ }
+
+ const organization = await db.organization.findUnique({
+ where: { id: organizationId },
+ select: { name: true },
+ });
+ const organizationName = organization?.name ?? 'your organization';
+ const reviewUrl = `${getAppUrl()}/${organizationId}/documents/access-request/submissions/${submissionId}`;
+
+ await Promise.allSettled(
+ recipients.map((recipient) =>
+ this.sendToRecipient({
+ recipient,
+ organizationName,
+ submitterName,
+ reviewUrl,
+ accountsNeeded: stringField(data, 'accountsNeeded'),
+ permissionsNeeded: stringField(data, 'permissionsNeeded'),
+ reasonForRequest: stringField(data, 'reasonForRequest'),
+ }),
+ ),
+ );
+ }
+
+ private async sendToRecipient(params: {
+ recipient: Recipient;
+ organizationName: string;
+ submitterName: string;
+ reviewUrl: string;
+ accountsNeeded: string;
+ permissionsNeeded: string;
+ reasonForRequest: string;
+ }): Promise {
+ const { recipient, submitterName, organizationName, reviewUrl } = params;
+
+ try {
+ const isUnsubscribed = await isUserUnsubscribed(db, recipient.email);
+ if (isUnsubscribed) {
+ this.logger.log(
+ `Skipping access request notification: ${recipient.email} unsubscribed`,
+ );
+ return;
+ }
+
+ await triggerEmail({
+ to: recipient.email,
+ subject: `New access request from ${submitterName}`,
+ react: EvidenceAccessRequestSubmittedEmail({
+ toName: recipient.name,
+ toEmail: recipient.email,
+ organizationName,
+ requesterName: submitterName,
+ accountsNeeded: params.accountsNeeded,
+ permissionsNeeded: params.permissionsNeeded,
+ reasonForRequest: params.reasonForRequest,
+ reviewUrl,
+ }),
+ system: true,
+ });
+ } catch (error) {
+ this.logger.error(
+ `Failed to send access request notification to ${recipient.email}:`,
+ error instanceof Error ? error.message : 'Unknown error',
+ );
+ }
+ }
+
+ private async getOwnersAndAdmins(
+ organizationId: string,
+ excludeUserId: string,
+ ): Promise {
+ try {
+ const members = await db.member.findMany({
+ where: { organizationId, deactivated: false, isActive: true },
+ select: {
+ role: true,
+ user: { select: { id: true, email: true, name: true } },
+ },
+ });
+
+ const seen = new Set();
+ const recipients: Recipient[] = [];
+ for (const member of members) {
+ if (!member.role.includes('admin') && !member.role.includes('owner')) {
+ continue;
+ }
+ const { user } = member;
+ if (user.id === excludeUserId || !user.email || seen.has(user.id)) {
+ continue;
+ }
+ seen.add(user.id);
+ recipients.push({
+ userId: user.id,
+ email: user.email,
+ name: user.name || user.email,
+ });
+ }
+ return recipients;
+ } catch (error) {
+ this.logger.error('Failed to resolve owners/admins:', error);
+ return [];
+ }
+ }
+}
diff --git a/apps/api/src/evidence-forms/evidence-forms.module.ts b/apps/api/src/evidence-forms/evidence-forms.module.ts
index a2d69ad34a..fa997430fe 100644
--- a/apps/api/src/evidence-forms/evidence-forms.module.ts
+++ b/apps/api/src/evidence-forms/evidence-forms.module.ts
@@ -3,12 +3,13 @@ import { AttachmentsModule } from '@/attachments/attachments.module';
import { AuthModule } from '@/auth/auth.module';
import { TimelinesModule } from '../timelines/timelines.module';
import { EvidenceFormsController } from './evidence-forms.controller';
+import { EvidenceFormsNotifierService } from './evidence-forms-notifier.service';
import { EvidenceFormsService } from './evidence-forms.service';
@Module({
imports: [AuthModule, AttachmentsModule, TimelinesModule],
controllers: [EvidenceFormsController],
- providers: [EvidenceFormsService],
+ providers: [EvidenceFormsService, EvidenceFormsNotifierService],
exports: [EvidenceFormsService],
})
export class EvidenceFormsModule {}
diff --git a/apps/api/src/evidence-forms/evidence-forms.service.spec.ts b/apps/api/src/evidence-forms/evidence-forms.service.spec.ts
index be6c396b1a..9093e6c305 100644
--- a/apps/api/src/evidence-forms/evidence-forms.service.spec.ts
+++ b/apps/api/src/evidence-forms/evidence-forms.service.spec.ts
@@ -12,7 +12,7 @@ jest.mock(
);
jest.mock('../frameworks/frameworks-timeline.helper', () => ({
- checkAutoCompletePhases: jest.fn(),
+ checkAutoCompletePhases: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../timelines/timelines.service', () => ({
@@ -42,6 +42,7 @@ jest.mock('@db', () => {
findFirst: jest.fn(),
groupBy: jest.fn(),
update: jest.fn(),
+ create: jest.fn(),
},
evidenceFormSetting: {
findMany: jest.fn(),
@@ -56,6 +57,7 @@ type MockDb = {
findFirst: jest.Mock;
groupBy: jest.Mock;
update: jest.Mock;
+ create: jest.Mock;
};
evidenceFormSetting: {
findMany: jest.Mock;
@@ -82,9 +84,14 @@ describe('EvidenceFormsService', () => {
const timelinesServiceMock =
{} as unknown as import('../timelines/timelines.service').TimelinesService;
+ const evidenceFormsNotifierMock = {
+ notifyAccessRequestSubmitted: jest.fn().mockResolvedValue(undefined),
+ };
+
const service = new EvidenceFormsService(
attachmentsServiceMock,
timelinesServiceMock,
+ evidenceFormsNotifierMock as unknown as import('./evidence-forms-notifier.service').EvidenceFormsNotifierService,
);
const mockedDb = db as unknown as MockDb;
@@ -187,6 +194,81 @@ describe('EvidenceFormsService', () => {
});
});
+ describe('submitForm', () => {
+ const accessRequestPayload = {
+ submissionDate: '2026-01-01T00:00:00.000Z',
+ userName: 'Jane Employee',
+ accountsNeeded: 'GitHub (org: engineering)',
+ permissionsNeeded: 'write',
+ reasonForRequest: 'Needs write access for a new project',
+ accessGrantedBy: 'IT Admin',
+ dateAccessGranted: '2026-01-01',
+ };
+
+ it('notifies owners/admins after an access-request submission', async () => {
+ mockedDb.evidenceSubmission.create.mockResolvedValue({
+ id: 'sub_access_1',
+ formType: 'access_request',
+ data: accessRequestPayload,
+ submittedBy: {
+ id: 'usr_reviewer',
+ name: 'Jane Employee',
+ email: 'reviewer@example.com',
+ },
+ });
+
+ await service.submitForm({
+ organizationId: 'org_123',
+ formType: 'access-request',
+ payload: accessRequestPayload,
+ authContext,
+ });
+
+ expect(
+ evidenceFormsNotifierMock.notifyAccessRequestSubmitted,
+ ).toHaveBeenCalledWith({
+ organizationId: 'org_123',
+ submitterUserId: 'usr_reviewer',
+ submitterName: 'Jane Employee',
+ submissionId: 'sub_access_1',
+ data: accessRequestPayload,
+ });
+ });
+
+ it('does not notify owners/admins for non access-request submissions', async () => {
+ const meetingPayload = {
+ submissionDate: '2026-01-01T00:00:00.000Z',
+ attendees: 'Board members',
+ date: '2026-01-01',
+ meetingMinutes: 'Discussed Q1 roadmap',
+ meetingMinutesApprovedBy: 'CEO',
+ approvedDate: '2026-01-02',
+ };
+
+ mockedDb.evidenceSubmission.create.mockResolvedValue({
+ id: 'sub_meeting_1',
+ formType: 'meeting',
+ data: meetingPayload,
+ submittedBy: {
+ id: 'usr_reviewer',
+ name: 'Jane Employee',
+ email: 'reviewer@example.com',
+ },
+ });
+
+ await service.submitForm({
+ organizationId: 'org_123',
+ formType: 'meeting',
+ payload: meetingPayload,
+ authContext,
+ });
+
+ expect(
+ evidenceFormsNotifierMock.notifyAccessRequestSubmitted,
+ ).not.toHaveBeenCalled();
+ });
+ });
+
describe('reviewSubmission', () => {
it('includes submittedBy and reviewedBy relations on review update', async () => {
mockedDb.evidenceSubmission.findFirst.mockResolvedValue({
diff --git a/apps/api/src/evidence-forms/evidence-forms.service.ts b/apps/api/src/evidence-forms/evidence-forms.service.ts
index b2ed8afb8c..86050d2c55 100644
--- a/apps/api/src/evidence-forms/evidence-forms.service.ts
+++ b/apps/api/src/evidence-forms/evidence-forms.service.ts
@@ -23,6 +23,7 @@ import {
} from './evidence-forms.definitions';
import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper';
import { TimelinesService } from '../timelines/timelines.service';
+import { EvidenceFormsNotifierService } from './evidence-forms-notifier.service';
const listQuerySchema = z.object({
search: z.string().trim().optional(),
@@ -140,6 +141,7 @@ export class EvidenceFormsService {
constructor(
private readonly attachmentsService: AttachmentsService,
private readonly timelinesService: TimelinesService,
+ private readonly evidenceFormsNotifier: EvidenceFormsNotifierService,
) {}
private requireJwtUser(authContext: AuthContext): string {
@@ -594,6 +596,23 @@ export class EvidenceFormsService {
this.logger.warn('timeline auto-complete check failed', err);
});
+ if (parsedType.data === 'access-request') {
+ this.evidenceFormsNotifier
+ .notifyAccessRequestSubmitted({
+ organizationId: params.organizationId,
+ submitterUserId: params.authContext.userId,
+ submitterName:
+ submission.submittedBy?.name ??
+ submission.submittedBy?.email ??
+ 'A user',
+ submissionId: submission.id,
+ data: parsedPayload.data,
+ })
+ .catch((err) => {
+ this.logger.warn('access request notification failed', err);
+ });
+ }
+
return submission;
}