12 Commits

Author SHA1 Message Date
866930c943 feat(intake): residence-type capture + CRM-owned inquiry emails for website cutover
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m17s
Website register-interest form now offers the 3 residence types as a
multi-select; the choice + preferred-contact flow into the CRM inquiry
payload, the inbox detail, and the residential emails.

- inquiry inbox detail surfaces residence type(s), preferred contact,
  type-of-interest, comments (full data capture)
- residential-inquiry emails: client confirmation names the chosen
  villa(s); sales alert converted to the canonical detail-line format
  (uniform with berth/contact) + residence type(s)/preferred contact +
  plain-text part
- website-intake-fields parses residence_types[] + method_of_contact
- contact_form alerts split to their own recipient key
  (contact_notification_recipients)
- Residential Interests section: new residence_type field (schema +
  migration 0099, validators, inline select on the detail)
- contact-form-alert email refactor shipped (interest-alert style)

Tests: website-intake-fields, residential-inquiry templates,
contact-form-alert, residential-interest validators.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-25 20:58:53 +02:00
64a488dc15 fix(signing): branded embed URL for developer + Copy link
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m6s
Two bugs in the marketing-site embed-URL rewrite, both surfaced by
testing through the actual invitation emails:

1. Developer invite linked to `…/sign/undefined/<token>`. document_signers
   persists Documenso's normalized role, so the order-2 EOI developer
   arrives as 'signer', which ROLE_TO_URL_SEGMENT didn't have — the lookup
   returned undefined. Add a 'signer' → 'developer' alias and a 'cc'
   fallback so an unknown role can never emit `/sign/undefined/`.

2. "Copy link" copied the bare Documenso URL, not the branded embed URL.
   listDocumentSigners (feeds the EOI tab signers + Copy link) now runs
   each signing_url through transformSigningUrl, so the copied link
   matches what the invitation email sends. Send-invitation/automation
   read the raw rows directly, so they're unaffected.

Regression tests pin 'signer' → /sign/developer and the unknown-role
fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:14:28 +02:00
2bc2cfac6f fix(eoi): render signed-PDF preview inline (preview endpoint + font-src)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m5s
After adding frame-src the preview iframe stopped showing the broken-file
icon but went blank: it pointed at /api/v1/files/[id]/download, which
presigns with the filename so S3 returns Content-Disposition: attachment
— the browser downloaded the PDF instead of rendering it. Point the
SignedPdfPreview iframe at the existing /preview endpoint, which presigns
WITHOUT a filename (inline disposition) so the native PDF viewer renders.

Also widen font-src to include https: so react-pdf/pdf.js can load its
standard-font pack (LiberationSans*) — previously blocked by
font-src 'self' data:, breaking the pdf.js-based viewers' glyphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:45:34 +02:00
3f6f845c02 fix(csp): add frame-src so signed-PDF previews + embedded signing load
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m55s
Build & Push Docker Images / build-and-push (push) Successful in 8m22s
The CSP set default-src 'self' with no frame-src, so it fell back to
'self' for frames. Inline signed-EOI previews iframe a presigned
s3.portnimara.com URL and the admin embedded-signing card iframes the
Documenso host — both were blocked, rendering a broken-file placeholder
("Framing 'https://s3.portnimara.com/' violates ... default-src 'self'").

Add `frame-src 'self' blob: https:` to both CSP definitions (proxy.ts
middleware + next.config.ts), matching the existing https: posture of
img-src/connect-src. frame-ancestors 'none' is unchanged, so we still
can't be embedded by third parties.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:26:38 +02:00
fc994cd88b fix(eoi): silence Documenso's own lifecycle emails on createDocument
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m53s
The local-fill EOI pathway creates fresh Documenso envelopes via
createDocument, which (unlike the template pathway that inherits
template 8's all-false emailSettings) used Documenso's defaults — every
email event defaults to true on both the v1 and v2.13 APIs. So Documenso
fired its OWN unbranded "Waiting for others to complete signing." and
"Signing Complete!" emails (signed PDF attached, reply-to sales@),
bypassing EMAIL_REDIRECT_TO and duplicating the CRM's branded sends.

Force emailSettings to all-false (DOCUMENSO_SILENT_EMAIL_SETTINGS) on
every createDocument call (v1 JSON + v2 multipart). The CRM stays the
sole sender of signing comms. Verified against the live v2.13 OpenAPI +
template 8's stored meta.

Also stop the EMAIL_REDIRECT_TO gate from appending "(was: <email>)" to
the recipient NAME: a "Name" field auto-fills from it into the signed
PDF, so the annotation overlapped the signature. Redirect the email
only; the original is still logged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:20:55 +02:00
e17476f3e3 fix(eoi): v1 field placement uses percent coords, not absolute points
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m50s
Build & Push Docker Images / build-and-push (push) Successful in 8m38s
Documenso 2.x's v1-compat POST /documents/{id}/fields expects percent
coordinates (0-100), same as v2 — but placeFields multiplied by a hardcoded
A4 page size, so 39.6% became ~237 and was read as 237%, placing the EOI
signature fields far off-page. Send percent straight through (matches
template-native fields, which render correctly). Drop the now-dead
getPageDimensions/DEFAULT_PAGE_DIMENSIONS A4 fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 04:05:42 +02:00
f4cfc5600f fix(eoi): uppercase Documenso recipient roles at the API boundary
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m48s
Build & Push Docker Images / build-and-push (push) Successful in 8m9s
The local-fill EOI pathway passed lowercase roles ('signer'/'approver') to
createDocument; Documenso's API requires UPPERCASE (CC|SIGNER|VIEWER|APPROVER|
ASSISTANT) and rejected them with a 400 "Invalid enum value", surfacing as a
502 DOCUMENSO_UPSTREAM_ERROR on generate. Normalize role to uppercase where
safeRecipients is built so both v1 + v2 paths and all callers are covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 03:37:14 +02:00
0ca9b2c3b5 feat(eoi): toggleable local-fill pathway — clean detail render + address wrapping
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m48s
Build & Push Docker Images / build-and-push (push) Successful in 8m34s
EOI detail fields (address, name, yacht, berth) rendered oversized and
top-clipped because Documenso auto-sizes AcroForm text when *it* fills the
template (ignores the PDF's 12pt font; a taller box → bigger font → more clip,
and a 2-line address box renders huge). Proven: filling the same source PDF
locally at 12pt renders cleanly and wraps long addresses to a 2nd line.

Add a per-port `eoi_fill_method` setting (default `local`), toggleable in
admin → Documenso → Templates & signing pathway:
- local:     CRM fills + flattens the source PDF (pdf-lib, fixed 12pt +
             multiline address wrap), uploads the flattened PDF to Documenso,
             and places ONLY the 6 page-3 signature fields. Documenso never
             re-renders the body text → no clipping.
- documenso: legacy template AcroForm fill (auto-sizes/clips) — fallback only.

Both still flow through Documenso for signing, so branded invites, embedded
signing, webhooks, signer rows, and the EOI milestone are unchanged.

- computeEoiSignatureLayout(): 6 page-3 fields at template-8 coords (unit-tested)
- createDocument (v1): PUT bytes to Documenso's presigned uploadUrl (2.x v1-compat
  ignores the base64 field) so the uploaded document actually has content
