Removes resend npm dependency. Adds lib/notifications/mailer.ts with a raw-HTTP Brevo wrapper (sendEmail + sendPasswordResetEmail). All three notification files updated to use the new wrapper. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
150 lines
5.1 KiB
TypeScript
150 lines
5.1 KiB
TypeScript
import 'server-only'
|
|
import { sendEmail } from '@/lib/notifications/mailer'
|
|
import { asAdmin } from '@/lib/db/with-user'
|
|
import { capaActions, incidents, users, notificationsLog } from '@/lib/db/schema'
|
|
import { and, eq, inArray, not } from 'drizzle-orm'
|
|
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
|
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
|
import { getApiKey } from '@/lib/settings'
|
|
|
|
export type EscalationThreshold = 'warning_3d' | 'due_today' | 'overdue_3d' | 'overdue_7d'
|
|
|
|
export function getEscalationThreshold(dueDateIso: string): EscalationThreshold | null {
|
|
const today = new Date()
|
|
today.setUTCHours(0, 0, 0, 0)
|
|
const due = new Date(dueDateIso)
|
|
// date-only ISO strings are parsed as UTC midnight — keep due in UTC too
|
|
const diffDays = Math.round((due.getTime() - today.getTime()) / 86_400_000)
|
|
|
|
if (diffDays === 3) return 'warning_3d'
|
|
if (diffDays === 0) return 'due_today'
|
|
if (diffDays === -3) return 'overdue_3d'
|
|
if (diffDays === -7) return 'overdue_7d'
|
|
return null
|
|
}
|
|
|
|
const THRESHOLD_SUBJECT: Record<EscalationThreshold, string> = {
|
|
warning_3d: '[IMS] CAPA action due in 3 days',
|
|
due_today: '[IMS] CAPA action due TODAY',
|
|
overdue_3d: '[IMS] CAPA action 3 days OVERDUE',
|
|
overdue_7d: '[IMS] URGENT: CAPA action 7 days OVERDUE',
|
|
}
|
|
|
|
export async function escalateOverdueCapa(): Promise<{ notified: number }> {
|
|
const capas = await asAdmin(db =>
|
|
db.select({
|
|
id: capaActions.id,
|
|
description: capaActions.description,
|
|
dueDate: capaActions.dueDate,
|
|
incidentId: capaActions.incidentId,
|
|
ownerUserId: capaActions.ownerUserId,
|
|
incidentRef: incidents.referenceNo,
|
|
siteId: incidents.siteId,
|
|
ownerEmail: users.email,
|
|
ownerName: users.name,
|
|
ownerPhone: users.phone,
|
|
})
|
|
.from(capaActions)
|
|
.leftJoin(incidents, eq(capaActions.incidentId, incidents.id))
|
|
.leftJoin(users, eq(capaActions.ownerUserId, users.id))
|
|
.where(not(inArray(capaActions.status, ['verified', 'closed'])))
|
|
)
|
|
|
|
if (!capas || capas.length === 0) return { notified: 0 }
|
|
|
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
|
|
let notified = 0
|
|
|
|
const whatsappPhoneNumberId = await getApiKey('META_WHATSAPP_PHONE_NUMBER_ID')
|
|
const whatsappAccessToken = await getApiKey('META_WHATSAPP_ACCESS_TOKEN')
|
|
|
|
for (const capa of capas) {
|
|
const threshold = getEscalationThreshold(capa.dueDate)
|
|
if (!threshold) continue
|
|
|
|
const alreadySent = await asAdmin(db =>
|
|
db.select({ id: notificationsLog.id })
|
|
.from(notificationsLog)
|
|
.where(and(
|
|
eq(notificationsLog.capaId, capa.id),
|
|
eq(notificationsLog.channel, 'email'),
|
|
eq(notificationsLog.status, threshold),
|
|
))
|
|
.limit(1)
|
|
)
|
|
if (alreadySent.length > 0) continue
|
|
|
|
const ownerEmail = capa.ownerEmail
|
|
const ownerName = capa.ownerName ?? 'Owner'
|
|
if (!ownerEmail) continue
|
|
|
|
const incidentRef = capa.incidentRef ?? capa.incidentId
|
|
const capaUrl = `${siteUrl}/ims/hse/capa/${capa.id}`
|
|
const subject = THRESHOLD_SUBJECT[threshold]
|
|
const html = `
|
|
<p>Hi ${ownerName},</p>
|
|
<p>CAPA action for incident <strong>${incidentRef}</strong> requires attention.</p>
|
|
<p><strong>Action:</strong> ${capa.description}</p>
|
|
<p><strong>Due date:</strong> ${capa.dueDate}</p>
|
|
<p><a href="${capaUrl}">View CAPA</a></p>
|
|
`
|
|
const text = `CAPA ${incidentRef}: ${capa.description}\nDue: ${capa.dueDate}\n${capaUrl}`
|
|
|
|
const to = [ownerEmail]
|
|
if (capa.siteId && ['due_today', 'overdue_3d', 'overdue_7d'].includes(threshold)) {
|
|
const hseUsers = await asAdmin(db =>
|
|
db.select({ email: users.email })
|
|
.from(users)
|
|
.where(and(
|
|
eq(users.siteId, capa.siteId!),
|
|
inArray(users.role, ['hse', 'supervisor', 'management']),
|
|
))
|
|
)
|
|
to.push(...hseUsers.map(u => u.email).filter(Boolean) as string[])
|
|
}
|
|
|
|
await sendEmail({ to: [...new Set(to)], subject, html, text })
|
|
|
|
// WhatsApp for urgent thresholds only (owner must have a phone number)
|
|
if (['overdue_3d', 'overdue_7d'].includes(threshold)) {
|
|
const ownerPhone = capa.ownerPhone ?? ''
|
|
if (ownerPhone) {
|
|
try {
|
|
await sendWhatsAppMessage(
|
|
ownerPhone,
|
|
'ims_capa_overdue',
|
|
[incidentRef ?? '', capa.description, capa.dueDate],
|
|
whatsappPhoneNumberId,
|
|
whatsappAccessToken,
|
|
)
|
|
} catch (waErr) {
|
|
console.error('WhatsApp escalation error:', waErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
await asAdmin(db =>
|
|
db.insert(notificationsLog).values({
|
|
capaId: capa.id,
|
|
channel: 'email',
|
|
recipient: [...new Set(to)].join(','),
|
|
status: threshold,
|
|
})
|
|
)
|
|
|
|
if (capa.ownerUserId) {
|
|
await createInAppNotifications([{
|
|
userId: capa.ownerUserId,
|
|
title: `${THRESHOLD_SUBJECT[threshold].replace('[IMS] ', '')} — ${capa.incidentRef ?? capa.incidentId}`,
|
|
link: `/hse/capa/${capa.id}`,
|
|
incidentId: capa.incidentId,
|
|
capaId: capa.id,
|
|
}])
|
|
}
|
|
|
|
notified++
|
|
}
|
|
|
|
return { notified }
|
|
}
|