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); }