feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker

Phase 3b — EOI dialog field overrides:
- New EoiOverridesInput shape (clientEmail / clientPhone / yachtName)
  threaded through generate-and-sign validator + both pathways
  (in-app pdf-lib fill, Documenso template generate).
- src/lib/services/eoi-overrides.service.ts applies side-effects in one
  transaction: useOnlyForThisEoi writes documents.override_* and stops;
  setAsDefault demotes the prior primary + promotes (existing contactId)
  or inserts + promotes (fresh value); neither flag inserts a non-primary
  client_contacts row for future dropdown reuse.
- Document override columns persisted post-insert, with a 1-minute
  source_document_id backfill on freshly inserted contact rows.
- eoi-context route returns available.{emails, phones} so the dialog
  can render combobox options.
- <OverridableContactField> in eoi-generate-dialog.tsx renders the
  combobox + manual input + 2 checkboxes per field with mutually
  exclusive intent semantics.

Phase 3c — yacht spawn from EOI dialog:
- YachtForm gains createExtras + onCreated callbacks; the EOI dialog
  opens it as a nested Sheet pre-filled with the linked client as owner.
  On save the new yacht is stamped source='eoi-generated' and the
  interest is PATCHed with the new yachtId so the EOI context reflows.

Phase 3d — promote-to-primary + audit + [EOI] badge:
- POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary
  (transactional demote+promote via promoteContactToPrimary).
- src/lib/audit.ts AuditAction type adds eoi_field_override,
  promote_to_primary, eoi_spawn_yacht (DB column is free-text).
- ContactsEditor surfaces an [EOI] badge on non-primary rows where
  source='eoi-custom-input'.

Phase 4 — worker + TOD picker:
- processOverdueReminders refactored to UPDATE...RETURNING with a
  fired_at IS NULL gate so parallel workers can't double-fire. Uses
  the idx_reminders_due_unfired partial index from migration 0072.
- /settings gets a "Default reminder time" time-of-day picker; the
  value lands in user_profiles.preferences.digestTimeOfDay (validated
  HH:MM at the route). <ReminderForm> seeds its dueAt from this
  preference via a React-Query me-prefs fetch.

Phase 6 hardening:
- IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste
  of Google Workspace's 16-char App Password formatted as
  "abcd efgh ijkl mnop" still authenticates. Workspace activation
  procedure documented in MASTER-PLAN §Phase 6 (was previously written
  to CLAUDE.md, which was bloat — moved to the plan).

Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 16:18:03 +02:00
parent 503207ef68
commit eaab14943b
20 changed files with 1119 additions and 92 deletions

View File

