From 75fa2605bf85218680dbfc4fb286d3e74ab1d530 Mon Sep 17 00:00:00 2001 From: weeihan Date: Sat, 11 Jul 2026 15:20:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CAPA=20overdue=20escalation=20cron=20?= =?UTF-8?q?=E2=80=94=204=20thresholds,=20once-per-threshold=20via=20notifi?= =?UTF-8?q?cations=5Flog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr --- .env.local.example | 1 + app/api/cron/capa-escalation/route.ts | 17 +++ docs/vps-cron.md | 22 ++++ lib/notifications/capa-escalation.ts | 105 ++++++++++++++++++ .../lib/notifications/capa-escalation.test.ts | 34 ++++++ 5 files changed, 179 insertions(+) create mode 100644 app/api/cron/capa-escalation/route.ts create mode 100644 docs/vps-cron.md create mode 100644 lib/notifications/capa-escalation.ts create mode 100644 tests/lib/notifications/capa-escalation.test.ts diff --git a/.env.local.example b/.env.local.example index 0525956..53a7394 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,3 +2,4 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here NEXT_PUBLIC_APP_URL=http://localhost:3000 +CRON_SECRET= diff --git a/app/api/cron/capa-escalation/route.ts b/app/api/cron/capa-escalation/route.ts new file mode 100644 index 0000000..9f33eb5 --- /dev/null +++ b/app/api/cron/capa-escalation/route.ts @@ -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 }) +} diff --git a/docs/vps-cron.md b/docs/vps-cron.md new file mode 100644 index 0000000..4f7dc70 --- /dev/null +++ b/docs/vps-cron.md @@ -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` diff --git a/lib/notifications/capa-escalation.ts b/lib/notifications/capa-escalation.ts new file mode 100644 index 0000000..eff6597 --- /dev/null +++ b/lib/notifications/capa-escalation.ts @@ -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 = { + 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 = ` +

Hi ${ownerName},

+

CAPA action for incident ${incidentRef} requires attention.

+

Action: ${(capa as { description: string }).description}

+

Due date: ${(capa as { due_date: string }).due_date}

+

View CAPA

+ ` + 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 } +} diff --git a/tests/lib/notifications/capa-escalation.test.ts b/tests/lib/notifications/capa-escalation.test.ts new file mode 100644 index 0000000..5ad35cb --- /dev/null +++ b/tests/lib/notifications/capa-escalation.test.ts @@ -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() + }) +})