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:
@@ -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,
|
||||
}])
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } from '@/lib/db/schema'
|
||||
import { and, eq, inArray, isNotNull, lt } from 'drizzle-orm'
|
||||
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||
import { getApiKey } from '@/lib/settings'
|
||||
|
||||
@@ -27,22 +30,31 @@ export function shouldSendRecheck(
|
||||
return recheckDate <= today
|
||||
}
|
||||
|
||||
export async function sendEffectivenessRecheckNotifications(
|
||||
supabase: SupabaseClient,
|
||||
): Promise<{ notified: number }> {
|
||||
export async function sendEffectivenessRecheckNotifications(): Promise<{ notified: number }> {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
const { data: capas } = await supabase
|
||||
.from('capa_actions')
|
||||
.select(`
|
||||
id, description, effectiveness_recheck_date, effectiveness_recheck_round,
|
||||
verified_at, incident_id,
|
||||
incidents (reference_no),
|
||||
verifier:users!verified_by (email, name, phone)
|
||||
`)
|
||||
.in('status', ['verified', 'closed'])
|
||||
.not('effectiveness_recheck_date', 'is', null)
|
||||
.lt('effectiveness_recheck_round', 3)
|
||||
const capas = await asAdmin(db =>
|
||||
db.select({
|
||||
id: capaActions.id,
|
||||
description: capaActions.description,
|
||||
effectivenessRecheckDate: capaActions.effectivenessRecheckDate,
|
||||
effectivenessRecheckRound: capaActions.effectivenessRecheckRound,
|
||||
verifiedAt: capaActions.verifiedAt,
|
||||
incidentId: capaActions.incidentId,
|
||||
incidentRef: incidents.referenceNo,
|
||||
verifierEmail: users.email,
|
||||
verifierName: users.name,
|
||||
verifierPhone: users.phone,
|
||||
})
|
||||
.from(capaActions)
|
||||
.leftJoin(incidents, eq(capaActions.incidentId, incidents.id))
|
||||
.leftJoin(users, eq(capaActions.verifiedBy, users.id))
|
||||
.where(and(
|
||||
inArray(capaActions.status, ['verified', 'closed']),
|
||||
isNotNull(capaActions.effectivenessRecheckDate),
|
||||
lt(capaActions.effectivenessRecheckRound, 3),
|
||||
))
|
||||
)
|
||||
|
||||
if (!capas || capas.length === 0) return { notified: 0 }
|
||||
|
||||
@@ -53,8 +65,8 @@ export async function sendEffectivenessRecheckNotifications(
|
||||
let whatsappPhoneId: string | null = null
|
||||
let whatsappToken: string | null = null
|
||||
try {
|
||||
whatsappPhoneId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
|
||||
whatsappToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
|
||||
whatsappPhoneId = await getApiKey('META_WHATSAPP_PHONE_NUMBER_ID')
|
||||
whatsappToken = await getApiKey('META_WHATSAPP_ACCESS_TOKEN')
|
||||
} catch {
|
||||
// WhatsApp not configured — email only
|
||||
}
|
||||
@@ -62,21 +74,20 @@ export async function sendEffectivenessRecheckNotifications(
|
||||
let notified = 0
|
||||
|
||||
for (const capa of capas) {
|
||||
const recheckDate = (capa as { effectiveness_recheck_date: string | null }).effectiveness_recheck_date
|
||||
const round = (capa as { effectiveness_recheck_round: number }).effectiveness_recheck_round
|
||||
const recheckDate = capa.effectivenessRecheckDate
|
||||
const round = capa.effectivenessRecheckRound
|
||||
|
||||
if (!shouldSendRecheck(recheckDate, round, today)) continue
|
||||
|
||||
const verifierEmail = (capa.verifier as unknown as { email: string } | null)?.email
|
||||
const verifierName = (capa.verifier as unknown as { name: string } | null)?.name ?? 'HSE Officer'
|
||||
const verifierPhone = (capa.verifier as unknown as { phone: string | null } | null)?.phone ?? ''
|
||||
const verifierEmail = capa.verifierEmail
|
||||
const verifierName = capa.verifierName ?? 'HSE Officer'
|
||||
const verifierPhone = capa.verifierPhone ?? ''
|
||||
if (!verifierEmail) continue
|
||||
|
||||
const incidentRef = (capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no
|
||||
?? (capa as { incident_id: string }).incident_id
|
||||
const incidentRef = capa.incidentRef ?? capa.incidentId
|
||||
const roundLabel = getRoundLabel(round)
|
||||
const capaDesc = (capa as { description: string }).description
|
||||
const capaUrl = `${siteUrl}/ims/hse/capa/${(capa as { id: string }).id}`
|
||||
const capaDesc = capa.description
|
||||
const capaUrl = `${siteUrl}/ims/hse/capa/${capa.id}`
|
||||
|
||||
const subject = `[IMS] ${roundLabel} effectiveness check — ${incidentRef}`
|
||||
const html = `
|
||||
@@ -99,22 +110,22 @@ export async function sendEffectivenessRecheckNotifications(
|
||||
sendWhatsAppMessage(
|
||||
verifierPhone,
|
||||
'ims_effectiveness_recheck',
|
||||
[roundLabel, incidentRef, capaDesc],
|
||||
[roundLabel, incidentRef ?? '', capaDesc],
|
||||
whatsappPhoneId,
|
||||
whatsappToken,
|
||||
).catch(err => console.error('WhatsApp recheck error:', err))
|
||||
}
|
||||
|
||||
// Advance round
|
||||
const verifiedAt = (capa as { verified_at: string }).verified_at
|
||||
const nextDate = getNextRecheckDate(verifiedAt, round)
|
||||
await supabase
|
||||
.from('capa_actions')
|
||||
.update({
|
||||
effectiveness_recheck_round: round + 1,
|
||||
effectiveness_recheck_date: nextDate ?? null,
|
||||
})
|
||||
.eq('id', (capa as { id: string }).id)
|
||||
const nextDate = getNextRecheckDate(capa.verifiedAt!.toISOString(), round)
|
||||
await asAdmin(db =>
|
||||
db.update(capaActions)
|
||||
.set({
|
||||
effectivenessRecheckRound: round + 1,
|
||||
effectivenessRecheckDate: nextDate ?? null,
|
||||
})
|
||||
.where(eq(capaActions.id, capa.id))
|
||||
)
|
||||
|
||||
notified++
|
||||
}
|
||||
|
||||
+30
-19
@@ -1,5 +1,8 @@
|
||||
import 'server-only'
|
||||
import { Resend } from 'resend'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { asAdmin } from '@/lib/db/with-user'
|
||||
import { users, incidents, sites } from '@/lib/db/schema'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { newIncidentTemplate } from '@/lib/notifications/templates/new-incident'
|
||||
|
||||
export async function sendNewIncidentEmail(
|
||||
@@ -8,29 +11,37 @@ export async function sendNewIncidentEmail(
|
||||
reference_no: string,
|
||||
incidentType: string,
|
||||
): Promise<void> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: recipients } = await supabase
|
||||
.from('users')
|
||||
.select('email, name, role')
|
||||
.in('role', ['supervisor', 'hse'])
|
||||
.eq('site_id', siteId)
|
||||
const recipients = await asAdmin(db =>
|
||||
db.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(and(
|
||||
inArray(users.role, ['supervisor', 'hse']),
|
||||
eq(users.siteId, siteId),
|
||||
))
|
||||
)
|
||||
|
||||
if (!recipients || recipients.length === 0) return
|
||||
|
||||
const to = recipients.map((r: { email: string }) => r.email).filter(Boolean)
|
||||
const to = recipients.map(r => r.email).filter(Boolean) as string[]
|
||||
if (to.length === 0) return
|
||||
|
||||
const { data: incident } = await supabase
|
||||
.from('incidents')
|
||||
.select('reported_at, sites (name), reporter:users!reported_by (name)')
|
||||
.eq('id', incidentId)
|
||||
.single()
|
||||
|
||||
const siteName = (incident?.sites as unknown as { name: string } | null)?.name ?? 'Unknown Site'
|
||||
const reporterName = (incident?.reporter as unknown as { name: string } | null)?.name ?? 'Unknown'
|
||||
const reportedAt = incident?.reported_at
|
||||
? new Date(incident.reported_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })
|
||||
const incidentRows = await asAdmin(db =>
|
||||
db.select({
|
||||
reportedAt: incidents.reportedAt,
|
||||
siteName: sites.name,
|
||||
reporterName: users.name,
|
||||
})
|
||||
.from(incidents)
|
||||
.leftJoin(sites, eq(incidents.siteId, sites.id))
|
||||
.leftJoin(users, eq(incidents.reportedBy, users.id))
|
||||
.where(eq(incidents.id, incidentId))
|
||||
.limit(1)
|
||||
)
|
||||
const incident = incidentRows[0]
|
||||
const siteName = incident?.siteName ?? 'Unknown Site'
|
||||
const reporterName = incident?.reporterName ?? 'Unknown'
|
||||
const reportedAt = incident?.reportedAt
|
||||
? new Date(incident.reportedAt).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })
|
||||
: '-'
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
|
||||
|
||||
|
||||
+18
-16
@@ -1,4 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import 'server-only'
|
||||
import { asAdmin } from '@/lib/db/with-user'
|
||||
import { notificationsLog } from '@/lib/db/schema'
|
||||
|
||||
export interface InAppNotification {
|
||||
userId: string
|
||||
@@ -8,11 +10,7 @@ export interface InAppNotification {
|
||||
capaId?: string
|
||||
}
|
||||
|
||||
// Inserts go through the create_in_app_notification SECURITY DEFINER RPC:
|
||||
// notifications_log INSERT is RLS-restricted to elevated roles, but reporters
|
||||
// must still be able to trigger alerts to supervisors/HSE.
|
||||
export async function createInAppNotifications(
|
||||
supabase: SupabaseClient,
|
||||
notifications: InAppNotification[],
|
||||
): Promise<{ created: number }> {
|
||||
const seen = new Set<string>()
|
||||
@@ -24,18 +22,22 @@ export async function createInAppNotifications(
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
|
||||
const { error } = await supabase.rpc('create_in_app_notification', {
|
||||
p_recipient: n.userId,
|
||||
p_title: n.title,
|
||||
p_link: n.link ?? null,
|
||||
p_incident_id: n.incidentId ?? null,
|
||||
p_capa_id: n.capaId ?? null,
|
||||
})
|
||||
if (error) {
|
||||
console.error('in-app notification error:', error)
|
||||
continue
|
||||
try {
|
||||
await asAdmin(db =>
|
||||
db.insert(notificationsLog).values({
|
||||
channel: 'in_app',
|
||||
recipient: n.userId,
|
||||
recipientUserId: n.userId,
|
||||
title: n.title,
|
||||
link: n.link ?? null,
|
||||
incidentId: n.incidentId ?? null,
|
||||
capaId: n.capaId ?? null,
|
||||
})
|
||||
)
|
||||
created++
|
||||
} catch (e) {
|
||||
console.error('in-app notification error:', e)
|
||||
}
|
||||
created++
|
||||
}
|
||||
|
||||
return { created }
|
||||
|
||||
+12
-8
@@ -1,13 +1,17 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import 'server-only'
|
||||
import { asAdmin } from '@/lib/db/with-user'
|
||||
import { appSettings } from '@/lib/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
export async function getApiKey(supabase: SupabaseClient, key: string): Promise<string> {
|
||||
export async function getApiKey(key: string): Promise<string> {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('app_settings')
|
||||
.select('value')
|
||||
.eq('key', key)
|
||||
.single()
|
||||
if (data?.value) return data.value
|
||||
const rows = await asAdmin(db =>
|
||||
db.select({ value: appSettings.value })
|
||||
.from(appSettings)
|
||||
.where(eq(appSettings.key, key))
|
||||
.limit(1)
|
||||
)
|
||||
if (rows[0]?.value) return rows[0].value
|
||||
} catch {
|
||||
// fall through to env
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user