@@ -28,6 +28,7 @@ import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
import { Input } from '@/components/ui/input';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { YachtForm } from '@/components/yachts/yacht-form';
import { toastError } from '@/lib/api/toast-error';
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
@@ -82,9 +83,37 @@ interface EoiContextResponse {
} | null;
eoiBerthRange: string;
port: { name: string };
/** Phase 3b — every contact row the dialog renders in its
* override comboboxes. Populated by the eoi-context route. */
available: {
emails: Array<{ id: string; value: string; isPrimary: boolean; source: string }>;
phones: Array<{
id: string;
value: string;
isPrimary: boolean;
channel: 'phone' | 'whatsapp';
source: string;
}>;
};
};
}
/**
* Phase 3b — per-field override state captured by the dialog. Sent
* verbatim on the generate-and-sign POST and translated server-side
* into the documents.override_* columns + (optionally) client_contacts
* mutations.
*/
interface FieldOverrideState {
/** When null, no override is active for this field. */
value: string | null;
/** The client_contacts.id the value came from (when picked from the
* combobox). Null = fresh typed value. */
contactId: string | null;
useOnlyForThisEoi: boolean;
setAsDefault: boolean;
}
interface EoiGenerateDialogProps {
interestId: string;
/** Used to wire the "Edit on client" deep-link inside the dialog. */
@@ -122,6 +151,12 @@ export function EoiGenerateDialog({
// (drives off the yacht's `lengthUnit` column). Stored as state so the
// rep can flip ft↔m before generating without losing the underlying data.
const [dimensionUnit, setDimensionUnit] = useState<'ft' | 'm' | null>(null);
// Phase 3b — per-field override state. null entries = no override.
const [emailOverride, setEmailOverride] = useState<FieldOverrideState | null>(null);
const [phoneOverride, setPhoneOverride] = useState<FieldOverrideState | null>(null);
const [yachtNameOverride, setYachtNameOverride] = useState<FieldOverrideState | null>(null);
// Phase 3c — yacht spawn flow.
const [yachtSpawnOpen, setYachtSpawnOpen] = useState(false);
// Resolved EOI context — the actual values the document will be
// auto-filled with. Loaded only while the dialog is open so we don't
@@ -254,14 +289,14 @@ export function EoiGenerateDialog({
await apiFetch(`/api/v1/clients/${ctx.client.id}`, { method: 'PATCH', body });
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
}
async function patchYacht(body: Record<string, unknown>) {
if (!ctx?.yacht) return;
await apiFetch(`/api/v1/yachts/${ctx.yacht.id}`, { method: 'PATCH', body });
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
}
// Required for the EOI's top paragraph (Section 2). Without these
// the document is unsignable, so generation is blocked.
//
// Email is rendered separately below with the Phase 3b override
// controls (combobox + 2 checkboxes), so it's omitted from the row
// array here — but its required-met status still gates `requiredMet`
// via `emailPresent` below.
const required = ctx
? [
{
@@ -274,12 +309,6 @@ export function EoiGenerateDialog({
placeholder: 'Full legal name',
},
},
{
key: 'email',
label: 'Email address',
value: ctx.client.primaryEmail ?? null,
present: !!ctx.client.primaryEmail,
},
{
key: 'address',
// Mirrors the rendered EOI Address field exactly so the rep sees
@@ -313,19 +342,10 @@ export function EoiGenerateDialog({
: [];
// Optional — Section 3 of the EOI. Generation proceeds without them.
// Yacht-name + phone are rendered separately below with Phase 3b
// override controls; the remainder show as straight previews.
const optional = ctx
? [
{
key: 'yacht',
label: 'Yacht name',
value: ctx.yacht?.name ?? null,
edit: ctx.yacht
? {
onSave: async (next: string | null) => await patchYacht({ name: next ?? '' }),
placeholder: 'Yacht name',
}
: undefined,
},
{
key: 'dimensions',
label: `Dimensions (L × W × D, ${effectiveDimensionUnit})`,
@@ -341,15 +361,12 @@ export function EoiGenerateDialog({
label: 'Berth bundle range',
value: ctx.eoiBerthRange || null,
},
{
key: 'phone',
label: 'Phone',
value: ctx.client.primaryPhone ?? null,
},
]
: [];
const requiredMet = required.length > 0 && required.every((r) => r.present);
const emailPresent = ctx ? !!(emailOverride?.value ?? ctx.client.primaryEmail) : false;
const requiredMet =
!!ctx && required.length > 0 && required.every((r) => r.present) && emailPresent;
async function handleGenerate() {
if (!requiredMet) return;
@@ -358,6 +375,25 @@ export function EoiGenerateDialog({
try {
const isDocumenso = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE;
const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`;
// Phase 3b — pack the per-field overrides the rep selected. Each
// is null when untouched; the server validator accepts an absent
// entry and falls back to the canonical record.
const overridePayload = (s: FieldOverrideState | null) =>
s && s.value !== null
? {
value: s.value,
useOnlyForThisEoi: s.useOnlyForThisEoi,
setAsDefault: s.setAsDefault,
...(s.contactId ? { contactId: s.contactId } : {}),
}
: undefined;
const overrides = {
clientEmail: overridePayload(emailOverride),
clientPhone: overridePayload(phoneOverride),
yachtName: overridePayload(yachtNameOverride),
};
const hasAnyOverride = overrides.clientEmail || overrides.clientPhone || overrides.yachtName;
await apiFetch(url, {
method: 'POST',
body: {
@@ -370,6 +406,7 @@ export function EoiGenerateDialog({
// EOI's Length/Width/Draft formValues. Defaults server-side to
// the yacht's own `lengthUnit` column when unspecified.
dimensionUnit: effectiveDimensionUnit,
...(hasAnyOverride ? { overrides } : {}),
},
});
// Bounce every cache that surfaces the interest's EOI state so the
@@ -451,6 +488,15 @@ export function EoiGenerateDialog({
edit={row.edit}
/>
))}
<OverridableContactField
label="Email address"
canonicalValue={ctx.client.primaryEmail ?? null}
canonicalContactId={ctx.available.emails.find((e) => e.isPrimary)?.id ?? null}
options={ctx.available.emails}
override={emailOverride}
onChange={setEmailOverride}
missing={!emailPresent}
/>
</dl>
</div>
<div className="space-y-1 border-t pt-2">
@@ -490,9 +536,39 @@ export function EoiGenerateDialog({
) : null}
</div>
<dl className="space-y-1.5">
<div className="flex items-start gap-2">
<div className="flex-1">
<OverridableContactField
label="Yacht name"
canonicalValue={ctx.yacht?.name ?? null}
canonicalContactId={null}
options={[]}
override={yachtNameOverride}
onChange={setYachtNameOverride}
/>
</div>
{clientId ? (
<button
type="button"
onClick={() => setYachtSpawnOpen(true)}
className="mt-0.5 shrink-0 text-[11px] text-primary hover:underline"
title="Create a new yacht linked to this client"
>
+ New yacht
</button>
) : null}
</div>
{optional.map((row) => (
<PreviewRow key={row.key} label={row.label} value={row.value} edit={row.edit} />
<PreviewRow key={row.key} label={row.label} value={row.value} />
))}
<OverridableContactField
label="Phone"
canonicalValue={ctx.client.primaryPhone ?? null}
canonicalContactId={ctx.available.phones.find((p) => p.isPrimary)?.id ?? null}
options={ctx.available.phones}
override={phoneOverride}
onChange={setPhoneOverride}
/>
</dl>
</div>
{portSlug && clientId && (
@@ -674,6 +750,33 @@ export function EoiGenerateDialog({
</Button>
</SheetFooter>
</SheetContent>
{/* Phase 3c — nested yacht-spawn Sheet. Pre-selects the linked
client as owner so the rep only types the yacht-specific
fields. After save, PATCH the interest with the new yachtId so
the EOI's yacht block populates without a manual re-link. */}
{clientId ? (
<YachtForm
open={yachtSpawnOpen}
onOpenChange={setYachtSpawnOpen}
initialOwner={{ type: 'client', id: clientId }}
createExtras={{ source: 'eoi-generated' }}
onCreated={async (created) => {
try {
await apiFetch(`/api/v1/interests/${interestId}`, {
method: 'PATCH',
body: { yachtId: created.id },
});
await queryClient.invalidateQueries({
queryKey: ['interests', interestId, 'eoi-context'],
});
await queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
} catch (err) {
toastError(err);
}
}}
/>
) : null}
</Sheet>
);
}
@@ -781,3 +884,217 @@ function PreviewRow({
</div>
);
}
/**
* Phase 3b — overridable row for a contact channel (email/phone) or a
* single-value field (yacht name). Renders as a plain text row showing
* the canonical value, with a small "Override" affordance that expands
* into a Select (over `options`) + Input (for fresh values) + the two
* intent checkboxes.
*
* State is owned by the parent dialog so cancellation collapses cleanly
* back to canonical without round-tripping through the row.
*/
function OverridableContactField({
label,
canonicalValue,
canonicalContactId,
options,
override,
onChange,
missing,
}: {
label: string;
canonicalValue: string | null;
/** id of the row that holds `canonicalValue` in `options`. Used to
* pre-select the matching Select item when the user opens override
* mode without changing anything. */
canonicalContactId: string | null;
/** Picker options. For yacht-name pass [] — only the manual text path
* is available. */
options: Array<{ id: string; value: string; isPrimary: boolean }>;
override: FieldOverrideState | null;
onChange: (next: FieldOverrideState | null) => void;
missing?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const [manualValue, setManualValue] = useState('');
// Synthetic combobox value: either an existing contact id, "__manual__"
// for free-text entry, or "__canonical__" for "use the canonical primary".
const selectValue =
override?.contactId ??
(override?.value != null && override?.contactId == null ? '__manual__' : '__canonical__');
// Resolve the displayed effective value for the read-only header (when
// collapsed): show the override if active, otherwise canonical.
const effective = override?.value ?? canonicalValue ?? null;
const collapseAndClear = () => {
setExpanded(false);
setManualValue('');
onChange(null);
};
return (
<div className="space-y-1.5">
<div className="flex items-baseline gap-2 text-sm">
<dt className="w-32 shrink-0 text-xs text-muted-foreground">{label}</dt>
<dd
className={cn(
'flex-1 wrap-break-word inline-flex items-center gap-2',
missing
? 'text-rose-700 font-medium'
: effective
? 'text-foreground'
: 'text-muted-foreground italic',
)}
>
<span className="flex-1">
{effective ?? (missing ? 'Missing — required' : 'Not set')}
{override?.value != null ? (
<span className="ml-1 inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800">
[EOI]
</span>
) : null}
</span>
{!expanded ? (
<button
type="button"
onClick={() => setExpanded(true)}
className="text-[11px] text-primary hover:underline"
>
{override?.value != null ? 'Edit override' : 'Override'}
</button>
) : (
<button
type="button"
onClick={collapseAndClear}
className="text-[11px] text-muted-foreground hover:underline"
>
Clear & close
</button>
)}
</dd>
</div>
{expanded ? (
<div className="ml-32 space-y-2 rounded-md border bg-background/60 p-2">
{options.length > 0 ? (
<Select
value={selectValue}
onValueChange={(v) => {
if (v === '__canonical__') {
onChange(null);
setManualValue('');
return;
}
if (v === '__manual__') {
onChange({
value: manualValue.trim() || null,
contactId: null,
useOnlyForThisEoi: override?.useOnlyForThisEoi ?? false,
setAsDefault: override?.setAsDefault ?? false,
});
return;
}
const picked = options.find((o) => o.id === v);
if (!picked) return;
onChange({
value: picked.value,
contactId: picked.id,
useOnlyForThisEoi: override?.useOnlyForThisEoi ?? false,
setAsDefault: override?.setAsDefault ?? false,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__canonical__">
Use canonical {label.toLowerCase()}
{canonicalContactId && options.find((o) => o.id === canonicalContactId)
? ` (${canonicalValue ?? ''})`
: ''}
</SelectItem>
{options
.filter((o) => !o.isPrimary)
.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.value}
</SelectItem>
))}
<SelectItem value="__manual__">+ Type a new value</SelectItem>
</SelectContent>
</Select>
) : null}
{selectValue === '__manual__' || options.length === 0 ? (
<Input
value={manualValue || override?.value || ''}
placeholder={`New ${label.toLowerCase()}`}
onChange={(e) => {
setManualValue(e.target.value);
onChange({
value: e.target.value.trim() || null,
contactId: null,
useOnlyForThisEoi: override?.useOnlyForThisEoi ?? false,
setAsDefault: override?.setAsDefault ?? false,
});
}}
className="h-8 text-xs"
/>
) : null}
<div className="space-y-1">
<label className="flex items-start gap-2 text-[11px] text-muted-foreground cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={override?.useOnlyForThisEoi ?? false}
disabled={!override || !override.value}
onChange={(e) =>
override &&
onChange({
...override,
useOnlyForThisEoi: e.target.checked,
// Mutually exclusive intent — both true at once doesn't
// make sense (per-doc vs. promote-to-canonical).
setAsDefault: e.target.checked ? false : override.setAsDefault,
})
}
/>
<span>
Use only for this EOI
<span className="block text-[10px]">
Records the deviation on this document; canonical record untouched.
</span>
</span>
</label>
<label className="flex items-start gap-2 text-[11px] text-muted-foreground cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={override?.setAsDefault ?? false}
disabled={!override || !override.value}
onChange={(e) =>
override &&
onChange({
...override,
setAsDefault: e.target.checked,
useOnlyForThisEoi: e.target.checked ? false : override.useOnlyForThisEoi,
})
}
/>
<span>
Set as default for future docs
<span className="block text-[10px]">
Promotes this value to the canonical primary on save.
</span>
</span>
</label>
</div>
</div>
) : null}
</div>
);
}