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:
@@ -337,6 +337,23 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
|
||||
countryIso: input.country ?? null,
|
||||
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 {
|
||||
const addrPatch: Record<string, unknown> = {};
|
||||
if (input.address && input.address !== existingAddr.streetAddress) {
|
||||
@@ -407,7 +424,17 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
|
||||
valueCountry: input.phoneCountry,
|
||||
isPrimary: true,
|
||||
});
|
||||
overrides.push({
|
||||
fieldPath: 'client.primaryPhone',
|
||||
oldValue: null,
|
||||
newValue: input.phoneE164,
|
||||
});
|
||||
} else if (existing.valueE164 !== input.phoneE164) {
|
||||
overrides.push({
|
||||
fieldPath: 'client.primaryPhone',
|
||||
oldValue: existing.valueE164 ?? existing.value,
|
||||
newValue: input.phoneE164,
|
||||
});
|
||||
await tx
|
||||
.update(clientContacts)
|
||||
.set({
|
||||
@@ -425,11 +452,42 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
|
||||
where: eq(interests.id, row.interestId),
|
||||
});
|
||||
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> = {};
|
||||
if (input.yachtName) yachtPatch.name = input.yachtName;
|
||||
if (input.yachtLengthFt !== null) yachtPatch.lengthFt = String(input.yachtLengthFt);
|
||||
if (input.yachtWidthFt !== null) yachtPatch.widthFt = String(input.yachtWidthFt);
|
||||
if (input.yachtDraftFt !== null) yachtPatch.draftFt = String(input.yachtDraftFt);
|
||||
if (input.yachtName && input.yachtName !== existingYacht?.name) {
|
||||
yachtPatch.name = input.yachtName;
|
||||
overrides.push({
|
||||
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) {
|
||||
await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId));
|
||||
}
|
||||
|
||||
206
src/lib/templates/bindable-fields.ts
Normal file
206
src/lib/templates/bindable-fields.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BINDABLE_PATHS } from '@/lib/templates/bindable-fields';
|
||||
|
||||
export const formFieldSchema = z.object({
|
||||
key: z.string().min(1).max(80),
|
||||
label: z.string().min(1).max(200),
|
||||
@@ -7,6 +9,19 @@ export const formFieldSchema = z.object({
|
||||
required: z.boolean().optional().default(false),
|
||||
options: z.array(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({
|
||||
|
||||
Reference in New Issue
Block a user