Skip to content

Commit 8c1b525

Browse files
github-actions[bot]chasprowebdevMarfuen
authored
feat(portal): Whenever the policy is published, signedBy field should be cleared and send email to only previous singers to let them accept it again. (#1532)
* feat: send emails after the policy is published * fix: update email for policy publishment --------- Co-authored-by: chasprowebdev <chasgarciaprowebdev@gmail.com> Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent b8c409d commit 8c1b525

File tree

7 files changed

+269
-2
lines changed

7 files changed

+269
-2
lines changed

apps/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@trigger.dev/react-hooks": "4.0.0",
4848
"@trigger.dev/sdk": "4.0.0",
4949
"@trycompai/db": "^1.3.4",
50+
"@trycompai/email": "workspace:*",
5051
"@types/canvas-confetti": "^1.9.0",
5152
"@types/three": "^0.180.0",
5253
"@uploadthing/react": "^7.3.0",

apps/app/src/actions/policies/accept-requested-policy-changes.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use server';
22

33
import { db, PolicyStatus } from '@db';
4+
import { sendPolicyNotificationEmail } from '@trycompai/email';
45
import { revalidatePath, revalidateTag } from 'next/cache';
56
import { z } from 'zod';
67
import { authActionClient } from '../safe-action';
@@ -40,6 +41,13 @@ export const acceptRequestedPolicyChangesAction = authActionClient
4041
id,
4142
organizationId: session.activeOrganizationId,
4243
},
44+
include: {
45+
organization: {
46+
select: {
47+
name: true,
48+
},
49+
},
50+
},
4351
});
4452

4553
if (!policy) {
@@ -50,7 +58,10 @@ export const acceptRequestedPolicyChangesAction = authActionClient
5058
throw new Error('Approver is not the same');
5159
}
5260

