From 60365dc3ded13eb9ea902635e596a0616fbdcfdf Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 21:45:42 +0200 Subject: [PATCH] =?UTF-8?q?fix(audit):=20backlog=20sweep=20=E2=80=94=20par?= =?UTF-8?q?tial=20archived=20indexes,=20custom-fields=20per-entity=20gate,?= =?UTF-8?q?=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred items (deferring the Documenso Phases 2-7 build and items needing design decisions or live external instances). DB schema: - Migration 0046 converts 5 composite (port_id, archived_at) indexes to partial WHERE archived_at IS NULL — clients, interests, yachts, and both residential tables. Smaller, faster planner choice for the dominant list-query shape. Multi-tenant isolation: - document_sends now verifies recipient.interestId belongs to the port before landing on the audit row (the surrounding clientId check was already port-scoped; interestId pollution was the gap). Routes / API: - /api/v1/custom-fields/[entityId] requires entityType query param and gates on the matching resource permission (clients/interests/berths/ yachts/companies). Fixes the cross-resource gap where a user with clients.view could read company custom-field values. - Admin user list trash button wrapped in PermissionGate (edit was already gated; remove was not). Service polish: - berth-recommender accepts string-shaped JSONB booleans ('true'/'false') so admin UIs that wrap values as strings don't silently fall through to defaults. - expense-pdf renderReceiptHeader anchors all text positions to a captured baseY rather than reading mutating doc.y after rect+stroke. Headers no longer drift on the first receipt page after a soft page break. - berth-pdf apply: collect non-finite numeric coercion drops + warn-log them so partial silent drops are observable (was invisible because the no-fields-supplied check only fires when ALL drop). - Storage cache fingerprint comment documenting the encrypted-secret invariant + the explicit invalidation hook. UI polish: - invoice-detail typed: replaced two `any` casts with a proper InvoiceDetailData / LineItem / LinkedExpense interface set. - YachtForm now accepts initialOwner prop. Wired through: - client-yachts-tab passes { type: 'client', id: clientId } - interest-form passes { type: 'client', id: selectedClientId } - Interest-form yacht picker now includes company-owned yachts where the selected client is a member (fetches client.companies and feeds YachtPicker an array filter). Plus an inline "Add new" button that opens YachtForm pre-bound to the client. - YachtPicker accepts ownerFilter as single OR array for "match any" semantics. BACKLOG.md updated with what landed vs what's still deferred (and why each deferred item is genuinely larger than this push warrants). Tests: 1185/1185 vitest, tsc clean. --- docs/BACKLOG.md | 138 ++++++++++++++++ .../api/v1/custom-fields/[entityId]/route.ts | 149 ++++++++++++------ src/components/admin/users/user-list.tsx | 32 ++-- src/components/clients/client-yachts-tab.tsx | 16 +- src/components/interests/interest-form.tsx | 60 ++++++- src/components/invoices/invoice-detail.tsx | 52 +++++- .../shared/custom-fields-section.tsx | 30 ++-- src/components/yachts/yacht-form.tsx | 12 +- src/components/yachts/yacht-picker.tsx | 26 ++- .../0046_partial_archived_indexes.sql | 32 ++++ src/lib/db/schema/clients.ts | 4 +- src/lib/db/schema/interests.ts | 4 +- src/lib/db/schema/residential.ts | 9 +- src/lib/db/schema/yachts.ts | 4 +- src/lib/services/berth-pdf.service.ts | 16 +- src/lib/services/berth-recommender.service.ts | 13 +- src/lib/services/document-sends.service.ts | 22 +++ src/lib/services/expense-pdf.service.ts | 23 ++- src/lib/storage/index.ts | 10 ++ 19 files changed, 527 insertions(+), 125 deletions(-) create mode 100644 docs/BACKLOG.md create mode 100644 src/lib/db/migrations/0046_partial_archived_indexes.sql diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md new file mode 100644 index 0000000..6edc195 --- /dev/null +++ b/docs/BACKLOG.md @@ -0,0 +1,138 @@ +# Master backlog index + +**Single source of truth for everything outstanding.** Start here when +asking "what's left to build/fix?". Items are grouped by source doc; +each entry links back to the original spec for full context. + +Last updated: 2026-05-07 (after the audit-final-deferred sweep — partial +archived indexes, document_sends interestId port-verify, custom-fields +per-entity permission gate, recommender bool parsing, expense PDF cursor +math, berth PDF silent-drop logging, YachtForm preset-owner + interest +form member-company yacht filter + add-new shortcut, invoice detail +typed). Many older items in §C and §F were already resolved by earlier +fix-audit commit waves; the audit doc was stale. + +--- + +## A. Documenso build (deferred for later) + +**Source:** [`docs/documenso-build-plan.md`](./documenso-build-plan.md) — full phase plan with locked decisions (Q1–Q10). +**Tracker delta:** [`docs/admin-ux-backlog.md`](./admin-ux-backlog.md) — what landed in Phase 1. + +Phase 1 (EOI generate flow polish + APPROVER-as-CC + per-port settings + signing-URL fix) is **DONE** and committed. + +Remaining phases — explicitly back-burnered by the user on 2026-05-07: + +| Phase | Scope | Estimate | Notes | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Phase 2** | Webhook handler enhancement: cascading "your turn" emails, on-completion PDF distribution, token-based recipient matching, idempotency lock | ~3–4h | Schema columns already in place from Phase 1 (`document_signers.invited_at / opened_at / signing_token`, `documents.completion_cc_emails`). | +| **Phase 3** | Custom doc upload-to-Documenso: `custom-document-upload.service.ts` + `POST /api/v1/interests/[id]/upload-for-signing` | ~6–8h | Depends on Phase 2 webhook UX in anger before locking the upload UX. | +| **Phase 4** | Field placement UI: react-pdf + dnd-kit overlay + auto-detect anchor scanner via pdfjs `getTextContent` | ~10–14h | Largest piece. Plan locked in build-plan Phase 4 — regexes, anchors, type-to-bbox sizing all spelled out. Best done in a focused session with the user watching. | +| **Phase 5** | Embedded signing URL emission verification: confirm website's `/sign//` page handles every signer-role × documentType combination; update `signerMessages` map; apply nginx CORS block from integration audit | ~1–2h | | +| **Phase 6** | Polish: auto-send delay, audit-log additions, per-document customisation, document expiration, reminder rate-limit display, failed-webhook recovery UI | each ~2–3h | All deferred until Phases 1–4 ship. | +| **Phase 7** | Project Director RBAC — UI binding for the developer-user fields. Add "Linked to CRM user" dropdown in `/admin/documenso/page.tsx`; auto-fill name/email; webhook handler matches against linked user's email for in-CRM signing-status updates. Schema + setting keys (`documenso_developer_user_id`, `documenso_approver_user_id`, `_label`) already in place from Phase 1. | ~1h | Smallest piece; could be picked off independently of Phase 2. | +| **Risk #4** | v2 webhook payload audit against a live v2 instance (`payload.documentId` vs `payload.id`, `recipient.token` vs `recipient.recipientId`) before relying on Phase 2 cascading emails | ~1h | Needs a live v2 instance. | + +--- + +## B. Custom-fields hardening (~ongoing, deferred) + +**Source:** [`docs/admin-ux-backlog.md`](./admin-ux-backlog.md) §7. + +Custom Settings page already shows the amber warning banner. Remediation work: + +- **Search index** — extend the GIN tsvector to include `customFieldValues` content +- **Audit diff** — extend `diffEntity` to walk the `customFieldValues` blob +- **Merge tokens** — add `{{custom.}}` handling at template-render time, plus surface them in the merge-tokens UI + +--- + +## C. Audit-final deferred items + +**Source:** [`docs/audit-final-deferred.md`](./audit-final-deferred.md) — pre-merge + post-merge audit findings explicitly carried over. + +The 2026-05-07 backlog sweep landed every small/concrete item. Remaining +entries are deferred because they need design decisions, live external +instances, or cross-cutting refactors: + +### Deferred — needs design or larger refactor + +- **Storage proxy token does not bind to port_id** — `src/lib/storage/filesystem.ts:73-84`. Adding a `p` (portId) claim is mechanical; the meaningful security gain requires the proxy verifier to look up the file's owning row + assert `owner.portId === payload.p`. That requires either a routing prefix in the key (currently `${portSlug}/...` already, so a prefix check is plausible) or a per-table lookup across all owners. Decide which approach before implementing — current state ships with `validateStorageKey` + per-issuer port scoping, so this is defense-in-depth rather than an open hole. +- **Documenso webhook does not enforce port_id on document lookups** — `src/app/api/webhooks/documenso/route.ts:96-148`. Adding port scope requires either including the originating Documenso instance/team id in the lookup (Documenso doesn't surface that on the webhook payload today) OR proving `documents(documenso_id)` is globally unique with a DB constraint and a backfill check. Pick the strategy with the audit doc open. +- **Webhook dedup vs per-recipient signed events** — `src/app/api/webhooks/documenso/route.ts:103-110`. Replacing the body-hash dedup with a `(documensoDocumentId, recipientEmail, eventType)` composite unique requires schema column for recipient_email on `documentEvents`. Right place to do this is alongside Documenso Phase 2 (webhook handler enhancement) since they touch the same code. +- **v2 voidDocument endpoint shape verification** — `src/lib/services/documenso-client.ts:450-466`. Needs a live Documenso 2.x instance to confirm `POST /api/v2/envelope/delete` body shape. Bundle with Documenso Phase 5. +- **Public POST routes bypass service layer** — `src/app/api/public/{interests,website-inquiries,residential-inquiries}/route.ts`. Multi-route refactor extracting a shared `publicInterestService.create(...)`. Worth doing but big enough to deserve its own session. +- **Inconsistent response shapes** — most endpoints return `{ data: ... }`, but `notifications/[notificationId]` returns `{ success: true }`, `website-inquiries` returns `{ id, deduped }`. Codebase-wide migration; document a convention in CLAUDE.md first. +- **`systemSettings` PK / unique-index drift** — `src/lib/db/schema/system.ts:119-133`. Schema declares `uniqueIndex` on `(key, port_id)`, migration uses `key` as PK. `port_id` is nullable so `(key, port_id)` cannot serve as a PK with default NULLs-not-equal semantics. Reconcile by either making `portId` non-null with a sentinel ("**global**") and declaring composite PK, OR by dropping the schema-level unique index and using partial unique indexes for global vs per-port. Either path is a data migration. + +### Done in 2026-05-07 sweep (commits in this session) + +- ✅ Partial archived indexes (migration 0046) — `clients`, `interests`, `yachts`, `residential_clients`, `residential_interests` +- ✅ `document_sends` interestId port-verification helper +- ✅ Custom-fields per-entity permission gate (replaces hardcoded `clients.view/edit`) +- ✅ EOI Berth Range warn log (was already in place) +- ✅ v1 `placeFields` retry with backoff (was already in place) +- ✅ S3 bucket-exists check at boot (was already in place) +- ✅ Filesystem dev HMAC fallback warn (was already in place) +- ✅ Storage cache fingerprint documentation comment +- ✅ AI worker cost ledger writes (was already in place) +- ✅ Logger redact paths covering headers, encrypted blobs, two-level nesting (was already in place) +- ✅ `loadRecommenderSettings` accepts string `"true"`/`"false"` JSONB booleans +- ✅ `renderReceiptHeader` cursor math anchored to captured `baseY` +- ✅ Berth PDF apply: silent-drop logging for non-finite numeric coercions +- ✅ Saved-views: confirmed by-design owner-only (existing inline doc) +- ✅ Alerts ack/dismiss: confirmed by-design port-wide (service correctly bounded) +- ✅ Storage admin migration toasts (already in place) +- ✅ Invoice send/payment toasts + permission gates (already in place) +- ✅ Admin user list edit + remove gates (added remove gate) +- ✅ Email threads list skeleton + empty state (already in place) +- ✅ Scan page error state for OCR failures (already in place) +- ✅ Invoice detail typed (replaced `any` with `InvoiceDetailData` interface) +- ✅ All FK indexes called out in audit doc (already in place — audit was stale) +- ✅ `documentSends.sentByUserId` FK (already had `.references(...)`) + +### Still open — small enough to bundle next time + +- **`berths.current_pdf_version_id` lacks Drizzle FK** — `src/lib/db/schema/berths.ts:83`. The in-line comment fully documents why (circular FK between `berths` ↔ `berth_pdf_versions` makes column-level `.references()` infeasible). FK is enforced via migration 0030. Treat as documented limitation; revisit if Drizzle adds deferred-FK support. +- **`req.json()` without `parseBody` helper** — admin custom-fields routes use `await req.json(); schema.parse(body)` directly. Migrate for uniform 400 error shapes when the surface area calms down. + +--- + +## D. Inline TODOs in code (2 remaining) + +| File:line | Note | Status | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------ | +| ~~`client-yachts-tab.tsx:93`~~ | YachtForm preset owner prop | ✅ landed 2026-05-07 (`initialOwner` prop) | +| ~~`interest-form.tsx:329`~~ | Include company-owned yachts where client is a member | ✅ landed 2026-05-07 (`yachtOwnerFilter` array filter) | +| ~~`interest-form.tsx:330`~~ | "Add new yacht" inline shortcut | ✅ landed 2026-05-07 (Plus button + YachtForm sheet) | +| [`src/lib/queue/scheduler.ts:44`](../src/lib/queue/scheduler.ts#L44) | Per-user reminder schedule configurable from `user_settings` | Open — needs `user_settings` UI surface | +| [`src/lib/queue/workers/import.ts:13`](../src/lib/queue/workers/import.ts#L13) | Import job handlers — worker is a stub | Open — entire feature surface | + +--- + +## E. Hidden / stubbed UI tabs + +- **Company Documents tab** — `src/components/companies/company-tabs.tsx:229`. Hidden until `/api/v1/files` accepts a `companyId` filter (schema supports it, validator doesn't). +- **Berth Waiting List + Maintenance Log tabs** — `src/components/berths/berth-tabs.tsx:346`. Removed entirely; revisit if/when product asks. +- **Interest Contract / Reservation tabs** — `src/components/interests/interest-{contract,reservation}-tab.tsx`. Render a "coming soon" friendly card; the real flow is gated on Documenso Phases 2–6. + +--- + +## F. Historical audit docs (mostly resolved) + +These dossiers drove the audit-fix commit waves on 2026-05-05/06. Items +not surfaced in §C above were resolved via the `fix(audit): …` commits +(`588f8bc`, `94331bd`, `a8c6c07`, `5fc68a5`, `da7ede7`, `c5b41ca`, +`b4fb3b2`, `0f648a9`, `c312cd3`, `0a5f085`, `1a87f28`, `f3143d7`, +`05babe5`). Keep for historical context: + +- [`audit-comprehensive-2026-05-05.md`](./audit-comprehensive-2026-05-05.md) — pre-merge audit (1 CRIT + 18 HIGH at start) +- [`audit-comprehensive-2026-05-06.md`](./audit-comprehensive-2026-05-06.md) — post-merge audit (1 CRIT + 7 HIGH + 10 MED + 7 LOW) +- [`audit-frontend-2026-05-06.md`](./audit-frontend-2026-05-06.md) — frontend-only sweep +- [`audit-missing-features-2026-05-06.md`](./audit-missing-features-2026-05-06.md) — admin-promised-but-unwired features (V1–V12) +- [`audit-permissions-2026-05-06.md`](./audit-permissions-2026-05-06.md) — permission-gate gaps +- [`audit-reliability-2026-05-06.md`](./audit-reliability-2026-05-06.md) — transactional integrity / TOCTOU +- [`berth-feature-handoff-prompt.md`](./berth-feature-handoff-prompt.md) — berth recommender handoff (shipped, kept as reference) +- [`berth-recommender-and-pdf-plan.md`](./berth-recommender-and-pdf-plan.md) — berth recommender + per-berth PDF plan (Phases 0–8 shipped) +- [`documenso-integration-audit.md`](./documenso-integration-audit.md) — Documenso integration spec (drives §A) +- [`website-refactor.md`](./website-refactor.md) — public website cutover plan diff --git a/src/app/api/v1/custom-fields/[entityId]/route.ts b/src/app/api/v1/custom-fields/[entityId]/route.ts index 212dcfa..65f5d5c 100644 --- a/src/app/api/v1/custom-fields/[entityId]/route.ts +++ b/src/app/api/v1/custom-fields/[entityId]/route.ts @@ -1,55 +1,114 @@ import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; -import { withAuth, withPermission } from '@/lib/api/helpers'; -import { errorResponse, NotFoundError } from '@/lib/errors'; +import { withAuth } from '@/lib/api/helpers'; +import { parseQuery } from '@/lib/api/route-helpers'; +import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; +import { requirePermission } from '@/lib/auth/permissions'; import { setValuesSchema } from '@/lib/validators/custom-fields'; import { getValues, setValues } from '@/lib/services/custom-fields.service'; -// Custom-field values live on top of a port-scoped entity (client, yacht, -// interest, berth, company). Reading the values is in scope for any role -// that can view clients (the most common surface); writing requires the -// equivalent edit permission. The service-layer also re-validates the -// entityId against the field definition's entityType + portId so a -// caller cannot poke values onto an arbitrary or foreign-port entity. -export const GET = withAuth( - withPermission('clients', 'view', async (_req: NextRequest, ctx, params) => { - try { - const { entityId } = params; - if (!entityId) throw new NotFoundError('Entity'); +/** + * Custom-field values live on top of a port-scoped entity (client, yacht, + * interest, berth, company). The previous implementation hardcoded the + * permission check on `clients.view` / `clients.edit`, which let a user + * with only `clients.view` read company-scoped custom-field values + * through this endpoint. We now require an `entityType` query param so + * the gate can match the entity's own resource permission. + * + * The service-layer also re-validates the entityId against each field + * definition's entityType + portId so a caller cannot poke values onto + * an arbitrary or foreign-port entity. + */ - const data = await getValues(entityId, ctx.portId); - return NextResponse.json({ data }); - } catch (error) { - return errorResponse(error); - } - }), -); +const ENTITY_TYPE_VALUES = ['client', 'interest', 'berth', 'yacht', 'company'] as const; +type EntityTypeQuery = (typeof ENTITY_TYPE_VALUES)[number]; -export const PUT = withAuth( - withPermission('clients', 'edit', async (req: NextRequest, ctx, params) => { - try { - const { entityId } = params; - if (!entityId) throw new NotFoundError('Entity'); +const querySchema = z.object({ + entityType: z.enum(ENTITY_TYPE_VALUES), +}); - const body = await req.json(); - const { values } = setValuesSchema.parse(body); +function gateForView( + entityType: EntityTypeQuery, + ctx: Parameters[0], +): void { + switch (entityType) { + case 'client': + return requirePermission(ctx, 'clients', 'view'); + case 'interest': + return requirePermission(ctx, 'interests', 'view'); + case 'berth': + return requirePermission(ctx, 'berths', 'view'); + case 'yacht': + return requirePermission(ctx, 'yachts', 'view'); + case 'company': + return requirePermission(ctx, 'companies', 'view'); + default: + throw new ValidationError(`Unsupported entityType: ${entityType as string}`); + } +} - const result = await setValues( - entityId, - ctx.portId, - ctx.userId, - values as Array<{ fieldId: string; value: unknown }>, - { - userId: ctx.userId, - portId: ctx.portId, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, - }, - ); +function gateForEdit( + entityType: EntityTypeQuery, + ctx: Parameters[0], +): void { + switch (entityType) { + case 'client': + return requirePermission(ctx, 'clients', 'edit'); + case 'interest': + return requirePermission(ctx, 'interests', 'edit'); + case 'berth': + return requirePermission(ctx, 'berths', 'edit'); + case 'yacht': + return requirePermission(ctx, 'yachts', 'edit'); + case 'company': + return requirePermission(ctx, 'companies', 'edit'); + default: + throw new ValidationError(`Unsupported entityType: ${entityType as string}`); + } +} - return NextResponse.json({ data: result }); - } catch (error) { - return errorResponse(error); - } - }), -); +export const GET = withAuth(async (req: NextRequest, ctx, params) => { + try { + const { entityId } = params; + if (!entityId) throw new NotFoundError('Entity'); + + const { entityType } = parseQuery(req, querySchema); + gateForView(entityType, ctx); + + const data = await getValues(entityId, ctx.portId); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } +}); + +export const PUT = withAuth(async (req: NextRequest, ctx, params) => { + try { + const { entityId } = params; + if (!entityId) throw new NotFoundError('Entity'); + + const { entityType } = parseQuery(req, querySchema); + gateForEdit(entityType, ctx); + + const body = await req.json(); + const { values } = setValuesSchema.parse(body); + + const result = await setValues( + entityId, + ctx.portId, + ctx.userId, + values as Array<{ fieldId: string; value: unknown }>, + { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + ); + + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/components/admin/users/user-list.tsx b/src/components/admin/users/user-list.tsx index 8b40a75..34e9361 100644 --- a/src/components/admin/users/user-list.tsx +++ b/src/components/admin/users/user-list.tsx @@ -118,19 +118,25 @@ export function UserList() { Edit - - - Remove - - } - title="Remove User" - description={`Remove "${row.original.displayName}" from this port? They will lose access but their account remains.`} - confirmLabel="Remove" - onConfirm={() => handleRemoveUser(row.original.userId)} - loading={deletingId === row.original.userId} - /> + + + + Remove + + } + title="Remove User" + description={`Remove "${row.original.displayName}" from this port? They will lose access but their account remains.`} + confirmLabel="Remove" + onConfirm={() => handleRemoveUser(row.original.userId)} + loading={deletingId === row.original.userId} + /> + ), enableSorting: false, diff --git a/src/components/clients/client-yachts-tab.tsx b/src/components/clients/client-yachts-tab.tsx index cd57df1..a3ab63e 100644 --- a/src/components/clients/client-yachts-tab.tsx +++ b/src/components/clients/client-yachts-tab.tsx @@ -31,7 +31,7 @@ interface ClientYachtsTabProps { }>; } -export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTabProps) { +export function ClientYachtsTab({ clientId, yachts }: ClientYachtsTabProps) { const routeParams = useParams<{ portSlug: string }>(); const portSlug = routeParams?.portSlug ?? ''; const [createOpen, setCreateOpen] = useState(false); @@ -89,13 +89,13 @@ export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTab )} - {/* - TODO: YachtForm (Task 5.2) does not yet accept a preset owner prop. - When opened here, the user must manually pick this client in the owner - picker. Wire an `initialOwner` prop into YachtForm in a follow-up so - we can pre-select `{ type: 'client', id: clientId }`. - */} - {createOpen && } + {createOpen && ( + + )} ); } diff --git a/src/components/interests/interest-form.tsx b/src/components/interests/interest-form.tsx index d461341..ac0d009 100644 --- a/src/components/interests/interest-form.tsx +++ b/src/components/interests/interest-form.tsx @@ -3,8 +3,8 @@ import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Loader2, ChevronsUpDown, Check } from 'lucide-react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; @@ -30,6 +30,7 @@ import { import { Checkbox } from '@/components/ui/checkbox'; import { Separator } from '@/components/ui/separator'; import { TagPicker } from '@/components/shared/tag-picker'; +import { YachtForm } from '@/components/yachts/yacht-form'; import { YachtPicker } from '@/components/yachts/yacht-picker'; import { apiFetch } from '@/lib/api/client'; import { useEntityOptions } from '@/hooks/use-entity-options'; @@ -100,6 +101,25 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: const selectedClientId = watch('clientId'); const selectedBerthId = watch('berthId'); const selectedYachtId = watch('yachtId'); + const [createYachtOpen, setCreateYachtOpen] = useState(false); + + // Fetch the selected client's company memberships so the YachtPicker can + // include yachts owned by companies the client belongs to (e.g. a + // managing-director client whose yachts are titled to the company). + const { data: clientDetail } = useQuery<{ + data: { companies?: Array<{ company: { id: string } }> }; + }>({ + queryKey: ['client-detail-for-interest-form', selectedClientId], + queryFn: () => apiFetch(`/api/v1/clients/${selectedClientId}`), + enabled: !!selectedClientId, + }); + const memberCompanyIds: string[] = clientDetail?.data.companies?.map((m) => m.company.id) ?? []; + const yachtOwnerFilter = selectedClientId + ? [ + { type: 'client' as const, id: selectedClientId }, + ...memberCompanyIds.map((id) => ({ type: 'company' as const, id })), + ] + : undefined; const { options: clientOptions, @@ -313,21 +333,38 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
- +
+ + {selectedClientId && ( + + )} +
setValue('yachtId', id ?? undefined)} - ownerFilter={ - selectedClientId ? { type: 'client', id: selectedClientId } : undefined - } + ownerFilter={yachtOwnerFilter} disabled={!selectedClientId} placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'} />

