17 Commits

Author SHA1 Message Date
ba128646e1 fix(documenso): skip legacy v1 document IDs in the v2 status poller
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m7s
Build & Push Docker Images / build-and-push (push) Successful in 9m22s
The signature-poll worker fetched every sent/partially_signed document via
GET /api/v2/envelope/{id}. Pre-cutover documents carry a bare numeric
documenso_id (e.g. "46"), which the v2 envelope endpoint rejects with 400
"Invalid envelope ID" — ~36 errors/hour on prod from 3 abandoned June-3 EOIs.

Skip documents whose documenso_id isn't an `envelope_xxx` string when the
port is on the v2 API (v1-API ports keep polling numeric ids). New v2 signing
is unaffected. Pure isPollableDocumensoId() helper, unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-28 18:48:20 +02:00
32b57354ad fix(clients): archiving is edit-level, not delete (unblocks Sales/Director)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 8m38s
Every client-archive route gated on clients:delete, which only super_admin
holds, so Sales Agent/Manager and Director users (clients.delete=false) got
403 on archive — the button was shown but never worked. Archiving is
reversible, so gate it on clients:edit instead; permanent deletion stays the
separate admin:permanently_delete_clients permission.

- New single source of truth CLIENT_ARCHIVE_ACTION='edit' (permissions.ts).
- All 5 archive routes use it: [id] DELETE, archive, archive-dossier,
  bulk-archive-preflight, bulk POST.
- UI affordances gate on clients:edit (detail-header archive/restore, bulk
  archive, per-row list + mobile-card archive) so view-only users don't see a
  button that 403s.
- permission-matrix regression test locks the policy (Sales/Director/Agent can
  archive; Viewer cannot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-28 18:16:09 +02:00
9f5810e3df feat(users): per-user signing-email override (login ≠ signing identity)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m3s
Build & Push Docker Images / build-and-push (push) Successful in 8m51s
Adds nullable user_profiles.signing_email — the address representing a user
in SIGNING contexts only (EOI developer/approver signer slot, in-CRM 'your
turn to sign' notification, 'awaiting my signature' hub tab). Never a login
credential; better-auth identity stays single-email. NULL → user signs as
their own login email.

Lets a person who logs in as e.g. abbie@ sign on behalf of a shared role
mailbox (sales@) without changing their login. Additive + backward-compatible:
every existing user (NULL override) is byte-for-byte unaffected.

- migration 0100 + drizzle column (nullable, no unique constraint)
- resolveCrmUser: signing_email ?? user.email
- listDocuments hub filter: match the caller's owned email SET (login +
  override) for awaiting_me / awaiting_them (currentUserEmail -> currentUserEmails)
- ctx.user.signingEmail surfaced from the already-loaded profile (no extra query)
- updateUser persists/lowercases the override; '' clears it (edit-only)
- admin Edit-User form: optional 'Signing email' field
- TDD: validator unit test + 3 integration tests (resolver override+fallback,
  updateUser persist/clear, hub awaiting_me matches override)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-26 15:08:44 +02:00
b2692839f1 feat(admin): set a sign-in username when creating a user
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m4s
Build & Push Docker Images / build-and-push (push) Successful in 9m11s
The New User form had no username field, so users created through it
could only sign in by email. Add an optional username to the create
flow (form + createUserSchema + createUser service), validated up front
(shape via USERNAME_REGEX, reserved-list, case-insensitive uniqueness)
before the auth account is minted. Fix the stale schema comment (2–30,
not 3–30; enforced in-app, no DB CHECK constraint exists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-25 22:57:24 +02:00
caaebd77fa fix(intake): client inquiry emails mirror website copy + never show "Port Nimara CRM"
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m55s
Build & Push Docker Images / build-and-push (push) Successful in 8m49s
Client-facing confirmation emails now:
- use the PUBLIC port name ("Port Nimara" via ports.name), never the CRM
  appName ("Port Nimara CRM") which is reserved for internal/staff surfaces
- mirror the website's wording verbatim ("Thank you for expressing
  interest…", "Best regards,") and drop the CRM-style headings
- sign off per category: berth → "Port Nimara Sales Team", contact →
  "Port Nimara Team", residential → "Port Nimara Residences Team"
- show + reply-to a public contact address, admin-configurable per category
  (inquiry_contact_email → sales@ for berth/residence,
  contact_form_contact_email → hello@ for contact form), never the noreply From

Internal alerts keep the CRM detail-line format + link (name fixed to
"Port Nimara"), EXCEPT the residential alert which drops all CRM mention
(it reaches an external recipient) and signs "- Port Nimara Residences".

sendEmail gains an optional per-message replyTo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-25 22:07:47 +02:00
866930c943 feat(intake): residence-type capture + CRM-owned inquiry emails for website cutover
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m17s
Website register-interest form now offers the 3 residence types as a
multi-select; the choice + preferred-contact flow into the CRM inquiry
payload, the inbox detail, and the residential emails.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[] = [];

View File

@@ -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,
};

View File

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

View File

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

View File

@@ -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',

View File

@@ -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. 230 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, &ldquo;awaiting my signature&rdquo; 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">

View File

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

View File

@@ -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>
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
);
}
}

View File

@@ -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',

View File

@@ -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[];

View File

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

View File

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

View File

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

View File

@@ -303,12 +303,23 @@ export const userProfiles = pgTable(
displayName: text('display_name').notNull(),
/**
* Optional sign-in alias. Lowercase a-z0-9 plus dot/underscore/hyphen,
* 330 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.
* 230 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

View File

@@ -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 } : {}),

View File

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

View File

@@ -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,
};
}

View File

@@ -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&apos;ve noted your enquiry, and a member of
our team will be in touch shortly through your preferred channel with the details
you&apos;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&apos;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');

View File

@@ -1,4 +1,4 @@
import { Button, Link, Text, render } from '@react-email/components';
import { Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
@@ -11,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&apos;ve requested.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
Should anything come to mind in the meantime, please don&apos;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,
};
}

View File

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

View File

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

View File

@@ -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 };
}
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 230 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));

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
};

View File

@@ -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);
});
});

View 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);
});
});

View File

@@ -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', () => {

View 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();
});
});

View File

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

View File

@@ -0,0 +1,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);
});
});

View 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');
});
});

View File

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

View File

@@ -0,0 +1,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');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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();
});
});

View File

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

View File

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