- placeFields (v1): pass fieldMeta through so the Place-of-Signing TEXT field
  keeps its label/required

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:48:11 +02:00
af05bb18dc fix(documenso): follow v1 /download JSON {downloadUrl} to fetch the real signed PDF
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m47s
Build & Push Docker Images / build-and-push (push) Successful in 8m39s
Documenso 2.13's v1-compat `GET /api/v1/documents/{id}/download` returns JSON
`{ downloadUrl }` (a presigned S3 URL), not raw PDF bytes. `downloadSignedPdf`
was doing `res.arrayBuffer()` directly, so it stored the ~500-byte JSON as the
"signed PDF" — every signer got a corrupt attachment ("Adobe could not open …
damaged") and the corrupt file was filed against the deal in the CRM.

Fix: magic-byte detection — if the v1 /download body isn't a `%PDF-`, parse the
JSON and follow `downloadUrl` to fetch the actual file; validate the result is a
real PDF before returning. Backward-compatible with older v1 servers that return
the PDF directly. This also protects the CRM deposit + email fan-out, since both
consume downloadSignedPdf's buffer — a non-PDF now throws instead of depositing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
2026-06-24 23:55:16 +02:00
1c91d76c52 feat(signing): internal "who signed" + completion email alerts to configurable recipients
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m56s
Adds a per-port `signing_notification_recipients` setting (users / roles /
explicit emails via the existing RecipientPicker) and fires a branded
internal email on (a) each party signing and (b) full completion —
replicating the legacy "Document Signed" / "EOI Complete Update Status"
Activepieces flows that staff relied on.

- New branded template `signing-status-notification.tsx` (per-signer
  progress + completion variants, deep-links into the CRM).
- New `sendSigningStatusNotification` resolver in document-signing-emails:
  resolves recipients, falls back to the port reply-to (sales@) when the
  list is empty so alerts are never silently dropped, per-recipient send.
- Wired into `handleRecipientSigned` (first signed transition) and
  `handleDocumentCompleted` (idempotent, fires once) — reached by both the
  Documenso webhook and the 5-min poll. Fully guarded so a notification
  failure never undoes a signing side effect. Respects EMAIL_REDIRECT_TO.
- Admin UI: `Document signing alerts` recipients card in settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
2026-06-24 21:38:22 +02:00
352b2420b7 fix(ui): mobile cutoff polish — onboarding banner + yacht owner truncate (R1/R2)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m2s
Build & Push Docker Images / build-and-push (push) Successful in 8m28s
Responsive-overflow sweep findings (tests/e2e/matrix/responsive-overflow.spec.ts):

- R1: the onboarding banner's verbose "N of M steps done. Next: <link>" was
  clipped on mobile (extended ~160px past a 390px viewport) and duplicated the
  always-visible "View checklist" button. Now hidden below sm:; mobile shows
  just "Setup X% complete" + the checklist button.
- R2: yacht card owner subtitle used inline-flex + truncate, so a long owner
  name overflowed ~11px on the narrowest widths. Switched to flex min-w-0 so it
  truncates within the card.
- Detector: skip SVG internals (icons / the react-grab dev overlay) and elements
  inside overflow-x scroll containers (data tables scroll on purpose) to drop
  false positives. Sweep now confirms mobile/tablet clean + no real desktop
  overflow (berths wide table is the DataTable's intended horizontal scroll).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:23:56 +02:00
459c68a2c3 feat(rbac): residential-partner route lockdown + role-aware mobile nav
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m0s
Build & Push Docker Images / build-and-push (push) Successful in 8m32s
UAT (residential partners must have zero access to anything non-residential;
no marina dashboard). Server-side their permission map already 403s every
marina domain — this locks the client surface to match:

- AppShell: a residential-only user (residential_clients.view && !clients.view,
  non-super-admin) is redirected off ANY non-residential route to
  /residential/clients. Blocks the marina dashboard + every marina page in one
  place; personal surfaces (settings, inbox) stay reachable. (Fixes F4 — they
  no longer land on a marina dashboard of 403-ing empty widgets.)
- Mobile bottom tabs were hardcoded Dashboard/Clients/Berths regardless of role;
  now role-aware — residential-only users get Residential Clients/Interests
  instead of marina tabs they 403 on. (Fixes F5.)
- e2e: stale `#email` login selector → `#identifier` (smoke helper) — a real
  reason the smoke auth specs fail independent of the dev-server OOM.
- New crash-safe `matrix` Playwright project (role×viewport access matrix +
  responsive overflow sweep) — lean alternative to the full suite which
  OOM-crashes next dev locally.

Verified: matrix run shows residential_partner redirected to residential +
residential-scoped mobile tabs; 403s unchanged; tsc + eslint + 42 permission
tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:53:22 +02:00
40 changed files with 2065 additions and 252 deletions

View File

@@ -51,8 +51,13 @@ const csp = [
`script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
// https: so react-pdf/pdf.js can load its standard-font pack + branding fonts.
"font-src 'self' data: https:",
`connect-src 'self' ws: wss: https:${devConnectHosts}`,
// PDF previews iframe a presigned storage URL; embedded-signing iframes the
// Documenso host. Both are per-port/per-env, so allow https: (matching
// img-src). frame-ancestors 'none' still blocks others from embedding us.
"frame-src 'self' blob: https:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",

View File

@@ -24,6 +24,28 @@ export default defineConfig({
name: 'setup',
testMatch: /smoke\/global-setup\.ts/,
},
{
// Permission-matrix UX sweep. Users + roles are seeded separately via
// `pnpm tsx tests/e2e/permissions/seed-permission-matrix.ts` (no global
// setup dependency — relies on the already-seeded dev DB).
name: 'permissions',
testMatch: /permissions\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
// Lean role × viewport access matrix. Users pre-seeded (admin/director/
// sales/viewer/residential_partner) — no global-setup dependency. Few
// route compilations, so it stays under the dev-server OOM threshold.
name: 'matrix',
testMatch: /matrix\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
name: 'smoke',
testMatch: /smoke\/\d{2}-.*\.spec\.ts/,

View File

@@ -50,20 +50,25 @@ export function OnboardingBanner() {
<div className="flex min-w-0 items-center gap-2">
<Sparkles className="size-4 shrink-0" aria-hidden />
<span className="truncate">
<strong>Setup is {data.percent}% complete</strong>. {data.completed} of {data.total} steps
done.{' '}
{next ? (
<>
Next:{' '}
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/admin/${next.href}` as any}
className="font-medium underline-offset-2 hover:underline"
>
{next.label}
</Link>
</>
) : null}
<strong>Setup is {data.percent}% complete</strong>
{/* Verbose progress + the "Next:" deep-link are hidden on mobile,
where they get clipped (R1) and duplicate the always-visible
"View checklist" button. Shown from sm: up. */}
<span className="hidden sm:inline">
. {data.completed} of {data.total} steps done.{' '}
{next ? (
<>
Next:{' '}
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/admin/${next.href}` as any}
className="font-medium underline-offset-2 hover:underline"
>
{next.label}
</Link>
</>
) : null}
</span>
</span>
</div>
<div className="flex shrink-0 items-center gap-1">

View File

@@ -149,9 +149,17 @@ const KNOWN_SETTINGS: Array<{
},
{
key: 'inquiry_notification_recipients',
label: 'Berth & contact inquiry alerts',
label: 'Berth inquiry alerts',
description:
'Who receives staff alerts for new berth + contact-form inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
'Who receives staff alerts for new berth inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
type: 'recipients',
defaultValue: [],
},
{
key: 'contact_notification_recipients',
label: 'Contact-form alerts',
description:
'Who receives staff alerts for new website contact-form submissions: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Falls back to Inquiry Contact Email when empty.',
type: 'recipients',
defaultValue: [],
},
@@ -163,6 +171,14 @@ const KNOWN_SETTINGS: Array<{
type: 'recipients',
defaultValue: [],
},
{
key: 'signing_notification_recipients',
label: 'Document signing alerts',
description:
'Who gets emailed each time a party signs an EOI / contract and when a document is fully signed: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Add yourself and sales@ here. Falls back to the Reply-To address when empty.',
type: 'recipients',
defaultValue: [],
},
{
key: 'eoi_signers',
label: 'EOI Signers',

View File

@@ -77,10 +77,29 @@ export function InquiryDetail({ id }: { id: string }) {
const p = (data?.payload ?? {}) as Record<string, unknown>;
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
// Read a payload value that may be a string[] (e.g. residence_types, the
// contact form's interest[]) OR a lone string, and present it comma-joined.
const list = (k: string): string => {
const v = p[k];
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string').join(', ');
return typeof v === 'string' ? v : '';
};
// The free-text message a lead left. Website forms use different keys
// (contact form -> `comments`; others -> `message`/`comment`), so probe the
// common ones and surface it for every inquiry kind.
const comment = str('comments') || str('message') || str('comment') || str('notes');
// Preferred method of contact (register form: 'email' | 'phone'). Surfaced so
// reps honour the lead's stated contact request.
const preferredContactRaw = str('method_of_contact').toLowerCase();
const preferredContact =
preferredContactRaw === 'email'
? 'Email'
: preferredContactRaw === 'phone'
? 'Phone call back'
: '';
const residenceTypes = list('residence_types');
// Contact-form "type of interest" (owner/broker/investor/…), stored as an array.
const contactInterest = list('interest');
const tabs: DetailTab[] = [
{
@@ -91,10 +110,17 @@ export function InquiryDetail({ id }: { id: string }) {
<Row label="Name" value={data?.contactName} />
<Row label="Email" value={data?.contactEmail} />
<Row label="Phone" value={str('phone')} />
{data?.kind === 'residence_inquiry' ? (
<Row label="Residence type(s)" value={residenceTypes} />
) : null}
{data?.kind === 'residence_inquiry' ? (
<Row label="Place of residence" value={str('address')} />
) : null}
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
{data?.kind === 'contact_form' && contactInterest ? (
<Row label="Type of interest" value={contactInterest} />
) : null}
{preferredContact ? <Row label="Preferred contact" value={preferredContact} /> : null}
{comment ? (
<Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} />
) : null}

View File

@@ -868,10 +868,15 @@ function SignedEoiCard({
* the file in a new tab via the alongside View button for full-screen.
*/
function SignedPdfPreview({ fileId }: { fileId: string }) {
const { data, isLoading, isError } = useQuery<{ data: { url: string; filename: string } }>({
queryKey: ['files', fileId, 'download-url'],
// Use the PREVIEW endpoint, not /download: /download presigns with the
// filename so S3 returns `Content-Disposition: attachment`, which makes the
// iframe trigger a file download (blank preview) instead of rendering. The
// preview endpoint presigns WITHOUT a filename → inline disposition → the
// browser's native PDF viewer renders it in the card.
const { data, isLoading, isError } = useQuery<{ data: { url: string; mimeType: string } }>({
queryKey: ['files', fileId, 'preview-url'],
queryFn: () =>
apiFetch<{ data: { url: string; filename: string } }>(`/api/v1/files/${fileId}/download`),
apiFetch<{ data: { url: string; mimeType: string } }>(`/api/v1/files/${fileId}/preview`),
// Presigned URL TTLs vary per backend - refresh well before they
// expire so a long-open card doesn't suddenly 403. 4 minutes is
// comfortably below the 5-minute MinIO default.

View File

@@ -1,8 +1,10 @@
'use client';
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { usePermissions } from '@/hooks/use-permissions';
import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar';
import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker';
@@ -112,6 +114,30 @@ export function AppShell({
const currentPortId = useUIStore((s) => s.currentPortId);
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
// Residential lockdown: a residential-only user (residential access, no
// marina `clients.view`) must never see marina pages — including the marina
// dashboard. The API already 403s their data; this guard blocks the *routes*,
// redirecting any non-residential path to their residential home. Personal
// surfaces (settings, inbox) stay reachable.
const pathname = usePathname();
const router = useRouter();
const { can } = usePermissions();
const residentialOnly =
!isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view');
useEffect(() => {
if (!residentialOnly || !pathname) return;
const [portSeg, ...rest] = pathname.split('/').filter(Boolean);
const sub = rest.join('/');
const allowed =
sub === '' ||
sub.startsWith('residential') ||
sub.startsWith('settings') ||
sub.startsWith('inbox');
if (!allowed && portSeg) {
router.replace(`/${portSeg}/residential/clients`);
}
}, [residentialOnly, pathname, router]);
useEffect(() => {
const mqMobile = window.matchMedia(MOBILE_QUERY);
const mqTablet = window.matchMedia(TABLET_QUERY);

View File

@@ -2,9 +2,10 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
import { Anchor, ClipboardList, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { usePermissions } from '@/hooks/use-permissions';
type TabSpec = {
label: string;
@@ -12,16 +13,21 @@ type TabSpec = {
segment: string; // route segment after /[portSlug]/
};
// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More.
// Search occupies the center slot. Documents demoted to the MoreSheet -
// reps reach docs less often than berths during a walking inventory check,
// and pinned-to-client documents are accessed via the client detail anyway.
const TABS_LEFT: TabSpec[] = [
// Marina users: Dashboard, Clients | Berths. Search center, More right.
const MARINA_TABS_LEFT: TabSpec[] = [
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
{ label: 'Clients', icon: Users, segment: 'clients' },
];
const MARINA_TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }];
const TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }];
// Residential-only users (e.g. residential partners) never have marina access,
// so the bottom tabs mirror their residential-only sidebar instead of showing
// Clients/Berths they 403 on (matches the AppShell route lockdown).
const RESIDENTIAL_TABS_LEFT: TabSpec[] = [
{ label: 'Clients', icon: Users, segment: 'residential/clients' },
{ label: 'Interests', icon: ClipboardList, segment: 'residential/interests' },
];
const RESIDENTIAL_TABS_RIGHT: TabSpec[] = [];
interface MobileBottomTabsProps {
onMoreClick: () => void;
@@ -31,6 +37,11 @@ interface MobileBottomTabsProps {
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
const pathname = usePathname();
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
const { can, isSuperAdmin } = usePermissions();
const residentialOnly =
!isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view');
const tabsLeft = residentialOnly ? RESIDENTIAL_TABS_LEFT : MARINA_TABS_LEFT;
const tabsRight = residentialOnly ? RESIDENTIAL_TABS_RIGHT : MARINA_TABS_RIGHT;
function isActive(segment: string): boolean {
return pathname.startsWith(`/${portSlug}/${segment}`);
@@ -46,7 +57,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
'flex items-end',
)}
>
{TABS_LEFT.map((tab) => (
{tabsLeft.map((tab) => (
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
))}
@@ -60,7 +71,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
<span className="relative font-medium">Search</span>
</button>
{TABS_RIGHT.map((tab) => (
{tabsRight.map((tab) => (
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
))}

View File

@@ -20,6 +20,7 @@ interface ResidentialInterest {
source: string | null;
notes: string | null;
preferences: string | null;
residenceType: string | null;
assignedTo: string | null;
client: { id: string; fullName: string } | null;
}

View File

@@ -9,6 +9,7 @@ import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { SOURCES } from '@/lib/constants';
import { RESIDENCE_TYPES } from '@/lib/validators/residential';
interface ResidentialInterest {
id: string;
@@ -17,6 +18,7 @@ interface ResidentialInterest {
source: string | null;
notes: string | null;
preferences: string | null;
residenceType: string | null;
assignedTo: string | null;
}
@@ -28,6 +30,7 @@ interface Args {
}
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
const RESIDENCE_TYPE_OPTIONS = RESIDENCE_TYPES.map((t) => ({ value: t, label: t }));
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
@@ -151,6 +154,15 @@ function OverviewTab({
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Details</h3>
<Row label="Residence type">
<InlineEditableField
variant="select"
options={RESIDENCE_TYPE_OPTIONS}
value={interest.residenceType}
onSave={save('residenceType')}
placeholder="Not set"
/>
</Row>
<Row label="Preferences">
<InlineEditableField
variant="textarea"

View File

@@ -102,9 +102,11 @@ export function YachtCard({ yacht, portSlug, onEdit, onArchive }: YachtCardProps
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Owner subtitle */}
{/* Owner subtitle. `flex min-w-0` (not inline-flex) so a long owner
name truncates within the card instead of overflowing ~11px on
the narrowest mobile widths (R2). */}
{yacht.currentOwnerName ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<p className="mt-0.5 flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
<OwnerIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">{yacht.currentOwnerName}</span>
</p>

View File

@@ -0,0 +1,5 @@
-- Residential interests: structured residence unit type the lead is pursuing
-- (e.g. "Two Bedroom Marina Villa"). Mirrors the multi-select on the website's
-- register-interest form. Nullable; additive — safe to apply online.
ALTER TABLE residential_interests
ADD COLUMN IF NOT EXISTS residence_type text;

View File

@@ -97,6 +97,13 @@ export const residentialInterests = pgTable(
* heavily. Schema can grow into structured columns later if needed.
*/
preferences: text('preferences'),
/**
* Structured residence unit type the lead is pursuing (e.g. "Two Bedroom
* Marina Villa"). Mirrors the multi-select on the website's register-interest
* form; on a structured interest it captures the single unit type being
* worked. Nullable - older rows + manual entries may leave it unset.
*/
residenceType: text('residence_type'),
/**
* better-auth user id of the residential team member working this lead.
*/

View File

@@ -1,4 +1,4 @@
import { Button, Text, render } from '@react-email/components';
import { Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
@@ -17,6 +17,9 @@ export interface ContactFormSalesAlertData {
portName?: string;
}
// Mirrors the interest-registration alert (inquiry-sales-notification.tsx):
// friendly intro, `**Label:** value` detail lines, inline CRM follow-up link,
// and a plain-text part — so contact-form alerts read identically to interest ones.
function SalesAlertBody({
portName,
data,
@@ -26,61 +29,42 @@ function SalesAlertBody({
data: ContactFormSalesAlertData;
accent: string;
}) {
const labelCell = { color: '#666', width: '140px' } as const;
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
const comments = data.comments?.trim() ? data.comments : '(none provided)';
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
New contact form submission
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
A new contact-form enquiry has come in for <strong>{portName}</strong>. {data.fullName} got
in touch via the website contact page - full details below:
</Text>
<table
role="presentation"
width="100%"
cellPadding={6}
cellSpacing={0}
style={{ fontSize: '14px', lineHeight: '1.4', marginBottom: '20px' }}
>
<tbody>
<tr>
<td style={labelCell}>Name</td>
<td>{data.fullName}</td>
</tr>
<tr>
<td style={labelCell}>Email</td>
<td>{data.email}</td>
</tr>
{data.interestType ? (
<tr>
<td style={labelCell}>Interest</td>
<td>{data.interestType}</td>
</tr>
) : null}
{data.comments ? (
<tr>
<td style={labelCell}>Comments</td>
<td>{data.comments}</td>
</tr>
) : null}
</tbody>
</table>
{data.crmDeepLink ? (
<div style={{ textAlign: 'center', margin: '24px 0' }}>
<Button
href={safeUrl(data.crmDeepLink)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '12px 28px',
borderRadius: '5px',
fontWeight: 'bold',
}}
>
Open in CRM
</Button>
</div>
<Text style={detailStyle}>
<strong>Name:</strong> {data.fullName}
</Text>
<Text style={detailStyle}>
<strong>Email:</strong> {data.email}
</Text>
{data.interestType ? (
<Text style={detailStyle}>
<strong>Interest:</strong> {data.interestType}
</Text>
) : null}
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
<Text style={{ margin: '0 0 16px 0', fontSize: '16px' }}>
<strong>Comments:</strong> {comments}
</Text>
{data.crmDeepLink ? (
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Open the{' '}
<Link
href={safeUrl(data.crmDeepLink)}
style={{ color: accent, textDecoration: 'underline' }}
>
{portName} CRM
</Link>{' '}
to follow up.
</Text>
) : null}
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
</>
);
}
@@ -89,7 +73,7 @@ export async function contactFormSalesAlert(
data: ContactFormSalesAlertData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'our team';
const portName = data.portName ?? 'Port Nimara';
const subject = overrides?.subject?.trim()
? overrides.subject
: `New contact form submission - ${data.fullName}`;
@@ -97,8 +81,27 @@ export async function contactFormSalesAlert(
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false,
});
const comments = data.comments?.trim() ? data.comments : '(none provided)';
const text = [
'Hello,',
'',
`A new contact-form enquiry has come in for ${portName}. ${data.fullName} got in touch via the website contact page - full details below:`,
'',
`Name: ${data.fullName}`,
`Email: ${data.email}`,
...(data.interestType ? [`Interest: ${data.interestType}`] : []),
`Comments: ${comments}`,
'',
...(data.crmDeepLink
? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, '']
: []),
`- ${portName} CRM`,
].join('\n');
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}

View File

@@ -1,4 +1,4 @@
import { Button, Link, Text, render } from '@react-email/components';
import { Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
@@ -11,18 +11,35 @@ interface RenderOpts {
export interface ResidentialClientConfirmationData {
firstName: string;
contactEmail: string;
residenceTypes?: string[];
portName?: string;
}
/**
* Human-readable list of the residence types a lead selected, e.g.
* "the Two Bedroom Marina Villa and the Four Bedroom Oceanfront Villa".
* Falls back to a generic phrase when nothing was selected so the copy
* always reads naturally.
*/
function residencePhrase(portName: string, types: string[] | undefined): string {
const list = (types ?? []).filter(Boolean);
if (list.length === 0) return `the residences at ${portName}`;
if (list.length === 1) return `the ${list[0]}`;
if (list.length === 2) return `the ${list[0]} and the ${list[1]}`;
return `the ${list.slice(0, -1).join(', the ')}, and the ${list[list.length - 1]}`;
}
function ClientConfirmationBody({
portName,
firstName,
contactEmail,
residencePhraseText,
accent,
}: {
portName: string;
firstName: string;
contactEmail: string;
residencePhraseText: string;
accent: string;
}) {
return (
@@ -34,7 +51,7 @@ function ClientConfirmationBody({
Dear {firstName},
</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
Thank you for your interest in the residences at {portName}. Our residential sales team has
Thank you for your interest in {residencePhraseText}. 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>
@@ -66,18 +83,31 @@ export async function residentialClientConfirmation(
? overrides.subject
: `Thank you for your interest in ${portName} Residences`;
const accent = brandingPrimaryColor(overrides?.branding);
const residencePhraseText = residencePhrase(portName, data.residenceTypes);
const body = await render(
<ClientConfirmationBody
portName={portName}
firstName={data.firstName}
contactEmail={data.contactEmail}
residencePhraseText={residencePhraseText}
accent={accent}
/>,
{ pretty: false },
);
const text = [
`Dear ${data.firstName},`,
'',
`Thank you for your interest in ${residencePhraseText}. 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.`,
'',
`Should anything come to mind in the meantime, please don't hesitate to write to us at ${data.contactEmail}.`,
'',
'With warm regards,',
`The ${portName} Residential Team`,
].join('\n');
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
@@ -85,6 +115,7 @@ export interface ResidentialSalesAlertData {
fullName: string;
email: string;
phone: string;
residenceTypes?: string[];
placeOfResidence?: string;
preferredContactMethod?: 'email' | 'phone';
notes?: string;
@@ -93,6 +124,12 @@ export interface ResidentialSalesAlertData {
portName?: string;
}
function formatPreferredContact(method: 'email' | 'phone' | undefined): string | undefined {
if (method === 'email') return 'Email';
if (method === 'phone') return 'Phone call back';
return undefined;
}
function SalesAlertBody({
portName,
data,
@@ -102,77 +139,65 @@ function SalesAlertBody({
data: ResidentialSalesAlertData;
accent: string;
}) {
const labelCell = { color: '#666', width: '140px' } as const;
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
const preferredContact = formatPreferredContact(data.preferredContactMethod);
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
New residential inquiry
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
A new residential enquiry has come in for <strong>{portName}</strong>. {data.fullName} has
asked us to be in touch - full details below:
</Text>
<table
role="presentation"
width="100%"
cellPadding={6}
cellSpacing={0}
style={{ fontSize: '14px', lineHeight: '1.4', marginBottom: '20px' }}
>
<tbody>
<tr>
<td style={labelCell}>Name</td>
<td>{data.fullName}</td>
</tr>
<tr>
<td style={labelCell}>Email</td>
<td>{data.email}</td>
</tr>
<tr>
<td style={labelCell}>Phone</td>
<td>{data.phone}</td>
</tr>
{data.placeOfResidence ? (
<tr>
<td style={labelCell}>Residence</td>
<td>{data.placeOfResidence}</td>
</tr>
) : null}
{data.preferredContactMethod ? (
<tr>
<td style={labelCell}>Prefers</td>
<td>{data.preferredContactMethod}</td>
</tr>
) : null}
{data.preferences ? (
<tr>
<td style={labelCell}>Preferences</td>
<td>{data.preferences}</td>
</tr>
) : null}
{data.notes ? (
<tr>
<td style={labelCell}>Notes</td>
<td>{data.notes}</td>
</tr>
) : null}
</tbody>
</table>
<div style={{ marginBottom: '16px' }}>
<Text style={detailStyle}>
<strong>Name:</strong> {data.fullName}
</Text>
<Text style={detailStyle}>
<strong>Email:</strong> {data.email}
</Text>
<Text style={detailStyle}>
<strong>Telephone:</strong> {data.phone}
</Text>
{residenceTypes.length > 0 ? (
<Text style={detailStyle}>
<strong>Residence type(s):</strong> {residenceTypes.join(', ')}
</Text>
) : null}
{preferredContact ? (
<Text style={detailStyle}>
<strong>Preferred contact:</strong> {preferredContact}
</Text>
) : null}
{data.placeOfResidence ? (
<Text style={detailStyle}>
<strong>Place of residence:</strong> {data.placeOfResidence}
</Text>
) : null}
{data.preferences ? (
<Text style={detailStyle}>
<strong>Preferences:</strong> {data.preferences}
</Text>
) : null}
{data.notes ? (
<Text style={detailStyle}>
<strong>Comments:</strong> {data.notes}
</Text>
) : null}
</div>
{data.crmDeepLink ? (
<div style={{ textAlign: 'center', margin: '24px 0' }}>
<Button
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Open the{' '}
<Link
href={safeUrl(data.crmDeepLink)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '12px 28px',
borderRadius: '5px',
fontWeight: 'bold',
}}
style={{ color: accent, textDecoration: 'underline' }}
>
Open in CRM
</Button>
</div>
{portName} CRM
</Link>{' '}
to follow up.
</Text>
) : null}
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
</>
);
}
@@ -189,8 +214,32 @@ export async function residentialSalesAlert(
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false,
});
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
const preferredContact = formatPreferredContact(data.preferredContactMethod);
const text = [
'Hello,',
'',
`A new residential 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}`,
...(residenceTypes.length > 0 ? [`Residence type(s): ${residenceTypes.join(', ')}`] : []),
...(preferredContact ? [`Preferred contact: ${preferredContact}`] : []),
...(data.placeOfResidence ? [`Place of residence: ${data.placeOfResidence}`] : []),
...(data.preferences ? [`Preferences: ${data.preferences}`] : []),
...(data.notes ? [`Comments: ${data.notes}`] : []),
'',
...(data.crmDeepLink
? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, '']
: []),
`- ${portName} CRM`,
].join('\n');
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}

