feat: email notifications via Resend on new incident
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Generated
+85
-37
@@ -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"
|
||||
},
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user