fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).
CRITICAL (3):
- C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
no longer silently drop interest links
- C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
- C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
callers must go through /stage with the override-guard chain
HIGH (14/15):
- H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
interests/documents/reservations/reminders/invoices (migration 0070)
- H-02 login page reads ?redirect= param with same-origin guard
- H-03 CRM invite token moves to URL fragment so it never lands in
nginx access logs / Referer headers
- H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
- H-05 toggleAccount writes an audit row
- H-06 upsertSetting masks any value whose key ends with _encrypted
- H-07 archiveClient cascade fires per-interest audit rows
- H-08 createSalesTransporter applies SMTP_TIMEOUTS
- H-09 AppShell stable children — viewport flip across breakpoint no
longer destroys in-progress form drafts
- H-10 portal documents page swaps Unicode glyph status icons for
Lucide CheckCircle2/XCircle/Circle + aria-labels
- H-12 list components swap alert(...) for toast.warning(...)
- H-13 5 icon-only buttons gain aria-label
- H-14 parseBody treats empty bodies as {}
- H-15 admin layout renders a 403 panel instead of silent bounce
- H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet
MEDIUM (28+):
- M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
WHEREs across custom-fields, notes (all 6 entity types x update +
delete), client-contacts, yacht ownerClient lookup, webhook reads
- M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
- M-EM01 portal-auth emails thread through portId
- M-EM02 sendEmail accepts cc/bcc params
- M-EM04 notification_digest catalog key
- M-IN01 portal presigned download URLs use 4h TTL
- M-IN02 OpenAI client lazy-instantiated
- M-IN04 stale pdfme refs updated to pdf-lib AcroForm
- M-IN05 umami.testConnection returns tagged union
- M-L01 reservations tenure_type unified with berths
- M-L02 report-generators canonicalize stage values
- M-AU01 audit log placeholder copy fixed
- M-AU04 outcome_set / outcome_cleared distinct audit verbs
- M-NEW-2 activity feed entity name+type separator
- M-R01 portal allowlist narrowed + portal_session backstop in proxy
- M-SC02 companies archived partial index
- M-SC04 audit_logs.searchText documented as DB-managed
- M-S01 storage_s3_access_key_encrypted admin field
- M-U01 audit log empty state uses <EmptyState>
- M-U09 invoice delete dialog -> <AlertDialog>
- M-U10 toast.success on ClientForm + InterestForm create/edit
- M-U11 settings-form-card logo preview alt text
- M-U14 mobile topbar title on clients/yachts/interests/berths
- M-U15 Invoices in mobile More-sheet
LOW (6/8):
- L-AU01 severity defaults for security-relevant verbs
- L-AU02 +13 missing actions in admin audit filter
- L-AU03 +7 missing entity types in admin audit filter
- L-AU04 dead listAuditLogs stubbed
- L-D02 CLAUDE.md Owner-wins chain tightened
Bonus — Document detail polish (#67 partial, 3/6 deliverables):
- state-aware action button per signer
- watcher Add UI with display-name resolution
- cleanSignerName cleanup
Prior session work bundled in:
- Documenso v2 webhook + envelope-ID normalization + sequential signing
- SigningProgress UI redesign (avatars, per-signer state, timestamps)
- env->admin settings registry + RegistryDrivenForm + encrypted creds
- Embedded-signing card + Test connection + setup help
- Dev-mode EMAIL_REDIRECT_TO banner
- Pipeline rules admin page
- Sales email config card
- Audit log details Sheet
- EOI tab: Finalising badge, absolute timestamps, sequential indicator
- Notes pipeline_stage_at_creation (migration 0069)
- Documenso numeric ID dual-key webhook (migration 0068)
- Dimensions criterion copy (migration 0067)
Tests: 1374/1374 vitest pass. tsc clean. lint clean.
See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -120,6 +121,26 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
const [createYachtOpen, setCreateYachtOpen] = useState(false);
|
||||
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
|
||||
|
||||
// Auto-fill pipelineStage + leadCategory based on whether a berth was
|
||||
// picked. Once the rep manually edits either field we stop touching it,
|
||||
// so we don't fight the user. Edit mode skips the auto-fill entirely —
|
||||
// changing the berth on an in-flight interest shouldn't silently demote
|
||||
// it back to "enquiry".
|
||||
const userTouchedStage = useRef(false);
|
||||
const userTouchedCategory = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isEdit) return;
|
||||
const hasBerth = !!selectedBerthId;
|
||||
if (!userTouchedStage.current) {
|
||||
setValue('pipelineStage', hasBerth ? 'qualified' : 'enquiry');
|
||||
}
|
||||
if (!userTouchedCategory.current) {
|
||||
setValue('leadCategory', hasBerth ? 'specific_qualified' : 'general_interest');
|
||||
}
|
||||
// setValue is stable from RHF; isEdit doesn't change after mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedBerthId]);
|
||||
|
||||
function requestClose() {
|
||||
if (isDirty && !isSubmitting && !mutation.isPending) {
|
||||
setDiscardConfirmOpen(true);
|
||||
@@ -146,6 +167,39 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
]
|
||||
: undefined;
|
||||
|
||||
// Probe whether the selected client (or their member companies) owns any
|
||||
// yachts. When zero, the form swaps the picker for an "Add yacht" CTA so
|
||||
// reps don't get stuck on an empty dropdown wondering what to do. We hit
|
||||
// the same autocomplete endpoint the picker uses but with an empty query
|
||||
// to get the full unfiltered list scoped to the owner filter.
|
||||
// Tags-availability probe — drives whether the whole Tags section
|
||||
// (label + picker) renders. The picker itself returns null when empty,
|
||||
// but the wrapping label/separator needed the same gate.
|
||||
const { data: tagsList } = useQuery<{ data: Array<{ id: string }> }>({
|
||||
queryKey: ['tag-availability-for-interest-form'],
|
||||
queryFn: () => apiFetch('/api/v1/tags/options'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const tagsAvailable = (tagsList?.data?.length ?? 0) > 0;
|
||||
|
||||
const { data: yachtCount } = useQuery<{ data: Array<{ id: string }> }>({
|
||||
queryKey: [
|
||||
'yacht-count-for-interest-form',
|
||||
selectedClientId,
|
||||
memberCompanyIds.sort().join(','),
|
||||
],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ q: '' });
|
||||
if (selectedClientId) params.set('ownerClientId', selectedClientId);
|
||||
if (memberCompanyIds.length > 0) {
|
||||
params.set('ownerCompanyIds', memberCompanyIds.join(','));
|
||||
}
|
||||
return apiFetch(`/api/v1/yachts/autocomplete?${params.toString()}`);
|
||||
},
|
||||
enabled: !!selectedClientId,
|
||||
});
|
||||
const hasAnyYachts = (yachtCount?.data?.length ?? 0) > 0;
|
||||
|
||||
const {
|
||||
options: clientOptions,
|
||||
isLoading: clientsLoading,
|
||||
@@ -230,10 +284,27 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
method: 'POST',
|
||||
body: enriched,
|
||||
});
|
||||
// Materialise any additional berths the rep picked in the multi-
|
||||
// select. The first (primary) berth is already linked via the create
|
||||
// payload's berthId; everything else gets a follow-up POST to the
|
||||
// junction endpoint. We fire them in parallel — failure on one is
|
||||
// surfaced as a toast but doesn't roll back the interest creation.
|
||||
if (additionalBerthIds.length > 0) {
|
||||
await Promise.allSettled(
|
||||
additionalBerthIds.map((berthId) =>
|
||||
apiFetch(`/api/v1/interests/${res.data.id}/berths`, {
|
||||
method: 'POST',
|
||||
body: { berthId, isSpecificInterest: false },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
return { id: res.data.id, created: true };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
// M-U10: confirm the write landed.
|
||||
toast.success(result.created ? 'Interest created' : 'Interest updated');
|
||||
onOpenChange(false);
|
||||
// F20: navigate to the new interest's detail page so the rep can
|
||||
// start the workflow immediately. Edits stay in place — no point
|
||||
@@ -254,6 +325,15 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
const selectedClient = clientOptions.find((c) => c.value === selectedClientId);
|
||||
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
|
||||
|
||||
// Additional berths (beyond the primary `berthId`) accumulated by the
|
||||
// multi-select. On create, after the interest row exists, each id here
|
||||
// gets a follow-up POST /interests/{id}/berths so they show up in the
|
||||
// linked-berths list with isPrimary=false. The primary berth (the form's
|
||||
// `berthId`) is materialised by the standard create path. Edit mode
|
||||
// doesn't surface this — managing extra berths post-create happens on
|
||||
// the interest detail page's linked-berths section.
|
||||
const [additionalBerthIds, setAdditionalBerthIds] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
@@ -337,7 +417,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Berth (optional)</Label>
|
||||
<Label>Berths (optional)</Label>
|
||||
<Popover open={berthOpen} onOpenChange={setBerthOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -346,10 +426,20 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
aria-expanded={berthOpen}
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!selectedBerthId && 'text-muted-foreground',
|
||||
!selectedBerthId &&
|
||||
additionalBerthIds.length === 0 &&
|
||||
'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{selectedBerth?.label ?? interest?.berthMooringNumber ?? 'Select berth...'}
|
||||
<span className="truncate">
|
||||
{selectedBerthId
|
||||
? `${selectedBerth?.label ?? interest?.berthMooringNumber ?? selectedBerthId}${
|
||||
additionalBerthIds.length > 0
|
||||
? ` + ${additionalBerthIds.length} more`
|
||||
: ''
|
||||
}`
|
||||
: 'Select berths…'}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -362,43 +452,80 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value=""
|
||||
value="__clear__"
|
||||
onSelect={() => {
|
||||
setValue('berthId', undefined);
|
||||
setBerthOpen(false);
|
||||
setAdditionalBerthIds([]);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
!selectedBerthId ? 'opacity-100' : 'opacity-0',
|
||||
!selectedBerthId && additionalBerthIds.length === 0
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
None
|
||||
</CommandItem>
|
||||
{berthOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(val) => {
|
||||
setValue('berthId', val);
|
||||
setBerthOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedBerthId === option.value ? 'opacity-100' : 'opacity-0',
|
||||
{berthOptions.map((option) => {
|
||||
const isPrimary = selectedBerthId === option.value;
|
||||
const isAdditional = additionalBerthIds.includes(option.value);
|
||||
const isSelected = isPrimary || isAdditional;
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(val) => {
|
||||
// Multi-select toggle. First pick becomes
|
||||
// the primary berthId (the one the API uses
|
||||
// for templates / list views). Subsequent
|
||||
// picks go into additionalBerthIds and are
|
||||
// materialised via POST /berths after the
|
||||
// interest is created.
|
||||
if (isPrimary) {
|
||||
// Demote primary; promote first additional
|
||||
// (if any) to primary so the deal still
|
||||
// has one primary berth.
|
||||
const promote = additionalBerthIds[0];
|
||||
setValue('berthId', promote ?? undefined);
|
||||
setAdditionalBerthIds(additionalBerthIds.slice(1));
|
||||
} else if (isAdditional) {
|
||||
setAdditionalBerthIds(
|
||||
additionalBerthIds.filter((id) => id !== val),
|
||||
);
|
||||
} else if (!selectedBerthId) {
|
||||
setValue('berthId', val);
|
||||
} else {
|
||||
setAdditionalBerthIds([...additionalBerthIds, val]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
isSelected ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1">{option.label}</span>
|
||||
{isPrimary && (
|
||||
<span className="ml-2 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
|
||||
primary
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick one or more berths. The first becomes the primary berth (used in templates and
|
||||
list views); the rest get linked as alternates and can be promoted later from the
|
||||
interest detail page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -406,7 +533,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
<Label>
|
||||
Yacht <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</Label>
|
||||
{selectedClientId && (
|
||||
{selectedClientId && hasAnyYachts && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -419,15 +546,34 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<YachtPicker
|
||||
value={selectedYachtId ?? null}
|
||||
onChange={(id) => setValue('yachtId', id ?? undefined)}
|
||||
ownerFilter={yachtOwnerFilter}
|
||||
disabled={!selectedClientId}
|
||||
placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'}
|
||||
/>
|
||||
{/* Hide the picker entirely when the selected client has no
|
||||
yachts on file (and isn't linked to a company with yachts).
|
||||
An empty dropdown is a dead-end UX — the only useful action
|
||||
in that state is "create a yacht for this client". */}
|
||||
{selectedClientId && !hasAnyYachts ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/40 p-3 text-sm">
|
||||
<p className="text-muted-foreground">This client has no yachts on file yet.</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => setCreateYachtOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden />
|
||||
Add a yacht for this client
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<YachtPicker
|
||||
value={selectedYachtId ?? null}
|
||||
onChange={(id) => setValue('yachtId', id ?? 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 "Open" stage.
|
||||
Required before the interest can leave the New Enquiry stage.
|
||||
{memberCompanyIds.length > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
@@ -450,10 +596,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
<div className="space-y-1">
|
||||
<Label>Stage</Label>
|
||||
<Select
|
||||
value={watch('pipelineStage') ?? 'open'}
|
||||
onValueChange={(v) =>
|
||||
setValue('pipelineStage', v as (typeof PIPELINE_STAGES)[number])
|
||||
}
|
||||
value={watch('pipelineStage') ?? 'enquiry'}
|
||||
onValueChange={(v) => {
|
||||
userTouchedStage.current = true;
|
||||
setValue('pipelineStage', v as (typeof PIPELINE_STAGES)[number]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select stage" />
|
||||
@@ -472,12 +619,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
<Label>Lead Category</Label>
|
||||
<Select
|
||||
value={watch('leadCategory') ?? ''}
|
||||
onValueChange={(v) =>
|
||||
onValueChange={(v) => {
|
||||
userTouchedCategory.current = true;
|
||||
setValue(
|
||||
'leadCategory',
|
||||
v ? (v as (typeof LEAD_CATEGORIES)[number]) : undefined,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
@@ -583,13 +731,19 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
|
||||
</div>
|
||||
{/* Tags — TagPicker itself returns null when the port has no tags
|
||||
configured AND the form has nothing selected. We hide the
|
||||
wrapping label + separator in that same case so an empty
|
||||
"Tags" header doesn't sit in the form. */}
|
||||
{(tagIds.length > 0 || tagsAvailable) && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SheetFooter>
|
||||
<Button type="button" variant="outline" onClick={requestClose}>
|
||||
|
||||
Reference in New Issue
Block a user