diff --git a/app/api/capa/[id]/verify/route.ts b/app/api/capa/[id]/verify/route.ts index be32bd6..5f969b1 100644 --- a/app/api/capa/[id]/verify/route.ts +++ b/app/api/capa/[id]/verify/route.ts @@ -28,10 +28,20 @@ export async function POST( if (body.verdict !== 'verified' && body.verdict !== 'reopened') 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 = { status: body.verdict, 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 diff --git a/app/api/cron/effectiveness-recheck/route.ts b/app/api/cron/effectiveness-recheck/route.ts new file mode 100644 index 0000000..93c81af --- /dev/null +++ b/app/api/cron/effectiveness-recheck/route.ts @@ -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 }) +} diff --git a/lib/notifications/effectiveness-recheck.ts b/lib/notifications/effectiveness-recheck.ts new file mode 100644 index 0000000..8f07b1c --- /dev/null +++ b/lib/notifications/effectiveness-recheck.ts @@ -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 = ` +

Hi ${verifierName},

+

This is the ${roundLabel} effectiveness check for a corrective action you verified on incident ${incidentRef}.

+

Action: ${capaDesc}

+

Please confirm the corrective action is still in place and effective.

+

View CAPA

+ ` + 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 } +} diff --git a/tests/lib/notifications/effectiveness-recheck.test.ts b/tests/lib/notifications/effectiveness-recheck.test.ts new file mode 100644 index 0000000..4e8b3f9 --- /dev/null +++ b/tests/lib/notifications/effectiveness-recheck.test.ts @@ -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) + }) +})