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:
@@ -56,7 +56,7 @@ export async function POST(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (capa.owner_user_id) {
|
if (capa.owner_user_id) {
|
||||||
await createInAppNotifications(supabase, [{
|
await createInAppNotifications([{
|
||||||
userId: capa.owner_user_id,
|
userId: capa.owner_user_id,
|
||||||
title: body.verdict === 'verified'
|
title: body.verdict === 'verified'
|
||||||
? 'Your CAPA action was verified'
|
? 'Your CAPA action was verified'
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export async function POST(request: NextRequest) {
|
|||||||
p_new_value: { incident_id, description, owner_user_id, department, due_date },
|
p_new_value: { incident_id, description, owner_user_id, department, due_date },
|
||||||
})
|
})
|
||||||
|
|
||||||
await createInAppNotifications(supabase, [{
|
await createInAppNotifications([{
|
||||||
userId: owner_user_id,
|
userId: owner_user_id,
|
||||||
title: `CAPA assigned to you, due ${due_date}`,
|
title: `CAPA assigned to you, due ${due_date}`,
|
||||||
link: `/hse/capa/${capa.id}`,
|
link: `/hse/capa/${capa.id}`,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
import { timingSafeEqual } from 'crypto'
|
import { timingSafeEqual } from 'crypto'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { escalateOverdueCapa } from '@/lib/notifications/capa-escalation'
|
import { escalateOverdueCapa } from '@/lib/notifications/capa-escalation'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -15,7 +14,6 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = await createClient()
|
const { notified } = await escalateOverdueCapa()
|
||||||
const { notified } = await escalateOverdueCapa(supabase)
|
|
||||||
return NextResponse.json({ ok: true, notified })
|
return NextResponse.json({ ok: true, notified })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
import { timingSafeEqual } from 'crypto'
|
import { timingSafeEqual } from 'crypto'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { sendEffectivenessRecheckNotifications } from '@/lib/notifications/effectiveness-recheck'
|
import { sendEffectivenessRecheckNotifications } from '@/lib/notifications/effectiveness-recheck'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -15,7 +14,6 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = await createClient()
|
const { notified } = await sendEffectivenessRecheckNotifications()
|
||||||
const { notified } = await sendEffectivenessRecheckNotifications(supabase)
|
|
||||||
return NextResponse.json({ ok: true, notified })
|
return NextResponse.json({ ok: true, notified })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export async function POST() {
|
|||||||
return NextResponse.json({ error: 'Rate limit: wait 60 seconds between AI requests' }, { status: 429 })
|
return NextResponse.json({ error: 'Rate limit: wait 60 seconds between AI requests' }, { status: 429 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY')
|
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
|
||||||
const client = createDeepSeekClient(deepseekKey)
|
const client = createDeepSeekClient(deepseekKey)
|
||||||
|
|
||||||
let res: Awaited<ReturnType<typeof client.chat.completions.create>>
|
let res: Awaited<ReturnType<typeof client.chat.completions.create>>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function POST(
|
|||||||
if ((recentCount ?? 0) > 0)
|
if ((recentCount ?? 0) > 0)
|
||||||
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
||||||
|
|
||||||
const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY')
|
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
|
||||||
const client = createDeepSeekClient(deepseekKey)
|
const client = createDeepSeekClient(deepseekKey)
|
||||||
|
|
||||||
const { data: incident } = await supabase
|
const { data: incident } = await supabase
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function POST(
|
|||||||
if ((recentCount ?? 0) > 0)
|
if ((recentCount ?? 0) > 0)
|
||||||
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
||||||
|
|
||||||
const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY')
|
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
|
||||||
const client = createDeepSeekClient(deepseekKey)
|
const client = createDeepSeekClient(deepseekKey)
|
||||||
|
|
||||||
const { data: incident } = await supabase
|
const { data: incident } = await supabase
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export async function POST(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (incident.reported_by) {
|
if (incident.reported_by) {
|
||||||
await createInAppNotifications(supabase, [{
|
await createInAppNotifications([{
|
||||||
userId: incident.reported_by,
|
userId: incident.reported_by,
|
||||||
title: `Your incident report ${incident.reference_no ?? ''} has been closed`,
|
title: `Your incident report ${incident.reference_no ?? ''} has been closed`,
|
||||||
link: '/reporter',
|
link: '/reporter',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function GET(
|
|||||||
|
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
const googleAiKey = await getApiKey(supabase, 'GOOGLE_AI_API_KEY')
|
const googleAiKey = await getApiKey('GOOGLE_AI_API_KEY')
|
||||||
|
|
||||||
const { data: incident } = await supabase
|
const { data: incident } = await supabase
|
||||||
.from('incidents')
|
.from('incidents')
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
|
|||||||
if ((recentCount ?? 0) > 0)
|
if ((recentCount ?? 0) > 0)
|
||||||
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
||||||
|
|
||||||
const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY')
|
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
|
||||||
const client = createDeepSeekClient(deepseekKey)
|
const client = createDeepSeekClient(deepseekKey)
|
||||||
|
|
||||||
let body: { description?: string; incident_type?: string }
|
let body: { description?: string; incident_type?: string }
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ async function handlePost(request: Request) {
|
|||||||
// WhatsApp alert — fire-and-forget alongside email
|
// WhatsApp alert — fire-and-forget alongside email
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const phoneNumberId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
|
const phoneNumberId = await getApiKey('META_WHATSAPP_PHONE_NUMBER_ID')
|
||||||
const accessToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
|
const accessToken = await getApiKey('META_WHATSAPP_ACCESS_TOKEN')
|
||||||
const { data: siteData } = await supabase
|
const { data: siteData } = await supabase
|
||||||
.from('sites').select('name').eq('id', zone.site_id).single()
|
.from('sites').select('name').eq('id', zone.site_id).single()
|
||||||
const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown'
|
const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown'
|
||||||
@@ -177,7 +177,6 @@ async function handlePost(request: Request) {
|
|||||||
.eq('site_id', zone.site_id)
|
.eq('site_id', zone.site_id)
|
||||||
|
|
||||||
await createInAppNotifications(
|
await createInAppNotifications(
|
||||||
supabase,
|
|
||||||
(recipients ?? []).map((r: { id: string }) => ({
|
(recipients ?? []).map((r: { id: string }) => ({
|
||||||
userId: r.id,
|
userId: r.id,
|
||||||
title: `New ${input.incident_type.replace(/_/g, ' ')} incident ${incident.reference_no ?? ''} at ${siteName}`,
|
title: `New ${input.incident_type.replace(/_/g, ' ')} incident ${incident.reference_no ?? ''} at ${siteName}`,
|
||||||
@@ -205,7 +204,7 @@ async function handlePost(request: Request) {
|
|||||||
// Embed description asynchronously for future similarity search
|
// Embed description asynchronously for future similarity search
|
||||||
const supabaseForEmbed = supabase
|
const supabaseForEmbed = supabase
|
||||||
import('@/lib/settings').then(({ getApiKey }) =>
|
import('@/lib/settings').then(({ getApiKey }) =>
|
||||||
getApiKey(supabaseForEmbed, 'GOOGLE_AI_API_KEY').then(googleAiKey =>
|
getApiKey('GOOGLE_AI_API_KEY').then(googleAiKey =>
|
||||||
import('@/lib/claude/embed').then(({ embedText }) =>
|
import('@/lib/claude/embed').then(({ embedText }) =>
|
||||||
embedText(input.description.trim(), googleAiKey).then(embedding =>
|
embedText(input.description.trim(), googleAiKey).then(embedding =>
|
||||||
supabase.from('incidents').update({
|
supabase.from('incidents').update({
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'server-only'
|
||||||
import { Resend } from 'resend'
|
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 { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||||
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||||
import { getApiKey } from '@/lib/settings'
|
import { getApiKey } from '@/lib/settings'
|
||||||
@@ -27,17 +30,25 @@ const THRESHOLD_SUBJECT: Record<EscalationThreshold, string> = {
|
|||||||
overdue_7d: '[IMS] URGENT: CAPA action 7 days OVERDUE',
|
overdue_7d: '[IMS] URGENT: CAPA action 7 days OVERDUE',
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function escalateOverdueCapa(
|
export async function escalateOverdueCapa(): Promise<{ notified: number }> {
|
||||||
supabase: SupabaseClient
|
const capas = await asAdmin(db =>
|
||||||
): Promise<{ notified: number }> {
|
db.select({
|
||||||
const { data: capas } = await supabase
|
id: capaActions.id,
|
||||||
.from('capa_actions')
|
description: capaActions.description,
|
||||||
.select(`
|
dueDate: capaActions.dueDate,
|
||||||
id, description, due_date, incident_id, owner_user_id,
|
incidentId: capaActions.incidentId,
|
||||||
incidents (reference_no, site_id),
|
ownerUserId: capaActions.ownerUserId,
|
||||||
owner:users!owner_user_id (email, name, phone)
|
incidentRef: incidents.referenceNo,
|
||||||
`)
|
siteId: incidents.siteId,
|
||||||
.not('status', 'in', '(verified,closed)')
|
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 }
|
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'
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
|
||||||
let notified = 0
|
let notified = 0
|
||||||
|
|
||||||
const whatsappPhoneNumberId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
|
const whatsappPhoneNumberId = await getApiKey('META_WHATSAPP_PHONE_NUMBER_ID')
|
||||||
const whatsappAccessToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
|
const whatsappAccessToken = await getApiKey('META_WHATSAPP_ACCESS_TOKEN')
|
||||||
|
|
||||||
for (const capa of capas) {
|
for (const capa of capas) {
|
||||||
const threshold = getEscalationThreshold((capa as { due_date: string }).due_date)
|
const threshold = getEscalationThreshold(capa.dueDate)
|
||||||
if (!threshold) continue
|
if (!threshold) continue
|
||||||
|
|
||||||
const { data: alreadySent } = await supabase
|
const alreadySent = await asAdmin(db =>
|
||||||
.from('notifications_log')
|
db.select({ id: notificationsLog.id })
|
||||||
.select('id')
|
.from(notificationsLog)
|
||||||
.eq('capa_id', capa.id)
|
.where(and(
|
||||||
.eq('channel', 'email')
|
eq(notificationsLog.capaId, capa.id),
|
||||||
.eq('status', threshold)
|
eq(notificationsLog.channel, 'email'),
|
||||||
|
eq(notificationsLog.status, threshold),
|
||||||
|
))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.maybeSingle()
|
)
|
||||||
|
if (alreadySent.length > 0) continue
|
||||||
|
|
||||||
if (alreadySent) continue
|
const ownerEmail = capa.ownerEmail
|
||||||
|
const ownerName = capa.ownerName ?? 'Owner'
|
||||||
const ownerEmail = (capa.owner as unknown as { email: string } | null)?.email
|
|
||||||
const ownerName = (capa.owner as unknown as { name: string } | null)?.name ?? 'Owner'
|
|
||||||
if (!ownerEmail) continue
|
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 capaUrl = `${siteUrl}/ims/hse/capa/${capa.id}`
|
||||||
const subject = THRESHOLD_SUBJECT[threshold]
|
const subject = THRESHOLD_SUBJECT[threshold]
|
||||||
const html = `
|
const html = `
|
||||||
<p>Hi ${ownerName},</p>
|
<p>Hi ${ownerName},</p>
|
||||||
<p>CAPA action for incident <strong>${incidentRef}</strong> requires attention.</p>
|
<p>CAPA action for incident <strong>${incidentRef}</strong> requires attention.</p>
|
||||||
<p><strong>Action:</strong> ${(capa as { description: string }).description}</p>
|
<p><strong>Action:</strong> ${capa.description}</p>
|
||||||
<p><strong>Due date:</strong> ${(capa as { due_date: string }).due_date}</p>
|
<p><strong>Due date:</strong> ${capa.dueDate}</p>
|
||||||
<p><a href="${capaUrl}">View CAPA</a></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 to = [ownerEmail]
|
||||||
const siteId = (capa.incidents as unknown as { site_id: string } | null)?.site_id
|
if (capa.siteId && ['due_today', 'overdue_3d', 'overdue_7d'].includes(threshold)) {
|
||||||
if (siteId && ['due_today', 'overdue_3d', 'overdue_7d'].includes(threshold)) {
|
const hseUsers = await asAdmin(db =>
|
||||||
const { data: hseUsers } = await supabase
|
db.select({ email: users.email })
|
||||||
.from('users')
|
.from(users)
|
||||||
.select('email')
|
.where(and(
|
||||||
.eq('site_id', siteId)
|
eq(users.siteId, capa.siteId!),
|
||||||
.in('role', ['hse', 'supervisor', 'management'])
|
inArray(users.role, ['hse', 'supervisor', 'management']),
|
||||||
if (hseUsers) to.push(...hseUsers.map((u: { email: string }) => u.email))
|
))
|
||||||
|
)
|
||||||
|
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 })
|
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)
|
// WhatsApp for urgent thresholds only (owner must have a phone number)
|
||||||
if (['overdue_3d', 'overdue_7d'].includes(threshold)) {
|
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) {
|
if (ownerPhone) {
|
||||||
try {
|
try {
|
||||||
await sendWhatsAppMessage(
|
await sendWhatsAppMessage(
|
||||||
ownerPhone,
|
ownerPhone,
|
||||||
'ims_capa_overdue',
|
'ims_capa_overdue',
|
||||||
[incidentRef, (capa as { description: string }).description, (capa as { due_date: string }).due_date],
|
[incidentRef ?? '', capa.description, capa.dueDate],
|
||||||
whatsappPhoneNumberId,
|
whatsappPhoneNumberId,
|
||||||
whatsappAccessToken,
|
whatsappAccessToken,
|
||||||
)
|
)
|
||||||
@@ -115,21 +129,22 @@ export async function escalateOverdueCapa(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await supabase.from('notifications_log').insert({
|
await asAdmin(db =>
|
||||||
capa_id: capa.id,
|
db.insert(notificationsLog).values({
|
||||||
|
capaId: capa.id,
|
||||||
channel: 'email',
|
channel: 'email',
|
||||||
recipient: to.join(','),
|
recipient: [...new Set(to)].join(','),
|
||||||
status: threshold,
|
status: threshold,
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const ownerUserId = (capa as { owner_user_id: string | null }).owner_user_id
|
if (capa.ownerUserId) {
|
||||||
if (ownerUserId) {
|
await createInAppNotifications([{
|
||||||
await createInAppNotifications(supabase, [{
|
userId: capa.ownerUserId,
|
||||||
userId: ownerUserId,
|
title: `${THRESHOLD_SUBJECT[threshold].replace('[IMS] ', '')} — ${capa.incidentRef ?? capa.incidentId}`,
|
||||||
title: `${THRESHOLD_SUBJECT[threshold].replace('[IMS] ', '')} — ${incidentRef}`,
|
|
||||||
link: `/hse/capa/${capa.id}`,
|
link: `/hse/capa/${capa.id}`,
|
||||||
incidentId: capa.incident_id as string,
|
incidentId: capa.incidentId,
|
||||||
capaId: capa.id as string,
|
capaId: capa.id,
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'server-only'
|
||||||
import { Resend } from 'resend'
|
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 { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||||
import { getApiKey } from '@/lib/settings'
|
import { getApiKey } from '@/lib/settings'
|
||||||
|
|
||||||
@@ -27,22 +30,31 @@ export function shouldSendRecheck(
|
|||||||
return recheckDate <= today
|
return recheckDate <= today
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendEffectivenessRecheckNotifications(
|
export async function sendEffectivenessRecheckNotifications(): Promise<{ notified: number }> {
|
||||||
supabase: SupabaseClient,
|
|
||||||
): Promise<{ notified: number }> {
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
|
||||||
const { data: capas } = await supabase
|
const capas = await asAdmin(db =>
|
||||||
.from('capa_actions')
|
db.select({
|
||||||
.select(`
|
id: capaActions.id,
|
||||||
id, description, effectiveness_recheck_date, effectiveness_recheck_round,
|
description: capaActions.description,
|
||||||
verified_at, incident_id,
|
effectivenessRecheckDate: capaActions.effectivenessRecheckDate,
|
||||||
incidents (reference_no),
|
effectivenessRecheckRound: capaActions.effectivenessRecheckRound,
|
||||||
verifier:users!verified_by (email, name, phone)
|
verifiedAt: capaActions.verifiedAt,
|
||||||
`)
|
incidentId: capaActions.incidentId,
|
||||||
.in('status', ['verified', 'closed'])
|
incidentRef: incidents.referenceNo,
|
||||||
.not('effectiveness_recheck_date', 'is', null)
|
verifierEmail: users.email,
|
||||||
.lt('effectiveness_recheck_round', 3)
|
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 }
|
if (!capas || capas.length === 0) return { notified: 0 }
|
||||||
|
|
||||||
@@ -53,8 +65,8 @@ export async function sendEffectivenessRecheckNotifications(
|
|||||||
let whatsappPhoneId: string | null = null
|
let whatsappPhoneId: string | null = null
|
||||||
let whatsappToken: string | null = null
|
let whatsappToken: string | null = null
|
||||||
try {
|
try {
|
||||||
whatsappPhoneId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
|
whatsappPhoneId = await getApiKey('META_WHATSAPP_PHONE_NUMBER_ID')
|
||||||
whatsappToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
|
whatsappToken = await getApiKey('META_WHATSAPP_ACCESS_TOKEN')
|
||||||
} catch {
|
} catch {
|
||||||
// WhatsApp not configured — email only
|
// WhatsApp not configured — email only
|
||||||
}
|
}
|
||||||
@@ -62,21 +74,20 @@ export async function sendEffectivenessRecheckNotifications(
|
|||||||
let notified = 0
|
let notified = 0
|
||||||
|
|
||||||
for (const capa of capas) {
|
for (const capa of capas) {
|
||||||
const recheckDate = (capa as { effectiveness_recheck_date: string | null }).effectiveness_recheck_date
|
const recheckDate = capa.effectivenessRecheckDate
|
||||||
const round = (capa as { effectiveness_recheck_round: number }).effectiveness_recheck_round
|
const round = capa.effectivenessRecheckRound
|
||||||
|
|
||||||
if (!shouldSendRecheck(recheckDate, round, today)) continue
|
if (!shouldSendRecheck(recheckDate, round, today)) continue
|
||||||
|
|
||||||
const verifierEmail = (capa.verifier as unknown as { email: string } | null)?.email
|
const verifierEmail = capa.verifierEmail
|
||||||
const verifierName = (capa.verifier as unknown as { name: string } | null)?.name ?? 'HSE Officer'
|
const verifierName = capa.verifierName ?? 'HSE Officer'
|
||||||
const verifierPhone = (capa.verifier as unknown as { phone: string | null } | null)?.phone ?? ''
|
const verifierPhone = capa.verifierPhone ?? ''
|
||||||
if (!verifierEmail) continue
|
if (!verifierEmail) continue
|
||||||
|
|
||||||
const incidentRef = (capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no
|
const incidentRef = capa.incidentRef ?? capa.incidentId
|
||||||
?? (capa as { incident_id: string }).incident_id
|
|
||||||
const roundLabel = getRoundLabel(round)
|
const roundLabel = getRoundLabel(round)
|
||||||
const capaDesc = (capa as { description: string }).description
|
const capaDesc = capa.description
|
||||||
const capaUrl = `${siteUrl}/ims/hse/capa/${(capa as { id: string }).id}`
|
const capaUrl = `${siteUrl}/ims/hse/capa/${capa.id}`
|
||||||
|
|
||||||
const subject = `[IMS] ${roundLabel} effectiveness check — ${incidentRef}`
|
const subject = `[IMS] ${roundLabel} effectiveness check — ${incidentRef}`
|
||||||
const html = `
|
const html = `
|
||||||
@@ -99,22 +110,22 @@ export async function sendEffectivenessRecheckNotifications(
|
|||||||
sendWhatsAppMessage(
|
sendWhatsAppMessage(
|
||||||
verifierPhone,
|
verifierPhone,
|
||||||
'ims_effectiveness_recheck',
|
'ims_effectiveness_recheck',
|
||||||
[roundLabel, incidentRef, capaDesc],
|
[roundLabel, incidentRef ?? '', capaDesc],
|
||||||
whatsappPhoneId,
|
whatsappPhoneId,
|
||||||
whatsappToken,
|
whatsappToken,
|
||||||
).catch(err => console.error('WhatsApp recheck error:', err))
|
).catch(err => console.error('WhatsApp recheck error:', err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance round
|
// Advance round
|
||||||
const verifiedAt = (capa as { verified_at: string }).verified_at
|
const nextDate = getNextRecheckDate(capa.verifiedAt!.toISOString(), round)
|
||||||
const nextDate = getNextRecheckDate(verifiedAt, round)
|
await asAdmin(db =>
|
||||||
await supabase
|
db.update(capaActions)
|
||||||
.from('capa_actions')
|
.set({
|
||||||
.update({
|
effectivenessRecheckRound: round + 1,
|
||||||
effectiveness_recheck_round: round + 1,
|
effectivenessRecheckDate: nextDate ?? null,
|
||||||
effectiveness_recheck_date: nextDate ?? null,
|
|
||||||
})
|
})
|
||||||
.eq('id', (capa as { id: string }).id)
|
.where(eq(capaActions.id, capa.id))
|
||||||
|
)
|
||||||
|
|
||||||
notified++
|
notified++
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-19
@@ -1,5 +1,8 @@
|
|||||||
|
import 'server-only'
|
||||||
import { Resend } from 'resend'
|
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'
|
import { newIncidentTemplate } from '@/lib/notifications/templates/new-incident'
|
||||||
|
|
||||||
export async function sendNewIncidentEmail(
|
export async function sendNewIncidentEmail(
|
||||||
@@ -8,29 +11,37 @@ export async function sendNewIncidentEmail(
|
|||||||
reference_no: string,
|
reference_no: string,
|
||||||
incidentType: string,
|
incidentType: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const supabase = await createClient()
|
const recipients = await asAdmin(db =>
|
||||||
|
db.select({ email: users.email })
|
||||||
const { data: recipients } = await supabase
|
.from(users)
|
||||||
.from('users')
|
.where(and(
|
||||||
.select('email, name, role')
|
inArray(users.role, ['supervisor', 'hse']),
|
||||||
.in('role', ['supervisor', 'hse'])
|
eq(users.siteId, siteId),
|
||||||
.eq('site_id', siteId)
|
))
|
||||||
|
)
|
||||||
|
|
||||||
if (!recipients || recipients.length === 0) return
|
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
|
if (to.length === 0) return
|
||||||
|
|
||||||
const { data: incident } = await supabase
|
const incidentRows = await asAdmin(db =>
|
||||||
.from('incidents')
|
db.select({
|
||||||
.select('reported_at, sites (name), reporter:users!reported_by (name)')
|
reportedAt: incidents.reportedAt,
|
||||||
.eq('id', incidentId)
|
siteName: sites.name,
|
||||||
.single()
|
reporterName: users.name,
|
||||||
|
})
|
||||||
const siteName = (incident?.sites as unknown as { name: string } | null)?.name ?? 'Unknown Site'
|
.from(incidents)
|
||||||
const reporterName = (incident?.reporter as unknown as { name: string } | null)?.name ?? 'Unknown'
|
.leftJoin(sites, eq(incidents.siteId, sites.id))
|
||||||
const reportedAt = incident?.reported_at
|
.leftJoin(users, eq(incidents.reportedBy, users.id))
|
||||||
? new Date(incident.reported_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })
|
.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'
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
|
||||||
|
|
||||||
|
|||||||
+17
-15
@@ -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 {
|
export interface InAppNotification {
|
||||||
userId: string
|
userId: string
|
||||||
@@ -8,11 +10,7 @@ export interface InAppNotification {
|
|||||||
capaId?: string
|
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(
|
export async function createInAppNotifications(
|
||||||
supabase: SupabaseClient,
|
|
||||||
notifications: InAppNotification[],
|
notifications: InAppNotification[],
|
||||||
): Promise<{ created: number }> {
|
): Promise<{ created: number }> {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -24,18 +22,22 @@ export async function createInAppNotifications(
|
|||||||
if (seen.has(key)) continue
|
if (seen.has(key)) continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
|
|
||||||
const { error } = await supabase.rpc('create_in_app_notification', {
|
try {
|
||||||
p_recipient: n.userId,
|
await asAdmin(db =>
|
||||||
p_title: n.title,
|
db.insert(notificationsLog).values({
|
||||||
p_link: n.link ?? null,
|
channel: 'in_app',
|
||||||
p_incident_id: n.incidentId ?? null,
|
recipient: n.userId,
|
||||||
p_capa_id: n.capaId ?? null,
|
recipientUserId: n.userId,
|
||||||
|
title: n.title,
|
||||||
|
link: n.link ?? null,
|
||||||
|
incidentId: n.incidentId ?? null,
|
||||||
|
capaId: n.capaId ?? null,
|
||||||
})
|
})
|
||||||
if (error) {
|
)
|
||||||
console.error('in-app notification error:', error)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
created++
|
created++
|
||||||
|
} catch (e) {
|
||||||
|
console.error('in-app notification error:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { 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 {
|
try {
|
||||||
const { data } = await supabase
|
const rows = await asAdmin(db =>
|
||||||
.from('app_settings')
|
db.select({ value: appSettings.value })
|
||||||
.select('value')
|
.from(appSettings)
|
||||||
.eq('key', key)
|
.where(eq(appSettings.key, key))
|
||||||
.single()
|
.limit(1)
|
||||||
if (data?.value) return data.value
|
)
|
||||||
|
if (rows[0]?.value) return rows[0].value
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to env
|
// fall through to env
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @vitest-environment node
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { getEscalationThreshold, escalateOverdueCapa } from '@/lib/notifications/capa-escalation'
|
import { getEscalationThreshold, escalateOverdueCapa } from '@/lib/notifications/capa-escalation'
|
||||||
|
|
||||||
@@ -17,6 +18,16 @@ vi.mock('@/lib/settings', () => ({
|
|||||||
getApiKey: vi.fn().mockResolvedValue('test-cred'),
|
getApiKey: vi.fn().mockResolvedValue('test-cred'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/notifications/in-app', () => ({
|
||||||
|
createInAppNotifications: vi.fn().mockResolvedValue({ created: 1 }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// At top of file, before other vi.mock calls
|
||||||
|
let mockAsAdminImpl: (fn: (db: unknown) => unknown) => unknown
|
||||||
|
vi.mock('@/lib/db/with-user', () => ({
|
||||||
|
asAdmin: vi.fn().mockImplementation((fn: (db: unknown) => unknown) => mockAsAdminImpl(fn)),
|
||||||
|
}))
|
||||||
|
|
||||||
describe('getEscalationThreshold', () => {
|
describe('getEscalationThreshold', () => {
|
||||||
function daysFromNow(n: number): string {
|
function daysFromNow(n: number): string {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
@@ -49,106 +60,60 @@ describe('getEscalationThreshold', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('escalateOverdueCapa — WhatsApp', () => {
|
describe('escalateOverdueCapa', () => {
|
||||||
function daysFromNow(n: number): string {
|
function daysFromNow(n: number): string {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setDate(d.getDate() + n)
|
d.setDate(d.getDate() + n)
|
||||||
return d.toISOString().split('T')[0]
|
return d.toISOString().split('T')[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSupabaseMock(overrides: {
|
beforeEach(() => { vi.clearAllMocks() })
|
||||||
capas?: unknown[]
|
|
||||||
alreadySent?: unknown
|
|
||||||
hseUsers?: unknown[]
|
|
||||||
}) {
|
|
||||||
const { capas = [], alreadySent = null, hseUsers = [] } = overrides
|
|
||||||
|
|
||||||
// Each .from() call returns a fresh chainable builder
|
it('returns notified:0 when no active capas', async () => {
|
||||||
// We track call order: first from('capa_actions'), then from('notifications_log'), then from('users'), then from('notifications_log').insert
|
let call = 0
|
||||||
let fromCallIndex = 0
|
mockAsAdminImpl = (fn) => {
|
||||||
|
call++
|
||||||
const makeChain = (resolvedValue: unknown) => {
|
if (call === 1) return fn({ select: () => ({ from: () => ({ leftJoin: () => ({ leftJoin: () => ({ where: () => [] }) }) }) }) })
|
||||||
const chain: Record<string, unknown> = {}
|
return fn({})
|
||||||
const methods = ['select', 'not', 'eq', 'in', 'limit', 'maybeSingle', 'insert']
|
|
||||||
for (const m of methods) {
|
|
||||||
chain[m] = vi.fn(() => chain)
|
|
||||||
}
|
}
|
||||||
// Terminal: awaiting the chain resolves to resolvedValue
|
const { notified } = await escalateOverdueCapa()
|
||||||
Object.defineProperty(chain, 'then', {
|
expect(notified).toBe(0)
|
||||||
get() {
|
|
||||||
return (resolve: (v: unknown) => unknown) => Promise.resolve(resolvedValue).then(resolve)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
return chain
|
|
||||||
|
it('skips capa with no threshold match', async () => {
|
||||||
|
let call = 0
|
||||||
|
mockAsAdminImpl = (fn) => {
|
||||||
|
call++
|
||||||
|
if (call === 1) {
|
||||||
|
// Return capa due in 2 days (no threshold)
|
||||||
|
return Promise.resolve([{
|
||||||
|
id: 'c1', description: 'Fix it', dueDate: daysFromNow(2),
|
||||||
|
incidentId: 'i1', ownerUserId: 'u1',
|
||||||
|
incidentRef: 'SITE-202507-0001', siteId: 's1',
|
||||||
|
ownerEmail: 'owner@example.com', ownerName: 'Owner', ownerPhone: null,
|
||||||
|
}])
|
||||||
}
|
}
|
||||||
|
return Promise.resolve([])
|
||||||
const supabase = {
|
|
||||||
from: vi.fn(() => {
|
|
||||||
const index = fromCallIndex++
|
|
||||||
if (index === 0) return makeChain({ data: capas, error: null }) // capa_actions
|
|
||||||
if (index === 1) return makeChain({ data: alreadySent, error: null }) // notifications_log check
|
|
||||||
if (index === 2) return makeChain({ data: hseUsers, error: null }) // hse users for CC
|
|
||||||
return makeChain({ data: null, error: null }) // notifications_log insert
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
const { notified } = await escalateOverdueCapa()
|
||||||
|
expect(notified).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
return supabase as unknown as import('@supabase/supabase-js').SupabaseClient
|
it('sends email and WhatsApp for overdue_3d capa with phone', async () => {
|
||||||
|
const { sendWhatsAppMessage } = await import('@/lib/notifications/whatsapp')
|
||||||
|
let call = 0
|
||||||
|
mockAsAdminImpl = (fn) => {
|
||||||
|
call++
|
||||||
|
if (call === 1) return Promise.resolve([{
|
||||||
|
id: 'c1', description: 'Fix it', dueDate: daysFromNow(-3),
|
||||||
|
incidentId: 'i1', ownerUserId: 'u1',
|
||||||
|
incidentRef: 'SITE-202507-0001', siteId: 's1',
|
||||||
|
ownerEmail: 'owner@example.com', ownerName: 'Owner', ownerPhone: '+60123456789',
|
||||||
|
}])
|
||||||
|
return Promise.resolve([]) // alreadySent = [], hseUsers = [], insert void
|
||||||
}
|
}
|
||||||
|
const { notified } = await escalateOverdueCapa()
|
||||||
beforeEach(() => {
|
expect(notified).toBe(1)
|
||||||
vi.clearAllMocks()
|
expect(sendWhatsAppMessage).toHaveBeenCalled()
|
||||||
})
|
|
||||||
|
|
||||||
it('sends WhatsApp when threshold is overdue_7d and owner has phone', async () => {
|
|
||||||
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
|
|
||||||
|
|
||||||
const supabase = makeSupabaseMock({
|
|
||||||
capas: [
|
|
||||||
{
|
|
||||||
id: 'capa-1',
|
|
||||||
description: 'Fix safety barrier',
|
|
||||||
due_date: daysFromNow(-7),
|
|
||||||
incident_id: 'inc-1',
|
|
||||||
incidents: { reference_no: 'KL-202501-0001', site_id: 'site-1' },
|
|
||||||
owner: { email: 'owner@test.com', name: 'Alice', phone: '60123456789' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
alreadySent: null,
|
|
||||||
hseUsers: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
await escalateOverdueCapa(supabase)
|
|
||||||
|
|
||||||
expect(mockWA).toHaveBeenCalledWith(
|
|
||||||
expect.stringMatching(/^\d+$/),
|
|
||||||
'ims_capa_overdue',
|
|
||||||
expect.arrayContaining([expect.any(String)]),
|
|
||||||
'test-cred',
|
|
||||||
'test-cred',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does NOT send WhatsApp when threshold is warning_3d', async () => {
|
|
||||||
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
|
|
||||||
vi.clearAllMocks()
|
|
||||||
|
|
||||||
const supabase = makeSupabaseMock({
|
|
||||||
capas: [
|
|
||||||
{
|
|
||||||
id: 'capa-2',
|
|
||||||
description: 'Inspect equipment',
|
|
||||||
due_date: daysFromNow(3),
|
|
||||||
incident_id: 'inc-2',
|
|
||||||
incidents: { reference_no: 'KL-202501-0002', site_id: 'site-1' },
|
|
||||||
owner: { email: 'owner@test.com', name: 'Bob', phone: '60129876543' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
alreadySent: null,
|
|
||||||
hseUsers: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
await escalateOverdueCapa(supabase)
|
|
||||||
|
|
||||||
expect(mockWA).not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
// @vitest-environment node
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
const { mockSend, mockFrom } = vi.hoisted(() => ({
|
const { mockSend } = vi.hoisted(() => ({
|
||||||
mockSend: vi.fn(),
|
mockSend: vi.fn(),
|
||||||
mockFrom: vi.fn(),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('resend', () => {
|
vi.mock('resend', () => {
|
||||||
@@ -11,42 +11,36 @@ vi.mock('resend', () => {
|
|||||||
return { Resend: MockResend }
|
return { Resend: MockResend }
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('@/lib/supabase/server', () => ({
|
// Mock asAdmin: first call returns recipients, second returns incident+join rows
|
||||||
createClient: vi.fn().mockResolvedValue({ from: mockFrom }),
|
let asAdminCallCount = 0
|
||||||
|
const mockRecipients = [
|
||||||
|
{ email: 'supervisor@setiacorp.com' },
|
||||||
|
{ email: 'hse@setiacorp.com' },
|
||||||
|
]
|
||||||
|
const mockIncidentRows = [{
|
||||||
|
reportedAt: new Date('2026-07-10T09:00:00Z'),
|
||||||
|
siteName: 'SCW1',
|
||||||
|
reporterName: 'John Doe',
|
||||||
|
}]
|
||||||
|
|
||||||
|
vi.mock('@/lib/db/with-user', () => ({
|
||||||
|
asAdmin: vi.fn().mockImplementation((fn: (db: unknown) => unknown) => {
|
||||||
|
asAdminCallCount++
|
||||||
|
if (asAdminCallCount % 2 === 1) {
|
||||||
|
// first call: recipients query
|
||||||
|
return Promise.resolve(mockRecipients)
|
||||||
|
}
|
||||||
|
// second call: incident join query
|
||||||
|
return Promise.resolve(mockIncidentRows)
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { sendNewIncidentEmail } from '@/lib/notifications/email'
|
import { sendNewIncidentEmail } from '@/lib/notifications/email'
|
||||||
|
|
||||||
const mockIncidentRow = {
|
|
||||||
reported_at: '2026-07-10T09:00:00Z',
|
|
||||||
sites: { name: 'SCW1' },
|
|
||||||
reporter: { name: 'John Doe' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupMocks(recipientData: object[]) {
|
|
||||||
mockFrom.mockImplementation((table: string) => {
|
|
||||||
if (table === 'users') {
|
|
||||||
return {
|
|
||||||
select: vi.fn().mockReturnThis(),
|
|
||||||
in: vi.fn().mockReturnThis(),
|
|
||||||
eq: vi.fn().mockResolvedValue({ data: recipientData, error: null }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
select: vi.fn().mockReturnThis(),
|
|
||||||
eq: vi.fn().mockReturnThis(),
|
|
||||||
single: vi.fn().mockResolvedValue({ data: mockIncidentRow, error: null }),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
asAdminCallCount = 0
|
||||||
mockSend.mockResolvedValue({ data: { id: 'email-123' }, error: null })
|
mockSend.mockResolvedValue({ data: { id: 'email-123' }, error: null })
|
||||||
setupMocks([
|
|
||||||
{ email: 'supervisor@setiacorp.com', name: 'Ahmad', role: 'supervisor' },
|
|
||||||
{ email: 'hse@setiacorp.com', name: 'Priya', role: 'hse' },
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sendNewIncidentEmail', () => {
|
describe('sendNewIncidentEmail', () => {
|
||||||
@@ -59,7 +53,8 @@ describe('sendNewIncidentEmail', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('does not send if no recipients', async () => {
|
it('does not send if no recipients', async () => {
|
||||||
setupMocks([])
|
const { asAdmin } = await import('@/lib/db/with-user')
|
||||||
|
vi.mocked(asAdmin).mockResolvedValueOnce([])
|
||||||
await sendNewIncidentEmail('inc-001', 'site-001', 'SCW1-202607-0001', 'near_miss')
|
await sendNewIncidentEmail('inc-001', 'site-001', 'SCW1-202607-0001', 'near_miss')
|
||||||
expect(mockSend).not.toHaveBeenCalled()
|
expect(mockSend).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,60 +1,58 @@
|
|||||||
|
// @vitest-environment node
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
|
|
||||||
function makeSupabaseMock(rpcResult: { error: unknown } = { error: null }) {
|
// Mock asAdmin before importing the module under test
|
||||||
return {
|
const mockInsert = vi.fn()
|
||||||
rpc: vi.fn().mockResolvedValue(rpcResult),
|
const mockValues = vi.fn().mockResolvedValue([])
|
||||||
} as unknown as SupabaseClient
|
vi.mock('@/lib/db/with-user', () => ({
|
||||||
}
|
asAdmin: vi.fn().mockImplementation(fn =>
|
||||||
|
fn({
|
||||||
|
insert: mockInsert.mockReturnValue({ values: mockValues }),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||||
|
|
||||||
describe('createInAppNotifications', () => {
|
describe('createInAppNotifications', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockInsert.mockReturnValue({ values: mockValues })
|
||||||
|
mockValues.mockResolvedValue([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls create_in_app_notification RPC once per recipient', async () => {
|
it('inserts once per recipient', async () => {
|
||||||
const supabase = makeSupabaseMock()
|
const { created } = await createInAppNotifications([
|
||||||
const { created } = await createInAppNotifications(supabase, [
|
|
||||||
{ userId: 'u1', title: 'New incident', link: '/hse/incidents/i1', incidentId: 'i1' },
|
{ userId: 'u1', title: 'New incident', link: '/hse/incidents/i1', incidentId: 'i1' },
|
||||||
{ userId: 'u2', title: 'New incident', link: '/hse/incidents/i1', incidentId: 'i1' },
|
{ userId: 'u2', title: 'New incident', link: '/hse/incidents/i1', incidentId: 'i1' },
|
||||||
])
|
])
|
||||||
expect(created).toBe(2)
|
expect(created).toBe(2)
|
||||||
expect(supabase.rpc).toHaveBeenCalledTimes(2)
|
expect(mockValues).toHaveBeenCalledTimes(2)
|
||||||
expect(supabase.rpc).toHaveBeenCalledWith('create_in_app_notification', {
|
|
||||||
p_recipient: 'u1',
|
|
||||||
p_title: 'New incident',
|
|
||||||
p_link: '/hse/incidents/i1',
|
|
||||||
p_incident_id: 'i1',
|
|
||||||
p_capa_id: null,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('skips entries missing userId or title', async () => {
|
it('skips entries missing userId or title', async () => {
|
||||||
const supabase = makeSupabaseMock()
|
const { created } = await createInAppNotifications([
|
||||||
const { created } = await createInAppNotifications(supabase, [
|
|
||||||
{ userId: '', title: 'x' },
|
{ userId: '', title: 'x' },
|
||||||
{ userId: 'u1', title: '' },
|
{ userId: 'u1', title: '' },
|
||||||
])
|
])
|
||||||
expect(created).toBe(0)
|
expect(created).toBe(0)
|
||||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
expect(mockValues).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('counts only successful inserts when RPC errors', async () => {
|
it('counts only successful inserts; failed insert is caught', async () => {
|
||||||
const supabase = makeSupabaseMock({ error: { message: 'boom' } })
|
mockValues.mockRejectedValueOnce(new Error('DB error'))
|
||||||
const { created } = await createInAppNotifications(supabase, [
|
const { created } = await createInAppNotifications([
|
||||||
{ userId: 'u1', title: 'x' },
|
{ userId: 'u1', title: 'x' },
|
||||||
])
|
])
|
||||||
expect(created).toBe(0)
|
expect(created).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('deduplicates recipients for the same notification', async () => {
|
it('deduplicates same userId+title+incidentId', async () => {
|
||||||
const supabase = makeSupabaseMock()
|
const { created } = await createInAppNotifications([
|
||||||
const { created } = await createInAppNotifications(supabase, [
|
|
||||||
{ userId: 'u1', title: 'same', incidentId: 'i1' },
|
{ userId: 'u1', title: 'same', incidentId: 'i1' },
|
||||||
{ userId: 'u1', title: 'same', incidentId: 'i1' },
|
{ userId: 'u1', title: 'same', incidentId: 'i1' },
|
||||||
])
|
])
|
||||||
expect(created).toBe(1)
|
expect(created).toBe(1)
|
||||||
expect(supabase.rpc).toHaveBeenCalledTimes(1)
|
expect(mockValues).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user