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:
2026-07-28 17:03:00 +08:00
co-authored by Claude Sonnet 4.6
parent e7d82f3a5a
commit 1b0941ec69
2 changed files with 254 additions and 0 deletions
+159
View File
@@ -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),
},
})
}