diff --git a/app/api/incidents/[id]/report-pdf/route.ts b/app/api/incidents/[id]/report-pdf/route.ts new file mode 100644 index 0000000..62aaa87 --- /dev/null +++ b/app/api/incidents/[id]/report-pdf/route.ts @@ -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), + }, + }) +} diff --git a/tests/app/api/incidents/report-pdf.test.ts b/tests/app/api/incidents/report-pdf.test.ts new file mode 100644 index 0000000..0666d0c --- /dev/null +++ b/tests/app/api/incidents/report-pdf.test.ts @@ -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() + }) +})