View File

@@ -0,0 +1,152 @@
/**
* Internal "signing progress" alert — sent to the port's configured
* signing-notification recipients (e.g. the admin + sales@) so staff get
* a heads-up every time a party signs and again when a document is fully
* signed. This is the CRM equivalent of the legacy "Document Signed" /
* "EOI Complete Update Status" Activepieces flows.
*
* Two events:
* - `signed` — a single party just signed (carries who + progress).
* - `completed` — all parties have signed.
*
* Unlike the signer-facing templates, this one links back into the CRM
* (deep link to the document) rather than to a signing page — the
* recipients are staff, not signers.
*/
import { Button, Hr, Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface RenderOpts {
subject?: string | null;
branding?: BrandingShell | null;
}
export interface StatusNotificationData {
event: 'signed' | 'completed';
documentLabel: string;
/** Deal / client name for the salutation + subject context. */
clientName: string;
portName: string;
/** Deep link into the CRM document detail page. */
crmUrl: string;
/** For `signed`: who just signed. */
signerName?: string | null;
signerRole?: string | null;
/** For `signed`: progress within the signing order. */
signedCount?: number;
totalCount?: number;
}
function roleLabel(role?: string | null): string {
switch (role) {
case 'client':
return 'the client';
case 'developer':
return 'the developer';
case 'approver':
return 'the approver';
case 'witness':
return 'a witness';
default:
return 'a signer';
}
}
function StatusBody({ data, accent }: { data: StatusNotificationData; accent: string }) {
const isCompleted = data.event === 'completed';
const progress =
typeof data.signedCount === 'number' && typeof data.totalCount === 'number'
? `${data.signedCount} of ${data.totalCount}`
: null;
const heading = isCompleted
? `${data.documentLabel} fully signed`
: `${data.signerName ?? 'A signer'} has signed`;
return (
<>
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
{heading}
</Text>
{isCompleted ? (
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
The {data.documentLabel} for <strong>{data.clientName}</strong> has now been signed by all
parties. The fully signed PDF has been filed against the deal in the {data.portName} CRM.
</Text>
) : (
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
<strong>{data.signerName ?? 'A signer'}</strong> ({roleLabel(data.signerRole)}) has signed
the {data.documentLabel} for <strong>{data.clientName}</strong>
{progress ? `${progress} signatures collected so far.` : '.'}
</Text>
)}
<div style={{ textAlign: 'center', margin: '28px 0' }}>
<Button
href={safeUrl(data.crmUrl)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 36px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
View in CRM
</Button>
</div>
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
<Text style={{ fontSize: '13px', color: '#666', lineHeight: '1.5', padding: '14px 0 0' }}>
Open the deal:{' '}
<Link
href={safeUrl(data.crmUrl)}
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
>
{data.crmUrl}
</Link>
</Text>
<Text style={{ fontSize: '13px', color: '#999', lineHeight: '1.5', marginTop: '14px' }}>
You&apos;re receiving this because you&apos;re on the signing-notification list for{' '}
{data.portName}. An administrator can change who gets these alerts in CRM settings.
</Text>
</>
);
}
export async function signingStatusNotificationEmail(
data: StatusNotificationData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const accent = brandingPrimaryColor(overrides?.branding);
const isCompleted = data.event === 'completed';
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{clientName\}\}/g, data.clientName)
.replace(/\{\{portName\}\}/g, data.portName)
: isCompleted
? `${data.documentLabel} fully signed — ${data.clientName}`
: `${data.signerName ?? 'A signer'} signed the ${data.documentLabel}${data.clientName}`;
const body = await render(<StatusBody data={data} accent={accent} />, { pretty: false });
const progress =
typeof data.signedCount === 'number' && typeof data.totalCount === 'number'
? ` (${data.signedCount} of ${data.totalCount} signed)`
: '';
const text = isCompleted
? `The ${data.documentLabel} for ${data.clientName} has been signed by all parties and filed in the ${data.portName} CRM.\n\nView in CRM: ${data.crmUrl}`
: `${data.signerName ?? 'A signer'} (${roleLabel(data.signerRole)}) has signed the ${data.documentLabel} for ${data.clientName}${progress}.\n\nView in CRM: ${data.crmUrl}`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}

View File

