Files
ims/lib/notifications/capa-escalation.ts
T
adminandClaude Fable 5 576557181a feat: Phase 5 & 6 — usability, compliance hardening, analytics
Phase 5 (usability + compliance):
- In-app notification bell/badge: migration 016 adds read state + per-user
  RLS + create_in_app_notification SECURITY DEFINER RPC; /api/notifications;
  wired into incident creation, CAPA assign/verify, escalation cron
- Incident closure: new POST /api/incidents/[id]/close (requires verification
  status + all CAPAs verified); migration 017 locks closed incidents at DB
  level (update/delete triggers) with append-only incident_addenda + UI panel
- Server-side pagination on HSE/supervisor incident inboxes (.range, 25/page)
- Investigation form: alcohol/urine test result + witness statement refs
  (existing schema columns, now editable)
- Type-specific intake fields: migration 018 adds incidents.type_details
  JSONB; whitelist validation; environmental/asset/security/fire field
  groups in report form; EN/MS/ZH labels; offline queue support
- JKKP 8 annual register CSV export (/api/reports/jkkp8) + dashboard button
  + January statutory deadline banner
- Admin page: user invite (service-role client), role/site/active management,
  site + zone CRUD with QR report links — replaces Phase 0 stub
- Evidence gallery thumbnails via Supabase render transform with fallback

Phase 6 (analytics):
- 12-month stacked trend chart (leading/lagging/other) + top root causes
  (lib/dashboard/trends.ts pure helpers)
- AI rising-risk zones: /api/dashboard/ai/risk-flags aggregates 90-day
  zone stats, claude-opus-4-8 forced tool_use, panel on HSE + management
  dashboards, suggestion audit-logged

Also fixes 9 pre-existing missing /ims basePath prefixes in client fetches
and download links.

132 tests passing, tsc clean, next build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
2026-07-12 10:25:08 +08:00

141 lines
5.2 KiB
TypeScript

import { Resend } from 'resend'
import type { SupabaseClient } from '@supabase/supabase-js'
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.setHours(0, 0, 0, 0)
const due = new Date(dueDateIso)
due.setHours(0, 0, 0, 0)
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(
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)')
if (!capas || capas.length === 0) return { notified: 0 }
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM_EMAIL ?? 'onboarding@resend.dev'
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')
for (const capa of capas) {
const threshold = getEscalationThreshold((capa as { due_date: string }).due_date)
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()
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'
if (!ownerEmail) continue
const incidentRef = (capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no ?? capa.incident_id
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><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 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))
}
const { error } = await resend.emails.send({ from, to: [...new Set(to)], subject, html, text })
if (error) {
console.error('Escalation email error:', error)
continue
}
// 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 ?? ''
if (ownerPhone) {
try {
await sendWhatsAppMessage(
ownerPhone,
'ims_capa_overdue',
[incidentRef, (capa as { description: string }).description, (capa as { due_date: string }).due_date],
whatsappPhoneNumberId,
whatsappAccessToken,
)
} catch (waErr) {
console.error('WhatsApp escalation error:', waErr)
}
}
}
await supabase.from('notifications_log').insert({
capa_id: capa.id,
channel: 'email',
recipient: 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}`,
link: `/hse/capa/${capa.id}`,
incidentId: capa.incident_id as string,
capaId: capa.id as string,
}])
}
notified++
}
return { notified }
}