import { Router, Response } from 'express'; import { authenticateUser, AuthenticatedRequest } from '../middleware/auth'; import { getJobById } from '../../db/jobs'; import { createSignedUrl } from '../../db/storage'; import { config } from '../../config'; import { logger } from '../../utils/logger'; const router = Router(); function buildReportDownloadName(inputFileName: string): string { const base = String(inputFileName || 'report') .replace(/\.[^.]+$/, '') .replace(/[\\/:*?"<>|]+/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 140) || 'report'; return `RelVDev_${base}.pdf`; } function buildReceiptDownloadName(inputFileName: string): string { const base = String(inputFileName || 'receipt') .replace(/\.[^.]+$/, '') .replace(/[\\/:*?"<>|]+/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 132) || 'receipt'; return `RelVDev_Receipt_${base}.pdf`; } /** * GET /api/jobs/:jobId/report-url * * Creates a short-lived signed URL for a completed report PDF. The browser * should not sign private storage objects directly because storage RLS can hide * private files as "not found"; this endpoint verifies job ownership first and * then signs with the backend service role. */ router.get( '/api/jobs/:jobId/report-url', authenticateUser, async (req, res: Response): Promise => { const authReq = req as AuthenticatedRequest; const jobId = String(authReq.params.jobId || ''); try { const job = await getJobById(jobId); if (!job) { res.status(404).json({ error: 'Job not found' }); return; } if (job.user_id !== authReq.userId) { res.status(403).json({ error: 'Forbidden' }); return; } if (!job.output_pdf_path) { res.status(404).json({ error: 'Report PDF is not available yet' }); return; } if ( job.output_pdf_expires_at && new Date(job.output_pdf_expires_at).getTime() < Date.now() ) { res.status(410).json({ error: 'Report PDF has expired' }); return; } const signedUrl = await createSignedUrl( config.reportBucket, job.output_pdf_path, 300, buildReportDownloadName(job.input_file_name), ); res.json({ signedUrl, fileName: buildReportDownloadName(job.input_file_name), expiresIn: 300, expiresAt: new Date(Date.now() + 300 * 1000).toISOString(), }); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); logger.error('Failed to create report signed URL', { jobId, userId: authReq.userId, error: message, }); res.status(500).json({ error: 'Failed to create report download URL' }); } }, ); /** * GET /api/jobs/:jobId/receipt-url * * Creates a short-lived signed URL for a legacy Digital Receipt PDF. */ router.get( '/api/jobs/:jobId/receipt-url', authenticateUser, async (req, res: Response): Promise => { const authReq = req as AuthenticatedRequest; const jobId = String(authReq.params.jobId || ''); try { const job = await getJobById(jobId); if (!job) { res.status(404).json({ error: 'Job not found' }); return; } if (job.user_id !== authReq.userId) { res.status(403).json({ error: 'Forbidden' }); return; } if (!job.receipt_pdf_path) { res.status(404).json({ error: 'Digital Receipt is not available yet' }); return; } if ( job.receipt_pdf_expires_at && new Date(job.receipt_pdf_expires_at).getTime() < Date.now() ) { res.status(410).json({ error: 'Digital Receipt has expired' }); return; } const fileName = buildReceiptDownloadName(job.input_file_name); const signedUrl = await createSignedUrl( config.reportBucket, job.receipt_pdf_path, 300, fileName, ); res.json({ signedUrl, fileName, expiresIn: 300, expiresAt: new Date(Date.now() + 300 * 1000).toISOString(), }); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); logger.error('Failed to create Digital Receipt signed URL', { jobId, userId: authReq.userId, error: message, }); res.status(500).json({ error: 'Failed to create Digital Receipt download URL' }); } }, ); export default router;