feat(uat-batch): Groups F + G + H — DocsHub/signing + admin consolidation + email

F27–F29, G30, G31, H32, H33 from the 2026-05-21 plan.

Shipped now:
  F28  Past-milestones expandable history. The Past strip on the
       Interest overview becomes an <Accordion> — each row collapses
       to the same one-line summary as before, expands to render the
       full <MilestoneSection> (steps list, sub-status, inline doc
       actions). Reuses the existing MilestoneSection so no new
       per-milestone rendering needs to be maintained.
  F29  Watchers configurable at document creation time. The unified
       create-document wizard gets a Watchers section with a
       multi-select checkbox list backed by /api/v1/admin/users/picker.
       Selected user ids are sent in the `watchers` array on the POST
       (replacing the prior hardcoded `[]`). UI matches the
       post-creation WatchersCard so reps see the same identity rows
       regardless of entry point.
  G30  /admin/invitations merged into /admin/users. The Users page
       now wraps the existing UserList + InvitationsManager in a
       Tabs control (Active users / Invitations). The standalone
       /admin/invitations route returns a redirect to the merged page
       for bookmark back-compat. Removed nav catalog entry +
       admin-sections-browser tile; extended the Users catalog
       keywords with "invitations / pending invites / onboarding"
       so command-K search still lands on the right surface.
  G31  /admin/ai picks up the berth-PDF-parser section + a "planned
       AI surfaces" placeholder. Berth PDF parser remains
       env-configured today; the page now documents it so admins
       don't hunt for the controls. Closes the "where do I configure
       AI?" loop.
  H32  Email settings explainer panel above the SMTP cards. Spells
       out why noreply + sales have separate credentials and which
       workflows ship from each mailbox. Existing field titles
       gained the "(noreply)" suffix so the model maps cleanly.
  H33  Supplemental-info-request email rebuilt to use the shared
       branded shell (logo + blurred overhead background + max-
       width 600 table layout) instead of the prior plain-HTML
       page. Per-port branding (logo / primary color / background /
       header / footer) flows from getPortBrandingConfig. CTA
       button picks up the port's primary color.

Already shipped (verified pre-shipped):
  F27  DocumentsHub root view already hides the breadcrumb via
       `selectedFolderId !== undefined` conditional.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:40:48 +02:00
parent 431375d794
commit 94c24a123a
9 changed files with 278 additions and 57 deletions

View File

@@ -9,7 +9,8 @@ import {
} from '@/lib/services/supplemental-forms.service';
import { sendEmail } from '@/lib/email';
import { env } from '@/lib/env';
import { getPortEmailConfig } from '@/lib/services/port-config';
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
import { brandingPrimaryColor, renderShell } from '@/lib/email/shell';
/**
* POST /api/v1/interests/[id]/supplemental-info-request
@@ -82,22 +83,56 @@ export const POST = withAuth(
// `sendEmail` body flag.
const willSendEmail = resendTokenId ? true : shouldSendEmail;
if (willSendEmail && result.clientEmail) {
const html = `
<p>Hello ${escapeHtml(result.clientName)},</p>
<p>Before we draft your Expression of Interest, we need to confirm a few details.
// Render through the shared branded shell (logo + blurred overhead
// background + max-width 600 table layout) so the supplemental-
// info email matches portal-activation / reset / login + the rest
// of the branded surfaces. Per-port branding (logo, primary
// color, background image, header/footer) flows from
// system_settings via getPortBrandingConfig.
const branding = await getPortBrandingConfig(ctx.portId);
const accent = brandingPrimaryColor({
logoUrl: branding.logoUrl,
backgroundUrl: branding.emailBackgroundUrl,
primaryColor: branding.primaryColor,
emailHeaderHtml: branding.emailHeaderHtml,
emailFooterHtml: branding.emailFooterHtml,
});
const body = `
<h1 style="font-family:Arial,sans-serif;font-size:20px;font-weight:600;color:#0f172a;margin:0 0 16px;">
One quick step before your EOI
</h1>
<p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.55;color:#334155;margin:0 0 12px;">
Hello ${escapeHtml(result.clientName)},
</p>
<p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.55;color:#334155;margin:0 0 16px;">
Before we draft your Expression of Interest, we need to confirm a few details.
The form below is pre-filled with what we have on file — please review, correct
anything that's wrong, and add what's missing.</p>
<p style="text-align:center;margin:24px 0">
anything that&apos;s wrong, and add what&apos;s missing.
</p>
<p style="text-align:center;margin:24px 0;">
<a href="${link}"
style="background:#1e3a8a;color:#fff;text-decoration:none;padding:12px 24px;border-radius:6px;display:inline-block">
style="display:inline-block;background:${accent};color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:6px;font-family:Arial,sans-serif;font-size:14px;font-weight:600;">
Open the form
</a>
</p>
<p style="color:#64748b;font-size:12px">
<p style="font-family:Arial,sans-serif;font-size:12px;color:#64748b;margin:0 0 4px;">
This link expires on ${result.expiresAt.toUTCString()}.
If you didn't expect this email, please let us know.
</p>
<p style="font-family:Arial,sans-serif;font-size:12px;color:#64748b;margin:0;">
If you didn&apos;t expect this email, please let us know.
</p>
`;
const html = renderShell({
title: 'Please complete a few details before we draft your EOI',
body,
branding: {
logoUrl: branding.logoUrl,
backgroundUrl: branding.emailBackgroundUrl,
primaryColor: branding.primaryColor,
emailHeaderHtml: branding.emailHeaderHtml,
emailFooterHtml: branding.emailFooterHtml,
},
});
await sendEmail(
result.clientEmail,
'Please complete a few details before we draft your EOI',