From 8c1b525ead980181cd3e8f57e72961c1b82b7e4f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:16:52 -0400 Subject: [PATCH] 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 Co-authored-by: Mariano Fuentes --- apps/app/package.json | 1 + .../accept-requested-policy-changes.ts | 83 ++++++++++- apps/app/tsconfig.json | 4 +- bun.lock | 1 + packages/email/emails/policy-notification.tsx | 133 ++++++++++++++++++ packages/email/index.ts | 2 + packages/email/lib/policy-notification.ts | 47 +++++++ 7 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 packages/email/emails/policy-notification.tsx create mode 100644 packages/email/lib/policy-notification.ts diff --git a/apps/app/package.json b/apps/app/package.json index 9b01f7c48..3a22988cd 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -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", diff --git a/apps/app/src/actions/policies/accept-requested-policy-changes.ts b/apps/app/src/actions/policies/accept-requested-policy-changes.ts index 554299c30..1ec426539 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -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'; @@ -40,6 +41,13 @@ export const acceptRequestedPolicyChangesAction = authActionClient id, organizationId: session.activeOrganizationId, }, + include: { + organization: { + select: { + name: true, + }, + }, + }, }); if (!policy) { @@ -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, @@ -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({ diff --git a/apps/app/tsconfig.json b/apps/app/tsconfig.json index 4855c23fd..d5a802aaf 100644 --- a/apps/app/tsconfig.json +++ b/apps/app/tsconfig.json @@ -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"], diff --git a/bun.lock b/bun.lock index 5f3ada71c..d398adf10 100644 --- a/bun.lock +++ b/bun.lock @@ -156,6 +156,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", diff --git a/packages/email/emails/policy-notification.tsx b/packages/email/emails/policy-notification.tsx new file mode 100644 index 000000000..28c3178ee --- /dev/null +++ b/packages/email/emails/policy-notification.tsx @@ -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 ( + + + + + + + + + {subjectText} + + + + + + {subjectText} + + + + Hi {userName}, + + + + {getBodyText()} + + + + Your organization {organizationName} requires all employees to review and accept this policy. + + +
+ +
+ + + or copy and paste this URL into your browser{' '} + + {link} + + + +
+
+ + This notification was intended for {email}. + +
+ +
+ +