Compare commits
12 Commits
adc9802361
...
cutover/we
| Author | SHA1 | Date | |
|---|---|---|---|
| 866930c943 | |||
| 64a488dc15 | |||
| 2bc2cfac6f | |||
| 3f6f845c02 | |||
| fc994cd88b | |||
| e17476f3e3 | |||
| f4cfc5600f | |||
| 0ca9b2c3b5 | |||
| af05bb18dc | |||
| 1c91d76c52 | |||
| 352b2420b7 | |||
| 459c68a2c3 |
@@ -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'",
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)} />
|
||||
))}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'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,
|
||||
};
|
||||
}
|
||||
|
||||
152
src/lib/email/templates/signing-status-notification.tsx
Normal file
152
src/lib/email/templates/signing-status-notification.tsx
Normal 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're receiving this because you'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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
10
src/proxy.ts
10
src/proxy.ts
@@ -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'",
|
||||
|
||||
155
tests/e2e/matrix/responsive-overflow.spec.ts
Normal file
155
tests/e2e/matrix/responsive-overflow.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
138
tests/e2e/matrix/role-access.spec.ts
Normal file
138
tests/e2e/matrix/role-access.spec.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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"]');
|
||||
|
||||
|
||||
@@ -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:');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
39
tests/unit/email/contact-form-alert.test.ts
Normal file
39
tests/unit/email/contact-form-alert.test.ts
Normal 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:');
|
||||
});
|
||||
});
|
||||
68
tests/unit/email/residential-inquiry.test.ts
Normal file
68
tests/unit/email/residential-inquiry.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
50
tests/unit/email/signing-status-notification.test.ts
Normal file
50
tests/unit/email/signing-status-notification.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
79
tests/unit/services/documenso-download-signed-pdf.test.ts
Normal file
79
tests/unit/services/documenso-download-signed-pdf.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
44
tests/unit/services/eoi-signature-layout.test.ts
Normal file
44
tests/unit/services/eoi-signature-layout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
44
tests/unit/validators/residential-interest.test.ts
Normal file
44
tests/unit/validators/residential-interest.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user