feat: CAPA effectiveness re-check cron — 30/60/90-day email + WhatsApp notifications
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
This commit is contained in:
@@ -28,10 +28,20 @@ export async function POST(
|
|||||||
if (body.verdict !== 'verified' && body.verdict !== 'reopened')
|
if (body.verdict !== 'verified' && body.verdict !== 'reopened')
|
||||||
return NextResponse.json({ error: 'verdict must be verified or reopened' }, { status: 422 })
|
return NextResponse.json({ error: 'verdict must be verified or reopened' }, { status: 422 })
|
||||||
|
|
||||||
|
const verifiedAt = new Date()
|
||||||
|
const recheckDate = new Date(verifiedAt)
|
||||||
|
recheckDate.setDate(recheckDate.getDate() + 30)
|
||||||
|
|
||||||
const update: Record<string, unknown> = {
|
const update: Record<string, unknown> = {
|
||||||
status: body.verdict,
|
status: body.verdict,
|
||||||
verified_by: user.id,
|
verified_by: user.id,
|
||||||
verified_at: new Date().toISOString(),
|
verified_at: verifiedAt.toISOString(),
|
||||||
|
...(body.verdict === 'verified'
|
||||||
|
? {
|
||||||
|
effectiveness_recheck_date: recheckDate.toISOString().split('T')[0],
|
||||||
|
effectiveness_recheck_round: 0,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { sendEffectivenessRecheckNotifications } from '@/lib/notifications/effectiveness-recheck'
|
||||||
|
|
||||||
|
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 sendEffectivenessRecheckNotifications(supabase)
|
||||||
|
return NextResponse.json({ ok: true, notified })
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Resend } from 'resend'
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||||
|
import { getApiKey } from '@/lib/settings'
|
||||||
|
|
||||||
|
const ROUND_LABELS = ['30-day', '60-day', '90-day'] as const
|
||||||
|
const NEXT_INTERVAL_DAYS: (number | null)[] = [60, 90, null]
|
||||||
|
|
||||||
|
export function getRoundLabel(round: number): string {
|
||||||
|
return ROUND_LABELS[round] ?? '90-day'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextRecheckDate(verifiedAt: string, currentRound: number): string | null {
|
||||||
|
const days = NEXT_INTERVAL_DAYS[currentRound]
|
||||||
|
if (days === null || days === undefined) return null
|
||||||
|
const base = new Date(verifiedAt)
|
||||||
|
base.setDate(base.getDate() + days)
|
||||||
|
return base.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSendRecheck(
|
||||||
|
recheckDate: string | null,
|
||||||
|
round: number,
|
||||||
|
today: string = new Date().toISOString().split('T')[0],
|
||||||
|
): boolean {
|
||||||
|
if (!recheckDate || round >= 3) return false
|
||||||
|
return recheckDate <= today
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEffectivenessRecheckNotifications(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
): 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)
|
||||||
|
|
||||||
|
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 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')
|
||||||
|
} catch {
|
||||||
|
// WhatsApp not configured — email only
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 ?? ''
|
||||||
|
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 roundLabel = getRoundLabel(round)
|
||||||
|
const capaDesc = (capa as { description: string }).description
|
||||||
|
const capaUrl = `${siteUrl}/ims/hse/capa/${(capa as { id: string }).id}`
|
||||||
|
|
||||||
|
const subject = `[IMS] ${roundLabel} effectiveness check — ${incidentRef}`
|
||||||
|
const html = `
|
||||||
|
<p>Hi ${verifierName},</p>
|
||||||
|
<p>This is the <strong>${roundLabel} effectiveness check</strong> for a corrective action you verified on incident <strong>${incidentRef}</strong>.</p>
|
||||||
|
<p><strong>Action:</strong> ${capaDesc}</p>
|
||||||
|
<p>Please confirm the corrective action is still in place and effective.</p>
|
||||||
|
<p><a href="${capaUrl}">View CAPA</a></p>
|
||||||
|
`
|
||||||
|
const text = `${roundLabel} effectiveness check for ${incidentRef}\nAction: ${capaDesc}\n${capaUrl}`
|
||||||
|
|
||||||
|
const { error } = await resend.emails.send({ from, to: [verifierEmail], subject, html, text })
|
||||||
|
if (error) {
|
||||||
|
console.error('Effectiveness recheck email error:', error)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsApp (non-blocking, best-effort)
|
||||||
|
if (whatsappPhoneId && whatsappToken && verifierPhone) {
|
||||||
|
sendWhatsAppMessage(
|
||||||
|
verifierPhone,
|
||||||
|
'ims_effectiveness_recheck',
|
||||||
|
[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)
|
||||||
|
|
||||||
|
notified++
|
||||||
|
}
|
||||||
|
|
||||||
|
return { notified }
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
getNextRecheckDate,
|
||||||
|
shouldSendRecheck,
|
||||||
|
getRoundLabel,
|
||||||
|
} from '@/lib/notifications/effectiveness-recheck'
|
||||||
|
|
||||||
|
describe('getRoundLabel', () => {
|
||||||
|
it('returns "30-day" for round 0', () => {
|
||||||
|
expect(getRoundLabel(0)).toBe('30-day')
|
||||||
|
})
|
||||||
|
it('returns "60-day" for round 1', () => {
|
||||||
|
expect(getRoundLabel(1)).toBe('60-day')
|
||||||
|
})
|
||||||
|
it('returns "90-day" for round 2', () => {
|
||||||
|
expect(getRoundLabel(2)).toBe('90-day')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getNextRecheckDate', () => {
|
||||||
|
// verifiedAt 2026-07-01. July has 31 days.
|
||||||
|
// +60d → July 1 + 60 = Aug 30
|
||||||
|
// +90d → July 1 + 90 = Sep 29
|
||||||
|
it('returns 60d from verifiedAt when round 0 just sent', () => {
|
||||||
|
expect(getNextRecheckDate('2026-07-01T00:00:00Z', 0)).toBe('2026-08-30')
|
||||||
|
})
|
||||||
|
it('returns 90d from verifiedAt when round 1 just sent', () => {
|
||||||
|
expect(getNextRecheckDate('2026-07-01T00:00:00Z', 1)).toBe('2026-09-29')
|
||||||
|
})
|
||||||
|
it('returns null when round 2 just sent (all done)', () => {
|
||||||
|
expect(getNextRecheckDate('2026-07-01T00:00:00Z', 2)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shouldSendRecheck', () => {
|
||||||
|
it('true when date equals today and round < 3', () => {
|
||||||
|
expect(shouldSendRecheck('2026-07-11', 0, '2026-07-11')).toBe(true)
|
||||||
|
})
|
||||||
|
it('true when date is in the past and round < 3', () => {
|
||||||
|
expect(shouldSendRecheck('2026-07-10', 2, '2026-07-11')).toBe(true)
|
||||||
|
})
|
||||||
|
it('false when round is 3 (all done)', () => {
|
||||||
|
expect(shouldSendRecheck('2026-07-10', 3, '2026-07-11')).toBe(false)
|
||||||
|
})
|
||||||
|
it('false when recheckDate is null', () => {
|
||||||
|
expect(shouldSendRecheck(null, 0, '2026-07-11')).toBe(false)
|
||||||
|
})
|
||||||
|
it('false when date is in the future', () => {
|
||||||
|
expect(shouldSendRecheck('2026-07-20', 0, '2026-07-11')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user