Skip to content

Commit 8d2a811

Browse files
Marfuentofikwest
andauthored
feat(api): add access request notification email functionality (#1910)
Co-authored-by: Tofik Hasanov <[email protected]>
1 parent 9d73855 commit 8d2a811

File tree

3 files changed

+269
-0
lines changed

3 files changed

+269
-0
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import {
2+
Body,
3+
Button,
4+
Container,
5+
Font,
6+
Heading,
7+
Html,
8+
Preview,
9+
Section,
10+
Tailwind,
11+
Text,
12+
} from '@react-email/components';
13+
import { Footer } from '../components/footer';
14+
import { Logo } from '../components/logo';
15+
16+
interface Props {
17+
organizationName: string;
18+
requesterName: string;
19+
requesterEmail: string;
20+
requesterCompany?: string | null;
21+
requesterJobTitle?: string | null;
22+
purpose?: string | null;
23+
requestedDurationDays?: number | null;
24+
reviewUrl: string;
25+
}
26+
27+
export const AccessRequestNotificationEmail = ({
28+
organizationName,
29+
requesterName,
30+
requesterEmail,
31+
requesterCompany,
32+
requesterJobTitle,
33+
purpose,
34+
requestedDurationDays,
35+
reviewUrl,
36+
}: Props) => {
37+
return (
38+
<Html>
39+
<Tailwind>
40+
<head>
41+
<Font
42+
fontFamily="Geist"
43+
fallbackFontFamily="Helvetica"
44+
fontWeight={400}
45+
fontStyle="normal"
46+
/>
47+
<Font
48+
fontFamily="Geist"
49+
fallbackFontFamily="Helvetica"
50+
fontWeight={500}
51+
fontStyle="normal"
52+
/>
53+
</head>
54+
<Preview>New Trust Portal Access Request from {requesterName}</Preview>
55+
56+
<Body className="mx-auto my-auto bg-[#fff] font-sans">
57+
<Container
58+
className="mx-auto my-[40px] max-w-[600px] border-transparent p-[20px] md:border-[#E8E7E1]"
59+
style={{ borderStyle: 'solid', borderWidth: 1 }}
60+
>
61+
<Logo />
62+
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-[#121212]">
63+
New Access Request
64+
</Heading>
65+
66+
<Text className="text-[14px] leading-[24px] text-[#121212]">
67+
A new request to access <strong>{organizationName}</strong>'s
68+
trust portal has been submitted and is awaiting your review.
69+
</Text>
70+
71+
<Section
72+
className="mt-[20px] mb-[20px] rounded-[3px] p-[15px]"
73+
style={{ backgroundColor: '#f8f9fa' }}
74+
>
75+
<Text className="m-0 mb-[10px] text-[14px] font-semibold text-[#121212]">
76+
Requester Details
77+
</Text>
78+
<Text className="m-0 text-[14px] leading-[20px] text-[#121212]">
79+
<strong>Name:</strong> {requesterName}
80+
<br />
81+
<strong>Email:</strong> {requesterEmail}
82+
{requesterCompany && (
83+
<>
84+
<br />
85+
<strong>Company:</strong> {requesterCompany}
86+
</>
87+
)}
88+
{requesterJobTitle && (
89+
<>
90+
<br />
91+
<strong>Job Title:</strong> {requesterJobTitle}
92+
</>
93+
)}
94+
</Text>
95+
</Section>
96+
97+
{purpose && (
98+
<Section className="mb-[20px]">
99+
<Text className="m-0 mb-[8px] text-[14px] font-semibold text-[#121212]">
100+
Purpose
101+
</Text>
102+
<Text className="m-0 text-[14px] leading-[20px] text-[#121212]">
103+
{purpose}
104+
</Text>
105+
</Section>
106+
)}
107+
108+
{requestedDurationDays && (
109+
<Text className="text-[14px] leading-[24px] text-[#121212]">
110+
<strong>Requested Access Duration:</strong>{' '}
111+
{requestedDurationDays} days
112+
</Text>
113+
)}
114+
115+
<Section className="mt-[32px] mb-[32px] text-center">
116+
<Button
117+
className="rounded-[3px] bg-[#121212] px-[20px] py-[12px] text-center text-[14px] font-semibold text-white no-underline"
118+
href={reviewUrl}
119+
>
120+
Review Request
121+
</Button>
122+
</Section>
123+
124+
<Section
125+
className="mt-[30px] mb-[20px] rounded-[3px] border-l-4 p-[15px]"
126+
style={{ backgroundColor: '#fff4e6', borderColor: '#f59e0b' }}
127+
>
128+
<Text className="m-0 text-[14px] leading-[24px] text-[#121212]">
129+
<strong>Action Required</strong>
130+
<br />
131+
Please review this request and either approve or deny access.
132+
Approved requests will require the requester to sign an NDA
133+
before accessing your trust portal.
134+
</Text>
135+
</Section>
136+
137+
<br />
138+
139+
<Footer />
140+
</Container>
141+
</Body>
142+
</Tailwind>
143+
</Html>
144+
);
145+
};
146+
147+
export default AccessRequestNotificationEmail;

apps/api/src/trust-portal/email.service.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { sendEmail } from '../email/resend';
33
import { AccessGrantedEmail } from '../email/templates/access-granted';
44
import { AccessReclaimEmail } from '../email/templates/access-reclaim';
55
import { NdaSigningEmail } from '../email/templates/nda-signing';
6+
import { AccessRequestNotificationEmail } from '../email/templates/access-request-notification';
67

78
@Injectable()
89
export class TrustEmailService {
@@ -77,4 +78,48 @@ export class TrustEmailService {
7778

7879
this.logger.log(`Access reclaim email sent to ${toEmail} (ID: ${id})`);
7980
}
81+
82+
async sendAccessRequestNotification(params: {
83+
toEmail: string;
84+
organizationName: string;
85+
requesterName: string;
86+
requesterEmail: string;
87+
requesterCompany?: string | null;
88+
requesterJobTitle?: string | null;
89+
purpose?: string | null;
90+
requestedDurationDays?: number | null;
91+
reviewUrl: string;
92+
}): Promise<void> {
93+
const {
94+
toEmail,
95+
organizationName,
96+
requesterName,
97+
requesterEmail,
98+
requesterCompany,
99+
requesterJobTitle,
100+
purpose,
101+
requestedDurationDays,
102+
reviewUrl,
103+
} = params;
104+
105+
const { id } = await sendEmail({
106+
to: toEmail,
107+
subject: `New Trust Portal Access Request - ${organizationName}`,
108+
react: AccessRequestNotificationEmail({
109+
organizationName,
110+
requesterName,
111+
requesterEmail,
112+
requesterCompany,
113+
requesterJobTitle,
114+
purpose,
115+
requestedDurationDays,
116+
reviewUrl,
117+
}),
118+
system: true,
119+
});
120+
121+
this.logger.log(
122+
`Access request notification sent to ${toEmail} for requester ${requesterEmail} (ID: ${id})`,
123+
);
124+
}
80125
}

apps/api/src/trust-portal/trust-access.service.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,90 @@ export class TrustAccessService {
151151
},
152152
});
153153