53-
// Update policy status
61+
// Check if there were previous signers to determine notification type
62+
const isNewPolicy = policy.lastPublishedAt === null;
63+
64+
// Update policy status and clear signedBy field
5465
await db.policy.update({
5566
where: {
5667
id,
@@ -59,9 +70,79 @@ export const acceptRequestedPolicyChangesAction = authActionClient
5970
data: {
6071
status: PolicyStatus.published,
6172
approverId: null,
73+
signedBy: [], // Clear the signedBy field
74+
lastPublishedAt: new Date(), // Update last published date
6275
},
6376
});
6477

78+
// Get all employees in the organization to send notifications
79+
const employees = await db.member.findMany({
80+
where: {
81+
organizationId: session.activeOrganizationId,
82+
isActive: true,
83+
},
84+
include: {
85+
user: true,
86+
},
87+
});
88+
89+
// Filter to get only employees
90+
const employeeMembers = employees.filter((member) => {
91+
const roles = member.role.includes(',') ? member.role.split(',') : [member.role];
92+
return roles.includes('employee');
93+
});
94+
95+
// Send notification emails to all employees
96+
// Send emails in batches of 2 per second to respect rate limit
97+
const BATCH_SIZE = 2;
98+
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
99+
100+
const sendEmailsInBatches = async () => {
101+
for (let i = 0; i < employeeMembers.length; i += BATCH_SIZE) {
102+
const batch = employeeMembers.slice(i, i + BATCH_SIZE);
103+
104+
await Promise.all(
105+
batch.map(async (employee) => {
106+
if (!employee.user.email) return;
107+
108+
let notificationType: 'new' | 're-acceptance' | 'updated';
109+
const wasAlreadySigned = policy.signedBy.includes(employee.id);
110+
if (isNewPolicy) {
111+
notificationType = 'new';
112+
} else if (wasAlreadySigned) {
113+
notificationType = 're-acceptance';
114+
} else {
115+
notificationType = 'updated';
116+
}
117+
118+
try {
119+
await sendPolicyNotificationEmail({
120+
email: employee.user.email,
121+
userName: employee.user.name || employee.user.email || 'Employee',
122+
policyName: policy.name,
123+
organizationName: policy.organization.name,
124+
organizationId: session.activeOrganizationId,
125+
notificationType,
126+
});
127+
} catch (emailError) {
128+
console.error(`Failed to send email to ${employee.user.email}:`, emailError);
129+
// Don't fail the whole operation if email fails
130+
}
131+
}),
132+
);
133+
134+
// Only delay if there are more emails to send
135+
if (i + BATCH_SIZE < employeeMembers.length) {
136+
await delay(1000); // wait 1 second between batches
137+
}
138+
}
139+
};
140+
141+
// Fire and forget, but log errors if any
142+
sendEmailsInBatches().catch((error) => {
143+
console.error('Some emails failed to send:', error);
144+
});
145+
65146
// If a comment was provided, create a comment
66147
if (comment && comment.trim() !== '') {
67148
const member = await db.member.findFirst({

apps/app/tsconfig.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@
4242
"@comp/kv": ["../../packages/kv/src/index.ts"],
4343
"@comp/kv/*": ["../../packages/kv/src/*"],
4444
"@comp/tsconfig": ["../../packages/tsconfig/index.ts"],
45-
"@comp/tsconfig/*": ["../../packages/tsconfig/*"]
45+
"@comp/tsconfig/*": ["../../packages/tsconfig/*"],
46+
"@trycompai/email": ["../../packages/email/index.ts"],
47+
"@trycompai/email/*": ["../../packages/email/*"]
4648
}
4749
},
4850
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "trigger.config.ts"],

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {
2+
Body,
3+
Button,
4+
Container,
5+
Font,
6+
Heading,
7+
Html,
8+
Link,
9+
Preview,
10+
Section,
11+
Tailwind,
12+
Text,
13+
} from '@react-email/components';
14+
import { Footer } from '../components/footer';
15+
import { Logo } from '../components/logo';
16+
17+
interface Props {
18+
email: string;
19+
userName: string;
20+
policyName: string;
21+
organizationName: string;
22+
organizationId: string;
23+
notificationType: 'new' | 'updated' | 're-acceptance';
24+
}
25+
26+
export const PolicyNotificationEmail = ({
27+
email,
28+
userName,
29+
policyName,
30+
organizationName,
31+
organizationId,
32+
notificationType,
33+
}: Props) => {
34+
const link = `${process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'}/${organizationId}`;
35+
const subjectText = 'Please review and accept this policy';
36+
37+
const getBodyText = () => {
38+
switch (notificationType) {
39+
case 'new':
40+
return `The "${policyName}" policy has been created.`;
41+
case 'updated':
42+
case 're-acceptance':
43+
return `The "${policyName}" policy has been updated.`;
44+
default:
45+
return `Please review and accept the policy "${policyName}".`;
46+
}
47+
};
48+
49+
return (
50+
<Html>
51+
<Tailwind>
52+
<head>
53+
<Font
54+
fontFamily="Geist"
55+
fallbackFontFamily="Helvetica"
56+
webFont={{
57+
url: 'https://app.trycomp.ai/fonts/geist/geist-sans-latin-400-normal.woff2',
58+
format: 'woff2',
59+
}}
60+
fontWeight={400}
61+
fontStyle="normal"
62+
/>
63+
64+
<Font
65+
fontFamily="Geist"
66+
fallbackFontFamily="Helvetica"
67+
webFont={{
68+
url: 'https://app.trycomp.ai/fonts/geist/geist-sans-latin-500-normal.woff2',
69+
format: 'woff2',
70+
}}
71+
fontWeight={500}
72+
fontStyle="normal"
73+
/>
74+
</head>
75+
76+
<Preview>{subjectText}</Preview>
77+
78+
<Body className="mx-auto my-auto bg-[#fff] font-sans">
79+
<Container
80+
className="mx-auto my-[40px] max-w-[600px] border-transparent p-[20px] md:border-[#E8E7E1]"
81+
style={{ borderStyle: 'solid', borderWidth: 1 }}
82+
>
83+
<Logo />
84+
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-[#121212]">
85+
{subjectText}
86+
</Heading>
87+
88+
<Text className="text-[14px] leading-[24px] text-[#121212]">
89+
Hi {userName},
90+
</Text>
91+
92+
<Text className="text-[14px] leading-[24px] text-[#121212]">
93+
{getBodyText()}
94+
</Text>
95+
96+
<Text className="text-[14px] leading-[24px] text-[#121212]">
97+
Your organization <strong>{organizationName}</strong> requires all employees to review and accept this policy.
98+
</Text>
99+
100+
<Section className="mt-[32px] mb-[42px] text-center">
101+
<Button
102+
className="text-primary border border-solid border-[#121212] bg-transparent px-6 py-3 text-center text-[14px] font-medium text-[#121212] no-underline"
103+
href={link}
104+
>
105+
Review & Accept Policy
106+
</Button>
107+
</Section>
108+
109+
<Text className="text-[14px] leading-[24px] break-all text-[#707070]">
110+
or copy and paste this URL into your browser{' '}
111+
<Link href={link} className="text-[#707070] underline">
112+
{link}
113+
</Link>
114+
</Text>
115+
116+
<br />
117+
<Section>
118+
<Text className="text-[12px] leading-[24px] text-[#666666]">
119+
This notification was intended for <span className="text-[#121212]">{email}</span>.
120+
</Text>
121+
</Section>
122+
123+
<br />
124+
125+
<Footer />
126+
</Container>
127+
</Body>
128+
</Tailwind>
129+
</Html>
130+
);
131+
};
132+
133+
export default PolicyNotificationEmail;

packages/email/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ export * from './emails/invite-portal';
44
export * from './emails/magic-link';
55
export * from './emails/marketing/welcome';
66
export * from './emails/otp';
7+
export * from './emails/policy-notification';
78
export * from './emails/waitlist';
89

910
// Email sending functions
1011
export * from './lib/invite-member';
1112
export * from './lib/magic-link';
13+
export * from './lib/policy-notification';
1214
export * from './lib/resend';
1315
export * from './lib/waitlist';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import PolicyNotificationEmail from '../emails/policy-notification';
2+
import { sendEmail } from './resend';
3+
4+
export const sendPolicyNotificationEmail = async (params: {
5+
email: string;
6+
userName: string;
7+
policyName: string;
8+
organizationName: string;
9+
organizationId: string;
10+
notificationType: 'new' | 'updated' | 're-acceptance';
11+
}) => {
12+
const {
13+
email,
14+
userName,
15+
policyName,
16+
organizationName,
17+
organizationId,
18+
notificationType,
19+
} = params;
20+
const subjectText = 'Please review and accept this policy';
21+
22+
try {
23+
const sent = await sendEmail({
24+
to: email,
25+
subject: subjectText,
26+
react: PolicyNotificationEmail({
27+
email,
28+
userName,
29+
policyName,
30+
organizationName,
31+
organizationId,
32+
notificationType,
33+
}),
34+
system: true, // Use system email address
35+
});
36+
37+
if (!sent) {
38+
console.error('Failed to send policy notification email');
39+
return { success: false };
40+
}
41+
42+
return { success: true };
43+
} catch (error) {
44+
console.error('Error sending policy notification email:', error);
45+
return { success: false };
46+
}
47+
};

0 commit comments

Comments
 (0)