feat(uat-batch): M43 — form-template bindings + inline field history

Closes plan item 43 (Form-template fields bind to Interest/Client data —
autofill, override-preservation history, dual-surface audit trail).

Phase 1 — Editor:
- New bindable-fields catalog (src/lib/templates/bindable-fields.ts):
  client/yacht/interest paths, each tagged with the entity, column, and
  default input type. Source of truth for what can bind + what
  interest_field_history.field_path strings the writers should use.
- formFieldSchema gains optional bindTo, validated against the catalog
  as an allow-list (no arbitrary paths sneak through).
- form-template-form admin sheet: per-field "Bind to" dropdown grouped
  by entity, auto-derives label/key/type when a binding is picked,
  shows "Autofills from + writes back to {label} . {path}" badge.

Phase 2 — Runtime + history writes:
- supplemental-forms.service.applySubmission already wrote
  interest_field_history rows for client name/email/address from the
  earlier 0081 migration session. Extended to also capture phone +
  yacht (name, length, width, draft) diffs that were silently going
  to the entity without an audit row, and to push insert-path
  overrides for the no-existing-address case.
- Field paths aligned with the bindable-fields catalog so detail-page
  lookups work via exact-match WHERE field_path = ?.

Phase 3 — Inline history surface:
- New /api/v1/clients/[id]/field-history (mirror of the existing
  interests endpoint).
- shared/field-history: FieldHistoryProvider wraps a detail tab and
  fires a single keyed GET; FieldHistoryIcon consumes the context and
  renders a small clock affordance only when at least one override
  exists, opening a popover with the reverse-chrono diff list.
- Client + Interest detail Overview tabs wrapped in the provider;
  EditableRow gains an optional historyPath prop; ContactsEditor
  renders the icon next to the canonical primary email/phone.

1454/1454 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 12:51:39 +02:00
parent be261f3f90
commit 91be0f9136
9 changed files with 1084 additions and 454 deletions

View File

