From 81859581f70ddeb9baff76c5b8b53c65987c24d3 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Sun, 12 Oct 2025 12:07:09 -0400 Subject: [PATCH 1/3] fix(app): trigger 2 new-policy-email events per second at most due to resend rate limit --- .../src/app/api/send-policy-email/route.ts | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/apps/app/src/app/api/send-policy-email/route.ts b/apps/app/src/app/api/send-policy-email/route.ts index ad8457774..43cd36f3b 100644 --- a/apps/app/src/app/api/send-policy-email/route.ts +++ b/apps/app/src/app/api/send-policy-email/route.ts @@ -26,16 +26,30 @@ export async function POST(request: NextRequest) { const novu = new Novu({ secretKey: novuApiKey }); try { - const result = await novu.triggerBulk({ - events: events.map((event: any) => ({ - workflowId: "new-policy-email", - to: { - subscriberId: event.subscriberId, - email: event.email, - }, - payload: event, - })), - }); + // Throttle calls: process in batches of 2 events per second to avoid rate limit + const batchSize = 2; + const results: unknown[] = []; + for (let i = 0; i < events.length; i += batchSize) { + const batch = events.slice(i, i + batchSize); + const batchResults = await Promise.all( + batch.map((event: any) => + novu.trigger({ + workflowId: "new-policy-email", + to: { + subscriberId: event.subscriberId, + email: event.email, + }, + payload: event, + }) + ) + ); + results.push(...batchResults); + // If there are more batches to process, wait 1 second + if (i + batchSize < events.length) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + const result = { triggered: results.length, details: results }; return NextResponse.json({ success: true, result }); } catch (error) { From 7df0b62fdfecd9a0e5cfe8c622cca43393fa0fe9 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 15 Oct 2025 22:51:34 -0400 Subject: [PATCH 2/3] fix(app): create trigger.dev task to send policy email due to resend rate limit issue --- .../accept-requested-policy-changes.ts | 48 ++++++++++-------- .../src/jobs/tasks/email/new-policy-email.ts | 49 +++++++++++++++++++ 2 files changed, 76 insertions(+), 21 deletions(-) create mode 100644 apps/app/src/jobs/tasks/email/new-policy-email.ts 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 bdaa73637..a457e4eb1 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,8 @@ 'use server'; +import { sendNewPolicyEmail } from '@/jobs/tasks/email/new-policy-email'; import { db, PolicyStatus } from '@db'; +import { tasks } from '@trigger.dev/sdk'; import { revalidatePath, revalidateTag } from 'next/cache'; import { z } from 'zod'; import { authActionClient } from '../safe-action'; @@ -97,29 +99,33 @@ export const acceptRequestedPolicyChangesAction = authActionClient // Prepare the events array for the API const events = employeeMembers .filter((employee) => employee.user.email) - .map((employee) => ({ - subscriberId: `${employee.user.id}-${session.activeOrganizationId}`, - email: employee.user.email, - userName: employee.user.name || employee.user.email || 'Employee', - policyName: policy.name, - organizationName: policy.organization.name, - url: `${process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'}/${session.activeOrganizationId}`, - description: `The "${policy.name}" policy has been ${isNewPolicy ? 'created' : 'updated'}.`, - })); + .map((employee) => { + 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'; + } + + return { + email: employee.user.email, + userName: employee.user.name || employee.user.email || 'Employee', + policyName: policy.name, + organizationId: session.activeOrganizationId || '', + organizationName: policy.organization.name, + notificationType, + }; + }); // Call the API route to send the emails - try { - await fetch(`${process.env.BETTER_AUTH_URL ?? ''}/api/send-policy-email`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(events), - }); - } catch (error) { - console.error('Failed to call /api/send-policy-email:', error); - // Don't throw, just log - } + await Promise.all( + events.map((event) => + tasks.trigger('send-new-policy-email', event), + ), + ); // If a comment was provided, create a comment if (comment && comment.trim() !== '') { diff --git a/apps/app/src/jobs/tasks/email/new-policy-email.ts b/apps/app/src/jobs/tasks/email/new-policy-email.ts new file mode 100644 index 000000000..b9ee7f4da --- /dev/null +++ b/apps/app/src/jobs/tasks/email/new-policy-email.ts @@ -0,0 +1,49 @@ +import { sendPolicyNotificationEmail } from '@comp/email'; +import { logger, queue, task } from '@trigger.dev/sdk'; + +// Queue with concurrency limit of 1 to ensure rate limiting (1 email per second max) +const policyEmailQueue = queue({ + name: 'policy-email-queue', + concurrencyLimit: 2, +}); + +interface PolicyEmailPayload { + email: string; + userName: string; + policyName: string; + organizationId: string; + organizationName: string; + notificationType: 'new' | 'updated' | 're-acceptance'; +} + +export const sendNewPolicyEmail = task({ + id: 'send-new-policy-email', + queue: policyEmailQueue, + run: async (payload: PolicyEmailPayload) => { + logger.info('Sending new policy email', { + email: payload.email, + policyName: payload.policyName, + }); + + try { + await sendPolicyNotificationEmail(payload); + + logger.info('Successfully sent policy email', { + email: payload.email, + policyName: payload.policyName, + }); + + return { + success: true, + email: payload.email, + }; + } catch (error) { + logger.error('Failed to send policy email', { + email: payload.email, + error: error instanceof Error ? error.message : String(error), + }); + + throw error; + } + }, +}); From 4a2449cff5cf70dc73283af69cb7debc88e83cdf Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 15 Oct 2025 22:56:00 -0400 Subject: [PATCH 3/3] fix(app): remove unused send-policy-email API --- .../accept-requested-policy-changes.ts | 2 - .../src/app/api/send-policy-email/route.ts | 64 ------------------- 2 files changed, 66 deletions(-) delete mode 100644 apps/app/src/app/api/send-policy-email/route.ts 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 a457e4eb1..c1ca22993 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -94,8 +94,6 @@ export const acceptRequestedPolicyChangesAction = authActionClient return roles.includes('employee'); }); - // Call /api/send-policy-email to send emails to employees - // Prepare the events array for the API const events = employeeMembers .filter((employee) => employee.user.email) diff --git a/apps/app/src/app/api/send-policy-email/route.ts b/apps/app/src/app/api/send-policy-email/route.ts deleted file mode 100644 index 43cd36f3b..000000000 --- a/apps/app/src/app/api/send-policy-email/route.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { Novu } from '@novu/api'; - -export async function POST(request: NextRequest) { - let events; - try { - events = await request.json(); - } catch (error) { - return NextResponse.json( - { success: false, error: 'Invalid JSON in request body' }, - { status: 400 } - ); - } - - // You may want to validate required fields in the body here - // For now, we just pass the whole body to Novu - - const novuApiKey = process.env.NOVU_API_KEY; - if (!novuApiKey) { - return NextResponse.json( - { success: false, error: 'Novu API key not configured' }, - { status: 500 } - ); - } - - const novu = new Novu({ secretKey: novuApiKey }); - - try { - // Throttle calls: process in batches of 2 events per second to avoid rate limit - const batchSize = 2; - const results: unknown[] = []; - for (let i = 0; i < events.length; i += batchSize) { - const batch = events.slice(i, i + batchSize); - const batchResults = await Promise.all( - batch.map((event: any) => - novu.trigger({ - workflowId: "new-policy-email", - to: { - subscriberId: event.subscriberId, - email: event.email, - }, - payload: event, - }) - ) - ); - results.push(...batchResults); - // If there are more batches to process, wait 1 second - if (i + batchSize < events.length) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - const result = { triggered: results.length, details: results }; - - return NextResponse.json({ success: true, result }); - } catch (error) { - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to trigger notification', - }, - { status: 500 } - ); - } -}