fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m37s
Build & Push Docker Images / build-and-push (push) Failing after 24s

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.
This commit is contained in:
2026-05-07 21:45:42 +02:00
parent 5c8c12ba1f
commit 60365dc3de
19 changed files with 527 additions and 125 deletions

View File

@@ -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<typeof requirePermission>[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<typeof requirePermission>[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);
}
});

View File

@@ -118,19 +118,25 @@ export function UserList() {
<span className="sr-only">Edit</span>
</Button>
</PermissionGate>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove</span>
</Button>
}
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}
/>
<PermissionGate resource="admin" action="manage_users">
<ConfirmationDialog
trigger={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove</span>
</Button>
}
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}
/>
</PermissionGate>
</div>
),
enableSorting: false,

View File

@@ -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
</div>
)}
{/*
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 && <YachtForm open={createOpen} onOpenChange={setCreateOpen} />}
{createOpen && (
<YachtForm
open={createOpen}
onOpenChange={setCreateOpen}
initialOwner={{ type: 'client', id: clientId }}
/>
)}
</div>
);
}

View File

@@ -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 }:
</div>
<div className="space-y-2">
<Label>Yacht</Label>
<div className="flex items-center justify-between">
<Label>Yacht</Label>
{selectedClientId && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setCreateYachtOpen(true)}
>
<Plus className="mr-1 h-3 w-3" />
Add new
</Button>
)}
</div>
<YachtPicker
value={selectedYachtId ?? null}
onChange={(id) => setValue('yachtId', id ?? undefined)}
ownerFilter={
selectedClientId ? { type: 'client', id: selectedClientId } : undefined
}
ownerFilter={yachtOwnerFilter}
disabled={!selectedClientId}
placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'}
/>
<p className="text-xs text-muted-foreground">
Required before the interest can leave the &quot;Open&quot; stage.
{memberCompanyIds.length > 0 && (
<>
{' '}
Includes yachts from {memberCompanyIds.length}{' '}
{memberCompanyIds.length === 1 ? 'member company' : 'member companies'}.
</>
)}
</p>
{/* 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) */}
</div>
</div>
@@ -501,6 +538,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</SheetFooter>
</form>
</SheetContent>
{createYachtOpen && selectedClientId && (
<YachtForm
open={createYachtOpen}
onOpenChange={setCreateYachtOpen}
initialOwner={{ type: 'client', id: selectedClientId }}
/>
)}
</Sheet>
);
}

View File

@@ -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) {
<span className="col-span-2 text-right">Unit Price</span>
<span className="col-span-2 text-right">Total</span>
</div>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{(invoice.lineItems as any[]).map((li) => (
{invoice.lineItems.map((li) => (
<div key={li.id} className="grid grid-cols-12 gap-2 text-sm">
<span className="col-span-6">{li.description}</span>
<span className="col-span-2 text-right tabular-nums">{li.quantity}</span>
@@ -299,8 +336,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<TabsContent value="expenses" className="pt-4">
{invoice.linkedExpenses && invoice.linkedExpenses.length > 0 ? (
<div className="space-y-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{(invoice.linkedExpenses as any[]).map((exp) => (
{invoice.linkedExpenses.map((exp) => (
<div
key={exp.id}
className="flex items-center justify-between p-3 border rounded-md text-sm"

View File

@@ -43,7 +43,7 @@ interface FieldEntry {
}
interface CustomFieldsSectionProps {
entityType: 'client' | 'interest' | 'berth';
entityType: 'client' | 'interest' | 'berth' | 'yacht' | 'company';
entityId: string;
}
@@ -56,10 +56,10 @@ export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectio
// ── Data fetching ──────────────────────────────────────────────────────────
const { data: entries, isLoading } = useQuery<FieldEntry[]>({
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 (
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setCollapsed((c) => !c)}
>
<CardHeader className="cursor-pointer select-none" onClick={() => setCollapsed((c) => !c)}>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Custom Fields</CardTitle>
{collapsed ? (
@@ -127,9 +125,7 @@ export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectio
<FieldControl
key={entry.definition.id}
entry={entry}
onSave={(fieldId, value) =>
mutation.mutate([{ fieldId, value }])
}
onSave={(fieldId, value) => mutation.mutate([{ fieldId, value }])}
/>
))}
</div>
@@ -278,11 +274,7 @@ function BooleanField({
return (
<div className="flex items-center justify-between gap-2">
{label}
<Switch
id={`cf-${definition.id}`}
checked={checked}
onCheckedChange={handleChange}
/>
<Switch id={`cf-${definition.id}`} checked={checked} onCheckedChange={handleChange} />
</div>
);
}

View File

@@ -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<string | null>(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) => {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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),
],

View File

@@ -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),
],
);

View File

@@ -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`),
],
);

View File

@@ -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`),
],
);

View File

@@ -479,6 +479,11 @@ export async function applyParseResults(
const update: Record<string, unknown> = {};
const applied: Array<keyof ExtractedBerthFields> = [];
// 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) => {

View File

@@ -114,7 +114,18 @@ export async function loadRecommenderSettings(portId: string): Promise<Recommend
}
return null;
};
const asBool = (v: unknown): boolean | null => (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;

View File

@@ -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<void> {
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<void> {
// 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<SendResult
// Rate-limit AFTER validation so a typo'd recipient or missing-PDF rep
// doesn't burn a slot on a send that would have failed anyway.
const recipientEmail = await resolveRecipientEmail(input.portId, input.recipient);
if (input.recipient.interestId) {
await assertInterestInPort(input.portId, input.recipient.interestId);
}
// Resolve berth + active version.
const berth = await db.query.berths.findFirst({
@@ -444,6 +463,9 @@ export async function sendBerthPdf(input: SendBerthPdfInput): Promise<SendResult
export async function sendBrochure(input: SendBrochureInput): Promise<SendResult> {
// 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;

View File

@@ -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(

View File

@@ -155,6 +155,16 @@ async function loadStorageConfig(): Promise<StorageConfigSnapshot> {
};
}
/**
* 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);
}