@@ -243,16 +243,19 @@ export interface DocumensoDocument {
/**
* When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient
* email so Documenso doesn't accidentally email real clients during a
* data import / migration dry-run. Names are prefixed with the original
* email so the recipient (you) can tell who would have received the doc.
* data import / migration dry-run.
*
* In production this env var is unset and recipients flow through unchanged.
* The NAME is left untouched: a "Name" signature field auto-fills from the
* recipient name and renders into the signed PDF, so any annotation here
* (we used to append "(was: <email>)") leaks into the document and overlaps
* the signature. The original email is captured in the createDocument log
* line instead. In production this env var is unset and recipients flow
* through unchanged.
*/
function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] {
if (!env.EMAIL_REDIRECT_TO) return recipients;
return recipients.map((r) => ({
...r,
name: `${r.name} (was: ${r.email})`,
email: env.EMAIL_REDIRECT_TO!,
}));
}
@@ -265,11 +268,11 @@ function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoReci
function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> {
if (!env.EMAIL_REDIRECT_TO) return payload;
const out: Record<string, unknown> = { ...payload };
// 2.x recipient shape
// 2.x recipient shape — redirect the email only, keep the name clean (it
// renders into the signed PDF's Name field). See applyRecipientRedirect.
if (Array.isArray(out.recipients)) {
out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({
...r,
name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`,
email: env.EMAIL_REDIRECT_TO,
}));
}
@@ -288,11 +291,41 @@ function applyPayloadRedirect(payload: Record<string, unknown>): Record<string,
return out;
}
/**
* Documenso fires its OWN lifecycle emails for every envelope: each event
* below defaults to `true` (verified against the v2.13 OpenAPI + the EOI
* Documenso template's stored meta). The CRM is the SOLE sender of signing
* comms — branded invitations via `sendSigningInvitation`, plus the
* completion / "who signed" alert emails — so we disable ALL of Documenso's
* events at creation time.
*
* Without this, the local-fill pathway (which creates fresh envelopes via
* `createDocument`, unlike the template pathway that inherits the template's
* all-false `emailSettings`) leaks unbranded "Waiting for others" /
* "Signing Complete!" emails — sent with the signed PDF attached from the
* Documenso instance's own account (reply-to sales@) — duplicating ours.
*
* The v2 schema marks every key `required` when the object is present, so
* all nine are listed explicitly.
*/
export const DOCUMENSO_SILENT_EMAIL_SETTINGS = {
recipientSigningRequest: false,
recipientRemoved: false,
recipientSigned: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: false,
ownerRecipientExpired: false,
ownerDocumentCreated: false,
} as const;
/**
* Optional metadata applied to the document on creation. v1 accepts
* `redirectUrl` and `subject`/`message` on its `/documents` endpoint.
* v2's `/envelope/create` accepts the same plus `signingOrder` for
* PARALLEL-vs-SEQUENTIAL signing enforcement.
* PARALLEL-vs-SEQUENTIAL signing enforcement. `emailSettings` is always
* forced to `DOCUMENSO_SILENT_EMAIL_SETTINGS` inside `createDocument`.
*/
export interface CreateDocumentMeta {
subject?: string;
@@ -309,7 +342,14 @@ export async function createDocument(
portId?: string,
meta?: CreateDocumentMeta,
): Promise<DocumensoDocument> {
const safeRecipients = applyRecipientRedirect(recipients);
// Documenso's API requires UPPERCASE recipient roles
// (CC | SIGNER | VIEWER | APPROVER | ASSISTANT). The CRM uses lowercase
// role strings internally ('signer' / 'approver'), so normalize here at the
// API boundary — otherwise create fails with a 400 "Invalid enum value".
const safeRecipients = applyRecipientRedirect(recipients).map((r) => ({
...r,
role: typeof r.role === 'string' ? r.role.toUpperCase() : r.role,
}));
if (env.EMAIL_REDIRECT_TO) {
logger.info(
{ redirected: safeRecipients.length, original: recipients.map((r) => r.email) },
@@ -335,16 +375,14 @@ export async function createDocument(
role: r.role,
signingOrder: r.signingOrder || i + 1,
})),
...(meta
? {
meta: {
...(meta.subject ? { subject: meta.subject } : {}),
...(meta.message ? { message: meta.message } : {}),
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
...(meta.signingOrder ? { signingOrder: meta.signingOrder } : {}),
},
}
: {}),
meta: {
// CRM is the sole email sender — Documenso stays silent.
emailSettings: DOCUMENSO_SILENT_EMAIL_SETTINGS,
...(meta?.subject ? { subject: meta.subject } : {}),
...(meta?.message ? { message: meta.message } : {}),
...(meta?.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
...(meta?.signingOrder ? { signingOrder: meta.signingOrder } : {}),
},
};
form.append('payload', JSON.stringify(payload));
form.append(
@@ -391,8 +429,13 @@ export async function createDocument(
return getDocument(envelopeId, portId);
}
// v1: existing path. Meta keys are accepted at the top level.
return documensoFetch(
// v1: existing path. Meta keys are accepted at the top level. We still send
// `document` (base64) for older Documenso servers that store it inline, but
// Documenso 2.x's v1-compat endpoint instead returns a presigned `uploadUrl`
// and expects the PDF bytes to be PUT there (the base64 is ignored). So when
// the create response carries an `uploadUrl`, upload the bytes to it — without
// this the document is created with NO content (signers see a blank PDF).
const raw = (await documensoFetch(
'/api/v1/documents',
{
method: 'POST',
@@ -400,19 +443,49 @@ export async function createDocument(
title,
document: pdfBase64,
recipients: safeRecipients,
...(meta?.subject || meta?.message || meta?.redirectUrl
? {
meta: {
...(meta.subject ? { subject: meta.subject } : {}),
...(meta.message ? { message: meta.message } : {}),
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
},
}
: {}),
meta: {
// CRM is the sole email sender — Documenso stays silent.
emailSettings: DOCUMENSO_SILENT_EMAIL_SETTINGS,
...(meta?.subject ? { subject: meta.subject } : {}),
...(meta?.message ? { message: meta.message } : {}),
...(meta?.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
},
}),
},
portId,
).then(normalizeDocument);
)) as Record<string, unknown>;
const uploadUrl = typeof raw.uploadUrl === 'string' ? raw.uploadUrl : null;
if (uploadUrl) {
const pdfBuffer = Buffer.from(pdfBase64, 'base64');
let putRes: Response;
try {
putRes = await fetchWithTimeout(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/pdf' },
body: pdfBuffer,
});
} catch (err) {
if (err instanceof FetchTimeoutError) {
throw new CodedError('DOCUMENSO_TIMEOUT', {
internalMessage: `v1 createDocument uploadUrl PUT timed out after ${err.timeoutMs}ms`,
});
}
throw err;
}
if (!putRes.ok) {
const errText = await putRes.text().catch(() => '');
logger.error(
{ status: putRes.status, err: errText, portId },
'Documenso v1 createDocument uploadUrl PUT failed - document has no content',
);
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `v1 createDocument uploadUrl PUT → ${putRes.status}: ${errText}`,
});
}
}
return normalizeDocument(raw);
}
export async function generateDocumentFromTemplate(
@@ -1060,8 +1133,52 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
});
}
const arrayBuffer = await res.arrayBuffer();
return Buffer.from(arrayBuffer);
// Documenso 2.13's v1-compat `/download` returns JSON `{ downloadUrl }`
// (a presigned S3 URL), NOT the raw PDF. Older v1 returned the PDF bytes
// directly. Detect by magic bytes: a real PDF starts with `%PDF-`. When it
// doesn't, parse the JSON and follow `downloadUrl` to fetch the actual file.
// Saving the JSON body as the "signed PDF" produced a ~500-byte corrupt file
// that got emailed to every signer + filed in the CRM (audit 2026-06-24).
const firstBuf = Buffer.from(await res.arrayBuffer());
if (firstBuf.subarray(0, 5).toString('latin1') === '%PDF-') {
return firstBuf;
}
let downloadUrl: string | undefined;
try {
downloadUrl = (JSON.parse(firstBuf.toString('utf8')) as { downloadUrl?: string }).downloadUrl;
} catch {
/* body was neither a PDF nor JSON */
}
if (!downloadUrl) {
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `${path} returned a ${firstBuf.length}-byte non-PDF body with no downloadUrl`,
});
}
let pdfRes: Response;
try {
pdfRes = await fetchWithTimeout(downloadUrl, {});
} catch (err) {
if (err instanceof FetchTimeoutError) {
throw new CodedError('DOCUMENSO_TIMEOUT', {
internalMessage: `signed-PDF presigned download timed out after ${err.timeoutMs}ms`,
});
}
throw err;
}
if (!pdfRes.ok) {
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `signed-PDF presigned URL → ${pdfRes.status}`,
});
}
const pdfBuf = Buffer.from(await pdfRes.arrayBuffer());
if (pdfBuf.subarray(0, 5).toString('latin1') !== '%PDF-') {
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `signed-PDF presigned URL returned a ${pdfBuf.length}-byte non-PDF`,
});
}
return pdfBuf;
}
/** Convenience health-check used by the admin "Test connection" button.
@@ -1203,8 +1320,6 @@ export interface DocumensoPageDimensions {
height: number;
}
const DEFAULT_PAGE_DIMENSIONS: DocumensoPageDimensions = { width: 595, height: 842 }; // A4 pt
const pageDimensionCache = new Map<string, DocumensoPageDimensions>();
/** Test seam - clears the page-dimension memoization. */
@@ -1212,18 +1327,6 @@ export function __resetDocumensoCachesForTests(): void {
pageDimensionCache.clear();
}
async function getPageDimensions(docId: string, portId?: string): Promise<DocumensoPageDimensions> {
const cached = pageDimensionCache.get(docId);
if (cached) return cached;
// v1 doesn't expose page dimensions cleanly via the public API; the auto-
// placement use case is footer-anchored signature fields, where a default A4
// page rendered by Documenso is a safe assumption. Real page dims can be
// wired in a follow-up by parsing the document/document-data endpoints.
void portId;
pageDimensionCache.set(docId, DEFAULT_PAGE_DIMENSIONS);
return DEFAULT_PAGE_DIMENSIONS;
}
/**
* Place one or more fields on a Documenso document. Coordinates are PERCENT
* (0-100) and converted to pixels for v1 internally.
@@ -1286,16 +1389,23 @@ export async function placeFields(
return;
}
const dims = await getPageDimensions(docId, portId);
for (const f of fields) {
const body = {
recipientId: typeof f.recipientId === 'string' ? Number(f.recipientId) : f.recipientId,
type: f.type,
pageNumber: f.pageNumber,
pageX: Math.round((f.pageX / 100) * dims.width),
pageY: Math.round((f.pageY / 100) * dims.height),
pageWidth: Math.round((f.pageWidth / 100) * dims.width),
pageHeight: Math.round((f.pageHeight / 100) * dims.height),
// Documenso 2.x's v1-compat /fields endpoint expects PERCENT coords
// (0-100), the same as v2 — NOT absolute points. (Confirmed live:
// absolute values like 237 were read as 237% and placed fields far
// off-page.) Send the percent values straight through.
pageX: f.pageX,
pageY: f.pageY,
pageWidth: f.pageWidth,
pageHeight: f.pageHeight,
// Pass fieldMeta through on v1 too (Documenso 2.x's v1-compat endpoint
// accepts it) so TEXT fields like "Place of Signing" keep their label /
// required / placeholder. Older v1 servers ignore unknown keys.
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
};
// Retry transient failures so one flaky 5xx mid-loop doesn't leave
// the document with a partial field set. 3 attempts at 250 / 500 /
@@ -1381,6 +1491,93 @@ export function computeDefaultSignatureLayout(
}));
}
/**
* EOI page-3 signature-block layout — the six fields template 8 carries, so
* the in-app pathway (local pdf-lib fill + flatten → upload as a Documenso
* document) produces a signed EOI that matches the legacy template output
* exactly. Coordinates are percent of page, captured verbatim from template 8.
*
* Client (signer 1) gets Signature + Name + Place-of-Signing (TEXT) + Date.
* Developer (signer 2) gets Name + Signature. The approver (signer 3) carries
* no fields. `fieldMeta` is passed through to Documenso (v1 + v2) so the
* Place-of-Signing field keeps its label / required / placeholder.
*/
export function computeEoiSignatureLayout(
clientRecipientId: number | string,
developerRecipientId: number | string,
): DocumensoFieldPlacement[] {
return [
{
recipientId: clientRecipientId,
type: 'SIGNATURE',
pageNumber: 3,
pageX: 39.64497370960451,
pageY: 64.81957098456644,
pageWidth: 21.21662173851308,
pageHeight: 4.303685358613111,
fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' },
},
{
recipientId: clientRecipientId,
type: 'NAME',
pageNumber: 3,
pageX: 14.34911393977768,
pageY: 64.81957098456644,
pageWidth: 24.33234194973456,
pageHeight: 4.303685358613111,
fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' },
},
{
recipientId: clientRecipientId,
type: 'TEXT',
pageNumber: 3,
pageX: 14.49704042881816,
pageY: 57.4932908677896,
pageWidth: 24.4807121661721,
pageHeight: 4.40865329418904,
fieldMeta: {
type: 'text',
label: 'Place of Signing',
readOnly: false,
required: true,
textAlign: 'left',
placeholder: 'Anguilla, AI',
characterLimit: 0,
},
},
{
recipientId: clientRecipientId,
type: 'DATE',
pageNumber: 3,
pageX: 39.79290246256028,
pageY: 57.4932908677896,
pageWidth: 21.06824925816024,
pageHeight: 4.40865329418904,
fieldMeta: { type: 'date', fontSize: 10, overflow: 'auto', textAlign: 'left' },
},
{
recipientId: developerRecipientId,
type: 'NAME',
pageNumber: 3,
pageX: 14.34911393977768,
pageY: 72.56877244919716,
pageWidth: 24.33234194973456,
pageHeight: 3.988781551885322,
fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' },
},
{
recipientId: developerRecipientId,
type: 'SIGNATURE',
pageNumber: 3,
pageX: 39.64497370960451,
pageY: 72.56877244919716,
pageWidth: 21.21662173851308,
pageHeight: 3.988781551885322,
fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' },
},
];
}
/**
* Void/cancel a Documenso document.
*

View File

@@ -38,8 +38,10 @@ import {
signingInvitationEmail,
signingReminderEmail,
} from '@/lib/email/templates/document-signing';
import { signingStatusNotificationEmail } from '@/lib/email/templates/signing-status-notification';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { extractSigningToken } from '@/lib/services/documenso-signers';
import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
import { logger } from '@/lib/logger';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -129,9 +131,14 @@ export interface SigningCompletedArgs {
* Risk #5 - fixing this mapping prevents an `approver` invite from
* landing on `/sign/error`.
*/
const ROLE_TO_URL_SEGMENT: Record<SignerRole, 'client' | 'cc' | 'developer' | 'witness'> = {
const ROLE_TO_URL_SEGMENT: Record<string, 'client' | 'cc' | 'developer' | 'witness'> = {
client: 'client',
developer: 'developer',
// `document_signers.signer_role` persists Documenso's normalized role, so
// the order-2 EOI developer arrives here as 'signer' (not 'developer').
// Without this alias the lookup returned `undefined` and the branded link
// became `…/sign/undefined/<token>` (dead). Map it to the developer page.
signer: 'developer',
approver: 'cc',
witness: 'witness',
other: 'cc',
@@ -153,7 +160,9 @@ export function transformSigningUrl(
// Trim trailing slashes off the host so we always produce a clean
// single `/` between segments.
const host = embeddedSigningHost.replace(/\/+$/, '');
const urlRole = ROLE_TO_URL_SEGMENT[signerRole];
// Fall back to the passive `cc` page for any unrecognised role rather than
// ever emitting `…/sign/undefined/<token>`.
const urlRole = ROLE_TO_URL_SEGMENT[signerRole] ?? 'cc';
return `${host}/sign/${urlRole}/${token}`;
}
@@ -329,3 +338,78 @@ export async function sendSigningCancelled(args: SigningCancelledArgs): Promise<
),
);
}
// ─── Internal status notifications (staff "who signed" alerts) ────────────────
export interface SigningStatusNotificationArgs {
portId: string;
portName: string;
/** `signed` = one party just signed; `completed` = all parties done. */
event: 'signed' | 'completed';
documentLabel: string;
/** Deal / client name for context in the subject + body. */
clientName: string;
/** Deep link into the CRM document detail page. */
crmUrl: string;
/** For `signed`: who just signed + their role + running progress. */
signerName?: string | null;
signerRole?: SignerRole | null;
signedCount?: number;
totalCount?: number;
}
/**
* Notify the port's configured signing-notification recipients (the admin
* + sales@, plus any extras) that a party signed or that a document is
* fully signed. CRM equivalent of the legacy "Document Signed" /
* "EOI Complete Update Status" Activepieces flows.
*
* Recipients come from the `signing_notification_recipients` setting
* (users / roles / emails), falling back to the port's reply-to address
* (`email_reply_to`) so the alert is never silently dropped. No-op when
* nothing resolves. Per-recipient send so the internal list isn't exposed
* across recipients; failures are logged, never thrown (the webhook /
* completion path must not be undone by an email hiccup).
*/
export async function sendSigningStatusNotification(
args: SigningStatusNotificationArgs,
): Promise<void> {
const recipients = await resolveNotificationRecipients(
args.portId,
'signing_notification_recipients',
'email_reply_to',
);
if (recipients.length === 0) return;
const branding = await getBrandingShell(args.portId);
const { subject, html, text } = await signingStatusNotificationEmail(
{
event: args.event,
documentLabel: args.documentLabel,
clientName: args.clientName,
portName: args.portName,
crmUrl: args.crmUrl,
signerName: args.signerName ?? null,
signerRole: args.signerRole ?? null,
signedCount: args.signedCount,
totalCount: args.totalCount,
},
{ branding },
);
const sendLimit = pLimit(3);
await Promise.all(
recipients.map((to) =>
sendLimit(async () => {
try {
await sendEmail(to, subject, html, undefined, text, args.portId);
} catch (err) {
logger.error(
{ err, portId: args.portId, recipient: to, event: args.event },
'Signing status notification send failed',
);
}
}),
),
);
}

View File

@@ -17,11 +17,14 @@ import { emitToRoom } from '@/lib/socket/server';
import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { getCountryName } from '@/lib/i18n/countries';
import {
createDocument as documensoCreate,
sendDocument as documensoSend,
generateDocumentFromTemplate as documensoGenerateFromTemplate,
placeFields as documensoPlaceFields,
computeEoiSignatureLayout,
} from '@/lib/services/documenso-client';
import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
@@ -714,7 +717,15 @@ async function generateAndSignViaInApp(
}
const pdfBase64 = Buffer.concat(chunks).toString('base64');
// Create Documenso document
// Per-port Documenso config for the post-signing redirect + signing order
// (parity with the documenso-template pathway).
const docCfg = await getPortDocumensoConfig(portId);
// Create the Documenso document from the locally-filled + flattened PDF.
// Because the detail fields are flattened by pdf-lib (clean 12pt + multiline
// address wrapping), Documenso never re-renders them — it only collects
// signatures. This is what fixes the auto-sized/clipped detail text the
// Documenso template-fill pathway produced.
const documensoDoc = await documensoCreate(
template.name,
pdfBase64,
@@ -724,10 +735,38 @@ async function generateAndSignViaInApp(
role: s.role,
signingOrder: s.signingOrder,
})),
portId,
{
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
},
);
// Place the EOI page-3 signature block. The flattened PDF carries no
// Documenso fields, so place the six fields (client Signature/Name/
// Place-of-Signing/Date, developer Name/Signature) at template 8's
// coordinates, mapped by signing order (1 = client, 2 = developer; the
// approver signs no fields).
if (template.templateType === 'eoi') {
const byOrder = new Map(documensoDoc.recipients.map((r) => [r.signingOrder, r.id]));
const clientRecipientId = byOrder.get(1);
const developerRecipientId = byOrder.get(2);
if (clientRecipientId && developerRecipientId) {
await documensoPlaceFields(
documensoDoc.id,
computeEoiSignatureLayout(clientRecipientId, developerRecipientId),
portId,
);
} else {
logger.warn(
{ docId: documensoDoc.id, recipients: documensoDoc.recipients.length },
'EOI in-app pathway: could not resolve client/developer recipients for signature-field placement',
);
}
}
// Send document for signing
await documensoSend(documensoDoc.id);
await documensoSend(documensoDoc.id, portId);
// Update our document record with Documenso ID and status
await db
@@ -740,6 +779,46 @@ async function generateAndSignViaInApp(
})
.where(eq(documents.id, documentRecord.id));
// Persist per-recipient signer rows so the EOI tab's signing-progress panel
// and the webhook handler (which matches by token / email) work — parity
// with the documenso-template pathway. Strip the `(was: …)` /
// `(placeholder)` suffixes EMAIL_REDIRECT_TO bakes into names.
if (documensoDoc.recipients.length > 0) {
await db.insert(documentSigners).values(
documensoDoc.recipients.map((r) => {
const cleanName = (r.name || r.email)
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
.trim();
const role =
r.role.toUpperCase() === 'SIGNER' && r.signingOrder === 1
? 'client'
: normalizeSignerRole(r.role);
return {
documentId: documentRecord.id,
signerName: cleanName || r.email,
signerEmail: r.email,
signerRole: role,
signingOrder: r.signingOrder,
status: 'pending' as const,
signingUrl: r.signingUrl ?? null,
embeddedUrl: r.embeddedUrl ?? null,
signingToken: r.token ?? null,
invitedAt: null,
};
}),
);
}
// Stamp the interest's EOI milestone so the Overview tab flips to
// "EOI sent / awaiting signatures" — parity with the template pathway.
if (context.interestId) {
await db
.update(interests)
.set({ eoiDocStatus: 'sent', dateEoiSent: new Date(), updatedAt: new Date() })
.where(eq(interests.id, context.interestId));
}
void createAuditLog({
userId: meta.userId,
portId,
@@ -794,48 +873,152 @@ async function generateAndSignViaDocumensoTemplate(
// platform to one Documenso instance per CRM process.
const docCfg = await getPortDocumensoConfig(portId);
// v2 prefillFields-by-ID emission requires a field-name → field-ID map
// populated by the admin "Sync from Documenso" button. Absent (or partial)
// map → payload skips prefillFields and v2 accepts the legacy formValues
// shape via backward compat.
const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service');
const fieldMap = await getEoiFieldMap(portId);
// Pick which side of the yacht's stored dimensions ships to Documenso.
// Pick which side of the yacht's stored dimensions ships to the PDF.
// The drawer's toggle drives this; if the caller omitted it, default to
// whichever unit the rep originally typed in (yacht.lengthUnit). Legacy
// yachts without a unit column default to 'ft'.
const dimensionUnit: 'ft' | 'm' = options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft';
const payload = buildDocumensoPayload(
eoiContext,
// Document title used by both fill methods + the documents row.
const docTitle = `Expression of Interest ${eoiContext.client.fullName}`;
let documensoDoc;
let localFileId: string | null = null;
if (docCfg.eoiFillMethod === 'local') {
// LOCAL-FILL (default): fill + flatten the source PDF ourselves (pdf-lib,
// fixed 12pt + multiline address wrapping), upload the flattened PDF to
// Documenso as a document, and place ONLY the page-3 signature fields.
// Documenso never renders the body text, so it can't auto-size/clip it —
// this is the fix for the oversized/clipped detail fields the Documenso
// template-fill produced. Still flows through Documenso for signing, so
// branded invites, embedded signing, webhooks, and emails are unchanged.
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, { dimensionUnit });
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(
port?.slug ?? portId,
'eoi',
context.interestId,
fileId,
'pdf',
);
{
interestId: context.interestId,
clientRecipientId: docCfg.clientRecipientId,
developerRecipientId: docCfg.developerRecipientId,
approvalRecipientId: docCfg.approvalRecipientId,
developerName: signers.developer.name,
developerEmail: signers.developer.email,
approverName: signers.approver.name,
approverEmail: signers.approver.email,
// Prefer per-port post-signing redirect (typically marketing-site
// /sign/success on v2). Falls back to APP_URL on v1 / when unset.
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
// v2-only signing-order enforcement. v1 instances ignore this key.
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
dimensionUnit,
},
fieldMap,
);
const buffer = Buffer.from(pdfBytes);
const backend = await getStorageBackend();
await backend.put(storagePath, buffer, {
contentType: 'application/pdf',
sizeBytes: buffer.length,
});
}
const [fileRecord] = await db
.insert(files)
.values({
portId,
clientId: context.clientId ?? null,
filename: 'expression-of-interest.pdf',
originalName: 'Expression of Interest.pdf',
mimeType: 'application/pdf',
sizeBytes: String(pdfBytes.byteLength),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'eoi',
uploadedBy: meta.userId,
})
.returning();
localFileId = fileRecord!.id;
const documensoDoc = await documensoGenerateFromTemplate(
docCfg.eoiTemplateId,
payload as unknown as Record<string, unknown>,
portId,
);
const created = await documensoCreate(
docTitle,
Buffer.from(pdfBytes).toString('base64'),
[
{
name: eoiContext.client.fullName,
email: eoiContext.client.primaryEmail ?? '',
role: 'signer',
signingOrder: 1,
},
{
name: signers.developer.name,
email: signers.developer.email,
role: 'signer',
signingOrder: 2,
},
{
name: signers.approver.name,
email: signers.approver.email,
role: 'approver',
signingOrder: 3,
},
],
portId,
{
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
},
);
// Record a documents row referencing the Documenso document. No local file -
// Documenso owns the PDF and delivers signed copies via webhook (handled elsewhere).
// Place the six page-3 signature fields at template-8 coordinates, mapped
// by signing order (1 = client, 2 = developer; approver signs no fields).
const byOrder = new Map(created.recipients.map((r) => [r.signingOrder, r.id]));
const clientRid = byOrder.get(1);
const developerRid = byOrder.get(2);
if (clientRid && developerRid) {
await documensoPlaceFields(
created.id,
computeEoiSignatureLayout(clientRid, developerRid),
portId,
);
} else {
logger.warn(
{ docId: created.id, recipients: created.recipients.length },
'EOI local-fill: could not resolve client/developer recipients for field placement',
);
}
// v2 envelopes don't return signing URLs until distribute; v1 returns them
// on create. Distribute (suppressing Documenso's own emails via
// distributionMethod:NONE on v2 / DRAFT-stays-quiet on v1) only when
// they're missing, so document_signers.signing_url is populated for the
// branded "Send invitation" flow regardless of API version.
const needsDistribute = created.recipients.some((r) => !r.signingUrl);
documensoDoc = needsDistribute ? await documensoSend(created.id, portId) : created;
} else {
// DOCUMENSO TEMPLATE FILL (legacy fallback, eoi_fill_method='documenso'):
// Documenso fills the template's AcroForm fields from the payload. Note it
// auto-sizes/clips long values — kept only as a per-port escape hatch.
// v2 prefillFields-by-ID needs a field-name → field-ID map from the admin
// "Sync from Documenso" button; absent it, v2 ignores the legacy formValues.
const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service');
const fieldMap = await getEoiFieldMap(portId);
const payload = buildDocumensoPayload(
eoiContext,
{
interestId: context.interestId,
clientRecipientId: docCfg.clientRecipientId,
developerRecipientId: docCfg.developerRecipientId,
approvalRecipientId: docCfg.approvalRecipientId,
developerName: signers.developer.name,
developerEmail: signers.developer.email,
approverName: signers.approver.name,
approverEmail: signers.approver.email,
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
dimensionUnit,
},
fieldMap,
);
documensoDoc = await documensoGenerateFromTemplate(
docCfg.eoiTemplateId,
payload as unknown as Record<string, unknown>,
portId,
);
}
// Record a documents row referencing the Documenso document. Local-fill
// attaches the flattened PDF we stored; template-fill has no local file
// (Documenso owns the PDF; signed copy arrives via webhook).
const [documentRecord] = await db
.insert(documents)
.values({
@@ -843,8 +1026,9 @@ async function generateAndSignViaDocumensoTemplate(
clientId: context.clientId ?? null,
interestId: context.interestId,
documentType: 'eoi',
title: payload.title,
title: docTitle,
status: 'sent',
fileId: localFileId,
documensoId: documensoDoc.id,
documensoNumericId: documensoDoc.numericId,
isManualUpload: false,

View File

@@ -45,6 +45,8 @@ import {
import {
sendSigningInvitation,
sendSigningCompleted,
sendSigningStatusNotification,
transformSigningUrl,
type SignerRole,
} from '@/lib/services/document-signing-emails.service';
import {
@@ -1062,10 +1064,23 @@ export async function uploadSignedManually(
export async function listDocumentSigners(documentId: string, portId: string) {
await getDocumentById(documentId, portId); // verify access
return db.query.documentSigners.findMany({
const rows = await db.query.documentSigners.findMany({
where: eq(documentSigners.documentId, documentId),
orderBy: (ds, { asc }) => [asc(ds.signingOrder)],
});
// Surface the BRANDED marketing-site embed URL (the same wrap the
// invitation email applies) rather than the bare Documenso link, so the
// EOI tab's "Copy link" shares the on-brand signing page. No-op when the
// port has no embeddedSigningHost configured (transformSigningUrl returns
// the raw URL unchanged).
const { embeddedSigningHost } = await getPortDocumensoConfig(portId);
return rows.map((r) => ({
...r,
signingUrl: r.signingUrl
? transformSigningUrl(r.signingUrl, embeddedSigningHost, r.signerRole as SignerRole)
: r.signingUrl,
}));
}
// ─── List Events ──────────────────────────────────────────────────────────────
@@ -1248,6 +1263,14 @@ export async function handleRecipientSigned(eventData: {
'cascading "your turn" invite failed after recipient signed',
);
});
// Internal "who signed" alert to the port's signing-notification
// recipients (admin + sales@). Fire-and-forget + fully guarded inside
// the helper so it can't undo the signing that just succeeded.
void notifySigningStatus(doc, 'signed', {
name: signer.signerName,
role: signer.signerRole,
});
}
}
@@ -1345,6 +1368,70 @@ async function sendCascadingInviteForNextSigner(doc: {
}
}
/**
* Fire the internal "signing progress" alert to the port's configured
* signing-notification recipients (admin + sales@, etc). Self-contained
* and fully guarded — a notification failure must never undo a signing /
* completion side effect, so all errors are swallowed + logged. Resolves
* the deal client name + a deep CRM link, and (for `signed`) the running
* signed/total progress.
*/
async function notifySigningStatus(
doc: Parameters<typeof resolveDocumentOwner>[1] & {
id: string;
portId: string;
documentType: string;
title: string;
},
event: 'signed' | 'completed',
signer?: { name: string; role: string | null } | null,
): Promise<void> {
try {
const port = await db.query.ports.findFirst({
where: eq(ports.id, doc.portId),
columns: { name: true, slug: true },
});
let clientName = doc.title;
const owner = await resolveDocumentOwner(doc.portId, doc);
if (owner?.entityType === 'client') {
const client = await db.query.clients.findFirst({
where: eq(clients.id, owner.entityId),
columns: { fullName: true },
});
if (client?.fullName) clientName = client.fullName;
}
let signedCount: number | undefined;
let totalCount: number | undefined;
if (event === 'signed') {
const all = await db
.select({ status: documentSigners.status })
.from(documentSigners)
.where(eq(documentSigners.documentId, doc.id));
totalCount = all.length;
signedCount = all.filter((s) => s.status === 'signed').length;
}
const crmUrl = `${env.APP_URL ?? ''}/${port?.slug ?? ''}/documents/${doc.id}`;
await sendSigningStatusNotification({
portId: doc.portId,
portName: port?.name ?? 'Port Nimara',
event,
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
clientName,
crmUrl,
signerName: signer?.name ?? null,
signerRole: (signer?.role as SignerRole) ?? null,
signedCount,
totalCount,
});
} catch (err) {
logger.error({ err, documentId: doc.id, event }, 'signing status notification failed');
}
}
/**
* Manually (re)send the finalized signed PDF of a completed document to the
* deal's client. Mirrors the automatic completion fan-out (sendSigningCompleted)
@@ -1924,6 +2011,11 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
}),
);
}
// Internal "fully signed" alert to the port's signing-notification
// recipients (admin + sales@). handleDocumentCompleted is idempotent
// (early-returns on re-delivery), so this fires exactly once per doc.
void notifySigningStatus(doc, 'completed');
}
export async function handleDocumentExpired(eventData: { documentId: string; portId?: string }) {

View File

@@ -49,6 +49,11 @@ export const SETTING_KEYS = {
// timing-safe comparison.
documensoWebhookSecret: 'documenso_webhook_secret',
eoiDefaultPathway: 'eoi_default_pathway',
// EOI body-text fill method: 'local' (CRM fills + flattens the PDF, clean
// 12pt + multiline address wrap, Documenso signs only) vs 'documenso'
// (legacy: Documenso fills the template AcroForm fields and auto-sizes /
// clips them). Toggleable per-port in admin → Documenso.
eoiFillMethod: 'eoi_fill_method',
// Identity of the developer + approver that the template's static
// recipient slots get filled with. Old system hardcoded these
// (David Mizrahi, Abbie May @ portnimara.com) but multi-port deploys
@@ -316,6 +321,16 @@ export interface PortDocumensoConfig {
apiUrlSource: 'port' | 'global' | 'env' | 'default' | 'none';
eoiTemplateId: number;
defaultPathway: EoiPathway;
/**
* EOI body-text fill method:
* - 'local' : CRM fills + flattens the source PDF (pdf-lib, fixed 12pt +
* multiline address wrapping), then uploads the flattened PDF
* to Documenso for signature placement only. Renders cleanly.
* - 'documenso': legacy — Documenso fills the template's AcroForm fields via
* the template-generate API (auto-sizes the text → clips it).
* Toggleable per-port in admin → Documenso. Defaults to 'local'.
*/
eoiFillMethod: 'local' | 'documenso';
/** Documenso template recipient slot IDs (per-instance numeric). */
clientRecipientId: number;
developerRecipientId: number;
@@ -387,6 +402,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
developerRecipientId,
approvalRecipientId,
defaultPathway,
eoiFillMethod,
developerName,
developerEmail,
approverName,
@@ -411,6 +427,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
readSetting<string | number>(SETTING_KEYS.documensoDeveloperRecipientId, portId),
readSetting<string | number>(SETTING_KEYS.documensoApprovalRecipientId, portId),
readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId),
readSetting<'local' | 'documenso'>(SETTING_KEYS.eoiFillMethod, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperName, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperEmail, portId),
readSetting<string>(SETTING_KEYS.documensoApproverName, portId),
@@ -464,6 +481,9 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
approvalRecipientId:
toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID ?? 0,
defaultPathway: defaultPathway ?? 'documenso-template',
// Default to the local-fill method (clean render + address wrapping). Set
// to 'documenso' per-port to fall back to Documenso's template AcroForm fill.
eoiFillMethod: eoiFillMethod === 'documenso' ? 'documenso' : 'local',
developerName: developerName ?? '',
developerEmail: developerEmail ?? '',
approverName: approverName ?? '',

View File

@@ -151,7 +151,12 @@ export async function sendWebsiteSubmissionEmails(
if (kind === 'residence_inquiry') {
if (fields.email) {
const confirmation = await residentialClientConfirmation(
{ firstName: fields.firstName, contactEmail, portName },
{
firstName: fields.firstName,
contactEmail,
residenceTypes: fields.residenceTypes,
portName,
},
{ branding },
);
const subject = await resolveSubject({
@@ -160,7 +165,14 @@ export async function sendWebsiteSubmissionEmails(
fallback: confirmation.subject,
tokens: { portName, recipientName: fields.firstName },
});
await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId);
await sendEmail(
fields.email,
subject,
confirmation.html,
undefined,
confirmation.text,
portId,
);
}
const recipients = await resolveRecipients(portId, 'residential_notification_recipients');
@@ -170,6 +182,8 @@ export async function sendWebsiteSubmissionEmails(
fullName: fields.fullName,
email: fields.email,
phone: fields.phone,
residenceTypes: fields.residenceTypes,
preferredContactMethod: fields.preferredContact ?? undefined,
placeOfResidence: fields.placeOfResidence ?? undefined,
notes: fields.comments ?? undefined,
crmDeepLink: crmUrl,
@@ -183,7 +197,7 @@ export async function sendWebsiteSubmissionEmails(
fallback: alert.subject,
tokens: { portName, clientName: fields.fullName, email: fields.email, phone: fields.phone },
});
await sendEmail(recipients, subject, alert.html, undefined, undefined, portId);
await sendEmail(recipients, subject, alert.html, undefined, alert.text, portId);
}
return;
}
@@ -206,7 +220,11 @@ export async function sendWebsiteSubmissionEmails(
await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId);
}
const recipients = await resolveRecipients(portId, 'inquiry_notification_recipients');
// Contact-form alerts go to their own recipient list (the website routed
// them to hello@ separately from berth alerts). Falls back to
// inquiry_notification_recipients only via the shared resolver's
// inquiry_contact_email fallback when unset.
const recipients = await resolveRecipients(portId, 'contact_notification_recipients');
if (recipients.length > 0) {
const alert = await contactFormSalesAlert(
{

View File

@@ -24,6 +24,10 @@ export interface InquiryFields {
comments: string | null;
/** The contact form's `interest` (string or string[]) joined for display. */
interestType: string | null;
/** The residence form's `residence_types` multi-select (villa types chosen). */
residenceTypes: string[];
/** The register form's `method_of_contact` preference: 'email' | 'phone'. */
preferredContact: 'email' | 'phone' | null;
}
function str(value: unknown): string {
@@ -44,6 +48,20 @@ export function extractInquiryFields(payload: Record<string, unknown>): InquiryF
? rawInterest.filter((v): v is string => typeof v === 'string').join(', ') || null
: str(rawInterest) || null;
// The residence form posts `residence_types` as an array of villa-type
// strings. Defensively coerce a lone string to a single-item array so a
// future single-select form variant still maps cleanly.
const rawResidenceTypes = payload.residence_types;
const residenceTypes = Array.isArray(rawResidenceTypes)
? rawResidenceTypes.filter((v): v is string => typeof v === 'string' && v.trim() !== '')
: str(rawResidenceTypes)
? [str(rawResidenceTypes)]
: [];
const methodOfContact = str(payload.method_of_contact).toLowerCase();
const preferredContact =
methodOfContact === 'email' ? 'email' : methodOfContact === 'phone' ? 'phone' : null;
return {
firstName,
lastName,
@@ -54,5 +72,7 @@ export function extractInquiryFields(payload: Record<string, unknown>): InquiryF
placeOfResidence,
comments,
interestType,
residenceTypes,
preferredContact,
};
}

View File

@@ -204,6 +204,20 @@ export const REGISTRY: SettingEntry[] = [
scope: 'port',
defaultValue: 'documenso-template',
},
{
key: 'eoi_fill_method',
section: 'documenso.templates',
label: 'EOI form fill method',
description:
"How the EOI's detail fields (name, address, yacht, berth) get filled in. Local fill (recommended) = the CRM fills + flattens the PDF itself at a fixed 12pt with multiline address wrapping, then sends it to Documenso for signatures only — text renders cleanly. Documenso template fill = Documenso fills the template's form fields and auto-sizes the text, which oversizes/clips long values. Both still go through Documenso for signing, branded emails, and embedded signing.",
type: 'radio',
options: [
{ value: 'local', label: 'Local fill — clean text + address wrapping (recommended)' },
{ value: 'documenso', label: 'Documenso template fill — legacy (may clip long values)' },
],
scope: 'port',
defaultValue: 'local',
},
{
key: 'eoi_send_mode',
section: 'documenso.templates',
@@ -688,7 +702,7 @@ export const REGISTRY: SettingEntry[] = [
section: 'operations.intake',
label: 'CRM-owned website inquiry emails',
description:
'When enabled, the CRM sends the registrant confirmation + staff alert for inquiries captured from the marketing website (/api/public/website-inquiries), reusing the branded inquiry templates and the per-port From address. Leave OFF until cutover so the website keeps sending its own emails and we never double-send. Recipients come from inquiry_notification_recipients / residential_notification_recipients (fallback inquiry_contact_email).',
'When enabled, the CRM sends the registrant confirmation + staff alert for inquiries captured from the marketing website (/api/public/website-inquiries), reusing the branded inquiry templates and the per-port From address. Leave OFF until cutover so the website keeps sending its own emails and we never double-send. Recipients come from inquiry_notification_recipients (berth) / contact_notification_recipients (contact form) / residential_notification_recipients (residences), each falling back to inquiry_contact_email.',
type: 'boolean',
scope: 'port',
defaultValue: false,

View File

@@ -69,12 +69,28 @@ export const DEFAULT_RESIDENTIAL_PIPELINE_STAGES = [
/** Backwards-compat alias kept for any existing imports. */
export const PIPELINE_STAGES = DEFAULT_RESIDENTIAL_PIPELINE_STAGES;
/**
* Residence unit types offered at Port Nimara. Single source of truth for the
* residential interest's `residenceType` field + the residential UI select.
* Mirrors (intentionally duplicated, separate repo) the website register form's
* multi-select options.
*/
export const RESIDENCE_TYPES = [
'Two Bedroom Marina Villa',
'Four Bedroom Oceanfront Villa',
'Five Bedroom Oceanfront Villa',
] as const;
export const createResidentialInterestSchema = z.object({
residentialClientId: z.string().min(1),
pipelineStage: z.string().optional().default('new'),
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
notes: z.string().optional(),
preferences: z.string().optional(),
// Accept the known unit types or null/'' (cleared via the inline select).
residenceType: z
.preprocess((v) => (v === '' ? null : v), z.enum(RESIDENCE_TYPES).nullable())
.optional(),
assignedTo: z.string().optional(),
});

View File

@@ -27,8 +27,16 @@ function buildCspWithNonce(nonce: string, isProd: boolean): string {
scriptSrc,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
// https: so react-pdf/pdf.js can pull its standard-font pack (the PDF
// viewers fetch LiberationSans etc. from a CDN) and port-branding fonts.
"font-src 'self' data: https:",
connectSrc,
// PDF previews (signed EOIs etc.) iframe a presigned storage URL, and the
// embedded-signing card iframes the Documenso host. Both are per-port /
// per-env hosts, so allow https: (matching img-src/connect-src). This is
// what WE may embed; frame-ancestors 'none' below still blocks others
// from embedding us.
"frame-src 'self' blob: https:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",

View File

@@ -0,0 +1,155 @@
/**
* Responsive overflow / cutoff sweep.
*
* Walks the key pages at desktop / tablet / mobile / small-mobile viewports and
* programmatically flags layout bugs the eye looks for on small screens:
* - horizontal overflow (document wider than the viewport → off-screen content,
* a horizontal scrollbar),
* - individual elements whose right edge runs past the viewport (clipped /
* off-screen buttons + text),
* - elements overflowing the BOTTOM of their own box (cut-off text).
* Captures a full-page screenshot per page/viewport for eyeball QC.
*
* Runs as `admin` (sees every page). Layout is role-independent, so one broad
* role surfaces the responsive issues; role-specific nav scoping is covered by
* role-access.spec.ts.
*/
import { test, expect } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
const PORT = 'port-nimara';
const OUT = join(process.cwd(), '.audit', 'responsive');
const ADMIN = { email: 'admin@portnimara.test', pw: 'SuperAdmin12345!' };
const VIEWPORTS = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'tablet', width: 820, height: 1180 },
{ name: 'mobile', width: 390, height: 844 },
{ name: 'small', width: 360, height: 740 },
] as const;
const PAGES = [
'dashboard',
'clients',
'interests',
'inquiries',
'berths',
'yachts',
'companies',
'reports',
'reports/financial',
'documents',
'expenses',
'inbox',
'settings',
'admin',
'admin/users',
];
test.describe('Responsive overflow sweep', () => {
test('admin — every key page at every viewport, flag overflow + cutoff', async ({ page }) => {
test.setTimeout(600_000);
mkdirSync(OUT, { recursive: true });
const res = await page.request.post('/api/auth/sign-in/email', {
data: { email: ADMIN.email, password: ADMIN.pw },
headers: { 'content-type': 'application/json' },
});
expect(res.ok()).toBeTruthy();
const findings: string[] = [];
for (const vp of VIEWPORTS) {
await page.setViewportSize({ width: vp.width, height: vp.height });
for (const p of PAGES) {
const url = `/${PORT}/${p}`;
const slug = p.replace(/\//g, '_');
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
} catch {
findings.push(`NAV-FAIL ${vp.name.padEnd(7)} ${p}`);
continue;
}
// let layout settle + data paint
await page.waitForTimeout(1800);
const report = await page.evaluate((vpWidth) => {
const docW = document.documentElement.scrollWidth;
const innerW = window.innerWidth;
const horizOverflow = docW - innerW;
// Elements whose right edge runs past the viewport by > 2px and are
// actually visible (have size, not display:none).
const offscreen: { tag: string; cls: string; right: number; text: string }[] = [];
const SVG_INTERNAL = new Set([
'svg',
'g',
'ellipse',
'path',
'circle',
'rect',
'line',
'polyline',
'polygon',
]);
const els = document.querySelectorAll('body *');
for (const el of els) {
const tag = el.tagName.toLowerCase();
// Skip SVG internals (icons, the react-grab dev overlay, chart guts)
// — not layout-cutoff signal.
if (SVG_INTERNAL.has(tag)) continue;
const r = (el as HTMLElement).getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (r.right > vpWidth + 2 && r.left < vpWidth) {
// Skip elements inside a horizontal-scroll container (data tables
// etc. scroll on purpose) — that's intended, not a clip.
let p: HTMLElement | null = el.parentElement;
let inScroll = false;
while (p) {
const ox = getComputedStyle(p).overflowX;
if (ox === 'auto' || ox === 'scroll') {
inScroll = true;
break;
}
p = p.parentElement;
}
if (inScroll) continue;
const cls = ((el as HTMLElement).className || '').toString().slice(0, 40);
const text = (el.textContent || '').trim().slice(0, 30);
offscreen.push({ tag, cls, right: Math.round(r.right), text });
}
}
// de-dupe by tag+text, cap
const seen = new Set<string>();
const uniq = offscreen
.filter((o) => {
const k = `${o.tag}:${o.text}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
})
.slice(0, 6);
return { horizOverflow, docW, innerW, offscreen: uniq };
}, vp.width);
await page
.screenshot({ path: join(OUT, `admin-${vp.name}-${slug}.png`), fullPage: true })
.catch(() => {});
const flagged = report.horizOverflow > 3 || report.offscreen.length > 0;
const line = `${flagged ? 'OVERFLOW' : 'ok '} ${vp.name.padEnd(7)} ${p.padEnd(18)} hScroll=${report.horizOverflow}px doc=${report.docW}/${report.innerW}`;
console.log(line);
if (report.offscreen.length) {
for (const o of report.offscreen) {
console.log(` ↳ off-right ${o.tag} right=${o.right} "${o.text}" .${o.cls}`);
}
}
if (flagged) findings.push(line);
}
}
console.log(`\n=== OVERFLOW FINDINGS (${findings.length}) ===`);
for (const f of findings) console.log(f);
});
});

