# Phase 4 — Scale & Polish Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add WhatsApp Business API notifications, CAPA effectiveness re-check automation (30/60/90-day), multi-language UI (EN/MS/ZH), and PWA offline incident capture. **Architecture:** WhatsApp is a thin fetch wrapper over Meta Graph API used fire-and-forget alongside existing Resend email. i18n uses a lightweight cookie-based custom context (no extra library) to avoid restructuring App Router routes. Offline capture uses `idb` (IndexedDB wrapper) to queue reports when offline and auto-sync on reconnect via a client-side listener. All cron routes share the existing `Bearer $CRON_SECRET` auth pattern. **Tech Stack:** Next.js 15 App Router · Supabase · Meta WhatsApp Business Cloud API v19.0 · idb · Vitest 4 ## Global Constraints - `export const dynamic = 'force-dynamic'` on every route.ts and protected page.tsx. - Auth pattern for hse/admin routes: `auth.getUser()` → `users.role` check → 401/403. - Every DB mutation calls `supabase.rpc('write_audit_log', { p_table_name, p_record_id, p_action, p_new_value })`. - Supabase join type casts: `(row.relation as unknown as { field: string } | null)?.field`. - `getApiKey(supabase, 'KEY_NAME')` from `lib/settings.ts` for all API credentials — DB first, env fallback. - `basePath: '/ims'` — hard-code `/ims/` prefix in manifest `start_url`, SW registration path, and WhatsApp notification URLs. Client-side `fetch()` calls use `/api/...` paths (consistent with existing codebase). - WhatsApp sends are always fire-and-forget (non-blocking, `catch(err => console.error(...))`). - No service-role key in app code. No `.env` secrets committed. - Cron routes: `GET` protected by `Authorization: Bearer $CRON_SECRET` header. - `next.config.js` uses CommonJS (`module.exports`). No new build plugins introduced. - Meta WhatsApp template names: `ims_incident_alert`, `ims_capa_overdue`, `ims_effectiveness_recheck`. These must be created and approved in Meta Business Manager before production use. The implementation is complete regardless; see Task 1 Step 1 for exact variable specs. --- ## File Map **New files:** - `lib/notifications/whatsapp.ts` - `lib/notifications/effectiveness-recheck.ts` - `lib/i18n/locales.ts` - `lib/i18n/context.tsx` - `lib/i18n/server.ts` - `messages/en.json` - `messages/ms.json` - `messages/zh.json` - `components/language-switcher.tsx` - `components/incidents/offline-sync.tsx` - `lib/offline/db.ts` - `public/manifest.json` - `public/icons/icon.svg` - `public/sw.js` - `app/api/cron/effectiveness-recheck/route.ts` - `supabase/migrations/20260711000015_phase4.sql` - `tests/lib/notifications/whatsapp.test.ts` - `tests/lib/notifications/effectiveness-recheck.test.ts` - `tests/lib/i18n/translations.test.ts` - `tests/lib/offline/db.test.ts` **Modified files:** - `app/api/settings/route.ts` — expand ALLOWED_KEYS with WhatsApp credentials - `app/api/incidents/route.ts` — fire WhatsApp alongside email - `lib/notifications/capa-escalation.ts` — add WhatsApp for urgent thresholds - `app/api/capa/[id]/verify/route.ts` — set effectiveness_recheck_date + round on verify - `app/layout.tsx` — I18nProvider wrap + manifest link + SW registration script - `app/report/page.tsx` — add LanguageSwitcher - `components/incidents/report-form.tsx` — offline mode + i18n translations --- ## Task 1: WhatsApp notification helper + migration **Files:** - Create: `lib/notifications/whatsapp.ts` - Create: `supabase/migrations/20260711000015_phase4.sql` - Modify: `app/api/settings/route.ts` - Test: `tests/lib/notifications/whatsapp.test.ts` **Interfaces:** - Produces: `sendWhatsAppMessage(phoneNumber, templateName, parameters, phoneNumberId, accessToken): Promise` — pure, testable, used by Tasks 2 and 3. **WhatsApp template variable specs (create these in Meta Business Manager):** ``` Template: ims_incident_alert Body: "IMS Alert: New incident {{1}} ({{2}}) reported at {{3}}. Please review immediately." Variables: {{1}}=reference_no, {{2}}=incident_type, {{3}}=site_name Template: ims_capa_overdue Body: "IMS CAPA Overdue: Action for incident {{1}} is overdue. Description: {{2}}. Due: {{3}}." Variables: {{1}}=incident_ref, {{2}}=capa_description, {{3}}=due_date Template: ims_effectiveness_recheck Body: "IMS Effectiveness Check ({{1}}): Please verify that the corrective action for incident {{2}} is still holding. Action: {{3}}" Variables: {{1}}=round_label (e.g. "30-day"), {{2}}=incident_ref, {{3}}=capa_description ``` - [ ] **Step 1: Write the failing test** ```typescript // tests/lib/notifications/whatsapp.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest' import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp' const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) describe('sendWhatsAppMessage', () => { beforeEach(() => fetchMock.mockClear()) it('posts to Meta Graph API with correct template structure', async () => { fetchMock.mockResolvedValueOnce( new Response('{"messages":[{"id":"wamid.abc"}]}', { status: 200 }) ) await sendWhatsAppMessage( '60123456789', 'ims_incident_alert', ['SETIA-202407-0001', 'injury', 'Warehouse A'], 'test-phone-id', 'test-token' ) expect(fetchMock).toHaveBeenCalledOnce() const [url, opts] = fetchMock.mock.calls[0] expect(url).toBe('https://graph.facebook.com/v19.0/test-phone-id/messages') expect(opts.method).toBe('POST') expect(opts.headers['Authorization']).toBe('Bearer test-token') const body = JSON.parse(opts.body) expect(body.messaging_product).toBe('whatsapp') expect(body.to).toBe('60123456789') expect(body.type).toBe('template') expect(body.template.name).toBe('ims_incident_alert') expect(body.template.language.code).toBe('en_US') expect(body.template.components[0].parameters).toHaveLength(3) expect(body.template.components[0].parameters[0]).toEqual({ type: 'text', text: 'SETIA-202407-0001' }) }) it('throws on non-2xx response', async () => { fetchMock.mockResolvedValueOnce( new Response('{"error":{"message":"Invalid token"}}', { status: 400 }) ) await expect( sendWhatsAppMessage('60123456789', 'ims_incident_alert', ['a'], 'pid', 'tok') ).rejects.toThrow('WhatsApp API error: 400') }) it('returns immediately without calling fetch when phoneNumber is empty', async () => { await sendWhatsAppMessage('', 'ims_incident_alert', ['a'], 'pid', 'tok') expect(fetchMock).not.toHaveBeenCalled() }) it('sanitizes phone number — strips spaces, dashes, and plus sign', async () => { fetchMock.mockResolvedValueOnce( new Response('{"messages":[{"id":"x"}]}', { status: 200 }) ) await sendWhatsAppMessage('+60 12-345 6789', 'ims_incident_alert', ['a'], 'pid', 'tok') const body = JSON.parse(fetchMock.mock.calls[0][1].body) expect(body.to).toBe('60123456789') }) }) ``` - [ ] **Step 2: Run test to confirm it fails** ```bash npx vitest run tests/lib/notifications/whatsapp.test.ts ``` Expected: FAIL — "Cannot find module '@/lib/notifications/whatsapp'" - [ ] **Step 3: Create `lib/notifications/whatsapp.ts`** ```typescript export async function sendWhatsAppMessage( phoneNumber: string, templateName: string, parameters: string[], phoneNumberId: string, accessToken: string, ): Promise { const sanitized = phoneNumber.replace(/[^0-9]/g, '') if (!sanitized) return const body = { messaging_product: 'whatsapp', to: sanitized, type: 'template', template: { name: templateName, language: { code: 'en_US' }, components: [ { type: 'body', parameters: parameters.map(text => ({ type: 'text', text })), }, ], }, } const res = await fetch( `https://graph.facebook.com/v19.0/${phoneNumberId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, }, body: JSON.stringify(body), } ) if (!res.ok) { throw new Error(`WhatsApp API error: ${res.status}`) } } ``` - [ ] **Step 4: Run test to confirm it passes** ```bash npx vitest run tests/lib/notifications/whatsapp.test.ts ``` Expected: 4/4 PASS - [ ] **Step 5: Create migration `supabase/migrations/20260711000015_phase4.sql`** ```sql -- Add effectiveness recheck round tracking to capa_actions ALTER TABLE capa_actions ADD COLUMN IF NOT EXISTS effectiveness_recheck_round INT NOT NULL DEFAULT 0; -- WhatsApp and effectiveness recheck credentials for Settings UI INSERT INTO app_settings (key, value, updated_at, updated_by) VALUES ('META_WHATSAPP_PHONE_NUMBER_ID', '', now(), NULL), ('META_WHATSAPP_ACCESS_TOKEN', '', now(), NULL) ON CONFLICT (key) DO NOTHING; ``` - [ ] **Step 6: Expand ALLOWED_KEYS in `app/api/settings/route.ts`** Change line 6 from: ```typescript const ALLOWED_KEYS = ['ANTHROPIC_API_KEY', 'VOYAGE_API_KEY'] as const type SettingKey = typeof ALLOWED_KEYS[number] ``` To: ```typescript const ALLOWED_KEYS = [ 'ANTHROPIC_API_KEY', 'VOYAGE_API_KEY', 'META_WHATSAPP_PHONE_NUMBER_ID', 'META_WHATSAPP_ACCESS_TOKEN', ] as const type SettingKey = typeof ALLOWED_KEYS[number] ``` - [ ] **Step 7: Build check** ```bash npm run build 2>&1 | tail -20 ``` Expected: zero TypeScript/ESLint errors - [ ] **Step 8: Commit** ```bash git add lib/notifications/whatsapp.ts \ supabase/migrations/20260711000015_phase4.sql \ app/api/settings/route.ts \ tests/lib/notifications/whatsapp.test.ts git commit -m "feat: WhatsApp notification helper + phase 4 migration (recheck_round, WhatsApp settings)" ``` --- ## Task 2: WhatsApp integration — new incident alert + CAPA escalation **Files:** - Modify: `app/api/incidents/route.ts` - Modify: `lib/notifications/capa-escalation.ts` - Test: extend `tests/lib/notifications/whatsapp.test.ts` (no, this is in capa-escalation.test.ts) **Interfaces:** - Consumes: `sendWhatsAppMessage` from `lib/notifications/whatsapp.ts` - Consumes: `getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')` from `lib/settings.ts` **Note:** The `users` table has a `phone TEXT` column. WhatsApp sends only go to users with a non-empty phone. Entire WhatsApp block is fire-and-forget — never blocks the main request. - [ ] **Step 1: Write the failing test for WhatsApp in capa escalation** Read `tests/lib/notifications/capa-escalation.test.ts` first to understand the existing mock structure. Then: 1. Add `vi.mock('@/lib/notifications/whatsapp', () => ({ sendWhatsAppMessage: vi.fn().mockResolvedValue(undefined) }))` at the top of the file (alongside the existing vi.mock calls). 2. Import `sendWhatsAppMessage` from the mock: `import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'` 3. Add the following two test cases inside the existing `describe` block, after existing tests: ```typescript // In tests/lib/notifications/capa-escalation.test.ts // (add vi.mock and import above, then these test cases inside the describe block) it('sends WhatsApp when threshold is overdue_7d and owner has phone', async () => { // Use the same mock pattern as existing tests for supabase + resend. // Key difference: mock the owner to have a phone number and set today 7 days after due_date. // Then call escalateOverdueCapa(supabase) and assert: const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp') // Set up getApiKey to return dummy credentials (mock lib/settings.ts if not already mocked): // vi.mock('@/lib/settings', () => ({ getApiKey: vi.fn().mockResolvedValue('test-cred') })) // The supabase mock must return a capa with due_date = 7 days ago and owner.phone set. // After calling escalateOverdueCapa(supabase), expect: expect(mockWA).toHaveBeenCalledWith( expect.stringMatching(/^\d+$/), // sanitized phone digits 'ims_capa_overdue', expect.arrayContaining([expect.any(String)]), 'test-cred', 'test-cred', ) }) it('does NOT send WhatsApp when threshold is warning_3d', async () => { const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp') vi.clearAllMocks() // Set up supabase mock to return a capa with due_date = 3 days from today (warning_3d threshold). // After calling escalateOverdueCapa(supabase): expect(mockWA).not.toHaveBeenCalled() }) ``` **Implementation note for the test setup:** The existing tests in this file already mock `supabase` as a chainable query builder and `Resend`. Mirror that exact pattern — do not create new mock infrastructure. Only add the `whatsapp` mock and `settings` mock on top of what exists. If `lib/settings` is already mocked in the file, reuse that mock; if not, add `vi.mock('@/lib/settings', () => ({ getApiKey: vi.fn().mockResolvedValue('test-cred') }))` at the top. - [ ] **Step 2: Modify `lib/notifications/capa-escalation.ts`** Add imports at the top (after existing imports): ```typescript import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp' import { getApiKey } from '@/lib/settings' ``` Inside `escalateOverdueCapa`, after the `resend.emails.send()` call and before the `notifications_log.insert()` call, add the WhatsApp block. Locate this section (around line 88): ```typescript const { error } = await resend.emails.send({ from, to: [...new Set(to)], subject, html, text }) if (error) { console.error('Escalation email error:', error) continue } ``` Change to: ```typescript 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 { const phoneNumberId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID') const accessToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN') await sendWhatsAppMessage( ownerPhone, 'ims_capa_overdue', [incidentRef, (capa as { description: string }).description, (capa as { due_date: string }).due_date], phoneNumberId, accessToken, ) } catch (waErr) { console.error('WhatsApp escalation error:', waErr) } } } ``` Also update the `.select()` query near the top of `escalateOverdueCapa` to include `phone` in the owner join. Change: ```typescript owner:users!owner_user_id (email, name) ``` To: ```typescript owner:users!owner_user_id (email, name, phone) ``` - [ ] **Step 3: Add WhatsApp fire-and-forget to `app/api/incidents/route.ts`** Add import at the top (after the existing `sendNewIncidentEmail` import): ```typescript import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp' import { getApiKey } from '@/lib/settings' ``` Find the fire-and-forget email call (around line 109): ```typescript sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type) .catch(err => console.error('email notification failed:', err)) ``` Add immediately after it: ```typescript // WhatsApp alert — fire-and-forget alongside email ;(async () => { try { const phoneNumberId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID') const accessToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN') const { data: siteData } = await supabase .from('sites').select('name').eq('id', zone.site_id).single() const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown' const { data: recipients } = await supabase .from('users') .select('phone') .in('role', ['supervisor', 'hse']) .eq('site_id', zone.site_id) for (const r of recipients ?? []) { const phone = (r as { phone: string | null }).phone ?? '' if (!phone) continue await sendWhatsAppMessage( phone, 'ims_incident_alert', [incident.reference_no ?? '', input.incident_type, siteName], phoneNumberId, accessToken, ) } } catch (err) { console.error('WhatsApp incident alert error:', err) } })() ``` - [ ] **Step 4: Build check** ```bash npm run build 2>&1 | tail -20 ``` Expected: zero errors - [ ] **Step 5: Run tests** ```bash npx vitest run tests/lib/notifications/ ``` Expected: all pass (previous whatsapp.test.ts 4/4 + capa-escalation additions pass) - [ ] **Step 6: Commit** ```bash git add app/api/incidents/route.ts lib/notifications/capa-escalation.ts git commit -m "feat: WhatsApp notifications — new incident alert and CAPA overdue escalation" ``` --- ## Task 3: CAPA effectiveness re-check automation (30/60/90-day) **Files:** - Create: `lib/notifications/effectiveness-recheck.ts` - Create: `app/api/cron/effectiveness-recheck/route.ts` - Modify: `app/api/capa/[id]/verify/route.ts` - Test: `tests/lib/notifications/effectiveness-recheck.test.ts` **Interfaces:** - Consumes: `sendWhatsAppMessage` from `lib/notifications/whatsapp.ts` - Produces: `getNextRecheckDate(verifiedAt, currentRound): string | null` — pure, tested - Produces: `shouldSendRecheck(recheckDate, round, today?): boolean` — pure, tested - Produces: `sendEffectivenessRecheckNotifications(supabase): Promise<{notified: number}>` **Re-check round semantics:** ``` effectiveness_recheck_round = 0 + recheck_date = verified_at + 30d → cron fires → send 30-day check → set round=1, date=verified_at+60d effectiveness_recheck_round = 1 + recheck_date = verified_at + 60d → cron fires → send 60-day check → set round=2, date=verified_at+90d effectiveness_recheck_round = 2 + recheck_date = verified_at + 90d → cron fires → send 90-day check → set round=3, date=null (done) effectiveness_recheck_round = 3 → skip (done) ``` - [ ] **Step 1: Write the failing tests** ```typescript // tests/lib/notifications/effectiveness-recheck.test.ts 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) }) }) ``` - [ ] **Step 2: Run test to confirm failure** ```bash npx vitest run tests/lib/notifications/effectiveness-recheck.test.ts ``` Expected: FAIL — module not found - [ ] **Step 3: Create `lib/notifications/effectiveness-recheck.ts`** ```typescript 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 } } ``` - [ ] **Step 4: Run pure-function tests** ```bash npx vitest run tests/lib/notifications/effectiveness-recheck.test.ts ``` Expected: 8/8 PASS - [ ] **Step 5: Create `app/api/cron/effectiveness-recheck/route.ts`** ```typescript 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 }) } ``` - [ ] **Step 6: Modify `app/api/capa/[id]/verify/route.ts` — set recheck fields on verify** Find the `update` object (around line 37): ```typescript const update: Record = { status: body.verdict, verified_by: user.id, verified_at: new Date().toISOString(), } ``` Change to: ```typescript 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: verifiedAt.toISOString(), ...(body.verdict === 'verified' ? { effectiveness_recheck_date: recheckDate.toISOString().split('T')[0], effectiveness_recheck_round: 0, } : {}), } ``` - [ ] **Step 7: Build check** ```bash npm run build 2>&1 | tail -20 ``` Expected: zero errors - [ ] **Step 8: Run all tests** ```bash npx vitest run ``` Expected: all existing + new tests pass - [ ] **Step 9: Commit** ```bash git add lib/notifications/effectiveness-recheck.ts \ app/api/cron/effectiveness-recheck/route.ts \ app/api/capa/\[id\]/verify/route.ts \ tests/lib/notifications/effectiveness-recheck.test.ts git commit -m "feat: CAPA effectiveness re-check cron — 30/60/90-day email + WhatsApp notifications" ``` --- ## Task 4: i18n infrastructure + report form translations (EN/MS/ZH) **Files:** - Create: `lib/i18n/locales.ts` - Create: `lib/i18n/context.tsx` - Create: `lib/i18n/server.ts` - Create: `messages/en.json` - Create: `messages/ms.json` - Create: `messages/zh.json` - Create: `components/language-switcher.tsx` - Modify: `app/layout.tsx` - Modify: `app/report/page.tsx` - Modify: `components/incidents/report-form.tsx` - Test: `tests/lib/i18n/translations.test.ts` **Interfaces:** - Produces: `I18nProvider` (client component) — wraps layout, provides translations to tree - Produces: `useTranslations(namespace: K): Messages[K]` — client hook - Produces: `getLocale(): Promise` — server helper, reads `locale` cookie - Produces: `loadMessages(locale: Locale): Promise` — server helper **Design decision:** No external i18n library. Cookie-based locale (no URL restructuring). `app/layout.tsx` becomes async server component to load messages. Locale switches trigger `router.refresh()` which re-renders server components with the new cookie. - [ ] **Step 1: Write the failing translations test** ```typescript // tests/lib/i18n/translations.test.ts import { describe, it, expect } from 'vitest' import en from '@/messages/en.json' import ms from '@/messages/ms.json' import zh from '@/messages/zh.json' const NAMESPACES = ['ReportForm', 'IncidentType', 'MedicalStatus'] as const NAMESPACES.forEach(ns => { describe(`${ns} namespace`, () => { const enKeys = Object.keys(en[ns]) it(`ms.${ns} has all keys present in en.${ns}`, () => { const msKeys = Object.keys(ms[ns]) enKeys.forEach(key => expect(msKeys, `missing key: ${key}`).toContain(key)) }) it(`zh.${ns} has all keys present in en.${ns}`, () => { const zhKeys = Object.keys(zh[ns]) enKeys.forEach(key => expect(zhKeys, `missing key: ${key}`).toContain(key)) }) it(`en.${ns} values are non-empty strings`, () => { enKeys.forEach(key => { const val = (en[ns] as Record)[key] expect(typeof val).toBe('string') expect(val.length).toBeGreaterThan(0) }) }) }) }) ``` - [ ] **Step 2: Run test to confirm failure** ```bash npx vitest run tests/lib/i18n/translations.test.ts ``` Expected: FAIL — modules not found - [ ] **Step 3: Create `lib/i18n/locales.ts`** ```typescript export const SUPPORTED_LOCALES = ['en', 'ms', 'zh'] as const export type Locale = typeof SUPPORTED_LOCALES[number] ``` - [ ] **Step 4: Create `messages/en.json`** ```json { "ReportForm": { "title": "Report an Incident", "incidentTypeLabel": "Incident type", "incidentTypePlaceholder": "Select type…", "descriptionLabel": "What happened?", "descriptionPlaceholder": "Describe what happened, where, and any immediate actions taken…", "injuryInvolved": "Person was injured", "treatmentLevel": "Treatment level", "treatmentPlaceholder": "Select treatment…", "assetInvolved": "Equipment / asset was damaged", "filesLabel": "Photos / Videos / Documents", "submitButton": "Submit Incident Report", "submitting": "Submitting…", "submitAnyway": "Submit anyway", "qualityScoreLabel": "Report quality — {score}/10", "errorGeneric": "Something went wrong. Please try again.", "savedOffline": "Report saved. It will be submitted automatically when you’re back online.", "offlineBanner": "You’re offline. Your report will be saved and submitted when you reconnect." }, "IncidentType": { "injury": "Injury / Medical", "near_miss": "Near Miss", "hazard": "Hazard / Unsafe Condition", "asset_damage": "Asset / Equipment Damage", "environmental": "Environmental Incident", "security": "Security Incident", "fire": "Fire / Emergency" }, "MedicalStatus": { "none": "No treatment needed", "first_aid": "First aid only", "medical_treatment": "Medical treatment (non-LTI)", "lti": "Lost Time Injury (LTI)" } } ``` - [ ] **Step 5: Create `messages/ms.json`** ```json { "ReportForm": { "title": "Laporkan Insiden", "incidentTypeLabel": "Jenis insiden", "incidentTypePlaceholder": "Pilih jenis…", "descriptionLabel": "Apa yang berlaku?", "descriptionPlaceholder": "Terangkan apa yang berlaku, di mana, dan tindakan segera yang diambil…", "injuryInvolved": "Seseorang telah cedera", "treatmentLevel": "Tahap rawatan", "treatmentPlaceholder": "Pilih rawatan…", "assetInvolved": "Peralatan / aset rosak", "filesLabel": "Foto / Video / Dokumen", "submitButton": "Hantar Laporan Insiden", "submitting": "Menghantar…", "submitAnyway": "Hantar juga", "qualityScoreLabel": "Kualiti laporan — {score}/10", "errorGeneric": "Berlaku ralat. Sila cuba lagi.", "savedOffline": "Laporan disimpan. Ia akan dihantar secara automatik apabila anda dalam talian semula.", "offlineBanner": "Anda tiada sambungan. Laporan anda akan disimpan dan dihantar apabila disambungkan semula." }, "IncidentType": { "injury": "Kecederaan / Perubatan", "near_miss": "Hampir Berlaku", "hazard": "Bahaya / Keadaan Tidak Selamat", "asset_damage": "Kerosakan Aset / Peralatan", "environmental": "Insiden Alam Sekitar", "security": "Insiden Keselamatan", "fire": "Kebakaran / Kecemasan" }, "MedicalStatus": { "none": "Tiada rawatan diperlukan", "first_aid": "Pertolongan cemas sahaja", "medical_treatment": "Rawatan perubatan (bukan LTI)", "lti": "Kecederaan Masa Hilang (LTI)" } } ``` - [ ] **Step 6: Create `messages/zh.json`** ```json { "ReportForm": { "title": "事故报告", "incidentTypeLabel": "事故类型", "incidentTypePlaceholder": "选择类型…", "descriptionLabel": "发生了什么?", "descriptionPlaceholder": "描述发生了什么、在哪里,以及采取的即时行动…", "injuryInvolved": "有人受伤", "treatmentLevel": "治疗级别", "treatmentPlaceholder": "选择治疗方式…", "assetInvolved": "设备/资产受损", "filesLabel": "照片/视频/文件", "submitButton": "提交事故报告", "submitting": "提交中…", "submitAnyway": "仍然提交", "qualityScoreLabel": "报告质量 — {score}/10", "errorGeneric": "出现错误,请重试。", "savedOffline": "报告已保存。当您重新联网时将自动提交。", "offlineBanner": "您处于离线状态。您的报告将在重新联网时自动提交。" }, "IncidentType": { "injury": "受伤/医疗", "near_miss": "未遂事故", "hazard": "危险/不安全状况", "asset_damage": "资产/设备损坏", "environmental": "环境事故", "security": "安全事故", "fire": "火灾/紧急情况" }, "MedicalStatus": { "none": "无需治疗", "first_aid": "仅急救", "medical_treatment": "医疗治疗(非 LTI)", "lti": "工伤失时(LTI)" } } ``` - [ ] **Step 7: Run translations test to confirm it passes** ```bash npx vitest run tests/lib/i18n/translations.test.ts ``` Expected: 9/9 PASS (3 namespaces × 3 assertions each) - [ ] **Step 8: Create `lib/i18n/context.tsx`** ```typescript 'use client' import { createContext, useContext } from 'react' import type en from '../../messages/en.json' export type Messages = typeof en const I18nContext = createContext(null) export function I18nProvider({ messages, children, }: { messages: Messages children: React.ReactNode }) { return {children} } export function useTranslations(namespace: K): Messages[K] { const ctx = useContext(I18nContext) if (!ctx) throw new Error('useTranslations must be used inside I18nProvider') return ctx[namespace] } ``` - [ ] **Step 9: Create `lib/i18n/server.ts`** ```typescript import { cookies } from 'next/headers' import { SUPPORTED_LOCALES, type Locale } from './locales' import type { Messages } from './context' export async function getLocale(): Promise { const cookieStore = await cookies() const lang = cookieStore.get('locale')?.value if (lang && (SUPPORTED_LOCALES as readonly string[]).includes(lang)) { return lang as Locale } return 'en' } export async function loadMessages(locale: Locale): Promise { switch (locale) { case 'ms': return (await import('../../messages/ms.json')).default as Messages case 'zh': return (await import('../../messages/zh.json')).default as Messages default: return (await import('../../messages/en.json')).default as Messages } } ``` - [ ] **Step 10: Create `components/language-switcher.tsx`** ```typescript 'use client' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' const LOCALES: Record = { en: 'English', ms: 'Bahasa Malaysia', zh: '中文', } export function LanguageSwitcher() { const router = useRouter() const [current, setCurrent] = useState('en') useEffect(() => { const match = document.cookie .split('; ') .find(c => c.startsWith('locale=')) ?.split('=')[1] if (match && match in LOCALES) setCurrent(match) }, []) function handleChange(locale: string) { document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax` setCurrent(locale) router.refresh() } return ( ) } ``` - [ ] **Step 11: Modify `app/layout.tsx` — make async, add I18nProvider** Replace the entire file with: ```typescript import type { Metadata } from 'next' import { Geist, Geist_Mono } from 'next/font/google' import './globals.css' import { I18nProvider } from '@/lib/i18n/context' import { getLocale, loadMessages } from '@/lib/i18n/server' const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'], }) const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'], }) export const metadata: Metadata = { title: 'IMS — HSE Incident Management', description: 'Setia Corporation HSE Incident Management System', } export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { const locale = await getLocale() const messages = await loadMessages(locale) return ( {children}