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

@@ -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&apos;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&apos;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&apos;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');

View File

@@ -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&apos;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&apos;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 {

View File

@@ -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 {

View File

@@ -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&apos;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&apos;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,