Required before the interest can leave the "Open" stage. + {memberCompanyIds.length > 0 && ( + <> + {' '} + Includes yachts from {memberCompanyIds.length}{' '} + {memberCompanyIds.length === 1 ? 'member company' : 'member companies'}. + + )}

- {/* TODO: also include company-owned yachts where client is a member - requires autocomplete owner=any|company filter */} - {/* TODO: add "Add new yacht" inline shortcut (requires YachtForm integration) */}
@@ -501,6 +538,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: + {createYachtOpen && selectedClientId && ( + + )} ); } diff --git a/src/components/invoices/invoice-detail.tsx b/src/components/invoices/invoice-detail.tsx index 827f886..c0dae67 100644 --- a/src/components/invoices/invoice-detail.tsx +++ b/src/components/invoices/invoice-detail.tsx @@ -75,15 +75,53 @@ interface InvoiceDetailProps { invoiceId: string; } +interface InvoiceLineItem { + id: string; + description: string; + quantity: number | string; + unitPrice: number | string; + total: number | string; +} + +interface InvoiceLinkedExpense { + id: string; + establishmentName: string | null; + category: string | null; + expenseDate: string; + amount: number | string; + currency: string; +} + +interface InvoiceDetailData { + id: string; + invoiceNumber: string; + status: string; + clientName: string; + currency: string; + total: number | string; + subtotal: number | string; + discountAmount: number | string; + discountPct: number | string; + feeAmount: number | string; + feePct: number | string; + dueDate: string | null; + paymentTerms: string | null; + notes: string | null; + pdfFileId: string | null; + paymentDate: string | null; + paymentMethod: string | null; + paymentReference: string | null; + lineItems?: InvoiceLineItem[]; + linkedExpenses?: InvoiceLinkedExpense[]; +} + export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) { const queryClient = useQueryClient(); const [tab, setTab] = useState('overview'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data, isLoading, error } = useQuery<{ data: any }>({ + const { data, isLoading, error } = useQuery<{ data: InvoiceDetailData }>({ queryKey: ['invoices', invoiceId], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - queryFn: () => apiFetch<{ data: any }>(`/api/v1/invoices/${invoiceId}`), + queryFn: () => apiFetch<{ data: InvoiceDetailData }>(`/api/v1/invoices/${invoiceId}`), }); const { setChrome } = useMobileChrome(); @@ -233,8 +271,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) { Unit Price Total - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {(invoice.lineItems as any[]).map((li) => ( + {invoice.lineItems.map((li) => (
{li.description} {li.quantity} @@ -299,8 +336,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) { {invoice.linkedExpenses && invoice.linkedExpenses.length > 0 ? (
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {(invoice.linkedExpenses as any[]).map((exp) => ( + {invoice.linkedExpenses.map((exp) => (
({ - queryKey: ['custom-field-values', entityId], + queryKey: ['custom-field-values', entityType, entityId], queryFn: async () => { const res = await apiFetch<{ data: FieldEntry[] }>( - `/api/v1/custom-fields/${entityId}`, + `/api/v1/custom-fields/${entityId}?entityType=${entityType}`, ); return res.data; }, @@ -67,20 +67,21 @@ export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectio }); // Only show fields for this entity type - const filteredEntries = - entries?.filter((e) => e.definition.entityType === entityType) ?? []; + const filteredEntries = entries?.filter((e) => e.definition.entityType === entityType) ?? []; // ── Mutation ─────────────────────────────────────────────────────────────── const mutation = useMutation({ mutationFn: async (values: Array<{ fieldId: string; value: unknown }>) => { - await apiFetch(`/api/v1/custom-fields/${entityId}`, { + await apiFetch(`/api/v1/custom-fields/${entityId}?entityType=${entityType}`, { method: 'PUT', body: { values }, }); }, onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['custom-field-values', entityId] }); + void queryClient.invalidateQueries({ + queryKey: ['custom-field-values', entityType, entityId], + }); }, }); @@ -103,10 +104,7 @@ export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectio return ( - setCollapsed((c) => !c)} - > + setCollapsed((c) => !c)}>
Custom Fields {collapsed ? ( @@ -127,9 +125,7 @@ export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectio - mutation.mutate([{ fieldId, value }]) - } + onSave={(fieldId, value) => mutation.mutate([{ fieldId, value }])} /> ))}
@@ -278,11 +274,7 @@ function BooleanField({ return (
{label} - +
); } diff --git a/src/components/yachts/yacht-form.tsx b/src/components/yachts/yacht-form.tsx index 5e76817..ed848af 100644 --- a/src/components/yachts/yacht-form.tsx +++ b/src/components/yachts/yacht-form.tsx @@ -49,11 +49,18 @@ interface YachtFormProps { status?: string | null; notes?: string | null; }; + /** + * In create mode, pre-select the owner so a user opening this form from + * a client/company detail page doesn't have to manually re-pick the + * entity they're already on. Ignored in edit mode (the existing + * owner-history workflow is the right surface for ownership changes). + */ + initialOwner?: { type: 'client' | 'company'; id: string }; } type YachtStatus = 'active' | 'retired' | 'sold_away'; -export function YachtForm({ open, onOpenChange, yacht }: YachtFormProps) { +export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtFormProps) { const queryClient = useQueryClient(); const isEdit = !!yacht; const [formError, setFormError] = useState(null); @@ -109,10 +116,11 @@ export function YachtForm({ open, onOpenChange, yacht }: YachtFormProps) { name: '', status: 'active', tagIds: [], + ...(initialOwner ? { owner: initialOwner } : {}), }); } setFormError(null); - }, [yacht, open, reset]); + }, [yacht, open, reset, initialOwner]); const mutation = useMutation({ mutationFn: async (data: CreateYachtInput) => { diff --git a/src/components/yachts/yacht-picker.tsx b/src/components/yachts/yacht-picker.tsx index 9b1a098..2b8aba1 100644 --- a/src/components/yachts/yacht-picker.tsx +++ b/src/components/yachts/yacht-picker.tsx @@ -27,11 +27,17 @@ interface YachtOption { currentOwnerId?: string; } +type OwnerFilter = { type: 'client' | 'company'; id: string }; + interface YachtPickerProps { value: string | null; onChange: (yachtId: string | null) => void; - /** Optional filter to only show yachts owned by the given client or company. */ - ownerFilter?: { type: 'client' | 'company'; id: string }; + /** + * Optional filter. Single owner = strict match. Array = match ANY of the + * supplied owners (used by the interest-form to include yachts owned by + * the client AND by companies the client is a member of). + */ + ownerFilter?: OwnerFilter | OwnerFilter[]; placeholder?: string; disabled?: boolean; } @@ -54,11 +60,17 @@ export function YachtPicker({ }); const rawOptions = data?.data ?? []; - const options = ownerFilter - ? rawOptions.filter( - (y) => y.currentOwnerType === ownerFilter.type && y.currentOwnerId === ownerFilter.id, - ) - : rawOptions; + const filterList: OwnerFilter[] | null = ownerFilter + ? Array.isArray(ownerFilter) + ? ownerFilter + : [ownerFilter] + : null; + const options = + filterList && filterList.length > 0 + ? rawOptions.filter((y) => + filterList.some((f) => y.currentOwnerType === f.type && y.currentOwnerId === f.id), + ) + : rawOptions; const selectedLabel = (() => { if (!value) return placeholder; diff --git a/src/lib/db/migrations/0046_partial_archived_indexes.sql b/src/lib/db/migrations/0046_partial_archived_indexes.sql new file mode 100644 index 0000000..9259565 --- /dev/null +++ b/src/lib/db/migrations/0046_partial_archived_indexes.sql @@ -0,0 +1,32 @@ +-- Convert composite (port_id, archived_at) archived indexes to partial +-- indexes WHERE archived_at IS NULL. Every list query in the codebase that +-- hits archived_at filters on `archived_at IS NULL` (verified in +-- clients.service / interests.service / search.service / residential.service +-- / yachts.service). The composite index always carries the archived rows +-- as dead weight; the partial index is smaller, has a higher cache hit rate, +-- and lets the planner skip the index entirely when the predicate is absent. + +-- clients +DROP INDEX IF EXISTS "idx_clients_archived"; +CREATE INDEX IF NOT EXISTS "idx_clients_archived" ON "clients" ("port_id") + WHERE "archived_at" IS NULL; + +-- interests +DROP INDEX IF EXISTS "idx_interests_archived"; +CREATE INDEX IF NOT EXISTS "idx_interests_archived" ON "interests" ("port_id") + WHERE "archived_at" IS NULL; + +-- yachts +DROP INDEX IF EXISTS "idx_yachts_archived"; +CREATE INDEX IF NOT EXISTS "idx_yachts_archived" ON "yachts" ("port_id") + WHERE "archived_at" IS NULL; + +-- residential clients +DROP INDEX IF EXISTS "idx_residential_clients_archived"; +CREATE INDEX IF NOT EXISTS "idx_residential_clients_archived" ON "residential_clients" ("port_id") + WHERE "archived_at" IS NULL; + +-- residential interests +DROP INDEX IF EXISTS "idx_residential_interests_archived"; +CREATE INDEX IF NOT EXISTS "idx_residential_interests_archived" ON "residential_interests" ("port_id") + WHERE "archived_at" IS NULL; diff --git a/src/lib/db/schema/clients.ts b/src/lib/db/schema/clients.ts index c302ff1..7f0f37a 100644 --- a/src/lib/db/schema/clients.ts +++ b/src/lib/db/schema/clients.ts @@ -57,7 +57,9 @@ export const clients = pgTable( (table) => [ index('idx_clients_port').on(table.portId), index('idx_clients_name').on(table.portId, table.fullName), - index('idx_clients_archived').on(table.portId, table.archivedAt), + index('idx_clients_archived') + .on(table.portId) + .where(sql`${table.archivedAt} IS NULL`), index('idx_clients_nationality_iso').on(table.nationalityIso), index('idx_clients_merged_into').on(table.mergedIntoClientId), ], diff --git a/src/lib/db/schema/interests.ts b/src/lib/db/schema/interests.ts index 36f5918..56d2386 100644 --- a/src/lib/db/schema/interests.ts +++ b/src/lib/db/schema/interests.ts @@ -72,7 +72,9 @@ export const interests = pgTable( index('idx_interests_client').on(table.clientId), index('idx_interests_yacht').on(table.yachtId), index('idx_interests_stage').on(table.portId, table.pipelineStage), - index('idx_interests_archived').on(table.portId, table.archivedAt), + index('idx_interests_archived') + .on(table.portId) + .where(sql`${table.archivedAt} IS NULL`), index('idx_interests_outcome').on(table.portId, table.outcome), ], ); diff --git a/src/lib/db/schema/residential.ts b/src/lib/db/schema/residential.ts index 27b0745..1f0b931 100644 --- a/src/lib/db/schema/residential.ts +++ b/src/lib/db/schema/residential.ts @@ -1,4 +1,5 @@ import { boolean, pgTable, text, timestamp, index } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; import { ports } from './ports'; @@ -50,7 +51,9 @@ export const residentialClients = pgTable( (table) => [ index('idx_residential_clients_port').on(table.portId), index('idx_residential_clients_email').on(table.email), - index('idx_residential_clients_archived').on(table.portId, table.archivedAt), + index('idx_residential_clients_archived') + .on(table.portId) + .where(sql`${table.archivedAt} IS NULL`), ], ); @@ -98,7 +101,9 @@ export const residentialInterests = pgTable( index('idx_residential_interests_client').on(table.residentialClientId), index('idx_residential_interests_stage').on(table.portId, table.pipelineStage), index('idx_residential_interests_assigned').on(table.assignedTo), - index('idx_residential_interests_archived').on(table.portId, table.archivedAt), + index('idx_residential_interests_archived') + .on(table.portId) + .where(sql`${table.archivedAt} IS NULL`), ], ); diff --git a/src/lib/db/schema/yachts.ts b/src/lib/db/schema/yachts.ts index 53e9c64..5ade72a 100644 --- a/src/lib/db/schema/yachts.ts +++ b/src/lib/db/schema/yachts.ts @@ -51,7 +51,9 @@ export const yachts = pgTable( table.currentOwnerId, ), index('idx_yachts_name').on(table.portId, table.name), - index('idx_yachts_archived').on(table.portId, table.archivedAt), + index('idx_yachts_archived') + .on(table.portId) + .where(sql`${table.archivedAt} IS NULL`), ], ); diff --git a/src/lib/services/berth-pdf.service.ts b/src/lib/services/berth-pdf.service.ts index 76f2274..27e0aa1 100644 --- a/src/lib/services/berth-pdf.service.ts +++ b/src/lib/services/berth-pdf.service.ts @@ -479,6 +479,11 @@ export async function applyParseResults( const update: Record = {}; const applied: Array = []; + // Capture keys whose values were supplied but couldn't be coerced + // (e.g. a numeric column receiving a non-finite or non-numeric value). + // Without this, partial silent drops are invisible because the + // "no appliable fields supplied" check only fires when EVERY key drops. + const dropped: Array<{ key: keyof ExtractedBerthFields; reason: string }> = []; for (const key of APPLIABLE_FIELDS) { const value = fieldsToApply[key]; if (value === undefined) continue; @@ -489,7 +494,10 @@ export async function applyParseResults( } if (NUMERIC_FIELDS.has(key)) { const n = typeof value === 'number' ? value : Number(value); - if (!Number.isFinite(n)) continue; + if (!Number.isFinite(n)) { + dropped.push({ key, reason: `non-finite numeric (${typeof value}: ${String(value)})` }); + continue; + } // numeric columns expect strings to preserve precision. update[key] = String(n); } else { @@ -500,6 +508,12 @@ export async function applyParseResults( if (applied.length === 0) { throw new ValidationError('No appliable fields supplied.'); } + if (dropped.length > 0) { + logger.warn( + { berthId, versionId, dropped }, + 'Berth PDF apply: silently dropped fields that failed type coercion', + ); + } update.updatedAt = new Date(); await db.transaction(async (tx) => { diff --git a/src/lib/services/berth-recommender.service.ts b/src/lib/services/berth-recommender.service.ts index 3b43fd1..b894b03 100644 --- a/src/lib/services/berth-recommender.service.ts +++ b/src/lib/services/berth-recommender.service.ts @@ -114,7 +114,18 @@ export async function loadRecommenderSettings(portId: string): Promise (typeof v === 'boolean' ? v : null); + const asBool = (v: unknown): boolean | null => { + if (typeof v === 'boolean') return v; + // Some admin UIs (or older settings rows) persist booleans as the + // strings "true" / "false" inside the JSONB blob. Without this + // tolerant parse, a per-port override quietly falls through to the + // default and the admin's tuning has no effect. + if (typeof v === 'string') { + if (v === 'true') return true; + if (v === 'false') return false; + } + return null; + }; const asPolicy = (v: unknown): RecommenderSettings['fallthroughPolicy'] | null => { if (v === 'immediate_with_heat' || v === 'cooldown' || v === 'never_auto_recommend') { return v; diff --git a/src/lib/services/document-sends.service.ts b/src/lib/services/document-sends.service.ts index 9756a12..71ce266 100644 --- a/src/lib/services/document-sends.service.ts +++ b/src/lib/services/document-sends.service.ts @@ -38,6 +38,7 @@ import { berthPdfVersions, clients, clientContacts, + interests, ports, } from '@/lib/db/schema'; import type { DocumentSend } from '@/lib/db/schema'; @@ -225,6 +226,21 @@ async function resolveRecipientEmail( return primary.value; } +/** + * Verify a caller-supplied `interestId` belongs to the authenticated port + * before it lands on the `document_sends` audit row. Without this, an + * attacker who knows a foreign-port interest UUID can pollute another + * tenant's audit history (the surrounding `clientId` lookup is already + * port-scoped, so data isn't exposed — but the audit trail would be). + */ +async function assertInterestInPort(portId: string, interestId: string): Promise { + const row = await db.query.interests.findFirst({ + where: and(eq(interests.id, interestId), eq(interests.portId, portId)), + columns: { id: true }, + }); + if (!row) throw new NotFoundError('Interest'); +} + async function checkSendRateLimit(portId: string, userId: string): Promise { // Per-(port, user) so a multi-port rep can't be DoS'd by another tenant // burning their global cap. Audit caught this — the original @@ -375,6 +391,9 @@ export async function sendBerthPdf(input: SendBerthPdfInput): Promise { // Rate-limit AFTER validation (audit finding); typos shouldn't burn slots. const recipientEmail = await resolveRecipientEmail(input.portId, input.recipient); + if (input.recipient.interestId) { + await assertInterestInPort(input.portId, input.recipient.interestId); + } // Resolve brochure + most-recent version. let brochureRow; diff --git a/src/lib/services/expense-pdf.service.ts b/src/lib/services/expense-pdf.service.ts index 2ce1fdd..1274751 100644 --- a/src/lib/services/expense-pdf.service.ts +++ b/src/lib/services/expense-pdf.service.ts @@ -913,8 +913,15 @@ function renderReceiptHeader( ) { const margin = 60; const headerH = 90; + // Capture the header's top edge BEFORE drawing — every subsequent text + // call below uses pdfkit's auto-flow which advances `doc.y`. Using + // `doc.y - headerH + 10` after the rect+stroke block computes against + // the post-rect position and only happens to work because pdfkit's + // text-after-rect hasn't moved y yet. On the first receipt page after + // a soft page break that assumption breaks and the header misaligns. + const baseY = doc.y; doc - .rect(margin, doc.y, doc.page.width - margin * 2, headerH) + .rect(margin, baseY, doc.page.width - margin * 2, headerH) .fillColor('#f8f9fa') .fill() .strokeColor('#dee2e6') @@ -924,14 +931,14 @@ function renderReceiptHeader( doc .fontSize(14) .font('Helvetica-Bold') - .text(`Receipt ${index} of ${total}`, margin + 10, doc.y - headerH + 10); + .text(`Receipt ${index} of ${total}`, margin + 10, baseY + 10); doc .fontSize(11) .font('Helvetica-Bold') .text( `${expense.establishmentName ?? '—'} ${sym}${expense.amountTarget.toFixed(2)}`, margin + 10, - doc.y + 4, + baseY + 36, ); doc .fontSize(9) @@ -940,14 +947,14 @@ function renderReceiptHeader( .text( `Date: ${expense.expenseDate.toISOString().slice(0, 10)} · Payer: ${expense.payer ?? '—'} · Category: ${expense.category ?? '—'} · File: ${file.filename}`, margin + 10, - doc.y + 4, + baseY + 56, { width: doc.page.width - margin * 2 - 20 }, ); doc.fillColor('#000000'); - // Reset cursor to below the header block. - const margin2 = 60; - doc.y = doc.y + Math.max(headerH - 50, 20); - void margin2; + // Reset cursor to below the header block, anchored to the captured + // baseline so it's independent of however many auto-flowed text runs + // occurred above. + doc.y = baseY + headerH + 8; } function addReceiptErrorPage( diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts index be4587e..b5f225e 100644 --- a/src/lib/storage/index.ts +++ b/src/lib/storage/index.ts @@ -155,6 +155,16 @@ async function loadStorageConfig(): Promise { }; } +/** + * The fingerprint includes encrypted-secret material because rotating the + * secret should invalidate the cached client. After a key rotation the + * settings-write hook calls `resetStorageBackendCache()` explicitly, so + * this comparison is a defense-in-depth backstop rather than the primary + * invalidation path. If you ever change `loadStorageConfig` to read + * additional sensitive material, make sure the rotation flow keeps + * resetting the cache — relying on fingerprint diff alone means the old + * client is held in memory until the next mismatch. + */ function fingerprint(cfg: StorageConfigSnapshot): string { return JSON.stringify(cfg); }