From 3596376014660ce688d5ee13efed9bae880a18ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:27:04 -0400 Subject: [PATCH 01/14] fix(portal): fix build error to add AUTH_SECRET env to portal (#1590) Co-authored-by: chasprowebdev --- apps/portal/src/app/lib/auth.ts | 3 ++- apps/portal/src/env.mjs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/portal/src/app/lib/auth.ts b/apps/portal/src/app/lib/auth.ts index b7eadfcb3..45b8fc073 100644 --- a/apps/portal/src/app/lib/auth.ts +++ b/apps/portal/src/app/lib/auth.ts @@ -5,6 +5,7 @@ import { prismaAdapter } from 'better-auth/adapters/prisma'; import { nextCookies } from 'better-auth/next-js'; import { emailOTP, multiSession, organization } from 'better-auth/plugins'; import { ac, admin, auditor, employee, owner } from './permissions'; +import { env } from '@/env.mjs'; export const auth = betterAuth({ database: prismaAdapter(db, { @@ -16,7 +17,7 @@ export const auth = betterAuth({ generateId: false, }, trustedOrigins: ['http://localhost:3000', 'https://*.trycomp.ai'], - secret: process.env.AUTH_SECRET!, + secret: env.AUTH_SECRET!, plugins: [ organization({ membershipLimit: 100000000000, diff --git a/apps/portal/src/env.mjs b/apps/portal/src/env.mjs index 07eed2712..2f7086a70 100644 --- a/apps/portal/src/env.mjs +++ b/apps/portal/src/env.mjs @@ -10,6 +10,7 @@ export const env = createEnv({ UPSTASH_REDIS_REST_TOKEN: z.string().optional(), AUTH_GOOGLE_ID: z.string(), AUTH_GOOGLE_SECRET: z.string(), + AUTH_SECRET: z.string(), }, client: { @@ -29,6 +30,7 @@ export const env = createEnv({ UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET, + AUTH_SECRET: process.env.AUTH_SECRET, }, skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION, From 572003fba63662f7c92ef9efb69afbbda527f1d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 10:00:34 -0400 Subject: [PATCH 02/14] fix(app): onboarding flow being overriden (#1595) Co-authored-by: chasprowebdev --- .../components/PostPaymentOnboarding.tsx | 45 +++++++++++-------- .../hooks/usePostPaymentOnboarding.ts | 5 --- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index 80fa16566..29fa8f756 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -94,31 +94,38 @@ export function PostPaymentOnboarding({ - {!isLoading && step && ( -
+ {!isLoading && ( + - ( - - - - -
- -
-
- )} - /> + {steps.map((s, idx) => ( +
+ ( + + + + +
+ +
+
+ )} + /> +
+ ))} )} diff --git a/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts b/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts index 11df27210..08943bcd9 100644 --- a/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts +++ b/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts @@ -77,11 +77,6 @@ export function usePostPaymentOnboarding({ defaultValues: { [step.key]: savedAnswers[step.key] || '' }, }); - // Reset form when step changes - useEffect(() => { - form.reset({ [step.key]: savedAnswers[step.key] || '' }); - }, [savedAnswers, step.key, form]); - // Track onboarding start useEffect(() => { trackEvent('onboarding_started', { From ab82c4ade9569c292abc3f4416a0fbf71f875ab5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 10:13:12 -0400 Subject: [PATCH 03/14] ENG-30 Implement in-app notifications - Novu (#1589) * feat(app): implement in-app notification & email for policy-schedule with Novu * feat(app): trigger notification in task-schedule * fix(app): update in-app subscriber when switching org * fix(app): customize Inbox component * fix(app): hide Inbox after clicking on Notification Item * fix(app): show unread indicator if total is greater than 0 * feat(app): send email via Novu when the policy has been published * fix(app): remove notification icon and minor style updates on Inbox * fix(app): use triggerBulk to trigger notifications --------- Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes --- apps/app/package.json | 2 + .../accept-requested-policy-changes.ts | 79 +++----- .../src/app/api/send-policy-email/route.ts | 50 +++++ apps/app/src/components/header.tsx | 6 +- .../notifications/notification-bell.tsx | 98 +++++++++ apps/app/src/env.mjs | 4 + .../src/jobs/tasks/task/policy-schedule.ts | 170 ++++++++-------- apps/app/src/jobs/tasks/task/task-schedule.ts | 189 ++++++++---------- .../emails/policy-review-notification.tsx | 117 ----------- .../email/emails/task-review-notification.tsx | 115 ----------- packages/email/index.ts | 4 - .../email/lib/policy-review-notification.ts | 42 ---- .../email/lib/task-review-notification.ts | 41 ---- 13 files changed, 355 insertions(+), 562 deletions(-) create mode 100644 apps/app/src/app/api/send-policy-email/route.ts create mode 100644 apps/app/src/components/notifications/notification-bell.tsx delete mode 100644 packages/email/emails/policy-review-notification.tsx delete mode 100644 packages/email/emails/task-review-notification.tsx delete mode 100644 packages/email/lib/policy-review-notification.ts delete mode 100644 packages/email/lib/task-review-notification.ts diff --git a/apps/app/package.json b/apps/app/package.json index e746ddbf4..10b39d0fa 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -29,6 +29,8 @@ "@monaco-editor/react": "^4.7.0", "@nangohq/frontend": "^0.53.2", "@next/third-parties": "^15.3.1", + "@novu/api": "^1.6.0", + "@novu/nextjs": "^3.10.1", "@number-flow/react": "^0.5.9", "@prisma/client": "^6.13.0", "@prisma/instrumentation": "6.6.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 1ec426539..f568c5c06 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -1,7 +1,6 @@ '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'; @@ -92,56 +91,34 @@ export const acceptRequestedPolicyChangesAction = authActionClient 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); - }); + // 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) + .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_APP_URL ?? 'https://app.trycomp.ai'}/${session.activeOrganizationId}/policies/${policy.id}`, + description: `The "${policy.name}" policy has been ${isNewPolicy ? 'created' : 'updated'}.`, + })); + + // 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 + } // If a comment was provided, create a comment if (comment && comment.trim() !== '') { diff --git a/apps/app/src/app/api/send-policy-email/route.ts b/apps/app/src/app/api/send-policy-email/route.ts new file mode 100644 index 000000000..ad8457774 --- /dev/null +++ b/apps/app/src/app/api/send-policy-email/route.ts @@ -0,0 +1,50 @@ +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 { + const result = await novu.triggerBulk({ + events: events.map((event: any) => ({ + workflowId: "new-policy-email", + to: { + subscriberId: event.subscriberId, + email: event.email, + }, + payload: event, + })), + }); + + 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 } + ); + } +} diff --git a/apps/app/src/components/header.tsx b/apps/app/src/components/header.tsx index 65e4d9185..5b938c832 100644 --- a/apps/app/src/components/header.tsx +++ b/apps/app/src/components/header.tsx @@ -4,6 +4,7 @@ import { Skeleton } from '@comp/ui/skeleton'; import { Suspense } from 'react'; import { AssistantButton } from './ai/chat-button'; import { MobileMenu } from './mobile-menu'; +import { NotificationBell } from './notifications/notification-bell'; export async function Header({ organizationId, @@ -20,7 +21,10 @@ export async function Header({ {!hideChat && } -
+
+ +
+
}> diff --git a/apps/app/src/components/notifications/notification-bell.tsx b/apps/app/src/components/notifications/notification-bell.tsx new file mode 100644 index 000000000..87cb72cc7 --- /dev/null +++ b/apps/app/src/components/notifications/notification-bell.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { env } from '@/env.mjs'; +import { Inbox } from '@novu/nextjs'; +import { useSession } from '@/utils/auth-client'; +import { Bell, Settings } from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; + +export function NotificationBell() { + const applicationIdentifier = env.NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER; + const { data: session } = useSession(); + const sessionData = session?.session; + const pathname = usePathname(); + const orgId = pathname?.split('/')[1] || null; + const [visible, setVisible] = useState(false); + const inboxRef = useRef(null); + + // Handle click outside to close inbox + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (inboxRef.current && !inboxRef.current.contains(event.target as Node)) { + setVisible(false); + } + } + + if (visible) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [visible]); + + // Don't render if we don't have the required config + if (!applicationIdentifier || !sessionData?.userId || !orgId) { + return null; + } + + const appearance = { + icons: { + cogs: () => , + }, + elements: { + popoverContent: { + right: '8px', + left: 'auto !important', + marginTop: '8px', + width: '360px', + borderRadius: '8px', + }, + notification: { + paddingLeft: '24px', + }, + notificationDot: { + backgroundColor: 'hsl(var(--primary))', + }, + notificationImage: { + display: 'none', + }, + notificationBar: ({ notification }: { notification: any }) => { + return notification.isRead ? 'bg-transparent' : 'bg-primary'; + } + } + }; + + return ( +
+ ( + + )} + renderSubject={(notification) => {notification.subject}} + renderBody={(notification) => ( +
+

+ {notification.body} +

+
+ )} + onNotificationClick={() => setVisible(false)} + /> +
+ ); +} diff --git a/apps/app/src/env.mjs b/apps/app/src/env.mjs index 2793771a7..522fe452e 100644 --- a/apps/app/src/env.mjs +++ b/apps/app/src/env.mjs @@ -35,6 +35,7 @@ export const env = createEnv({ GA4_API_SECRET: z.string().optional(), GA4_MEASUREMENT_ID: z.string().optional(), LINKEDIN_CONVERSIONS_ACCESS_TOKEN: z.string().optional(), + NOVU_API_KEY: z.string().optional(), }, client: { @@ -47,6 +48,7 @@ export const env = createEnv({ NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL: z.string().optional(), NEXT_PUBLIC_API_URL: z.string().optional(), NEXT_PUBLIC_BETTER_AUTH_URL: z.string().optional(), + NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER: z.string().optional(), }, runtimeEnv: { @@ -91,6 +93,8 @@ export const env = createEnv({ NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL: process.env.NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_BETTER_AUTH_URL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL, + NOVU_API_KEY: process.env.NOVU_API_KEY, + NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER: process.env.NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER, }, skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION, diff --git a/apps/app/src/jobs/tasks/task/policy-schedule.ts b/apps/app/src/jobs/tasks/task/policy-schedule.ts index 32fed28a0..877d0102c 100644 --- a/apps/app/src/jobs/tasks/task/policy-schedule.ts +++ b/apps/app/src/jobs/tasks/task/policy-schedule.ts @@ -1,5 +1,5 @@ import { db } from '@db'; -import { sendPolicyReviewNotificationEmail } from '@trycompai/email'; +import { Novu } from '@novu/api'; import { logger, schedules } from '@trigger.dev/sdk'; export const policySchedule = schedules.task({ @@ -9,6 +9,10 @@ export const policySchedule = schedules.task({ run: async () => { const now = new Date(); + const novu = new Novu({ + secretKey: process.env.NOVU_API_KEY + }); + // Find all published policies that have a review date and frequency set const candidatePolicies = await db.policy.findMany({ where: { @@ -23,12 +27,33 @@ export const policySchedule = schedules.task({ include: { organization: { select: { + id: true, name: true, + members: { + where: { + role: { contains: 'owner' } + }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }, }, }, assignee: { - include: { - user: true, + select: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, }, }, }, @@ -96,101 +121,74 @@ export const policySchedule = schedules.task({ }, }); - // Log details about updated policies - overduePolicies.forEach((policy) => { - logger.info( - `Updated policy "${policy.name}" (${policy.id}) from org "${policy.organization.name}" - frequency ${policy.frequency} - last reviewed ${policy.reviewDate?.toISOString()}`, - ); - }); - - logger.info(`Successfully updated ${updateResult.count} policies to "needs_review" status`); - - // Build a map of owners by organization for targeted notifications - const uniqueOrgIds = Array.from(new Set(overduePolicies.map((p) => p.organizationId))); - const owners = await db.member.findMany({ - where: { - organizationId: { in: uniqueOrgIds }, - isActive: true, - // role is a comma-separated string sometimes - role: { contains: 'owner' }, - }, - include: { - user: true, - }, - }); - - const ownersByOrgId = new Map(); - owners.forEach((owner) => { - const email = owner.user?.email; - if (!email) return; - const list = ownersByOrgId.get(owner.organizationId) ?? []; - list.push({ email, name: owner.user.name ?? email }); - ownersByOrgId.set(owner.organizationId, list); - }); - - // Send review notifications to org owners and the policy assignee only - // Send review notifications to org owners and the policy assignee only, rate-limited to 2 emails/sec - const EMAIL_BATCH_SIZE = 2; - const EMAIL_BATCH_DELAY_MS = 1000; - - // Build a flat list of all emails to send, with their policy context - type EmailJob = { + // Build array of recipients (org owner(s) and policy assignee(s)) for each overdue policy + const recipientsMap = new Map(); + const addRecipients = ( + users: Array<{ user: { id: string; email: string; name?: string } }>, + policy: typeof overduePolicies[number], + ) => { + for (const entry of users) { + const user = entry.user; + if (user && user.email && user.id) { + const key = `${user.id}-${policy.id}`; + if (!recipientsMap.has(key)) { + recipientsMap.set(key, { + email: user.email, + userId: user.id, + name: user.name ?? '', + policy, + }); + } + } + } }; - const emailJobs: EmailJob[] = []; + // trigger notification for each policy for (const policy of overduePolicies) { - const recipients = new Map(); // email -> name - - // Assignee (if any) - const assigneeEmail = policy.assignee?.user?.email; - if (assigneeEmail) { - recipients.set(assigneeEmail, policy.assignee?.user?.name ?? assigneeEmail); - } - - // Organization owners - const orgOwners = ownersByOrgId.get(policy.organizationId) ?? []; - orgOwners.forEach((o) => recipients.set(o.email, o.name)); - - if (recipients.size === 0) { - logger.info(`No recipients found for policy ${policy.id} (${policy.name})`); - continue; + // Org owners + if (policy.organization && Array.isArray(policy.organization.members)) { + addRecipients(policy.organization.members, policy); } - - for (const [email, name] of recipients.entries()) { - emailJobs.push({ email, name, policy }); + // Policy assignee + if (policy.assignee) { + addRecipients([policy.assignee], policy); } } - // Send emails in batches of EMAIL_BATCH_SIZE per second - for (let i = 0; i < emailJobs.length; i += EMAIL_BATCH_SIZE) { - const batch = emailJobs.slice(i, i + EMAIL_BATCH_SIZE); - - await Promise.all( - batch.map(async ({ email, name, policy }) => { - try { - await sendPolicyReviewNotificationEmail({ - email, - userName: name, - policyName: policy.name, - organizationName: policy.organization.name, - organizationId: policy.organizationId, - policyId: policy.id, - }); - logger.info(`Sent policy review notification to ${email} for policy ${policy.id}`); - } catch (emailError) { - logger.error(`Failed to send review email to ${email} for policy ${policy.id}: ${emailError}`); - } - }), + // Final deduplicated recipients array + const recipients = Array.from(recipientsMap.values()); + novu.triggerBulk({ + events: recipients.map((recipient) => ({ + workflowId: 'policy-review-required', + to: { + subscriberId: `${recipient.userId}-${recipient.policy.organizationId}`, + email: recipient.email, + }, + payload: { + email: recipient.email, + userName: recipient.name, + policyName: recipient.policy.name, + organizationName: recipient.policy.organization.name, + organizationId: recipient.policy.organizationId, + policyId: recipient.policy.id, + policyUrl: `${process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.trycomp.ai'}/${recipient.policy.organizationId}/policies/${recipient.policy.id}`, + } + })), + }); + + // Log details about updated policies + overduePolicies.forEach((policy) => { + logger.info( + `Updated policy "${policy.name}" (${policy.id}) from org "${policy.organization.name}" - frequency ${policy.frequency} - last reviewed ${policy.reviewDate?.toISOString()}`, ); + }); - // Only delay if there are more emails to send - if (i + EMAIL_BATCH_SIZE < emailJobs.length) { - await new Promise((resolve) => setTimeout(resolve, EMAIL_BATCH_DELAY_MS)); - } - } + logger.info(`Successfully updated ${updateResult.count} policies to "needs_review" status`); return { success: true, diff --git a/apps/app/src/jobs/tasks/task/task-schedule.ts b/apps/app/src/jobs/tasks/task/task-schedule.ts index 303d781ed..bf43d4ded 100644 --- a/apps/app/src/jobs/tasks/task/task-schedule.ts +++ b/apps/app/src/jobs/tasks/task/task-schedule.ts @@ -1,5 +1,5 @@ import { db } from '@db'; -import { sendTaskReviewNotificationEmail } from '@trycompai/email'; +import { Novu } from '@novu/api'; import { logger, schedules } from '@trigger.dev/sdk'; export const taskSchedule = schedules.task({ @@ -8,6 +8,9 @@ export const taskSchedule = schedules.task({ maxDuration: 1000 * 60 * 10, // 10 minutes run: async () => { const now = new Date(); + const novu = new Novu({ + secretKey: process.env.NOVU_API_KEY + }); // Find all Done tasks that have a review date and frequency set const candidateTasks = await db.task.findMany({ @@ -23,18 +26,39 @@ export const taskSchedule = schedules.task({ include: { organization: { select: { + id: true, name: true, + members: { + where: { + role: { contains: 'owner' } + }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }, }, }, assignee: { - include: { - user: true, + select: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, }, }, }, }); - // Helpers to compute next due date based on frequency + // FIle all tasks past their review deadline. const addDaysToDate = (date: Date, days: number) => { const result = new Date(date.getTime()); result.setDate(result.getDate() + days); @@ -90,8 +114,8 @@ export const taskSchedule = schedules.task({ }; } - // Update all overdue tasks to "todo" status try { + // Update all overdue tasks to "todo" status const taskIds = overdueTasks.map((task) => task.id); const updateResult = await db.task.updateMany({ @@ -105,119 +129,74 @@ export const taskSchedule = schedules.task({ }, }); - - - // Log details about updated tasks - overdueTasks.forEach((task) => { - logger.info( - `Updated task "${task.title}" (${task.id}) from org "${task.organization.name}" - frequency ${task.frequency} - last reviewed ${task.reviewDate?.toISOString()}`, - ); - }); - - logger.info(`Successfully updated ${updateResult.count} tasks to "todo" status`); - - // Build a map of admins by organization for targeted notifications - const uniqueOrgIds = Array.from(new Set(overdueTasks.map((t) => t.organizationId))); - const admins = await db.member.findMany({ - where: { - organizationId: { in: uniqueOrgIds }, - isActive: true, - // role is a comma-separated string sometimes - role: { contains: 'admin' }, - }, - include: { - user: true, - }, - }); - - const adminsByOrgId = new Map(); - admins.forEach((admin) => { - const email = admin.user?.email; - if (!email) return; - const list = adminsByOrgId.get(admin.organizationId) ?? []; - list.push({ email, name: admin.user.name ?? email }); - adminsByOrgId.set(admin.organizationId, list); - }); - - // Rate limit: 2 emails per second - const EMAIL_BATCH_SIZE = 2; - const EMAIL_BATCH_DELAY_MS = 1000; - - // Build a flat list of email jobs - type EmailJob = { + const recipientsMap = new Map { - switch (frequency) { - case 'daily': - return addDaysToDate(reviewDate, 1); - case 'weekly': - return addDaysToDate(reviewDate, 7); - case 'monthly': - return addMonthsToDate(reviewDate, 1); - case 'quarterly': - return addMonthsToDate(reviewDate, 3); - case 'yearly': - return addMonthsToDate(reviewDate, 12); - default: - return null; + }>(); + const addRecipients = ( + users: Array<{ user: { id: string; email: string; name?: string } }>, + task: typeof overdueTasks[number], + ) => { + for (const entry of users) { + const user = entry.user; + if (user && user.email && user.id) { + const key = `${user.id}-${task.id}`; + if (!recipientsMap.has(key)) { + recipientsMap.set(key, { + email: user.email, + userId: user.id, + name: user.name ?? '', + task, + }); + } + } } }; + // Find recipients (org owner and assignee) for each task and add to recipientsMap for (const task of overdueTasks) { - const recipients = new Map(); // email -> name - - // Assignee (if any) - const assigneeEmail = task.assignee?.user?.email; - if (assigneeEmail) { - recipients.set(assigneeEmail, task.assignee?.user?.name ?? assigneeEmail); - } - - // Organization admins - const orgAdmins = adminsByOrgId.get(task.organizationId) ?? []; - orgAdmins.forEach((a) => recipients.set(a.email, a.name)); - - if (recipients.size === 0) { - logger.info(`No recipients found for task ${task.id} (${task.title})`); - continue; + // Org owners + if (task.organization && Array.isArray(task.organization.members)) { + addRecipients(task.organization.members, task); } - - for (const [email, name] of recipients.entries()) { - emailJobs.push({ email, name, task }); + // Policy assignee + if (task.assignee) { + addRecipients([task.assignee], task); } } - for (let i = 0; i < emailJobs.length; i += EMAIL_BATCH_SIZE) { - const batch = emailJobs.slice(i, i + EMAIL_BATCH_SIZE); - - await Promise.all( - batch.map(async ({ email, name, task }) => { - try { - await sendTaskReviewNotificationEmail({ - email, - userName: name, - taskName: task.title, - organizationName: task.organization.name, - organizationId: task.organizationId, - taskId: task.id, - }); - logger.info(`Sent task review notification to ${email} for task ${task.id}`); - } catch (emailError) { - logger.error(`Failed to send review email to ${email} for task ${task.id}: ${emailError}`); - } - }), + // Final deduplicated recipients array. + const recipients = Array.from(recipientsMap.values()); + // Trigger notification for each recipient. + novu.triggerBulk({ + events: recipients.map((recipient) => ({ + workflowId: 'task-review-required', + to: { + subscriberId: `${recipient.userId}-${recipient.task.organizationId}`, + email: recipient.email, + }, + payload: { + email: recipient.email, + userName: recipient.name, + taskName: recipient.task.title, + organizationName: recipient.task.organization.name, + organizationId: recipient.task.organizationId, + taskId: recipient.task.id, + taskUrl: `${process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.trycomp.ai'}/${recipient.task.organizationId}/tasks/${recipient.task.id}`, + } + })), + }); + + // Log details about updated tasks + overdueTasks.forEach((task) => { + logger.info( + `Updated task "${task.title}" (${task.id}) from org "${task.organization.name}" - frequency ${task.frequency} - last reviewed ${task.reviewDate?.toISOString()}`, ); + }); - // Only delay if there are more emails to send - if (i + EMAIL_BATCH_SIZE < emailJobs.length) { - await new Promise((resolve) => setTimeout(resolve, EMAIL_BATCH_DELAY_MS)); - } - } + logger.info(`Successfully updated ${updateResult.count} tasks to "todo" status`); return { success: true, diff --git a/packages/email/emails/policy-review-notification.tsx b/packages/email/emails/policy-review-notification.tsx deleted file mode 100644 index 917cf532a..000000000 --- a/packages/email/emails/policy-review-notification.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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; - policyId: string; -} - -export const PolicyReviewNotificationEmail = ({ - email, - userName, - policyName, - organizationName, - organizationId, - policyId, -}: Props) => { - const link = `${process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.trycomp.ai'}/${organizationId}/policies/${policyId}`; - const subjectText = 'Policy review required'; - - return ( - - - - - - - - - {subjectText} - - - - - - {subjectText} - - - Hi {userName}, - - - The "{policyName}" policy for {organizationName} is due for review. Please review and publish. - - -
- -
- - - or copy and paste this URL into your browser{' '} - - {link} - - - -
-
- - This notification was intended for {email}. - -
- -
- -