View File

@@ -0,0 +1,138 @@
/**
* Role × viewport access matrix.
*
* A LEAN, crash-safe alternative to running the full 162-test smoke suite
* (which OOM-crashes `next dev` locally). For each of the 5 core roles it:
* - logs in (UI),
* - probes a fixed set of API endpoints in the authenticated session and
* records the HTTP status (the read/permission matrix),
* - records which sidebar nav sections are visible,
* - screenshots the dashboard at desktop / tablet / mobile viewports.
*
* Few route compilations per run, so the dev server stays up. Users are
* pre-seeded (admin/director/sales/viewer/residential_partner); no global
* setup dependency.
*/
import { test, expect } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
const PORT = 'port-nimara';
const OUT = join(process.cwd(), '.audit', 'matrix');
const ROLES = [
{ key: 'super_admin', email: 'admin@portnimara.test', pw: 'SuperAdmin12345!' },
{ key: 'director', email: 'director@portnimara.test', pw: 'DirectorUser12345!' },
{ key: 'sales', email: 'mpciaccio13@verizon.net', pw: 'SallySales12345!' },
{ key: 'viewer', email: 'viewer@portnimara.test', pw: 'ViewerUser12345!' },
{ key: 'residential_partner', email: 'respartner@portnimara.test', pw: 'ResPartner12345!' },
] as const;
const VIEWPORTS = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'tablet', width: 820, height: 1180 },
{ name: 'mobile', width: 390, height: 844 },
] as const;
// GET probes — expected status varies by role; we just record what we get.
const PROBES: { label: string; path: string }[] = [
{ label: 'clients.view', path: '/api/v1/clients?limit=1' },
{ label: 'interests.view', path: '/api/v1/interests?limit=1' },
{ label: 'yachts.view', path: '/api/v1/yachts?limit=1' },
{ label: 'reports.financial', path: '/api/v1/reports/financial' },
{ label: 'alerts(interests.view)', path: '/api/v1/alerts?status=open' },
{ label: 'residential.clients', path: '/api/v1/residential/clients?limit=1' },
{ label: 'admin.users', path: '/api/v1/admin/users' },
{ label: 'admin.audit', path: '/api/v1/admin/audit?limit=1' },
{ label: 'admin.onboarding', path: '/api/v1/admin/onboarding/status' },
];
async function login(page: import('@playwright/test').Page, email: string, pw: string) {
// Authenticate via the API (better-auth sign-in) rather than the UI: the
// dev-mode login page hydrates slowly and a pre-hydration click submits the
// form as a native GET. page.request shares the cookie jar with the page
// context, so after this the page's navigations + fetches are authenticated.
const res = await page.request.post('/api/auth/sign-in/email', {
data: { email, password: pw },
headers: { 'content-type': 'application/json' },
});
if (!res.ok()) {
throw new Error(`API login failed for ${email}: ${res.status()} ${await res.text()}`);
}
}
test.describe('Role × viewport access matrix', () => {
// Independent tests — a flake in one role must not skip the others.
for (const role of ROLES) {
test(`${role.key} — access matrix + nav + viewport renders`, async ({ page }) => {
mkdirSync(OUT, { recursive: true });
test.setTimeout(120_000);
await login(page, role.email, role.pw);
// Land on the app (authenticated via the shared cookie) so in-page
// fetch() has the right origin + the sidebar nav is present.
await page.goto(`/${PORT}/dashboard`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
// 1. API access matrix (authenticated fetch in-page). Non-super-admins
// need the X-Port-Id header (apiFetch adds it) or every route 400s on
// "Port context required" — resolve it via /me/ports first.
const matrix = await page.evaluate(async (probes) => {
let portId = '';
try {
const pr = await fetch('/api/v1/me/ports', { headers: { accept: 'application/json' } });
const pj = (await pr.json()) as { data?: { id: string; slug: string }[] };
portId =
(pj.data ?? []).find((p) => p.slug === 'port-nimara')?.id ??
(pj.data ?? [])[0]?.id ??
'';
} catch {
/* leave empty */
}
const out: Record<string, number | string> = { _port: portId ? 'ok' : 'MISSING' };
for (const p of probes) {
try {
const r = await fetch(p.path, {
headers: { accept: 'application/json', 'X-Port-Id': portId },
});
out[p.label] = r.status;
} catch {
out[p.label] = -1;
}
}
return out;
}, PROBES);
// 2. Visible nav sections
const nav = await page.evaluate(() =>
[...document.querySelectorAll('nav a')].map((a) => a.getAttribute('href')).filter(Boolean),
);
const hasAdminNav = nav.some((h) => h?.includes('/admin'));
const hasResidentialNav = nav.some((h) => h?.includes('/residential'));
console.log(`\n=== ROLE: ${role.key} ===`);
console.log(' access:', JSON.stringify(matrix));
console.log(
` nav: adminSection=${hasAdminNav} residentialSection=${hasResidentialNav} count=${nav.length}`,
);
// 3. Viewport renders — dashboard + clients at each size
for (const vp of VIEWPORTS) {
await page.setViewportSize({ width: vp.width, height: vp.height });
for (const path of [`/${PORT}/dashboard`, `/${PORT}/clients`]) {
const slug = path.split('/').pop();
const resp = await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
await page
.screenshot({ path: join(OUT, `${role.key}-${vp.name}-${slug}.png`), fullPage: false })
.catch(() => {});
console.log(` render ${vp.name} ${slug}: http=${resp?.status()}`);
}
}
// Sanity: every role can at least reach its landing without a hard error.
expect(matrix['clients.view'] === 200 || matrix['residential.clients'] === 200).toBeTruthy();
});
}
});

View File

@@ -25,9 +25,10 @@ export async function login(page: Page, role: keyof typeof USERS = 'super_admin'
const user = USERS[role];
await page.goto('/login');
await page.waitForSelector('#email', { state: 'visible' });
// The email/username field id is `identifier` (accepts either).
await page.waitForSelector('#identifier', { state: 'visible' });
await page.fill('#email', user.email);
await page.fill('#identifier', user.email);
await page.fill('#password', user.password);
await page.click('button[type="submit"]');

View File

@@ -21,6 +21,7 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
const originalDocumensoUrl = process.env.DOCUMENSO_API_URL;
const originalDocumensoKey = process.env.DOCUMENSO_API_KEY;
const originalDocumensoVersion = process.env.DOCUMENSO_API_VERSION;
let fetchMock: ReturnType<typeof vi.fn>;
@@ -28,6 +29,10 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
process.env.EMAIL_REDIRECT_TO = REDIRECT_TARGET;
process.env.DOCUMENSO_API_URL = 'https://documenso.example.test';
process.env.DOCUMENSO_API_KEY = 'test-key';
// Pin v1 — prod's API version + these assertions read the JSON request
// body. Without this the local .env's DOCUMENSO_API_VERSION leaks in and
// the v2 multipart/FormData path makes JSON.parse(body) throw.
process.env.DOCUMENSO_API_VERSION = 'v1';
fetchMock = vi.fn(async () => ({
ok: true,
@@ -49,6 +54,8 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
else process.env.DOCUMENSO_API_URL = originalDocumensoUrl;
if (originalDocumensoKey === undefined) delete process.env.DOCUMENSO_API_KEY;
else process.env.DOCUMENSO_API_KEY = originalDocumensoKey;
if (originalDocumensoVersion === undefined) delete process.env.DOCUMENSO_API_VERSION;
else process.env.DOCUMENSO_API_VERSION = originalDocumensoVersion;
vi.resetModules();
});
@@ -63,10 +70,72 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
expect(fetchMock).toHaveBeenCalledOnce();
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any;
expect(callBody.recipients).toHaveLength(2);
const namesByOrder = Object.fromEntries(
callBody.recipients.map((r: any) => [r.signingOrder, r.name]),
);
for (const r of callBody.recipients) {
expect(r.email).toBe(REDIRECT_TARGET);
// Original email preserved in the name for traceability
expect(r.name).toMatch(/\(was: .+@realclient\.com\)/);
}
// Name must stay CLEAN — it renders into the signed PDF's Name field, so
// the "(was: …)" redirect annotation must NOT leak into it (it overlapped
// the signature). Email-only redirect; original email lives in the logs.
expect(namesByOrder[1]).toBe('Alice Smith');
expect(namesByOrder[2]).toBe('Bob Smith');
for (const r of callBody.recipients) {
expect(r.name).not.toContain('(was:');
}
});
it('createDocument - suppresses Documenso own emails (emailSettings all false)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.createDocument('Test Doc', 'pdf-base64', [
{ name: 'Alice Smith', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
]);
expect(fetchMock).toHaveBeenCalledOnce();
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any;
// The CRM is the SOLE sender of signing comms. Documenso must never fire
// its own "Waiting for others" / "Signing Complete!" lifecycle emails, so
// every per-document email event is disabled at creation time.
expect(callBody.meta).toBeDefined();
expect(callBody.meta.emailSettings).toBeDefined();
const es = callBody.meta.emailSettings;
for (const key of [
'recipientSigningRequest',
'recipientSigned',
'recipientRemoved',
'documentPending',
'documentCompleted',
'documentDeleted',
'ownerDocumentCreated',
'ownerDocumentCompleted',
'ownerRecipientExpired',
]) {
expect(es[key]).toBe(false);
}
});
it('createDocument (v2) - emailSettings all false in the multipart payload', async () => {
process.env.DOCUMENSO_API_VERSION = 'v2';
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.createDocument('Test Doc', 'pdf-base64', [
{ name: 'Alice Smith', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
]);
// v2 envelope/create is multipart/form-data; the JSON lives in `payload`.
const form = fetchMock.mock.calls[0]![1].body as FormData;
const payload = JSON.parse(form.get('payload') as string) as any;
expect(payload.meta.emailSettings).toBeDefined();
for (const key of [
'recipientSigningRequest',
'recipientSigned',
'documentPending',
'documentCompleted',
'ownerDocumentCompleted',
]) {
expect(payload.meta.emailSettings[key]).toBe(false);
}
});
@@ -102,7 +171,8 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any;
for (const r of callBody.recipients) {
expect(r.email).toBe(REDIRECT_TARGET);
expect(r.name).toMatch(/\(was: .+@realclient\.com\)/);
// Name stays clean — no "(was: …)" annotation (renders into the PDF).
expect(r.name).not.toContain('(was:');
}
});

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert';
describe('contactFormSalesAlert', () => {
it('renders a branded HTML alert with all submitted details + a follow-up link', async () => {
const { subject, html, text } = await contactFormSalesAlert({
fullName: 'Jane Doe',
email: 'jane@example.com',
interestType: 'Owner, Crew',
comments: 'Interested in a berth for a 40m yacht.',
crmDeepLink: 'https://crm.portnimara.com/inquiries/abc',
portName: 'Port Nimara',
});
expect(subject).toContain('Jane Doe');
// Interest-registration style: friendly intro + detail lines + CRM follow-up link.
expect(html).toContain('A new contact-form enquiry has come in');
expect(html).toContain('Jane Doe');
expect(html).toContain('jane@example.com');
expect(html).toContain('Owner, Crew');
expect(html).toContain('Interested in a berth for a 40m yacht.');
expect(html).toContain('to follow up');
// Plain-text part mirrors the interest alert.
expect(text).toContain('A new contact-form enquiry');
expect(text).toContain('Comments: Interested in a berth for a 40m yacht.');
});
it('falls back gracefully when interest + comments are absent', async () => {
const { html, text } = await contactFormSalesAlert({
fullName: 'Bob Smith',
email: 'bob@example.com',
portName: 'Port Nimara',
});
expect(html).toContain('(none provided)');
expect(text).toContain('Comments: (none provided)');
expect(html).not.toContain('Interest:');
});
});

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
residentialClientConfirmation,
residentialSalesAlert,
} from '@/lib/email/templates/residential-inquiry';
describe('residentialClientConfirmation', () => {
it('reflects the chosen residence types in the thank-you copy', async () => {
const { html, text } = await residentialClientConfirmation({
firstName: 'Mia',
contactEmail: 'sales@portnimara.com',
residenceTypes: ['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa'],
portName: 'Port Nimara',
});
expect(html).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa');
expect(text).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa');
expect(html).toContain('Mia');
});
it('falls back to a generic phrase when no types are selected', async () => {
const { html } = await residentialClientConfirmation({
firstName: 'Sam',
contactEmail: 'sales@portnimara.com',
portName: 'Port Nimara',
});
expect(html).toContain('the residences at Port Nimara');
});
});
describe('residentialSalesAlert', () => {
it('renders residence type(s) + preferred contact + comments in the detail-line format', async () => {
const { html, text } = await residentialSalesAlert({
fullName: 'Mia Ng',
email: 'mia@example.com',
phone: '+15551234',
residenceTypes: ['Two Bedroom Marina Villa'],
preferredContactMethod: 'phone',
notes: 'Looking for a winter completion.',
crmDeepLink: 'https://crm.portnimara.com/port-nimara',
portName: 'Port Nimara',
});
// Uniform with the berth/contact alerts: friendly intro + bold detail lines + CRM link.
expect(html).toContain('A new residential enquiry has come in');
expect(html).toContain('Residence type(s):');
expect(html).toContain('Two Bedroom Marina Villa');
expect(html).toContain('Preferred contact:');
expect(html).toContain('Phone call back');
expect(html).toContain('Looking for a winter completion.');
expect(html).toContain('to follow up');
// Plain-text part mirrors the other alerts.
expect(text).toContain('Residence type(s): Two Bedroom Marina Villa');
expect(text).toContain('Preferred contact: Phone call back');
expect(text).toContain('Comments: Looking for a winter completion.');
});
it('omits optional rows cleanly when absent', async () => {
const { html } = await residentialSalesAlert({
fullName: 'Bob Smith',
email: 'bob@example.com',
phone: '+1999',
portName: 'Port Nimara',
});
expect(html).not.toContain('Residence type(s):');
expect(html).not.toContain('Preferred contact:');
expect(html).toContain('Bob Smith');
});
});

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { signingStatusNotificationEmail } from '@/lib/email/templates/signing-status-notification';
describe('signingStatusNotificationEmail', () => {
it('renders a per-signer "has signed" alert with progress + CRM link', async () => {
const { subject, html, text } = await signingStatusNotificationEmail({
event: 'signed',
documentLabel: 'Expression of Interest',
clientName: 'Jane Doe',
portName: 'Port Nimara',
crmUrl: 'https://crm.portnimara.com/port-nimara/documents/abc',
signerName: 'Jane Doe',
signerRole: 'client',
signedCount: 1,
totalCount: 3,
});
// Subject names who signed + the deal so sales can triage at a glance.
expect(subject).toContain('Jane Doe');
expect(subject).toContain('signed');
// Body states the signing event, the document, and progress.
expect(html).toContain('Jane Doe');
expect(html).toContain('has signed');
expect(html).toContain('Expression of Interest');
expect(html).toContain('1 of 3');
// Internal recipients get a deep link into the CRM, not a signing link.
expect(html).toContain('https://crm.portnimara.com/port-nimara/documents/abc');
expect(text).toContain('Jane Doe');
expect(text).toContain('1 of 3');
});
it('renders a completion alert when all parties have signed', async () => {
const { subject, html, text } = await signingStatusNotificationEmail({
event: 'completed',
documentLabel: 'Sales Contract',
clientName: 'Acme Holdings',
portName: 'Port Nimara',
crmUrl: 'https://crm.portnimara.com/port-nimara/documents/xyz',
});
expect(subject).toContain('Acme Holdings');
expect(subject.toLowerCase()).toContain('fully signed');
expect(html).toContain('all parties');
expect(html).toContain('Sales Contract');
expect(html).toContain('Acme Holdings');
expect(html).toContain('https://crm.portnimara.com/port-nimara/documents/xyz');
expect(text).toContain('Acme Holdings');
});
});

