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:
@@ -27,19 +27,19 @@ function AdminEmailChangeBody({
|
||||
loginUrl,
|
||||
accent,
|
||||
}: AdminEmailChangeData & { portName: string; accent: string }) {
|
||||
const greeting = recipientName ? `Hello ${recipientName},` : 'Hello,';
|
||||
const greeting = recipientName ? `Dear ${recipientName},` : 'Hello,';
|
||||
const adminLine = changedByDisplayName
|
||||
? `${changedByDisplayName} (an administrator)`
|
||||
: 'an administrator';
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
Your sign-in email was changed
|
||||
Your sign-in email has changed
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
{adminLine} just updated the email address linked to your {portName} account. From now on,
|
||||
please sign in with the new address below:
|
||||
We're writing to let you know that {adminLine} has updated the email address linked to
|
||||
your {portName} account. Going forward, please sign in with the address below:
|
||||
</Text>
|
||||
<Text style={{ margin: '20px 0', textAlign: 'center', fontSize: '16px' }}>
|
||||
<strong>{newEmail}</strong>
|
||||
@@ -65,13 +65,13 @@ function AdminEmailChangeBody({
|
||||
) : null}
|
||||
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
|
||||
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
|
||||
If you weren't expecting this change, contact your administrator immediately. Your old
|
||||
address (the one this message was sent to) can no longer be used to sign in.
|
||||
If this change wasn't expected, please contact your administrator straight away. The
|
||||
previous address (where this message was delivered) is no longer accepted for sign-in.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Thanks,
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>{portName}</strong>
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
@@ -84,7 +84,7 @@ export async function adminEmailChangeEmail(
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `An administrator updated your ${portName} sign-in email`;
|
||||
: `Your ${portName} sign-in email has been updated`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(
|
||||
@@ -95,14 +95,17 @@ export async function adminEmailChangeEmail(
|
||||
);
|
||||
|
||||
const text = [
|
||||
`Your sign-in email was changed`,
|
||||
`Your sign-in email has changed`,
|
||||
'',
|
||||
`${data.changedByDisplayName ?? 'An administrator'} updated the email linked to your ${portName} account.`,
|
||||
`From now on, sign in with: ${data.newEmail}`,
|
||||
`${data.changedByDisplayName ?? 'An administrator'} has updated the email address linked to your ${portName} account.`,
|
||||
`Going forward, please sign in with: ${data.newEmail}`,
|
||||
'',
|
||||
data.loginUrl ? `Sign in: ${data.loginUrl}` : '',
|
||||
'',
|
||||
`If you weren't expecting this change, contact your administrator immediately.`,
|
||||
`If this change wasn't expected, please contact your administrator straight away.`,
|
||||
'',
|
||||
`With warm regards,`,
|
||||
`The ${portName} Team`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
@@ -33,7 +33,7 @@ function InviteBody({
|
||||
role: string;
|
||||
accent: string;
|
||||
}) {
|
||||
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome,';
|
||||
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome aboard,';
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
@@ -41,8 +41,9 @@ function InviteBody({
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
You've been invited to the {portName} CRM as a {role}. Click the button below to set
|
||||
your password and activate your account. The link expires in {ttlHours} hours.
|
||||
You've been invited to join the {portName} CRM as a {role}. Use the button below to set
|
||||
your password and activate your account at your convenience — the link will remain valid for{' '}
|
||||
{ttlHours} hours.
|
||||
</Text>
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
@@ -73,9 +74,9 @@ function InviteBody({
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Thank you,
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>{portName} CRM</strong>
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
@@ -107,13 +108,13 @@ export async function crmInviteEmail(
|
||||
const text = [
|
||||
`Welcome to the ${portName} CRM`,
|
||||
'',
|
||||
`You've been invited as a ${role}.`,
|
||||
`You've been invited to join the ${portName} CRM as a ${role}.`,
|
||||
`Set up your account: ${data.link}`,
|
||||
'',
|
||||
`The link expires in ${data.ttlHours} hours.`,
|
||||
`The link will remain valid for ${data.ttlHours} hours.`,
|
||||
'',
|
||||
`Thank you,`,
|
||||
`${portName} CRM`,
|
||||
`With warm regards,`,
|
||||
`The ${portName} Team`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
|
||||
@@ -37,10 +37,10 @@ function SalesNotificationBody({
|
||||
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear Administrator,</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
{fullName} has expressed their interest in <strong>{portName}</strong>. Here are their
|
||||
details:
|
||||
A new enquiry has come in for <strong>{portName}</strong>. {fullName} has asked us to be in
|
||||
touch — full details below:
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Name:</strong> {fullName}
|
||||
@@ -55,17 +55,13 @@ function SalesNotificationBody({
|
||||
<strong>Berths Selected:</strong> {mooringDisplay}
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Please visit the{' '}
|
||||
Open the{' '}
|
||||
<Link href={safeUrl(crmUrl)} style={{ color: accent, textDecoration: 'underline' }}>
|
||||
{portName} CRM
|
||||
</Link>{' '}
|
||||
to view more information.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px' }}>
|
||||
Thank you,
|
||||
<br />
|
||||
{portName} CRM
|
||||
to follow up.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px' }}>— {portName} CRM</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +72,9 @@ export async function inquirySalesNotification(
|
||||
) {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const mooringDisplay = data.mooringNumber || 'None';
|
||||
const subject = overrides?.subject?.trim() ? overrides.subject : `New Interest - ${portName}`;
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `New enquiry — ${portName}${data.mooringNumber ? ` (Berth ${data.mooringNumber})` : ''}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(
|
||||
@@ -93,19 +91,18 @@ export async function inquirySalesNotification(
|
||||
);
|
||||
|
||||
const text = [
|
||||
'Dear Administrator,',
|
||||
'Hello,',
|
||||
'',
|
||||
`${data.fullName} has expressed their interest in ${portName}. Here are their details:`,
|
||||
`A new enquiry has come in for ${portName}. ${data.fullName} has asked us to be in touch — full details below:`,
|
||||
'',
|
||||
`Name: ${data.fullName}`,
|
||||
`Email: ${data.email}`,
|
||||
`Telephone: ${data.phone}`,
|
||||
`Berths Selected: ${mooringDisplay}`,
|
||||
'',
|
||||
`Please visit the ${portName} CRM (${data.crmUrl}) to view more information.`,
|
||||
`Open the ${portName} CRM (${data.crmUrl}) to follow up.`,
|
||||
'',
|
||||
'Thank you',
|
||||
`${portName} CRM`,
|
||||
`— ${portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
|
||||
@@ -34,11 +34,12 @@ function ClientConfirmationBody({
|
||||
Dear {firstName},
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
Thank you for expressing interest in {portName} residences. Our residential sales team has
|
||||
received your inquiry and will reach out to you shortly with more information.
|
||||
Thank you for your interest in the residences at {portName}. Our residential sales team has
|
||||
received your enquiry, and a member of the team will be in touch shortly with the details
|
||||
you've requested.
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
If you have any questions in the meantime, please reach us at{' '}
|
||||
Should anything come to mind in the meantime, please don't hesitate to write to us at{' '}
|
||||
<Link
|
||||
href={safeUrl(`mailto:${contactEmail}`)}
|
||||
style={{ color: accent, textDecoration: 'underline' }}
|
||||
@@ -48,7 +49,7 @@ function ClientConfirmationBody({
|
||||
.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Best regards,
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {portName} Residential Team</strong>
|
||||
</Text>
|
||||
@@ -63,7 +64,7 @@ export async function residentialClientConfirmation(
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `Thank You for Your Interest - ${portName} Residences`;
|
||||
: `Thank you for your interest in ${portName} Residences`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const body = await render(
|
||||
<ClientConfirmationBody
|
||||
@@ -183,7 +184,7 @@ export async function residentialSalesAlert(
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `New Residential Inquiry - ${data.fullName}`;
|
||||
: `New residential enquiry — ${data.fullName}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
|
||||
pretty: false,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -109,12 +109,29 @@ export const generateAndSignSchema = generateSchema.extend({
|
||||
* EOI's Length/Width/Draft formValues. The drawer's toggle drives this;
|
||||
* server defaults to the yacht's `lengthUnit` column when omitted. */
|
||||
dimensionUnit: z.enum(['ft', 'm']).optional(),
|
||||
/** Phase 3b — optional per-field overrides applied at generation. */
|
||||
/** Phase 3b/3-follow-up — optional per-field overrides applied at generation. */
|
||||
overrides: z
|
||||
.object({
|
||||
clientEmail: fieldOverrideSchema.optional(),
|
||||
clientPhone: fieldOverrideSchema.optional(),
|
||||
yachtName: fieldOverrideSchema.optional(),
|
||||
// Phase 3 follow-up — multi-component address override. Treated as
|
||||
// one logical "field" with one pair of checkboxes (the dialog
|
||||
// surfaces it that way, and the side-effects helper applies the
|
||||
// intent to the whole address rather than per-component).
|
||||
clientAddress: z
|
||||
.object({
|
||||
line1: z.string().max(500).optional(),
|
||||
line2: z.string().max(500).optional(),
|
||||
city: z.string().max(200).optional(),
|
||||
subdivisionIso: z.string().max(20).optional(),
|
||||
postalCode: z.string().max(50).optional(),
|
||||
countryIso: z.string().length(2).optional(),
|
||||
useOnlyForThisEoi: z.boolean().default(false),
|
||||
setAsDefault: z.boolean().default(false),
|
||||
addressId: z.string().uuid().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user