feat: CAPA overdue escalation cron — 4 thresholds, once-per-threshold via notifications_log
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
|
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
CRON_SECRET=<generate with: openssl rand -hex 32>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { escalateOverdueCapa } from '@/lib/notifications/capa-escalation'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = request.headers.get('authorization')
|
||||||
|
const expected = `Bearer ${process.env.CRON_SECRET}`
|
||||||
|
if (!auth || auth !== expected) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { notified } = await escalateOverdueCapa(supabase)
|
||||||
|
return NextResponse.json({ ok: true, notified })
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# VPS Cron Jobs
|
||||||
|
|
||||||
|
## CAPA escalation
|
||||||
|
|
||||||
|
Runs daily at 00:00 UTC (08:00 MYT). Checks all open CAPAs against 4 thresholds
|
||||||
|
(`warning_3d`, `due_today`, `overdue_3d`, `overdue_7d`) and sends email via Resend.
|
||||||
|
Each threshold fires once per CAPA, tracked in `notifications_log.status`.
|
||||||
|
|
||||||
|
**Install (SSH into VPS):**
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
# Add:
|
||||||
|
0 0 * * * curl -s -H "Authorization: Bearer $(grep CRON_SECRET /ims/.env.local | cut -d= -f2)" http://localhost:3000/ims/api/cron/capa-escalation >> /var/log/ims-cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual test:**
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/ims/api/cron/capa-escalation
|
||||||
|
# Expected: {"ok":true,"notified":N}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Log:** `/var/log/ims-cron.log`
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Resend } from 'resend'
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
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,
|
||||||
|
incidents (reference_no, site_id),
|
||||||
|
owner:users!owner_user_id (email, name)
|
||||||
|
`)
|
||||||
|
.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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase.from('notifications_log').insert({
|
||||||
|
capa_id: capa.id,
|
||||||
|
channel: 'email',
|
||||||
|
recipient: to.join(','),
|
||||||
|
status: threshold,
|
||||||
|
})
|
||||||
|
|
||||||
|
notified++
|
||||||
|
}
|
||||||
|
|
||||||
|
return { notified }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { getEscalationThreshold } from '@/lib/notifications/capa-escalation'
|
||||||
|
|
||||||
|
describe('getEscalationThreshold', () => {
|
||||||
|
function daysFromNow(n: number): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + n)
|
||||||
|
return d.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns warning_3d when due in 3 days', () => {
|
||||||
|
expect(getEscalationThreshold(daysFromNow(3))).toBe('warning_3d')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns due_today when due today', () => {
|
||||||
|
expect(getEscalationThreshold(daysFromNow(0))).toBe('due_today')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns overdue_3d when 3 days past due', () => {
|
||||||
|
expect(getEscalationThreshold(daysFromNow(-3))).toBe('overdue_3d')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns overdue_7d when 7 days past due', () => {
|
||||||
|
expect(getEscalationThreshold(daysFromNow(-7))).toBe('overdue_7d')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for 2 days before due (no threshold)', () => {
|
||||||
|
expect(getEscalationThreshold(daysFromNow(2))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for 4 days before due', () => {
|
||||||
|
expect(getEscalationThreshold(daysFromNow(4))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user