Files
pn-new-crm/src/lib/validators/expenses.ts

107 lines
4.4 KiB
TypeScript
Raw Normal View History

import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
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>
2026-05-05 04:38:32 +02:00
/**
* Inner-shape ZodObject kept exported (without .refine wrapping) so
* `updateExpenseSchema` can still call `.partial()`. The `.refine()` rule
* for "receipt or acknowledgement" is applied via `createExpenseSchema`.
*/
export const createExpenseShape = z.object({
establishmentName: z.string().max(200).optional(),
amount: z.coerce.number().positive(),
currency: z.string().length(3).default('USD'),
paymentMethod: z.enum(PAYMENT_METHODS).optional(),
category: z.enum(EXPENSE_CATEGORIES).optional(),
payer: z.string().max(200).optional(),
expenseDate: z.coerce.date(),
description: z.string().max(2000).optional(),
receiptFileIds: z.array(z.string()).optional(),
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>
2026-05-05 04:38:32 +02:00
/**
* Set to `true` when the rep deliberately creates an expense without a
* receipt. The UI shows a non-blocking warning that surfaces both at
* creation time and again in the PDF export. Without this flag, the
* server rejects an expense submitted with no `receiptFileIds` so reps
* can't accidentally lose-receipt by mistake.
*/
noReceiptAcknowledged: z.boolean().optional().default(false),
paymentStatus: z.enum(['unpaid', 'paid', 'partial']).default('unpaid'),
paymentDate: z.string().optional(),
paymentReference: z.string().optional(),
paymentNotes: z.string().optional(),
feat(expenses+interests): trip/event grouping (lightweight) Per the trips/events design discussion: instead of building a full events domain (table + CRUD UI + calendar) for the 6–12 yacht shows a year, ship the cheap version that covers the actual asks. Expenses — `tripLabel` free-text: - New `expenses.trip_label` text column (migration 0039) + index for filter / autocomplete lookup. - Validator: createExpenseShape + listExpensesSchema + exportExpensePdfSchema.filter all accept tripLabel. - Service: createExpense + updateExpense persist; listExpenses filters; new `listTripLabels(portId, search?)` returns distinct values ordered by most-recent expenseDate so the autocomplete surfaces recently-used labels first. - New `GET /api/v1/expenses/trip-labels` endpoint (gated by expenses.view) backs the autocomplete. - Form dialog: native `<datalist>` powered by the autocomplete query so reps don't end up with "Palm Beach 2026" / "palm-beach 2026" fragmented across two PDF sections. - Expense list: new "Trip" column (badge) + free-text filter. - Detail page: trip label rendered alongside Category / Payer. - PDF export: GroupBy gains 'trip'; filter.tripLabel narrows the export. Untagged rows fall under "(no trip)". - Trim/normalize on write so " Palm Beach 2026 " === "Palm Beach 2026". Interests — event tagging via existing tag system: - Reps can tag interests with an event tag (e.g. "Palm Beach 2026") via the existing InlineTagEditor on the detail page; tags are port-scoped and reusable. - Interest list now has a TagPicker filter rendered next to the FilterBar so reps can sort prospects by event attended ("show me every lead from Palm Beach"). Hidden 'relation'-typed FilterDefinition for tagIds wires URL round-trip + saved-views capture without rendering inside the FilterBar. - FilterBar deserializer now handles `relation` types as comma-joined arrays on URL load. Why a free-text trip label and not a trips table: - 6–12 events/year doesn't justify a domain. The CRUD UI cost would be most of the engineering, and reps already have the events on their personal calendars. - If usage proves demand for per-event ROI dashboards or richer attribution, promote to a real `trips` table later. Migration path: trip_label → tripId is a backfill+swap. Test status: 1168/1168 vitest. tsc clean. Migration 0039 applied in dev (also caught + fixed an unrelated audit-v3 follow-up: 0037 had `idx_br_interest` colliding with the existing `berth_recommendations.idx_br_interest`; renamed to `idx_brr_interest` / `idx_brr_contract_file`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:46:54 +02:00
/**
* Free-text trip / event label (e.g. "Palm Beach 2026"). Empty / null
* means "no trip". The form's autocomplete suggests prior values per
* port to keep spellings consistent so group-by works downstream.
*/
tripLabel: z.string().max(120).optional(),
});
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>
2026-05-05 04:38:32 +02:00
export const createExpenseSchema = createExpenseShape.refine(
(v) => (v.receiptFileIds && v.receiptFileIds.length > 0) || v.noReceiptAcknowledged === true,
{
message:
'Receipt required. Tick "I have no receipt for this expense" if you understand it may not be reimbursed.',
path: ['receiptFileIds'],
},
);
// Update accepts partial fields and skips the create-time receipt-or-ack
// rule (the row already exists and may legitimately be edited without
// touching receipts).
export const updateExpenseSchema = createExpenseShape.partial();
export const listExpensesSchema = baseListQuerySchema.extend({
category: z.string().optional(),
paymentStatus: z.string().optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
currency: z.string().optional(),
payer: z.string().optional(),
feat(expenses+interests): trip/event grouping (lightweight) Per the trips/events design discussion: instead of building a full events domain (table + CRUD UI + calendar) for the 6–12 yacht shows a year, ship the cheap version that covers the actual asks. Expenses — `tripLabel` free-text: - New `expenses.trip_label` text column (migration 0039) + index for filter / autocomplete lookup. - Validator: createExpenseShape + listExpensesSchema + exportExpensePdfSchema.filter all accept tripLabel. - Service: createExpense + updateExpense persist; listExpenses filters; new `listTripLabels(portId, search?)` returns distinct values ordered by most-recent expenseDate so the autocomplete surfaces recently-used labels first. - New `GET /api/v1/expenses/trip-labels` endpoint (gated by expenses.view) backs the autocomplete. - Form dialog: native `<datalist>` powered by the autocomplete query so reps don't end up with "Palm Beach 2026" / "palm-beach 2026" fragmented across two PDF sections. - Expense list: new "Trip" column (badge) + free-text filter. - Detail page: trip label rendered alongside Category / Payer. - PDF export: GroupBy gains 'trip'; filter.tripLabel narrows the export. Untagged rows fall under "(no trip)". - Trim/normalize on write so " Palm Beach 2026 " === "Palm Beach 2026". Interests — event tagging via existing tag system: - Reps can tag interests with an event tag (e.g. "Palm Beach 2026") via the existing InlineTagEditor on the detail page; tags are port-scoped and reusable. - Interest list now has a TagPicker filter rendered next to the FilterBar so reps can sort prospects by event attended ("show me every lead from Palm Beach"). Hidden 'relation'-typed FilterDefinition for tagIds wires URL round-trip + saved-views capture without rendering inside the FilterBar. - FilterBar deserializer now handles `relation` types as comma-joined arrays on URL load. Why a free-text trip label and not a trips table: - 6–12 events/year doesn't justify a domain. The CRUD UI cost would be most of the engineering, and reps already have the events on their personal calendars. - If usage proves demand for per-event ROI dashboards or richer attribution, promote to a real `trips` table later. Migration path: trip_label → tripId is a backfill+swap. Test status: 1168/1168 vitest. tsc clean. Migration 0039 applied in dev (also caught + fixed an unrelated audit-v3 follow-up: 0037 had `idx_br_interest` colliding with the existing `berth_recommendations.idx_br_interest`; renamed to `idx_brr_interest` / `idx_brr_contract_file`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:46:54 +02:00
tripLabel: z.string().optional(),
});
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>
2026-05-05 04:38:32 +02:00
/**
* Body for `POST /api/v1/expenses/export/pdf`. Mirrors the legacy
* `PDFOptions` shape from the Nuxt client-portal so reps can re-use the
* same mental model. `expenseIds` selects an explicit subset; when
* absent, the listExpenses-style filter is used to gather rows.
*
* Limits are deliberate:
* - max 1000 expenseIds so a runaway selection can't queue an OOM-able
* receipt-fetch loop (see expense-pdf.service.ts).
* - documentName is sanitized at the service layer for the filename;
* the validator only enforces a sane upper bound.
*/
export const exportExpensePdfSchema = z.object({
expenseIds: z.array(z.string()).max(1000).optional(),
filter: z
.object({
dateFrom: z.string().optional().nullable(),
dateTo: z.string().optional().nullable(),
category: z.string().optional().nullable(),
paymentStatus: z.string().optional().nullable(),
payer: z.string().optional().nullable(),
feat(expenses+interests): trip/event grouping (lightweight) Per the trips/events design discussion: instead of building a full events domain (table + CRUD UI + calendar) for the 6–12 yacht shows a year, ship the cheap version that covers the actual asks. Expenses — `tripLabel` free-text: - New `expenses.trip_label` text column (migration 0039) + index for filter / autocomplete lookup. - Validator: createExpenseShape + listExpensesSchema + exportExpensePdfSchema.filter all accept tripLabel. - Service: createExpense + updateExpense persist; listExpenses filters; new `listTripLabels(portId, search?)` returns distinct values ordered by most-recent expenseDate so the autocomplete surfaces recently-used labels first. - New `GET /api/v1/expenses/trip-labels` endpoint (gated by expenses.view) backs the autocomplete. - Form dialog: native `<datalist>` powered by the autocomplete query so reps don't end up with "Palm Beach 2026" / "palm-beach 2026" fragmented across two PDF sections. - Expense list: new "Trip" column (badge) + free-text filter. - Detail page: trip label rendered alongside Category / Payer. - PDF export: GroupBy gains 'trip'; filter.tripLabel narrows the export. Untagged rows fall under "(no trip)". - Trim/normalize on write so " Palm Beach 2026 " === "Palm Beach 2026". Interests — event tagging via existing tag system: - Reps can tag interests with an event tag (e.g. "Palm Beach 2026") via the existing InlineTagEditor on the detail page; tags are port-scoped and reusable. - Interest list now has a TagPicker filter rendered next to the FilterBar so reps can sort prospects by event attended ("show me every lead from Palm Beach"). Hidden 'relation'-typed FilterDefinition for tagIds wires URL round-trip + saved-views capture without rendering inside the FilterBar. - FilterBar deserializer now handles `relation` types as comma-joined arrays on URL load. Why a free-text trip label and not a trips table: - 6–12 events/year doesn't justify a domain. The CRUD UI cost would be most of the engineering, and reps already have the events on their personal calendars. - If usage proves demand for per-event ROI dashboards or richer attribution, promote to a real `trips` table later. Migration path: trip_label → tripId is a backfill+swap. Test status: 1168/1168 vitest. tsc clean. Migration 0039 applied in dev (also caught + fixed an unrelated audit-v3 follow-up: 0037 had `idx_br_interest` colliding with the existing `berth_recommendations.idx_br_interest`; renamed to `idx_brr_interest` / `idx_brr_contract_file`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:46:54 +02:00
tripLabel: z.string().optional().nullable(),
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>
2026-05-05 04:38:32 +02:00
includeArchived: z.boolean().optional(),
})
.optional(),
options: z.object({
documentName: z.string().min(1).max(200),
subheader: z.string().max(300).optional(),
feat(expenses+interests): trip/event grouping (lightweight) Per the trips/events design discussion: instead of building a full events domain (table + CRUD UI + calendar) for the 6–12 yacht shows a year, ship the cheap version that covers the actual asks. Expenses — `tripLabel` free-text: - New `expenses.trip_label` text column (migration 0039) + index for filter / autocomplete lookup. - Validator: createExpenseShape + listExpensesSchema + exportExpensePdfSchema.filter all accept tripLabel. - Service: createExpense + updateExpense persist; listExpenses filters; new `listTripLabels(portId, search?)` returns distinct values ordered by most-recent expenseDate so the autocomplete surfaces recently-used labels first. - New `GET /api/v1/expenses/trip-labels` endpoint (gated by expenses.view) backs the autocomplete. - Form dialog: native `<datalist>` powered by the autocomplete query so reps don't end up with "Palm Beach 2026" / "palm-beach 2026" fragmented across two PDF sections. - Expense list: new "Trip" column (badge) + free-text filter. - Detail page: trip label rendered alongside Category / Payer. - PDF export: GroupBy gains 'trip'; filter.tripLabel narrows the export. Untagged rows fall under "(no trip)". - Trim/normalize on write so " Palm Beach 2026 " === "Palm Beach 2026". Interests — event tagging via existing tag system: - Reps can tag interests with an event tag (e.g. "Palm Beach 2026") via the existing InlineTagEditor on the detail page; tags are port-scoped and reusable. - Interest list now has a TagPicker filter rendered next to the FilterBar so reps can sort prospects by event attended ("show me every lead from Palm Beach"). Hidden 'relation'-typed FilterDefinition for tagIds wires URL round-trip + saved-views capture without rendering inside the FilterBar. - FilterBar deserializer now handles `relation` types as comma-joined arrays on URL load. Why a free-text trip label and not a trips table: - 6–12 events/year doesn't justify a domain. The CRUD UI cost would be most of the engineering, and reps already have the events on their personal calendars. - If usage proves demand for per-event ROI dashboards or richer attribution, promote to a real `trips` table later. Migration path: trip_label → tripId is a backfill+swap. Test status: 1168/1168 vitest. tsc clean. Migration 0039 applied in dev (also caught + fixed an unrelated audit-v3 follow-up: 0037 had `idx_br_interest` colliding with the existing `berth_recommendations.idx_br_interest`; renamed to `idx_brr_interest` / `idx_brr_contract_file`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:46:54 +02:00
groupBy: z.enum(['none', 'payer', 'category', 'date', 'trip']).default('none'),
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>
2026-05-05 04:38:32 +02:00
includeReceipts: z.boolean().default(false),
includeReceiptContents: z.boolean().default(false),
includeSummary: z.boolean().default(true),
includeDetails: z.boolean().default(true),
includeProcessingFee: z.boolean().default(false),
targetCurrency: z.enum(['USD', 'EUR']).default('EUR'),
pageFormat: z.enum(['A4', 'Letter', 'Legal']).default('A4'),
}),
});
export type CreateExpenseInput = z.infer<typeof createExpenseSchema>;
export type UpdateExpenseInput = z.infer<typeof updateExpenseSchema>;
export type ListExpensesInput = z.infer<typeof listExpensesSchema>;
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>
2026-05-05 04:38:32 +02:00
export type ExportExpensePdfInput = z.infer<typeof exportExpensePdfSchema>;