feat(post-audit): finish Phase 3 / 4 / 5 / 7 — remaining work

Phase 3 — EOI overrides (now ☑):
- Address override field with the same per-component input UX as the
  canonical address form (line1/line2/city/state/postal + ISO
  subdivision + CountryCombobox). Two-checkbox intent semantics
  identical to email/phone — useOnlyForThisEoi writes only to
  documents.override_client_address_* columns; setAsDefault promotes
  to the canonical client_addresses primary inside the override
  transaction; neither flag inserts a non-primary address row for
  future reuse. eoi-context route now returns available.addresses so
  the dialog can render the picker over existing rows.
- yachts.source_document_id backfill — yachts spawned via EOI run
  BEFORE generateAndSign creates the document row, so source_document_id
  stayed NULL. Mirrored the bounded-recent backfill pattern from
  contacts into persistDocumentOverrides for both client_addresses and
  yachts (every row inserted in the last 60s with NULL source_document_id
  and the right source flag gets attributed).
- Audit-log filter chips for the new verbs — eoi_field_override,
  promote_to_primary, eoi_spawn_yacht now appear in /admin/audit
  dropdown + get human labels in the card view.

Phase 4 — reminders inline section (now ☑):
- New <RemindersInline> shared component shows the 3-5 most recent
  open reminders for an entity. Mounted on Overview tab of yacht /
  client / interest detail. Empty state hints at the header button
  rather than duplicating it.

Phase 5 — email tone (now ☑ across all 8 templates):
- admin-email-change, crm-invite, inquiry-sales-notification,
  residential-inquiry — voice + sign-off match the 4 shipped earlier
  ("Dear X", "With warm regards, The {portName} Team", sentence-case
  subjects). Snapshot tests deferred — they'd need a 2nd-port fixture
  set up to catch port-name leaks; templates are correct in review.

Phase 7 — PDF editor (now ☑):
- 7.1 polish: unsaved-changes guard (beforeunload + "Unsaved changes"
  badge), ResizeObserver-driven responsive PDF width, required-tokens-
  unplaced indicator reading template.mergeFields.
- 7.2 drag-to-move with on-page clamping.
- 7.2 four-corner resize handles with min-size enforcement.
- 7.2 right-click context delete via onContextMenu.
- 7.2 multi-page navigation + per-page marker filter.
- 7.2 live preview endpoint POST /api/v1/document-templates/[id]/preview
  runs the in-app pdf-lib fill against the supplied interest, uploads
  to a transient previews/ key, returns a 15-min presigned URL.
- 7.2 new-PDF upload POST /api/v1/document-templates/[id]/source-pdf
  takes multipart FormData, magic-byte verifies %PDF-, parses page
  count via pdf-lib, swaps documentTemplates.sourceFileId. Editor
  warns when the new page count truncates the prior set.

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 17:09:19 +02:00
parent f938847ed9
commit ef0dc5abc4
18 changed files with 1532 additions and 204 deletions

View File

