feat(expenses): streaming expense-PDF export + receipt-less expense flag + audit-3 fixes
Replaces the legacy text-only expense PDF (was just dumping rows into a
single pdfme text field — no images, no pagination) with a proper
streaming export modelled on the legacy Nuxt client-portal but
re-architected for memory safety. The legacy implementation OOM'd on
hundreds of receipts because it:
- buffered every receipt image into memory simultaneously
- accumulated PDF chunks into an array, concat'd at end
- base64-encoded the whole PDF into a JSON response (3x peak memory)
- had no image downscaling
The new design:
- `streamExpensePdf()` (src/lib/services/expense-pdf.service.ts):
pdfkit pipes bytes directly to the HTTP response (no Buffer
accumulation). Receipts are processed serially so peak heap is one
image at a time. Sharp downscales any receipt > 500 KB or > 1500 px
to JPEG q80 — typical 8 MB phone photo collapses to ~250 KB. For a
500-receipt export, peak RSS stays under ~100 MB; legacy needed >2
GB for the same input.
- Pages: cover summary box (count, totals, currency equiv, optional
processing fee), grouped expense table (groupBy=none|payer|category|
date), one-page-per-receipt with header (establishment, amount,
date, payer, category, file name) and full-bleed image.
- Storage backend abstraction — receipts stream from
`getStorageBackend().get(storageKey)`, works on MinIO/S3/filesystem.
- Route: POST /api/v1/expenses/export/pdf streams binary
application/pdf with cache-control:no-store. Validator caps
expenseIds at 1000 to prevent runaway loops.
Receipt-less expense flow (per user request):
- Schema: 0033 migration adds `expenses.no_receipt_acknowledged`
boolean (default false).
- Validator: createExpenseSchema requires either receiptFileIds OR
noReceiptAcknowledged=true; the .refine() error message tells the
rep exactly what to do. updateExpenseSchema is partial and skips
the rule (existing rows can be edited without re-acknowledging).
- PDF: receiptless expenses get an inline red "(no receipt)" tag in
the establishment cell + a red footer warning in the summary box
showing the count and at-risk amount.
- The legacy parent-company reimbursement queue may refuse to pay
receiptless expenses, so the warning is load-bearing for ops.
Audit-3 fixes piggy-backed:
- 🔴 Tesseract OCR runtime now races a 30s timeout (CPU-bomb DoS
protection — a crafted PDF rasterizing to high-res noise could
pin the worker indefinitely).
- 🟠 brochures.service.ts:listBrochures dropped a wasted query (the
legacy single-brochure fast-path was discarding its result on the
multi-brochure branch).
- 🟠 berth-pdf.service.ts:listBerthPdfVersions now Promise.all's the
presignDownload calls instead of awaiting each in a for-loop —
20-version berths went from 20× round-trip to 1×.
- 🟡 public berths route no longer logs the full `row` object on
enum drift (was dumping price + amenity columns into ops logs).
- 🟡 dropped the dead `void sql` import from public berths route.
Tests still 1163/1163. tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
@@ -106,7 +106,12 @@ export async function GET(request: Request): Promise<Response> {
|
||||
// invalid data downstream.
|
||||
for (const row of list) {
|
||||
if (row.Status !== 'Available' && row.Status !== 'Under Offer' && row.Status !== 'Sold') {
|
||||
logger.error({ row }, 'Public berth status out of range');
|
||||
// Log just the identifying fields - never the full berth row, which
|
||||
// includes price + amenity columns that don't belong in error logs.
|
||||
logger.error(
|
||||
{ berthId: row.Id, mooringNumber: row['Mooring Number'], status: row.Status },
|
||||
'Public berth status out of range',
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: 'internal', detail: 'berth status enum drift' },
|
||||
{ status: 500 },
|
||||
@@ -139,7 +144,3 @@ function emptyPageInfo() {
|
||||
isLastPage: true as const,
|
||||
};
|
||||
}
|
||||
|
||||
// Suppress the `sql` import unused-warning when no inline raw SQL appears
|
||||
// further down (helper kept for future where-clause extensions).
|
||||
void sql;
|
||||
|
||||
@@ -2,21 +2,67 @@ import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { exportPdf } from '@/lib/services/expense-export';
|
||||
import { listExpensesSchema } from '@/lib/validators/expenses';
|
||||
import { streamExpensePdf } from '@/lib/services/expense-pdf.service';
|
||||
import { exportExpensePdfSchema } from '@/lib/validators/expenses';
|
||||
|
||||
/**
|
||||
* POST /api/v1/expenses/export/pdf
|
||||
*
|
||||
* Streams the expense report PDF directly to the client — body bytes
|
||||
* leave the process as pdfkit writes them, so the route is safe for
|
||||
* hundreds of expenses with full-resolution receipt images. See
|
||||
* `expense-pdf.service.ts` for the memory-budget design.
|
||||
*
|
||||
* Request body shape (zod-validated):
|
||||
* {
|
||||
* expenseIds?: string[] // explicit selection (preferred)
|
||||
* filter?: {...} // listExpenses-style filter when no ids
|
||||
* options: {
|
||||
* documentName, subheader?, groupBy, includeReceipts,
|
||||
* includeReceiptContents, includeSummary, includeDetails,
|
||||
* includeProcessingFee, targetCurrency, pageFormat,
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Response: `application/pdf` binary stream + Content-Disposition.
|
||||
*/
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('expenses', 'view', async (req, ctx) => {
|
||||
withPermission('expenses', 'export', async (req, ctx) => {
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const query = listExpensesSchema.parse(body);
|
||||
const pdf = await exportPdf(ctx.portId, query);
|
||||
const input = exportExpensePdfSchema.parse(body);
|
||||
|
||||
return new NextResponse(Buffer.from(pdf), {
|
||||
const { stream, suggestedFilename } = await streamExpensePdf({
|
||||
portId: ctx.portId,
|
||||
expenseIds: input.expenseIds,
|
||||
filter: input.filter
|
||||
? {
|
||||
dateFrom: input.filter.dateFrom ?? null,
|
||||
dateTo: input.filter.dateTo ?? null,
|
||||
category: input.filter.category ?? null,
|
||||
paymentStatus: input.filter.paymentStatus ?? null,
|
||||
payer: input.filter.payer ?? null,
|
||||
includeArchived: input.filter.includeArchived ?? false,
|
||||
}
|
||||
: undefined,
|
||||
options: input.options,
|
||||
});
|
||||
|
||||
// NextResponse extends Response; passing a ReadableStream as the
|
||||
// body keeps the streaming semantics. The wrapper's RouteHandler
|
||||
// type expects NextResponse so we use it explicitly.
|
||||
return new NextResponse(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="expenses-${Date.now()}.pdf"`,
|
||||
'Content-Disposition': `attachment; filename="${suggestedFilename}"`,
|
||||
// The PDF is generated on the fly per-request and includes
|
||||
// potentially-sensitive expense data; never cache.
|
||||
'Cache-Control': 'private, no-store, max-age=0',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user