feat(db): phase 4 group 1 — lib/ settings + notifications to Drizzle

Convert lib/settings.ts, lib/notifications/{in-app,email,capa-escalation,
effectiveness-recheck}.ts from Supabase PostgREST to Drizzle asAdmin queries.
Drop supabase arg from all call sites in app/api/ and cron routes. Rewrite
notification unit tests to mock @/lib/db/with-user instead of SupabaseClient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 16:37:51 +08:00
co-authored by Claude Sonnet 4.6
parent d18d29168a
commit 25f923f530
19 changed files with 296 additions and 300 deletions
+69 -54
View File
@@ -1,5 +1,8 @@
import 'server-only'
import { Resend } from 'resend'
import type { SupabaseClient } from '@supabase/supabase-js'
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'
@@ -27,17 +30,25 @@ const THRESHOLD_SUBJECT: Record<EscalationThreshold, string> = {
overdue_7d: '[IMS] URGENT: CAPA action 7 days OVERDUE',
}
export async function escalateOverdueCapa(
supabase: SupabaseClient
): Promise<{ notified: number }> {
const { data: capas } = await supabase
.from('capa_actions')
.select(`
id, description, due_date, incident_id, owner_user_id,
incidents (reference_no, site_id),
owner:users!owner_user_id (email, name, phone)
`)
.not('status', 'in', '(verified,closed)')
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 }
@@ -46,49 +57,52 @@ export async function escalateOverdueCapa(
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
let notified = 0
const whatsappPhoneNumberId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
const whatsappAccessToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
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 as { due_date: string }).due_date)
const threshold = getEscalationThreshold(capa.dueDate)
if (!threshold) continue
const { data: alreadySent } = await supabase
.from('notifications_log')
.select('id')
.eq('capa_id', capa.id)
.eq('channel', 'email')
.eq('status', threshold)
.limit(1)
.maybeSingle()
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
if (alreadySent) continue
const ownerEmail = (capa.owner as unknown as { email: string } | null)?.email
const ownerName = (capa.owner as unknown as { name: string } | null)?.name ?? 'Owner'
const ownerEmail = capa.ownerEmail
const ownerName = capa.ownerName ?? 'Owner'
if (!ownerEmail) continue
const incidentRef = (capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no ?? capa.incident_id
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 as { description: string }).description}</p>
<p><strong>Due date:</strong> ${(capa as { due_date: string }).due_date}</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 as { description: string }).description}\nDue: ${(capa as { due_date: string }).due_date}\n${capaUrl}`
const text = `CAPA ${incidentRef}: ${capa.description}\nDue: ${capa.dueDate}\n${capaUrl}`
const to = [ownerEmail]
const siteId = (capa.incidents as unknown as { site_id: string } | null)?.site_id
if (siteId && ['due_today', 'overdue_3d', 'overdue_7d'].includes(threshold)) {
const { data: hseUsers } = await supabase
.from('users')
.select('email')
.eq('site_id', siteId)
.in('role', ['hse', 'supervisor', 'management'])
if (hseUsers) to.push(...hseUsers.map((u: { email: string }) => u.email))
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[])
}
const { error } = await resend.emails.send({ from, to: [...new Set(to)], subject, html, text })
@@ -99,13 +113,13 @@ export async function escalateOverdueCapa(
// WhatsApp for urgent thresholds only (owner must have a phone number)
if (['overdue_3d', 'overdue_7d'].includes(threshold)) {
const ownerPhone = (capa.owner as unknown as { phone: string | null } | null)?.phone ?? ''
const ownerPhone = capa.ownerPhone ?? ''
if (ownerPhone) {
try {
await sendWhatsAppMessage(
ownerPhone,
'ims_capa_overdue',
[incidentRef, (capa as { description: string }).description, (capa as { due_date: string }).due_date],
[incidentRef ?? '', capa.description, capa.dueDate],
whatsappPhoneNumberId,
whatsappAccessToken,
)
@@ -115,21 +129,22 @@ export async function escalateOverdueCapa(
}
}
await supabase.from('notifications_log').insert({
capa_id: capa.id,
channel: 'email',
recipient: to.join(','),
status: threshold,
})
await asAdmin(db =>
db.insert(notificationsLog).values({
capaId: capa.id,
channel: 'email',
recipient: [...new Set(to)].join(','),
status: threshold,
})
)
const ownerUserId = (capa as { owner_user_id: string | null }).owner_user_id
if (ownerUserId) {
await createInAppNotifications(supabase, [{
userId: ownerUserId,
title: `${THRESHOLD_SUBJECT[threshold].replace('[IMS] ', '')}${incidentRef}`,
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.incident_id as string,
capaId: capa.id as string,
incidentId: capa.incidentId,
capaId: capa.id,
}])
}