@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { and, desc, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { interestFieldHistory } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
/**
* GET /api/v1/clients/[id]/field-history
*
* Returns every supplemental-form override that touched the client (rolling
* up across all of their interests), newest first. Powers the inline clock
* icon + popover on Client detail. Mirrors /interests/[id]/field-history.
*/
export const GET = withAuth(
withPermission('clients', 'view', async (_req, ctx, params) => {
try {
const rows = await db
.select()
.from(interestFieldHistory)
.where(
and(
eq(interestFieldHistory.portId, ctx.portId),
eq(interestFieldHistory.clientId, params.id!),
),
)
.orderBy(desc(interestFieldHistory.createdAt))
.limit(100);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -13,14 +13,24 @@ import { Textarea } from '@/components/ui/textarea';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import type { FormField } from '@/lib/validators/form-templates'; import type { FormField } from '@/lib/validators/form-templates';
import {
bindableFieldsByEntity,
getBindableField,
type BindableType,
} from '@/lib/templates/bindable-fields';
const BIND_TO_NONE = '__none__';
interface FormTemplate { interface FormTemplate {
id: string; id: string;
@@ -103,6 +113,41 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f))); setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f)));
} }
function changeBinding(idx: number, raw: string) {
if (raw === BIND_TO_NONE) {
// Clear the binding but leave the rest of the field untouched —
// admins may want to keep a custom field that no longer autofills.
setFields((prev) =>
prev.map((f, i) => {
if (i !== idx) return f;
const { bindTo, ...rest } = f;
void bindTo;
return rest;
}),
);
return;
}
const meta = getBindableField(raw);
if (!meta) return;
// Adopt the binding + auto-derive input type and label when the admin
// hasn't typed one yet (saves repetitive data entry on the common
// "bind to client email" flow). Pre-existing labels are preserved so
// admins can override the friendly name per template.
setFields((prev) =>
prev.map((f, i) =>
i === idx
? {
...f,
bindTo: raw,
type: coerceFieldType(meta.inputType, f.type),
label: f.label.trim() ? f.label : meta.label,
key: f.key.trim() ? f.key : meta.path.split('.').pop()!,
}
: f,
),
);
}
function addField() { function addField() {
setFields((prev) => [...prev, { ...DEFAULT_FIELD }]); setFields((prev) => [...prev, { ...DEFAULT_FIELD }]);
} }
@@ -158,6 +203,40 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
</Button> </Button>
)} )}
</div> </div>
<div className="space-y-1">
<Label className="text-xs">Bind to (autofill + write-back)</Label>
<Select
value={f.bindTo ?? BIND_TO_NONE}
onValueChange={(v) => changeBinding(i, v)}
>
<SelectTrigger>
<SelectValue placeholder="No binding (free-form)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BIND_TO_NONE}>
No binding - store in submission only
</SelectItem>
{bindableFieldsByEntity().map((group) => (
<SelectGroup key={group.entity}>
<SelectLabel>{group.label}</SelectLabel>
{group.fields.map((bf) => (
<SelectItem key={bf.path} value={bf.path}>
{bf.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
{f.bindTo ? (
<Badge variant="secondary" className="text-[10px] font-normal">
Autofills from + writes back to {getBindableField(f.bindTo)?.label} ·{' '}
{f.bindTo}
</Badge>
) : null}
</div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">Key (no spaces)</Label> <Label className="text-xs">Key (no spaces)</Label>
@@ -240,3 +319,18 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
</Sheet> </Sheet>
); );
} }
/**
* Map a bindable column's natural input type onto the form-field types we
* actually render. When binding to a `number` column we still let the
* admin keep `select` if they'd already chosen it (e.g. they want to
* constrain to specific values) — same for `textarea`.
*/
function coerceFieldType(
bindableType: BindableType,
currentType: FormField['type'],
): FormField['type'] {
if (currentType === 'select' || currentType === 'checkbox') return currentType;
if (currentType === 'textarea' && bindableType === 'text') return 'textarea';
return bindableType;
}

View File

@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout'; import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { InlineCountryField } from '@/components/shared/inline-country-field'; import { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field'; import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { RemindersInline } from '@/components/reminders/reminders-inline'; import { RemindersInline } from '@/components/reminders/reminders-inline';
@@ -56,11 +57,25 @@ function useClientPatch(clientId: string) {
}); });
} }
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) { function EditableRow({
label,
children,
historyPath,
}: {
label: string;
children: React.ReactNode;
/** When set, renders a clock icon (if any override rows exist) that
* opens the field-history popover. The icon component renders nothing
* when the field has no history, so it's safe to pass on every row. */
historyPath?: string;
}) {
return ( return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center"> <div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt> <dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd> <dd className="flex-1 min-w-0 flex items-center gap-1">
<div className="flex-1 min-w-0">{children}</div>
{historyPath ? <FieldHistoryIcon fieldPath={historyPath} /> : null}
</dd>
</div> </div>
); );
} }
@@ -135,6 +150,7 @@ function OverviewTab({
}; };
return ( return (
<FieldHistoryProvider scope={{ type: 'client', id: clientId }}>
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-xl border border-border bg-card p-4 shadow-sm"> <div className="rounded-xl border border-border bg-card p-4 shadow-sm">
<ClientPipelineSummary clientId={clientId} variant="panel" /> <ClientPipelineSummary clientId={clientId} variant="panel" />
@@ -145,7 +161,7 @@ function OverviewTab({
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Personal Information</h3> <h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl> <dl>
<EditableRow label="Full Name"> <EditableRow label="Full Name" historyPath="client.fullName">
<InlineEditableField value={client.fullName} onSave={save('fullName')} /> <InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow> </EditableRow>
<EditableRow label="Country"> <EditableRow label="Country">
@@ -230,6 +246,7 @@ function OverviewTab({
<RemindersInline clientId={clientId} /> <RemindersInline clientId={clientId} />
</div> </div>
</div> </div>
</FieldHistoryProvider>
); );
} }

View File

@@ -16,6 +16,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryIcon } from '@/components/shared/field-history';
import { InlinePhoneField } from '@/components/shared/inline-phone-field'; import { InlinePhoneField } from '@/components/shared/inline-phone-field';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useConfirmation } from '@/hooks/use-confirmation'; import { useConfirmation } from '@/hooks/use-confirmation';
@@ -199,6 +200,7 @@ function ContactRow({
<ChannelPicker value={contact.channel} onChange={changeChannel}> <ChannelPicker value={contact.channel} onChange={changeChannel}>
<Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden /> <Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
</ChannelPicker> </ChannelPicker>
<div className="min-w-0 flex-1 flex items-center gap-1">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? ( {contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
<InlinePhoneField <InlinePhoneField
@@ -226,6 +228,17 @@ function ContactRow({
/> />
)} )}
</div> </div>
{/* Override history is only meaningful for the canonical "primary
email" / "primary phone" entries the supplemental form
overwrites — secondary contacts don't have a matching
bindable path. The icon renders nothing when no rows exist. */}
{contact.isPrimary && contact.channel === 'email' ? (
<FieldHistoryIcon fieldPath="client.primaryEmail" />
) : null}
{contact.isPrimary && (contact.channel === 'phone' || contact.channel === 'whatsapp') ? (
<FieldHistoryIcon fieldPath="client.primaryPhone" />
) : null}
</div>
</div> </div>
{/* Bottom / right: tag + actions. {/* Bottom / right: tag + actions.

View File

@@ -19,6 +19,7 @@ import {
} from '@/components/ui/accordion'; } from '@/components/ui/accordion';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { ClientChannelEditor } from '@/components/clients/client-channel-editor'; import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RemindersInline } from '@/components/reminders/reminders-inline'; import { RemindersInline } from '@/components/reminders/reminders-inline';
@@ -222,11 +223,26 @@ function useStageMutation(interestId: string) {
}); });
} }
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) { function EditableRow({
label,
children,
historyPath,
}: {
label: string;
children: React.ReactNode;
/** When set, renders a clock icon (when at least one override row
* exists for this path on the surrounding FieldHistoryProvider scope)
* that opens the field-history popover. The icon renders nothing
* without history, so it's safe to pass on every row. */
historyPath?: string;
}) {
return ( return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center"> <div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt> <dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd> <dd className="flex-1 min-w-0 flex items-center gap-1">
<div className="flex-1 min-w-0">{children}</div>
{historyPath ? <FieldHistoryIcon fieldPath={historyPath} /> : null}
</dd>
</div> </div>
); );
} }
@@ -977,6 +993,7 @@ function OverviewTab({
const futureMilestones = milestones.filter((m) => m.phase === 'future'); const futureMilestones = milestones.filter((m) => m.phase === 'future');
return ( return (
<FieldHistoryProvider scope={{ type: 'interest', id: interestId }}>
<div className="space-y-6"> <div className="space-y-6">
{/* Skip-ahead nudge - informational only; fires when the deal jumped {/* Skip-ahead nudge - informational only; fires when the deal jumped
past a milestone without stamping the matching date. */} past a milestone without stamping the matching date. */}
@@ -1147,7 +1164,7 @@ function OverviewTab({
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3> <h3 className="text-sm font-medium mb-2">Contact</h3>
<dl> <dl>
<EditableRow label="Email"> <EditableRow label="Email" historyPath="client.primaryEmail">
{interest.clientId ? ( {interest.clientId ? (
<ClientChannelEditor <ClientChannelEditor
clientId={interest.clientId} clientId={interest.clientId}
@@ -1160,7 +1177,7 @@ function OverviewTab({
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</EditableRow> </EditableRow>
<EditableRow label="Phone"> <EditableRow label="Phone" historyPath="client.primaryPhone">
{interest.clientId ? ( {interest.clientId ? (
<ClientChannelEditor <ClientChannelEditor
clientId={interest.clientId} clientId={interest.clientId}
@@ -1182,8 +1199,8 @@ function OverviewTab({
</> </>
) : ( ) : (
<p className="mt-1 text-xs text-muted-foreground italic"> <p className="mt-1 text-xs text-muted-foreground italic">
No contact activity logged yet - log a call, email, or meeting from the Contact log No contact activity logged yet - log a call, email, or meeting from the Contact
tab to start tracking. log tab to start tracking.
</p> </p>
)} )}
{interest.reservationStatus ? ( {interest.reservationStatus ? (
@@ -1245,7 +1262,9 @@ function OverviewTab({
<EditableRow label={`Desired width (${unitLabel})`}> <EditableRow label={`Desired width (${unitLabel})`}>
<InlineEditableField <InlineEditableField
value={ value={
unitIsM ? (interest.desiredWidthM ?? null) : (interest.desiredWidthFt ?? null) unitIsM
? (interest.desiredWidthM ?? null)
: (interest.desiredWidthFt ?? null)
} }
onSave={onSavePair( onSave={onSavePair(
unitIsM ? 'desiredWidthM' : 'desiredWidthFt', unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
@@ -1258,7 +1277,9 @@ function OverviewTab({
<EditableRow label={`Desired draft (${unitLabel})`}> <EditableRow label={`Desired draft (${unitLabel})`}>
<InlineEditableField <InlineEditableField
value={ value={
unitIsM ? (interest.desiredDraftM ?? null) : (interest.desiredDraftFt ?? null) unitIsM
? (interest.desiredDraftM ?? null)
: (interest.desiredDraftFt ?? null)
} }
onSave={onSavePair( onSave={onSavePair(
unitIsM ? 'desiredDraftM' : 'desiredDraftFt', unitIsM ? 'desiredDraftM' : 'desiredDraftFt',
@@ -1393,6 +1414,7 @@ function OverviewTab({
onOpenChange={setEoiGenerateOpen} onOpenChange={setEoiGenerateOpen}
/> />
</div> </div>
</FieldHistoryProvider>
); );
} }

View File

@@ -0,0 +1,170 @@
'use client';
/**
* Inline field-history surface for detail pages.
*
* The pattern:
*
* 1. Wrap the detail page (or just the section containing editable
* fields) in <FieldHistoryProvider scope={{ type, id }} />. The
* provider fires a single GET for every recorded override, keyed
* by entity, and exposes a Map<fieldPath, HistoryRow[]> via context.
*
* 2. Pass `historyPath` to any InlineEditableField that maps to a
* bindable path (see src/lib/templates/bindable-fields.ts). The
* field renders a small clock icon next to the value when at least
* one history row exists for that path. Click → popover with the
* reverse-chronological diff list.
*
* Fields without `historyPath`, or fields whose path has zero history
* rows, render nothing extra — the surface is purely additive.
*/
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Clock } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { apiFetch } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { getBindableField } from '@/lib/templates/bindable-fields';
import { cn } from '@/lib/utils';
export interface FieldHistoryScope {
type: 'interest' | 'client';
id: string;
}
export interface FieldHistoryRow {
id: string;
fieldPath: string;
oldValue: unknown;
newValue: unknown;
source: string;
createdAt: string;
}
interface ContextValue {
byPath: Map<string, FieldHistoryRow[]>;
isLoading: boolean;
}
const FieldHistoryContext = createContext<ContextValue>({
byPath: new Map(),
isLoading: false,
});
interface ProviderProps {
scope: FieldHistoryScope | null;
children: ReactNode;
}
export function FieldHistoryProvider({ scope, children }: ProviderProps) {
const { data, isLoading } = useQuery({
enabled: scope !== null,
queryKey: ['field-history', scope?.type, scope?.id],
queryFn: async () => {
if (!scope) return [] as FieldHistoryRow[];
const url =
scope.type === 'interest'
? `/api/v1/interests/${scope.id}/field-history`
: `/api/v1/clients/${scope.id}/field-history`;
const res = await apiFetch<{ data: FieldHistoryRow[] }>(url);
return res.data;
},
// Field history is small + read-mostly. 30s is plenty fresh for the
// common case where the rep edits a field and wants to see the
// history reflect immediately — react-query refetches on focus.
staleTime: 30_000,
});
const byPath = useMemo(() => {
const map = new Map<string, FieldHistoryRow[]>();
for (const row of data ?? []) {
const arr = map.get(row.fieldPath) ?? [];
arr.push(row);
map.set(row.fieldPath, arr);
}
return map;
}, [data]);
return (
<FieldHistoryContext.Provider value={{ byPath, isLoading }}>
{children}
</FieldHistoryContext.Provider>
);
}
interface IconProps {
fieldPath: string;
className?: string;
}
/**
* Renders nothing when the field has no history. When at least one
* override row exists, renders a small clock button that opens a
* popover with the diff timeline.
*/
export function FieldHistoryIcon({ fieldPath, className }: IconProps) {
const { byPath } = useContext(FieldHistoryContext);
const rows = byPath.get(fieldPath);
if (!rows || rows.length === 0) return null;
const meta = getBindableField(fieldPath);
const label = meta?.label ?? fieldPath;
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('h-5 w-5 text-muted-foreground hover:text-foreground', className)}
aria-label={`Field history for ${label}`}
title={`${rows.length} override${rows.length === 1 ? '' : 's'}`}
>
<Clock className="h-3 w-3" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-0">
<div className="border-b px-3 py-2">
<div className="text-xs font-medium">{label}</div>
<div className="text-[10px] text-muted-foreground">
{rows.length} override{rows.length === 1 ? '' : 's'}
</div>
</div>
<ScrollArea className="max-h-72">
<ul className="divide-y text-xs">
{rows.map((r) => (
<li key={r.id} className="px-3 py-2 space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">{formatSource(r.source)}</span>
<span className="text-[10px] text-muted-foreground">
{formatDistanceToNow(new Date(r.createdAt), { addSuffix: true })}
</span>
</div>
<div className="text-muted-foreground">
<span className="line-through">{formatValue(r.oldValue)}</span>
<span className="mx-1"></span>
<span className="text-foreground">{formatValue(r.newValue)}</span>
</div>
</li>
))}
</ul>
</ScrollArea>
</PopoverContent>
</Popover>
);
}
function formatValue(v: unknown): string {
if (v === null || v === undefined) return '(empty)';
if (typeof v === 'string') return v.length > 80 ? `${v.slice(0, 77)}` : v;
return JSON.stringify(v);
}
function formatSource(source: string): string {
if (source === 'supplemental_form') return 'Supplemental info form';
return source;
}

View File

@@ -337,6 +337,23 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
countryIso: input.country ?? null, countryIso: input.country ?? null,
isPrimary: true, isPrimary: true,
}); });
// Insert-path: every populated field is a "from null → value"
// override so the history panel surfaces the initial population
// the same way it surfaces later edits.
if (input.address) {
overrides.push({
fieldPath: 'client.address.streetAddress',
oldValue: null,
newValue: input.address,
});
}
if (input.country) {
overrides.push({
fieldPath: 'client.address.countryIso',
oldValue: null,
newValue: input.country,
});
}
} else { } else {
const addrPatch: Record<string, unknown> = {}; const addrPatch: Record<string, unknown> = {};
if (input.address && input.address !== existingAddr.streetAddress) { if (input.address && input.address !== existingAddr.streetAddress) {
@@ -407,7 +424,17 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
valueCountry: input.phoneCountry, valueCountry: input.phoneCountry,
isPrimary: true, isPrimary: true,
}); });
overrides.push({
fieldPath: 'client.primaryPhone',
oldValue: null,
newValue: input.phoneE164,
});
} else if (existing.valueE164 !== input.phoneE164) { } else if (existing.valueE164 !== input.phoneE164) {
overrides.push({
fieldPath: 'client.primaryPhone',
oldValue: existing.valueE164 ?? existing.value,
newValue: input.phoneE164,
});
await tx await tx
.update(clientContacts) .update(clientContacts)
.set({ .set({
@@ -425,11 +452,42 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
where: eq(interests.id, row.interestId), where: eq(interests.id, row.interestId),
}); });
if (interest?.yachtId && (input.yachtName || input.yachtLengthFt)) { if (interest?.yachtId && (input.yachtName || input.yachtLengthFt)) {
const existingYacht = await tx.query.yachts.findFirst({
where: eq(yachts.id, interest.yachtId),
});
const yachtPatch: Record<string, unknown> = {}; const yachtPatch: Record<string, unknown> = {};
if (input.yachtName) yachtPatch.name = input.yachtName; if (input.yachtName && input.yachtName !== existingYacht?.name) {
if (input.yachtLengthFt !== null) yachtPatch.lengthFt = String(input.yachtLengthFt); yachtPatch.name = input.yachtName;
if (input.yachtWidthFt !== null) yachtPatch.widthFt = String(input.yachtWidthFt); overrides.push({
if (input.yachtDraftFt !== null) yachtPatch.draftFt = String(input.yachtDraftFt); fieldPath: 'yacht.name',
oldValue: existingYacht?.name ?? null,
newValue: input.yachtName,
});
}
if (input.yachtLengthFt !== null && String(input.yachtLengthFt) !== existingYacht?.lengthFt) {
yachtPatch.lengthFt = String(input.yachtLengthFt);
overrides.push({
fieldPath: 'yacht.lengthFt',
oldValue: existingYacht?.lengthFt ?? null,
newValue: String(input.yachtLengthFt),
});
}
if (input.yachtWidthFt !== null && String(input.yachtWidthFt) !== existingYacht?.widthFt) {
yachtPatch.widthFt = String(input.yachtWidthFt);
overrides.push({
fieldPath: 'yacht.widthFt',
oldValue: existingYacht?.widthFt ?? null,
newValue: String(input.yachtWidthFt),
});
}
if (input.yachtDraftFt !== null && String(input.yachtDraftFt) !== existingYacht?.draftFt) {
yachtPatch.draftFt = String(input.yachtDraftFt);
overrides.push({
fieldPath: 'yacht.draftFt',
oldValue: existingYacht?.draftFt ?? null,
newValue: String(input.yachtDraftFt),
});
}
if (Object.keys(yachtPatch).length > 0) { if (Object.keys(yachtPatch).length > 0) {
await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId)); await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId));
} }

View File

@@ -0,0 +1,206 @@
/**
* Catalog of fields a form-template can bind to on Interest / Client / Yacht.
*
* Each entry maps a dot-path token (e.g. `client.email`) to:
* - the entity table whose row should be read/written
* - the column on that table
* - the form input type that best matches the column
* - a human label shown in the admin "Bind to" picker
*
* The form-template editor uses this as an allow-list (a field whose
* `bindTo` isn't in the catalog is rejected by the validator). The
* supplemental-form runtime uses it twice: at load time to prefill the
* public form with the entity's current value, and at submission time to
* route each posted answer back to the correct table+column with an
* `interest_field_history` row capturing the override.
*
* Entity scoping rules:
* - `interest.*` → write to `interests` row resolved from the token
* - `client.*` → write to `clients` row resolved from interest.clientId
* - `client_address.*` → write to first `client_addresses` row (or insert)
* - `yacht.*` → write to the interest's linked yacht (if present)
*
* Not in the catalog (intentional):
* - Polymorphic ownership columns (yacht.current_owner_*) — needs
* ownership-flow service, not a flat write.
* - Anything on companies (M43 first pass scopes to client+yacht).
*/
export type BindableType = 'text' | 'textarea' | 'email' | 'phone' | 'number';
export interface BindableField {
/** Stable dot-path. The form template stores this verbatim in `field.bindTo`. */
path: string;
/** Human label shown in the admin picker + the field-history popover. */
label: string;
/** Entity bucket — drives the picker grouping + write routing. */
entity: 'interest' | 'client' | 'client_address' | 'yacht';
/** Column on the entity's row. */
column: string;
/** Default form-input type when the binding is set (the editor still lets the admin override). */
inputType: BindableType;
}
/**
* Path naming convention:
* - `client.<column>` — top-level clients row
* - `client.primaryEmail|primaryPhone` — synthesised over client_contacts
* - `client.address.<column>` — the primary client_addresses row
* - `yacht.<column>` — interest's linked yachts row
* - `interest.<column>` — interests row resolved from the token
*
* `interest_field_history.field_path` stores these strings verbatim, so the
* detail-page history popover can `WHERE field_path = ?` to surface the
* inline clock icon for the matching InlineEditableField.
*/
export const BINDABLE_FIELDS: readonly BindableField[] = [
// ─── Client (top-level identity) ─────────────────────────────────
{
path: 'client.fullName',
label: 'Full name',
entity: 'client',
column: 'fullName',
inputType: 'text',
},
{
path: 'client.primaryEmail',
label: 'Primary email',
entity: 'client',
column: 'primaryEmail',
inputType: 'email',
},
{
path: 'client.primaryPhone',
label: 'Primary phone',
entity: 'client',
column: 'primaryPhone',
inputType: 'phone',
},
{
path: 'client.nationality',
label: 'Nationality',
entity: 'client',
column: 'nationality',
inputType: 'text',
},
// ─── Client address (single canonical row) ───────────────────────
{
path: 'client.address.streetAddress',
label: 'Street address',
entity: 'client_address',
column: 'streetAddress',
inputType: 'text',
},
{
path: 'client.address.city',
label: 'City',
entity: 'client_address',
column: 'city',
inputType: 'text',
},
{
path: 'client.address.postalCode',
label: 'Postal code',
entity: 'client_address',
column: 'postalCode',
inputType: 'text',
},
{
path: 'client.address.countryIso',
label: 'Country',
entity: 'client_address',
column: 'countryIso',
inputType: 'text',
},
// ─── Yacht ───────────────────────────────────────────────────────
{ path: 'yacht.name', label: 'Yacht name', entity: 'yacht', column: 'name', inputType: 'text' },
{
path: 'yacht.hullNumber',
label: 'Hull number',
entity: 'yacht',
column: 'hullNumber',
inputType: 'text',
},
{
path: 'yacht.registration',
label: 'Registration',
entity: 'yacht',
column: 'registration',
inputType: 'text',
},
{ path: 'yacht.flag', label: 'Flag', entity: 'yacht', column: 'flag', inputType: 'text' },
{
path: 'yacht.yearBuilt',
label: 'Year built',
entity: 'yacht',
column: 'yearBuilt',
inputType: 'number',
},
{
path: 'yacht.lengthFt',
label: 'Length (ft)',
entity: 'yacht',
column: 'lengthFt',
inputType: 'number',
},
{
path: 'yacht.widthFt',
label: 'Beam (ft)',
entity: 'yacht',
column: 'widthFt',
inputType: 'number',
},
{
path: 'yacht.draftFt',
label: 'Draft (ft)',
entity: 'yacht',
column: 'draftFt',
inputType: 'number',
},
// ─── Interest (deal-level free-form) ─────────────────────────────
{
path: 'interest.notes',
label: 'Additional notes',
entity: 'interest',
column: 'notes',
inputType: 'textarea',
},
] as const;
const BINDABLE_BY_PATH = new Map(BINDABLE_FIELDS.map((f) => [f.path, f]));
export function getBindableField(path: string | null | undefined): BindableField | null {
if (!path) return null;
return BINDABLE_BY_PATH.get(path) ?? null;
}
export function isBindablePath(path: string): boolean {
return BINDABLE_BY_PATH.has(path);
}
export const BINDABLE_PATHS: readonly string[] = BINDABLE_FIELDS.map((f) => f.path);
/**
* Grouped form for the admin picker. Returns entries in the order entities
* should appear in the dropdown (Client, then Yacht, then Interest, then
* address — most-frequent first).
*/
export function bindableFieldsByEntity(): Array<{
entity: BindableField['entity'];
label: string;
fields: readonly BindableField[];
}> {
const buckets: Array<{ entity: BindableField['entity']; label: string }> = [
{ entity: 'client', label: 'Client' },
{ entity: 'client_address', label: 'Client address' },
{ entity: 'yacht', label: 'Yacht' },
{ entity: 'interest', label: 'Interest' },
];
return buckets.map((b) => ({
...b,
fields: BINDABLE_FIELDS.filter((f) => f.entity === b.entity),
}));
}

View File

@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { BINDABLE_PATHS } from '@/lib/templates/bindable-fields';
export const formFieldSchema = z.object({ export const formFieldSchema = z.object({
key: z.string().min(1).max(80), key: z.string().min(1).max(80),
label: z.string().min(1).max(200), label: z.string().min(1).max(200),
@@ -7,6 +9,19 @@ export const formFieldSchema = z.object({
required: z.boolean().optional().default(false), required: z.boolean().optional().default(false),
options: z.array(z.string()).optional(), options: z.array(z.string()).optional(),
helpText: z.string().optional(), helpText: z.string().optional(),
/**
* Optional binding to an Interest/Client/Yacht column. When set, the
* supplemental-form runtime prefills the field from the linked entity
* AND writes the submitted value back to that column on apply (with an
* `interest_field_history` row capturing the override). Validated
* against `BINDABLE_FIELDS` so unknown paths can't sneak in.
*/
bindTo: z
.string()
.refine((v) => BINDABLE_PATHS.includes(v), {
message: 'Unknown bindable path',
})
.optional(),
}); });
export const createFormTemplateSchema = z.object({ export const createFormTemplateSchema = z.object({