From 2239b6e2df479f2be869560a743e39d1d3df0082 Mon Sep 17 00:00:00 2001 From: weeihan Date: Fri, 10 Jul 2026 13:24:44 +0800 Subject: [PATCH] feat: email notifications via Resend on new incident --- app/api/incidents/route.ts | 4 + lib/notifications/email.ts | 52 +++++++++ lib/notifications/templates/new-incident.ts | 45 ++++++++ package-lock.json | 122 ++++++++++++++------ package.json | 3 +- tests/lib/notifications/email.test.ts | 66 +++++++++++ 6 files changed, 254 insertions(+), 38 deletions(-) create mode 100644 lib/notifications/email.ts create mode 100644 lib/notifications/templates/new-incident.ts create mode 100644 tests/lib/notifications/email.test.ts diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts index ea476ac..1b827d8 100644 --- a/app/api/incidents/route.ts +++ b/app/api/incidents/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { validateIncidentInput } from '@/lib/incidents/validate' import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage' +import { sendNewIncidentEmail } from '@/lib/notifications/email' export const dynamic = 'force-dynamic' @@ -105,5 +106,8 @@ export async function POST(request: Request) { p_new_value: { incident_type: input.incident_type, reported_by: user.id }, }) + sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type) + .catch(err => console.error('email notification failed:', err)) + return NextResponse.json({ id: incident.id, reference_no: incident.reference_no }, { status: 201 }) } diff --git a/lib/notifications/email.ts b/lib/notifications/email.ts new file mode 100644 index 0000000..d02a591 --- /dev/null +++ b/lib/notifications/email.ts @@ -0,0 +1,52 @@ +import { Resend } from 'resend' +import { createClient } from '@/lib/supabase/server' +import { newIncidentTemplate } from '@/lib/notifications/templates/new-incident' + +export async function sendNewIncidentEmail( + incidentId: string, + siteId: string, + reference_no: string, + incidentType: string, +): Promise { + const supabase = await createClient() + + const { data: recipients } = await supabase + .from('users') + .select('email, name, role') + .in('role', ['supervisor', 'hse']) + .eq('site_id', siteId) + + if (!recipients || recipients.length === 0) return + + const to = recipients.map((r: { email: string }) => r.email).filter(Boolean) + if (to.length === 0) return + + const { data: incident } = await supabase + .from('incidents') + .select('reported_at, sites (name), reporter:users!reported_by (name)') + .eq('id', incidentId) + .single() + + const siteName = (incident?.sites as any)?.name ?? 'Unknown Site' + const reporterName = (incident?.reporter as any)?.name ?? 'Unknown' + const reportedAt = incident?.reported_at + ? new Date(incident.reported_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' }) + : '-' + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000' + + const { subject, html, text } = newIncidentTemplate({ + reference_no, + incident_type: incidentType, + site_name: siteName, + reporter_name: reporterName, + reported_at: reportedAt, + site_url: siteUrl, + incident_id: incidentId, + }) + + const resend = new Resend(process.env.RESEND_API_KEY) + const from = process.env.RESEND_FROM_EMAIL ?? 'onboarding@resend.dev' + + const { error } = await resend.emails.send({ from, to, subject, html, text }) + if (error) console.error('Resend error:', error) +} diff --git a/lib/notifications/templates/new-incident.ts b/lib/notifications/templates/new-incident.ts new file mode 100644 index 0000000..b45f9fe --- /dev/null +++ b/lib/notifications/templates/new-incident.ts @@ -0,0 +1,45 @@ +export function newIncidentTemplate(params: { + reference_no: string + incident_type: string + site_name: string + reporter_name: string + reported_at: string + site_url: string + incident_id: string +}): { subject: string; html: string; text: string } { + const typeLabel = params.incident_type.replace(/_/g, ' ') + const subject = `[IMS] New incident ${params.reference_no} — ${typeLabel} at ${params.site_name}` + const link = `${params.site_url}/hse/incidents/${params.incident_id}` + + const text = ` +New incident report received. + +Reference: ${params.reference_no} +Type: ${typeLabel} +Site: ${params.site_name} +Reported by: ${params.reporter_name} +Time: ${params.reported_at} + +View incident: ${link} + `.trim() + + const html = ` +
+

