feat(pdf): add incident report PDF API route
GET /api/incidents/[id]/report-pdf — hse/admin only, closed incidents only. Joins incidents + sites + zones + users + investigations + capaActions + addenda, then delegates to buildIncidentReportPdf() and returns application/pdf download. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEYxFQiCyxJvnBCoZeYzB9
This commit is contained in:
@@ -0,0 +1,159 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { aliasedTable, eq } from 'drizzle-orm'
|
||||||
|
import { getSession } from '@/lib/auth/get-session'
|
||||||
|
import { withUser } from '@/lib/db/with-user'
|
||||||
|
import { incidents, sites, zones, users, investigations, capaActions, incidentAddenda } from '@/lib/db/schema'
|
||||||
|
import { buildIncidentReportPdf } from '@/lib/pdf/incident-report'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
if (!['hse', 'admin'].includes(session.role))
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
const reporterAlias = aliasedTable(users, 'reporter')
|
||||||
|
const triagedByAlias = aliasedTable(users, 'triaged_by_user')
|
||||||
|
const ownerAlias = aliasedTable(users, 'capa_owner')
|
||||||
|
const addendaAuthorAlias = aliasedTable(users, 'addenda_author')
|
||||||
|
|
||||||
|
const [incidentRows, investigationRows, capaRows, addendaRows] = await withUser(
|
||||||
|
session.sub,
|
||||||
|
async tx => Promise.all([
|
||||||
|
tx.select({
|
||||||
|
id: incidents.id,
|
||||||
|
referenceNo: incidents.referenceNo,
|
||||||
|
incidentType: incidents.incidentType,
|
||||||
|
reportedAt: incidents.reportedAt,
|
||||||
|
status: incidents.status,
|
||||||
|
siteName: sites.name,
|
||||||
|
zoneName: zones.name,
|
||||||
|
reporterName: reporterAlias.name,
|
||||||
|
severity: incidents.severity,
|
||||||
|
medicalStatus: incidents.medicalStatus,
|
||||||
|
description: incidents.description,
|
||||||
|
injuryInvolved: incidents.injuryInvolved,
|
||||||
|
assetInvolved: incidents.assetInvolved,
|
||||||
|
isFatality: incidents.isFatality,
|
||||||
|
isSeriousBodilyInjury: incidents.isSeriousBodilyInjury,
|
||||||
|
isDangerousOccurrence: incidents.isDangerousOccurrence,
|
||||||
|
isOccupationalDisease: incidents.isOccupationalDisease,
|
||||||
|
lostDays: incidents.lostDays,
|
||||||
|
triagedByName: triagedByAlias.name,
|
||||||
|
triagedAt: incidents.triagedAt,
|
||||||
|
triageNotes: incidents.triageNotes,
|
||||||
|
closedAt: incidents.closedAt,
|
||||||
|
})
|
||||||
|
.from(incidents)
|
||||||
|
.leftJoin(sites, eq(incidents.siteId, sites.id))
|
||||||
|
.leftJoin(zones, eq(incidents.zoneId, zones.id))
|
||||||
|
.leftJoin(reporterAlias, eq(incidents.reportedBy, reporterAlias.id))
|
||||||
|
.leftJoin(triagedByAlias, eq(incidents.triagedBy, triagedByAlias.id))
|
||||||
|
.where(eq(incidents.id, id))
|
||||||
|
.limit(1),
|
||||||
|
|
||||||
|
tx.select({
|
||||||
|
method: investigations.method,
|
||||||
|
findingsText: investigations.findingsText,
|
||||||
|
rootCauseSummary: investigations.rootCauseSummary,
|
||||||
|
alcoholTestResult: investigations.alcoholTestResult,
|
||||||
|
urineTestResult: investigations.urineTestResult,
|
||||||
|
})
|
||||||
|
.from(investigations)
|
||||||
|
.where(eq(investigations.incidentId, id))
|
||||||
|
.limit(1),
|
||||||
|
|
||||||
|
tx.select({
|
||||||
|
description: capaActions.description,
|
||||||
|
ownerName: ownerAlias.name,
|
||||||
|
department: capaActions.department,
|
||||||
|
dueDate: capaActions.dueDate,
|
||||||
|
priority: capaActions.priority,
|
||||||
|
status: capaActions.status,
|
||||||
|
completedAt: capaActions.completedAt,
|
||||||
|
ownerNotes: capaActions.ownerNotes,
|
||||||
|
})
|
||||||
|
.from(capaActions)
|
||||||
|
.leftJoin(ownerAlias, eq(capaActions.ownerUserId, ownerAlias.id))
|
||||||
|
.where(eq(capaActions.incidentId, id)),
|
||||||
|
|
||||||
|
tx.select({
|
||||||
|
body: incidentAddenda.body,
|
||||||
|
createdAt: incidentAddenda.createdAt,
|
||||||
|
authorName: addendaAuthorAlias.name,
|
||||||
|
})
|
||||||
|
.from(incidentAddenda)
|
||||||
|
.leftJoin(addendaAuthorAlias, eq(incidentAddenda.author, addendaAuthorAlias.id))
|
||||||
|
.where(eq(incidentAddenda.incidentId, id)),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
const incident = incidentRows[0]
|
||||||
|
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
if (incident.status !== 'closed')
|
||||||
|
return NextResponse.json({ error: 'Incident is not closed' }, { status: 400 })
|
||||||
|
|
||||||
|
const inv = investigationRows[0] ?? null
|
||||||
|
|
||||||
|
const pdfBytes = await buildIncidentReportPdf({
|
||||||
|
referenceNo: incident.referenceNo,
|
||||||
|
incidentType: incident.incidentType,
|
||||||
|
reportedAt: incident.reportedAt instanceof Date ? incident.reportedAt.toISOString() : (incident.reportedAt as string),
|
||||||
|
siteName: incident.siteName ?? 'Unknown',
|
||||||
|
zoneName: incident.zoneName ?? null,
|
||||||
|
reporterName: incident.reporterName ?? 'Unknown',
|
||||||
|
severity: incident.severity ?? null,
|
||||||
|
medicalStatus: incident.medicalStatus ?? null,
|
||||||
|
description: incident.description,
|
||||||
|
injuryInvolved: incident.injuryInvolved,
|
||||||
|
assetInvolved: incident.assetInvolved,
|
||||||
|
isFatality: incident.isFatality,
|
||||||
|
isSeriousBodilyInjury: incident.isSeriousBodilyInjury,
|
||||||
|
isDangerousOccurrence: incident.isDangerousOccurrence,
|
||||||
|
isOccupationalDisease: incident.isOccupationalDisease,
|
||||||
|
lostDays: incident.lostDays ?? null,
|
||||||
|
triagedBy: incident.triagedByName ?? null,
|
||||||
|
triagedAt: incident.triagedAt instanceof Date ? incident.triagedAt.toISOString() : (incident.triagedAt as string | null),
|
||||||
|
triageNotes: incident.triageNotes ?? null,
|
||||||
|
investigation: inv ? {
|
||||||
|
method: inv.method,
|
||||||
|
findingsText: inv.findingsText ?? null,
|
||||||
|
rootCauseSummary: inv.rootCauseSummary ?? null,
|
||||||
|
alcoholTestResult: inv.alcoholTestResult ?? null,
|
||||||
|
urineTestResult: inv.urineTestResult ?? null,
|
||||||
|
} : null,
|
||||||
|
capas: capaRows.map(c => ({
|
||||||
|
description: c.description,
|
||||||
|
ownerName: c.ownerName ?? 'Unknown',
|
||||||
|
department: c.department,
|
||||||
|
dueDate: c.dueDate,
|
||||||
|
priority: c.priority,
|
||||||
|
status: c.status,
|
||||||
|
completedAt: c.completedAt instanceof Date ? c.completedAt.toISOString() : (c.completedAt as string | null),
|
||||||
|
ownerNotes: c.ownerNotes ?? null,
|
||||||
|
})),
|
||||||
|
addenda: addendaRows.map(a => ({
|
||||||
|
authorName: a.authorName ?? 'Unknown',
|
||||||
|
createdAt: a.createdAt instanceof Date ? a.createdAt.toISOString() : (a.createdAt as string),
|
||||||
|
body: a.body,
|
||||||
|
})),
|
||||||
|
closedAt: incident.closedAt instanceof Date ? incident.closedAt.toISOString() : (incident.closedAt as string | null),
|
||||||
|
})
|
||||||
|
|
||||||
|
const filename = `incident-report-${incident.referenceNo ?? id}.pdf`
|
||||||
|
|
||||||
|
return new NextResponse(Buffer.from(pdfBytes), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
'Content-Length': String(pdfBytes.byteLength),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// Mock auth
|
||||||
|
vi.mock('@/lib/auth/get-session', () => ({
|
||||||
|
getSession: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock db
|
||||||
|
vi.mock('@/lib/db/with-user', () => ({
|
||||||
|
withUser: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock pdf builder
|
||||||
|
vi.mock('@/lib/pdf/incident-report', () => ({
|
||||||
|
buildIncidentReportPdf: vi.fn().mockResolvedValue(new Uint8Array([37, 80, 68, 70])), // %PDF
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { GET } from '@/app/api/incidents/[id]/report-pdf/route'
|
||||||
|
import { getSession } from '@/lib/auth/get-session'
|
||||||
|
import { withUser } from '@/lib/db/with-user'
|
||||||
|
import { buildIncidentReportPdf } from '@/lib/pdf/incident-report'
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
const mockSession = { sub: 'user-1', role: 'hse' as const }
|
||||||
|
|
||||||
|
const MOCK_INCIDENT = {
|
||||||
|
id: 'inc-1',
|
||||||
|
referenceNo: 'SC1-202607-0001',
|
||||||
|
incidentType: 'injury',
|
||||||
|
reportedAt: new Date('2026-07-01T08:00:00Z'),
|
||||||
|
status: 'closed',
|
||||||
|
siteName: 'Warehouse KL',
|
||||||
|
zoneName: 'Bay A',
|
||||||
|
reporterName: 'Ahmad',
|
||||||
|
severity: 3,
|
||||||
|
medicalStatus: 'first_aid',
|
||||||
|
description: 'Slip and fall',
|
||||||
|
injuryInvolved: true,
|
||||||
|
assetInvolved: false,
|
||||||
|
isFatality: false,
|
||||||
|
isSeriousBodilyInjury: false,
|
||||||
|
isDangerousOccurrence: false,
|
||||||
|
isOccupationalDisease: false,
|
||||||
|
lostDays: null,
|
||||||
|
triagedBy: 'Supervisor',
|
||||||
|
triagedAt: new Date('2026-07-01T10:00:00Z'),
|
||||||
|
triageNotes: 'Reviewed',
|
||||||
|
closedAt: new Date('2026-07-20T15:00:00Z'),
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(id: string) {
|
||||||
|
return new NextRequest(`http://localhost/api/incidents/${id}/report-pdf`)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /api/incidents/[id]/report-pdf', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
vi.mocked(getSession).mockResolvedValue(null)
|
||||||
|
const res = await GET(makeRequest('inc-1'), { params: Promise.resolve({ id: 'inc-1' }) })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 403 for non-hse/admin roles', async () => {
|
||||||
|
vi.mocked(getSession).mockResolvedValue({ ...mockSession, role: 'reporter' as never })
|
||||||
|
const res = await GET(makeRequest('inc-1'), { params: Promise.resolve({ id: 'inc-1' }) })
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when incident not found', async () => {
|
||||||
|
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||||
|
vi.mocked(withUser).mockResolvedValue([[], [], [], []])
|
||||||
|
const res = await GET(makeRequest('inc-1'), { params: Promise.resolve({ id: 'inc-1' }) })
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when incident is not closed', async () => {
|
||||||
|
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||||
|
vi.mocked(withUser).mockResolvedValue([[{ ...MOCK_INCIDENT, status: 'investigating' }], [], [], []])
|
||||||
|
const res = await GET(makeRequest('inc-1'), { params: Promise.resolve({ id: 'inc-1' }) })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns PDF with correct headers for a closed incident', async () => {
|
||||||
|
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||||
|
vi.mocked(withUser).mockResolvedValue([[MOCK_INCIDENT], [], [], []])
|
||||||
|
const res = await GET(makeRequest('inc-1'), { params: Promise.resolve({ id: 'inc-1' }) })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.headers.get('Content-Type')).toBe('application/pdf')
|
||||||
|
expect(res.headers.get('Content-Disposition')).toContain('SC1-202607-0001')
|
||||||
|
expect(buildIncidentReportPdf).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user