View File

@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Documenso 2.13's v1-compat `GET /api/v1/documents/{id}/download` returns
// JSON `{ downloadUrl }` (a presigned S3 URL), NOT raw PDF bytes. The CRM was
// saving that ~500-byte JSON as the "signed PDF" → corrupt file emailed to every
// signer + filed in the CRM. These tests pin the two-step follow behaviour.
vi.mock('@/lib/fetch-with-timeout', () => ({
fetchWithTimeout: vi.fn(),
FetchTimeoutError: class FetchTimeoutError extends Error {
timeoutMs = 0;
},
}));
vi.mock('@/lib/services/port-config', () => ({
getPortDocumensoConfig: vi.fn().mockResolvedValue({
apiUrl: 'https://sig.example.com',
apiKey: 'k',
apiVersion: 'v1',
}),
}));
vi.mock('@/lib/logger', () => ({
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
}));
import { downloadSignedPdf } from '@/lib/services/documenso-client';
import { fetchWithTimeout } from '@/lib/fetch-with-timeout';
const mockFetch = vi.mocked(fetchWithTimeout);
function jsonRes(obj: unknown) {
const bytes = Buffer.from(JSON.stringify(obj));
return {
ok: true,
status: 200,
arrayBuffer: async () => bytes,
text: async () => JSON.stringify(obj),
headers: { get: () => 'application/json' },
} as unknown as Response;
}
function pdfRes(text: string) {
const bytes = Buffer.from(text);
return {
ok: true,
status: 200,
arrayBuffer: async () => bytes,
headers: { get: () => 'application/pdf' },
} as unknown as Response;
}
describe('downloadSignedPdf (v1) — Documenso 2.13 JSON downloadUrl', () => {
beforeEach(() => mockFetch.mockReset());
it('follows the JSON { downloadUrl } and returns the real signed PDF bytes', async () => {
mockFetch
.mockResolvedValueOnce(jsonRes({ downloadUrl: 'https://s3.example/signed.pdf?sig=1' }))
.mockResolvedValueOnce(pdfRes('%PDF-1.7\nreal signed content'));
const buf = await downloadSignedPdf('117', 'port-1');
expect(buf.subarray(0, 5).toString('latin1')).toBe('%PDF-');
expect(buf.toString()).toContain('real signed content');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[1]![0]).toBe('https://s3.example/signed.pdf?sig=1');
});
it('returns raw PDF directly when the endpoint already serves PDF bytes (older v1)', async () => {
mockFetch.mockResolvedValueOnce(pdfRes('%PDF-1.7 direct bytes'));
const buf = await downloadSignedPdf('118', 'port-1');
expect(buf.toString()).toContain('direct bytes');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('throws when the body is neither a PDF nor a downloadUrl JSON', async () => {
mockFetch.mockResolvedValueOnce(jsonRes({ nope: true }));
await expect(downloadSignedPdf('119', 'port-1')).rejects.toThrow();
});
});

View File

@@ -40,6 +40,7 @@ function configurePort(version: 'v1' | 'v2'): void {
apiVersion: version,
eoiTemplateId: 8,
defaultPathway: 'documenso-template',
eoiFillMethod: 'local',
clientRecipientId: 192,
developerRecipientId: 193,
approvalRecipientId: 194,

View File

@@ -44,6 +44,21 @@ describe('transformSigningUrl', () => {
);
});
it("maps 'signer' (Documenso's persisted order-2 role) → /sign/developer/<token>", () => {
// document_signers.signer_role stores Documenso's normalized role, so the
// EOI developer arrives as 'signer'. Regression: this used to fall through
// to `undefined` → dead `…/sign/undefined/<token>` invitation links.
expect(transformSigningUrl(RAW, HOST, 'signer' as never)).toBe(
'https://portnimara.com/sign/developer/vbT8hi3jKQmrFP_LN1WcS',
);
});
it('falls back to /sign/cc/<token> for any unrecognised role (never undefined)', () => {
expect(transformSigningUrl(RAW, HOST, 'mystery-role' as never)).toBe(
'https://portnimara.com/sign/cc/vbT8hi3jKQmrFP_LN1WcS',
);
});
it('maps witness → /sign/witness/<token>', () => {
expect(transformSigningUrl(RAW, HOST, 'witness')).toBe(
'https://portnimara.com/sign/witness/vbT8hi3jKQmrFP_LN1WcS',

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { computeEoiSignatureLayout } from '@/lib/services/documenso-client';
// The EOI moves from the Documenso *template* pathway (Documenso fills the
// AcroForm detail fields and auto-sizes/clips them) to the in-app pathway:
// we fill + flatten the PDF locally, upload it as a Documenso *document*, then
// place ONLY the page-3 signature fields. This layout must match template 8's
// six fields exactly (client: Signature/Name/Place-of-Signing/Date; developer:
// Name/Signature) so the signed EOI looks identical. Coords are percent of page.
describe('computeEoiSignatureLayout', () => {
const CLIENT = 101;
const DEV = 102;
const fields = computeEoiSignatureLayout(CLIENT, DEV);
it('produces exactly the 6 page-3 EOI signature fields', () => {
expect(fields).toHaveLength(6);
expect(fields.every((f) => f.pageNumber === 3)).toBe(true);
});
it('maps client recipient to Signature + Name + Place-of-Signing + Date', () => {
const client = fields.filter((f) => f.recipientId === CLIENT);
expect(client.map((f) => f.type).sort()).toEqual(['DATE', 'NAME', 'SIGNATURE', 'TEXT']);
});
it('maps developer recipient to Name + Signature only', () => {
const dev = fields.filter((f) => f.recipientId === DEV);
expect(dev.map((f) => f.type).sort()).toEqual(['NAME', 'SIGNATURE']);
});
it('carries the Place-of-Signing label + required so the signer is prompted', () => {
const place = fields.find((f) => f.recipientId === CLIENT && f.type === 'TEXT');
expect(place?.fieldMeta?.label).toBe('Place of Signing');
expect(place?.fieldMeta?.required).toBe(true);
});
it('positions fields at template-8 coordinates (page-3 signature block)', () => {
const sig = fields.find((f) => f.recipientId === CLIENT && f.type === 'SIGNATURE');
expect(sig?.pageX).toBeCloseTo(39.645, 2);
expect(sig?.pageY).toBeCloseTo(64.82, 1);
const devSig = fields.find((f) => f.recipientId === DEV && f.type === 'SIGNATURE');
expect(devSig?.pageY).toBeCloseTo(72.57, 1);
});
});

View File

@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Boundaries the sender depends on — mock the I/O edges, exercise the real
// wiring (recipient resolution → template → per-recipient send).
vi.mock('@/lib/email', () => ({ sendEmail: vi.fn().mockResolvedValue(undefined) }));
vi.mock('@/lib/email/branding-resolver', () => ({
getBrandingShell: vi.fn().mockResolvedValue(null),
}));
vi.mock('@/lib/services/notification-recipients', () => ({
resolveNotificationRecipients: vi.fn(),
}));
import { sendEmail } from '@/lib/email';
import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
import { sendSigningStatusNotification } from '@/lib/services/document-signing-emails.service';
const mockSendEmail = vi.mocked(sendEmail);
const mockResolve = vi.mocked(resolveNotificationRecipients);
const baseArgs = {
portId: 'port-1',
portName: 'Port Nimara',
event: 'signed' as const,
documentLabel: 'Expression of Interest',
clientName: 'Jane Doe',
crmUrl: 'https://crm.portnimara.com/port-nimara/documents/abc',
signerName: 'Jane Doe',
signerRole: 'client' as const,
signedCount: 1,
totalCount: 3,
};
describe('sendSigningStatusNotification', () => {
beforeEach(() => {
mockSendEmail.mockClear();
mockResolve.mockReset();
});
it('emails every configured recipient when a signer signs', async () => {
mockResolve.mockResolvedValue(['admin@portnimara.com', 'sales@portnimara.com']);
await sendSigningStatusNotification(baseArgs);
// Resolves from the signing list, falling back to the reply-to address.
expect(mockResolve).toHaveBeenCalledWith(
'port-1',
'signing_notification_recipients',
'email_reply_to',
);
expect(mockSendEmail).toHaveBeenCalledTimes(2);
const recipients = mockSendEmail.mock.calls.map((c) => c[0]);
expect(recipients).toContain('admin@portnimara.com');
expect(recipients).toContain('sales@portnimara.com');
// Subject reflects who signed.
const subject = mockSendEmail.mock.calls[0]?.[1] as string;
expect(subject).toContain('Jane Doe');
// portId threaded through so per-port From + redirect apply.
expect(mockSendEmail.mock.calls[0]?.[5]).toBe('port-1');
});
it('sends nothing when no recipients are configured or resolvable', async () => {
mockResolve.mockResolvedValue([]);
await sendSigningStatusNotification(baseArgs);
expect(mockSendEmail).not.toHaveBeenCalled();
});
it('uses the completion subject for the completed event', async () => {
mockResolve.mockResolvedValue(['sales@portnimara.com']);
await sendSigningStatusNotification({
...baseArgs,
event: 'completed',
signerName: null,
});
const subject = mockSendEmail.mock.calls[0]?.[1] as string;
expect(subject.toLowerCase()).toContain('fully signed');
expect(subject).toContain('Jane Doe');
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
RESIDENCE_TYPES,
createResidentialInterestSchema,
updateResidentialInterestSchema,
} from '@/lib/validators/residential';
describe('residential interest residenceType', () => {
it('accepts a known residence type', () => {
const parsed = createResidentialInterestSchema.parse({
residentialClientId: 'rc_1',
residenceType: 'Two Bedroom Marina Villa',
});
expect(parsed.residenceType).toBe('Two Bedroom Marina Villa');
});
it('coerces empty string to null (inline-select clear)', () => {
const parsed = updateResidentialInterestSchema.parse({ residenceType: '' });
expect(parsed.residenceType).toBeNull();
});
it('accepts explicit null', () => {
const parsed = updateResidentialInterestSchema.parse({ residenceType: null });
expect(parsed.residenceType).toBeNull();
});
it('rejects an unknown residence type', () => {
expect(() =>
createResidentialInterestSchema.parse({
residentialClientId: 'rc_1',
residenceType: 'Penthouse Suite',
}),
).toThrow();
});
it('exposes the three offered unit types', () => {
expect(RESIDENCE_TYPES).toEqual([
'Two Bedroom Marina Villa',
'Four Bedroom Oceanfront Villa',
'Five Bedroom Oceanfront Villa',
]);
});
});

View File

@@ -37,6 +37,31 @@ describe('extractInquiryFields', () => {
expect(f.fullName).toBe('Sam Lee');
});
it('maps residence_types[] + method_of_contact from the register form', () => {
const f = extractInquiryFields({
first_name: 'Mia',
last_name: 'Ng',
email: 'mia@example.com',
interest: 'residences',
residence_types: ['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa'],
method_of_contact: 'phone',
});
expect(f.residenceTypes).toEqual(['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa']);
expect(f.preferredContact).toBe('phone');
});
it('coerces a lone residence_types string to a single-item array and filters blanks', () => {
const f = extractInquiryFields({
residence_types: ['Two Bedroom Marina Villa', '', 7 as unknown as string],
method_of_contact: 'EMAIL',
});
expect(f.residenceTypes).toEqual(['Two Bedroom Marina Villa']);
expect(f.preferredContact).toBe('email');
const single = extractInquiryFields({ residence_types: 'Four Bedroom Oceanfront Villa' });
expect(single.residenceTypes).toEqual(['Four Bedroom Oceanfront Villa']);
});
it('maps a contact form payload (interest[] -> joined interestType + comments)', () => {
const f = extractInquiryFields({
first_name: 'Ann',
@@ -70,6 +95,8 @@ describe('extractInquiryFields', () => {
placeOfResidence: null,
comments: null,
interestType: null,
residenceTypes: [],
preferredContact: null,
});
});
});