New Incident Report

+ + + + + + +
Reference${params.reference_no}
Type${typeLabel}
Site${params.site_name}
Reported by${params.reporter_name}
Time${params.reported_at}
+

+ + View Incident + +

+
+ `.trim() + + return { subject, html, text } +} diff --git a/package-lock.json b/package-lock.json index 29049aa..ee5a23b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@supabase/supabase-js": "^2.110.2", "next": "^15.5.20", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "resend": "^6.17.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -2330,6 +2331,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2746,17 +2753,6 @@ "node": ">=18" } }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -3772,13 +3768,13 @@ "license": "Python-2.0" }, "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" + "dependencies": { + "dequal": "^2.0.3" } }, "node_modules/array-buffer-byte-length": { @@ -4476,7 +4472,6 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -5105,6 +5100,16 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -5323,6 +5328,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7393,6 +7404,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -7462,14 +7479,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7482,6 +7491,13 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7553,11 +7569,12 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redent": { "version": "3.0.0", @@ -7644,6 +7661,27 @@ "dev": true, "license": "ISC" }, + "node_modules/resend": { + "version": "6.17.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.17.2.tgz", + "integrity": "sha512-hbaXEORFIFfT2Bh03NsA/akTTTKkD1hiuJ88ke64c5dWVV6DyoLxzFve3OiZqVOQ+JKLJ5uVkPR6hlUslpJFoA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -8084,6 +8122,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -8443,22 +8491,22 @@ } }, "node_modules/tldts": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz", - "integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==", + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.7" + "tldts-core": "^7.4.8" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz", - "integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==", + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index daa9caa..ed861f6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "@supabase/supabase-js": "^2.110.2", "next": "^15.5.20", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "resend": "^6.17.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/tests/lib/notifications/email.test.ts b/tests/lib/notifications/email.test.ts new file mode 100644 index 0000000..8033430 --- /dev/null +++ b/tests/lib/notifications/email.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockSend, mockFrom } = vi.hoisted(() => ({ + mockSend: vi.fn(), + mockFrom: vi.fn(), +})) + +vi.mock('resend', () => { + function MockResend() {} + MockResend.prototype.emails = { send: mockSend } + return { Resend: MockResend } +}) + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn().mockResolvedValue({ from: mockFrom }), +})) + +import { sendNewIncidentEmail } from '@/lib/notifications/email' + +const mockIncidentRow = { + reported_at: '2026-07-10T09:00:00Z', + sites: { name: 'SCW1' }, + reporter: { name: 'John Doe' }, +} + +function setupMocks(recipientData: object[]) { + mockFrom.mockImplementation((table: string) => { + if (table === 'users') { + return { + select: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), + eq: vi.fn().mockResolvedValue({ data: recipientData, error: null }), + } + } + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: mockIncidentRow, error: null }), + } + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockSend.mockResolvedValue({ data: { id: 'email-123' }, error: null }) + setupMocks([ + { email: 'supervisor@setiacorp.com', name: 'Ahmad', role: 'supervisor' }, + { email: 'hse@setiacorp.com', name: 'Priya', role: 'hse' }, + ]) +}) + +describe('sendNewIncidentEmail', () => { + it('sends email to supervisor and hse users', async () => { + await sendNewIncidentEmail('inc-001', 'site-001', 'SCW1-202607-0001', 'near_miss') + expect(mockSend).toHaveBeenCalledTimes(1) + const call = mockSend.mock.calls[0][0] + expect(call.to).toEqual(expect.arrayContaining(['supervisor@setiacorp.com', 'hse@setiacorp.com'])) + expect(call.subject).toContain('SCW1-202607-0001') + }) + + it('does not send if no recipients', async () => { + setupMocks([]) + await sendNewIncidentEmail('inc-001', 'site-001', 'SCW1-202607-0001', 'near_miss') + expect(mockSend).not.toHaveBeenCalled() + }) +})