Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@trigger.dev/react-hooks": "4.0.0",
"@trigger.dev/sdk": "4.0.0",
"@trycompai/db": "^1.3.4",
"@trycompai/email": "workspace:*",
"@types/canvas-confetti": "^1.9.0",
"@types/three": "^0.180.0",
"@uploadthing/react": "^7.3.0",
Expand Down
83 changes: 82 additions & 1 deletion apps/app/src/actions/policies/accept-requested-policy-changes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use server';

import { db, PolicyStatus } from '@db';
import { sendPolicyNotificationEmail } from '@trycompai/email';
import { revalidatePath, revalidateTag } from 'next/cache';
import { z } from 'zod';
import { authActionClient } from '../safe-action';
Expand Down Expand Up @@ -40,6 +41,13 @@ export const acceptRequestedPolicyChangesAction = authActionClient
id,
organizationId: session.activeOrganizationId,
},
include: {
organization: {
select: {
name: true,
},
},
},
});

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

// Update policy status
// Check if there were previous signers to determine notification type
const isNewPolicy = policy.lastPublishedAt === null;

// Update policy status and clear signedBy field
await db.policy.update({
where: {
id,
Expand All @@ -59,9 +70,79 @@ export const acceptRequestedPolicyChangesAction = authActionClient
data: {
status: PolicyStatus.published,
approverId: null,
signedBy: [], // Clear the signedBy field
lastPublishedAt: new Date(), // Update last published date
},
});

// Get all employees in the organization to send notifications
const employees = await db.member.findMany({
where: {
organizationId: session.activeOrganizationId,
isActive: true,
},
include: {
user: true,
},
});

// Filter to get only employees
const employeeMembers = employees.filter((member) => {
const roles = member.role.includes(',') ? member.role.split(',') : [member.role];
return roles.includes('employee');
});

// Send notification emails to all employees
// Send emails in batches of 2 per second to respect rate limit
const BATCH_SIZE = 2;
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const sendEmailsInBatches = async () => {
for (let i = 0; i < employeeMembers.length; i += BATCH_SIZE) {
const batch = employeeMembers.slice(i, i + BATCH_SIZE);

await Promise.all(
batch.map(async (employee) => {
if (!employee.user.email) return;

let notificationType: 'new' | 're-acceptance' | 'updated';
const wasAlreadySigned = policy.signedBy.includes(employee.id);
if (isNewPolicy) {
notificationType = 'new';
} else if (wasAlreadySigned) {
notificationType = 're-acceptance';
} else {
notificationType = 'updated';
}

try {
await sendPolicyNotificationEmail({
email: employee.user.email,
userName: employee.user.name || employee.user.email || 'Employee',
policyName: policy.name,
organizationName: policy.organization.name,
organizationId: session.activeOrganizationId,
notificationType,
});
} catch (emailError) {
console.error(`Failed to send email to ${employee.user.email}:`, emailError);
// Don't fail the whole operation if email fails
}
}),
);

// Only delay if there are more emails to send
if (i + BATCH_SIZE < employeeMembers.length) {
await delay(1000); // wait 1 second between batches
}
}
};

// Fire and forget, but log errors if any
sendEmailsInBatches().catch((error) => {
console.error('Some emails failed to send:', error);
});

// If a comment was provided, create a comment
if (comment && comment.trim() !== '') {
const member = await db.member.findFirst({
Expand Down
4 changes: 3 additions & 1 deletion apps/app/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@
"@comp/kv": ["../../packages/kv/src/index.ts"],
"@comp/kv/*": ["../../packages/kv/src/*"],
"@comp/tsconfig": ["../../packages/tsconfig/index.ts"],
"@comp/tsconfig/*": ["../../packages/tsconfig/*"]
"@comp/tsconfig/*": ["../../packages/tsconfig/*"],
"@trycompai/email": ["../../packages/email/index.ts"],
"@trycompai/email/*": ["../../packages/email/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "trigger.config.ts"],
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

133 changes: 133 additions & 0 deletions packages/email/emails/policy-notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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';

interface Props {
email: string;
userName: string;
policyName: string;
organizationName: string;
organizationId: string;
notificationType: 'new' | 'updated' | 're-acceptance';
}

export const PolicyNotificationEmail = ({
email,
userName,
policyName,
organizationName,
organizationId,
notificationType,
}: Props) => {
const link = `${process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'}/${organizationId}`;
const subjectText = 'Please review and accept this policy';

const getBodyText = () => {
switch (notificationType) {
case 'new':
return `The "${policyName}" policy has been created.`;
case 'updated':
case 're-acceptance':
return `The "${policyName}" policy has been updated.`;
default:
return `Please review and accept the policy "${policyName}".`;
}
};

return (
<Html>
<Tailwind>
<head>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
webFont={{
url: 'https://app.trycomp.ai/fonts/geist/geist-sans-latin-400-normal.woff2',
format: 'woff2',
}}
fontWeight={400}
fontStyle="normal"
/>

<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
webFont={{
url: 'https://app.trycomp.ai/fonts/geist/geist-sans-latin-500-normal.woff2',
format: 'woff2',
}}
fontWeight={500}
fontStyle="normal"
/>
</head>

<Preview>{subjectText}</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]">
{subjectText}
</Heading>

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

<Text className="text-[14px] leading-[24px] text-[#121212]">
{getBodyText()}
</Text>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Your organization <strong>{organizationName}</strong> requires all employees to review and accept this policy.
</Text>

<Section className="mt-[32px] mb-[42px] text-center">
<Button
className="text-primary border border-solid border-[#121212] bg-transparent px-6 py-3 text-center text-[14px] font-medium text-[#121212] no-underline"
href={link}
>
Review & Accept Policy
</Button>
</Section>

<Text className="text-[14px] leading-[24px] break-all text-[#707070]">
or copy and paste this URL into your browser{' '}
<Link href={link} className="text-[#707070] underline">
{link}
</Link>
</Text>

<br />
<Section>
<Text className="text-[12px] leading-[24px] text-[#666666]">
This notification was intended for <span className="text-[#121212]">{email}</span>.
</Text>
</Section>

<br />

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

export default PolicyNotificationEmail;
2 changes: 2 additions & 0 deletions packages/email/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ export * from './emails/invite-portal';
export * from './emails/magic-link';
export * from './emails/marketing/welcome';
export * from './emails/otp';
export * from './emails/policy-notification';
export * from './emails/waitlist';

// Email sending functions
export * from './lib/invite-member';
export * from './lib/magic-link';
export * from './lib/policy-notification';
export * from './lib/resend';
export * from './lib/waitlist';
47 changes: 47 additions & 0 deletions packages/email/lib/policy-notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import PolicyNotificationEmail from '../emails/policy-notification';
import { sendEmail } from './resend';

export const sendPolicyNotificationEmail = async (params: {
email: string;
userName: string;
policyName: string;
organizationName: string;
organizationId: string;
notificationType: 'new' | 'updated' | 're-acceptance';
}) => {
const {
email,
userName,
policyName,
organizationName,
organizationId,
notificationType,
} = params;
const subjectText = 'Please review and accept this policy';

try {
const sent = await sendEmail({
to: email,
subject: subjectText,
react: PolicyNotificationEmail({
email,
userName,
policyName,
organizationName,
organizationId,
notificationType,
}),
system: true, // Use system email address
});

if (!sent) {
console.error('Failed to send policy notification email');
return { success: false };
}

return { success: true };
} catch (error) {
console.error('Error sending policy notification email:', error);
return { success: false };
}
};
Loading