feat: email notifications via Resend on new incident

This commit is contained in:
2026-07-10 13:24:44 +08:00
parent 3f8da5bd91
commit 2239b6e2df
6 changed files with 254 additions and 38 deletions
+4
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { validateIncidentInput } from '@/lib/incidents/validate' import { validateIncidentInput } from '@/lib/incidents/validate'
import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage' import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage'
import { sendNewIncidentEmail } from '@/lib/notifications/email'
export const dynamic = 'force-dynamic' 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 }, 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 }) return NextResponse.json({ id: incident.id, reference_no: incident.reference_no }, { status: 201 })
} }
+52
View File
@@ -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<void> {
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)
}
@@ -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 = `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto">
<h2 style="color:#1e293b">New Incident Report</h2>
<table style="border-collapse:collapse;width:100%">
<tr><td style="padding:6px 0;color:#64748b">Reference</td><td style="padding:6px 0;font-weight:600">${params.reference_no}</td></tr>
<tr><td style="padding:6px 0;color:#64748b">Type</td><td style="padding:6px 0">${typeLabel}</td></tr>
<tr><td style="padding:6px 0;color:#64748b">Site</td><td style="padding:6px 0">${params.site_name}</td></tr>
<tr><td style="padding:6px 0;color:#64748b">Reported by</td><td style="padding:6px 0">${params.reporter_name}</td></tr>
<tr><td style="padding:6px 0;color:#64748b">Time</td><td style="padding:6px 0">${params.reported_at}</td></tr>
</table>
<p style="margin-top:20px">
<a href="${link}" style="background:#2563eb;color:#fff;padding:10px 18px;text-decoration:none;border-radius:6px;display:inline-block">
View Incident
</a>
</p>
</div>
`.trim()
return { subject, html, text }
}
+85 -37
View File
@@ -12,7 +12,8 @@
"@supabase/supabase-js": "^2.110.2", "@supabase/supabase-js": "^2.110.2",
"next": "^15.5.20", "next": "^15.5.20",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7",
"resend": "^6.17.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -2330,6 +2331,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2746,17 +2753,6 @@
"node": ">=18" "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": { "node_modules/@testing-library/jest-dom": {
"version": "6.9.1", "version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -3772,13 +3768,13 @@
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.2", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "dependencies": {
"node": ">= 0.4" "dequal": "^2.0.3"
} }
}, },
"node_modules/array-buffer-byte-length": { "node_modules/array-buffer-byte-length": {
@@ -4476,7 +4472,6 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -5105,6 +5100,16 @@
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" "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": { "node_modules/eslint-plugin-react": {
"version": "7.37.5", "version": "7.37.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
@@ -5323,6 +5328,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fastq": {
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -7393,6 +7404,12 @@
"node": ">= 0.4" "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": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
@@ -7462,14 +7479,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7482,6 +7491,13 @@
"react-is": "^16.13.1" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7553,11 +7569,12 @@
} }
}, },
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
@@ -7644,6 +7661,27 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/resolve": {
"version": "2.0.0-next.7", "version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -8084,6 +8122,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/std-env": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
@@ -8443,22 +8491,22 @@
} }
}, },
"node_modules/tldts": { "node_modules/tldts": {
"version": "7.4.7", "version": "7.4.8",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz",
"integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==", "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tldts-core": "^7.4.7" "tldts-core": "^7.4.8"
}, },
"bin": { "bin": {
"tldts": "bin/cli.js" "tldts": "bin/cli.js"
} }
}, },
"node_modules/tldts-core": { "node_modules/tldts-core": {
"version": "7.4.7", "version": "7.4.8",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz",
"integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==", "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
+2 -1
View File
@@ -16,7 +16,8 @@
"@supabase/supabase-js": "^2.110.2", "@supabase/supabase-js": "^2.110.2",
"next": "^15.5.20", "next": "^15.5.20",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7",
"resend": "^6.17.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+66
View File
@@ -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()
})
})