import { Router } from 'express'; import { deleteReport, deleteReportImage, getReport, getReportImages, listReports, submitReport } from '../services/reportService.js'; import { logAuditEvent } from '../services/auditService.js'; import { asyncHandler } from '../utils/asyncHandler.js'; import { validateParam } from '../middleware/validateParams.js'; const router = Router(); /** * @openapi * /api/v1/reports: * get: * summary: List reports * tags: * - Reports * security: * - bearerAuth: [] * - cookieAuth: [] * parameters: * - name: status * in: query * required: false * schema: * type: string * - name: templateCode * in: query * required: false * schema: * type: string * - name: limit * in: query * required: false * schema: * type: integer * default: 100 * maximum: 500 * - name: offset * in: query * required: false * schema: * type: integer * default: 0 * responses: * 200: * description: List of reports * post: * summary: Submit report * tags: * - Reports * security: * - bearerAuth: [] * - cookieAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - id * - reportNumber * - templateCode * - templateVersion * - answers * properties: * id: * type: string * reportNumber: * type: string * templateCode: * type: string * templateVersion: * type: integer * status: * type: string * answers: * type: object * responses: * 201: * description: Report submitted * 400: * description: Invalid input * /api/v1/reports/{reportId}: * get: * summary: Get report details * tags: * - Reports * security: * - bearerAuth: [] * - cookieAuth: [] * parameters: * - name: reportId * in: path * required: true * schema: * type: string * pattern: '^[a-zA-Z0-9_-]{1,100}$' * responses: * 200: * description: Report details * 404: * description: Report not found * delete: * summary: Delete report * tags: * - Reports * security: * - bearerAuth: [] * - cookieAuth: [] * parameters: * - name: reportId * in: path * required: true * schema: * type: string * pattern: '^[a-zA-Z0-9_-]{1,100}$' * responses: * 200: * description: Report deleted * /api/v1/reports/{reportId}/images: * get: * summary: Get report images * tags: * - Reports * security: * - bearerAuth: [] * - cookieAuth: [] * parameters: * - name: reportId * in: path * required: true * schema: * type: string * pattern: '^[a-zA-Z0-9_-]{1,100}$' * responses: * 200: * description: Report images * /api/v1/reports/{reportId}/images/{recordId}/{fileName}: * delete: * summary: Delete report image * tags: * - Reports * security: * - bearerAuth: [] * - cookieAuth: [] * parameters: * - name: reportId * in: path * required: true * schema: * type: string * pattern: '^[a-zA-Z0-9_-]{1,100}$' * - name: recordId * in: path * required: true * schema: * type: string * - name: fileName * in: path * required: true * schema: * type: string * pattern: '^[a-zA-Z0-9_.-]{1,500}$' * responses: * 200: * description: Image deleted */ /* * Report submission accepts the full local report payload (answers, template * binding, status) and stores it server-side. This bridges the offline-first * client workflow with centralized storage for review and archival. */ router.get( '/', asyncHandler(async (req, res) => { const reports = await listReports({ status: req.query.status || undefined, templateCode: req.query.templateCode || undefined, limit: Math.min(Number(req.query.limit) || 100, 500), offset: Math.max(Number(req.query.offset) || 0, 0) }); res.json({ items: reports }); }) ); router.get( '/:reportId', validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }), asyncHandler(async (req, res) => { const report = await getReport(req.params.reportId); if (!report) { return res.status(404).json({ message: 'Report not found.' }); } return res.json(report); }) ); router.post( '/', asyncHandler(async (req, res) => { const body = req.body; if (!body?.id || !body?.reportNumber || !body?.templateCode || !body?.templateVersion || !body?.answers) { return res.status(400).json({ message: 'id, reportNumber, templateCode, templateVersion, and answers are required.' }); } const report = await submitReport({ id: String(body.id).trim(), reportNumber: String(body.reportNumber).trim(), templateCode: String(body.templateCode).trim(), templateVersion: Number(body.templateVersion), status: body.status || 'exported', answers: body.answers }); await logAuditEvent({ entityType: 'report', entityCode: report.id, action: 'submit', newValue: { reportNumber: report.reportNumber, templateCode: report.templateCode } }); return res.status(201).json(report); }) ); /* Get all images for a report grouped by record */ router.get( '/:reportId/images', validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }), asyncHandler(async (req, res) => { const images = await getReportImages(req.params.reportId); return res.json(images); }) ); /* Delete a report and all associated images */ router.delete( '/:reportId', validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }), asyncHandler(async (req, res) => { await deleteReport(req.params.reportId); await logAuditEvent({ entityType: 'report', entityCode: req.params.reportId, action: 'delete', newValue: null }); return res.json({ message: 'Report and images deleted.' }); }) ); /* Safe pattern for image file names: alphanumeric, underscore, hyphen, dot */ const SAFE_FILENAME_PATTERN = /^[a-zA-Z0-9_.-]{1,500}$/; /* Delete a specific image from a report */ router.delete( '/:reportId/images/:recordId/:fileName', validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }), validateParam('fileName', { pattern: SAFE_FILENAME_PATTERN }), asyncHandler(async (req, res) => { await deleteReportImage(req.params.reportId, req.params.recordId, req.params.fileName); return res.json({ message: 'Image deleted.' }); }) ); export default router;