Compare commits
19 Commits
93989b1e1d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ba128646e1 | |||
| 32b57354ad | |||
| 9f5810e3df | |||
| b2692839f1 | |||
| caaebd77fa | |||
| 866930c943 | |||
| 64a488dc15 | |||
| 2bc2cfac6f | |||
| 3f6f845c02 | |||
| fc994cd88b | |||
| e17476f3e3 | |||
| f4cfc5600f | |||
| 0ca9b2c3b5 | |||
| af05bb18dc | |||
| 1c91d76c52 | |||
| 352b2420b7 | |||
| 459c68a2c3 | |||
| adc9802361 | |||
| d8f739a7c2 |
@@ -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/,
|
||||
|
||||
@@ -5,11 +5,13 @@ import { listAlertsForPort } from '@/lib/services/alerts.service';
|
||||
|
||||
type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
||||
|
||||
// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent
|
||||
// signals. Gated on admin.view_audit_log - same permission the audit log
|
||||
// page uses.
|
||||
// The alert feed is entirely operational/deal signals (stale interest, hot lead
|
||||
// silent, EOI unsigned, signer overdue, reservation needs agreement, berth
|
||||
// stalled, duplicate/unscanned expense) — there are no audit/security alert
|
||||
// rules. Gated on interests.view so the operational roles that act on these
|
||||
// (sales, director, viewer) see them; external residential partners don't.
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => {
|
||||
withPermission('interests', 'view', async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions';
|
||||
import { getClientArchiveDossier } from '@/lib/services/client-archive-dossier.service';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
|
||||
@@ -10,7 +11,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
* decision points, and warnings.
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'delete', async (_req, ctx, params) => {
|
||||
withPermission('clients', CLIENT_ARCHIVE_ACTION, async (_req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('client');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import {
|
||||
archiveClientWithDecisions,
|
||||
@@ -63,7 +64,7 @@ const decisionsSchema = z.object({
|
||||
});
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('clients', 'delete', async (req, ctx, params) => {
|
||||
withPermission('clients', CLIENT_ARCHIVE_ACTION, async (req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('client');
|
||||
|
||||
@@ -16,8 +16,8 @@ import { createAuditLog } from '@/lib/audit';
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission(
|
||||
'admin',
|
||||
'manage_settings',
|
||||
'clients',
|
||||
'gdpr_export',
|
||||
withRateLimit('exports', async (req, ctx, params) => {
|
||||
try {
|
||||
const url = await getExportDownloadUrl(params.exportId!, ctx.portId);
|
||||
|
||||
@@ -26,8 +26,8 @@ export const GET = withAuth(
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission(
|
||||
'admin',
|
||||
'manage_settings',
|
||||
'clients',
|
||||
'gdpr_export',
|
||||
withRateLimit('exports', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, requestSchema);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getClientById, updateClient, archiveClient } from '@/lib/services/clients.service';
|
||||
@@ -35,7 +36,7 @@ export const PATCH = withAuth(
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('clients', 'delete', async (req, ctx, params) => {
|
||||
withPermission('clients', CLIENT_ARCHIVE_ACTION, async (req, ctx, params) => {
|
||||
try {
|
||||
await archiveClient(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { getClientArchiveDossier } from '@/lib/services/client-archive-dossier.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
@@ -27,7 +28,7 @@ interface PreflightItem {
|
||||
* - surface blockers (e.g. "has unpaid invoices") for the operator
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('clients', 'delete', async (req, ctx) => {
|
||||
withPermission('clients', CLIENT_ARCHIVE_ACTION, async (req, ctx) => {
|
||||
try {
|
||||
const { ids } = await parseBody(req, bodySchema);
|
||||
const items: PreflightItem[] = [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
import { withAuth, withRateLimit } from '@/lib/api/helpers';
|
||||
import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { runBulk } from '@/lib/api/bulk-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
@@ -43,7 +44,9 @@ const bulkSchema = z.discriminatedUnion('action', [
|
||||
]);
|
||||
|
||||
const PERMISSION_BY_ACTION = {
|
||||
archive: 'delete' as const,
|
||||
// Archiving is reversible -> edit-level (see CLIENT_ARCHIVE_ACTION). Not the
|
||||
// destructive `delete`, which only super_admin holds.
|
||||
archive: CLIENT_ARCHIVE_ACTION,
|
||||
add_tag: 'edit' as const,
|
||||
remove_tag: 'edit' as const,
|
||||
};
|
||||
|
||||
@@ -25,7 +25,10 @@ export const GET = withAuth(
|
||||
}
|
||||
|
||||
const result = await listDocuments(ctx.portId, query, {
|
||||
currentUserEmail: ctx.user.email,
|
||||
// The caller's owned signing identities: their login email plus their
|
||||
// signing_email override (if set), so the "awaiting me" tab still
|
||||
// matches documents signed under a shared role mailbox.
|
||||
currentUserEmails: [ctx.user.email, ctx.user.signingEmail].filter((e): e is string => !!e),
|
||||
});
|
||||
|
||||
const { page, limit } = query;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -141,17 +141,33 @@ const KNOWN_SETTINGS: Array<{
|
||||
},
|
||||
{
|
||||
key: 'inquiry_contact_email',
|
||||
label: 'Inquiry Contact Email',
|
||||
label: 'Berth & residence reply-to email',
|
||||
description:
|
||||
'Reply-to email shown in client confirmation emails when a new interest is registered',
|
||||
'Public "reach out to us at …" address shown to clients in berth + residence inquiry confirmation emails. Defaults to sales@portnimara.com when blank.',
|
||||
type: 'string',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'contact_form_contact_email',
|
||||
label: 'Contact-form reply-to email',
|
||||
description:
|
||||
'Public "reach out to us at …" address shown to clients in contact-form confirmation emails. Defaults to hello@portnimara.com when blank.',
|
||||
type: 'string',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
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 +179,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',
|
||||
|
||||
@@ -46,6 +46,7 @@ interface UserFormProps {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
email: string;
|
||||
signingEmail?: string | null;
|
||||
phone: string | null;
|
||||
isActive: boolean;
|
||||
role: { id: string; name: string };
|
||||
@@ -88,11 +89,19 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
const [email, setEmail] = useState(user?.email ?? '');
|
||||
const [originalEmail] = useState(user?.email ?? '');
|
||||
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
|
||||
// Optional signing-identity override (edit-only). The address that
|
||||
// represents this user in signing contexts (EOI signer slot, signing
|
||||
// notifications, "awaiting my signature" tab) without changing their login.
|
||||
const [signingEmail, setSigningEmail] = useState(user?.signingEmail ?? '');
|
||||
const [originalSigningEmail] = useState(user?.signingEmail ?? '');
|
||||
const [password, setPassword] = useState('');
|
||||
// New users: email them a set-password link by default rather than typing a
|
||||
// password here. Toggle off to set one manually.
|
||||
const [sendSetupEmail, setSendSetupEmail] = useState(true);
|
||||
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
||||
// New users: optional sign-in username (they can also sign in with their
|
||||
// email). Lowercased on the way out; the API validates shape + uniqueness.
|
||||
const [username, setUsername] = useState('');
|
||||
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
|
||||
user?.phone ? { e164: user.phone, country: 'US' } : null,
|
||||
);
|
||||
@@ -134,6 +143,12 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
fullName: fullName || displayName,
|
||||
displayName,
|
||||
email: emailChanged ? email.trim() : undefined,
|
||||
// Send the signing override only when it changed. Empty string is
|
||||
// the explicit "clear it" sentinel the API understands.
|
||||
signingEmail:
|
||||
signingEmail.trim().toLowerCase() !== originalSigningEmail.toLowerCase()
|
||||
? signingEmail.trim().toLowerCase()
|
||||
: undefined,
|
||||
phone: phoneE164,
|
||||
roleId,
|
||||
isActive,
|
||||
@@ -153,6 +168,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
password: sendSetupEmail ? undefined : password,
|
||||
sendSetupEmail,
|
||||
displayName,
|
||||
username: username.trim() ? username.trim().toLowerCase() : undefined,
|
||||
phone: phoneE164 ?? undefined,
|
||||
roleId,
|
||||
residentialAccess,
|
||||
@@ -236,6 +252,26 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-username">Username (optional)</Label>
|
||||
<Input
|
||||
id="user-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="e.g. abbie"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lets them sign in with a short username instead of their email. 2–30 lowercase
|
||||
letters, digits, dot, underscore, or hyphen. Leave blank to sign in by email
|
||||
only.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-email">Email</Label>
|
||||
<Input
|
||||
@@ -259,6 +295,28 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-signing-email">Signing email (optional)</Label>
|
||||
<Input
|
||||
id="user-signing-email"
|
||||
type="email"
|
||||
value={signingEmail}
|
||||
onChange={(e) => setSigningEmail(e.target.value)}
|
||||
placeholder="e.g. sales@portnimara.com"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The address used to represent this user in document signing (signer slot,
|
||||
signing notifications, “awaiting my signature” tab). Lets them sign
|
||||
on behalf of a shared mailbox while keeping their own sign-in email. Leave blank
|
||||
to sign as their login email.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEdit && (
|
||||
<>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
|
||||
@@ -27,9 +27,17 @@ interface ClientCardProps {
|
||||
portSlug: string;
|
||||
onEdit: (client: ClientRow) => void;
|
||||
onArchive: (client: ClientRow) => void;
|
||||
/** Hide the Archive action for users who can't archive (clients:edit). */
|
||||
canArchive?: boolean;
|
||||
}
|
||||
|
||||
export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardProps) {
|
||||
export function ClientCard({
|
||||
client,
|
||||
portSlug,
|
||||
onEdit,
|
||||
onArchive,
|
||||
canArchive = true,
|
||||
}: ClientCardProps) {
|
||||
// Card display: prefer email, fall back to phone.
|
||||
const primaryContactValue = client.primaryEmail ?? client.primaryPhone ?? null;
|
||||
const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||
@@ -96,10 +104,12 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(client)}>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
{canArchive && (
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(client)}>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
|
||||
@@ -87,12 +87,15 @@ interface GetColumnsOptions {
|
||||
portSlug: string;
|
||||
onEdit: (client: ClientRow) => void;
|
||||
onArchive: (client: ClientRow) => void;
|
||||
/** Hide the row Archive action for users who can't archive (clients:edit). */
|
||||
canArchive?: boolean;
|
||||
}
|
||||
|
||||
export function getClientColumns({
|
||||
portSlug,
|
||||
onEdit,
|
||||
onArchive,
|
||||
canArchive = true,
|
||||
}: GetColumnsOptions): ColumnDef<ClientRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
@@ -318,10 +321,15 @@ export function getClientColumns({
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
{canArchive && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onArchive(row.original)}
|
||||
>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
|
||||
@@ -146,23 +146,28 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
>
|
||||
<Bell className="size-4" aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setArchiveOpen(true)}
|
||||
aria-label={isArchived ? 'Restore client' : 'Archive client'}
|
||||
title={isArchived ? 'Restore client' : 'Archive client'}
|
||||
className={cn(
|
||||
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-foreground/5',
|
||||
isArchived ? 'hover:text-emerald-600' : 'hover:text-destructive',
|
||||
)}
|
||||
>
|
||||
{isArchived ? (
|
||||
<RotateCcw className="size-4" aria-hidden />
|
||||
) : (
|
||||
<Archive className="size-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
{/* Archive + Restore are reversible -> gate on clients:edit, the
|
||||
same action the archive routes now require. Hidden for view-only
|
||||
users so they never click a button that 403s. */}
|
||||
<PermissionGate resource="clients" action="edit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setArchiveOpen(true)}
|
||||
aria-label={isArchived ? 'Restore client' : 'Archive client'}
|
||||
title={isArchived ? 'Restore client' : 'Archive client'}
|
||||
className={cn(
|
||||
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-foreground/5',
|
||||
isArchived ? 'hover:text-emerald-600' : 'hover:text-destructive',
|
||||
)}
|
||||
>
|
||||
{isArchived ? (
|
||||
<RotateCcw className="size-4" aria-hidden />
|
||||
) : (
|
||||
<Archive className="size-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</div>
|
||||
</DetailHeaderStrip>
|
||||
|
||||
@@ -101,7 +101,9 @@ export function ClientList() {
|
||||
|
||||
const { can } = usePermissions();
|
||||
const canHardDelete = can('admin', 'permanently_delete_clients');
|
||||
const canBulkArchive = can('clients', 'delete');
|
||||
// Archiving is reversible -> edit-level (see CLIENT_ARCHIVE_ACTION), not delete.
|
||||
const canArchive = can('clients', 'edit');
|
||||
const canBulkArchive = canArchive;
|
||||
const canBulkTag = can('clients', 'edit');
|
||||
|
||||
const {
|
||||
@@ -167,6 +169,7 @@ export function ClientList() {
|
||||
portSlug,
|
||||
onEdit: (client) => setEditClient(client),
|
||||
onArchive: (client) => setArchiveClient(client),
|
||||
canArchive,
|
||||
});
|
||||
|
||||
// Per-user column visibility, persisted into user_profiles.preferences
|
||||
@@ -296,6 +299,7 @@ export function ClientList() {
|
||||
portSlug={portSlug}
|
||||
onEdit={setEditClient}
|
||||
onArchive={setArchiveClient}
|
||||
canArchive={canArchive}
|
||||
/>
|
||||
)}
|
||||
emptyState={
|
||||
|
||||
@@ -63,7 +63,7 @@ export function GdprExportButton({
|
||||
const [emailToClient, setEmailToClient] = useState(false);
|
||||
const [emailOverride, setEmailOverride] = useState('');
|
||||
|
||||
const allowed = isSuperAdmin || can('admin', 'manage_settings');
|
||||
const allowed = isSuperAdmin || can('clients', 'gdpr_export');
|
||||
|
||||
const queryKey = ['gdpr-exports', clientId];
|
||||
const { data, isLoading } = useQuery<ListResp>({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
||||
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
|
||||
import { ReminderList } from '@/components/reminders/reminder-list';
|
||||
import { useAlertCount } from '@/components/alerts/use-alerts';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
|
||||
/**
|
||||
* Merged "Inbox" surface - replaces the previously-separate /alerts and
|
||||
@@ -29,6 +30,11 @@ export function InboxPageShell() {
|
||||
const [alertsOpen, setAlertsOpen] = useState(true);
|
||||
const [remindersOpen, setRemindersOpen] = useState(true);
|
||||
const { data: alertCount } = useAlertCount();
|
||||
// The deal-alert feed (stale interests, overdue signers, …) is gated on
|
||||
// interests.view — operational roles see it; external residential partners
|
||||
// don't. Hide the whole section rather than letting its query 403.
|
||||
const { can } = usePermissions();
|
||||
const canSeeAlerts = can('interests', 'view');
|
||||
|
||||
// localStorage hydration on mount - canonical "read from external
|
||||
// store" pattern. setState in effect is intentional.
|
||||
@@ -95,20 +101,22 @@ export function InboxPageShell() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
|
||||
<SectionHeader
|
||||
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
|
||||
label="Alerts"
|
||||
count={activeAlerts}
|
||||
open={alertsOpen}
|
||||
onToggle={toggleAlerts}
|
||||
/>
|
||||
{alertsOpen ? (
|
||||
<div className="border-t px-4 pb-4 pt-3">
|
||||
<AlertsPageShell embedded />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
{canSeeAlerts ? (
|
||||
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
|
||||
<SectionHeader
|
||||
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
|
||||
label="Alerts"
|
||||
count={activeAlerts}
|
||||
open={alertsOpen}
|
||||
onToggle={toggleAlerts}
|
||||
/>
|
||||
{alertsOpen ? (
|
||||
<div className="border-t px-4 pb-4 pt-3">
|
||||
<AlertsPageShell embedded />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,8 +27,11 @@ export interface OnboardingStatusPayload {
|
||||
* and the admin checklist summary. Cached for 60s so all three surfaces
|
||||
* share a single fetch on first paint.
|
||||
*
|
||||
* Pass `enabled=false` to skip the network call (e.g. when the current
|
||||
* user isn't a super_admin and the surface won't render anyway).
|
||||
* Defaults to OFF: the endpoint is admin-only (admin.manage_settings), so
|
||||
* callers must opt in with `enabled: true` once they've confirmed the user is
|
||||
* a super_admin. This prevents a transient 403 (e.g. a stale `isSuperAdmin`
|
||||
* during permission hydration) from firing the privileged request for
|
||||
* non-admins.
|
||||
*/
|
||||
export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
|
||||
return useQuery<OnboardingStatusPayload>({
|
||||
@@ -38,7 +41,7 @@ export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
|
||||
(r) => r.data,
|
||||
),
|
||||
staleTime: 60_000,
|
||||
enabled: opts.enabled ?? true,
|
||||
enabled: opts.enabled === true,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,8 +9,25 @@ import {
|
||||
handleDocumentExpired,
|
||||
handleDocumentRejected,
|
||||
} from '@/lib/services/documents.service';
|
||||
import { getPortDocumensoConfig, type DocumensoApiVersion } from '@/lib/services/port-config';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* Whether a document's `documensoId` can be fetched under the port's current
|
||||
* Documenso API version. A v2 envelope id is the public `envelope_xxx` string;
|
||||
* a legacy v1 id is a bare numeric (e.g. "46"). The v2 `/api/v2/envelope/{id}`
|
||||
* endpoint rejects numerics with a 400 "Invalid envelope ID", so a port that
|
||||
* has cut over to v2 must SKIP its old v1 documents in the poll instead of
|
||||
* erroring on every cycle. Ports still on the v1 API keep polling numeric ids.
|
||||
*/
|
||||
export function isPollableDocumensoId(
|
||||
documensoId: string,
|
||||
apiVersion: DocumensoApiVersion,
|
||||
): boolean {
|
||||
if (apiVersion === 'v2' && !documensoId.startsWith('envelope_')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function processDocumensoPoll(): Promise<void> {
|
||||
// Find all documents that are in-progress signing and have a documensoId
|
||||
const pendingDocs = await db.query.documents.findMany({
|
||||
@@ -24,9 +41,25 @@ export async function processDocumensoPoll(): Promise<void> {
|
||||
|
||||
logger.info({ count: pendingDocs.length }, 'Polling Documenso for document statuses');
|
||||
|
||||
// Per-port API version, resolved once per port (cheap memo for the run).
|
||||
const apiVersionByPort = new Map<string, DocumensoApiVersion>();
|
||||
let skippedLegacy = 0;
|
||||
|
||||
for (const doc of pendingDocs) {
|
||||
if (!doc.documensoId) continue;
|
||||
|
||||
let apiVersion = apiVersionByPort.get(doc.portId);
|
||||
if (!apiVersion) {
|
||||
apiVersion = (await getPortDocumensoConfig(doc.portId)).apiVersion;
|
||||
apiVersionByPort.set(doc.portId, apiVersion);
|
||||
}
|
||||
// Skip legacy v1 documents the port's v2 API can't resolve — otherwise each
|
||||
// poll cycle 400s ("Invalid envelope ID") on abandoned pre-cutover docs.
|
||||
if (!isPollableDocumensoId(doc.documensoId, apiVersion)) {
|
||||
skippedLegacy++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Pass the doc's portId so the client uses per-port credentials
|
||||
// (admin-set Documenso URL/key/version), not the global env fallback.
|
||||
@@ -112,4 +145,11 @@ export async function processDocumensoPoll(): Promise<void> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedLegacy > 0) {
|
||||
logger.info(
|
||||
{ skippedLegacy },
|
||||
'Documenso poll: skipped legacy v1 documents not resolvable on the v2 API',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface AuthContext {
|
||||
user: {
|
||||
email: string;
|
||||
name: string;
|
||||
/** Optional signing-identity override (user_profiles.signing_email). The
|
||||
* address representing this user in signing contexts; null when they sign
|
||||
* as their login email. Never a login credential. */
|
||||
signingEmail: string | null;
|
||||
};
|
||||
/** Client IP extracted from X-Forwarded-For header. */
|
||||
ipAddress: string;
|
||||
@@ -272,6 +276,7 @@ export function withAuth<TParams extends RouteParams = Record<string, string>>(
|
||||
user: {
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
signingEmail: profile.signingEmail ?? null,
|
||||
},
|
||||
ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown',
|
||||
userAgent: req.headers.get('user-agent') ?? 'unknown',
|
||||
|
||||
@@ -21,7 +21,7 @@ export type PermissionAction<R extends PermissionResource> = keyof RolePermissio
|
||||
* (audit finding L23).
|
||||
*/
|
||||
export const PERMISSION_CATALOG = {
|
||||
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export'],
|
||||
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export', 'gdpr_export'],
|
||||
interests: [
|
||||
'view',
|
||||
'create',
|
||||
@@ -75,6 +75,20 @@ export const PERMISSION_CATALOG = {
|
||||
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Permission action that gates ARCHIVING a client. Archiving is reversible
|
||||
* (sets `archivedAt`; restorable via the Restore flow), so it is an edit-level
|
||||
* action — deliberately NOT the destructive `clients:delete` (which historically
|
||||
* only super_admin held, locking every Sales/Director user out of archiving),
|
||||
* and NOT `admin:permanently_delete_clients` (irreversible hard delete).
|
||||
*
|
||||
* Single source of truth: every client-archive route (single DELETE, archive
|
||||
* with-decisions, archive-dossier, bulk-archive-preflight, bulk POST) gates on
|
||||
* this, and the UI affordances gate on `clients:edit` to match. See the
|
||||
* permission-matrix archive-policy test.
|
||||
*/
|
||||
export const CLIENT_ARCHIVE_ACTION = 'edit' as const;
|
||||
|
||||
/** Every valid resource key, in catalog order. */
|
||||
export const PERMISSION_RESOURCES = Object.keys(PERMISSION_CATALOG) as PermissionResource[];
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- New toggleable permission: clients.gdpr_export (trigger + download a client's
|
||||
-- GDPR data export). Previously the export routes were gated by
|
||||
-- admin.manage_settings, which sales roles lack. This grants it to the
|
||||
-- sales-capable system roles by default and makes it an explicit (off) toggle
|
||||
-- everywhere else, so admins can withhold it per-user (which hides the button).
|
||||
--
|
||||
-- Existing role rows store permissions as jsonb, so editing the seed/role maps
|
||||
-- alone won't reach them — this backfills the key. Idempotent.
|
||||
|
||||
-- Sales-capable system roles get it ON by default.
|
||||
UPDATE roles
|
||||
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'true'::jsonb, true),
|
||||
updated_at = now()
|
||||
WHERE name IN ('super_admin', 'director', 'sales_manager', 'sales_agent')
|
||||
AND permissions ? 'clients';
|
||||
|
||||
-- Every other role that has a clients block but not the key yet defaults to OFF,
|
||||
-- so the permission surfaces as an explicit toggle in the matrix.
|
||||
UPDATE roles
|
||||
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'false'::jsonb, true),
|
||||
updated_at = now()
|
||||
WHERE permissions ? 'clients'
|
||||
AND NOT (permissions -> 'clients' ? 'gdpr_export');
|
||||
@@ -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;
|
||||
13
src/lib/db/migrations/0100_user_profiles_signing_email.sql
Normal file
13
src/lib/db/migrations/0100_user_profiles_signing_email.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Per-user signing-identity override.
|
||||
--
|
||||
-- A nullable address used to represent the user in SIGNING contexts only:
|
||||
-- the EOI developer/approver signer slot (resolveCrmUser), the in-CRM
|
||||
-- "your turn to sign" notification, and the "awaiting my signature" hub tab.
|
||||
-- NEVER a login credential — better-auth identity stays single-email via
|
||||
-- user.email / account.account_id. NULL → user signs as their own login email.
|
||||
--
|
||||
-- Additive + backward-compatible: every existing row defaults to NULL and is
|
||||
-- byte-for-byte unaffected. No uniqueness constraint — a shared role mailbox
|
||||
-- (e.g. sales@) may legitimately be referenced by one CRM user.
|
||||
|
||||
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS signing_email 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.
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,9 @@ export type RolePermissions = {
|
||||
delete: boolean;
|
||||
merge: boolean;
|
||||
export: boolean;
|
||||
/** Trigger + download a GDPR data export for a client. Toggleable so it
|
||||
* can be hidden from a user (e.g. a sales rep) when withheld. */
|
||||
gdpr_export: boolean;
|
||||
};
|
||||
interests: {
|
||||
view: boolean;
|
||||
@@ -300,12 +303,23 @@ export const userProfiles = pgTable(
|
||||
displayName: text('display_name').notNull(),
|
||||
/**
|
||||
* Optional sign-in alias. Lowercase a-z0-9 plus dot/underscore/hyphen,
|
||||
* 3–30 chars (shape pinned by `chk_user_profiles_username_shape`).
|
||||
* Case-insensitive uniqueness is enforced by a partial unique index on
|
||||
* LOWER(username); NULL allows the column to coexist with users who
|
||||
* still sign in by email. See migration 0054.
|
||||
* 2–30 chars (shape enforced in-app by `USERNAME_REGEX` in
|
||||
* `@/lib/validators/username`). Case-insensitive uniqueness is enforced
|
||||
* by a partial unique index on LOWER(username); NULL allows the column to
|
||||
* coexist with users who still sign in by email. See migration 0054.
|
||||
*/
|
||||
username: text('username'),
|
||||
/**
|
||||
* Optional signing-identity override. When set, this is the address the
|
||||
* system uses to represent the user in SIGNING contexts only — the EOI
|
||||
* developer/approver signer slot (`resolveCrmUser`), the in-CRM "your turn
|
||||
* to sign" notification target, and the "awaiting my signature" hub tab.
|
||||
* It is NEVER a login credential (better-auth stays single-email via
|
||||
* `user.email` / `account.account_id`). NULL → the user signs as their
|
||||
* own login email. Lets a person (e.g. login `abbie@`) sign on behalf of a
|
||||
* shared role mailbox (e.g. `sales@`). See migration 0100.
|
||||
*/
|
||||
signingEmail: text('signing_email'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
/** FK into the polymorphic `files` table - the avatar is stored
|
||||
* via getStorageBackend() so an S3↔filesystem swap carries it
|
||||
|
||||
@@ -12,7 +12,15 @@
|
||||
import type { RolePermissions } from './schema/users';
|
||||
|
||||
export const ALL_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
||||
clients: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
merge: true,
|
||||
export: true,
|
||||
gdpr_export: true,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -104,7 +112,15 @@ export const ALL_PERMISSIONS: RolePermissions = {
|
||||
// reference the sales map directly.
|
||||
|
||||
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true },
|
||||
clients: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
merge: true,
|
||||
export: true,
|
||||
gdpr_export: true,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -196,7 +212,15 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
export const DIRECTOR_PERMISSIONS: RolePermissions = SALES_MANAGER_PERMISSIONS;
|
||||
|
||||
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true },
|
||||
clients: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
merge: false,
|
||||
export: true,
|
||||
gdpr_export: true,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -283,7 +307,15 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
};
|
||||
|
||||
export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false },
|
||||
clients: {
|
||||
view: true,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
merge: false,
|
||||
export: false,
|
||||
gdpr_export: false,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: false,
|
||||
@@ -379,7 +411,15 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
// inquiries on the marina's behalf. Sees only the residential pages and
|
||||
// nothing else; can't see marina clients, yachts, berths, EOIs, etc.
|
||||
export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
||||
clients: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
merge: false,
|
||||
export: false,
|
||||
gdpr_export: false,
|
||||
},
|
||||
interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
|
||||
@@ -128,6 +128,10 @@ export async function sendEmail(
|
||||
// the safety net.
|
||||
cc?: string | string[],
|
||||
bcc?: string | string[],
|
||||
// Optional per-message Reply-To. Overrides the port's `email_reply_to`
|
||||
// setting (`cfg.replyTo`) when provided — used so client inquiry
|
||||
// confirmations reply to the public sales@/hello@ inbox, not the noreply From.
|
||||
replyTo?: string,
|
||||
): Promise<nodemailer.SentMessageInfo> {
|
||||
const cfg = portId ? await getPortEmailConfig(portId) : null;
|
||||
const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter();
|
||||
@@ -150,13 +154,14 @@ export async function sendEmail(
|
||||
`Port Nimara CRM <noreply@${env.SMTP_HOST}>`;
|
||||
|
||||
const resolvedAttachments = await resolveAttachments(attachments, portId);
|
||||
const effectiveReplyTo = replyTo ?? cfg?.replyTo ?? undefined;
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: fromHeader,
|
||||
to: effectiveTo,
|
||||
subject: effectiveSubject,
|
||||
html,
|
||||
...(cfg?.replyTo ? { replyTo: cfg.replyTo } : {}),
|
||||
...(effectiveReplyTo ? { replyTo: effectiveReplyTo } : {}),
|
||||
...(text ? { text } : {}),
|
||||
...(effectiveCc ? { cc: effectiveCc } : {}),
|
||||
...(effectiveBcc ? { bcc: effectiveBcc } : {}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,18 +27,13 @@ function ClientConfirmationBody({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
Thank you for getting in touch
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Thank you for contacting {portName}. We have received your message and a member of our team
|
||||
will be in touch with you shortly.
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
Dear {firstName},
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
Thank you for reaching out to {portName}. We have received your message and a member of our
|
||||
team will be in touch with you shortly.
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
If anything else comes to mind in the meantime, please write to us at{' '}
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
If you have any questions in the meantime, please feel free to reach out to us at{' '}
|
||||
<Link
|
||||
href={safeUrl(`mailto:${contactEmail}`)}
|
||||
style={{ color: accent, textDecoration: 'underline' }}
|
||||
@@ -47,10 +42,10 @@ function ClientConfirmationBody({
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
With warm regards,
|
||||
<Text style={{ fontSize: '16px' }}>
|
||||
Best regards,
|
||||
<br />
|
||||
<strong>The {portName} Team</strong>
|
||||
The {portName} Team
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
@@ -60,10 +55,10 @@ export async function contactFormClientConfirmation(
|
||||
data: ContactFormClientConfirmationData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const portName = data.portName ?? 'our team';
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `Thank you for contacting ${portName}`;
|
||||
: `${portName} — Thank You for Contacting Us`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const body = await render(
|
||||
<ClientConfirmationBody
|
||||
@@ -74,8 +69,19 @@ export async function contactFormClientConfirmation(
|
||||
/>,
|
||||
{ pretty: false },
|
||||
);
|
||||
const text = [
|
||||
`Dear ${data.firstName},`,
|
||||
'',
|
||||
`Thank you for contacting ${portName}. We have received your message and a member of our team will be in touch with you shortly.`,
|
||||
'',
|
||||
`If you have any questions in the meantime, please feel free to reach out to us at ${data.contactEmail}.`,
|
||||
'',
|
||||
'Best regards,',
|
||||
`The ${portName} Team`,
|
||||
].join('\n');
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ function ClientConfirmationBody({
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Thank you for your interest in {berthText}. We've noted your enquiry, and a member of
|
||||
our team will be in touch shortly through your preferred channel with the details
|
||||
you've requested.
|
||||
Thank you for expressing interest in {berthText}. Our team has registered your interest, and
|
||||
we will reach out to you very shortly by your preferred method of contact with more
|
||||
information.
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Should anything come to mind in the meantime, please don't hesitate to write to us at{' '}
|
||||
If you have any questions, please feel free to reach out to us at{' '}
|
||||
<Link
|
||||
href={safeUrl(`mailto:${contactEmail}`)}
|
||||
style={{ color: accent, textDecoration: 'underline' }}
|
||||
@@ -47,7 +47,7 @@ function ClientConfirmationBody({
|
||||
.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px' }}>
|
||||
With warm regards,
|
||||
Best regards,
|
||||
<br />
|
||||
The {portName} Sales Team
|
||||
</Text>
|
||||
@@ -61,12 +61,10 @@ export async function inquiryClientConfirmation(
|
||||
) {
|
||||
const { firstName, mooringNumber, contactEmail } = data;
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`;
|
||||
const berthText = mooringNumber ? `Berth ${mooringNumber}` : 'a Berth';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: mooringNumber
|
||||
? `Thank you for your interest in Berth ${mooringNumber}`
|
||||
: `Thank you for your interest in ${portName}`;
|
||||
: `${portName} — Thank You for Your Interest`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(
|
||||
@@ -83,11 +81,11 @@ export async function inquiryClientConfirmation(
|
||||
const text = [
|
||||
`Dear ${firstName},`,
|
||||
'',
|
||||
`Thank you for your interest in ${berthText}. We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel with the details you've requested.`,
|
||||
`Thank you for expressing interest in ${berthText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
|
||||
'',
|
||||
`Should anything come to mind in the meantime, please don't hesitate to write to us at ${contactEmail}.`,
|
||||
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
|
||||
'',
|
||||
'With warm regards,',
|
||||
'Best regards,',
|
||||
`The ${portName} Sales Team`,
|
||||
].join('\n');
|
||||
|
||||
|
||||
@@ -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,35 +11,46 @@ interface RenderOpts {
|
||||
export interface ResidentialClientConfirmationData {
|
||||
firstName: string;
|
||||
contactEmail: string;
|
||||
residenceTypes?: string[];
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable phrase for the residence types a lead selected, e.g.
|
||||
* "the Two Bedroom Marina Villa and the Four Bedroom Oceanfront Villa".
|
||||
* Mirrors the website's phrasing, including its generic fallback.
|
||||
*/
|
||||
function residencePhrase(portName: string, types: string[] | undefined): string {
|
||||
const list = (types ?? []).filter(Boolean);
|
||||
if (list.length === 0) return `a ${portName} Residence`;
|
||||
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 (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
Welcome to {portName}
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Thank you for expressing interest in {residencePhraseText}. Our team has registered your
|
||||
interest, and we will reach out to you very shortly by your preferred method of contact with
|
||||
more information.
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
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
|
||||
received your enquiry, and a member of the team will be in touch shortly with the details
|
||||
you've requested.
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
Should anything come to mind in the meantime, please don't hesitate to write to us at{' '}
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
If you have any questions, please feel free to reach out to us at{' '}
|
||||
<Link
|
||||
href={safeUrl(`mailto:${contactEmail}`)}
|
||||
style={{ color: accent, textDecoration: 'underline' }}
|
||||
@@ -48,10 +59,10 @@ function ClientConfirmationBody({
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
With warm regards,
|
||||
<Text style={{ fontSize: '16px' }}>
|
||||
Best regards,
|
||||
<br />
|
||||
<strong>The {portName} Residential Team</strong>
|
||||
The {portName} Residences Team
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
@@ -61,23 +72,36 @@ export async function residentialClientConfirmation(
|
||||
data: ResidentialClientConfirmationData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const portName = data.portName ?? 'our team';
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `Thank you for your interest in ${portName} Residences`;
|
||||
: `${portName} — Thank You for Your Interest`;
|
||||
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 expressing interest in ${residencePhraseText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
|
||||
'',
|
||||
`If you have any questions, please feel free to reach out to us at ${data.contactEmail}.`,
|
||||
'',
|
||||
'Best regards,',
|
||||
`The ${portName} Residences Team`,
|
||||
].join('\n');
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,94 +109,74 @@ export interface ResidentialSalesAlertData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
residenceTypes?: string[];
|
||||
placeOfResidence?: string;
|
||||
preferredContactMethod?: 'email' | 'phone';
|
||||
notes?: string;
|
||||
preferences?: string;
|
||||
/**
|
||||
* Accepted for backwards-compat with the legacy `/api/public/residential-inquiries`
|
||||
* route, but intentionally NOT rendered: residential alerts go to external
|
||||
* recipients and must never mention the CRM.
|
||||
*/
|
||||
crmDeepLink?: string;
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
function SalesAlertBody({
|
||||
portName,
|
||||
data,
|
||||
accent,
|
||||
}: {
|
||||
portName: string;
|
||||
data: ResidentialSalesAlertData;
|
||||
accent: string;
|
||||
}) {
|
||||
const labelCell = { color: '#666', width: '140px' } as const;
|
||||
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 }: { portName: string; data: ResidentialSalesAlertData }) {
|
||||
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>
|
||||
{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>
|
||||
) : null}
|
||||
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
|
||||
<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>
|
||||
<Text style={{ fontSize: '16px' }}>- {portName} Residences</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -181,16 +185,36 @@ export async function residentialSalesAlert(
|
||||
data: ResidentialSalesAlertData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const portName = data.portName ?? 'our team';
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `New residential enquiry - ${data.fullName}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
|
||||
const body = await render(<SalesAlertBody portName={portName} data={data} />, {
|
||||
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}`] : []),
|
||||
'',
|
||||
`- ${portName} Residences`,
|
||||
].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.
|
||||
*
|
||||
|
||||
@@ -149,7 +149,11 @@ function isSignerEntry(v: unknown): v is { name: string; email: string } {
|
||||
}
|
||||
|
||||
/** Look up `{name, email}` for a CRM user id by joining `userProfiles`
|
||||
* (display name) + `user` (auth email). Returns nulls on miss. */
|
||||
* (display name + signing override) + `user` (auth/login email). The signing
|
||||
* email is `userProfiles.signingEmail` when set, otherwise the login
|
||||
* `user.email` — so a user who logs in as `abbie@` can sign on behalf of a
|
||||
* shared role mailbox (`sales@`) without changing their login identity.
|
||||
* Returns nulls on miss. */
|
||||
async function resolveCrmUser(
|
||||
userId: string | null,
|
||||
): Promise<{ name: string; email: string } | null> {
|
||||
@@ -157,14 +161,17 @@ async function resolveCrmUser(
|
||||
const [row] = await db
|
||||
.select({
|
||||
displayName: userProfiles.displayName,
|
||||
email: user.email,
|
||||
loginEmail: user.email,
|
||||
signingEmail: userProfiles.signingEmail,
|
||||
})
|
||||
.from(user)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, user.id))
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1);
|
||||
if (!row || !row.email) return null;
|
||||
return { name: row.displayName ?? row.email, email: row.email };
|
||||
if (!row) return null;
|
||||
const email = row.signingEmail ?? row.loginEmail;
|
||||
if (!email) return null;
|
||||
return { name: row.displayName ?? email, email };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { and, desc, eq, gte, inArray, isNull, lt, lte, ne, or, sql, exists } from 'drizzle-orm';
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNull,
|
||||
lt,
|
||||
lte,
|
||||
ne,
|
||||
notInArray,
|
||||
or,
|
||||
sql,
|
||||
exists,
|
||||
} from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
@@ -45,6 +59,8 @@ import {
|
||||
import {
|
||||
sendSigningInvitation,
|
||||
sendSigningCompleted,
|
||||
sendSigningStatusNotification,
|
||||
transformSigningUrl,
|
||||
type SignerRole,
|
||||
} from '@/lib/services/document-signing-emails.service';
|
||||
import {
|
||||
@@ -76,7 +92,11 @@ const NON_SIGNATURE_TYPES = [
|
||||
|
||||
function buildHubTabFilters(
|
||||
tab: ListDocumentsInput['tab'],
|
||||
currentUserEmail: string | undefined,
|
||||
// The set of addresses the caller "owns" for signing purposes: their login
|
||||
// email plus their signing_email override (if any). A pending signer row
|
||||
// counts as the caller's when its email is in this set. Empty → no caller
|
||||
// identity, so "awaiting me / them" can't be distinguished.
|
||||
currentUserEmails: string[],
|
||||
): ReturnType<typeof and>[] {
|
||||
const filters: ReturnType<typeof and>[] = [];
|
||||
if (!tab || tab === 'all') return filters;
|
||||
@@ -97,10 +117,10 @@ function buildHubTabFilters(
|
||||
filters.push(inArray(documents.status, ['draft', 'sent', 'partially_signed']));
|
||||
break;
|
||||
case 'awaiting_them':
|
||||
// "awaiting them" = pending signers other than the current user.
|
||||
// Without a known caller email we cannot make that distinction, so
|
||||
// short-circuit to empty rather than silently widen the result set.
|
||||
if (!currentUserEmail) {
|
||||
// "awaiting them" = pending signers that are NOT the caller (none of the
|
||||
// caller's owned emails). Without a known caller identity we cannot make
|
||||
// that distinction, so short-circuit to empty rather than widen.
|
||||
if (currentUserEmails.length === 0) {
|
||||
filters.push(sql`1 = 0`);
|
||||
break;
|
||||
}
|
||||
@@ -114,15 +134,15 @@ function buildHubTabFilters(
|
||||
and(
|
||||
eq(documentSigners.documentId, documents.id),
|
||||
eq(documentSigners.status, 'pending'),
|
||||
ne(documentSigners.signerEmail, currentUserEmail),
|
||||
notInArray(documentSigners.signerEmail, currentUserEmails),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 'awaiting_me':
|
||||
if (!currentUserEmail) {
|
||||
// Without a current-user email there is no concept of "awaiting me"
|
||||
if (currentUserEmails.length === 0) {
|
||||
// Without a caller identity there is no concept of "awaiting me"
|
||||
filters.push(sql`1 = 0`);
|
||||
break;
|
||||
}
|
||||
@@ -135,7 +155,7 @@ function buildHubTabFilters(
|
||||
and(
|
||||
eq(documentSigners.documentId, documents.id),
|
||||
eq(documentSigners.status, 'pending'),
|
||||
eq(documentSigners.signerEmail, currentUserEmail),
|
||||
inArray(documentSigners.signerEmail, currentUserEmails),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -155,8 +175,10 @@ function buildHubTabFilters(
|
||||
}
|
||||
|
||||
export interface ListDocumentsExtra {
|
||||
/** Email of the calling user - used by hub tab filtering for "awaiting me". */
|
||||
currentUserEmail?: string;
|
||||
/** The addresses the calling user owns for signing — login email plus their
|
||||
* signing_email override (if set). Used by hub tab filtering for "awaiting
|
||||
* me / them". */
|
||||
currentUserEmails?: string[];
|
||||
}
|
||||
|
||||
export async function listDocuments(
|
||||
@@ -234,7 +256,7 @@ export async function listDocuments(
|
||||
);
|
||||
}
|
||||
|
||||
filters.push(...buildHubTabFilters(tab, extra.currentUserEmail));
|
||||
filters.push(...buildHubTabFilters(tab, extra.currentUserEmails ?? []));
|
||||
|
||||
void NON_SIGNATURE_TYPES;
|
||||
void lt;
|
||||
@@ -1062,10 +1084,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 +1283,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 +1388,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 +2031,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 ?? '',
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { USERNAME_REGEX, isReservedUsername } from '@/lib/validators/username';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
|
||||
@@ -28,6 +29,7 @@ export async function listUsers(portId: string) {
|
||||
lastName: userProfiles.lastName,
|
||||
fullName: user.name,
|
||||
email: user.email,
|
||||
signingEmail: userProfiles.signingEmail,
|
||||
phone: userProfiles.phone,
|
||||
isActive: userProfiles.isActive,
|
||||
isSuperAdmin: userProfiles.isSuperAdmin,
|
||||
@@ -50,6 +52,7 @@ export async function listUsers(portId: string) {
|
||||
lastName: userProfiles.lastName,
|
||||
fullName: user.name,
|
||||
email: user.email,
|
||||
signingEmail: userProfiles.signingEmail,
|
||||
phone: userProfiles.phone,
|
||||
isActive: userProfiles.isActive,
|
||||
isSuperAdmin: userProfiles.isSuperAdmin,
|
||||
@@ -71,6 +74,7 @@ export async function listUsers(portId: string) {
|
||||
lastName: row.lastName,
|
||||
fullName: row.fullName,
|
||||
email: row.email,
|
||||
signingEmail: row.signingEmail,
|
||||
phone: row.phone,
|
||||
isActive: row.isActive,
|
||||
isSuperAdmin: row.isSuperAdmin,
|
||||
@@ -126,6 +130,7 @@ export async function getUser(userId: string, portId: string) {
|
||||
lastName: profile.lastName,
|
||||
fullName: authUser?.name ?? null,
|
||||
email: authUser?.email ?? '',
|
||||
signingEmail: profile.signingEmail,
|
||||
phone: profile.phone,
|
||||
isActive: profile.isActive,
|
||||
isSuperAdmin: profile.isSuperAdmin,
|
||||
@@ -153,6 +158,31 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
|
||||
});
|
||||
if (!role) throw new ValidationError('Invalid role ID');
|
||||
|
||||
// Optional sign-in username. Validated up front (before the auth user is
|
||||
// minted) so an invalid/taken username can't leave an orphaned account.
|
||||
// Mirrors the self-service /api/v1/me checks: shape + reserved + unique.
|
||||
let username: string | null = null;
|
||||
if (data.username && data.username.trim()) {
|
||||
const candidate = data.username.trim().toLowerCase();
|
||||
if (!USERNAME_REGEX.test(candidate)) {
|
||||
throw new ValidationError(
|
||||
'Username must be 2–30 lowercase letters, digits, dot, underscore, or hyphen.',
|
||||
);
|
||||
}
|
||||
if (isReservedUsername(candidate)) {
|
||||
throw new ValidationError('That username is reserved. Please pick another.');
|
||||
}
|
||||
const taken = await db
|
||||
.select({ userId: userProfiles.userId })
|
||||
.from(userProfiles)
|
||||
.where(sql`LOWER(${userProfiles.username}) = ${candidate}`)
|
||||
.limit(1);
|
||||
if (taken.length > 0) {
|
||||
throw new ConflictError('That username is already taken.');
|
||||
}
|
||||
username = candidate;
|
||||
}
|
||||
|
||||
// Two onboarding modes:
|
||||
// - setup-email (default when no password is supplied): provision the
|
||||
// account with a throwaway random password the admin never sees, then
|
||||
@@ -179,6 +209,7 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
|
||||
await db.insert(userProfiles).values({
|
||||
userId: newUserId,
|
||||
displayName: data.displayName,
|
||||
username,
|
||||
firstName: data.firstName ?? null,
|
||||
lastName: data.lastName ?? null,
|
||||
phone: data.phone ?? null,
|
||||
@@ -290,6 +321,12 @@ export async function updateUser(
|
||||
if (data.lastName !== undefined) profileUpdates.lastName = data.lastName;
|
||||
if (data.phone !== undefined) profileUpdates.phone = data.phone;
|
||||
if (data.isActive !== undefined) profileUpdates.isActive = data.isActive;
|
||||
// Signing-identity override. Empty string is the "clear it" sentinel; any
|
||||
// non-empty value is lowercased (the validator already guaranteed email
|
||||
// shape). NEVER touches auth identity — this is signing-only.
|
||||
if (data.signingEmail !== undefined) {
|
||||
profileUpdates.signingEmail = data.signingEmail === '' ? null : data.signingEmail.toLowerCase();
|
||||
}
|
||||
|
||||
if (Object.keys(profileUpdates).length > 1) {
|
||||
await db.update(userProfiles).set(profileUpdates).where(eq(userProfiles.userId, userId));
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { systemSettings } from '@/lib/db/schema/system';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert';
|
||||
import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation';
|
||||
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
|
||||
import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
|
||||
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
|
||||
import { createNotification } from '@/lib/services/notifications.service';
|
||||
@@ -77,13 +77,36 @@ export async function sendWebsiteSubmissionEmails(
|
||||
const { portId, portSlug, kind, payload } = input;
|
||||
const fields = extractInquiryFields(payload);
|
||||
|
||||
const [branding, portBrand, emailCfg] = await Promise.all([
|
||||
const [branding, portRow] = await Promise.all([
|
||||
getBrandingShell(portId),
|
||||
getPortBrandingConfig(portId).catch(() => null),
|
||||
getPortEmailConfig(portId).catch(() => null),
|
||||
db.select({ name: ports.name }).from(ports).where(eq(ports.id, portId)).limit(1),
|
||||
]);
|
||||
const portName = portBrand?.appName ?? 'Port Nimara';
|
||||
const contactEmail = emailCfg?.fromAddress ?? 'sales@portnimara.com';
|
||||
// Client-facing copy uses the PUBLIC port name ("Port Nimara"), never the CRM
|
||||
// appName ("Port Nimara CRM") which is reserved for internal/staff surfaces.
|
||||
const portName = portRow[0]?.name ?? 'Port Nimara';
|
||||
|
||||
// Public reply-to shown to clients in confirmation emails ("reach out to us
|
||||
// at ..."). Admin-configurable per category via system_settings; contact-form
|
||||
// enquiries default to the hello@ general inbox, berth + residence to sales@.
|
||||
// Never the noreply From address.
|
||||
const contactEmailKey =
|
||||
kind === 'contact_form' ? 'contact_form_contact_email' : 'inquiry_contact_email';
|
||||
const contactEmailDefault =
|
||||
kind === 'contact_form' ? 'hello@portnimara.com' : 'sales@portnimara.com';
|
||||
const [contactRow] = await db
|
||||
.select({ value: systemSettings.value })
|
||||
.from(systemSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(systemSettings.key, contactEmailKey),
|
||||
or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const contactEmail =
|
||||
typeof contactRow?.value === 'string' && contactRow.value.trim()
|
||||
? contactRow.value.trim()
|
||||
: contactEmailDefault;
|
||||
// No interest/client row exists for a raw submission, so link to the
|
||||
// dashboard rather than a (nonexistent) entity detail page.
|
||||
const crmUrl = `${process.env.APP_URL ?? ''}/${portSlug}`;
|
||||
@@ -116,6 +139,10 @@ export async function sendWebsiteSubmissionEmails(
|
||||
undefined,
|
||||
confirmation.text,
|
||||
portId,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
contactEmail,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,7 +178,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 +192,18 @@ 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,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
contactEmail,
|
||||
);
|
||||
}
|
||||
|
||||
const recipients = await resolveRecipients(portId, 'residential_notification_recipients');
|
||||
@@ -170,9 +213,10 @@ 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,
|
||||
portName,
|
||||
},
|
||||
{ branding },
|
||||
@@ -183,7 +227,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;
|
||||
}
|
||||
@@ -203,10 +247,25 @@ 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,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
contactEmail,
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ export const createUserSchema = z
|
||||
* password. When false, `password` must be supplied inline. */
|
||||
sendSetupEmail: z.boolean().optional(),
|
||||
displayName: z.string().min(1).max(200),
|
||||
/** Optional sign-in username. Shape/uniqueness/reserved checks run in the
|
||||
* service (mirrors the self-service /api/v1/me path). Omit for email-only. */
|
||||
username: z.string().optional(),
|
||||
firstName: z.string().min(1).max(200).nullable().optional(),
|
||||
lastName: z.string().min(1).max(200).nullable().optional(),
|
||||
phone: z.string().optional(),
|
||||
@@ -38,6 +41,12 @@ export const updateUserSchema = z.object({
|
||||
* sign-in email" notification to the prior address. UI sets this when
|
||||
* the admin confirms the warning dialog. */
|
||||
notifyEmailChange: z.boolean().optional(),
|
||||
/** Optional signing-identity override — the address used to represent this
|
||||
* user in signing contexts (EOI signer slot, signing notifications,
|
||||
* "awaiting my signature" hub tab). NEVER a login credential. Empty string
|
||||
* clears it; a non-empty value must be a valid email. The service lowercases
|
||||
* the value and maps '' → null. */
|
||||
signingEmail: z.union([z.literal(''), z.string().email()]).optional(),
|
||||
phone: z.string().nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
roleId: z.string().uuid().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"]');
|
||||
|
||||
|
||||
@@ -302,7 +302,15 @@ import type { RolePermissions } from '@/lib/db/schema/users';
|
||||
/** Full permissions - every action allowed. */
|
||||
export function makeFullPermissions(): RolePermissions {
|
||||
return {
|
||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
||||
clients: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
merge: true,
|
||||
export: true,
|
||||
gdpr_export: true,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -392,7 +400,15 @@ export function makeFullPermissions(): RolePermissions {
|
||||
/** Read-only viewer permissions - no create/update/delete. */
|
||||
export function makeViewerPermissions(): RolePermissions {
|
||||
return {
|
||||
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false },
|
||||
clients: {
|
||||
view: true,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
merge: false,
|
||||
export: false,
|
||||
gdpr_export: false,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: false,
|
||||
@@ -482,7 +498,15 @@ export function makeViewerPermissions(): RolePermissions {
|
||||
/** Sales agent permissions - own clients/interests, no admin. */
|
||||
export function makeSalesAgentPermissions(): RolePermissions {
|
||||
return {
|
||||
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: false },
|
||||
clients: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
merge: false,
|
||||
export: false,
|
||||
gdpr_export: true,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -572,7 +596,15 @@ export function makeSalesAgentPermissions(): RolePermissions {
|
||||
/** Sales manager - can do most things, limited admin. */
|
||||
export function makeSalesManagerPermissions(): RolePermissions {
|
||||
return {
|
||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
||||
clients: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
merge: true,
|
||||
export: true,
|
||||
gdpr_export: true,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
|
||||
@@ -20,7 +20,7 @@ export function makeMockCtx(opts: MockCtxOptions): AuthContext {
|
||||
portSlug: 'test-port',
|
||||
isSuperAdmin: opts.isSuperAdmin ?? false,
|
||||
permissions: opts.permissions ?? null,
|
||||
user: { email: 'test@example.com', name: 'Test User' },
|
||||
user: { email: 'test@example.com', name: 'Test User', signingEmail: null },
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'vitest/1.0',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The documents-hub "awaiting my signature" tab matches a document's pending
|
||||
* signer against the set of emails the caller owns — their login email AND
|
||||
* their signing_email override. So a user who logs in as `abbie@` but signs as
|
||||
* `sales@` still sees the `sales@`-signer documents in their personal tab.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { documents, documentSigners } from '@/lib/db/schema/documents';
|
||||
import { listDocuments } from '@/lib/services/documents.service';
|
||||
import { makePort, makeClient } from '../helpers/factories';
|
||||
|
||||
async function seedPendingDoc(portId: string, clientId: string, signerEmail: string) {
|
||||
const [doc] = await db
|
||||
.insert(documents)
|
||||
.values({
|
||||
portId,
|
||||
clientId,
|
||||
documentType: 'eoi',
|
||||
title: 'EOI awaiting signature',
|
||||
status: 'sent',
|
||||
createdBy: 'seed',
|
||||
})
|
||||
.returning();
|
||||
await db.insert(documentSigners).values({
|
||||
documentId: doc!.id,
|
||||
signerName: 'Approver',
|
||||
signerEmail,
|
||||
signerRole: 'approver',
|
||||
signingOrder: 1,
|
||||
status: 'pending',
|
||||
});
|
||||
return doc!.id;
|
||||
}
|
||||
|
||||
const baseQuery = {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
sort: 'createdAt' as const,
|
||||
order: 'desc' as const,
|
||||
includeArchived: false,
|
||||
tab: 'awaiting_me' as const,
|
||||
};
|
||||
|
||||
describe('documents hub - awaiting_me matches owned email set', () => {
|
||||
it('includes a doc whose pending signer matches the caller signing_email (not their login)', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
const docId = await seedPendingDoc(port.id, client.id, 'sales@portnimara.com');
|
||||
|
||||
const result = await listDocuments(port.id, baseQuery, {
|
||||
currentUserEmails: ['abbie@portnimara.com', 'sales@portnimara.com'],
|
||||
});
|
||||
|
||||
const ids = (result.data as Array<{ id: string }>).map((d) => d.id);
|
||||
expect(ids).toContain(docId);
|
||||
});
|
||||
|
||||
it('excludes the doc when the caller owns neither the signer email', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
const docId = await seedPendingDoc(port.id, client.id, 'sales@portnimara.com');
|
||||
|
||||
const result = await listDocuments(port.id, baseQuery, {
|
||||
currentUserEmails: ['abbie@portnimara.com'],
|
||||
});
|
||||
|
||||
const ids = (result.data as Array<{ id: string }>).map((d) => d.id);
|
||||
expect(ids).not.toContain(docId);
|
||||
});
|
||||
});
|
||||
88
tests/integration/eoi-signer-signing-email.test.ts
Normal file
88
tests/integration/eoi-signer-signing-email.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The EOI signer slot, when linked to a CRM user, resolves that user's
|
||||
* SIGNING identity — `user_profiles.signing_email` when set, otherwise the
|
||||
* login `user.email`. This lets a person who logs in as `abbie@` sign on
|
||||
* behalf of the shared `sales@` role mailbox without changing their login.
|
||||
*/
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { account, roles, user, userProfiles, systemSettings } from '@/lib/db/schema';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { createUser } from '@/lib/services/users.service';
|
||||
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
|
||||
import { makePort, makeAuditMeta } from '../helpers/factories';
|
||||
|
||||
describe('getPortEoiSigners - signing_email override', () => {
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdUserIds) {
|
||||
await db.delete(account).where(eq(account.userId, id));
|
||||
await db.delete(userProfiles).where(eq(userProfiles.userId, id));
|
||||
await db.delete(user).where(eq(user.id, id));
|
||||
}
|
||||
});
|
||||
|
||||
async function salesRoleId(): Promise<string> {
|
||||
const r = await db.query.roles.findFirst({ where: eq(roles.name, 'sales_manager') });
|
||||
if (!r) throw new Error('sales_manager role not seeded — run pnpm db:seed');
|
||||
return r.id;
|
||||
}
|
||||
|
||||
async function makeSignerUser(loginEmail: string): Promise<string> {
|
||||
const resetSpy = vi
|
||||
.spyOn(auth.api, 'requestPasswordReset')
|
||||
.mockResolvedValue({ status: true } as never);
|
||||
try {
|
||||
const port = await makePort();
|
||||
const created = await createUser(
|
||||
port.id,
|
||||
{
|
||||
email: loginEmail,
|
||||
name: 'Abbie May',
|
||||
displayName: 'Abbie May',
|
||||
roleId: await salesRoleId(),
|
||||
sendSetupEmail: true,
|
||||
residentialAccess: false,
|
||||
},
|
||||
makeAuditMeta(),
|
||||
);
|
||||
createdUserIds.push(created.userId);
|
||||
return created.userId;
|
||||
} finally {
|
||||
resetSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
it('resolves the linked signer email from signing_email when set', async () => {
|
||||
const port = await makePort();
|
||||
const userId = await makeSignerUser(`login-${Date.now()}-a@example.test`);
|
||||
await db
|
||||
.update(userProfiles)
|
||||
.set({ signingEmail: 'sales@portnimara.com' })
|
||||
.where(eq(userProfiles.userId, userId));
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({ portId: port.id, key: 'documenso_approver_user_id', value: userId });
|
||||
|
||||
const signers = await getPortEoiSigners(port.id);
|
||||
|
||||
expect(signers.approver.email).toBe('sales@portnimara.com');
|
||||
expect(signers.approver.name).toBe('Abbie May');
|
||||
});
|
||||
|
||||
it('falls back to the login email when signing_email is null', async () => {
|
||||
const port = await makePort();
|
||||
const loginEmail = `login-${Date.now()}-b@example.test`;
|
||||
const userId = await makeSignerUser(loginEmail);
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({ portId: port.id, key: 'documenso_approver_user_id', value: userId });
|
||||
|
||||
const signers = await getPortEoiSigners(port.id);
|
||||
|
||||
expect(signers.approver.email).toBe(loginEmail);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { withPermission, deepMerge, type AuthContext } from '@/lib/api/helpers';
|
||||
import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions';
|
||||
import {
|
||||
makeViewerPermissions,
|
||||
makeSalesAgentPermissions,
|
||||
@@ -33,7 +34,7 @@ function makeCtx(overrides: Partial<AuthContext>): AuthContext {
|
||||
portSlug: 'test-port',
|
||||
isSuperAdmin: false,
|
||||
permissions: makeViewerPermissions(),
|
||||
user: { email: 'test@example.com', name: 'Test User' },
|
||||
user: { email: 'test@example.com', name: 'Test User', signingEmail: null },
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'vitest/1.0',
|
||||
...overrides,
|
||||
@@ -64,6 +65,43 @@ async function checkPermission(
|
||||
return response.status;
|
||||
}
|
||||
|
||||
// ─── client archive policy ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Archiving a client is REVERSIBLE (sets archivedAt; restorable), so it is an
|
||||
* edit-level action — NOT the destructive `clients:delete` (which only
|
||||
* super_admin holds) and NOT `admin:permanently_delete_clients` (hard delete).
|
||||
* Regression guard: prod showed Sales Managers/Directors getting 403 on archive
|
||||
* because every archive route gated on `delete`. All archive routes now gate on
|
||||
* CLIENT_ARCHIVE_ACTION; if anyone flips it back to a permission the sales roles
|
||||
* lack, these fail.
|
||||
*/
|
||||
describe('Permission Matrix - client archive policy', () => {
|
||||
it('archiving is an edit-level action, not delete', () => {
|
||||
expect(CLIENT_ARCHIVE_ACTION).toBe('edit');
|
||||
});
|
||||
|
||||
it('a Sales Manager can archive a client', async () => {
|
||||
const ctx = makeCtx({ permissions: makeSalesManagerPermissions() });
|
||||
expect(await checkPermission(ctx, 'clients', CLIENT_ARCHIVE_ACTION)).toBe(200);
|
||||
});
|
||||
|
||||
it('a Director can archive a client', async () => {
|
||||
const ctx = makeCtx({ permissions: makeDirectorPermissions() });
|
||||
expect(await checkPermission(ctx, 'clients', CLIENT_ARCHIVE_ACTION)).toBe(200);
|
||||
});
|
||||
|
||||
it('a Sales Agent can archive a client', async () => {
|
||||
const ctx = makeCtx({ permissions: makeSalesAgentPermissions() });
|
||||
expect(await checkPermission(ctx, 'clients', CLIENT_ARCHIVE_ACTION)).toBe(200);
|
||||
});
|
||||
|
||||
it('a Viewer cannot archive a client', async () => {
|
||||
const ctx = makeCtx({ permissions: makeViewerPermissions() });
|
||||
expect(await checkPermission(ctx, 'clients', CLIENT_ARCHIVE_ACTION)).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── super_admin ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Permission Matrix - super_admin', () => {
|
||||
@@ -99,6 +137,10 @@ describe('Permission Matrix - viewer', () => {
|
||||
expect(await checkPermission(ctx, 'clients', 'create')).toBe(403);
|
||||
});
|
||||
|
||||
it('cannot run a GDPR export', async () => {
|
||||
expect(await checkPermission(ctx, 'clients', 'gdpr_export')).toBe(403);
|
||||
});
|
||||
|
||||
it('cannot update clients', async () => {
|
||||
expect(await checkPermission(ctx, 'clients', 'edit')).toBe(403);
|
||||
});
|
||||
@@ -177,6 +219,10 @@ describe('Permission Matrix - sales_manager', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('can run a GDPR export (clients.gdpr_export)', async () => {
|
||||
expect(await checkPermission(ctx, 'clients', 'gdpr_export')).toBe(200);
|
||||
});
|
||||
|
||||
it('can view audit log', async () => {
|
||||
expect(await checkPermission(ctx, 'admin', 'view_audit_log')).toBe(200);
|
||||
});
|
||||
|
||||
77
tests/integration/update-user-signing-email.test.ts
Normal file
77
tests/integration/update-user-signing-email.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* updateUser persists the optional signing-identity override
|
||||
* (user_profiles.signing_email): lowercased on the way in, and cleared to NULL
|
||||
* when an empty string is supplied.
|
||||
*/
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { account, roles, user, userProfiles } from '@/lib/db/schema';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { createUser, updateUser } from '@/lib/services/users.service';
|
||||
import { makePort, makeAuditMeta } from '../helpers/factories';
|
||||
|
||||
describe('updateUser - signing_email override', () => {
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdUserIds) {
|
||||
await db.delete(account).where(eq(account.userId, id));
|
||||
await db.delete(userProfiles).where(eq(userProfiles.userId, id));
|
||||
await db.delete(user).where(eq(user.id, id));
|
||||
}
|
||||
});
|
||||
|
||||
async function makeUser(): Promise<{ userId: string; portId: string }> {
|
||||
const resetSpy = vi
|
||||
.spyOn(auth.api, 'requestPasswordReset')
|
||||
.mockResolvedValue({ status: true } as never);
|
||||
try {
|
||||
const port = await makePort();
|
||||
const role = await db.query.roles.findFirst({ where: eq(roles.name, 'sales_manager') });
|
||||
if (!role) throw new Error('sales_manager role not seeded');
|
||||
const created = await createUser(
|
||||
port.id,
|
||||
{
|
||||
email: `signemail-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@example.test`,
|
||||
name: 'Abbie May',
|
||||
displayName: 'Abbie May',
|
||||
roleId: role.id,
|
||||
sendSetupEmail: true,
|
||||
residentialAccess: false,
|
||||
},
|
||||
makeAuditMeta(),
|
||||
);
|
||||
createdUserIds.push(created.userId);
|
||||
return { userId: created.userId, portId: port.id };
|
||||
} finally {
|
||||
resetSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
async function readSigningEmail(userId: string): Promise<string | null> {
|
||||
const row = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, userId),
|
||||
});
|
||||
return row?.signingEmail ?? null;
|
||||
}
|
||||
|
||||
it('persists a signing email, lowercased', async () => {
|
||||
const { userId, portId } = await makeUser();
|
||||
|
||||
await updateUser(userId, portId, { signingEmail: 'SALES@PortNimara.com' }, makeAuditMeta());
|
||||
|
||||
expect(await readSigningEmail(userId)).toBe('sales@portnimara.com');
|
||||
});
|
||||
|
||||
it('clears the override when an empty string is supplied', async () => {
|
||||
const { userId, portId } = await makeUser();
|
||||
await updateUser(userId, portId, { signingEmail: 'sales@portnimara.com' }, makeAuditMeta());
|
||||
expect(await readSigningEmail(userId)).toBe('sales@portnimara.com');
|
||||
|
||||
await updateUser(userId, portId, { signingEmail: '' }, makeAuditMeta());
|
||||
|
||||
expect(await readSigningEmail(userId)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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:');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
21
tests/unit/documenso-poll.test.ts
Normal file
21
tests/unit/documenso-poll.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { isPollableDocumensoId } from '@/jobs/processors/documenso-poll';
|
||||
|
||||
describe('isPollableDocumensoId', () => {
|
||||
it('skips legacy v1 numeric ids when the port is on the v2 API', () => {
|
||||
// Real prod offenders: abandoned June-3 v1 EOIs whose documenso_id is "46"/"85"/"88".
|
||||
// The v2 /api/v2/envelope/{id} endpoint rejects bare numerics ("Invalid envelope ID").
|
||||
expect(isPollableDocumensoId('46', 'v2')).toBe(false);
|
||||
expect(isPollableDocumensoId('125', 'v2')).toBe(false);
|
||||
});
|
||||
|
||||
it('polls real v2 envelope ids on the v2 API', () => {
|
||||
expect(isPollableDocumensoId('envelope_ydshkombscbhfnfd', 'v2')).toBe(true);
|
||||
});
|
||||
|
||||
it('still polls numeric ids for a port that is on the v1 API', () => {
|
||||
expect(isPollableDocumensoId('46', 'v1')).toBe(true);
|
||||
expect(isPollableDocumensoId('envelope_abc', 'v1')).toBe(true);
|
||||
});
|
||||
});
|
||||
52
tests/unit/email/client-confirmations.test.ts
Normal file
52
tests/unit/email/client-confirmations.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { inquiryClientConfirmation } from '@/lib/email/templates/inquiry-client-confirmation';
|
||||
import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation';
|
||||
|
||||
// Note: assert prose that spans an interpolation on the plain-text part — the
|
||||
// React-Email HTML renderer inserts `<!-- -->` markers at value boundaries.
|
||||
|
||||
describe('inquiryClientConfirmation (berth)', () => {
|
||||
it('mirrors the website copy for a specific berth + signs off as Sales', async () => {
|
||||
const { subject, html, text } = await inquiryClientConfirmation({
|
||||
firstName: 'Jane',
|
||||
mooringNumber: 'D13',
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
expect(subject).toBe('Port Nimara — Thank You for Your Interest');
|
||||
expect(text).toContain('Thank you for expressing interest in Berth D13');
|
||||
expect(text).toContain('Our team has registered your interest');
|
||||
expect(text).toContain('reach out to us at sales@portnimara.com');
|
||||
expect(text).toContain('The Port Nimara Sales Team');
|
||||
expect(html).toContain('sales@portnimara.com');
|
||||
expect(html).not.toContain('Port Nimara CRM');
|
||||
});
|
||||
|
||||
it('uses "a Berth" when no mooring is given', async () => {
|
||||
const { text, html } = await inquiryClientConfirmation({
|
||||
firstName: 'Jane',
|
||||
mooringNumber: null,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
expect(text).toContain('Thank you for expressing interest in a Berth');
|
||||
expect(html).not.toContain('Port Nimara CRM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contactFormClientConfirmation', () => {
|
||||
it('mirrors the website copy + signs off as the Port Nimara Team', async () => {
|
||||
const { subject, html, text } = await contactFormClientConfirmation({
|
||||
firstName: 'Bob',
|
||||
contactEmail: 'hello@portnimara.com',
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
expect(subject).toBe('Port Nimara — Thank You for Contacting Us');
|
||||
expect(text).toContain('Thank you for contacting Port Nimara');
|
||||
expect(text).toContain('We have received your message');
|
||||
expect(text).toContain('reach out to us at hello@portnimara.com');
|
||||
expect(text).toContain('The Port Nimara Team');
|
||||
expect(html).not.toContain('Port Nimara CRM');
|
||||
});
|
||||
});
|
||||
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:');
|
||||
});
|
||||
});
|
||||
75
tests/unit/email/residential-inquiry.test.ts
Normal file
75
tests/unit/email/residential-inquiry.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
residentialClientConfirmation,
|
||||
residentialSalesAlert,
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
|
||||
describe('residentialClientConfirmation', () => {
|
||||
it('mirrors the website copy + reflects the chosen residence types', async () => {
|
||||
const { subject, html, text } = await residentialClientConfirmation({
|
||||
firstName: 'Mia',
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
residenceTypes: ['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa'],
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
expect(subject).toBe('Port Nimara — Thank You for Your Interest');
|
||||
expect(text).toContain(
|
||||
'Thank you for expressing interest in the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa',
|
||||
);
|
||||
expect(text).toContain('Our team has registered your interest');
|
||||
expect(text).toContain('The Port Nimara Residences Team');
|
||||
// Never leak the CRM brand name to a client.
|
||||
expect(html).not.toContain('Port Nimara CRM');
|
||||
});
|
||||
|
||||
it('falls back to the website generic phrase when no types are selected', async () => {
|
||||
const { html } = await residentialClientConfirmation({
|
||||
firstName: 'Sam',
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
expect(html).toContain('a Port Nimara Residence');
|
||||
expect(html).not.toContain('Port Nimara CRM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('residentialSalesAlert', () => {
|
||||
it('renders residence type(s) + preferred contact + comments, with NO CRM mention', 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.',
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
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(text).toContain('- Port Nimara Residences');
|
||||
// Residential internal alerts must not mention the CRM (recipient is external).
|
||||
expect(html).not.toContain('CRM');
|
||||
expect(html).not.toContain('to follow up');
|
||||
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.');
|
||||
expect(text).not.toContain('CRM');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
19
tests/unit/users-validators.test.ts
Normal file
19
tests/unit/users-validators.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { updateUserSchema } from '@/lib/validators/users';
|
||||
|
||||
describe('updateUserSchema.signingEmail', () => {
|
||||
it('accepts and preserves a valid signing email', () => {
|
||||
const parsed = updateUserSchema.parse({ signingEmail: 'sales@portnimara.com' });
|
||||
expect(parsed.signingEmail).toBe('sales@portnimara.com');
|
||||
});
|
||||
|
||||
it('allows an empty string (sentinel for "clear the override")', () => {
|
||||
const parsed = updateUserSchema.parse({ signingEmail: '' });
|
||||
expect(parsed.signingEmail).toBe('');
|
||||
});
|
||||
|
||||
it('rejects a malformed signing email', () => {
|
||||
expect(() => updateUserSchema.parse({ signingEmail: 'not-an-email' })).toThrow();
|
||||
});
|
||||
});
|
||||
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