@@ -35,7 +35,7 @@
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { documents } from '@/lib/db/schema/documents';
import { interests } from '@/lib/db/schema/interests';
import { yachts } from '@/lib/db/schema/yachts';
@@ -50,10 +50,25 @@ export interface FieldOverrideInput {
contactId?: string | null;
}
export interface AddressOverrideInput {
line1?: string;
line2?: string;
city?: string;
subdivisionIso?: string;
postalCode?: string;
countryIso?: string;
useOnlyForThisEoi: boolean;
setAsDefault: boolean;
/** Existing client_addresses.id when the rep picked one from a list;
* null = fresh values typed in the dialog. */
addressId?: string | null;
}
export interface EoiOverridesInput {
clientEmail?: FieldOverrideInput;
clientPhone?: FieldOverrideInput;
yachtName?: FieldOverrideInput;
clientAddress?: AddressOverrideInput;
}
export interface AppliedOverrides {
@@ -62,6 +77,14 @@ export interface AppliedOverrides {
clientEmail?: string;
clientPhone?: string;
yachtName?: string;
clientAddress?: {
line1: string;
line2: string;
city: string;
subdivisionIso: string;
postalCode: string;
countryIso: string;
};
};
/** Columns to write to `documents.override_*` after the doc row exists.
* Empty when every override either ran `setAsDefault` (canonical
@@ -70,6 +93,12 @@ export interface AppliedOverrides {
overrideClientEmail: string;
overrideClientPhone: string;
overrideYachtName: string;
overrideClientAddressLine1: string;
overrideClientAddressLine2: string;
overrideClientCity: string;
overrideClientState: string;
overrideClientPostalCode: string;
overrideClientCountry: string;
}>;
}
@@ -230,30 +259,155 @@ export async function applyEoiOverridesBeforeRender(
resolved.yachtName = value;
}
// One audit row per touched field summarising the override intent.
const auditFields: Array<{ field: string; override: FieldOverrideInput }> = [];
if (overrides.clientEmail)
auditFields.push({ field: 'clientEmail', override: overrides.clientEmail });
if (overrides.clientPhone)
auditFields.push({ field: 'clientPhone', override: overrides.clientPhone });
if (overrides.yachtName)
auditFields.push({ field: 'yachtName', override: overrides.yachtName });
if (overrides.clientAddress) {
const a = overrides.clientAddress;
const resolvedAddr = {
line1: (a.line1 ?? '').trim(),
line2: (a.line2 ?? '').trim(),
city: (a.city ?? '').trim(),
subdivisionIso: (a.subdivisionIso ?? '').trim(),
postalCode: (a.postalCode ?? '').trim(),
countryIso: (a.countryIso ?? '').trim().toUpperCase(),
};
// Treat the address as one logical field — at least line1 + countryIso
// must be present for an EOI to render legally.
if (!resolvedAddr.line1 || !resolvedAddr.countryIso) {
throw new ValidationError('address override requires line1 and countryIso');
}
for (const { field, override } of auditFields) {
if (a.useOnlyForThisEoi) {
documentOverrideColumns.overrideClientAddressLine1 = resolvedAddr.line1;
if (resolvedAddr.line2)
documentOverrideColumns.overrideClientAddressLine2 = resolvedAddr.line2;
if (resolvedAddr.city) documentOverrideColumns.overrideClientCity = resolvedAddr.city;
if (resolvedAddr.subdivisionIso)
documentOverrideColumns.overrideClientState = resolvedAddr.subdivisionIso;
if (resolvedAddr.postalCode)
documentOverrideColumns.overrideClientPostalCode = resolvedAddr.postalCode;
documentOverrideColumns.overrideClientCountry = resolvedAddr.countryIso;
} else if (a.setAsDefault) {
// Promote: demote the prior primary, then either update an existing
// address row (when addressId was provided) or insert a fresh one.
await tx
.update(clientAddresses)
.set({ isPrimary: false, updatedAt: new Date() })
.where(and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)));
if (a.addressId) {
await tx
.update(clientAddresses)
.set({
// client_addresses has no addressLine2 column — concat line1+line2.
streetAddress: resolvedAddr.line2
? `${resolvedAddr.line1}\n${resolvedAddr.line2}`
: resolvedAddr.line1,
city: resolvedAddr.city || null,
subdivisionIso: resolvedAddr.subdivisionIso || null,
postalCode: resolvedAddr.postalCode || null,
countryIso: resolvedAddr.countryIso,
isPrimary: true,
updatedAt: new Date(),
})
.where(
and(eq(clientAddresses.id, a.addressId), eq(clientAddresses.clientId, client.id)),
);
} else {
await tx.insert(clientAddresses).values({
clientId: client.id,
portId: client.portId,
// client_addresses has no addressLine2 column — concat line1+line2.
streetAddress: resolvedAddr.line2
? `${resolvedAddr.line1}\n${resolvedAddr.line2}`
: resolvedAddr.line1,
city: resolvedAddr.city || null,
subdivisionIso: resolvedAddr.subdivisionIso || null,
postalCode: resolvedAddr.postalCode || null,
countryIso: resolvedAddr.countryIso,
isPrimary: true,
source: 'eoi-custom-input',
});
}
// Canonical now matches → documents.override_* stays NULL.
} else {
// Neither flag: persist per-doc + (if no addressId) insert a
// non-primary address row for future reuse.
if (!a.addressId) {
await tx.insert(clientAddresses).values({
clientId: client.id,
portId: client.portId,
streetAddress: resolvedAddr.line2
? `${resolvedAddr.line1}\n${resolvedAddr.line2}`
: resolvedAddr.line1,
city: resolvedAddr.city || null,
subdivisionIso: resolvedAddr.subdivisionIso || null,
postalCode: resolvedAddr.postalCode || null,
countryIso: resolvedAddr.countryIso,
isPrimary: false,
source: 'eoi-custom-input',
});
}
documentOverrideColumns.overrideClientAddressLine1 = resolvedAddr.line1;
if (resolvedAddr.line2)
documentOverrideColumns.overrideClientAddressLine2 = resolvedAddr.line2;
if (resolvedAddr.city) documentOverrideColumns.overrideClientCity = resolvedAddr.city;
if (resolvedAddr.subdivisionIso)
documentOverrideColumns.overrideClientState = resolvedAddr.subdivisionIso;
if (resolvedAddr.postalCode)
documentOverrideColumns.overrideClientPostalCode = resolvedAddr.postalCode;
documentOverrideColumns.overrideClientCountry = resolvedAddr.countryIso;
}
resolved.clientAddress = resolvedAddr;
}
// One audit row per touched field summarising the override intent.
const auditFields: Array<{ field: string; intent: Record<string, unknown> }> = [];
if (overrides.clientEmail)
auditFields.push({
field: 'clientEmail',
intent: {
value: overrides.clientEmail.value.slice(0, 200),
useOnlyForThisEoi: overrides.clientEmail.useOnlyForThisEoi,
setAsDefault: overrides.clientEmail.setAsDefault,
fromContactId: overrides.clientEmail.contactId ?? null,
},
});
if (overrides.clientPhone)
auditFields.push({
field: 'clientPhone',
intent: {
value: overrides.clientPhone.value.slice(0, 200),
useOnlyForThisEoi: overrides.clientPhone.useOnlyForThisEoi,
setAsDefault: overrides.clientPhone.setAsDefault,
fromContactId: overrides.clientPhone.contactId ?? null,
},
});
if (overrides.yachtName)
auditFields.push({
field: 'yachtName',
intent: {
value: overrides.yachtName.value.slice(0, 200),
useOnlyForThisEoi: overrides.yachtName.useOnlyForThisEoi,
setAsDefault: overrides.yachtName.setAsDefault,
},
});
if (overrides.clientAddress)
auditFields.push({
field: 'clientAddress',
intent: {
useOnlyForThisEoi: overrides.clientAddress.useOnlyForThisEoi,
setAsDefault: overrides.clientAddress.setAsDefault,
fromAddressId: overrides.clientAddress.addressId ?? null,
countryIso: overrides.clientAddress.countryIso,
},
});
for (const { field, intent } of auditFields) {
void createAuditLog({
userId: meta.userId,
portId,
action: 'eoi_field_override',
entityType: 'interest',
entityId: interestId,
newValue: {
field,
// Truncate to avoid bloating audit rows with long free-text.
value: override.value.slice(0, 200),
useOnlyForThisEoi: override.useOnlyForThisEoi,
setAsDefault: override.setAsDefault,
fromContactId: override.contactId ?? null,
},
newValue: { field, ...intent },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
@@ -278,27 +432,54 @@ export async function persistDocumentOverrides(
meta: AuditMeta,
): Promise<void> {
const cols = applied.documentOverrideColumns;
if (Object.keys(cols).length === 0) return;
// Even when cols is empty (every override ran setAsDefault), we still
// need to backfill source_document_id on freshly-inserted contact /
// address / yacht rows whose insertion preceded the document row's
// existence. Skip only when applied is the empty default.
const hasResolved = Object.keys(applied.resolved).length > 0;
if (Object.keys(cols).length > 0) {
await db.update(documents).set(cols).where(eq(documents.id, documentId));
} else if (!hasResolved) {
return;
}
await db.update(documents).set(cols).where(eq(documents.id, documentId));
// Backfill source_document_id on any client_contacts rows this run
// inserted. Done outside the override transaction because the
// document id wasn't known yet at that point.
// Backfill source_document_id on freshly-inserted contact + address +
// yacht rows from this generation pass. Bounded by createdAt < 1 min
// so re-runs don't sweep older orphans. Done outside the override
// transaction because the document id wasn't known yet at that point.
await db
.update(clientContacts)
.set({ sourceDocumentId: documentId })
.where(
and(
eq(clientContacts.source, 'eoi-custom-input'),
// Backfill only the recently-inserted rows that haven't been
// attributed yet. Bounded by createdAt so re-runs don't sweep up
// older orphans.
sql`${clientContacts.createdAt} > NOW() - INTERVAL '1 minute'`,
sql`${clientContacts.sourceDocumentId} IS NULL`,
),
);
await db
.update(clientAddresses)
.set({ sourceDocumentId: documentId })
.where(
and(
eq(clientAddresses.source, 'eoi-custom-input'),
sql`${clientAddresses.createdAt} > NOW() - INTERVAL '1 minute'`,
sql`${clientAddresses.sourceDocumentId} IS NULL`,
),
);
// Phase 3 follow-up — yacht spawn from EOI runs BEFORE generateAndSign
// so the yacht row's source_document_id is NULL at insert time. Same
// bounded backfill pattern as contacts.
await db
.update(yachts)
.set({ sourceDocumentId: documentId })
.where(
and(
eq(yachts.source, 'eoi-generated'),
sql`${yachts.createdAt} > NOW() - INTERVAL '1 minute'`,
sql`${yachts.sourceDocumentId} IS NULL`,
),
);
void createAuditLog({
userId: meta.userId,
portId: meta.portId,
@@ -319,7 +500,18 @@ export async function persistDocumentOverrides(
*/
export function applyOverridesToContext<
T extends {
client: { primaryEmail: string | null; primaryPhone: string | null };
client: {
primaryEmail: string | null;
primaryPhone: string | null;
address: {
street: string;
city: string;
subdivision: string;
postalCode: string;
country: string;
countryIso: string;
} | null;
};
yacht: { name: string } | null;
},
>(context: T, applied: AppliedOverrides): T {
@@ -332,5 +524,26 @@ export function applyOverridesToContext<
if (applied.resolved.yachtName !== undefined && context.yacht) {
context.yacht.name = applied.resolved.yachtName;
}
if (applied.resolved.clientAddress) {
const a = applied.resolved.clientAddress;
const combinedStreet = a.line2 ? `${a.line1}\n${a.line2}` : a.line1;
// Strip the country-code prefix from the subdivision ISO so the EOI
// renders the subdivision suffix exactly the way the canonical
// address pipeline does (e.g. 'US-CA' → 'CA').
const subdivisionSuffix = a.subdivisionIso.includes('-')
? a.subdivisionIso.split('-').slice(1).join('-')
: a.subdivisionIso;
context.client.address = {
street: combinedStreet,
city: a.city,
subdivision: subdivisionSuffix,
postalCode: a.postalCode,
// `country` (long name) is only used by the deprecated UI preview
// line; the EOI's Address field renders countryIso, so we set them
// consistently and leave the long-name lookup to the renderer.
country: a.countryIso,
countryIso: a.countryIso,
};
}
return context;
}