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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user