154+
// Send notification email to organization
155+
await this.sendAccessRequestNotificationToOrg(
156+
trust.organizationId,
157+
request.id,
158+
trust.organization.name,
159+
dto,
160+
);
161+
154162
return {
155163
id: request.id,
156164
status: request.status,
157165
message: 'Access request submitted for review',
158166
};
159167
}
160168

169+
private async sendAccessRequestNotificationToOrg(
170+
organizationId: string,
171+
requestId: string,
172+
organizationName: string,
173+
dto: CreateAccessRequestDto,
174+
) {
175+
// Get contact email from Trust or fallback to owner/admin emails
176+
const trust = await db.trust.findUnique({
177+
where: { organizationId },
178+
select: { contactEmail: true },
179+
});
180+
181+
let notificationEmails: string[] = [];
182+
183+
// Use contactEmail if available
184+
if (trust?.contactEmail) {
185+
notificationEmails.push(trust.contactEmail);
186+
} else {
187+
// Fallback: Get owner and admin emails
188+
const members = await db.member.findMany({
189+
where: {
190+
organizationId,
191+
},
192+
include: {
193+
user: {
194+
select: {
195+
email: true,
196+
},
197+
},
198+
},
199+
});
200+
201+
// Filter for members with owner or admin role (handles comma-separated roles)
202+
const ownerAdminMembers = members.filter((m) => {
203+
const role = m.role.toLowerCase();
204+
return role.includes('owner') || role.includes('admin');
205+
});
206+
207+
notificationEmails = ownerAdminMembers
208+
.map((m) => m.user.email)
209+
.filter((email): email is string => !!email);
210+
}
211+
212+
// If no notification emails found, skip sending
213+
if (notificationEmails.length === 0) {
214+
return;
215+
}
216+
217+
// Construct review URL
218+
const reviewUrl = `${process.env.BETTER_AUTH_URL}/${organizationId}/trust`;
219+
220+
// Send notification to all recipients
221+
const emailPromises = notificationEmails.map((email) =>
222+
this.emailService.sendAccessRequestNotification({
223+
toEmail: email,
224+
organizationName,
225+
requesterName: dto.name,
226+
requesterEmail: dto.email,
227+
requesterCompany: dto.company,
228+
requesterJobTitle: dto.jobTitle,
229+
purpose: dto.purpose,
230+
requestedDurationDays: dto.requestedDurationDays,
231+
reviewUrl,
232+
}),
233+
);
234+
235+
await Promise.allSettled(emailPromises);
236+
}
237+
161238
async listAccessRequests(organizationId: string, dto: ListAccessRequestsDto) {
162239
const where = {
163240
organizationId,

0 commit comments

Comments
 (0)